diff --git a/.dockerignore b/.dockerignore
index 9cc988e1..142f2573 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -25,9 +25,7 @@ student_clap/
# (vendored redis/pg binaries, assets, nfpm config, runtime launchers), the shared
# PyInstaller spec + build tooling, and build artifacts. None of it is used by the
# Linux container, so keep it out of the image.
-macos/
-linux/
-windows/
+native-build/
AudioMuse-AI.spec
scripts/standalone/
build/
@@ -67,7 +65,5 @@ model/
*.bin
*.h5
*.pb
-CLAMP3/weights_*.pth
-CLAMP3/*.npy
.cache/
*.tar.gz
diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml
index 901a3b0b..0582a59c 100644
--- a/.github/workflows/build-linux.yml
+++ b/.github/workflows/build-linux.yml
@@ -7,13 +7,13 @@
# Mirrors build-macos.yml. The package bundles EVERYTHING (Python runtime,
# onnxruntime, PyAV/ffmpeg, the ONNX models, an embedded PostgreSQL via pgserver
# and an embedded Redis), so an installed package needs no Docker and no
-# separately-installed database/broker. See linux/README.md for why Postgres and
+# separately-installed database/broker. See native-build/linux/README.md for why Postgres and
# Redis are bundled rather than declared as system dependencies (pgvector +
# unaccent/pg_trgm against an exact PG minor, plus the per-distro package-name /
# version skew, make a portable single .deb/.rpm "hard dependency" infeasible).
#
# The small native build inputs (redis-server, the unaccent/pg_trgm contrib
-# modules) are built from source in this workflow (linux/vendor/*) rather than
+# modules) are built from source in this workflow (native-build/linux/vendor/*) rather than
# committed, then baked into the bundle by the shared AudioMuse-AI.spec.
#
# The ~5 GB of models are NOT in git. This workflow assembles ./model from the
@@ -59,7 +59,7 @@ jobs:
# * x86_64 -> pgserver wheel (+ vendored unaccent/pg_trgm grafted in).
# * aarch64 -> a from-source PostgreSQL (server + the two contrib modules)
# built here and bundled wholesale; managed by
- # linux/embedded_pg.py at runtime.
+ # native-build/linux/embedded_pg.py at runtime.
strategy:
fail-fast: false
matrix:
@@ -114,8 +114,8 @@ jobs:
- name: Validate the desktop entries
run: |
set -euo pipefail
- desktop-file-validate linux/packaging/AudioMuse-AI.desktop
- desktop-file-validate linux/packaging/AudioMuse-AI-stop.desktop
+ desktop-file-validate native-build/linux/packaging/AudioMuse-AI.desktop
+ desktop-file-validate native-build/linux/packaging/AudioMuse-AI-stop.desktop
echo "Desktop entries are valid."
- name: Install Python dependencies
@@ -127,7 +127,7 @@ jobs:
pip install -r requirements/linux.txt
- name: Build vendored redis-server
- run: bash linux/vendor/build-redis.sh
+ run: bash native-build/linux/vendor/build-redis.sh
# x86_64: compile unaccent/pg_trgm against the installed pgserver's PG.
- name: Build vendored PostgreSQL contrib (x86_64, against pgserver)
@@ -135,13 +135,13 @@ jobs:
run: |
set -euo pipefail
source .venv-linux/bin/activate # pgserver must be importable
- bash linux/vendor/pg-contrib/build-pg-contrib.sh
+ bash native-build/linux/vendor/pg-contrib/build-pg-contrib.sh
# aarch64: no pgserver wheel -> build a relocatable PostgreSQL (server +
# unaccent/pg_trgm) from source and bundle the whole tree.
- name: Build vendored PostgreSQL from source (aarch64)
if: matrix.arch == 'aarch64'
- run: bash linux/vendor/postgres/build-postgres.sh
+ run: bash native-build/linux/vendor/postgres/build-postgres.sh
- name: Assemble ./model (mirrors the Dockerfile/macOS models stage)
env:
diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml
index 4afc6c04..44348857 100644
--- a/.github/workflows/build-macos.yml
+++ b/.github/workflows/build-macos.yml
@@ -6,16 +6,17 @@
# reliably universal2, so we build natively on an arm64 runner (macos-14).
#
# Reproducibility: the small, exact-version native build inputs are committed in
-# git (macos/vendor/redis/arm64/redis-server and macos/vendor/pg-contrib/arm64/*),
+# git (native-build/macos/vendor/redis/arm64/redis-server and
+# native-build/macos/vendor/pg-contrib/arm64/*),
# NOT regenerated at build time, so the bundle's embedded Redis and the Postgres
# unaccent/pg_trgm contrib extensions are byte-identical to the tested local build.
#
# The app is ad-hoc signed only (no Apple Developer account); end users clear
-# quarantine with `xattr -dr ...` after download (see macos/README.md).
+# quarantine with `xattr -dr ...` after download (see native-build/macos/README.md).
#
# The ~5 GB of models are NOT in git. This workflow assembles ./model from the
# GitHub releases the Dockerfile uses, INCLUDING the HuggingFace cache
-# (huggingface_models.tar.gz) — macos/env.py points HF_HOME at model/huggingface
+# (huggingface_models.tar.gz) — native-build/macos/env.py points HF_HOME at model/huggingface
# for the CLAP RoBERTa tokenizer, so the bundle needs it. The HF cache is then
# trimmed to just that tokenizer (bert/bart and the roberta weights are unused by
# the app, ~1.4 GB) so the release zip stays under GitHub's 2 GB asset limit.
@@ -86,20 +87,20 @@ jobs:
# These are committed in git (not regenerated here) so the bundle is
# reproducible. If a fresh checkout is missing them, fail loudly.
required=(
- macos/vendor/redis/arm64/redis-server
- macos/vendor/pg-contrib/arm64/lib/unaccent.dylib
- macos/vendor/pg-contrib/arm64/lib/pg_trgm.dylib
- macos/vendor/pg-contrib/arm64/extension/unaccent.control
- macos/vendor/pg-contrib/arm64/extension/pg_trgm.control
- macos/vendor/pg-contrib/arm64/tsearch_data/unaccent.rules
+ native-build/macos/vendor/redis/arm64/redis-server
+ native-build/macos/vendor/pg-contrib/arm64/lib/unaccent.dylib
+ native-build/macos/vendor/pg-contrib/arm64/lib/pg_trgm.dylib
+ native-build/macos/vendor/pg-contrib/arm64/extension/unaccent.control
+ native-build/macos/vendor/pg-contrib/arm64/extension/pg_trgm.control
+ native-build/macos/vendor/pg-contrib/arm64/tsearch_data/unaccent.rules
)
missing=0
for f in "${required[@]}"; do
if [ ! -s "$f" ]; then echo "::error::Missing committed vendor file: $f"; missing=1; fi
done
- [ "$missing" -eq 0 ] || { echo "::error::macos/vendor/ must be committed (see macos/vendor/pg-contrib/README.md)."; exit 1; }
- chmod +x macos/vendor/redis/arm64/redis-server
- file macos/vendor/redis/arm64/redis-server
+ [ "$missing" -eq 0 ] || { echo "::error::native-build/macos/vendor/ must be committed (see native-build/macos/vendor/pg-contrib/README.md)."; exit 1; }
+ chmod +x native-build/macos/vendor/redis/arm64/redis-server
+ file native-build/macos/vendor/redis/arm64/redis-server
- name: Assemble ./model (mirrors the Dockerfile models stage)
env:
diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml
index 983afcdb..a8cd58f1 100644
--- a/.github/workflows/build-windows.yml
+++ b/.github/workflows/build-windows.yml
@@ -81,12 +81,12 @@ jobs:
- name: Build vendored redis-server.exe
shell: cmd
- run: windows\vendor\build-redis.bat
+ run: native-build\windows\vendor\build-redis.bat
- name: Verify vendored PostgreSQL contrib is present
shell: powershell
run: |
- $dest = "windows\vendor\pg-contrib\$env:ARCH"
+ $dest = "native-build\windows\vendor\pg-contrib\$env:ARCH"
$required = @("lib\unaccent.dll", "lib\pg_trgm.dll", "extension\unaccent.control", "extension\pg_trgm.control", "extension\unaccent--1.1.sql", "extension\pg_trgm--1.3.sql", "tsearch_data\unaccent.rules")
$bad = @()
foreach ($f in $required) {
@@ -94,7 +94,7 @@ jobs:
if (-not (Test-Path $p) -or (Get-Item $p).Length -eq 0) { $bad += $f }
}
if ($bad.Count -ne 0) {
- Write-Error "Missing or empty vendored pg-contrib files: $($bad -join ', '). Regenerate with windows/vendor/pg-contrib/build-pg-contrib-cross.sh -- never ship empty placeholders (they silently disable unaccent/pg_trgm search)."
+ Write-Error "Missing or empty vendored pg-contrib files: $($bad -join ', '). Regenerate with native-build/windows/vendor/pg-contrib/build-pg-contrib-cross.sh -- never ship empty placeholders (they silently disable unaccent/pg_trgm search)."
exit 1
}
Write-Host "Vendored pg-contrib present and non-empty."
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 50120002..c3c9dde0 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -143,22 +143,22 @@ jobs:
- name: Run integration tests
run: |
# Run the MusiCNN integration test
- pytest test/test_analysis_integration.py -s -v --tb=short
+ pytest test/integration/test_analysis_integration.py -s -v --tb=short
# Run the CLAP integration test
- pytest test/test_clap_analysis_integration.py -s -v --tb=short
+ pytest test/integration/test_clap_analysis_integration.py -s -v --tb=short
- name: Run provider migration integration test
env:
AUDIOMUSE_TEST_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/audiomuse_migration_test
run: |
- pytest test/test_provider_migration_integration.py -s -v --tb=short
+ pytest test/integration/test_provider_migration_integration.py -s -v --tb=short
- name: Run app endpoint and auth integration tests
env:
AUDIOMUSE_TEST_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/audiomuse_migration_test
run: |
- pytest test/test_app_endpoints_integration.py test/test_auth_users_integration.py -m integration -s -v --tb=short
+ pytest test/integration/test_app_endpoints_integration.py test/integration/test_auth_users_integration.py -m integration -s -v --tb=short
- name: Run lyrics integration test
run: |
@@ -166,7 +166,7 @@ jobs:
# test enters RECORD mode automatically, writes the file and exits
# successfully. The next step then commits it back to main so
# subsequent runs pin against it (cosine similarity >= 0.99).
- pytest test/test_lyrics_analysis_integration.py -s -v --tb=short
+ pytest test/integration/test_lyrics_analysis_integration.py -s -v --tb=short
- name: Commit recorded lyrics expected vectors
# Push back the auto-recorded vectors on:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 34dda73d..8313cfa1 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -25,4 +25,4 @@ jobs:
- name: Run unit tests
run: |
- pytest tests/unit/ -v --tb=short
+ pytest test/unit/ -v --tb=short
diff --git a/.gitignore b/.gitignore
index 2baed74a..2708a402 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,11 +12,11 @@ venv/
.venv-windows/
env/
-# Linux native build inputs built fresh in CI (not committed; see linux/vendor/README.md)
-/linux/vendor/redis/
-/linux/vendor/pg-contrib/*/
-/linux/vendor/postgres/*/
-/windows/vendor/redis/
+# Linux native build inputs built fresh in CI (not committed; see native-build/linux/vendor/README.md)
+/native-build/linux/vendor/redis/
+/native-build/linux/vendor/pg-contrib/*/
+/native-build/linux/vendor/postgres/*/
+/native-build/windows/vendor/redis/
# Local environment variables
# IMPORTANT: Never commit your .env file with secrets!
@@ -46,16 +46,6 @@ env/
htmlcov/
nul
-# Large model files in query folder
-/query/*.pt
-/query/*.onnx
-
-
-# CLAMP3 weight files (large model files)
-/CLAMP3/weights_*.pth
-/CLAMP3/*.pth
-
-
# Downloaded ONNX models (from run_worker_macos.sh or similar)
/model/
@@ -82,4 +72,4 @@ analysis.md
.claude/settings.local.json
.claude/settings.local.json
lyrics_model_gte_vnni.tar.gz
-windows/vendor/redis/amd64/redis-server.exe
+native-build/windows/vendor/redis/amd64/redis-server.exe
diff --git a/.sonarcloud.properties b/.sonarcloud.properties
new file mode 100644
index 00000000..47ed46e1
--- /dev/null
+++ b/.sonarcloud.properties
@@ -0,0 +1,10 @@
+# Vendored third-party code (PostgreSQL contrib extensions bundled per platform)
+# is not ours to analyze.
+sonar.exclusions=native-build/*/vendor/**
+
+# Copy-paste detection exclusions:
+# - native-build/ holds three intentionally parallel per-platform trees
+# (linux/macos/windows supervisor, launcher, env, paths); their similarity
+# is by design, not accidental duplication.
+# - test/ duplication is repeated setup/fixture boilerplate across tests.
+sonar.cpd.exclusions=native-build/**,test/**
diff --git a/AudioMuse-AI.spec b/AudioMuse-AI.spec
index 1a71c328..860c9ba9 100644
--- a/AudioMuse-AI.spec
+++ b/AudioMuse-AI.spec
@@ -3,10 +3,13 @@ import glob
import importlib.util
import os
import platform
+import sys
from PyInstaller.utils.hooks import collect_data_files, collect_dynamic_libs, collect_submodules
ROOT = SPECPATH
+NATIVE = os.path.join(ROOT, "native-build")
+sys.path.insert(0, NATIVE)
_cfg_path = os.path.join(ROOT, "scripts", "standalone", "config.py")
_cfg_spec = importlib.util.spec_from_file_location("_amai_build_config", _cfg_path)
@@ -80,11 +83,11 @@ if not USE_PGSERVER:
a = Analysis(
[os.path.join(ROOT, cfg["launcher"])],
- pathex=[ROOT],
+ pathex=[ROOT, NATIVE],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
- hookspath=[os.path.join(ROOT, "macos/hooks")],
+ hookspath=[os.path.join(NATIVE, "macos", "hooks")],
hooksconfig={},
runtime_hooks=[],
excludes=excludes,
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index a5a05a7c..14e695c2 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -28,7 +28,7 @@ The following table details the most important paths in the repository, their pu
| :---- | :---- |
| 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. |
| 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|
-| tasks/mediaserver.py | In this fail the generic method to interact with the mediaservers are specialized to call the specific one |
+| 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`) |
| 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`. |
| 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. |
| 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 |
diff --git a/app.py b/app.py
index f1722c98..d316579d 100644
--- a/app.py
+++ b/app.py
@@ -10,7 +10,7 @@
# RQ imports
from rq.job import Job, JobStatus
from rq.exceptions import NoSuchJobError
-from tasks.setup_manager import SetupManager
+from tasks.setup_manager import setup_manager
# Redis client
from redis import Redis
@@ -29,7 +29,6 @@
# The Flask instance lives in `flask_app` so RQ task modules can import it
# without creating a circular import back into this file.
from flask_app import app
-setup_manager = SetupManager()
# Import helper functions
from app_helper import (
@@ -760,11 +759,6 @@ def listen_for_index_reloads():
# --- Import and Register Blueprints ---
# This is the original, working structure.
-# Import tasks modules to ensure they're available to RQ workers
-import tasks.clustering
-import tasks.analysis
-
-
from app_chat import chat_bp
from app_clustering import clustering_bp
from app_analysis import analysis_bp
diff --git a/app_clustering.py b/app_clustering.py
index 604fad94..8e4eb3ed 100644
--- a/app_clustering.py
+++ b/app_clustering.py
@@ -21,7 +21,7 @@
def clustering_task_failure_handler(job, connection, type, value, tb):
"""A failure handler for the main clustering task, executed by the worker."""
- from app import app
+ from flask_app import app
from app_helper import save_task_status, TASK_STATUS_FAILURE
with app.app_context():
task_id = getattr(job, 'id', None) or getattr(job, 'get_id', lambda: None)()
@@ -247,7 +247,7 @@ def start_clustering_endpoint():
"""
# Local imports to prevent circular dependency at startup
from app_helper import rq_queue_high, get_active_main_task
- from app_helper import clean_up_previous_main_tasks, save_task_status, TASK_STATUS_PENDING, TASK_STATUS_FAILURE
+ from app_helper import clean_up_previous_main_tasks, save_task_status, TASK_STATUS_PENDING
# Check for any existing active main task to prevent parallel batch runs
active_task = get_active_main_task()
diff --git a/app_cron.py b/app_cron.py
index c68f8a90..b4b41c93 100644
--- a/app_cron.py
+++ b/app_cron.py
@@ -1,6 +1,8 @@
from flask import Blueprint, render_template, jsonify, request
from psycopg2.extras import DictCursor
-from app_helper import get_db, rq_queue_high, save_task_status, TASK_STATUS_PENDING
+from database import get_db
+from taskqueue import rq_queue_high
+from app_helper import save_task_status, TASK_STATUS_PENDING
import uuid, time, logging
from config import (
TOP_N_MOODS,
@@ -292,7 +294,7 @@ def run_due_cron_jobs():
f"(playlist_id={playlist_id}, tracks={len(track_ids)}, job_id={job_id})"
)
except NotImplementedError:
- # MPD or unsupported backend: keep the legacy date-suffixed behavior.
+ # Unsupported backend: keep the legacy date-suffixed behavior.
legacy_name = f"Sonic Fingerprint (Cron {time.strftime('%Y-%m-%d')})"
playlist_id = create_playlist_from_ids(legacy_name, track_ids)
logger.info(
diff --git a/app_dashboard.py b/app_dashboard.py
index 32983e6b..d9f4273f 100644
--- a/app_dashboard.py
+++ b/app_dashboard.py
@@ -14,7 +14,8 @@
from flask import Blueprint, render_template, jsonify
from psycopg2.extras import DictCursor
-from app_helper import get_db, redis_conn
+from database import get_db
+from taskqueue import redis_conn
from tz_helper import LOCAL_TZ_FMT, UTC_NOW_SQL, to_local_str
logger = logging.getLogger(__name__)
diff --git a/app_helper.py b/app_helper.py
index a054df36..3651850f 100644
--- a/app_helper.py
+++ b/app_helper.py
@@ -10,7 +10,6 @@
import psycopg2
from psycopg2.extras import DictCursor
import numpy as np
-from flask import g
from database import get_db, close_db
from taskqueue import (
@@ -1419,7 +1418,7 @@ def build_and_store_map_projection(index_name='main_map'):
"""
# Import local projection helpers to avoid circular imports
try:
- from tasks.song_alchemy import _project_with_umap, _project_to_2d
+ from tasks.alchemy_projections import _project_with_umap, _project_to_2d
except Exception:
_project_with_umap = None
_project_to_2d = None
@@ -1540,7 +1539,7 @@ def build_and_store_artist_projection(index_name='artist_map'):
Returns True on success.
"""
from tasks.artist_gmm_manager import load_artist_index_for_querying
- from tasks.song_alchemy import _project_with_umap, _project_to_2d
+ from tasks.alchemy_projections import _project_with_umap, _project_to_2d
# Always reload artist GMM params from database (force reload to ensure fresh data)
load_artist_index_for_querying(force_reload=True)
@@ -1768,25 +1767,3 @@ def cancel_job_and_children_recursive(job_id, task_type_from_db=None, reason="Ta
logger.error(f"Failed to insert REVOKED recap row for {job_id}: {e_save}")
return cancelled_count
-
-
-# --- Auth / user-management helpers ---
-# All auth logic (setup/auth/admin barriers, user CRUD, password hashing,
-# JWT handling, the Flask routes) lives in ``app_auth``. The re-exports
-# below keep the legacy ``from app_helper import ...`` paths working.
-from app_auth import ( # noqa: E402 (intentional late import to avoid cycles)
- USER_ROLE_USER,
- USER_ROLE_ADMIN,
- check_setup_needed,
- check_auth_needed,
- check_admin_needed,
- is_admin_path,
- list_additional_users,
- count_admin_users,
- get_additional_user_by_id,
- create_additional_user,
- delete_additional_user_safe,
- verify_additional_user,
- upsert_admin_user,
- seed_admin_from_env,
-)
diff --git a/app_helper_artist.py b/app_helper_artist.py
index bf35fa02..f285c944 100644
--- a/app_helper_artist.py
+++ b/app_helper_artist.py
@@ -5,7 +5,7 @@
"""
import logging
-from app_helper import get_db
+from database import get_db
from tasks.memory_utils import sanitize_string_for_db
logger = logging.getLogger(__name__)
diff --git a/app_map.py b/app_map.py
index 80acf713..7b8160a9 100644
--- a/app_map.py
+++ b/app_map.py
@@ -6,16 +6,16 @@
import numpy as np
import gzip
-from app_helper import get_db, load_map_projection
+from database import get_db
+from app_helper import load_map_projection
-# Try to reuse projection helpers from song_alchemy
+# Try to reuse the shared projection helpers
try:
- from tasks.song_alchemy import _project_with_umap, _project_to_2d, _project_aligned_add_sub, _project_with_discriminant
+ from tasks.alchemy_projections import _project_with_umap, _project_to_2d, _project_with_discriminant
except Exception:
# Fallbacks will be used if import fails
_project_with_umap = None
_project_to_2d = None
- _project_aligned_add_sub = None
_project_with_discriminant = None
logger = logging.getLogger(__name__)
diff --git a/app_provider_migration.py b/app_provider_migration.py
index 1eb24897..b4942d56 100644
--- a/app_provider_migration.py
+++ b/app_provider_migration.py
@@ -21,8 +21,10 @@
# App-level singletons (DB connection, Redis, RQ queues). Importing here keeps
# the blueprint file self-contained — the rest of the app doesn't need to hand
# anything in.
-from app_helper import get_db, redis_conn, rq_queue_high, validate_outbound_url
-from tasks.mediaserver_helper import detect_path_format as _detect_path_format
+from database import get_db
+from taskqueue import redis_conn, rq_queue_high
+from app_helper import validate_outbound_url
+from tasks.mediaserver.helper import detect_path_format as _detect_path_format
logger = logging.getLogger(__name__)
@@ -59,14 +61,14 @@ def __getattr__(self, name):
# Supported target providers (what the tool knows how to talk to)
# ---------------------------------------------------------------------------
-_SUPPORTED_TARGETS = frozenset({'jellyfin', 'navidrome', 'emby', 'lyrion', 'mpd'})
+_SUPPORTED_TARGETS = frozenset({'jellyfin', 'navidrome', 'emby', 'lyrion'})
# ---------------------------------------------------------------------------
# SSRF guard for the user-supplied media-server URL. Delegates to the shared
# ``app_helper.validate_outbound_url`` (allows LAN/loopback, blocks non-HTTP(S)
-# schemes and link-local/cloud-metadata). MPD targets carry no URL, so a missing
-# url is allowed and left to the downstream probe.
+# schemes and link-local/cloud-metadata). A missing url is allowed and left to
+# the downstream probe.
# ---------------------------------------------------------------------------
def _validate_probe_url(creds):
@@ -114,8 +116,7 @@ def _current_provider_creds():
"""Build a creds dict from ``config`` for the currently active provider.
Returns ``(provider_type, creds_dict)`` or ``(None, {})`` when the
- provider isn't one we can re-probe (e.g. MPD — its paths come from the
- filesystem directly and don't need refreshing).
+ provider isn't one we can re-probe.
"""
import config as cfg
t = (getattr(cfg, 'MEDIASERVER_TYPE', '') or '').lower()
diff --git a/app_setup.py b/app_setup.py
index 6eae2957..347ab4cc 100644
--- a/app_setup.py
+++ b/app_setup.py
@@ -2,8 +2,10 @@
import types
from flask import request, jsonify, render_template, make_response, after_this_request
import config
-from app import app, setup_manager
-from app_helper import check_setup_needed, validate_outbound_url
+from flask_app import app
+from tasks.setup_manager import setup_manager
+from app_auth import check_setup_needed
+from app_helper import validate_outbound_url
import restart_manager
import tasks.mediaserver as mediaserver
from error import error_manager
@@ -74,10 +76,6 @@
'OTHER_FEATURE_PREDOMINANCE_THRESHOLD_FOR_PURITY',
'PROBE_TOP_PLAYED_LIMIT',
'MOOD_CENTROIDS_FILE',
- 'MPD_HOST',
- 'MPD_MUSIC_DIRECTORY',
- 'MPD_PASSWORD',
- 'MPD_PORT',
'OTHER_FEATURE_LABELS',
'STRATIFIED_GENRES',
'TEMPO_MAX_BPM',
@@ -219,7 +217,7 @@ def _get_allowed_setup_keys():
def _has_admin_user():
"""Return True if at least one admin exists in audiomuse_users."""
try:
- from app_helper import count_admin_users
+ from app_auth import count_admin_users
return count_admin_users() > 0
except Exception as exc:
app.logger.error(
@@ -426,7 +424,8 @@ def setup_api():
# If auth will remain enabled we need an admin after the save. That
# admin must either already exist in audiomuse_users or be provided
# via the form (new_admin_user + new_admin_password).
- from app_helper import count_admin_users, upsert_admin_user, get_db
+ from app_auth import count_admin_users, upsert_admin_user
+ from database import get_db
auth_will_be_enabled = not auth_being_disabled
if isinstance(simulated.AUTH_ENABLED, str):
auth_will_be_enabled = simulated.AUTH_ENABLED.strip().lower() == 'true'
@@ -710,7 +709,7 @@ def setup_lyrics_api_analyze():
ctx = None
import time as _time
_t0 = _time.monotonic()
- with urllib.request.urlopen(req, timeout=10, context=ctx) as resp:
+ with urllib.request.urlopen(req, timeout=60, context=ctx) as resp:
raw_bytes = resp.read(512 * 1024)
elapsed_ms = (_time.monotonic() - _t0) * 1000
raw_text = raw_bytes.decode('utf-8', errors='replace')
diff --git a/app_sync.py b/app_sync.py
index ea53b845..83694769 100644
--- a/app_sync.py
+++ b/app_sync.py
@@ -20,7 +20,8 @@
from flasgger import swag_from
import config
-from app_helper import get_db, load_map_projection
+from database import get_db
+from app_helper import load_map_projection
logger = logging.getLogger(__name__)
@@ -57,7 +58,7 @@
'Three modes: `?fields=index` returns a lightweight {id, fp} manifest '
'(<=1000/page) for client-side change detection; `?ids=a,b,c` returns full '
'payloads for a specific id set (<=500); default returns the full library '
- 'page by page (<=500). Not supported for the `mpd` media server (501).'
+ 'page by page (<=500).'
),
'parameters': [
{'name': 'fields', 'in': 'query', 'required': False,
@@ -79,13 +80,9 @@
'responses': {
'200': {'description': 'A page of the manifest or the full payload.'},
'500': {'description': 'Internal server error.'},
- '501': {'description': 'Media server type not supported (e.g. `mpd`).'},
},
})
def sync_endpoint():
- if config.MEDIASERVER_TYPE == 'mpd':
- return jsonify({"error": "mpd is not yet supported by the mobile sync endpoint"}), 501
-
manifest_mode = request.args.get('fields') == 'index'
page = max(1, request.args.get('page', 1, type=int))
max_limit = _MAX_MANIFEST_LIMIT if manifest_mode else _MAX_PAYLOAD_LIMIT
diff --git a/app_voyager.py b/app_voyager.py
index 283ab8c1..04253483 100644
--- a/app_voyager.py
+++ b/app_voyager.py
@@ -2,6 +2,7 @@
from flask import Blueprint, jsonify, request, render_template
import logging
import json
+import threading
import numpy as np
# Import the new config option
@@ -18,9 +19,10 @@
logger = logging.getLogger(__name__)
-# --- Load mood centroids at module level ---
_MOOD_CENTROIDS_DATA = {} # mood_name -> list of centroid dicts (with vectors)
_MOOD_CENTROIDS_META = {} # mood_name -> list of {cluster_id, top_tags (top 3)} for API
+_mood_centroids_loaded = False
+_mood_centroids_lock = threading.Lock()
def _load_mood_centroids_for_similarity():
try:
@@ -45,7 +47,22 @@ def _load_mood_centroids_for_similarity():
except Exception as e:
logger.warning(f"Could not load mood centroids from {MOOD_CENTROIDS_FILE}: {e}")
-_load_mood_centroids_for_similarity()
+def _ensure_mood_centroids_loaded():
+ """Parse the mood-centroids JSON on first use instead of at import.
+
+ The file is ~1MB; loading it lazily keeps module import (and therefore
+ web/worker startup) free of the parse cost. A single attempt is made,
+ matching the old import-time behavior where a failed load left the
+ dicts empty without retrying.
+ """
+ global _mood_centroids_loaded
+ if _mood_centroids_loaded:
+ return
+ with _mood_centroids_lock:
+ if _mood_centroids_loaded:
+ return
+ _load_mood_centroids_for_similarity()
+ _mood_centroids_loaded = True
# Create a Blueprint for Voyager (similarity) related routes
voyager_bp = Blueprint('voyager_bp', __name__, template_folder='../templates')
@@ -190,6 +207,7 @@ def get_mood_centroids_endpoint():
200:
description: Dictionary of mood names to lists of centroid metadata.
"""
+ _ensure_mood_centroids_loaded()
mood_filter = request.args.get('mood', '', type=str).strip().lower()
if mood_filter:
if mood_filter not in _MOOD_CENTROIDS_META:
@@ -300,6 +318,7 @@ def get_similar_tracks_endpoint():
# --- Mood centroid mode: use centroid vector instead of a song ---
if mood_param and centroid_index_param is not None:
+ _ensure_mood_centroids_loaded()
if mood_param not in _MOOD_CENTROIDS_DATA:
return jsonify({"error": f"Unknown mood '{mood_param}'. Available: {list(_MOOD_CENTROIDS_DATA.keys())}"}), 400
centroids = _MOOD_CENTROIDS_DATA[mood_param]
diff --git a/app_waveform.py b/app_waveform.py
index 8215355d..eb94d345 100644
--- a/app_waveform.py
+++ b/app_waveform.py
@@ -15,7 +15,7 @@
logger.warning("librosa not available. Waveform generation will not work. Install with: pip install librosa")
from config import MEDIASERVER_TYPE
-from app_helper import get_db
+from database import get_db
logger = logging.getLogger(__name__)
@@ -212,7 +212,7 @@ def get_waveform_endpoint():
# This ensures we have all the metadata needed for proper file extension detection
if MEDIASERVER_TYPE == "navidrome":
# Import Navidrome-specific function to get full song details
- from tasks.mediaserver_navidrome import _navidrome_request
+ from tasks.mediaserver.navidrome import _navidrome_request
song_response = _navidrome_request("getSong", {"id": item_id})
if song_response and "song" in song_response:
item = song_response["song"]
@@ -240,7 +240,6 @@ def get_waveform_endpoint():
item = {
'Id': item_id, # Jellyfin/Emby format
'id': item_id, # Navidrome/Lyrion format
- 'file': item_id, # MPD format (uses file path as ID)
'Name': title,
'Path': '' # Will be fetched by download_track if needed
}
diff --git a/config.py b/config.py
index 3cbaedb8..e3350f4c 100644
--- a/config.py
+++ b/config.py
@@ -2,7 +2,7 @@
import os
# --- Media Server Type ---
-MEDIASERVER_TYPE = os.environ.get("MEDIASERVER_TYPE", "jellyfin").lower() # Possible values: jellyfin, navidrome, lyrion, mpd, emby
+MEDIASERVER_TYPE = os.environ.get("MEDIASERVER_TYPE", "jellyfin").lower() # Possible values: jellyfin, navidrome, lyrion, emby
# --- Jellyfin and DB Constants (Read from Environment Variables first) ---
@@ -98,16 +98,8 @@ def _compute_headers():
'LYRICS_INSTRUMENTAL_AXIS_FILL',
}
-# --- MPD (Music Player Daemon) Constants ---
-# These are used only if MEDIASERVER_TYPE is "mpd".
-MPD_HOST = os.environ.get("MPD_HOST", "localhost")
-MPD_PORT = int(os.environ.get("MPD_PORT", "6600"))
-MPD_PASSWORD = os.environ.get("MPD_PASSWORD", "") # Optional password, leave empty if none
-MPD_MUSIC_DIRECTORY = os.environ.get("MPD_MUSIC_DIRECTORY", "/var/lib/mpd/music") # Path to MPD's music directory for file access
-
-
# --- General Constants (Read from Environment Variables where applicable) ---
-APP_VERSION = "v2.1.5"
+APP_VERSION = "v2.2.0"
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
@@ -667,36 +659,41 @@ def _compute_headers():
# Default is True to preserve the current secure behavior.
AUTH_ENABLED = os.environ.get("AUTH_ENABLED", "True").lower() == "true"
-try:
- from tasks.setup_manager import SetupManager
- _setup_manager = SetupManager()
- worker_mode = os.environ.get('AUDIOMUSE_ROLE', '').lower() == 'worker'
- if worker_mode:
- if _setup_manager.config_table_exists():
- _overrides = _setup_manager.get_raw_overrides(ensure_table=False)
+def _apply_db_overrides():
+ global HEADERS, refresh_config
+ try:
+ from tasks.setup_manager import SetupManager
+ _setup_manager = SetupManager()
+ worker_mode = os.environ.get('AUDIOMUSE_ROLE', '').lower() == 'worker'
+ if worker_mode:
+ if _setup_manager.config_table_exists():
+ _overrides = _setup_manager.get_raw_overrides(ensure_table=False)
+ else:
+ _overrides = {}
else:
- _overrides = {}
- else:
- _setup_manager.ensure_table()
- _overrides = _setup_manager.get_raw_overrides()
- _excluded_override_keys = globals().get('SETUP_BOOTSTRAP_EXCLUDED_KEYS', set())
- for _key, _value in _overrides.items():
- # Skip any keys that are explicitly excluded from overrides (Redis and Postgres)
- if _key in _excluded_override_keys:
- continue
- # Read the value from the db and override the variable
- if _key in globals():
- globals()[_key] = _setup_manager.cast_value(globals()[_key], _value)
-
- HEADERS = _compute_headers()
-
- def refresh_config():
- """Reload the config module from the current database and environment."""
- import importlib
- import sys
- importlib.reload(sys.modules[__name__])
-except Exception as _exc:
- import logging
- logging.getLogger(__name__).warning(f"Could not load config overrides from DB: {_exc}")
- def refresh_config():
- pass
+ _setup_manager.ensure_table()
+ _overrides = _setup_manager.get_raw_overrides()
+ _excluded_override_keys = globals().get('SETUP_BOOTSTRAP_EXCLUDED_KEYS', set())
+ for _key, _value in _overrides.items():
+ # Skip any keys that are explicitly excluded from overrides (Redis and Postgres)
+ if _key in _excluded_override_keys:
+ continue
+ # Read the value from the db and override the variable
+ if _key in globals():
+ globals()[_key] = _setup_manager.cast_value(globals()[_key], _value)
+
+ HEADERS = _compute_headers()
+
+ def refresh_config():
+ """Reload the config module from the current database and environment."""
+ import importlib
+ import sys
+ importlib.reload(sys.modules[__name__])
+ except Exception as _exc:
+ import logging
+ logging.getLogger(__name__).warning(f"Could not load config overrides from DB: {_exc}")
+ def refresh_config():
+ pass
+
+
+_apply_db_overrides()
diff --git a/database.py b/database.py
index e0764d30..cfb96182 100644
--- a/database.py
+++ b/database.py
@@ -55,7 +55,7 @@ def start_embedded(data_dir):
``embedded``. The data directory must live outside the read-only app bundle
and its path must not contain spaces (pgserver doubles it as the unix-socket
dir, which ``postgres`` receives via ``pg_ctl -o '-k
'`` and re-splits on
- whitespace); see ``macos/paths.py::app_support_dir``. Initializes the cluster
+ whitespace); see ``native-build/macos/paths.py::app_support_dir``. Initializes the cluster
on first run, idempotent afterwards.
"""
global _embedded_server
diff --git a/docs/ALGORITHM.md b/docs/ALGORITHM.md
index 925fdaef..1d2395a2 100644
--- a/docs/ALGORITHM.md
+++ b/docs/ALGORITHM.md
@@ -86,7 +86,7 @@ Core components and responsibilities:
- ONNX Models & Audio Stack: Analysis uses ONNX Runtime to run embedding and prediction models. Audio loading uses `librosa` with a `pydub`/ffmpeg fallback for resilient decoding. The Docker image pre-fetches ONNX model files and pins runtime libs to ensure consistent behavior across environments.
-- Media Server Adapters: `mediaserver.py` provides adapters for Jellyfin, Navidrome, Emby, etc., enabling playlist creation and reading play-history for the Sonic Fingerprint feature.
+- Media Server Adapters: the `tasks/mediaserver/` package provides adapters for Jellyfin, Navidrome, Emby, etc., enabling playlist creation and reading play-history for the Sonic Fingerprint feature.
Deployment considerations (informed by `Dockerfile`):
@@ -266,13 +266,12 @@ The Song Analysis functionality is configured by the following environment varia
#### **Media Server**
-* MEDIASERVER\_TYPE: **(Required)** Specifies the media server to connect to. (e.g., jellyfin, navidrome, emby, lyrion, mpd).
+* MEDIASERVER\_TYPE: **(Required)** Specifies the media server to connect to. (e.g., jellyfin, navidrome, emby, lyrion).
* MUSIC\_LIBRARIES: (Optional) A comma-separated list of library names to scan. If empty, all music libraries are scanned.
* JELLYFIN\_URL, JELLYFIN\_USER\_ID, JELLYFIN\_TOKEN: Credentials for Jellyfin (if MEDIASERVER\_TYPE="jellyfin").
* EMBY\_URL, EMBY\_USER\_ID, EMBY\_TOKEN: Credentials for Emby (if MEDIASERVER\_TYPE="emby").
* NAVIDROME\_URL, NAVIDROME\_USER, NAVIDROME\_PASSWORD: Credentials for Navidrome (if MEDIASERVER\_TYPE="navidrome").
-* LYRION\_URL: Credentials for Lyrion (if MEDIASERVER\_TYPE="lyrion").
-* MPD\_HOST, MPD\_PORT, MPD\_PASSWORD, MPD\_MUSIC\_DIRECTORY: Credentials for MPD (if MEDIASERVER\_TYPE="mpd").
+* LYRION\_URL: Credentials for Lyrion (if MEDIASERVER\_TYPE="lyrion").
#### **Task & Performance Tuning**
@@ -624,7 +623,7 @@ This feature primarily interacts with the pre-built Voyager index for fast simil
#### **Stage 4: Playlist Creation**
1. **Route:** Clicking "Create Playlist" (similarity.html) sends a POST request to /api/create\_playlist (app\_voyager.py). The payload contains the desired playlist\_name and the list of track\_ids (seed song \+ similar songs).
-2. **Backend Logic:** The endpoint calls create\_playlist\_from\_ids (voyager\_manager.py), which in turn calls create\_instant\_playlist (mediaserver.py). This function uses the configured MEDIASERVER\_TYPE and credentials to interact with the media server's API and create the actual playlist.
+2. **Backend Logic:** The endpoint calls create\_playlist\_from\_ids (voyager\_manager.py), which in turn calls create\_instant\_playlist (tasks/mediaserver/). This function uses the configured MEDIASERVER\_TYPE and credentials to interact with the media server's API and create the actual playlist.
### **3.3. Environment Variable Configuration**
@@ -1009,11 +1008,11 @@ This feature combines media server interaction for user history with vector anal
#### **Stage 2: Fingerprint Generation (generate\_sonic\_fingerprint in sonic\_fingerprint\_manager.py)**
-1. **Fetch Top Songs:** Calls get\_top\_played\_songs (from mediaserver.py), passing the user\_creds. This function interacts with the media server API (based on MEDIASERVER\_TYPE) to retrieve the user's top SONIC\_FINGERPRINT\_TOP\_N\_SONGS most played tracks.
+1. **Fetch Top Songs:** Calls get\_top\_played\_songs (from tasks/mediaserver/), passing the user\_creds. This function interacts with the media server API (based on MEDIASERVER\_TYPE) to retrieve the user's top SONIC\_FINGERPRINT\_TOP\_N\_SONGS most played tracks.
2. **Fetch Embeddings:** Retrieves the embedding vectors (embedding\_vector) for these top songs from the application's PostgreSQL database (embedding table) using get\_tracks\_by\_ids.
3. **Calculate Recency Weights:**
* Iterates through the top songs (for which embeddings were found).
- * For each song, calls get\_last\_played\_time (from mediaserver.py), passing user\_creds, to get the timestamp of the last play.
+ * For each song, calls get\_last\_played\_time (from tasks/mediaserver/), passing user\_creds, to get the timestamp of the last play.
* Calculates days\_since\_played.
* Applies an **exponential decay function** (weight \= exp(-decay\_rate \* days\_since\_played)) to calculate a weight. The half\_life (set to 30 days) determines how quickly the weight decreases for older plays. Songs without a valid last played time receive a fixed lower weight.
4. **Weighted Average:** Calculates the weighted average of the embedding vectors (average\_vector). This vector represents the user's "sonic fingerprint".
@@ -1120,7 +1119,7 @@ Stage 5: Execute Query & Post-process Results
Stage 6: Optional Playlist Creation on Media Server
-1. The frontend posts the playlist name and `item_ids` to an endpoint that maps internal `item_id`s to media-server-specific IDs and creates the playlist using the configured media server adapter (Jellyfin/Emby/Navidrome). These adapter functions live in `mediaserver.py`.
+1. The frontend posts the playlist name and `item_ids` to an endpoint that maps internal `item_id`s to media-server-specific IDs and creates the playlist using the configured media server adapter (Jellyfin/Emby/Navidrome). These adapter functions live in `tasks/mediaserver/`.
2. Return the media server response (success, playlist id, or error) to the frontend and display it in the UI.
Safety & Fallbacks
diff --git a/linux/__init__.py b/native-build/linux/__init__.py
similarity index 84%
rename from linux/__init__.py
rename to native-build/linux/__init__.py
index a0208290..62de9913 100644
--- a/linux/__init__.py
+++ b/native-build/linux/__init__.py
@@ -12,10 +12,10 @@
package (``macos.control_ipc.ControlServer`` and
``macos.reverse_log.NewestFirstFileHandler``) instead of duplicating them.
Both are pure-stdlib and contain nothing macOS-specific.
-* The child env (``linux/env.py``) reports ``AUDIOMUSE_PLATFORM=macos`` on
+* The child env (``native-build/linux/env.py``) reports ``AUDIOMUSE_PLATFORM=macos`` on
purpose: that value is the *only* platform-keyed branch in the shared
``restart_manager.py`` and it selects the unix-socket control-server path
(which this package's supervisor implements identically). We are not allowed
to touch shared code to add a separate ``linux`` value, so we ride the
- existing standalone-mode branch. See ``linux/env.py`` for the full rationale.
+ existing standalone-mode branch. See ``native-build/linux/env.py`` for the full rationale.
"""
diff --git a/linux/db_backend.py b/native-build/linux/db_backend.py
similarity index 100%
rename from linux/db_backend.py
rename to native-build/linux/db_backend.py
diff --git a/linux/embedded_pg.py b/native-build/linux/embedded_pg.py
similarity index 98%
rename from linux/embedded_pg.py
rename to native-build/linux/embedded_pg.py
index 91097aa4..05819da6 100644
--- a/linux/embedded_pg.py
+++ b/native-build/linux/embedded_pg.py
@@ -12,7 +12,7 @@
The bundled server is relocatable: PostgreSQL derives its support-file paths
from the running executable's location, so the tree works wherever the package
is installed (``/opt/AudioMuse-AI/_internal/pgsql``). The unix socket lives in
-the data dir itself (which ``linux/paths.py`` guarantees is space-free), exactly
+the data dir itself (which ``native-build/linux/paths.py`` guarantees is space-free), exactly
like the pgserver/macOS setup.
"""
diff --git a/linux/env.py b/native-build/linux/env.py
similarity index 97%
rename from linux/env.py
rename to native-build/linux/env.py
index 2e0e822d..823cd947 100644
--- a/linux/env.py
+++ b/native-build/linux/env.py
@@ -1,6 +1,6 @@
"""Build the environment handed to each supervised child process (Linux build).
-Linux counterpart of ``macos/env.py``. The standalone-mode overrides are
+Linux counterpart of ``native-build/macos/env.py``. The standalone-mode overrides are
centralized here so every child imports ``config`` already pointed at the
embedded services and the bundled models. Keys mirror the env-driven settings in
``config.py`` (``DATABASE_URL``, ``REDIS_URL``, ``TEMP_DIR``, the ``*_MODEL_PATH``
@@ -19,7 +19,7 @@
flow work on the native Linux build with **zero shared-code changes**. The value
is an internal "standalone embedded supervisor" signal, not a real OS check.
-Note the macOS-only workarounds from ``macos/env.py`` are intentionally omitted:
+Note the macOS-only workarounds from ``native-build/macos/env.py`` are intentionally omitted:
``OBJC_DISABLE_INITIALIZE_FORK_SAFETY`` and the ``LC_NUMERIC=C`` pin both address
Apple-framework behavior that does not exist on Linux (and Linux is the platform
``config.py``/the container already target, so its defaults are correct here).
diff --git a/linux/launcher.py b/native-build/linux/launcher.py
similarity index 99%
rename from linux/launcher.py
rename to native-build/linux/launcher.py
index e774ac2b..94de9175 100644
--- a/linux/launcher.py
+++ b/native-build/linux/launcher.py
@@ -1,6 +1,6 @@
"""Standalone Linux entry point (the PyInstaller entry script).
-Linux counterpart of ``macos/launcher.py``. There is no native menu-bar agent
+Linux counterpart of ``native-build/macos/launcher.py``. There is no native menu-bar agent
here (rumps/AppKit are macOS-only); instead the frozen binary is a small
multi-call launcher:
diff --git a/linux/packaging/AudioMuse-AI-stop.desktop b/native-build/linux/packaging/AudioMuse-AI-stop.desktop
similarity index 100%
rename from linux/packaging/AudioMuse-AI-stop.desktop
rename to native-build/linux/packaging/AudioMuse-AI-stop.desktop
diff --git a/linux/packaging/AudioMuse-AI.desktop b/native-build/linux/packaging/AudioMuse-AI.desktop
similarity index 100%
rename from linux/packaging/AudioMuse-AI.desktop
rename to native-build/linux/packaging/AudioMuse-AI.desktop
diff --git a/linux/packaging/audiomuse-ai.service b/native-build/linux/packaging/audiomuse-ai.service
similarity index 100%
rename from linux/packaging/audiomuse-ai.service
rename to native-build/linux/packaging/audiomuse-ai.service
diff --git a/linux/packaging/icons/audiomuse-ai_128.png b/native-build/linux/packaging/icons/audiomuse-ai_128.png
similarity index 100%
rename from linux/packaging/icons/audiomuse-ai_128.png
rename to native-build/linux/packaging/icons/audiomuse-ai_128.png
diff --git a/linux/packaging/icons/audiomuse-ai_256.png b/native-build/linux/packaging/icons/audiomuse-ai_256.png
similarity index 100%
rename from linux/packaging/icons/audiomuse-ai_256.png
rename to native-build/linux/packaging/icons/audiomuse-ai_256.png
diff --git a/linux/packaging/icons/audiomuse-ai_32.png b/native-build/linux/packaging/icons/audiomuse-ai_32.png
similarity index 100%
rename from linux/packaging/icons/audiomuse-ai_32.png
rename to native-build/linux/packaging/icons/audiomuse-ai_32.png
diff --git a/linux/packaging/icons/audiomuse-ai_48.png b/native-build/linux/packaging/icons/audiomuse-ai_48.png
similarity index 100%
rename from linux/packaging/icons/audiomuse-ai_48.png
rename to native-build/linux/packaging/icons/audiomuse-ai_48.png
diff --git a/linux/packaging/icons/audiomuse-ai_512.png b/native-build/linux/packaging/icons/audiomuse-ai_512.png
similarity index 100%
rename from linux/packaging/icons/audiomuse-ai_512.png
rename to native-build/linux/packaging/icons/audiomuse-ai_512.png
diff --git a/linux/packaging/icons/audiomuse-ai_64.png b/native-build/linux/packaging/icons/audiomuse-ai_64.png
similarity index 100%
rename from linux/packaging/icons/audiomuse-ai_64.png
rename to native-build/linux/packaging/icons/audiomuse-ai_64.png
diff --git a/linux/packaging/nfpm.yaml.in b/native-build/linux/packaging/nfpm.yaml.in
similarity index 96%
rename from linux/packaging/nfpm.yaml.in
rename to native-build/linux/packaging/nfpm.yaml.in
index 8bfa6820..87a79180 100644
--- a/linux/packaging/nfpm.yaml.in
+++ b/native-build/linux/packaging/nfpm.yaml.in
@@ -76,5 +76,5 @@ contents:
type: symlink
scripts:
- postinstall: linux/packaging/postinstall.sh
- postremove: linux/packaging/postremove.sh
+ postinstall: native-build/linux/packaging/postinstall.sh
+ postremove: native-build/linux/packaging/postremove.sh
diff --git a/linux/packaging/postinstall.sh b/native-build/linux/packaging/postinstall.sh
similarity index 100%
rename from linux/packaging/postinstall.sh
rename to native-build/linux/packaging/postinstall.sh
diff --git a/linux/packaging/postremove.sh b/native-build/linux/packaging/postremove.sh
similarity index 100%
rename from linux/packaging/postremove.sh
rename to native-build/linux/packaging/postremove.sh
diff --git a/linux/paths.py b/native-build/linux/paths.py
similarity index 93%
rename from linux/paths.py
rename to native-build/linux/paths.py
index f7fe4aba..664661a3 100644
--- a/linux/paths.py
+++ b/native-build/linux/paths.py
@@ -1,6 +1,6 @@
"""Filesystem locations for the standalone Linux build.
-Mirrors ``macos/paths.py`` but follows the XDG Base Directory spec instead of
+Mirrors ``native-build/macos/paths.py`` but follows the XDG Base Directory spec instead of
``~/Library``.
Read-only resources (ONNX models, Flask templates/static, the bundled
@@ -36,7 +36,7 @@ def resource_root():
def _repo_root():
- return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+ return os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def _ensure(path):
@@ -143,7 +143,7 @@ def redis_binary():
def _uses_pgserver():
"""x86_64 uses the pgserver wheel; other arches (aarch64) use the
from-source PostgreSQL bundled under ``pgsql/`` (pgserver has no arm64
- wheel). Mirrors ``linux/db_backend.py``'s selector."""
+ wheel). Mirrors ``native-build/linux/db_backend.py``'s selector."""
return platform.machine() in ("x86_64", "amd64")
@@ -153,8 +153,8 @@ def pg_install_dir():
* x86_64: the pgserver wheel's ``pginstall`` tree (bundled at
``pgserver/pginstall`` when frozen; resolved from the package in dev).
* aarch64: the from-source server bundled at ``pgsql/`` (built by
- ``linux/vendor/postgres/build-postgres.sh`` into
- ``linux/vendor/postgres/`` in dev).
+ ``native-build/linux/vendor/postgres/build-postgres.sh`` into
+ ``native-build/linux/vendor/postgres/`` in dev).
"""
if _uses_pgserver():
if getattr(sys, "frozen", False):
diff --git a/linux/supervisor.py b/native-build/linux/supervisor.py
similarity index 99%
rename from linux/supervisor.py
rename to native-build/linux/supervisor.py
index 350bd2db..d3c5c027 100644
--- a/linux/supervisor.py
+++ b/native-build/linux/supervisor.py
@@ -1,6 +1,6 @@
"""Process supervisor for the standalone Linux app.
-Linux counterpart of ``macos/supervisor.py`` -- the logic is platform-agnostic
+Linux counterpart of ``native-build/macos/supervisor.py`` -- the logic is platform-agnostic
(stdlib ``subprocess``/``signal``/``os.killpg`` + ``psutil``), so this is a near
copy that swaps ``macos`` path/env helpers for the ``linux`` ones and reuses the
two genuinely platform-neutral helpers from the macOS package
diff --git a/linux/vendor/build-redis.sh b/native-build/linux/vendor/build-redis.sh
similarity index 91%
rename from linux/vendor/build-redis.sh
rename to native-build/linux/vendor/build-redis.sh
index 64d8c7b3..0ded4efc 100755
--- a/linux/vendor/build-redis.sh
+++ b/native-build/linux/vendor/build-redis.sh
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
#
# Build a self-contained ``redis-server`` for the native Linux bundle and drop it
-# at ``linux/vendor/redis//redis-server`` (where the PyInstaller spec
+# at ``native-build/linux/vendor/redis//redis-server`` (where the PyInstaller spec
# expects it). Run from the repo root on the target architecture (the CI
# workflow runs it on an x86_64 and an aarch64 runner).
#
@@ -17,7 +17,7 @@ set -euo pipefail
REDIS_VERSION="${REDIS_VERSION:-7.4.2}"
ARCH="$(uname -m)" # x86_64 | aarch64
-DEST="linux/vendor/redis/${ARCH}"
+DEST="native-build/linux/vendor/redis/${ARCH}"
echo "==> Building redis-server ${REDIS_VERSION} for ${ARCH}"
work="$(mktemp -d)"
diff --git a/linux/vendor/pg-contrib/build-pg-contrib.sh b/native-build/linux/vendor/pg-contrib/build-pg-contrib.sh
similarity index 91%
rename from linux/vendor/pg-contrib/build-pg-contrib.sh
rename to native-build/linux/vendor/pg-contrib/build-pg-contrib.sh
index a2251fd9..39bda980 100755
--- a/linux/vendor/pg-contrib/build-pg-contrib.sh
+++ b/native-build/linux/vendor/pg-contrib/build-pg-contrib.sh
@@ -2,12 +2,12 @@
#
# Compile the PostgreSQL ``unaccent`` and ``pg_trgm`` contrib extensions against
# the EXACT PostgreSQL version that ``pgserver`` bundles, and drop the artifacts
-# under ``linux/vendor/pg-contrib//`` where the PyInstaller spec grafts
+# under ``native-build/linux/vendor/pg-contrib//`` where the PyInstaller spec grafts
# them into the bundled ``pgserver/pginstall`` tree.
#
# Run from the repo root, inside the build venv (so ``pgserver`` is importable):
# source .venv-linux/bin/activate
-# bash linux/vendor/pg-contrib/build-pg-contrib.sh
+# bash native-build/linux/vendor/pg-contrib/build-pg-contrib.sh
#
# Why this is necessary: ``pgserver`` ships a MINIMAL PostgreSQL (only plpgsql +
# pgvector), but the AudioMuse-AI schema (app_helper.py::init_db) runs
@@ -19,7 +19,7 @@
set -euo pipefail
ARCH="$(uname -m)" # x86_64 | aarch64
-DEST="linux/vendor/pg-contrib/${ARCH}"
+DEST="native-build/linux/vendor/pg-contrib/${ARCH}"
# pgserver's own pg_config (in the active venv).
PGC="$(python -c 'import pgserver,os;print(os.path.join(os.path.dirname(pgserver.__file__),"pginstall","bin","pg_config"))')"
diff --git a/linux/vendor/postgres/build-postgres.sh b/native-build/linux/vendor/postgres/build-postgres.sh
similarity index 93%
rename from linux/vendor/postgres/build-postgres.sh
rename to native-build/linux/vendor/postgres/build-postgres.sh
index 1bd837da..ecc3ffce 100755
--- a/linux/vendor/postgres/build-postgres.sh
+++ b/native-build/linux/vendor/postgres/build-postgres.sh
@@ -2,13 +2,13 @@
#
# Build a relocatable PostgreSQL (server + client tools + the ``unaccent`` and
# ``pg_trgm`` contrib extensions) from source and install it under
-# ``linux/vendor/postgres//`` where the PyInstaller spec bundles it as
+# ``native-build/linux/vendor/postgres//`` where the PyInstaller spec bundles it as
# ``pgsql/``. Used for the **aarch64** Linux build, where ``pgserver`` has no
# wheel.
#
# Run from the repo root on the target architecture (the CI workflow runs it on
# the aarch64 runner):
-# bash linux/vendor/postgres/build-postgres.sh
+# bash native-build/linux/vendor/postgres/build-postgres.sh
#
# Why from source (not a prebuilt binary like zonky's embedded-postgres):
# * we must compile the unaccent/pg_trgm contrib modules, which need the
@@ -22,7 +22,7 @@ set -euo pipefail
PG_VERSION="${PG_VERSION:-16.9}"
ARCH="$(uname -m)" # aarch64
-PREFIX="$(pwd)/linux/vendor/postgres/${ARCH}"
+PREFIX="$(pwd)/native-build/linux/vendor/postgres/${ARCH}"
echo "==> Building PostgreSQL ${PG_VERSION} (${ARCH}) -> ${PREFIX}"
rm -rf "$PREFIX"
diff --git a/macos/__init__.py b/native-build/macos/__init__.py
similarity index 100%
rename from macos/__init__.py
rename to native-build/macos/__init__.py
diff --git a/macos/assets/AudioMuse-AI.icns b/native-build/macos/assets/AudioMuse-AI.icns
similarity index 100%
rename from macos/assets/AudioMuse-AI.icns
rename to native-build/macos/assets/AudioMuse-AI.icns
diff --git a/macos/assets/menubar-icon.png b/native-build/macos/assets/menubar-icon.png
similarity index 100%
rename from macos/assets/menubar-icon.png
rename to native-build/macos/assets/menubar-icon.png
diff --git a/macos/control_ipc.py b/native-build/macos/control_ipc.py
similarity index 100%
rename from macos/control_ipc.py
rename to native-build/macos/control_ipc.py
diff --git a/macos/entitlements.plist b/native-build/macos/entitlements.plist
similarity index 100%
rename from macos/entitlements.plist
rename to native-build/macos/entitlements.plist
diff --git a/macos/env.py b/native-build/macos/env.py
similarity index 98%
rename from macos/env.py
rename to native-build/macos/env.py
index 1ddb3d16..0941dada 100644
--- a/macos/env.py
+++ b/native-build/macos/env.py
@@ -35,7 +35,7 @@ def build_child_env(role, database_url, redis_url):
# tasks stuck pending. Pinning the *numeric* locale to C makes strtold
# deterministic with no transition to race against. Kept to LC_NUMERIC only
# so LC_CTYPE/UTF-8 (accented file paths) is untouched. The matching
- # in-process pin lives in macos/launcher.py (numeric_bootstrap.py); this
+ # in-process pin lives in native-build/macos/launcher.py (numeric_bootstrap.py); this
# is all macOS-only and does not affect the Linux/Docker images.
"LC_NUMERIC": "C",
"APP_DATA_DIR": paths.app_support_dir(),
diff --git a/macos/hooks/hook-tasks.py b/native-build/macos/hooks/hook-tasks.py
similarity index 100%
rename from macos/hooks/hook-tasks.py
rename to native-build/macos/hooks/hook-tasks.py
diff --git a/macos/launcher.py b/native-build/macos/launcher.py
similarity index 100%
rename from macos/launcher.py
rename to native-build/macos/launcher.py
diff --git a/macos/make_icns.sh b/native-build/macos/make_icns.sh
similarity index 94%
rename from macos/make_icns.sh
rename to native-build/macos/make_icns.sh
index 2f8cee3c..598c7800 100644
--- a/macos/make_icns.sh
+++ b/native-build/macos/make_icns.sh
@@ -2,7 +2,7 @@
set -euo pipefail
SRC="screenshot/audiomuseai.png"
-OUT_DIR="macos/assets"
+OUT_DIR="native-build/macos/assets"
WORK="$(mktemp -d)/AudioMuse-AI.iconset"
mkdir -p "$WORK" "$OUT_DIR"
diff --git a/macos/paths.py b/native-build/macos/paths.py
similarity index 97%
rename from macos/paths.py
rename to native-build/macos/paths.py
index a9f42dde..2c58ab6b 100644
--- a/macos/paths.py
+++ b/native-build/macos/paths.py
@@ -25,7 +25,7 @@ def resource_root():
def _repo_root():
- return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+ return os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def _ensure(path):
diff --git a/macos/reverse_log.py b/native-build/macos/reverse_log.py
similarity index 100%
rename from macos/reverse_log.py
rename to native-build/macos/reverse_log.py
diff --git a/macos/supervisor.py b/native-build/macos/supervisor.py
similarity index 99%
rename from macos/supervisor.py
rename to native-build/macos/supervisor.py
index 0636e3a9..f9ddd827 100644
--- a/macos/supervisor.py
+++ b/native-build/macos/supervisor.py
@@ -63,7 +63,7 @@ def _setup_logging(self):
if not log.handlers:
# Newest line on top so opening the log shows the latest activity
# first. Bounded by line count (~40k) instead of a byte cap; see
- # macos/reverse_log.py for why it's not a literal per-line prepend.
+ # native-build/macos/reverse_log.py for why it's not a literal per-line prepend.
handler = NewestFirstFileHandler(paths.log_file())
handler.setFormatter(logging.Formatter("%(asctime)s %(message)s"))
log.addHandler(handler)
diff --git a/macos/vendor/pg-contrib/arm64/extension/pg_trgm--1.0--1.1.sql b/native-build/macos/vendor/pg-contrib/arm64/extension/pg_trgm--1.0--1.1.sql
similarity index 100%
rename from macos/vendor/pg-contrib/arm64/extension/pg_trgm--1.0--1.1.sql
rename to native-build/macos/vendor/pg-contrib/arm64/extension/pg_trgm--1.0--1.1.sql
diff --git a/macos/vendor/pg-contrib/arm64/extension/pg_trgm--1.1--1.2.sql b/native-build/macos/vendor/pg-contrib/arm64/extension/pg_trgm--1.1--1.2.sql
similarity index 100%
rename from macos/vendor/pg-contrib/arm64/extension/pg_trgm--1.1--1.2.sql
rename to native-build/macos/vendor/pg-contrib/arm64/extension/pg_trgm--1.1--1.2.sql
diff --git a/macos/vendor/pg-contrib/arm64/extension/pg_trgm--1.2--1.3.sql b/native-build/macos/vendor/pg-contrib/arm64/extension/pg_trgm--1.2--1.3.sql
similarity index 100%
rename from macos/vendor/pg-contrib/arm64/extension/pg_trgm--1.2--1.3.sql
rename to native-build/macos/vendor/pg-contrib/arm64/extension/pg_trgm--1.2--1.3.sql
diff --git a/macos/vendor/pg-contrib/arm64/extension/pg_trgm--1.3--1.4.sql b/native-build/macos/vendor/pg-contrib/arm64/extension/pg_trgm--1.3--1.4.sql
similarity index 100%
rename from macos/vendor/pg-contrib/arm64/extension/pg_trgm--1.3--1.4.sql
rename to native-build/macos/vendor/pg-contrib/arm64/extension/pg_trgm--1.3--1.4.sql
diff --git a/macos/vendor/pg-contrib/arm64/extension/pg_trgm--1.3.sql b/native-build/macos/vendor/pg-contrib/arm64/extension/pg_trgm--1.3.sql
similarity index 100%
rename from macos/vendor/pg-contrib/arm64/extension/pg_trgm--1.3.sql
rename to native-build/macos/vendor/pg-contrib/arm64/extension/pg_trgm--1.3.sql
diff --git a/macos/vendor/pg-contrib/arm64/extension/pg_trgm--1.4--1.5.sql b/native-build/macos/vendor/pg-contrib/arm64/extension/pg_trgm--1.4--1.5.sql
similarity index 100%
rename from macos/vendor/pg-contrib/arm64/extension/pg_trgm--1.4--1.5.sql
rename to native-build/macos/vendor/pg-contrib/arm64/extension/pg_trgm--1.4--1.5.sql
diff --git a/macos/vendor/pg-contrib/arm64/extension/pg_trgm--1.5--1.6.sql b/native-build/macos/vendor/pg-contrib/arm64/extension/pg_trgm--1.5--1.6.sql
similarity index 100%
rename from macos/vendor/pg-contrib/arm64/extension/pg_trgm--1.5--1.6.sql
rename to native-build/macos/vendor/pg-contrib/arm64/extension/pg_trgm--1.5--1.6.sql
diff --git a/macos/vendor/pg-contrib/arm64/extension/pg_trgm.control b/native-build/macos/vendor/pg-contrib/arm64/extension/pg_trgm.control
similarity index 100%
rename from macos/vendor/pg-contrib/arm64/extension/pg_trgm.control
rename to native-build/macos/vendor/pg-contrib/arm64/extension/pg_trgm.control
diff --git a/macos/vendor/pg-contrib/arm64/extension/unaccent--1.0--1.1.sql b/native-build/macos/vendor/pg-contrib/arm64/extension/unaccent--1.0--1.1.sql
similarity index 100%
rename from macos/vendor/pg-contrib/arm64/extension/unaccent--1.0--1.1.sql
rename to native-build/macos/vendor/pg-contrib/arm64/extension/unaccent--1.0--1.1.sql
diff --git a/macos/vendor/pg-contrib/arm64/extension/unaccent--1.1.sql b/native-build/macos/vendor/pg-contrib/arm64/extension/unaccent--1.1.sql
similarity index 100%
rename from macos/vendor/pg-contrib/arm64/extension/unaccent--1.1.sql
rename to native-build/macos/vendor/pg-contrib/arm64/extension/unaccent--1.1.sql
diff --git a/macos/vendor/pg-contrib/arm64/extension/unaccent.control b/native-build/macos/vendor/pg-contrib/arm64/extension/unaccent.control
similarity index 100%
rename from macos/vendor/pg-contrib/arm64/extension/unaccent.control
rename to native-build/macos/vendor/pg-contrib/arm64/extension/unaccent.control
diff --git a/macos/vendor/pg-contrib/arm64/lib/pg_trgm.dylib b/native-build/macos/vendor/pg-contrib/arm64/lib/pg_trgm.dylib
similarity index 100%
rename from macos/vendor/pg-contrib/arm64/lib/pg_trgm.dylib
rename to native-build/macos/vendor/pg-contrib/arm64/lib/pg_trgm.dylib
diff --git a/macos/vendor/pg-contrib/arm64/lib/unaccent.dylib b/native-build/macos/vendor/pg-contrib/arm64/lib/unaccent.dylib
similarity index 100%
rename from macos/vendor/pg-contrib/arm64/lib/unaccent.dylib
rename to native-build/macos/vendor/pg-contrib/arm64/lib/unaccent.dylib
diff --git a/macos/vendor/pg-contrib/arm64/tsearch_data/unaccent.rules b/native-build/macos/vendor/pg-contrib/arm64/tsearch_data/unaccent.rules
similarity index 100%
rename from macos/vendor/pg-contrib/arm64/tsearch_data/unaccent.rules
rename to native-build/macos/vendor/pg-contrib/arm64/tsearch_data/unaccent.rules
diff --git a/macos/vendor/redis/arm64/redis-server b/native-build/macos/vendor/redis/arm64/redis-server
similarity index 100%
rename from macos/vendor/redis/arm64/redis-server
rename to native-build/macos/vendor/redis/arm64/redis-server
diff --git a/native-build/windows/__init__.py b/native-build/windows/__init__.py
new file mode 100644
index 00000000..ec50a564
--- /dev/null
+++ b/native-build/windows/__init__.py
@@ -0,0 +1,4 @@
+"""Windows standalone build package.
+
+See native-build/windows/README.md for build instructions.
+"""
diff --git a/windows/assets/AudioMuse-AI.ico b/native-build/windows/assets/AudioMuse-AI.ico
similarity index 100%
rename from windows/assets/AudioMuse-AI.ico
rename to native-build/windows/assets/AudioMuse-AI.ico
diff --git a/windows/control_server.py b/native-build/windows/control_server.py
similarity index 95%
rename from windows/control_server.py
rename to native-build/windows/control_server.py
index 734e328a..eac8a0b1 100644
--- a/windows/control_server.py
+++ b/native-build/windows/control_server.py
@@ -1,6 +1,6 @@
"""TCP control server for the standalone Windows supervisor.
-Replaces the Unix-domain-socket ``macos/control_ipc.py`` on Windows, where
+Replaces the Unix-domain-socket ``native-build/macos/control_ipc.py`` on Windows, where
``AF_UNIX`` is not available. The protocol is identical (JSON line → response),
just the transport is TCP on localhost.
@@ -72,7 +72,7 @@ def _handle(self, conn, addr):
conn.sendall(b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nstopping")
return
- # JSON control protocol (same as macos/control_ipc.py).
+ # JSON control protocol (same as native-build/macos/control_ipc.py).
request = json.loads(text)
ok = bool(self._dispatch(request.get("action", ""), request.get("services", [])))
conn.sendall(b"ok" if ok else b"error")
diff --git a/windows/db_backend.py b/native-build/windows/db_backend.py
similarity index 100%
rename from windows/db_backend.py
rename to native-build/windows/db_backend.py
diff --git a/windows/download_models.bat b/native-build/windows/download_models.bat
similarity index 100%
rename from windows/download_models.bat
rename to native-build/windows/download_models.bat
diff --git a/windows/embedded_pg.py b/native-build/windows/embedded_pg.py
similarity index 100%
rename from windows/embedded_pg.py
rename to native-build/windows/embedded_pg.py
diff --git a/windows/env.py b/native-build/windows/env.py
similarity index 97%
rename from windows/env.py
rename to native-build/windows/env.py
index 7e304d13..af17ea7d 100644
--- a/windows/env.py
+++ b/native-build/windows/env.py
@@ -1,6 +1,6 @@
"""Build the environment handed to each supervised child process (Windows build).
-Windows counterpart of ``linux/env.py``. The standalone-mode overrides are
+Windows counterpart of ``native-build/linux/env.py``. The standalone-mode overrides are
centralized here so every child imports ``config`` already pointed at the
embedded services and the bundled models.
diff --git a/windows/launcher.py b/native-build/windows/launcher.py
similarity index 99%
rename from windows/launcher.py
rename to native-build/windows/launcher.py
index 1b7b4797..0203027f 100644
--- a/windows/launcher.py
+++ b/native-build/windows/launcher.py
@@ -1,6 +1,6 @@
"""Standalone Windows entry point (the PyInstaller entry script).
-Windows counterpart of ``linux/launcher.py``. There is no native menu-bar agent
+Windows counterpart of ``native-build/linux/launcher.py``. There is no native menu-bar agent
here (rumps/AppKit are macOS-only); instead the frozen binary is a small
multi-call launcher:
diff --git a/windows/paths.py b/native-build/windows/paths.py
similarity index 97%
rename from windows/paths.py
rename to native-build/windows/paths.py
index 702853f4..0e8de69e 100644
--- a/windows/paths.py
+++ b/native-build/windows/paths.py
@@ -1,6 +1,6 @@
"""Filesystem locations for the standalone Windows build.
-Mirrors ``linux/paths.py`` but uses Windows conventions (``%LOCALAPPDATA%``)
+Mirrors ``native-build/linux/paths.py`` but uses Windows conventions (``%LOCALAPPDATA%``)
instead of XDG directories.
Read-only resources (ONNX models, Flask templates/static, the bundled
@@ -37,7 +37,7 @@ def resource_root():
def _repo_root():
- return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+ return os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def tray_icon():
diff --git a/windows/supervisor.py b/native-build/windows/supervisor.py
similarity index 99%
rename from windows/supervisor.py
rename to native-build/windows/supervisor.py
index 81819d08..498012c0 100644
--- a/windows/supervisor.py
+++ b/native-build/windows/supervisor.py
@@ -1,6 +1,6 @@
"""Process supervisor for the standalone Windows app.
-Windows counterpart of ``linux/supervisor.py`` -- the logic is platform-agnostic
+Windows counterpart of ``native-build/linux/supervisor.py`` -- the logic is platform-agnostic
(stdlib ``subprocess``/``signal``/``os.kill`` + ``psutil``), so this is a near
copy that swaps ``linux`` path/env helpers for the ``windows`` ones and adapts
the control server to use a TCP socket instead of a Unix socket (Windows has no
diff --git a/windows/vendor/build-redis.bat b/native-build/windows/vendor/build-redis.bat
similarity index 97%
rename from windows/vendor/build-redis.bat
rename to native-build/windows/vendor/build-redis.bat
index f5f107eb..e50f525a 100644
--- a/windows/vendor/build-redis.bat
+++ b/native-build/windows/vendor/build-redis.bat
@@ -11,7 +11,7 @@ setlocal enabledelayedexpansion
set "ARCH=amd64"
if "%PROCESSOR_ARCHITECTURE%"=="ARM64" set "ARCH=arm64"
-set "DEST=windows\vendor\redis\%ARCH%"
+set "DEST=native-build\windows\vendor\redis\%ARCH%"
set "REDIS_VERSION=5.0.14.1"
set "ZIP=Redis-x64-%REDIS_VERSION%.zip"
set "URL=https://github.com/tporadowski/redis/releases/download/v%REDIS_VERSION%/%ZIP%"
diff --git a/windows/vendor/pg-contrib/amd64/extension/pg_trgm--1.0--1.1.sql b/native-build/windows/vendor/pg-contrib/amd64/extension/pg_trgm--1.0--1.1.sql
similarity index 100%
rename from windows/vendor/pg-contrib/amd64/extension/pg_trgm--1.0--1.1.sql
rename to native-build/windows/vendor/pg-contrib/amd64/extension/pg_trgm--1.0--1.1.sql
diff --git a/windows/vendor/pg-contrib/amd64/extension/pg_trgm--1.1--1.2.sql b/native-build/windows/vendor/pg-contrib/amd64/extension/pg_trgm--1.1--1.2.sql
similarity index 100%
rename from windows/vendor/pg-contrib/amd64/extension/pg_trgm--1.1--1.2.sql
rename to native-build/windows/vendor/pg-contrib/amd64/extension/pg_trgm--1.1--1.2.sql
diff --git a/windows/vendor/pg-contrib/amd64/extension/pg_trgm--1.2--1.3.sql b/native-build/windows/vendor/pg-contrib/amd64/extension/pg_trgm--1.2--1.3.sql
similarity index 100%
rename from windows/vendor/pg-contrib/amd64/extension/pg_trgm--1.2--1.3.sql
rename to native-build/windows/vendor/pg-contrib/amd64/extension/pg_trgm--1.2--1.3.sql
diff --git a/windows/vendor/pg-contrib/amd64/extension/pg_trgm--1.3--1.4.sql b/native-build/windows/vendor/pg-contrib/amd64/extension/pg_trgm--1.3--1.4.sql
similarity index 100%
rename from windows/vendor/pg-contrib/amd64/extension/pg_trgm--1.3--1.4.sql
rename to native-build/windows/vendor/pg-contrib/amd64/extension/pg_trgm--1.3--1.4.sql
diff --git a/windows/vendor/pg-contrib/amd64/extension/pg_trgm--1.3.sql b/native-build/windows/vendor/pg-contrib/amd64/extension/pg_trgm--1.3.sql
similarity index 100%
rename from windows/vendor/pg-contrib/amd64/extension/pg_trgm--1.3.sql
rename to native-build/windows/vendor/pg-contrib/amd64/extension/pg_trgm--1.3.sql
diff --git a/windows/vendor/pg-contrib/amd64/extension/pg_trgm--1.4--1.5.sql b/native-build/windows/vendor/pg-contrib/amd64/extension/pg_trgm--1.4--1.5.sql
similarity index 100%
rename from windows/vendor/pg-contrib/amd64/extension/pg_trgm--1.4--1.5.sql
rename to native-build/windows/vendor/pg-contrib/amd64/extension/pg_trgm--1.4--1.5.sql
diff --git a/windows/vendor/pg-contrib/amd64/extension/pg_trgm--1.5--1.6.sql b/native-build/windows/vendor/pg-contrib/amd64/extension/pg_trgm--1.5--1.6.sql
similarity index 100%
rename from windows/vendor/pg-contrib/amd64/extension/pg_trgm--1.5--1.6.sql
rename to native-build/windows/vendor/pg-contrib/amd64/extension/pg_trgm--1.5--1.6.sql
diff --git a/windows/vendor/pg-contrib/amd64/extension/pg_trgm.control b/native-build/windows/vendor/pg-contrib/amd64/extension/pg_trgm.control
similarity index 100%
rename from windows/vendor/pg-contrib/amd64/extension/pg_trgm.control
rename to native-build/windows/vendor/pg-contrib/amd64/extension/pg_trgm.control
diff --git a/windows/vendor/pg-contrib/amd64/extension/unaccent--1.0--1.1.sql b/native-build/windows/vendor/pg-contrib/amd64/extension/unaccent--1.0--1.1.sql
similarity index 100%
rename from windows/vendor/pg-contrib/amd64/extension/unaccent--1.0--1.1.sql
rename to native-build/windows/vendor/pg-contrib/amd64/extension/unaccent--1.0--1.1.sql
diff --git a/windows/vendor/pg-contrib/amd64/extension/unaccent--1.1.sql b/native-build/windows/vendor/pg-contrib/amd64/extension/unaccent--1.1.sql
similarity index 100%
rename from windows/vendor/pg-contrib/amd64/extension/unaccent--1.1.sql
rename to native-build/windows/vendor/pg-contrib/amd64/extension/unaccent--1.1.sql
diff --git a/windows/vendor/pg-contrib/amd64/extension/unaccent.control b/native-build/windows/vendor/pg-contrib/amd64/extension/unaccent.control
similarity index 100%
rename from windows/vendor/pg-contrib/amd64/extension/unaccent.control
rename to native-build/windows/vendor/pg-contrib/amd64/extension/unaccent.control
diff --git a/windows/vendor/pg-contrib/amd64/lib/pg_trgm.dll b/native-build/windows/vendor/pg-contrib/amd64/lib/pg_trgm.dll
similarity index 100%
rename from windows/vendor/pg-contrib/amd64/lib/pg_trgm.dll
rename to native-build/windows/vendor/pg-contrib/amd64/lib/pg_trgm.dll
diff --git a/windows/vendor/pg-contrib/amd64/lib/unaccent.dll b/native-build/windows/vendor/pg-contrib/amd64/lib/unaccent.dll
similarity index 100%
rename from windows/vendor/pg-contrib/amd64/lib/unaccent.dll
rename to native-build/windows/vendor/pg-contrib/amd64/lib/unaccent.dll
diff --git a/windows/vendor/pg-contrib/amd64/tsearch_data/unaccent.rules b/native-build/windows/vendor/pg-contrib/amd64/tsearch_data/unaccent.rules
similarity index 100%
rename from windows/vendor/pg-contrib/amd64/tsearch_data/unaccent.rules
rename to native-build/windows/vendor/pg-contrib/amd64/tsearch_data/unaccent.rules
diff --git a/windows/vendor/pg-contrib/build-pg-contrib-cross.sh b/native-build/windows/vendor/pg-contrib/build-pg-contrib-cross.sh
similarity index 90%
rename from windows/vendor/pg-contrib/build-pg-contrib-cross.sh
rename to native-build/windows/vendor/pg-contrib/build-pg-contrib-cross.sh
index 0fc7e6fd..651e8c25 100644
--- a/windows/vendor/pg-contrib/build-pg-contrib-cross.sh
+++ b/native-build/windows/vendor/pg-contrib/build-pg-contrib-cross.sh
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
-# Regenerate windows/vendor/pg-contrib// : run on Linux with gcc-mingw-w64-x86-64 + python3 + curl.
+# Regenerate native-build/windows/vendor/pg-contrib// : run on Linux with gcc-mingw-w64-x86-64 + python3 + curl.
set -euo pipefail
ARCH="amd64"
@@ -7,8 +7,8 @@ PG_VERSION="16.2"
PGSERVER_VERSION="0.1.4"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
-DEST="${DEST:-${REPO_ROOT}/windows/vendor/pg-contrib/${ARCH}}"
+REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../.." && pwd)"
+DEST="${DEST:-${REPO_ROOT}/native-build/windows/vendor/pg-contrib/${ARCH}}"
command -v x86_64-w64-mingw32-gcc >/dev/null || { echo "Need gcc-mingw-w64-x86-64 (apt install gcc-mingw-w64-x86-64)"; exit 1; }
diff --git a/numeric_bootstrap.py b/numeric_bootstrap.py
index 6db8461c..6ccb5020 100644
--- a/numeric_bootstrap.py
+++ b/numeric_bootstrap.py
@@ -15,9 +15,9 @@
locale transition there is to race against. It is kept narrow -- only the numeric
category, so ``LC_CTYPE``/UTF-8 (accented file paths) is untouched.
-This module is imported ONLY by the macOS launcher (``macos/launcher.py``); it is
+This module is imported ONLY by the macOS launcher (``native-build/macos/launcher.py``); it is
never referenced by the Linux/Docker worker entrypoints, so the container images
-are entirely unaffected. The matching env-level pin lives in ``macos/env.py``.
+are entirely unaffected. The matching env-level pin lives in ``native-build/macos/env.py``.
"""
import os
diff --git a/query/CLAMP3/clamp3_search_demo.py b/query/CLAMP3/clamp3_search_demo.py
deleted file mode 100644
index 1d59fcf8..00000000
--- a/query/CLAMP3/clamp3_search_demo.py
+++ /dev/null
@@ -1,557 +0,0 @@
-#!/usr/bin/env python3
-
-# source .venv/bin/activate
-# pip install -r requirements.txt
-"""
-CLAMP3 Audio Search Demo
-Standalone script for analyzing audio files and searching with natural language queries.
-Uses CLAMP3 model (SAAS version) for audio-text retrieval.
-"""
-
-print("Starting CLAMP3 demo script...")
-print("Importing dependencies...")
-
-import os
-import sys
-import numpy as np
-from pathlib import Path
-import torch
-import torch.nn as nn
-from transformers import AutoTokenizer, AutoModel, BertConfig, Wav2Vec2FeatureExtractor
-from tqdm import tqdm
-import librosa
-import warnings
-warnings.filterwarnings('ignore')
-
-print("✓ All imports successful!")
-
-
-# =============================================================================
-# CLAMP3 Configuration (matching the SAAS model)
-# =============================================================================
-
-TEXT_MODEL_NAME = "FacebookAI/xlm-roberta-base"
-CLAMP3_HIDDEN_SIZE = 768
-AUDIO_HIDDEN_SIZE = 768
-AUDIO_NUM_LAYERS = 12
-MAX_AUDIO_LENGTH = 128
-MAX_TEXT_LENGTH = 128
-
-
-# =============================================================================
-# Simplified CLaMP3 Model (inference only, no M3/symbolic encoder needed)
-# =============================================================================
-
-class CLaMP3AudioEncoder(nn.Module):
- """Simplified CLAMP3 model for audio-text retrieval."""
-
- def __init__(self, weights_path):
- super().__init__()
-
- # Load text model (XLM-RoBERTa)
- print("Loading text encoder (XLM-RoBERTa)...")
- print(" (This may take a few minutes on first run - downloading from HuggingFace)")
- self.text_model = AutoModel.from_pretrained(TEXT_MODEL_NAME)
- self.text_proj = nn.Linear(self.text_model.config.hidden_size, CLAMP3_HIDDEN_SIZE)
-
- # Load audio model (BERT-style transformer for MERT features)
- print("Loading audio encoder...")
- audio_config = BertConfig(
- vocab_size=1,
- hidden_size=AUDIO_HIDDEN_SIZE,
- num_hidden_layers=AUDIO_NUM_LAYERS,
- num_attention_heads=AUDIO_HIDDEN_SIZE // 64,
- intermediate_size=AUDIO_HIDDEN_SIZE * 4,
- max_position_embeddings=MAX_AUDIO_LENGTH
- )
- from transformers import BertModel
- self.audio_model = BertModel(audio_config)
- self.audio_proj = nn.Linear(audio_config.hidden_size, CLAMP3_HIDDEN_SIZE)
-
- # Load checkpoint
- print(f"Loading CLAMP3 weights from: {weights_path}")
- checkpoint = torch.load(weights_path, map_location='cpu')
-
- # Load state dict
- state_dict = checkpoint.get('model', checkpoint)
-
- # Filter and load only audio and text components
- text_model_dict = {k.replace('text_model.', ''): v for k, v in state_dict.items() if k.startswith('text_model.')}
- text_proj_dict = {k.replace('text_proj.', ''): v for k, v in state_dict.items() if k.startswith('text_proj.')}
- audio_model_dict = {k.replace('audio_model.', ''): v for k, v in state_dict.items() if k.startswith('audio_model.')}
- audio_proj_dict = {k.replace('audio_proj.', ''): v for k, v in state_dict.items() if k.startswith('audio_proj.')}
-
- print(f" - Text model params: {len(text_model_dict)}")
- print(f" - Text proj params: {len(text_proj_dict)}")
- print(f" - Audio model params: {len(audio_model_dict)}")
- 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(" Available keys in checkpoint:", list(state_dict.keys())[:10])
-
- self.text_model.load_state_dict(text_model_dict, strict=False)
- self.text_proj.load_state_dict(text_proj_dict)
- self.audio_model.load_state_dict(audio_model_dict)
- self.audio_proj.load_state_dict(audio_proj_dict)
-
- self.eval()
- print("✓ CLAMP3 model loaded successfully")
-
- def avg_pooling(self, features, masks):
- """Average pooling with mask."""
- masks = masks.unsqueeze(-1) # (batch, seq, 1)
- features = features * masks
- return features.sum(dim=1) / masks.sum(dim=1)
-
- def get_text_embedding(self, text_inputs, text_masks):
- """Get text embedding from text."""
- with torch.no_grad():
- text_features = self.text_model(text_inputs, attention_mask=text_masks)['last_hidden_state']
- text_features = self.avg_pooling(text_features, text_masks)
- text_features = self.text_proj(text_features)
- # Normalize
- text_features = text_features / text_features.norm(dim=-1, keepdim=True)
- return text_features
-
- def get_audio_embedding(self, audio_inputs, audio_masks):
- """Get audio embedding from MERT features."""
- with torch.no_grad():
- audio_features = self.audio_model(inputs_embeds=audio_inputs, attention_mask=audio_masks)['last_hidden_state']
- audio_features = self.avg_pooling(audio_features, audio_masks)
- audio_features = self.audio_proj(audio_features)
- # Normalize
- audio_features = audio_features / audio_features.norm(dim=-1, keepdim=True)
- return audio_features
-
-
-# =============================================================================
-# MERT Feature Extractor (with on-the-fly extraction)
-# =============================================================================
-
-class MERTFeatureExtractor:
- """MERT feature extractor for audio preprocessing."""
-
- def __init__(self, model_name='m-a-p/MERT-v1-95M', device='cpu',
- window_size=10, overlap_percent=50):
- """Initialize MERT model for feature extraction.
-
- Args:
- model_name: HuggingFace model name
- device: cpu or cuda
- window_size: Window size in seconds (default: 10)
- overlap_percent: Overlap percentage 0-100 (default: 50)
- """
- self.device = torch.device(device)
- self.target_sr = 24000
- self.window_size = window_size # seconds
- self.overlap_percent = overlap_percent
-
- print(f"Loading MERT model: {model_name}")
- print(f" Window: {window_size}s with {overlap_percent}% overlap")
- print(" (This may take a few minutes on first run - downloading ~400MB)")
- from transformers import AutoModel as HFAutoModel
- self.model = HFAutoModel.from_pretrained(model_name, trust_remote_code=True)
- self.model = self.model.to(self.device)
- self.model.eval()
-
- # Load MERT processor (Wav2Vec2 feature extractor) for proper preprocessing
- self.processor = Wav2Vec2FeatureExtractor(
- feature_size=1,
- sampling_rate=self.target_sr,
- padding_value=0.0,
- return_attention_mask=True,
- do_normalize=True,
- )
- print("✓ MERT model loaded")
-
- def load_audio(self, audio_path):
- """Load audio file and resample to target sample rate."""
- try:
- # Use librosa for MP3 compatibility (no torchcodec required)
- waveform, sr = librosa.load(audio_path, sr=self.target_sr, mono=True)
-
- # Convert to torch tensor
- waveform = torch.from_numpy(waveform).float()
-
- return waveform
- except Exception as e:
- print(f"Error loading audio {audio_path}: {e}")
- return None
-
- def extract_features(self, audio_path):
- """Extract MERT features from audio file.
-
- Returns: (num_windows, hidden_dim) array - one vector per window
- """
- # Load audio
- waveform = self.load_audio(audio_path)
- if waveform is None:
- return None
-
- # Get audio duration for debugging
- duration_sec = len(waveform) / self.target_sr
-
- # Process through Wav2Vec2FeatureExtractor for normalization
- processed_wav = self.processor(waveform.numpy(), return_tensors="pt",
- sampling_rate=self.target_sr).input_values[0]
- processed_wav = processed_wav.to(self.device)
-
- # Calculate stride based on overlap
- window_samples = int(self.target_sr * self.window_size)
- stride_samples = int(window_samples * (1 - self.overlap_percent / 100))
-
- all_features = []
-
- print(f" Audio: {duration_sec:.1f}s | Window: {self.window_size}s | Overlap: {self.overlap_percent}%", end=" ")
-
- with torch.no_grad():
- for start in range(0, processed_wav.shape[-1] - window_samples + 1, stride_samples):
- chunk = processed_wav[start:start + window_samples]
- chunk = chunk.unsqueeze(0) # Add batch dimension
-
- # Get all layers from MERT
- outputs = self.model(chunk, output_hidden_states=True)
- hidden_states = outputs.hidden_states # Tuple of (1, seq_len, hidden) per layer
-
- # Stack layers: (num_layers, 1, seq_len, hidden)
- hidden_states = torch.stack(hidden_states)
-
- # Average across TIME and LAYERS to get ONE vector per window
- features = hidden_states.mean(dim=2) # Average time: (num_layers, 1, hidden)
- features = features.mean(dim=0) # Average layers: (1, hidden)
-
- all_features.append(features.squeeze(0).cpu())
-
- if len(all_features) == 0:
- return None
-
- # Stack: (num_windows, hidden_dim)
- all_features = torch.stack(all_features)
- print(f"→ {len(all_features)} windows")
-
- return all_features.numpy()
-
-
-def extract_mert_features_simple(audio_path, mert_extractor=None):
- """
- Load pre-extracted MERT features (.npy files) or extract on-the-fly.
-
- Returns: (num_windows, hidden_dim) - multiple windows per song (NOT averaged)
- """
- npy_path = Path(audio_path).with_suffix('.npy')
-
- # Try to load pre-extracted features first
- if npy_path.exists():
- features = np.load(npy_path)
- features = torch.tensor(features).float()
- if features.ndim == 3:
- features = features.squeeze(0)
- if features.ndim == 1:
- features = features.unsqueeze(0)
- return features
-
- # Extract features on-the-fly if MERT extractor is provided
- if mert_extractor is not None:
- features = mert_extractor.extract_features(audio_path)
- if features is not None:
- # Save multi-window features (NOT averaged)
- npy_path.parent.mkdir(parents=True, exist_ok=True)
- np.save(npy_path, features)
- return torch.tensor(features).float()
-
-
-# =============================================================================
-# CLAMP3 Search System
-# =============================================================================
-
-class CLAMP3Searcher:
- """CLAMP3-based audio search system."""
-
- def __init__(self, model_path, device='cpu', extract_mert_on_fly=True):
- """Initialize CLAMP3 model."""
- self.device = torch.device(device)
- self.model = CLaMP3AudioEncoder(model_path).to(self.device)
- self.tokenizer = AutoTokenizer.from_pretrained(TEXT_MODEL_NAME)
-
- # Initialize MERT extractor if needed
- self.mert_extractor = None
- if extract_mert_on_fly:
- print("\nInitializing MERT feature extractor...")
- self.mert_extractor = MERTFeatureExtractor(device=device)
-
- # Cache for audio embeddings
- self.audio_embeddings = []
- self.audio_files = []
-
- def get_audio_embedding(self, audio_features):
- """Get CLAMP3 embedding for audio (from MERT features).
-
- Args:
- audio_features: (num_windows, hidden_dim) tensor from MERT
-
- Returns:
- Single embedding vector for the whole audio
- """
- if audio_features is None:
- return None
-
- # Add zero vectors at start and end (as in training)
- zero_vec = torch.zeros((1, audio_features.size(-1)))
- audio_features = torch.cat((zero_vec, audio_features, zero_vec), 0)
-
- total_length = audio_features.size(0)
-
- # If features fit in MAX_AUDIO_LENGTH, process at once
- if total_length <= MAX_AUDIO_LENGTH:
- # Pad to MAX_AUDIO_LENGTH
- pad_len = MAX_AUDIO_LENGTH - total_length
- if pad_len > 0:
- pad = torch.zeros((pad_len, audio_features.size(-1)))
- audio_features_padded = torch.cat((audio_features, pad), 0)
- else:
- audio_features_padded = audio_features
-
- # Create mask for actual content
- mask = torch.zeros(MAX_AUDIO_LENGTH)
- mask[:total_length] = 1
-
- # Get embedding from CLAMP3
- audio_features_padded = audio_features_padded.unsqueeze(0).to(self.device)
- mask = mask.unsqueeze(0).to(self.device)
-
- embedding = self.model.get_audio_embedding(audio_features_padded, mask)
- return embedding.cpu().numpy().flatten()
-
- else:
- # Song too long: split into chunks of MAX_AUDIO_LENGTH, get embeddings, average
- embeddings_list = []
-
- for start_idx in range(0, total_length, MAX_AUDIO_LENGTH):
- end_idx = min(start_idx + MAX_AUDIO_LENGTH, total_length)
- chunk = audio_features[start_idx:end_idx]
-
- # Pad chunk
- pad_len = MAX_AUDIO_LENGTH - chunk.size(0)
- if pad_len > 0:
- pad = torch.zeros((pad_len, chunk.size(-1)))
- chunk = torch.cat((chunk, pad), 0)
-
- # Create mask
- mask = torch.zeros(MAX_AUDIO_LENGTH)
- mask[:end_idx - start_idx] = 1
-
- # Get embedding
- chunk = chunk.unsqueeze(0).to(self.device)
- mask = mask.unsqueeze(0).to(self.device)
-
- embedding = self.model.get_audio_embedding(chunk, mask)
- embeddings_list.append(embedding)
-
- # Average all chunk embeddings
- final_embedding = torch.stack(embeddings_list).mean(dim=0)
- return final_embedding.cpu().numpy().flatten()
-
- def get_text_embedding(self, query_text):
- """Get CLAMP3 embedding for text query."""
- # Tokenize
- inputs = self.tokenizer(query_text, return_tensors='pt', max_length=MAX_TEXT_LENGTH,
- truncation=True, padding='max_length')
-
- text_inputs = inputs['input_ids'].to(self.device)
- text_masks = inputs['attention_mask'].to(self.device)
-
- # Get embedding
- embedding = self.model.get_text_embedding(text_inputs, text_masks)
- return embedding.cpu().numpy().flatten()
-
- def analyze_folder(self, folder_path):
- """Analyze all audio files in a folder."""
- folder = Path(folder_path)
-
- print(f"\nAnalyzing audio files in: {folder}")
- print("=" * 70)
-
- # Look for both pre-extracted .npy files and raw audio files
- npy_files = list(folder.glob('*.npy'))
- audio_exts = ['.mp3', '.wav', '.flac', '.ogg', '.m4a']
- raw_audio = []
- for ext in audio_exts:
- raw_audio.extend(folder.glob(f'*{ext}'))
-
- # Prioritize .npy files if they exist
- if npy_files:
- print(f"Found {len(npy_files)} pre-extracted MERT .npy file(s)")
- audio_files = npy_files
- 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" 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")
- audio_files = raw_audio
- process_as_npy = False
- else:
- print(f"No audio files found in {folder}")
- return
-
- print(f"Processing {len(audio_files)} file(s)...")
- print()
-
- # Analyze each file
- for audio_file in tqdm(sorted(audio_files), desc="Extracting embeddings"):
- if process_as_npy:
- # Load pre-extracted features
- features = np.load(audio_file)
- features = torch.tensor(features).float()
- if features.ndim == 3:
- features = features.squeeze(0)
- else:
- # Extract MERT features on-the-fly
- features = extract_mert_features_simple(audio_file, self.mert_extractor)
-
- if features is not None:
- embedding = self.get_audio_embedding(features)
- if embedding is not None:
- self.audio_embeddings.append(embedding)
- self.audio_files.append(audio_file)
-
- print()
- 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."""
- if not self.audio_embeddings:
- print("No audio files analyzed yet!")
- return []
-
- print(f"\n{'=' * 70}")
- print(f"Query: \"{query_text}\"")
- print(f"{'=' * 70}")
-
- # Get text embedding
- text_embedding = self.get_text_embedding(query_text)
-
- # Compute similarities with all audio files
- embeddings_matrix = np.vstack(self.audio_embeddings)
- similarities = embeddings_matrix @ text_embedding
-
- # Show score statistics for better understanding
- print(f"Score stats: min={similarities.min():.4f}, max={similarities.max():.4f}, "
- f"mean={similarities.mean():.4f}, std={similarities.std():.4f}")
-
- # Get top-k results
- top_indices = np.argsort(similarities)[::-1][:top_k]
-
- # Print results
- results = []
- for rank, idx in enumerate(top_indices, 1):
- similarity = similarities[idx]
- audio_file = self.audio_files[idx]
-
- # Calculate normalized score (0-100 scale for easier interpretation)
- score_range = similarities.max() - similarities.min()
- normalized_score = ((similarity - similarities.min()) / score_range * 100) if score_range > 0 else 50
-
- result = {
- 'rank': rank,
- 'file': audio_file.name,
- 'path': str(audio_file),
- 'similarity': float(similarity),
- 'normalized_score': float(normalized_score)
- }
- results.append(result)
-
- print(f"{rank}. {audio_file.stem}")
- print(f" Similarity: {similarity:.4f} | Normalized: {normalized_score:.1f}/100")
-
- return results
-
-
-def main():
- """Main function."""
- # Paths
- script_dir = Path(__file__).parent
- model_path = script_dir.parent.parent / "CLAMP3" / "weights_clamp3_saas_h_size_768_t_model_FacebookAI_xlm-roberta-base_t_length_128_a_size_768_a_layers_12_a_length_128_s_size_768_s_layers_12_p_size_64_p_length_512.pth"
-
- # Check for MERT features folder
- mert_folder = script_dir.parent.parent / "test" / "songs_mert"
- audio_folder = script_dir.parent.parent / "test" / "songs"
-
- # Check if model exists
- if not model_path.exists():
- print(f"ERROR: Model not found at {model_path}")
- print(f"\nPlease download the CLAMP3 SAAS model from:")
- print(f"https://huggingface.co/sander-wood/clamp3/blob/main/weights_clamp3_saas_h_size_768_t_model_FacebookAI_xlm-roberta-base_t_length_128_a_size_768_a_layers_12_a_length_128_s_size_768_s_layers_12_p_size_64_p_length_512.pth")
- sys.exit(1)
-
- print("=" * 70)
- print("CLAMP3 AUDIO SEARCH DEMO")
- print("=" * 70)
- print()
- print("This demo uses the CLAMP3 SAAS model for audio-text retrieval.")
- print()
- print("Requirements:")
- print(" 1. CLAMP3 SAAS weights (already found)")
- print(" 2. MERT-preprocessed audio features (.npy files)")
- print()
- print("=" * 70)
- print()
-
- # Detect CUDA
- device = 'cuda' if torch.cuda.is_available() else 'cpu'
- print(f"Using device: {device}")
- print()
-
- # Initialize searcher (with on-the-fly MERT extraction enabled)
- searcher = CLAMP3Searcher(str(model_path), device=device, extract_mert_on_fly=True)
-
- # Check if MERT features exist, otherwise process raw audio
- if mert_folder.exists():
- 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" 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}")
- sys.exit(1)
-
- # Search queries
- queries = [
- "relax ukulele song",
- "calm piano song",
- "Hard rock song",
- "slash bass song",
- "Classic music",
- "happy pop song",
- "Jazz song",
- "POP song with Female Vocalist"
- ]
-
- if len(searcher.audio_embeddings) > 0:
- print("\n" + "=" * 70)
- print("SEARCHING WITH TEXT QUERIES")
- print("=" * 70)
-
- all_results = {}
- for query in queries:
- results = searcher.search(query, top_k=3)
- all_results[query] = results
- print() # Add spacing between queries
-
- # Summary
- print("\n" + "=" * 70)
- print("SEARCH COMPLETE")
- print("=" * 70)
- print(f"Analyzed: {len(searcher.audio_files)} audio files")
- print(f"Queries: {len(queries)}")
- print()
-
-
-if __name__ == "__main__":
- main()
diff --git a/query/CLAMP3/requirements.txt b/query/CLAMP3/requirements.txt
deleted file mode 100644
index 47e9cd1b..00000000
--- a/query/CLAMP3/requirements.txt
+++ /dev/null
@@ -1,6 +0,0 @@
-numpy
-torch
-transformers
-tqdm
-accelerate
-librosa
diff --git a/query/throwaway_lyrics_search.py b/query/throwaway_lyrics_search.py
deleted file mode 100644
index c720372f..00000000
--- a/query/throwaway_lyrics_search.py
+++ /dev/null
@@ -1,96 +0,0 @@
-"""Throwaway sanity check for the gte-multilingual lyrics embedding.
-
-Invents 5 short songs in 5 languages (3 about love, 2 about war), embeds them
-with lyrics/gte_onnx.py, then ranks them by cosine similarity against the query
-word "love". Multilingual love songs should float to the top regardless of
-language; war songs should sink.
-
-Run from the repo root with the venv active:
- source .venv/bin/activate
- python throwaway_lyrics_search.py
-"""
-
-from __future__ import annotations
-
-import logging
-import os
-
-os.environ.setdefault('LYRICS_GTE_ONNX_PATH', 'model/gte-multilingual-base-int8.onnx')
-os.environ.setdefault('LYRICS_GTE_TOKENIZER_DIR', 'model/gte-multilingual-base')
-
-logging.basicConfig(level=logging.INFO, format='%(levelname)s %(name)s: %(message)s')
-log = logging.getLogger('lyrics-search-demo')
-
-import numpy as np
-
-from lyrics.gte_onnx import embed_text
-
-SONGS = {
- 'Italian - Love': (
- "Ti amo come il sole ama il mattino, "
- "il tuo nome è una carezza sul mio cuore. "
- "Resta con me per sempre, amore mio, "
- "le tue mani sono la mia casa."
- ),
- 'English - Love': (
- "I love you more than words can ever say, "
- "your gentle heart is the home where I belong. "
- "Hold me close and never let me go, "
- "our love is a tender, endless song."
- ),
- 'Spanish - Love': (
- "Te amo con toda mi alma y mi corazón, "
- "tu mirada es la luz de mi mañana. "
- "Eres mi amor, mi vida y mi canción, "
- "quédate conmigo cada semana."
- ),
- 'France - War': (
- "Les canons grondent sur le champ de bataille, "
- "les soldats tombent sous la fumée et le feu. "
- "La guerre dévore les villes en ruine, "
- "le sang coule sur la terre de Dieu."
- ),
- 'Chinese - War': (
- "战争的硝烟笼罩着大地,"
- "士兵在炮火中倒下流血。"
- "城市化为废墟,钢铁与火焰, "
- "战鼓敲响,敌人在前线厮杀。"
- ),
-}
-
-QUERIES = ['love', 'war', 'dog', 'amore', 'amor', '爱', 'guerra', '战争']
-
-
-def _search(query: str, song_vecs: dict) -> None:
- qvec = embed_text(query)
- ranked = sorted(
- ((name, float(np.dot(qvec, vec))) for name, vec in song_vecs.items()),
- key=lambda kv: kv[1],
- reverse=True,
- )
- log.info('=' * 56)
- log.info('SEARCH RESULTS for query %r (cosine, higher = closer):', query)
- log.info('%-6s %-18s %-8s %s', 'rank', 'song', 'score', 'theme')
- log.info('-' * 56)
- for i, (name, score) in enumerate(ranked, start=1):
- theme = 'LOVE' if name.endswith('Love') else 'WAR'
- log.info('%-6d %-18s %.4f %s', i, name, score, theme)
- log.info('=' * 56)
-
-
-def main() -> None:
- log.info('Embedding %d songs...', len(SONGS))
- song_vecs = {}
- for name, lyric in SONGS.items():
- vec = embed_text(lyric)
- song_vecs[name] = vec
- log.info('embedded %-16s dim=%s norm=%.4f', name, vec.shape[0],
- float(np.linalg.norm(vec)))
-
- for query in QUERIES:
- log.info('Embedding query word: %r', query)
- _search(query, song_vecs)
-
-
-if __name__ == '__main__':
- main()
diff --git a/requirements/common-noavx2.txt b/requirements/common-noavx2.txt
index 951b315a..971150ea 100644
--- a/requirements/common-noavx2.txt
+++ b/requirements/common-noavx2.txt
@@ -19,7 +19,6 @@ google-genai==1.57.0
mistralai>=1.11.1,<2.0.0
umap-learn
av==13.1.0
-python-mpd2
psutil
PyJWT==2.12.1
argon2-cffi==25.1.0
diff --git a/requirements/common.txt b/requirements/common.txt
index 4b8263ce..4675430e 100644
--- a/requirements/common.txt
+++ b/requirements/common.txt
@@ -19,7 +19,6 @@ google-genai==1.57.0
mistralai>=1.11.1,<2.0.0
umap-learn==0.5.12
av==13.1.0
-python-mpd2==3.1.1
psutil==7.2.2
onnx==1.20.0
resampy==0.4.3
diff --git a/requirements/linux.txt b/requirements/linux.txt
index e59de990..96df0050 100644
--- a/requirements/linux.txt
+++ b/requirements/linux.txt
@@ -7,11 +7,11 @@ onnxruntime==1.19.2
# pgserver ships a prebuilt PostgreSQL; 0.1.4 bundles PostgreSQL 16.2. Pinned
# because contrib modules are NOT ABI-stable across PG minors -- the vendored
-# unaccent/pg_trgm in linux/vendor/pg-contrib/ are compiled against 16.2 and a
+# unaccent/pg_trgm in native-build/linux/vendor/pg-contrib/ are compiled against 16.2 and a
# pgserver bump to a newer PG would break them at load time (see that README).
#
# x86_64 ONLY: pgserver publishes no Linux/aarch64 wheel, so on arm64 we bundle a
-# from-source PostgreSQL instead (linux/vendor/postgres/build-postgres.sh) and
+# from-source PostgreSQL instead (native-build/linux/vendor/postgres/build-postgres.sh) and
# this requirement is skipped. The marker makes `pip install -r` succeed on both.
pgserver==0.1.4; platform_machine == "x86_64"
diff --git a/requirements/macos.txt b/requirements/macos.txt
index c49ab72a..88f2092c 100644
--- a/requirements/macos.txt
+++ b/requirements/macos.txt
@@ -7,7 +7,7 @@ onnxruntime==1.19.2
# pgserver ships a prebuilt PostgreSQL; 0.1.4 bundles PostgreSQL 16.2. Pinned
# because contrib modules are NOT ABI-stable across PG minors -- the vendored
-# unaccent/pg_trgm in macos/vendor/pg-contrib/ are compiled against 16.2 and a
+# unaccent/pg_trgm in native-build/macos/vendor/pg-contrib/ are compiled against 16.2 and a
# pgserver bump to a newer PG would break them at load time (see that README).
pgserver==0.1.4
rumps
diff --git a/requirements/windows.txt b/requirements/windows.txt
index 2ed1e027..fd5b1dbb 100644
--- a/requirements/windows.txt
+++ b/requirements/windows.txt
@@ -6,7 +6,7 @@ onnxruntime==1.19.2
# pgserver ships a prebuilt PostgreSQL; 0.1.4 bundles PostgreSQL 16.2. Pinned
# because contrib modules are NOT ABI-stable across PG minors -- the vendored
-# unaccent/pg_trgm in windows/vendor/pg-contrib/ are compiled against 16.2.
+# unaccent/pg_trgm in native-build/windows/vendor/pg-contrib/ are compiled against 16.2.
pgserver==0.1.4
# Served by waitress (same WSGI server the macOS/Linux builds use); not in common.txt.
diff --git a/rq_janitor.py b/rq_janitor.py
index 299212f7..411558ff 100644
--- a/rq_janitor.py
+++ b/rq_janitor.py
@@ -7,7 +7,7 @@
try:
# We need the queue objects to get their registries
- from app_helper import redis_conn, rq_queue_high, rq_queue_default
+ from app_helper import rq_queue_high, rq_queue_default
from app_logging import configure_logging
except ImportError as e:
print(f"Error importing from app.py: {e}")
diff --git a/scripts/standalone/config.py b/scripts/standalone/config.py
index 26b52db7..f6347947 100644
--- a/scripts/standalone/config.py
+++ b/scripts/standalone/config.py
@@ -26,36 +26,36 @@ def read_app_version(root):
PLATFORMS = {
"windows": {
- "launcher": "windows/launcher.py",
- "vendor_dir": "windows/vendor",
+ "launcher": "native-build/windows/launcher.py",
+ "vendor_dir": "native-build/windows/vendor",
"redis_bin": "redis-server.exe",
"pg_contrib_glob": "*.dll",
"initdb_bin": "initdb.exe",
"use_pgserver": "always",
"console": True,
- "exe_icon": "windows/assets/AudioMuse-AI.ico",
- "extra_datas": [("windows/assets/AudioMuse-AI.ico", "assets")],
+ "exe_icon": "native-build/windows/assets/AudioMuse-AI.ico",
+ "extra_datas": [("native-build/windows/assets/AudioMuse-AI.ico", "assets")],
"extra_hiddenimports": ["macos.reverse_log", "pgserver.postgres_server"],
"collect_submodules": ["windows", "pystray", "PIL"],
"excludes_base": ["rumps", "AppKit", "Foundation", "objc"],
"bundle": None,
},
"macos": {
- "launcher": "macos/launcher.py",
- "vendor_dir": "macos/vendor",
+ "launcher": "native-build/macos/launcher.py",
+ "vendor_dir": "native-build/macos/vendor",
"redis_bin": "redis-server",
"pg_contrib_glob": "*.dylib",
"initdb_bin": "initdb",
"use_pgserver": "always",
"console": False,
"exe_icon": None,
- "extra_datas": [("macos/assets", "assets")],
+ "extra_datas": [("native-build/macos/assets", "assets")],
"extra_hiddenimports": ["numeric_bootstrap", "rumps"],
"collect_submodules": ["macos"],
"excludes_base": [],
"bundle": {
"name": "AudioMuse-AI.app",
- "icon": "macos/assets/AudioMuse-AI.icns",
+ "icon": "native-build/macos/assets/AudioMuse-AI.icns",
"bundle_identifier": "ai.audiomuse.standalone",
"info_plist": {
"LSUIElement": True,
@@ -66,8 +66,8 @@ def read_app_version(root):
},
},
"linux": {
- "launcher": "linux/launcher.py",
- "vendor_dir": "linux/vendor",
+ "launcher": "native-build/linux/launcher.py",
+ "vendor_dir": "native-build/linux/vendor",
"redis_bin": "redis-server",
"pg_contrib_glob": "*.so",
"initdb_bin": "initdb",
diff --git a/scripts/standalone/platforms/linux.py b/scripts/standalone/platforms/linux.py
index b078c277..8fcedec8 100644
--- a/scripts/standalone/platforms/linux.py
+++ b/scripts/standalone/platforms/linux.py
@@ -13,7 +13,7 @@ def _present(path):
def prepare(ctx):
arch = ctx.arch
- vendor = ctx.root / "linux" / "vendor"
+ vendor = ctx.root / "native-build" / "linux" / "vendor"
if ctx.use_pgserver:
required = [
vendor / "redis" / arch / "redis-server",
@@ -39,7 +39,7 @@ def prepare(ctx):
if missing:
for m in missing:
print(f"::error::Missing vendored file: {m}")
- raise SystemExit("Vendored inputs missing (see linux/vendor/*/README.md).")
+ raise SystemExit("Vendored inputs missing (see native-build/linux/vendor/*/README.md).")
(vendor / "redis" / arch / "redis-server").chmod(0o755)
@@ -66,7 +66,7 @@ def _stage(ctx):
(stage / "usr" / "lib" / "systemd" / "user").mkdir(parents=True)
subprocess.run(["cp", "-a", str(ctx.bundle_dir), str(stage / "opt" / "AudioMuse-AI")], check=True)
- pkg = ctx.root / "linux" / "packaging"
+ pkg = ctx.root / "native-build" / "linux" / "packaging"
shutil.copy2(pkg / "AudioMuse-AI.desktop", stage / "usr" / "share" / "applications" / "AudioMuse-AI.desktop")
shutil.copy2(pkg / "AudioMuse-AI-stop.desktop", stage / "usr" / "share" / "applications" / "AudioMuse-AI-stop.desktop")
shutil.copy2(pkg / "audiomuse-ai.service", stage / "usr" / "lib" / "systemd" / "user" / "audiomuse-ai.service")
@@ -91,7 +91,7 @@ def package(ctx):
nfpm_arch = _NFPM_ARCH[ctx.arch]
print("==> Generating nfpm config")
- template = (ctx.root / "linux" / "packaging" / "nfpm.yaml.in").read_text()
+ template = (ctx.root / "native-build" / "linux" / "packaging" / "nfpm.yaml.in").read_text()
content = (
template.replace("@VERSION@", ctx.version)
.replace("@ARCH@", nfpm_arch)
diff --git a/scripts/standalone/platforms/macos.py b/scripts/standalone/platforms/macos.py
index c7df7737..6356ab44 100644
--- a/scripts/standalone/platforms/macos.py
+++ b/scripts/standalone/platforms/macos.py
@@ -15,7 +15,7 @@
def prepare(ctx):
print("==> Generating icons from screenshot/audiomuseai.png")
- subprocess.run(["bash", "macos/make_icns.sh"], check=True, cwd=str(ctx.root))
+ subprocess.run(["bash", "native-build/macos/make_icns.sh"], check=True, cwd=str(ctx.root))
def _sign_nested(app, entitlements):
@@ -33,7 +33,7 @@ def _sign_nested(app, entitlements):
def package(ctx):
app = ctx.app_path
- entitlements = str(ctx.root / "macos" / "entitlements.plist")
+ entitlements = str(ctx.root / "native-build" / "macos" / "entitlements.plist")
_sign_nested(app, entitlements)
diff --git a/scripts/standalone/platforms/windows.py b/scripts/standalone/platforms/windows.py
index 5ee66db8..11efa49b 100644
--- a/scripts/standalone/platforms/windows.py
+++ b/scripts/standalone/platforms/windows.py
@@ -3,9 +3,9 @@
def prepare(ctx):
arch = ctx.arch
- pg_contrib = ctx.root / "windows" / "vendor" / "pg-contrib" / arch
+ pg_contrib = ctx.root / "native-build" / "windows" / "vendor" / "pg-contrib" / arch
required = [
- ctx.root / "windows" / "vendor" / "redis" / arch / "redis-server.exe",
+ ctx.root / "native-build" / "windows" / "vendor" / "redis" / arch / "redis-server.exe",
pg_contrib / "lib" / "unaccent.dll",
pg_contrib / "lib" / "pg_trgm.dll",
pg_contrib / "extension" / "unaccent.control",
@@ -16,7 +16,7 @@ def prepare(ctx):
if missing:
for m in missing:
print(f"[ERROR] Missing vendored file: {m}")
- raise SystemExit("Vendored inputs missing (see windows/vendor/README.md).")
+ raise SystemExit("Vendored inputs missing (see native-build/windows/vendor/README.md).")
def package(ctx):
diff --git a/static/setup.js b/static/setup.js
index 03c259a3..81694869 100644
--- a/static/setup.js
+++ b/static/setup.js
@@ -694,7 +694,7 @@ function updateMusicLibrariesHint() {
function collectMusicLibrariesValue() {
// Returns the MUSIC_LIBRARIES value to store, or null to skip writing.
if (!currentLibraryCheckboxes.length && !currentNoRestrictionCheckbox) {
- // Section isn't rendered (MPD or provider doesn't support it, or the
+ // Section isn't rendered (provider doesn't support it, or the
// fetch failed). Don't touch MUSIC_LIBRARIES.
return null;
}
diff --git a/tasks/alchemy_projections.py b/tasks/alchemy_projections.py
new file mode 100644
index 00000000..096f78c9
--- /dev/null
+++ b/tasks/alchemy_projections.py
@@ -0,0 +1,218 @@
+"""2D projection helpers shared by song_alchemy, app_map, and app_helper.
+
+These functions are pure numpy/sklearn math with no dependency on the
+voyager index, the media server layer, or the database, so consumers that
+only need a projection (e.g. the music map) can import this module without
+pulling the full song_alchemy import chain.
+"""
+
+import logging
+from typing import List, Tuple
+
+import numpy as np
+
+try:
+ # sklearn is already a dependency; import lazily for environments where it's present
+ from sklearn.decomposition import PCA
+ from sklearn.linear_model import LogisticRegression
+except Exception:
+ PCA = None
+ LogisticRegression = None
+
+logger = logging.getLogger(__name__)
+
+
+def _project_to_2d(vectors: List[np.ndarray]) -> List[Tuple[float, float]]:
+ """Simple PCA via SVD to project a list of vectors to 2D.
+ Returns a list of (x, y) tuples in the same order as input vectors.
+ If there are fewer than 2 vectors, returns zeros for all.
+ """
+ if not vectors:
+ return []
+ mat = np.vstack(vectors)
+ # Center
+ mean = np.mean(mat, axis=0)
+ mat_c = mat - mean
+ # SVD
+ try:
+ _, _, vh = np.linalg.svd(mat_c, full_matrices=False)
+ except Exception:
+ # Fallback: return zeros
+ return [(0.0, 0.0) for _ in vectors]
+ # Take first two principal components
+ pcs = vh[:2]
+ proj = mat_c.dot(pcs.T)
+ # Normalize projection for nicer plotting
+ if proj.size == 0:
+ return [(0.0, 0.0) for _ in vectors]
+ # Normalize preserving aspect ratio: use a single global scale so x/y units are comparable
+ # center at zero
+ proj_centered = proj - proj.mean(axis=0)
+ max_abs = np.max(np.abs(proj_centered))
+ if max_abs == 0:
+ return [(0.0, 0.0) for _ in vectors]
+ scaled = proj_centered / max_abs
+ # clamp to [-1,1] for safety
+ scaled = np.clip(scaled, -1.0, 1.0)
+ return [(float(x), float(y)) for x, y in scaled]
+
+
+def _project_aligned_add_sub(vectors: List[np.ndarray], add_centroid: np.ndarray, subtract_centroid: np.ndarray) -> List[Tuple[float, float]]:
+ """Project vectors to 2D where the x-axis is aligned with the vector
+ from add_centroid -> subtract_centroid. The y-axis is the leading
+ orthogonal component (first PC of residuals).
+ This emphasizes separation along the add-vs-subtract direction.
+ """
+ if not vectors:
+ return []
+ # Convert list to matrix and center relative to add_centroid
+ mat = np.vstack(vectors)
+ rel = mat - add_centroid
+ axis = subtract_centroid - add_centroid
+ axis_norm = np.linalg.norm(axis)
+ if axis_norm == 0:
+ # Fallback to PCA if centroids coincide
+ return _project_to_2d(vectors)
+ axis_u = axis / axis_norm
+
+ # Compute x coordinates as projection on axis
+ x_coords = rel.dot(axis_u)
+
+ # Remove axis component to get residuals for y-axis computation
+ proj_on_axis = np.outer(x_coords, axis_u)
+ residuals = rel - proj_on_axis
+
+ # Find leading direction in residuals via SVD
+ try:
+ # If residuals are all near-zero, SVD will still succeed but produce small values
+ _, _, vh = np.linalg.svd(residuals, full_matrices=False)
+ y_u = vh[0]
+ except Exception:
+ y_u = None
+
+ if y_u is None or np.linalg.norm(y_u) == 0:
+ # Create an arbitrary orthogonal vector to axis_u
+ # pick an index where axis_u has smallest absolute value
+ idx = int(np.argmin(np.abs(axis_u)))
+ e = np.zeros_like(axis_u)
+ e[idx] = 1.0
+ y_u = e - np.dot(e, axis_u) * axis_u
+ norm_y = np.linalg.norm(y_u)
+ if norm_y == 0:
+ # fallback
+ return _project_to_2d(vectors)
+ y_u = y_u / norm_y
+ else:
+ # ensure orthogonal to axis_u (numerical stability)
+ y_u = y_u - np.dot(y_u, axis_u) * axis_u
+ y_u_norm = np.linalg.norm(y_u)
+ if y_u_norm == 0:
+ return _project_to_2d(vectors)
+ y_u = y_u / y_u_norm
+
+ y_coords = residuals.dot(y_u)
+
+ coords = np.vstack([x_coords, y_coords]).T
+ # Center and scale uniformly so x and y share same units
+ coords_centered = coords - coords.mean(axis=0)
+ max_abs = np.max(np.abs(coords_centered))
+ if max_abs == 0:
+ return [(0.0, 0.0) for _ in vectors]
+ scaled = coords_centered / max_abs
+ scaled = np.clip(scaled, -1.0, 1.0)
+ return [(float(x), float(y)) for x, y in scaled]
+
+
+def _project_with_umap(vectors: List[np.ndarray], n_components: int = 2) -> List[Tuple[float, float]]:
+ """Try to project using UMAP if available. Raises ImportError if umap is not installed."""
+ import umap
+ if not vectors:
+ return []
+ mat = np.vstack(vectors)
+ reducer = umap.UMAP(n_components=n_components, random_state=None, n_jobs=-1)
+ embedding = reducer.fit_transform(mat)
+ # Center and scale uniformly so x and y share same units
+ emb_centered = embedding - embedding.mean(axis=0)
+ max_abs = np.max(np.abs(emb_centered))
+ if max_abs == 0:
+ return [(0.0, 0.0) for _ in vectors]
+ scaled = emb_centered / max_abs
+ scaled = np.clip(scaled, -1.0, 1.0)
+ return [(float(x), float(y)) for x, y in scaled]
+
+
+def _project_with_discriminant(add_vectors: List[np.ndarray], sub_vectors: List[np.ndarray], all_vectors: List[np.ndarray]) -> List[Tuple[float, float]]:
+ """Compute a discriminant direction separating add and sub using PCA+LogisticRegression.
+ Returns 2D coords for all_vectors projected onto (discriminant axis, residual axis).
+ Falls back (raises) if sklearn not available or insufficient samples.
+ """
+ if LogisticRegression is None or PCA is None:
+ raise RuntimeError('sklearn not available')
+ # Need at least one sample in each class
+ if not add_vectors or not sub_vectors:
+ raise RuntimeError('Insufficient classes for discriminant')
+
+ X_train = np.vstack([np.vstack(add_vectors), np.vstack(sub_vectors)])
+ y_train = np.array([1] * len(add_vectors) + [0] * len(sub_vectors))
+
+ n_samples, n_features = X_train.shape
+ # Reduce dimensionality so training is stable (components <= n_samples-1)
+ max_components = min(32, n_samples - 1, n_features)
+ if max_components < 1:
+ raise RuntimeError('Not enough samples for discriminant PCA')
+
+ pca = PCA(n_components=max_components, random_state=42)
+ x_pca = pca.fit_transform(X_train)
+
+ # Fit logistic regression with regularization for robustness
+ try:
+ clf = LogisticRegression(l1_ratio=0, C=1.0, solver='saga', max_iter=1000)
+ clf.fit(x_pca, y_train)
+ except Exception:
+ # Fallback with less regularization if solver fails
+ clf = LogisticRegression(l1_ratio=0, C=0.1, solver='saga', max_iter=1000)
+ clf.fit(x_pca, y_train)
+
+ # direction in PCA space
+ coef = clf.coef_.ravel()
+ norm = np.linalg.norm(coef)
+ if norm == 0:
+ raise RuntimeError('Discriminant produced zero vector')
+ dir_pca = coef / norm
+
+ # Project all vectors into PCA space then onto discriminant for x coords
+ all_mat = np.vstack(all_vectors)
+ all_pca = pca.transform(all_mat)
+ x_coords = all_pca.dot(dir_pca)
+
+ # Residuals in PCA space
+ proj_on_dir = np.outer(x_coords, dir_pca)
+ residuals = all_pca - proj_on_dir
+ # y direction: leading PC of residuals
+ try:
+ _, _, vh = np.linalg.svd(residuals, full_matrices=False)
+ y_u = vh[0]
+ except Exception:
+ y_u = None
+
+ if y_u is None or np.linalg.norm(y_u) == 0:
+ # fallback: arbitrary orthogonal
+ idx = int(np.argmin(np.abs(dir_pca)))
+ e = np.zeros_like(dir_pca)
+ e[idx] = 1.0
+ y_u = e - np.dot(e, dir_pca) * dir_pca
+ y_u = y_u / (np.linalg.norm(y_u) or 1.0)
+ else:
+ y_u = y_u - np.dot(y_u, dir_pca) * dir_pca
+ y_u = y_u / (np.linalg.norm(y_u) or 1.0)
+
+ y_coords = residuals.dot(y_u)
+
+ coords = np.vstack([x_coords, y_coords]).T
+ coords_centered = coords - coords.mean(axis=0)
+ max_abs = np.max(np.abs(coords_centered))
+ if max_abs == 0:
+ return [(0.0, 0.0) for _ in all_vectors]
+ scaled = coords_centered / max_abs
+ scaled = np.clip(scaled, -1.0, 1.0)
+ return [(float(x), float(y)) for x, y in scaled]
diff --git a/tasks/analysis.py b/tasks/analysis.py
index f7b7e3bd..66f3e2a9 100644
--- a/tasks/analysis.py
+++ b/tasks/analysis.py
@@ -33,11 +33,6 @@
# Import other project modules
-from .voyager_manager import build_and_store_voyager_index
-from .clap_text_search import build_and_store_clap_index
-from .lyrics_manager import build_and_store_lyrics_index, build_and_store_lyrics_axes_index
-from .sem_grove_manager import build_and_store_sem_grove_index
-from .artist_gmm_manager import build_and_store_artist_index
from .mediaserver import get_recent_albums, get_tracks_from_album, download_track
from .memory_utils import (
cleanup_cuda_memory,
@@ -132,7 +127,18 @@ def _run_all_index_builds(log_fn=None):
shows which builder is currently active (otherwise users see "Building
CLAP text search index..." for the entire 95–97 % window even while the
lyrics or SemGrove builds are running).
+
+ The index-builder modules are imported here rather than at module top so
+ that importing ``tasks.analysis`` does not pull in the voyager / CLAP /
+ lyrics / SemGrove / artist-GMM subsystems; they are only needed when a
+ rebuild actually runs.
"""
+ from .voyager_manager import build_and_store_voyager_index
+ from .clap_text_search import build_and_store_clap_index
+ from .lyrics_manager import build_and_store_lyrics_index, build_and_store_lyrics_axes_index
+ from .sem_grove_manager import build_and_store_sem_grove_index
+ from .artist_gmm_manager import build_and_store_artist_index
+
def _step(label, fn, progress=None, banner=None, fatal=False):
if log_fn and progress is not None and banner is not None:
try:
diff --git a/tasks/artist_gmm_manager.py b/tasks/artist_gmm_manager.py
index be4f661a..eef333e8 100644
--- a/tasks/artist_gmm_manager.py
+++ b/tasks/artist_gmm_manager.py
@@ -536,7 +536,7 @@ def build_and_store_artist_index(db_conn=None):
num_parts = len(parts)
logger.info(f"Artist index size {len(index_bytes)} exceeds {ARTIST_INDEX_MAX_PART_SIZE_MB}MB - storing as {num_parts} segmented rows.")
- insert_q = "INSERT INTO artist_index_data (index_name, index_data, artist_map_json, gmm_params_json, created_at) VALUES (%s, %s, %s, %s, NOW())"
+ insert_q = "INSERT INTO artist_index_data (index_name, index_data, artist_map_json, gmm_params_json, created_at) VALUES (%s, %s, %s, %s, NOW()) ON CONFLICT (index_name) DO UPDATE SET index_data = EXCLUDED.index_data, artist_map_json = EXCLUDED.artist_map_json, gmm_params_json = EXCLUDED.gmm_params_json, created_at = EXCLUDED.created_at"
for idx, part in enumerate(parts, start=1):
part_name = f"{ARTIST_INDEX_NAME}_{idx}_{num_parts}"
cur.execute(insert_q, (part_name, part, '', ''))
diff --git a/tasks/clap_text_search.py b/tasks/clap_text_search.py
index 34e7010d..dc99cba0 100644
--- a/tasks/clap_text_search.py
+++ b/tasks/clap_text_search.py
@@ -3,12 +3,8 @@
Provides in-memory caching and fast text-based music search using CLAP embeddings.
"""
-import gc
-import json
import logging
-import re
import sys
-import tempfile
import threading
import time
@@ -60,25 +56,8 @@ def get_clap_cache_size() -> int:
def _fetch_clap_metadata(item_ids: list) -> Dict[str, Dict[str, str]]:
- """Fetch metadata for CLAP result item_ids from the database."""
- metadata_map: Dict[str, Dict[str, str]] = {}
- if not item_ids:
- return metadata_map
-
- from app_helper import get_score_data_by_ids
- try:
- track_details_list = get_score_data_by_ids(item_ids)
- for row in track_details_list:
- item_id = row['item_id']
- metadata_map[item_id] = {
- 'title': row.get('title', ''),
- 'author': row.get('author', ''),
- 'album': row.get('album', ''),
- }
- except Exception:
- pass
-
- return metadata_map
+ from .commons import fetch_track_metadata_map
+ return fetch_track_metadata_map(item_ids)
def _load_clap_index_from_db() -> bool:
@@ -86,119 +65,26 @@ def _load_clap_index_from_db() -> bool:
from app_helper import get_db
from config import CLAP_EMBEDDING_DIMENSION, VOYAGER_QUERY_EF
+ from .index_build_helpers import load_voyager_index_from_db
try:
- conn = get_db()
- with conn.cursor() as cur:
- cur.execute("SET LOCAL statement_timeout = 0")
- cur.execute(
- "SELECT index_data, id_map_json, embedding_dimension FROM clap_index_data WHERE index_name = %s",
- ('clap_index',)
- )
- row = cur.fetchone()
-
- index_stream = None
- try:
- if row:
- index_binary_data, id_map_json, db_embedding_dim = row
- index_stream = tempfile.TemporaryFile()
- index_stream.write(index_binary_data)
- index_stream.seek(0)
- else:
- seg_pattern = re.compile(r'^clap_index_(\d+)_(\d+)$')
- parts = []
- total_expected = None
- with conn.cursor(name='clap_index_segments') as seg_cur:
- seg_cur.itersize = 50
- seg_cur.execute(
- "SELECT index_name, index_data, id_map_json, embedding_dimension FROM clap_index_data WHERE index_name LIKE %s ESCAPE '\\'",
- (r'clap_index\_%\_%',)
- )
- for name, part_data, part_id_map_json, part_dim in seg_cur:
- m = seg_pattern.match(name)
- if not m:
- continue
- part_no = int(m.group(1))
- total = int(m.group(2))
- if total_expected is None:
- total_expected = total
- elif total_expected != total:
- logger.error(f"Segment total mismatch for CLAP index parts ({total_expected} vs {total}).")
- return False
- parts.append((part_no, part_data, part_id_map_json, part_dim))
-
- if total_expected is None or len(parts) != total_expected:
- logger.error(f"Incomplete CLAP index segments: expected {total_expected}, found {len(parts)}.")
- return False
-
- parts.sort(key=lambda p: p[0])
- from .index_build_helpers import reassemble_segmented_id_map
- id_map_json_candidate = reassemble_segmented_id_map((p[0], p[2]) for p in parts)
- for _, _, _, part_dim in parts:
- if part_dim != CLAP_EMBEDDING_DIMENSION:
- logger.error(f"CLAP index embedding_dimension mismatch in segmented parts: expected {CLAP_EMBEDDING_DIMENSION}, got {part_dim}.")
- return False
-
- if not id_map_json_candidate:
- logger.error("No id_map_json found in segmented CLAP index rows.")
- return False
-
- db_embedding_dim = parts[0][3]
- index_stream = tempfile.TemporaryFile()
- for _, part_data, _, _ in parts:
- index_stream.write(part_data)
- index_stream.seek(0)
- id_map_json = id_map_json_candidate
-
- if index_stream is None:
- logger.error("CLAP index binary data was empty.")
- return False
-
- if db_embedding_dim != CLAP_EMBEDDING_DIMENSION:
- logger.error(f"CLAP index dimension mismatch: db={db_embedding_dim} expected={CLAP_EMBEDDING_DIMENSION}")
- index_stream.close()
- return False
-
- try:
- try:
- import voyager # type: ignore
- except ImportError:
- logger.warning("Voyager library is unavailable; cannot load persisted CLAP index.")
- return False
-
- loaded_index = voyager.Index.load(index_stream)
- loaded_index.ef = VOYAGER_QUERY_EF
- finally:
- if index_stream is not None:
- try:
- index_stream.close()
- except Exception as close_error:
- logger.warning("Failed to close CLAP index stream: %s", close_error, exc_info=True)
-
- except Exception:
- if index_stream is not None:
- try:
- index_stream.close()
- except Exception:
- pass
- raise
-
- id_map = {int(k): v for k, v in json.loads(id_map_json).items()}
- reverse_id_map = {v: k for k, v in id_map.items()}
-
- if not id_map:
- logger.error("CLAP index id_map is empty.")
- return False
-
- _CLAP_CACHE['loaded'] = True
-
- _CLAP_INDEX_CACHE['index'] = loaded_index
- _CLAP_INDEX_CACHE['id_map'] = id_map
- _CLAP_INDEX_CACHE['reverse_id_map'] = reverse_id_map
- _CLAP_INDEX_CACHE['loaded'] = True
-
- logger.info(f"CLAP index loaded from database with {len(id_map)} items.")
- return True
+ loaded = load_voyager_index_from_db(
+ get_db(), 'clap_index_data', 'clap_index',
+ CLAP_EMBEDDING_DIMENSION, VOYAGER_QUERY_EF, label='CLAP',
+ )
+ if loaded is None:
+ return False
+ loaded_index, id_map, reverse_id_map = loaded
+
+ _CLAP_CACHE['loaded'] = True
+
+ _CLAP_INDEX_CACHE['index'] = loaded_index
+ _CLAP_INDEX_CACHE['id_map'] = id_map
+ _CLAP_INDEX_CACHE['reverse_id_map'] = reverse_id_map
+ _CLAP_INDEX_CACHE['loaded'] = True
+
+ logger.info(f"CLAP index loaded from database with {len(id_map)} items.")
+ return True
except Exception as e:
logger.error(f"Failed to load CLAP index from DB: {e}", exc_info=True)
return False
@@ -208,63 +94,21 @@ def build_and_store_clap_index(db_conn=None):
"""Build a CLAP text search voyager index from stored CLAP embeddings and save it to the DB."""
from app_helper import get_db
from config import CLAP_EMBEDDING_DIMENSION, VOYAGER_METRIC
- from .index_build_helpers import (
- iter_embedding_batches,
- build_voyager_index_bytes_streaming,
- store_voyager_index_segmented,
- build_id_map,
- EmptyIndexError,
- )
-
- try:
- import voyager # type: ignore # noqa: F401
- except ImportError:
- logger.warning("Voyager library is unavailable; cannot build CLAP index.")
- return False
+ from .index_build_helpers import build_and_store_index_streaming
if db_conn is None:
db_conn = get_db()
- try:
- logger.info("Building CLAP voyager index (streaming)...")
- batches = iter_embedding_batches(
- table="clap_embedding",
- column="embedding",
- dim=CLAP_EMBEDDING_DIMENSION,
- )
- try:
- index_bytes, item_ids = build_voyager_index_bytes_streaming(
- batches, CLAP_EMBEDDING_DIMENSION, metric=VOYAGER_METRIC,
- )
- except EmptyIndexError as ve:
- logger.warning(f"No valid CLAP embedding vectors found for CLAP index build: {ve}")
- return False
- gc.collect()
-
- if not index_bytes:
- logger.error("Generated CLAP index binary is empty. Aborting storage.")
- return False
-
- id_map = build_id_map(item_ids)
- store_voyager_index_segmented(
- db_conn,
- target_table="clap_index_data",
- index_name="clap_index",
- index_bytes=index_bytes,
- id_map=id_map,
- embedding_dimension=CLAP_EMBEDDING_DIMENSION,
- )
-
- db_conn.commit()
- logger.info("CLAP text search index build successful.")
- return True
- except Exception as e:
- logger.error(f"Failed to build and store CLAP index: {e}", exc_info=True)
- try:
- db_conn.rollback()
- except Exception:
- pass
- return False
+ return build_and_store_index_streaming(
+ db_conn,
+ source_table="clap_embedding",
+ source_column="embedding",
+ dim=CLAP_EMBEDDING_DIMENSION,
+ target_table="clap_index_data",
+ index_name="clap_index",
+ metric=VOYAGER_METRIC,
+ label="CLAP",
+ )
def _unload_timer_worker():
@@ -360,9 +204,8 @@ def load_clap_cache_from_db():
Returns True if successful, False otherwise.
"""
- from app_helper import get_db
from config import CLAP_ENABLED
-
+
if not CLAP_ENABLED:
logger.info("CLAP is disabled, skipping cache load.")
return False
diff --git a/tasks/cleaning.py b/tasks/cleaning.py
index ed3e9517..0f61a365 100644
--- a/tasks/cleaning.py
+++ b/tasks/cleaning.py
@@ -28,7 +28,7 @@ def identify_and_clean_orphaned_albums_task():
Main RQ task to identify and automatically clean orphaned albums from the database.
This combines identification and deletion into a single automated process.
"""
- from app import app
+ from flask_app import app
from app_helper import redis_conn, get_db, save_task_status, TASK_STATUS_STARTED, TASK_STATUS_PROGRESS, TASK_STATUS_SUCCESS, TASK_STATUS_FAILURE
current_job = get_current_job(redis_conn)
@@ -276,7 +276,7 @@ def delete_orphaned_albums_sync(orphaned_track_ids):
Returns:
dict: Result summary with deletion statistics
"""
- from app import get_db
+ from app_helper import get_db
if not orphaned_track_ids:
return {"status": "SUCCESS", "message": "No tracks to delete", "deleted_count": 0}
diff --git a/tasks/clustering.py b/tasks/clustering.py
index 69736405..f03395d6 100644
--- a/tasks/clustering.py
+++ b/tasks/clustering.py
@@ -54,7 +54,7 @@
def batch_task_failure_handler(job, connection, type, value, tb):
"""A failure handler for the clustering batch sub-task, executed by the worker."""
- from app import app
+ from flask_app import app
from app_helper import save_task_status, TASK_STATUS_FAILURE
with app.app_context():
task_id = getattr(job, 'id', None) or getattr(job, 'get_id', lambda: None)()
@@ -133,7 +133,7 @@ def run_clustering_batch_task(
Executes a batch of clustering iterations. This task is enqueued by the main clustering task.
"""
# --- Local imports to prevent circular dependency ---
- from app import app
+ from flask_app import app
from app_helper import (redis_conn, save_task_status, get_task_info_from_db,
TASK_STATUS_PROGRESS, TASK_STATUS_REVOKED, TASK_STATUS_FAILURE,
TASK_STATUS_SUCCESS)
@@ -275,7 +275,7 @@ def run_clustering_task(
Orchestrates data preparation, batch job creation, result aggregation, and playlist creation.
"""
# --- Local imports to prevent circular dependency ---
- from app import app
+ from flask_app import app
from app_helper import redis_conn, get_db, save_task_status, get_task_info_from_db, update_playlist_table, get_child_tasks_from_db, TASK_STATUS_STARTED, TASK_STATUS_PROGRESS, TASK_STATUS_SUCCESS, TASK_STATUS_FAILURE, TASK_STATUS_REVOKED
current_job = get_current_job(redis_conn)
@@ -668,7 +668,7 @@ def _monitor_and_process_batches(state_dict, parent_task_id, initial_check=False
CRITICAL: This prevents the main task from hanging at 4980/5000 runs
by implementing timeouts and forced progress tracking.
"""
- 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
+ from app_helper import redis_conn, get_child_tasks_from_db, TASK_STATUS_SUCCESS, TASK_STATUS_FAILURE, TASK_STATUS_REVOKED, TASK_STATUS_STARTED
current_time = time.time()
timeout_seconds = CLUSTERING_BATCH_TIMEOUT_MINUTES * 60
diff --git a/tasks/clustering_helper.py b/tasks/clustering_helper.py
index 3ce5e236..c14598c8 100644
--- a/tasks/clustering_helper.py
+++ b/tasks/clustering_helper.py
@@ -113,7 +113,7 @@ def _perform_single_clustering_iteration(
"""
try:
# Local import to prevent circular dependency
- from app import app
+ from flask_app import app
if not item_ids_for_subset:
logger.warning(f"{log_prefix} Iteration {run_idx}: Received empty item ID subset. Skipping.")
@@ -717,7 +717,8 @@ def get_job_result_safely(job_id, parent_task_id, task_type="child task"):
'final_subset_track_ids') so the caller can use a single code path, or None on failure.
"""
# Local imports to prevent circular dependency
- from app import app, JobStatus
+ from flask_app import app
+ from rq.job import JobStatus
from app_helper import redis_conn, get_task_info_from_db, TASK_STATUS_SUCCESS
try:
diff --git a/tasks/commons.py b/tasks/commons.py
index 7bae8028..e77e0ef0 100644
--- a/tasks/commons.py
+++ b/tasks/commons.py
@@ -1,5 +1,7 @@
# tasks/commons.py
+import logging
+
import numpy as np
# Import necessary constants from config
@@ -8,6 +10,31 @@
ENERGY_MAX, ENERGY_MIN
)
+logger = logging.getLogger(__name__)
+
+
+def fetch_track_metadata_map(item_ids):
+ """Fetch {item_id: {title, author, album}} from the score table.
+
+ Shared by the CLAP, lyrics, and SemGrove search paths. Returns an empty
+ dict (and logs a warning) when the lookup fails, so callers can always
+ treat the result as a best-effort metadata overlay.
+ """
+ metadata_map = {}
+ if not item_ids:
+ return metadata_map
+ from app_helper import get_score_data_by_ids
+ try:
+ for row in get_score_data_by_ids(item_ids):
+ metadata_map[row['item_id']] = {
+ 'title': row.get('title', '') or '',
+ 'author': row.get('author', '') or '',
+ 'album': row.get('album', '') or '',
+ }
+ except Exception as e:
+ logger.warning(f"Failed to fetch track metadata: {e}")
+ return metadata_map
+
def score_vector(row, mood_labels_list, other_feature_labels_list): # other_feature_labels_list is now passed
"""Converts a database row into a numerical feature vector for clustering."""
# Extract features from the database row
diff --git a/tasks/index_build_helpers.py b/tasks/index_build_helpers.py
index a4ab18ab..2ab8b276 100644
--- a/tasks/index_build_helpers.py
+++ b/tasks/index_build_helpers.py
@@ -367,81 +367,6 @@ def _resolve_voyager_space(metric: str):
return voyager.Space.Cosine
-def build_voyager_index_bytes(
- buf: np.ndarray,
- dim: int,
- metric: str = "angular",
- m: Optional[int] = None,
- ef_construction: Optional[int] = None,
-) -> bytes:
- """Build a Voyager HNSW index over ``buf`` and serialize it to bytes.
-
- Item ids are assigned densely as ``0..N-1``; callers persist their own
- ``{voyager_id: item_id}`` map alongside the index bytes (see
- ``store_voyager_index_segmented``).
-
- Args:
- buf: contiguous ``np.ndarray`` of shape ``(N, dim)`` and dtype
- ``float32``. N must be >= 1.
- dim: number of dimensions, must equal ``buf.shape[1]``.
- metric: ``"angular"`` (cosine), ``"euclidean"``, or ``"dot"``.
- m, ef_construction: HNSW graph parameters. Default to the values in
- ``config.VOYAGER_M`` / ``config.VOYAGER_EF_CONSTRUCTION``.
-
- Returns:
- Serialized index bytes.
-
- Raises:
- ImportError: if the ``voyager`` library is not installed.
- ValueError: if ``buf`` is empty or has the wrong shape/dtype.
- """
- import voyager
-
- if not isinstance(buf, np.ndarray) or buf.ndim != 2:
- raise ValueError("buf must be a 2-D numpy array")
- if buf.shape[0] == 0:
- raise ValueError("buf is empty; refusing to build an empty index")
- if buf.shape[1] != dim:
- raise ValueError(
- f"buf has dim={buf.shape[1]} but caller declared dim={dim}"
- )
- if buf.dtype != np.float32:
- buf = buf.astype(np.float32, copy=False)
- if not buf.flags["C_CONTIGUOUS"]:
- buf = np.ascontiguousarray(buf)
-
- m_val = config.VOYAGER_M if m is None else int(m)
- ef_val = config.VOYAGER_EF_CONSTRUCTION if ef_construction is None else int(ef_construction)
-
- space = _resolve_voyager_space(metric)
- builder = voyager.Index(
- space=space,
- num_dimensions=dim,
- M=m_val,
- ef_construction=ef_val,
- )
-
- n = buf.shape[0]
- ids = np.arange(n, dtype=np.int64)
- builder.add_items(buf, ids=ids)
-
- temp_file_path: Optional[str] = None
- try:
- with tempfile.NamedTemporaryFile(delete=False, suffix=".voyager") as tmp:
- temp_file_path = tmp.name
- builder.save(temp_file_path)
- del builder
- gc.collect()
- with open(temp_file_path, "rb") as f:
- return f.read()
- finally:
- if temp_file_path and os.path.exists(temp_file_path):
- try:
- os.remove(temp_file_path)
- except Exception:
- pass
-
-
def build_voyager_index_bytes_streaming(
batch_iter: Iterable[Tuple[np.ndarray, List[str]]],
dim: int,
@@ -568,6 +493,188 @@ def reassemble_segmented_id_map(fragments: Iterable[Tuple[int, Optional[str]]])
return "".join(frag or "" for _, frag in sorted(fragments, key=lambda p: p[0]))
+def build_and_store_index_streaming(
+ db_conn,
+ source_table: str,
+ source_column: str,
+ dim: int,
+ target_table: str,
+ index_name: str,
+ metric: str,
+ where_clause: Optional[str] = None,
+ label: Optional[str] = None,
+) -> bool:
+ """Stream embeddings, build a Voyager index, and persist it.
+
+ Wraps the canonical build pipeline shared by the CLAP, lyrics, and
+ lyrics-axes builders: ``iter_embedding_batches`` ->
+ ``build_voyager_index_bytes_streaming`` -> ``store_voyager_index_segmented``.
+ Commits on success and rolls back on failure using the caller's
+ ``db_conn``. Returns True on success, False when the source table has no
+ usable vectors or the build/store fails.
+ """
+ label = label or index_name
+ try:
+ import voyager # type: ignore # noqa: F401
+ except ImportError:
+ logger.warning("Voyager library is unavailable; cannot build %s index.", label)
+ return False
+
+ try:
+ logger.info("Building %s voyager index (streaming)...", label)
+ batches = iter_embedding_batches(
+ table=source_table,
+ column=source_column,
+ dim=dim,
+ where_clause=where_clause,
+ )
+ try:
+ index_bytes, item_ids = build_voyager_index_bytes_streaming(
+ batches, dim, metric=metric,
+ )
+ except EmptyIndexError as ve:
+ logger.warning("No valid %s vectors found for index build: %s", label, ve)
+ return False
+ gc.collect()
+
+ if not index_bytes:
+ logger.error("Generated %s index binary is empty; aborting storage.", label)
+ return False
+
+ id_map = build_id_map(item_ids)
+ store_voyager_index_segmented(
+ db_conn,
+ target_table=target_table,
+ index_name=index_name,
+ index_bytes=index_bytes,
+ id_map=id_map,
+ embedding_dimension=dim,
+ )
+
+ db_conn.commit()
+ logger.info("%s index build successful.", label)
+ return True
+ except Exception as e:
+ logger.error("Failed to build/store %s index: %s", label, e, exc_info=True)
+ try:
+ db_conn.rollback()
+ except Exception:
+ pass
+ return False
+
+
+def load_voyager_index_from_db(
+ conn,
+ table: str,
+ index_name: str,
+ expected_dim: int,
+ query_ef: int,
+ label: Optional[str] = None,
+):
+ """Load a Voyager index persisted by ``store_voyager_index_segmented``.
+
+ Tries the classic single-row layout first, then the segmented
+ ``__`` layout. Returns ``(index, id_map,
+ reverse_id_map)`` on success or ``None`` when the index is missing,
+ incomplete, or fails validation. DB and deserialization exceptions
+ propagate to the caller, which is expected to log and treat the load
+ as failed (the historical per-manager behavior).
+ """
+ label = label or index_name
+ _validate_sql_identifier(table, "table")
+ _validate_sql_identifier(index_name, "index_name")
+
+ try:
+ import voyager # type: ignore
+ except ImportError:
+ logger.warning("Voyager library is unavailable; cannot load %s index.", label)
+ return None
+
+ with conn.cursor() as cur:
+ cur.execute("SET LOCAL statement_timeout = 0")
+ cur.execute(
+ f"SELECT index_data, id_map_json, embedding_dimension FROM {table} "
+ f"WHERE index_name = %s",
+ (index_name,),
+ )
+ row = cur.fetchone()
+
+ index_stream = None
+ try:
+ if row:
+ binary, id_map_json, db_dim = row
+ index_stream = tempfile.TemporaryFile()
+ index_stream.write(binary)
+ index_stream.seek(0)
+ else:
+ seg_pattern = re.compile(rf'^{re.escape(index_name)}_(\d+)_(\d+)$')
+ parts = []
+ total_expected = None
+ with conn.cursor(name=f'{index_name}_segments') as seg_cur:
+ seg_cur.itersize = 50
+ seg_cur.execute(
+ f"SELECT index_name, index_data, id_map_json, embedding_dimension "
+ f"FROM {table} WHERE index_name LIKE %s ESCAPE '\\'",
+ (index_name.replace('_', r'\_') + r'\_%\_%',),
+ )
+ for name, part_data, part_id_map, part_dim in seg_cur:
+ m = seg_pattern.match(name)
+ if not m:
+ continue
+ part_no = int(m.group(1))
+ total = int(m.group(2))
+ if total_expected is None:
+ total_expected = total
+ elif total_expected != total:
+ logger.error(
+ "%s index segment total mismatch: %s vs %s",
+ label, total_expected, total,
+ )
+ return None
+ parts.append((part_no, part_data, part_id_map, part_dim))
+
+ if total_expected is None or len(parts) != total_expected:
+ logger.info(
+ "No complete persisted %s index found (expected %s, have %d).",
+ label, total_expected, len(parts),
+ )
+ return None
+
+ parts.sort(key=lambda p: p[0])
+ db_dim = parts[0][3]
+ index_stream = tempfile.TemporaryFile()
+ for _, part_data, _, _ in parts:
+ index_stream.write(part_data)
+ index_stream.seek(0)
+ id_map_json = reassemble_segmented_id_map((p[0], p[2]) for p in parts)
+
+ if index_stream is None or not id_map_json:
+ logger.info("%s index not found or empty in the database.", label)
+ return None
+ if db_dim != expected_dim:
+ logger.error(
+ "%s index dimension mismatch: db=%s expected=%s",
+ label, db_dim, expected_dim,
+ )
+ return None
+
+ loaded_index = voyager.Index.load(index_stream)
+ loaded_index.ef = query_ef
+ finally:
+ if index_stream is not None:
+ try:
+ index_stream.close()
+ except Exception:
+ pass
+
+ id_map = {int(k): v for k, v in json.loads(id_map_json).items()}
+ if not id_map:
+ logger.warning("%s index id_map is empty.", label)
+ return None
+ reverse_id_map = {v: k for k, v in id_map.items()}
+ return loaded_index, id_map, reverse_id_map
+
+
def store_voyager_index_segmented(
db_conn,
target_table: str,
@@ -602,7 +709,7 @@ def store_voyager_index_segmented(
index_name: logical index name (e.g. ``"clap_index"``). Must be a
bare SQL identifier so the LIKE-escape pattern is unambiguous.
index_bytes: serialized index payload (from
- ``build_voyager_index_bytes``).
+ ``build_voyager_index_bytes_streaming``).
id_map: ``{voyager_id_int: item_id_str}`` mapping. Serialized to
JSON for the first row.
embedding_dimension: stored alongside the index for validation on
@@ -638,7 +745,12 @@ def store_voyager_index_segmented(
insert_sql = (
f"INSERT INTO {target_table} "
f"(index_name, {binary_column}, id_map_json, embedding_dimension, created_at) "
- f"VALUES (%s, %s, %s, %s, CURRENT_TIMESTAMP)"
+ f"VALUES (%s, %s, %s, %s, CURRENT_TIMESTAMP) "
+ f"ON CONFLICT (index_name) DO UPDATE SET "
+ f"{binary_column} = EXCLUDED.{binary_column}, "
+ f"id_map_json = EXCLUDED.id_map_json, "
+ f"embedding_dimension = EXCLUDED.embedding_dimension, "
+ f"created_at = EXCLUDED.created_at"
)
id_map_fits = len(id_map_json.encode("utf-8")) <= max_part_size
@@ -820,7 +932,10 @@ def store_segmented_blob(
)
insert_sql = (
f"INSERT INTO {target_table} (name, blob_data, created_at) "
- f"VALUES (%s, %s, CURRENT_TIMESTAMP)"
+ f"VALUES (%s, %s, CURRENT_TIMESTAMP) "
+ f"ON CONFLICT (name) DO UPDATE SET "
+ f"blob_data = EXCLUDED.blob_data, "
+ f"created_at = EXCLUDED.created_at"
)
with db_conn.cursor() as cur:
diff --git a/tasks/lyrics_manager.py b/tasks/lyrics_manager.py
index c08ec384..b7cd720b 100644
--- a/tasks/lyrics_manager.py
+++ b/tasks/lyrics_manager.py
@@ -14,12 +14,8 @@
* search_by_text(query, limit) for the open free-form text tab
"""
-import gc
-import json
import logging
-import re
import sys
-import tempfile
from typing import Dict, List, Optional
import numpy as np
@@ -52,21 +48,8 @@
# ---------------------------------------------------------------------------
def _fetch_lyrics_metadata(item_ids: List[str]) -> Dict[str, Dict[str, str]]:
- metadata_map: Dict[str, Dict[str, str]] = {}
- if not item_ids:
- return metadata_map
- from app_helper import get_score_data_by_ids
- try:
- track_details = get_score_data_by_ids(item_ids)
- for row in track_details:
- metadata_map[row['item_id']] = {
- 'title': row.get('title', '') or '',
- 'author': row.get('author', '') or '',
- 'album': row.get('album', '') or '',
- }
- except Exception as e:
- logger.warning(f"Failed to fetch lyrics metadata: {e}")
- return metadata_map
+ from .commons import fetch_track_metadata_map
+ return fetch_track_metadata_map(item_ids)
def _axis_columns_from_axes() -> List[tuple]:
@@ -83,68 +66,26 @@ def build_and_store_lyrics_index(db_conn=None) -> bool:
"""Build a voyager index from stored lyrics embeddings and persist it."""
from app_helper import get_db
from config import LYRICS_ENABLED, LYRICS_EMBEDDING_DIMENSION, VOYAGER_METRIC
- from .index_build_helpers import (
- iter_embedding_batches,
- build_voyager_index_bytes_streaming,
- store_voyager_index_segmented,
- build_id_map,
- EmptyIndexError,
- )
+ from .index_build_helpers import build_and_store_index_streaming
if not LYRICS_ENABLED:
logger.info("Lyrics analysis is disabled; skipping lyrics index build.")
return False
- try:
- import voyager # type: ignore # noqa: F401
- except ImportError:
- logger.warning("Voyager library is unavailable; cannot build lyrics index.")
- return False
-
if db_conn is None:
db_conn = get_db()
- try:
- logger.info("Building lyrics voyager index (streaming)...")
- batches = iter_embedding_batches(
- table="lyrics_embedding",
- column="embedding",
- dim=LYRICS_EMBEDDING_DIMENSION,
- where_clause="embedding IS NOT NULL",
- )
- try:
- index_bytes, item_ids = build_voyager_index_bytes_streaming(
- batches, LYRICS_EMBEDDING_DIMENSION, metric=VOYAGER_METRIC,
- )
- except EmptyIndexError as ve:
- logger.warning(f"No valid lyrics embedding vectors for index build: {ve}")
- return False
- gc.collect()
-
- if not index_bytes:
- logger.error("Generated lyrics index binary is empty; aborting storage.")
- return False
-
- id_map = build_id_map(item_ids)
- store_voyager_index_segmented(
- db_conn,
- target_table="lyrics_index_data",
- index_name="lyrics_index",
- index_bytes=index_bytes,
- id_map=id_map,
- embedding_dimension=LYRICS_EMBEDDING_DIMENSION,
- )
-
- db_conn.commit()
- logger.info("Lyrics search index build successful.")
- return True
- except Exception as e:
- logger.error(f"Failed to build/store lyrics index: {e}", exc_info=True)
- try:
- db_conn.rollback()
- except Exception:
- pass
- return False
+ return build_and_store_index_streaming(
+ db_conn,
+ source_table="lyrics_embedding",
+ source_column="embedding",
+ dim=LYRICS_EMBEDDING_DIMENSION,
+ target_table="lyrics_index_data",
+ index_name="lyrics_index",
+ metric=VOYAGER_METRIC,
+ where_clause="embedding IS NOT NULL",
+ label="lyrics",
+ )
# ---------------------------------------------------------------------------
@@ -155,24 +96,12 @@ def build_and_store_lyrics_axes_index(db_conn=None) -> bool:
"""Build a voyager index from the per-song axis_scores flattened to a fixed-order vector."""
from app_helper import get_db
from config import LYRICS_ENABLED
- from .index_build_helpers import (
- iter_embedding_batches,
- build_voyager_index_bytes_streaming,
- store_voyager_index_segmented,
- build_id_map,
- EmptyIndexError,
- )
+ from .index_build_helpers import build_and_store_index_streaming
if not LYRICS_ENABLED:
logger.info("Lyrics analysis is disabled; skipping lyrics axes index build.")
return False
- try:
- import voyager # type: ignore # noqa: F401
- except ImportError:
- logger.warning("Voyager library is unavailable; cannot build lyrics axes index.")
- return False
-
if db_conn is None:
db_conn = get_db()
@@ -182,47 +111,17 @@ def build_and_store_lyrics_axes_index(db_conn=None) -> bool:
return False
dim = len(columns)
- try:
- logger.info(f"Building lyrics axes voyager index (streaming, dim={dim})...")
- batches = iter_embedding_batches(
- table="lyrics_embedding",
- column="axis_vector",
- dim=dim,
- where_clause="axis_vector IS NOT NULL",
- )
- try:
- index_bytes, item_ids = build_voyager_index_bytes_streaming(
- batches, dim, metric="angular",
- )
- except EmptyIndexError as ve:
- logger.warning(f"No usable axis_vector rows; aborting axes index build: {ve}")
- return False
- gc.collect()
-
- if not index_bytes:
- logger.error("Generated lyrics axes index binary is empty; aborting storage.")
- return False
-
- id_map = build_id_map(item_ids)
- store_voyager_index_segmented(
- db_conn,
- target_table="lyrics_axes_index_data",
- index_name="lyrics_axes_index",
- index_bytes=index_bytes,
- id_map=id_map,
- embedding_dimension=dim,
- )
-
- db_conn.commit()
- logger.info("Lyrics axes index build successful.")
- return True
- except Exception as e:
- logger.error(f"Failed to build/store lyrics axes index: {e}", exc_info=True)
- try:
- db_conn.rollback()
- except Exception:
- pass
- return False
+ return build_and_store_index_streaming(
+ db_conn,
+ source_table="lyrics_embedding",
+ source_column="axis_vector",
+ dim=dim,
+ target_table="lyrics_axes_index_data",
+ index_name="lyrics_axes_index",
+ metric="angular",
+ where_clause="axis_vector IS NOT NULL",
+ label="lyrics axes",
+ )
# ---------------------------------------------------------------------------
@@ -233,105 +132,24 @@ def _load_lyrics_index_from_db() -> bool:
"""Load persisted voyager index for lyrics from the DB into the global cache."""
from app_helper import get_db
from config import LYRICS_EMBEDDING_DIMENSION, VOYAGER_QUERY_EF
+ from .index_build_helpers import load_voyager_index_from_db
try:
- conn = get_db()
- with conn.cursor() as cur:
- cur.execute("SET LOCAL statement_timeout = 0")
- cur.execute(
- "SELECT index_data, id_map_json, embedding_dimension FROM lyrics_index_data "
- "WHERE index_name = %s",
- ('lyrics_index',),
- )
- row = cur.fetchone()
-
- index_stream = None
- try:
- if row:
- binary, id_map_json, db_dim = row
- index_stream = tempfile.TemporaryFile()
- index_stream.write(binary)
- index_stream.seek(0)
- else:
- seg_pattern = re.compile(r'^lyrics_index_(\d+)_(\d+)$')
- parts = []
- total_expected = None
- with conn.cursor(name='lyrics_index_segments') as seg_cur:
- seg_cur.itersize = 50
- seg_cur.execute(
- "SELECT index_name, index_data, id_map_json, embedding_dimension "
- "FROM lyrics_index_data WHERE index_name LIKE %s ESCAPE '\\'",
- (r'lyrics_index\_%\_%',),
- )
- for name, part_data, part_id_map, part_dim in seg_cur:
- m = seg_pattern.match(name)
- if not m:
- continue
- part_no = int(m.group(1))
- total = int(m.group(2))
- if total_expected is None:
- total_expected = total
- elif total_expected != total:
- logger.error(
- f"Lyrics index segment total mismatch: {total_expected} vs {total}"
- )
- return False
- parts.append((part_no, part_data, part_id_map, part_dim))
-
- if total_expected is None or len(parts) != total_expected:
- logger.info(
- f"No complete persisted lyrics index found (expected {total_expected}, "
- f"have {len(parts)})."
- )
- return False
-
- parts.sort(key=lambda p: p[0])
- from .index_build_helpers import reassemble_segmented_id_map
- db_dim = parts[0][3]
- index_stream = tempfile.TemporaryFile()
- for _, part_data, _, _ in parts:
- index_stream.write(part_data)
- index_stream.seek(0)
- id_map_json = reassemble_segmented_id_map((p[0], p[2]) for p in parts)
-
- if index_stream is None:
- return False
- if db_dim != LYRICS_EMBEDDING_DIMENSION:
- logger.error(
- f"Lyrics index dimension mismatch: db={db_dim} expected={LYRICS_EMBEDDING_DIMENSION}"
- )
- index_stream.close()
- return False
-
- try:
- import voyager # type: ignore
- except ImportError:
- logger.warning("Voyager library is unavailable; cannot load lyrics index.")
- return False
-
- loaded_index = voyager.Index.load(index_stream)
- loaded_index.ef = VOYAGER_QUERY_EF
- finally:
- if index_stream is not None:
- try:
- index_stream.close()
- except Exception:
- pass
-
- id_map = {int(k): v for k, v in json.loads(id_map_json).items()}
- reverse_id_map = {v: k for k, v in id_map.items()}
-
- if not id_map:
- logger.warning("Lyrics index id_map is empty.")
- return False
-
- _LYRICS_INDEX_CACHE['index'] = loaded_index
- _LYRICS_INDEX_CACHE['id_map'] = id_map
- _LYRICS_INDEX_CACHE['reverse_id_map'] = reverse_id_map
- _LYRICS_INDEX_CACHE['loaded'] = True
-
- logger.info(f"Lyrics index loaded from database with {len(id_map)} items.")
- return True
+ loaded = load_voyager_index_from_db(
+ get_db(), 'lyrics_index_data', 'lyrics_index',
+ LYRICS_EMBEDDING_DIMENSION, VOYAGER_QUERY_EF, label='lyrics',
+ )
+ if loaded is None:
+ return False
+ loaded_index, id_map, reverse_id_map = loaded
+
+ _LYRICS_INDEX_CACHE['index'] = loaded_index
+ _LYRICS_INDEX_CACHE['id_map'] = id_map
+ _LYRICS_INDEX_CACHE['reverse_id_map'] = reverse_id_map
+ _LYRICS_INDEX_CACHE['loaded'] = True
+
+ logger.info(f"Lyrics index loaded from database with {len(id_map)} items.")
+ return True
except Exception as e:
logger.error(f"Failed to load lyrics index from DB: {e}", exc_info=True)
return False
@@ -345,112 +163,31 @@ def _load_lyrics_axes_index_from_db() -> bool:
"""Load persisted voyager index for the lyrics axis vectors."""
from app_helper import get_db
from config import VOYAGER_QUERY_EF
+ from .index_build_helpers import load_voyager_index_from_db
columns = _axis_columns_from_axes()
expected_dim = len(columns)
try:
- conn = get_db()
- with conn.cursor() as cur:
- cur.execute("SET LOCAL statement_timeout = 0")
- cur.execute(
- "SELECT index_data, id_map_json, embedding_dimension FROM lyrics_axes_index_data "
- "WHERE index_name = %s",
- ('lyrics_axes_index',),
- )
- row = cur.fetchone()
-
- index_stream = None
- try:
- if row:
- binary, id_map_json, db_dim = row
- index_stream = tempfile.TemporaryFile()
- index_stream.write(binary)
- index_stream.seek(0)
- else:
- seg_pattern = re.compile(r'^lyrics_axes_index_(\d+)_(\d+)$')
- parts = []
- total_expected = None
- with conn.cursor(name='lyrics_axes_index_segments') as seg_cur:
- seg_cur.itersize = 50
- seg_cur.execute(
- "SELECT index_name, index_data, id_map_json, embedding_dimension "
- "FROM lyrics_axes_index_data WHERE index_name LIKE %s ESCAPE '\\'",
- (r'lyrics_axes_index\_%\_%',),
- )
- for name, part_data, part_id_map, part_dim in seg_cur:
- m = seg_pattern.match(name)
- if not m:
- continue
- part_no = int(m.group(1))
- total = int(m.group(2))
- if total_expected is None:
- total_expected = total
- elif total_expected != total:
- logger.error(
- f"Lyrics axes index segment total mismatch: {total_expected} vs {total}"
- )
- return False
- parts.append((part_no, part_data, part_id_map, part_dim))
-
- if total_expected is None or len(parts) != total_expected:
- logger.info(
- f"No complete persisted lyrics axes index found (expected {total_expected}, "
- f"have {len(parts)})."
- )
- return False
-
- parts.sort(key=lambda p: p[0])
- from .index_build_helpers import reassemble_segmented_id_map
- db_dim = parts[0][3]
- index_stream = tempfile.TemporaryFile()
- for _, part_data, _, _ in parts:
- index_stream.write(part_data)
- index_stream.seek(0)
- id_map_json = reassemble_segmented_id_map((p[0], p[2]) for p in parts)
-
- if index_stream is None:
- return False
- if db_dim != expected_dim:
- logger.error(
- f"Lyrics axes index dimension mismatch: db={db_dim} expected={expected_dim}"
- )
- index_stream.close()
- return False
-
- try:
- import voyager # type: ignore
- except ImportError:
- logger.warning("Voyager library is unavailable; cannot load lyrics axes index.")
- return False
-
- loaded_index = voyager.Index.load(index_stream)
- loaded_index.ef = VOYAGER_QUERY_EF
- finally:
- if index_stream is not None:
- try:
- index_stream.close()
- except Exception:
- pass
-
- id_map = {int(k): v for k, v in json.loads(id_map_json).items()}
- reverse_id_map = {v: k for k, v in id_map.items()}
-
- if not id_map:
- logger.warning("Lyrics axes index id_map is empty.")
- return False
-
- metadata_map = _fetch_lyrics_metadata(list(id_map.values()))
-
- _LYRICS_AXIS_CACHE['index'] = loaded_index
- _LYRICS_AXIS_CACHE['id_map'] = id_map
- _LYRICS_AXIS_CACHE['reverse_id_map'] = reverse_id_map
- _LYRICS_AXIS_CACHE['axis_columns'] = columns
- _LYRICS_AXIS_CACHE['metadata'] = metadata_map
- _LYRICS_AXIS_CACHE['loaded'] = True
-
- logger.info(f"Lyrics axes index loaded from database with {len(id_map)} items.")
- return True
+ loaded = load_voyager_index_from_db(
+ get_db(), 'lyrics_axes_index_data', 'lyrics_axes_index',
+ expected_dim, VOYAGER_QUERY_EF, label='lyrics axes',
+ )
+ if loaded is None:
+ return False
+ loaded_index, id_map, reverse_id_map = loaded
+
+ metadata_map = _fetch_lyrics_metadata(list(id_map.values()))
+
+ _LYRICS_AXIS_CACHE['index'] = loaded_index
+ _LYRICS_AXIS_CACHE['id_map'] = id_map
+ _LYRICS_AXIS_CACHE['reverse_id_map'] = reverse_id_map
+ _LYRICS_AXIS_CACHE['axis_columns'] = columns
+ _LYRICS_AXIS_CACHE['metadata'] = metadata_map
+ _LYRICS_AXIS_CACHE['loaded'] = True
+
+ logger.info(f"Lyrics axes index loaded from database with {len(id_map)} items.")
+ return True
except Exception as e:
logger.error(f"Failed to load lyrics axes index from DB: {e}", exc_info=True)
return False
diff --git a/tasks/mediaserver.py b/tasks/mediaserver.py
deleted file mode 100644
index 1d3ee9e1..00000000
--- a/tasks/mediaserver.py
+++ /dev/null
@@ -1,413 +0,0 @@
-# tasks/mediaserver.py
-
-import logging
-import os
-import config # Import the config module to access server type and settings
-
-# Import the specific implementations
-from tasks.mediaserver_jellyfin import (
- resolve_user as jellyfin_resolve_user,
- get_all_playlists as jellyfin_get_all_playlists,
- get_lyrics as jellyfin_get_lyrics,
- delete_playlist as jellyfin_delete_playlist,
- get_recent_albums as jellyfin_get_recent_albums,
- get_tracks_from_album as jellyfin_get_tracks_from_album,
- search_albums as jellyfin_search_albums,
- test_connection as jellyfin_test_connection,
- download_track as jellyfin_download_track,
- get_all_songs as jellyfin_get_all_songs,
- get_playlist_by_name as jellyfin_get_playlist_by_name,
- create_playlist as jellyfin_create_playlist,
- create_instant_playlist as jellyfin_create_instant_playlist,
- create_or_replace_playlist as jellyfin_create_or_replace_playlist,
- get_top_played_songs as jellyfin_get_top_played_songs,
- get_last_played_time as jellyfin_get_last_played_time,
- list_libraries as _jellyfin_list_libraries,
-)
-from tasks.mediaserver_navidrome import (
- get_all_playlists as navidrome_get_all_playlists,
- get_lyrics as navidrome_get_lyrics,
- delete_playlist as navidrome_delete_playlist,
- get_recent_albums as navidrome_get_recent_albums,
- get_tracks_from_album as navidrome_get_tracks_from_album,
- search_albums as navidrome_search_albums,
- test_connection as navidrome_test_connection,
- download_track as navidrome_download_track,
- get_all_songs as navidrome_get_all_songs,
- get_playlist_by_name as navidrome_get_playlist_by_name,
- create_playlist as navidrome_create_playlist,
- create_instant_playlist as navidrome_create_instant_playlist,
- create_or_replace_playlist as navidrome_create_or_replace_playlist,
- get_top_played_songs as navidrome_get_top_played_songs,
- get_last_played_time as navidrome_get_last_played_time,
- list_libraries as _navidrome_list_libraries,
-)
-from tasks.mediaserver_lyrion import (
- get_all_playlists as lyrion_get_all_playlists,
- get_lyrics as lyrion_get_lyrics,
- delete_playlist as lyrion_delete_playlist,
- get_recent_albums as lyrion_get_recent_albums,
- get_tracks_from_album as lyrion_get_tracks_from_album,
- search_albums as lyrion_search_albums,
- test_connection as lyrion_test_connection,
- download_track as lyrion_download_track,
- get_all_songs as lyrion_get_all_songs,
- get_playlist_by_name as lyrion_get_playlist_by_name,
- create_playlist as lyrion_create_playlist,
- create_instant_playlist as lyrion_create_instant_playlist,
- create_or_replace_playlist as lyrion_create_or_replace_playlist,
- get_top_played_songs as lyrion_get_top_played_songs,
- get_last_played_time as lyrion_get_last_played_time,
- list_libraries as _lyrion_list_libraries,
-)
-from tasks.mediaserver_mpd import (
- get_all_playlists as mpd_get_all_playlists,
- delete_playlist as mpd_delete_playlist,
- get_recent_albums as mpd_get_recent_albums,
- get_tracks_from_album as mpd_get_tracks_from_album,
- download_track as mpd_download_track,
- get_all_songs as mpd_get_all_songs,
- get_playlist_by_name as mpd_get_playlist_by_name,
- create_playlist as mpd_create_playlist,
- create_instant_playlist as mpd_create_instant_playlist,
- get_top_played_songs as mpd_get_top_played_songs,
- get_last_played_time as mpd_get_last_played_time,
-)
-from tasks.mediaserver_emby import (
- resolve_user as emby_resolve_user,
- get_all_playlists as emby_get_all_playlists,
- get_lyrics as emby_get_lyrics,
- delete_playlist as emby_delete_playlist,
- get_recent_albums as emby_get_recent_albums,
- get_tracks_from_album as emby_get_tracks_from_album,
- search_albums as emby_search_albums,
- test_connection as emby_test_connection,
- download_track as emby_download_track,
- get_all_songs as emby_get_all_songs,
- get_playlist_by_name as emby_get_playlist_by_name,
- create_playlist as emby_create_playlist,
- create_instant_playlist as emby_create_instant_playlist,
- create_or_replace_playlist as emby_create_or_replace_playlist,
- get_top_played_songs as emby_get_top_played_songs,
- get_last_played_time as emby_get_last_played_time,
- list_libraries as _emby_list_libraries,
-)
-
-logger = logging.getLogger(__name__)
-
-
-# ##############################################################################
-# PUBLIC API (Dispatcher functions)
-# ##############################################################################
-
-def resolve_emby_jellyfin_user(identifier, token):
- """Public dispatcher for resolving a Jellyfin or Emby user identifier."""
- # This is specific to Jellyfin, so we call it directly.
- if config.MEDIASERVER_TYPE == 'jellyfin': return jellyfin_resolve_user(identifier, token)
- if config.MEDIASERVER_TYPE == 'emby': return emby_resolve_user(identifier, token)
- return []
-
-def _delete_matching_playlists(playlists_to_check, delete_function, suffix):
- """Deletes every playlist whose name ends with the suffix; keeps going if one deletion fails."""
- deleted_count = 0
- for p in playlists_to_check:
- # Navidrome uses 'id', others use 'Id'. Check for both.
- playlist_id = p.get('Id') or p.get('id')
- try:
- if p.get('Name', '').endswith(suffix) and delete_function(playlist_id):
- deleted_count += 1
- except Exception:
- logger.exception(f"Failed to delete playlist {playlist_id}; continuing with the remaining playlists.")
- return deleted_count
-
-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
-
- if config.MEDIASERVER_TYPE == 'jellyfin':
- playlists_to_check = jellyfin_get_all_playlists()
- delete_function = jellyfin_delete_playlist
- elif config.MEDIASERVER_TYPE == 'navidrome':
- playlists_to_check = navidrome_get_all_playlists()
- delete_function = navidrome_delete_playlist
- elif config.MEDIASERVER_TYPE == 'lyrion':
- playlists_to_check = lyrion_get_all_playlists()
- delete_function = lyrion_delete_playlist
- elif config.MEDIASERVER_TYPE == 'mpd':
- playlists_to_check = mpd_get_all_playlists()
- delete_function = mpd_delete_playlist
- elif config.MEDIASERVER_TYPE == 'emby':
- playlists_to_check = emby_get_all_playlists()
- delete_function = emby_delete_playlist
-
- if delete_function:
- deleted_count = _delete_matching_playlists(playlists_to_check, delete_function, suffix)
-
- 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)
- if config.MEDIASERVER_TYPE == 'navidrome': return navidrome_get_recent_albums(limit)
- if config.MEDIASERVER_TYPE == 'lyrion': return lyrion_get_recent_albums(limit)
- if config.MEDIASERVER_TYPE == 'mpd': return mpd_get_recent_albums(limit)
- if config.MEDIASERVER_TYPE == 'emby': return emby_get_recent_albums(limit)
- return []
-
-def get_tracks_from_album(album_id, user_creds=None, provider_type=None):
- """Fetches tracks for an album, optionally using explicit creds."""
- provider_type = provider_type or config.MEDIASERVER_TYPE
- if provider_type == 'jellyfin': return jellyfin_get_tracks_from_album(album_id, user_creds=user_creds)
- if provider_type == 'navidrome': return navidrome_get_tracks_from_album(album_id, user_creds=user_creds)
- if provider_type == 'lyrion': return lyrion_get_tracks_from_album(album_id, user_creds=user_creds)
- if provider_type == 'mpd': return mpd_get_tracks_from_album(album_id)
- if provider_type == 'emby': return emby_get_tracks_from_album(album_id, user_creds=user_creds)
- return []
-
-def download_track(temp_dir, item):
- """Downloads a track using admin credentials. Detects format from file if .tmp extension is used."""
- downloaded_path = None
-
- if config.MEDIASERVER_TYPE == 'jellyfin': downloaded_path = jellyfin_download_track(temp_dir, item)
- elif config.MEDIASERVER_TYPE == 'navidrome': downloaded_path = navidrome_download_track(temp_dir, item)
- elif config.MEDIASERVER_TYPE == 'lyrion': downloaded_path = lyrion_download_track(temp_dir, item)
- elif config.MEDIASERVER_TYPE == 'mpd': downloaded_path = mpd_download_track(temp_dir, item)
- elif config.MEDIASERVER_TYPE == 'emby': downloaded_path = emby_download_track(temp_dir, item)
-
- # If download failed or returned None, return as is
- if not downloaded_path:
- return None
-
- # If file has .tmp extension, try to detect real format from file content
- if downloaded_path.endswith('.tmp'):
- try:
- # Check if file exists before trying to detect format
- if not os.path.exists(downloaded_path):
- logger.warning(f"Downloaded file does not exist: {downloaded_path}")
- return downloaded_path
-
- detected_ext = _detect_audio_format(downloaded_path)
- if detected_ext and detected_ext != '.tmp':
- new_path = downloaded_path.replace('.tmp', detected_ext)
- # Check if target file already exists (avoid overwriting)
- if os.path.exists(new_path):
- logger.warning(f"Target file already exists, keeping .tmp: {new_path}")
- return downloaded_path
- os.rename(downloaded_path, new_path)
- logger.info(f"Detected format and renamed: {os.path.basename(downloaded_path)} -> {os.path.basename(new_path)}")
- return new_path
- except Exception as e:
- logger.debug(f"Format detection failed for {os.path.basename(downloaded_path)}, keeping .tmp: {e}")
-
- return downloaded_path
-
-
-def _detect_audio_format(filepath):
- """Detects audio format from file magic numbers. Returns extension like '.mp3' or '.flac'."""
- try:
- with open(filepath, 'rb') as f:
- header = f.read(12)
-
- # Check magic numbers for common audio formats
- if len(header) < 4:
- return '.tmp'
-
- # FLAC: fLaC
- if header[:4] == b'fLaC':
- return '.flac'
-
- # MP3: ID3 tag or MP3 sync bits
- if header[:3] == b'ID3' or (len(header) >= 2 and header[0] == 0xFF and (header[1] & 0xE0) == 0xE0):
- return '.mp3'
-
- # OGG: OggS
- if header[:4] == b'OggS':
- return '.ogg'
-
- # WAV/RIFF: RIFF....WAVE
- if header[:4] == b'RIFF' and len(header) >= 12 and header[8:12] == b'WAVE':
- return '.wav'
-
- # M4A/AAC: ftyp
- if len(header) >= 8 and header[4:8] == b'ftyp':
- return '.m4a'
-
- # WMA: ASF header
- if header[:4] == b'\x30\x26\xb2\x75':
- return '.wma'
-
- logger.debug(f"Unknown audio format, header: {header[:4].hex()}")
- return '.tmp'
-
- except Exception as e:
- logger.debug(f"Error detecting audio format: {e}")
- return '.tmp'
-
-def get_all_songs(user_creds=None, provider_type=None, apply_filter=True):
- """Fetches all songs using admin credentials or explicit creds.
-
- ``apply_filter`` is forwarded to providers that honor
- ``config.MUSIC_LIBRARIES`` (currently Navidrome). Migration probes pass
- ``apply_filter=False`` so the source provider's library filter does not
- falsely exclude tracks from the target server during dry-run.
- """
- provider_type = provider_type or config.MEDIASERVER_TYPE
- if provider_type == 'jellyfin': return jellyfin_get_all_songs(user_creds=user_creds)
- if provider_type == 'navidrome': return navidrome_get_all_songs(user_creds=user_creds, apply_filter=apply_filter)
- if provider_type == 'lyrion': return lyrion_get_all_songs(user_creds=user_creds)
- if provider_type == 'mpd': return mpd_get_all_songs()
- if provider_type == 'emby': return emby_get_all_songs(user_creds=user_creds)
- return []
-
-def list_libraries(user_creds=None, provider_type=None):
- """List all music libraries/folders a provider exposes.
-
- Returns {'libraries': [{'id': str, 'name': str}, ...], 'unsupported': bool}.
- The setup wizard and migration assistant use this to render a checkbox list
- after a successful test-connection. Uses admin credentials when
- ``user_creds`` is None, or the supplied creds when probing a target.
- """
- provider_type = provider_type or config.MEDIASERVER_TYPE
- if provider_type == 'jellyfin': return {'libraries': _jellyfin_list_libraries(user_creds=user_creds), 'unsupported': False}
- if provider_type == 'navidrome': return {'libraries': _navidrome_list_libraries(user_creds=user_creds), 'unsupported': False}
- if provider_type == 'lyrion': return {'libraries': _lyrion_list_libraries(user_creds=user_creds), 'unsupported': False}
- if provider_type == 'emby': return {'libraries': _emby_list_libraries(user_creds=user_creds), 'unsupported': False}
- return {'libraries': [], 'unsupported': True}
-
-def search_albums(query, user_creds=None, provider_type=None):
- """Searches for albums using admin credentials or explicit creds."""
- provider_type = provider_type or config.MEDIASERVER_TYPE
- if provider_type == 'jellyfin': return jellyfin_search_albums(query, user_creds=user_creds)
- if provider_type == 'navidrome': return navidrome_search_albums(query, user_creds=user_creds)
- if provider_type == 'lyrion': return lyrion_search_albums(query, user_creds=user_creds)
- if provider_type == 'mpd': raise NotImplementedError('MPD album search is not supported')
- if provider_type == 'emby': return emby_search_albums(query, user_creds=user_creds)
- return []
-
-def test_connection(user_creds=None, provider_type=None):
- """Tests provider connection using admin credentials or explicit creds."""
- provider_type = provider_type or config.MEDIASERVER_TYPE
- if provider_type == 'jellyfin': return jellyfin_test_connection(user_creds=user_creds)
- if provider_type == 'navidrome': return navidrome_test_connection(user_creds=user_creds)
- if provider_type == 'lyrion': return lyrion_test_connection(user_creds=user_creds)
- if provider_type == 'mpd':
- return {'ok': False, 'error': 'MPD migration probe is not supported', 'sample_count': 0, 'path_format': 'none', 'warnings': []}
- if provider_type == 'emby': return emby_test_connection(user_creds=user_creds)
- return {'ok': False, 'error': f"Provider '{provider_type}' not supported", 'sample_count': 0, 'path_format': 'none', 'warnings': []}
-
-def get_playlist_by_name(playlist_name):
- """Finds a playlist by name using admin credentials."""
- if not playlist_name: raise ValueError("Playlist name is required.")
- if config.MEDIASERVER_TYPE == 'jellyfin': return jellyfin_get_playlist_by_name(playlist_name)
- if config.MEDIASERVER_TYPE == 'navidrome': return navidrome_get_playlist_by_name(playlist_name)
- if config.MEDIASERVER_TYPE == 'lyrion': return lyrion_get_playlist_by_name(playlist_name)
- if config.MEDIASERVER_TYPE == 'mpd': return mpd_get_playlist_by_name(playlist_name)
- if config.MEDIASERVER_TYPE == 'emby': return emby_get_playlist_by_name(playlist_name)
- return None
-
-def create_playlist(base_name, item_ids):
- """Creates a playlist using admin credentials."""
- if not base_name: raise ValueError("Playlist name is required.")
- if not item_ids: raise ValueError("Track IDs are required.")
- if config.MEDIASERVER_TYPE == 'jellyfin': jellyfin_create_playlist(base_name, item_ids)
- elif config.MEDIASERVER_TYPE == 'navidrome': navidrome_create_playlist(base_name, item_ids)
- elif config.MEDIASERVER_TYPE == 'lyrion': lyrion_create_playlist(base_name, item_ids)
- elif config.MEDIASERVER_TYPE == 'mpd': mpd_create_playlist(base_name, item_ids)
- elif config.MEDIASERVER_TYPE == 'emby': emby_create_playlist(base_name, item_ids)
-
-def create_instant_playlist(playlist_name, item_ids, user_creds=None):
- """Creates an instant playlist. Uses user_creds if provided, otherwise admin."""
- if not playlist_name: raise ValueError("Playlist name is required.")
- if not item_ids: raise ValueError("Track IDs are required.")
-
- if config.MEDIASERVER_TYPE == 'jellyfin':
- return jellyfin_create_instant_playlist(playlist_name, item_ids, user_creds)
- if config.MEDIASERVER_TYPE == 'navidrome':
- return navidrome_create_instant_playlist(playlist_name, item_ids, user_creds)
- if config.MEDIASERVER_TYPE == 'lyrion':
- return lyrion_create_instant_playlist(playlist_name, item_ids)
- if config.MEDIASERVER_TYPE == 'mpd':
- return mpd_create_instant_playlist(playlist_name, item_ids, user_creds)
- if config.MEDIASERVER_TYPE == 'emby':
- return emby_create_instant_playlist(playlist_name, item_ids, user_creds)
- return None
-
-
-def create_or_replace_playlist(playlist_name, item_ids, user_creds=None):
- """Cron-only upsert: create the playlist if missing, or replace its contents in place.
-
- Used by the scheduled sonic_fingerprint task so the same server-side playlist (and ID,
- where the backend allows) gets reused across runs. Raises NotImplementedError for MPD
- and any other unsupported backend — the cron handler catches that and falls back to
- legacy date-suffixed playlist creation.
- """
- if not playlist_name:
- raise ValueError("Playlist name is required.")
- if not item_ids:
- raise ValueError("Track IDs are required.")
-
- if config.MEDIASERVER_TYPE == 'jellyfin':
- return jellyfin_create_or_replace_playlist(playlist_name, item_ids, user_creds)
- if config.MEDIASERVER_TYPE == 'navidrome':
- return navidrome_create_or_replace_playlist(playlist_name, item_ids, user_creds)
- if config.MEDIASERVER_TYPE == 'emby':
- return emby_create_or_replace_playlist(playlist_name, item_ids, user_creds)
- if config.MEDIASERVER_TYPE == 'lyrion':
- return lyrion_create_or_replace_playlist(playlist_name, item_ids, user_creds)
- raise NotImplementedError(
- f"create_or_replace_playlist not supported for MEDIASERVER_TYPE={config.MEDIASERVER_TYPE!r}"
- )
-
-def get_top_played_songs(limit, user_creds=None):
- """Fetches top played songs. Uses user_creds if provided, otherwise admin."""
- if config.MEDIASERVER_TYPE == 'jellyfin':
- return jellyfin_get_top_played_songs(limit, user_creds)
- if config.MEDIASERVER_TYPE == 'navidrome':
- return navidrome_get_top_played_songs(limit, user_creds)
- if config.MEDIASERVER_TYPE == 'lyrion':
- return lyrion_get_top_played_songs(limit)
- if config.MEDIASERVER_TYPE == 'mpd':
- return mpd_get_top_played_songs(limit, user_creds)
- if config.MEDIASERVER_TYPE == 'emby':
- return emby_get_top_played_songs(limit, user_creds)
- return []
-
-def get_last_played_time(item_id, user_creds=None):
- """Fetches last played time for a track. Uses user_creds if provided, otherwise admin."""
- if config.MEDIASERVER_TYPE == 'jellyfin':
- return jellyfin_get_last_played_time(item_id, user_creds)
- if config.MEDIASERVER_TYPE == 'navidrome':
- return navidrome_get_last_played_time(item_id, user_creds)
- if config.MEDIASERVER_TYPE == 'lyrion':
- return lyrion_get_last_played_time(item_id)
- if config.MEDIASERVER_TYPE == 'mpd':
- return mpd_get_last_played_time(item_id, user_creds)
- if config.MEDIASERVER_TYPE == 'emby':
- return emby_get_last_played_time(item_id, user_creds)
- return None
-
-def get_lyrics(track_id: str, timeout: float = 2.5):
- """Fetch lyrics embedded in the media server for a given track ID.
-
- Supported servers: Jellyfin, Emby, Navidrome, Lyrion.
- MPD does not provide a lyrics API; always returns None.
- Returns plain text or None.
- """
- if config.MEDIASERVER_TYPE == 'jellyfin':
- return jellyfin_get_lyrics(track_id, timeout=timeout)
- if config.MEDIASERVER_TYPE == 'emby':
- return emby_get_lyrics(track_id, timeout=timeout)
- if config.MEDIASERVER_TYPE == 'navidrome':
- return navidrome_get_lyrics(track_id, timeout=timeout)
- if config.MEDIASERVER_TYPE == 'lyrion':
- return lyrion_get_lyrics(track_id, timeout=timeout)
- return None
-
diff --git a/tasks/mediaserver/__init__.py b/tasks/mediaserver/__init__.py
new file mode 100644
index 00000000..e25b358c
--- /dev/null
+++ b/tasks/mediaserver/__init__.py
@@ -0,0 +1,283 @@
+# tasks/mediaserver/__init__.py
+
+import logging
+import os
+from importlib import import_module
+
+import config
+
+logger = logging.getLogger(__name__)
+
+_PROVIDER_NAMES = ('jellyfin', 'navidrome', 'lyrion', 'emby')
+_warned_unsupported = set()
+
+_PLAYLIST_NAME_REQUIRED = "Playlist name is required."
+_TRACK_IDS_REQUIRED = "Track IDs are required."
+
+
+def _provider(provider_type=None):
+ """Return the backend module for the given (or configured) provider type.
+
+ Provider modules are imported lazily on first use so that importing
+ ``tasks.mediaserver`` does not load the four inactive backends or
+ initialize their HTTP sessions. Returns None for unsupported types,
+ matching the old dispatcher fall-through behavior.
+ """
+ name = provider_type or config.MEDIASERVER_TYPE
+ if name not in _PROVIDER_NAMES:
+ if name not in _warned_unsupported:
+ _warned_unsupported.add(name)
+ logger.warning(
+ "Unsupported MEDIASERVER_TYPE %r (supported: %s); media-server operations are no-ops.",
+ name, ', '.join(_PROVIDER_NAMES))
+ return None
+ return import_module('.' + name, __name__)
+
+
+# ##############################################################################
+# PUBLIC API (Dispatcher functions)
+# ##############################################################################
+
+def resolve_emby_jellyfin_user(identifier, token):
+ """Public dispatcher for resolving a Jellyfin or Emby user identifier."""
+ if config.MEDIASERVER_TYPE in ('jellyfin', 'emby'):
+ return _provider().resolve_user(identifier, token)
+ return []
+
+def _delete_matching_playlists(playlists_to_check, delete_function, suffix):
+ """Deletes every playlist whose name ends with the suffix; keeps going if one deletion fails."""
+ deleted_count = 0
+ for p in playlists_to_check:
+ # Navidrome uses 'id', others use 'Id'. Check for both.
+ playlist_id = p.get('Id') or p.get('id')
+ try:
+ if p.get('Name', '').endswith(suffix) and delete_function(playlist_id):
+ deleted_count += 1
+ except Exception:
+ logger.exception(f"Failed to delete playlist {playlist_id}; continuing with the remaining playlists.")
+ return deleted_count
+
+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
+
+ provider = _provider()
+ if provider is not None:
+ deleted_count = _delete_matching_playlists(provider.get_all_playlists(), provider.delete_playlist, suffix)
+
+ 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."""
+ provider = _provider()
+ if provider is None:
+ return []
+ return provider.get_recent_albums(limit)
+
+def get_tracks_from_album(album_id, user_creds=None, provider_type=None):
+ """Fetches tracks for an album, optionally using explicit creds."""
+ provider = _provider(provider_type)
+ if provider is None:
+ return []
+ return provider.get_tracks_from_album(album_id, user_creds=user_creds)
+
+def download_track(temp_dir, item):
+ """Downloads a track using admin credentials. Detects format from file if .tmp extension is used."""
+ provider = _provider()
+ downloaded_path = provider.download_track(temp_dir, item) if provider is not None else None
+
+ # If download failed or returned None, return as is
+ if not downloaded_path:
+ return None
+
+ # If file has .tmp extension, try to detect real format from file content
+ if downloaded_path.endswith('.tmp'):
+ try:
+ # Check if file exists before trying to detect format
+ if not os.path.exists(downloaded_path):
+ logger.warning(f"Downloaded file does not exist: {downloaded_path}")
+ return downloaded_path
+
+ detected_ext = _detect_audio_format(downloaded_path)
+ if detected_ext and detected_ext != '.tmp':
+ new_path = downloaded_path.replace('.tmp', detected_ext)
+ # Check if target file already exists (avoid overwriting)
+ if os.path.exists(new_path):
+ logger.warning(f"Target file already exists, keeping .tmp: {new_path}")
+ return downloaded_path
+ os.rename(downloaded_path, new_path)
+ logger.info(f"Detected format and renamed: {os.path.basename(downloaded_path)} -> {os.path.basename(new_path)}")
+ return new_path
+ except Exception as e:
+ logger.debug(f"Format detection failed for {os.path.basename(downloaded_path)}, keeping .tmp: {e}")
+
+ return downloaded_path
+
+
+def _detect_audio_format(filepath):
+ """Detects audio format from file magic numbers. Returns extension like '.mp3' or '.flac'."""
+ try:
+ with open(filepath, 'rb') as f:
+ header = f.read(12)
+
+ # Check magic numbers for common audio formats
+ if len(header) < 4:
+ return '.tmp'
+
+ # FLAC: fLaC
+ if header[:4] == b'fLaC':
+ return '.flac'
+
+ # MP3: ID3 tag or MP3 sync bits
+ if header[:3] == b'ID3' or (len(header) >= 2 and header[0] == 0xFF and (header[1] & 0xE0) == 0xE0):
+ return '.mp3'
+
+ # OGG: OggS
+ if header[:4] == b'OggS':
+ return '.ogg'
+
+ # WAV/RIFF: RIFF....WAVE
+ if header[:4] == b'RIFF' and len(header) >= 12 and header[8:12] == b'WAVE':
+ return '.wav'
+
+ # M4A/AAC: ftyp
+ if len(header) >= 8 and header[4:8] == b'ftyp':
+ return '.m4a'
+
+ # WMA: ASF header
+ if header[:4] == b'\x30\x26\xb2\x75':
+ return '.wma'
+
+ logger.debug(f"Unknown audio format, header: {header[:4].hex()}")
+ return '.tmp'
+
+ except Exception as e:
+ logger.debug(f"Error detecting audio format: {e}")
+ return '.tmp'
+
+def get_all_songs(user_creds=None, provider_type=None, apply_filter=True):
+ """Fetches all songs using admin credentials or explicit creds.
+
+ ``apply_filter`` is forwarded to providers that honor
+ ``config.MUSIC_LIBRARIES`` (currently Navidrome). Migration probes pass
+ ``apply_filter=False`` so the source provider's library filter does not
+ falsely exclude tracks from the target server during dry-run.
+ """
+ provider_type = provider_type or config.MEDIASERVER_TYPE
+ provider = _provider(provider_type)
+ if provider is None:
+ return []
+ if provider_type == 'navidrome':
+ return provider.get_all_songs(user_creds=user_creds, apply_filter=apply_filter)
+ return provider.get_all_songs(user_creds=user_creds)
+
+def list_libraries(user_creds=None, provider_type=None):
+ """List all music libraries/folders a provider exposes.
+
+ Returns {'libraries': [{'id': str, 'name': str}, ...], 'unsupported': bool}.
+ The setup wizard and migration assistant use this to render a checkbox list
+ after a successful test-connection. Uses admin credentials when
+ ``user_creds`` is None, or the supplied creds when probing a target.
+ """
+ provider = _provider(provider_type)
+ if provider is None:
+ return {'libraries': [], 'unsupported': True}
+ return {'libraries': provider.list_libraries(user_creds=user_creds), 'unsupported': False}
+
+def search_albums(query, user_creds=None, provider_type=None):
+ """Searches for albums using admin credentials or explicit creds."""
+ provider = _provider(provider_type)
+ if provider is None:
+ return []
+ return provider.search_albums(query, user_creds=user_creds)
+
+def test_connection(user_creds=None, provider_type=None):
+ """Tests provider connection using admin credentials or explicit creds."""
+ provider_type = provider_type or config.MEDIASERVER_TYPE
+ provider = _provider(provider_type)
+ if provider is None:
+ return {'ok': False, 'error': f"Provider '{provider_type}' not supported", 'sample_count': 0, 'path_format': 'none', 'warnings': []}
+ return provider.test_connection(user_creds=user_creds)
+
+def get_playlist_by_name(playlist_name):
+ """Finds a playlist by name using admin credentials."""
+ if not playlist_name: raise ValueError(_PLAYLIST_NAME_REQUIRED)
+ provider = _provider()
+ if provider is None:
+ return None
+ return provider.get_playlist_by_name(playlist_name)
+
+def create_playlist(base_name, item_ids):
+ """Creates a playlist using admin credentials."""
+ if not base_name: raise ValueError(_PLAYLIST_NAME_REQUIRED)
+ if not item_ids: raise ValueError(_TRACK_IDS_REQUIRED)
+ provider = _provider()
+ if provider is not None:
+ provider.create_playlist(base_name, item_ids)
+
+def create_instant_playlist(playlist_name, item_ids, user_creds=None):
+ """Creates an instant playlist. Uses user_creds if provided, otherwise admin."""
+ if not playlist_name: raise ValueError(_PLAYLIST_NAME_REQUIRED)
+ if not item_ids: raise ValueError(_TRACK_IDS_REQUIRED)
+
+ provider = _provider()
+ if provider is None:
+ return None
+ if config.MEDIASERVER_TYPE == 'lyrion':
+ return provider.create_instant_playlist(playlist_name, item_ids)
+ return provider.create_instant_playlist(playlist_name, item_ids, user_creds)
+
+
+def create_or_replace_playlist(playlist_name, item_ids, user_creds=None):
+ """Cron-only upsert: create the playlist if missing, or replace its contents in place.
+
+ Used by the scheduled sonic_fingerprint task so the same server-side playlist (and ID,
+ where the backend allows) gets reused across runs. Raises NotImplementedError for any
+ unsupported backend — the cron handler catches that and falls back to legacy
+ date-suffixed playlist creation.
+ """
+ if not playlist_name:
+ raise ValueError(_PLAYLIST_NAME_REQUIRED)
+ if not item_ids:
+ raise ValueError(_TRACK_IDS_REQUIRED)
+
+ provider = _provider()
+ if provider is None:
+ raise NotImplementedError(
+ f"create_or_replace_playlist not supported for MEDIASERVER_TYPE={config.MEDIASERVER_TYPE!r}"
+ )
+ return provider.create_or_replace_playlist(playlist_name, item_ids, user_creds)
+
+def get_top_played_songs(limit, user_creds=None):
+ """Fetches top played songs. Uses user_creds if provided, otherwise admin."""
+ provider = _provider()
+ if provider is None:
+ return []
+ if config.MEDIASERVER_TYPE == 'lyrion':
+ return provider.get_top_played_songs(limit)
+ return provider.get_top_played_songs(limit, user_creds)
+
+def get_last_played_time(item_id, user_creds=None):
+ """Fetches last played time for a track. Uses user_creds if provided, otherwise admin."""
+ provider = _provider()
+ if provider is None:
+ return None
+ if config.MEDIASERVER_TYPE == 'lyrion':
+ return provider.get_last_played_time(item_id)
+ return provider.get_last_played_time(item_id, user_creds)
+
+def get_lyrics(track_id: str, timeout: float = 2.5):
+ """Fetch lyrics embedded in the media server for a given track ID.
+
+ Supported servers: Jellyfin, Emby, Navidrome, Lyrion.
+ Returns plain text or None.
+ """
+ provider = _provider()
+ if provider is None:
+ return None
+ return provider.get_lyrics(track_id, timeout=timeout)
diff --git a/tasks/mediaserver_emby.py b/tasks/mediaserver/emby.py
similarity index 95%
rename from tasks/mediaserver_emby.py
rename to tasks/mediaserver/emby.py
index 39bd57f1..ffe8fcb3 100644
--- a/tasks/mediaserver_emby.py
+++ b/tasks/mediaserver/emby.py
@@ -1,11 +1,12 @@
-# tasks/mediaserver_emby.py
+# tasks/mediaserver/emby.py
-from tasks import mediaserver_http as requests
+from . import http as requests
import logging
import os
import config
-from tasks.mediaserver_helper import detect_path_format
+from .helper import detect_path_format, detect_download_extension
+from .helper import select_best_artist as _select_best_artist
logger = logging.getLogger(__name__)
@@ -218,7 +219,7 @@ def _get_recent_standalone_tracks(limit, target_library_ids=None, user_creds=Non
# If parent is not a MusicAlbum, treat track as standalone
if parent_info.get('Type') != 'MusicAlbum':
standalone_tracks.append(track)
- except:
+ except Exception:
# If we can't check parent, assume it's standalone to be safe
standalone_tracks.append(track)
@@ -273,7 +274,7 @@ def _get_recent_standalone_tracks(limit, target_library_ids=None, user_creds=Non
parent_info = parent_r.json()
if parent_info.get('Type') != 'MusicAlbum':
standalone_tracks.append(track)
- except:
+ except Exception:
standalone_tracks.append(track)
all_tracks.extend(standalone_tracks)
@@ -502,22 +503,7 @@ def download_track(temp_dir, item):
# https://dev.emby.media/reference/RestAPI/LibraryService/getItemsByIdDownload.html
try:
track_id = item['Id']
-
- # Try to get format from Container field first (most reliable)
- file_extension = '.tmp'
- try:
- container = item.get('Container')
- if container and isinstance(container, str) and container.strip():
- # Ensure container value is safe (no path separators, etc.)
- safe_container = container.strip().replace('/', '').replace('\\', '')
- if safe_container:
- file_extension = f".{safe_container}"
- logger.debug(f"Using Container field for format: {file_extension}")
- elif item.get('Path'):
- file_extension = os.path.splitext(item['Path'])[1] or '.tmp'
- except Exception as e:
- logger.debug(f"Error getting format from Container/Path, using .tmp: {e}")
-
+ file_extension = detect_download_extension(item)
download_url = f"{config.EMBY_URL}/emby/Items/{track_id}/Download"
local_filename = os.path.join(temp_dir, f"{track_id}{file_extension}")
with requests.get(download_url, headers=config.HEADERS, stream=True, timeout=REQUESTS_TIMEOUT) as r:
@@ -530,29 +516,6 @@ def download_track(temp_dir, item):
logger.error(f"Failed to download track {item.get('Name', 'Unknown')}: {e}", exc_info=True)
return None
-def _select_best_artist(item, title="Unknown"):
- """
- Selects the best artist field from Emby item, prioritizing track artists over album artists.
- This helps avoid "Various Artists" issues in compilation albums.
- Returns tuple: (artist_name, artist_id)
- """
- # Priority: Artists array (track artists) > AlbumArtist > fallback
- # Emby provides ArtistItems array with Id and Name
- 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')
- elif item.get('Artists') and len(item['Artists']) > 0:
- track_artist = item['Artists'][0] # Take first artist if multiple
- artist_id = None
- elif item.get('AlbumArtist'):
- track_artist = item['AlbumArtist']
- artist_id = None
- else:
- track_artist = 'Unknown Artist'
- artist_id = None
-
- return track_artist, artist_id
-
def get_all_songs(user_creds=None):
# Emby might have a maximum number of items returned per request.
# not sure if this approach would work.. It defnitly needs testing.
diff --git a/tasks/mediaserver/helper.py b/tasks/mediaserver/helper.py
new file mode 100644
index 00000000..0187baec
--- /dev/null
+++ b/tasks/mediaserver/helper.py
@@ -0,0 +1,94 @@
+"""Shared media server helper utilities."""
+
+import logging
+import os
+import re
+
+logger = logging.getLogger(__name__)
+
+
+def select_best_artist(item, title="Unknown"):
+ """
+ Selects the best artist field from a Jellyfin/Emby item, prioritizing track
+ artists over album artists. This helps avoid "Various Artists" issues in
+ compilation albums.
+ Returns tuple: (artist_name, artist_id)
+ """
+ # Priority: Artists array (track artists) > AlbumArtist > fallback
+ # Jellyfin/Emby provides ArtistItems array with Id and Name
+ 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')
+ elif item.get('Artists') and len(item['Artists']) > 0:
+ track_artist = item['Artists'][0] # Take first artist if multiple
+ artist_id = None
+ elif item.get('AlbumArtist'):
+ track_artist = item['AlbumArtist']
+ artist_id = None
+ else:
+ track_artist = 'Unknown Artist'
+ artist_id = None
+
+ return track_artist, artist_id
+
+
+def detect_download_extension(item):
+ """Derive a file extension for a Jellyfin/Emby download.
+
+ Prefers the item's Container field (most reliable), falls back to the
+ Path extension, and defaults to '.tmp' so the dispatcher's magic-number
+ sniffing can rename the file after download.
+ """
+ file_extension = '.tmp'
+ try:
+ container = item.get('Container')
+ if container and isinstance(container, str) and container.strip():
+ # Ensure container value is safe (no path separators, etc.)
+ safe_container = container.strip().replace('/', '').replace('\\', '')
+ if safe_container:
+ file_extension = f".{safe_container}"
+ logger.debug(f"Using Container field for format: {file_extension}")
+ elif item.get('Path'):
+ file_extension = os.path.splitext(item['Path'])[1] or '.tmp'
+ except Exception as e:
+ logger.debug(f"Error getting format from Container/Path, using .tmp: {e}")
+ return file_extension
+
+
+def detect_path_format(tracks):
+ """Classify track path samples as absolute, relative, none, or mixed."""
+ def _is_absolute_path(path):
+ if not path:
+ return False
+ path_str = str(path)
+ lower = path_str.lower()
+ return (
+ path_str.startswith('/')
+ or path_str.startswith('\\')
+ or lower.startswith('file://')
+ or re.match(r'^[A-Za-z]:[\\/]', path_str)
+ )
+
+ paths = []
+ for track in tracks or []:
+ if not isinstance(track, dict):
+ continue
+ # Support lowercase/uppercase path keys and legacy URL fields.
+ path = (
+ track.get('path')
+ or track.get('Path')
+ or track.get('url')
+ or track.get('Url')
+ )
+ if path:
+ paths.append(path)
+
+ if not paths:
+ return 'none'
+
+ ratio = sum(1 for p in paths if _is_absolute_path(p)) / len(paths)
+ if ratio >= 0.8:
+ return 'absolute'
+ if ratio <= 0.2:
+ return 'relative'
+ return 'mixed'
diff --git a/tasks/mediaserver_http.py b/tasks/mediaserver/http.py
similarity index 97%
rename from tasks/mediaserver_http.py
rename to tasks/mediaserver/http.py
index 567de696..1a64ee4b 100644
--- a/tasks/mediaserver_http.py
+++ b/tasks/mediaserver/http.py
@@ -1,4 +1,4 @@
-# tasks/mediaserver_http.py
+# tasks/mediaserver/http.py
"""Centralized HTTP layer for the media-server clients.
Drop-in stand-in for the parts of ``requests`` the media-server modules use.
@@ -6,7 +6,7 @@
import requests
->
- from tasks import mediaserver_http as requests
+ from . import http as requests
Every existing ``requests.get(...)`` / ``requests.post(...)`` / etc. call then
gains a *connection-only* retry, and any other attribute
diff --git a/tasks/mediaserver_jellyfin.py b/tasks/mediaserver/jellyfin.py
similarity index 93%
rename from tasks/mediaserver_jellyfin.py
rename to tasks/mediaserver/jellyfin.py
index 42663b23..7d2d2549 100644
--- a/tasks/mediaserver_jellyfin.py
+++ b/tasks/mediaserver/jellyfin.py
@@ -1,11 +1,12 @@
-# tasks/mediaserver_jellyfin.py
+# tasks/mediaserver/jellyfin.py
-from tasks import mediaserver_http as requests
+from . import http as requests
import logging
import os
import config
-from tasks.mediaserver_helper import detect_path_format
+from .helper import detect_path_format, detect_download_extension
+from .helper import select_best_artist as _select_best_artist
logger = logging.getLogger(__name__)
@@ -259,22 +260,7 @@ def download_track(temp_dir, item):
"""Downloads a single track from Jellyfin using admin credentials."""
try:
track_id = item['Id']
-
- # Try to get format from Container field first (most reliable)
- file_extension = '.tmp'
- try:
- container = item.get('Container')
- if container and isinstance(container, str) and container.strip():
- # Ensure container value is safe (no path separators, etc.)
- safe_container = container.strip().replace('/', '').replace('\\', '')
- if safe_container:
- file_extension = f".{safe_container}"
- logger.debug(f"Using Container field for format: {file_extension}")
- elif item.get('Path'):
- file_extension = os.path.splitext(item['Path'])[1] or '.tmp'
- except Exception as e:
- logger.debug(f"Error getting format from Container/Path, using .tmp: {e}")
-
+ file_extension = detect_download_extension(item)
download_url = f"{config.JELLYFIN_URL}/Items/{track_id}/Download"
local_filename = os.path.join(temp_dir, f"{track_id}{file_extension}")
with requests.get(download_url, headers=config.HEADERS, stream=True, timeout=REQUESTS_TIMEOUT) as r:
@@ -287,29 +273,6 @@ def download_track(temp_dir, item):
logger.error(f"Failed to download track {item.get('Name', 'Unknown')}: {e}", exc_info=True)
return None
-def _select_best_artist(item, title="Unknown"):
- """
- Selects the best artist field from Jellyfin item, prioritizing track artists over album artists.
- This helps avoid "Various Artists" issues in compilation albums.
- Returns tuple: (artist_name, artist_id)
- """
- # Priority: Artists array (track artists) > AlbumArtist > fallback
- # Jellyfin/Emby provides ArtistItems array with Id and Name
- 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')
- elif item.get('Artists') and len(item['Artists']) > 0:
- track_artist = item['Artists'][0] # Take first artist if multiple
- artist_id = None
- elif item.get('AlbumArtist'):
- track_artist = item['AlbumArtist']
- artist_id = None
- else:
- track_artist = 'Unknown Artist'
- artist_id = None
-
- return track_artist, artist_id
-
def get_all_songs(user_creds=None):
"""Fetches all songs from Jellyfin using admin or override credentials, paginated.
diff --git a/tasks/mediaserver_lyrion.py b/tasks/mediaserver/lyrion.py
similarity index 99%
rename from tasks/mediaserver_lyrion.py
rename to tasks/mediaserver/lyrion.py
index e3f8e6f1..968edc9d 100644
--- a/tasks/mediaserver_lyrion.py
+++ b/tasks/mediaserver/lyrion.py
@@ -1,12 +1,12 @@
-# tasks/mediaserver_lyrion.py
+# tasks/mediaserver/lyrion.py
-from tasks import mediaserver_http as requests
+from . import http as requests
import logging
import os
from urllib.parse import unquote, urlparse
import config
-from tasks.mediaserver_helper import detect_path_format
+from .helper import detect_path_format
logger = logging.getLogger(__name__)
@@ -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("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}")
diff --git a/tasks/mediaserver_navidrome.py b/tasks/mediaserver/navidrome.py
similarity index 99%
rename from tasks/mediaserver_navidrome.py
rename to tasks/mediaserver/navidrome.py
index 86eeadde..f085db5f 100644
--- a/tasks/mediaserver_navidrome.py
+++ b/tasks/mediaserver/navidrome.py
@@ -1,12 +1,12 @@
-# tasks/mediaserver_navidrome.py
+# tasks/mediaserver/navidrome.py
-from tasks import mediaserver_http as requests
+from . import http as requests
import logging
import os
import random
import config
-from tasks.mediaserver_helper import detect_path_format
+from .helper import detect_path_format
logger = logging.getLogger(__name__)
diff --git a/tasks/mediaserver_helper.py b/tasks/mediaserver_helper.py
deleted file mode 100644
index 34005c05..00000000
--- a/tasks/mediaserver_helper.py
+++ /dev/null
@@ -1,42 +0,0 @@
-"""Shared media server helper utilities."""
-
-import re
-
-
-def detect_path_format(tracks):
- """Classify track path samples as absolute, relative, none, or mixed."""
- def _is_absolute_path(path):
- if not path:
- return False
- path_str = str(path)
- lower = path_str.lower()
- return (
- path_str.startswith('/')
- or path_str.startswith('\\')
- or lower.startswith('file://')
- or re.match(r'^[A-Za-z]:[\\/]', path_str)
- )
-
- paths = []
- for track in tracks or []:
- if not isinstance(track, dict):
- continue
- # Support lowercase/uppercase path keys and legacy URL fields.
- path = (
- track.get('path')
- or track.get('Path')
- or track.get('url')
- or track.get('Url')
- )
- if path:
- paths.append(path)
-
- if not paths:
- return 'none'
-
- ratio = sum(1 for p in paths if _is_absolute_path(p)) / len(paths)
- if ratio >= 0.8:
- return 'absolute'
- if ratio <= 0.2:
- return 'relative'
- return 'mixed'
diff --git a/tasks/mediaserver_mpd.py b/tasks/mediaserver_mpd.py
deleted file mode 100644
index e4b6ed28..00000000
--- a/tasks/mediaserver_mpd.py
+++ /dev/null
@@ -1,307 +0,0 @@
-# tasks/mediaserver_mpd.py
-
-import logging
-import os
-from datetime import datetime
-import config
-from tasks import mediaserver_http as requests # <-- shared retrying HTTP session
-import random # <-- ADDED: For shuffling albums
-
-# Add the MPD client library dependency
-# NOTE: This implementation requires the 'python-mpd2' library.
-# Install it with: pip install python-mpd2
-try:
- import mpd
-except ImportError:
- # Handle the case where the library isn't installed.
- # You might want to log a more prominent warning or exit if MPD is the configured server type.
- pass
-
-logger = logging.getLogger(__name__)
-
-# ##############################################################################
-# MPD (MUSIC PLAYER DAEMON) IMPLEMENTATION
-# ##############################################################################
-
-def _connect():
- """Establishes a connection to the MPD server."""
- # Set use_unicode=True to ensure all communication with the server,
- # including file paths, is handled as UTF-8.
- client = mpd.MPDClient(use_unicode=True)
- client.timeout = 60
- client.idletimeout = 30
-
-
- try:
- logger.info(f"Calling MPD connect('{config.MPD_HOST}', {config.MPD_PORT}, timeout=None)")
- client.connect(config.MPD_HOST, config.MPD_PORT)
- logger.info(f"Successfully connected to MPD server. Status: {client.status()}")
-
- if config.MPD_PASSWORD:
- logger.info("Authenticating with MPD password")
- client.password(config.MPD_PASSWORD)
-
- return client
- except Exception as e:
- logger.error(f"Failed to connect or configure MPD server: {e}", exc_info=True)
- _disconnect_safely(client)
- return None
-
-def _format_song(song_dict):
- """Formats an MPD song dictionary to the standard format used in this script."""
- # The 'Id' will be the file path, which is unique.
- return {
- 'Id': song_dict.get('file'),
- 'Name': song_dict.get('title', os.path.basename(song_dict.get('file', ''))),
- 'AlbumArtist': song_dict.get('albumartist'),
- 'OriginalAlbumArtist': song_dict.get('albumartist'),
- 'Artist': song_dict.get('artist'),
- 'Album': song_dict.get('album'),
- 'Path': song_dict.get('file'),
- 'last-modified': song_dict.get('last-modified')
- }
-
-def _disconnect_safely(client):
- """Safely closes and disconnects the MPD client."""
- if not client:
- return
- try:
- client.close()
- client.disconnect()
- except (mpd.ConnectionError, IOError, BrokenPipeError):
- pass # Ignore errors on disconnect, as the connection might already be lost.
-
-def get_recent_albums(limit):
- """
- [EFFICIENT VERSION] Fetches a random selection of albums from MPD.
-
- NOTE: Finding the chronologically "most recent" albums requires a full
- scan of every song in the library, which is extremely slow on large collections.
- This function provides a fast and practical alternative by returning a random
- sample, which is much better for discovering content to analyze.
- """
- client = _connect()
- if not client:
- return []
-
- albums = []
- try:
- logger.info("Fetching a random selection of albums for analysis...")
-
- # This is a very fast command that gets all unique album names.
- album_names = client.list('album')
-
- # If the user wants all albums, don't shuffle, just format.
- fetch_all = (limit == 0)
- if fetch_all:
- logger.info(f"Formatting all {len(album_names)} albums.")
- albums_to_process = album_names
- else:
- logger.info(f"Found {len(album_names)} total albums. Shuffling to select {limit} random ones.")
- random.shuffle(album_names)
- albums_to_process = album_names[:limit]
-
- # Format the selected albums into the expected dictionary structure.
- # We use a placeholder date as the true 'last_modified' is unknown without a full scan.
- now = datetime.now()
- albums = [{'Id': name, 'Name': name, 'last_modified': now} for name in albums_to_process]
-
- logger.info(f"Selected {len(albums)} albums to process.")
-
- except Exception as e:
- logger.error(f"MPD get_recent_albums failed: {e}", exc_info=True)
- finally:
- _disconnect_safely(client)
-
- return albums
-
-def get_tracks_from_album(album_id):
- """Fetches all audio tracks for a given album name from MPD. album_id is the album name."""
- client = _connect()
- if not client:
- return []
-
- tracks = []
- try:
- # Use client.find("album", album_id) to search by metadata tag.
- songs = client.find("album", album_id)
- tracks = [_format_song(s) for s in songs if 'file' in s]
- logger.info(f"Found {len(tracks)} tracks for album '{album_id}'.")
- except Exception as e:
- logger.error(f"MPD get_tracks_from_album failed for album '{album_id}': {e}", exc_info=True)
- finally:
- _disconnect_safely(client)
- return tracks
-
-def download_track(temp_dir, item):
- """
- Downloads a track from a remote MPD server using its built-in HTTP streamer.
- This function assumes MPD's HTTP stream is available on port 8000.
- """
- try:
- track_path = item.get('Path')
- if not track_path:
- logger.error("MPD item has no 'Path' attribute to download.")
- return None
-
- # Construct the HTTP stream URL for the track.
- # This uses the same MPD_HOST but assumes port 8000 for the HTTP stream.
- # The path needs to be URL-encoded to handle special characters.
- from urllib.parse import quote
- encoded_path = quote(track_path)
- download_url = f"http://{config.MPD_HOST}:8000/{encoded_path}"
-
- logger.info(f"Downloading track from URL: {download_url}")
-
- # Use requests library to download the file in a streaming fashion
- with requests.get(download_url, stream=True) as r:
- r.raise_for_status() # This will raise an exception for bad status codes (4xx or 5xx)
-
- # Create a safe local filename
- file_extension = os.path.splitext(track_path)[1]
- track_id = os.path.basename(track_path).replace(file_extension, '')
- local_filename = os.path.join(temp_dir, f"{track_id}{file_extension}")
-
- # Write the content to the local file in chunks
- with open(local_filename, 'wb') as f:
- for chunk in r.iter_content(chunk_size=8192):
- f.write(chunk)
-
- logger.info(f"Successfully downloaded '{item.get('Name', 'Unknown')}' to '{local_filename}'")
- return local_filename
-
- except requests.exceptions.RequestException as e:
- logger.error(f"HTTP download failed for track {item.get('Name', 'Unknown')}: {e}", exc_info=True)
- return None
- except Exception as e:
- logger.error(f"An unexpected error occurred during download of {item.get('Name', 'Unknown')}: {e}", exc_info=True)
- return None
-
-def get_all_songs():
- """Fetches all songs from MPD using a robust, song-by-song method."""
- client = _connect()
- if not client:
- return []
-
- all_formatted_songs = []
- try:
- logger.info("Fetching all songs from MPD database (robust method)...")
-
- all_files = client.list('file')
- logger.info(f"Found {len(all_files)} files to process.")
-
- for i, file_path_dict in enumerate(all_files):
- try:
- # FIX: The list command returns a list of dicts. We need the value from the 'file' key.
- file_path_str = file_path_dict.get('file')
- if not file_path_str:
- continue
-
- song_info_list = client.listallinfo(file_path_str)
- if song_info_list and 'file' in song_info_list[0]:
- all_formatted_songs.append(_format_song(song_info_list[0]))
-
- if (i + 1) % 1000 == 0:
- logger.info(f"Formatted {i+1}/{len(all_files)} songs...")
- except Exception:
- # Ignore errors for individual files (e.g., playlist files, etc.)
- pass
-
- logger.info(f"Successfully formatted {len(all_formatted_songs)} songs from MPD")
-
- except Exception as e:
- logger.error(f"MPD get_all_songs failed: {e}", exc_info=True)
- finally:
- _disconnect_safely(client)
- return all_formatted_songs
-
-def get_playlist_by_name(playlist_name):
- """Finds an MPD playlist by its exact name."""
- client = _connect()
- if not client:
- return None
-
- try:
- playlists = client.listplaylists()
- for p in playlists:
- if p.get('playlist') == playlist_name:
- return {'Id': playlist_name, 'Name': playlist_name}
- except Exception as e:
- logger.error(f"MPD get_playlist_by_name failed for '{playlist_name}': {e}", exc_info=True)
- finally:
- _disconnect_safely(client)
- return None
-
-def create_playlist(base_name, item_ids):
- """Creates a new playlist on MPD. item_ids are file paths."""
- client = _connect()
- if not client:
- return
-
- try:
- # Check if playlist exists and clear it, otherwise MPD appends.
- if any(p.get('playlist') == base_name for p in client.listplaylists()):
- client.playlistclear(base_name)
- logger.info(f"Cleared existing MPD playlist '{base_name}'.")
-
- 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.")
- except Exception as e:
- logger.error(f"Exception creating MPD playlist '{base_name}': {e}", exc_info=True)
- finally:
- _disconnect_safely(client)
-
-def get_all_playlists():
- """Fetches all playlists from MPD."""
- client = _connect()
- if not client:
- return []
-
- playlists = []
- try:
- mpd_playlists = client.listplaylists()
- playlists = [{'Id': p.get('playlist'), 'Name': p.get('playlist')} for p in mpd_playlists]
- except Exception as e:
- logger.error(f"MPD get_all_playlists failed: {e}", exc_info=True)
- finally:
- _disconnect_safely(client)
- return playlists
-
-def delete_playlist(playlist_id):
- """Deletes a playlist on MPD. playlist_id is the playlist name."""
- client = _connect()
- if not client:
- return False
-
- success = False
- try:
- client.rm(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)
- finally:
- _disconnect_safely(client)
- return success
-
-# --- User-specific MPD functions (STUBS) ---
-# MPD is a single-user daemon and does not track play counts or last played times by default.
-def get_top_played_songs(limit, user_creds=None):
- """Not supported by MPD. Returns an empty list."""
- logger.warning("get_top_played_songs is not supported by the MPD backend.")
- return []
-
-def get_last_played_time(item_id, user_creds=None):
- """Not supported by MPD. Returns None."""
- logger.warning("get_last_played_time is not supported by the MPD backend.")
- return None
-
-def create_instant_playlist(playlist_name, item_ids, user_creds=None):
- """Creates a new instant playlist on MPD."""
- final_playlist_name = f"{playlist_name.strip()}_instant"
- # For MPD, this is the same as a regular playlist. user_creds are ignored.
- create_playlist(final_playlist_name, item_ids)
- # The return value for this function in other implementations is a dict.
- return {'Id': final_playlist_name, 'Name': final_playlist_name}
-
diff --git a/tasks/path_manager.py b/tasks/path_manager.py
index 3bb1fca2..5920dec6 100644
--- a/tasks/path_manager.py
+++ b/tasks/path_manager.py
@@ -290,7 +290,7 @@ def find_path_between_songs(start_item_id, end_item_id, Lreq=PATH_DEFAULT_LENGTH
final merge fails catastrophically.
"""
# Local import to prevent circular dependency
- from app_helper import get_score_data_by_ids, get_tracks_by_ids
+ from app_helper import get_score_data_by_ids
logger.info(f"Starting centroid path generation (with merge logic) from {start_item_id} to {end_item_id} with requested length {Lreq}.")
if metric is None:
diff --git a/tasks/provider_migration_matcher.py b/tasks/provider_migration_matcher.py
index 05e04dae..3c98dcb9 100644
--- a/tasks/provider_migration_matcher.py
+++ b/tasks/provider_migration_matcher.py
@@ -179,7 +179,7 @@ def _best_artist_old(row):
"""Track-level artist for a source (``score``) row.
Precedence: ``author`` → ``artist`` → ``album_artist``.
- ``score.author`` holds the track performer that mediaserver_*.py picked via
+ ``score.author`` holds the track performer that the tasks/mediaserver backends picked via
``_select_best_artist``, while ``score.album_artist`` preserves the
album-level artist (often "Various Artists" on compilations). Preferring
``author`` keeps compilation tracks matchable to their real performer on
diff --git a/tasks/provider_migration_tasks.py b/tasks/provider_migration_tasks.py
index 2e7efa66..6f51c37c 100644
--- a/tasks/provider_migration_tasks.py
+++ b/tasks/provider_migration_tasks.py
@@ -580,8 +580,6 @@ def _run_migration_transaction(cur, mapping, new_meta,
'emby': {'url': 'EMBY_URL', 'user_id': 'EMBY_USER_ID', 'token': 'EMBY_TOKEN'},
'navidrome': {'url': 'NAVIDROME_URL', 'user': 'NAVIDROME_USER', 'password': 'NAVIDROME_PASSWORD'},
'lyrion': {'url': 'LYRION_URL'},
- 'mpd': {'host': 'MPD_HOST', 'port': 'MPD_PORT', 'password': 'MPD_PASSWORD',
- 'music_directory': 'MPD_MUSIC_DIRECTORY'},
}
diff --git a/tasks/radio_manager.py b/tasks/radio_manager.py
index 30a75e59..170a7b11 100644
--- a/tasks/radio_manager.py
+++ b/tasks/radio_manager.py
@@ -14,7 +14,7 @@ def run_radio_playlists():
playlists on online-first sync clients (e.g. Symfonium on Navidrome)
that track playlists by ID.
- Falls back to create_playlist for MPD and other unsupported backends.
+ Falls back to create_playlist for unsupported backends.
"""
from app_helper import get_alchemy_radios
@@ -47,7 +47,7 @@ def run_radio_playlists():
try:
create_or_replace_playlist(playlist_name, item_ids)
except NotImplementedError:
- # MPD or unsupported backend: fall back to plain create.
+ # Unsupported backend: fall back to plain create.
create_playlist(playlist_name, item_ids)
created += 1
logger.info(f"Radio playlist '{playlist_name}' upserted with {len(item_ids)} tracks.")
diff --git a/tasks/sem_grove_manager.py b/tasks/sem_grove_manager.py
index da46d397..a0b007bf 100644
--- a/tasks/sem_grove_manager.py
+++ b/tasks/sem_grove_manager.py
@@ -31,9 +31,7 @@
import json
import logging
import math
-import re
import sys
-import tempfile
from typing import Dict, List, Optional
import numpy as np
@@ -122,22 +120,8 @@ def _make_merged_vector(
def _fetch_metadata(item_ids: List[str]) -> Dict[str, Dict]:
- if not item_ids:
- return {}
- from app_helper import get_score_data_by_ids
- try:
- rows = get_score_data_by_ids(item_ids)
- return {
- r["item_id"]: {
- "title": r.get("title", "") or "",
- "author": r.get("author", "") or "",
- "album": r.get("album", "") or "",
- }
- for r in rows
- }
- except Exception as exc:
- logger.warning("SemGrove metadata fetch failed: %s", exc)
- return {}
+ from .commons import fetch_track_metadata_map
+ return fetch_track_metadata_map(item_ids)
# ---------------------------------------------------------------------------
@@ -386,109 +370,33 @@ def _load_sem_grove_index_from_db() -> bool:
audio_dim = int(whitening["audio_dim"])
merged_dim = lyrics_dim + audio_dim
- # ---- Index binary ----
- cur.execute(
- "SELECT index_data, id_map_json, embedding_dimension "
- "FROM lyrics_index_data WHERE index_name = %s",
- (SEM_GROVE_INDEX_NAME,),
- )
- row = cur.fetchone()
-
- index_stream = None
- id_map_json = None
- db_dim = None
- try:
- if row:
- binary, id_map_json, db_dim = row
- index_stream = tempfile.TemporaryFile()
- index_stream.write(binary)
- index_stream.seek(0)
- else:
- seg_pattern = re.compile(r"^sem_grove_index_(\d+)_(\d+)$")
- parts = []
- total_expected = None
-
- with conn.cursor(name="sem_grove_index_segments") as seg_cur:
- seg_cur.itersize = 50
- seg_cur.execute(
- "SELECT index_name, index_data, id_map_json, embedding_dimension "
- "FROM lyrics_index_data WHERE index_name LIKE %s ESCAPE '\\'",
- (r"sem_grove_index\_%\_%",),
- )
- for name, part_data, part_id_map, part_dim in seg_cur:
- m = seg_pattern.match(name)
- if not m:
- continue
- part_no = int(m.group(1))
- total = int(m.group(2))
- if total_expected is None:
- total_expected = total
- elif total_expected != total:
- logger.error("SemGrove: segment total mismatch.")
- return False
- parts.append((part_no, part_data, part_id_map, part_dim))
-
- if total_expected is None or len(parts) != total_expected:
- logger.info(
- "SemGrove: no complete index found in DB "
- "(expected=%s, got=%d).", total_expected, len(parts)
- )
- return False
-
- parts.sort(key=lambda p: p[0])
- from .index_build_helpers import reassemble_segmented_id_map
- db_dim = parts[0][3]
- id_map_json = reassemble_segmented_id_map((p[0], p[2]) for p in parts)
- index_stream = tempfile.TemporaryFile()
- for _, part_data, _, _ in parts:
- index_stream.write(part_data)
- index_stream.seek(0)
-
- if index_stream is None or not id_map_json:
- return False
-
- if db_dim != merged_dim:
- logger.error(
- "SemGrove: dimension mismatch (db=%d, expected=%d).",
- db_dim, merged_dim,
- )
- return False
-
- loaded_index = voyager.Index.load(index_stream)
- loaded_index.ef = VOYAGER_QUERY_EF
-
- finally:
- if index_stream is not None:
- try:
- index_stream.close()
- except Exception:
- pass
-
- id_map = {int(k): v for k, v in json.loads(id_map_json).items()}
- reverse_id_map = {v: k for k, v in id_map.items()}
+ from .index_build_helpers import load_voyager_index_from_db
+ loaded = load_voyager_index_from_db(
+ conn, 'lyrics_index_data', SEM_GROVE_INDEX_NAME,
+ merged_dim, VOYAGER_QUERY_EF, label='SemGrove',
+ )
+ if loaded is None:
+ return False
+ loaded_index, id_map, reverse_id_map = loaded
- if not id_map:
- logger.warning("SemGrove: id_map is empty after load.")
- return False
+ _SEM_GROVE_CACHE.update({
+ "index": loaded_index,
+ "id_map": id_map,
+ "reverse_id_map": reverse_id_map,
+ "std_lyrics": std_lyrics,
+ "std_audio": std_audio,
+ "lyrics_dim": lyrics_dim,
+ "audio_dim": audio_dim,
+ "w_lyrics": w_lyrics,
+ "w_audio": w_audio,
+ "loaded": True,
+ "song_count": len(id_map),
+ })
- _SEM_GROVE_CACHE.update({
- "index": loaded_index,
- "id_map": id_map,
- "reverse_id_map": reverse_id_map,
- "std_lyrics": std_lyrics,
- "std_audio": std_audio,
- "lyrics_dim": lyrics_dim,
- "audio_dim": audio_dim,
- "w_lyrics": w_lyrics,
- "w_audio": w_audio,
- "loaded": True,
- "song_count": len(id_map),
- })
-
- logger.info(
- "SemGrove index loaded: %d items, dim=%d.", len(id_map), merged_dim
- )
- return True
+ logger.info(
+ "SemGrove index loaded: %d items, dim=%d.", len(id_map), merged_dim
+ )
+ return True
except Exception as exc:
logger.error("SemGrove index load failed: %s", exc, exc_info=True)
diff --git a/tasks/setup_manager.py b/tasks/setup_manager.py
index f14df93a..553c252e 100644
--- a/tasks/setup_manager.py
+++ b/tasks/setup_manager.py
@@ -301,3 +301,6 @@ def get_all_fields(self, config_module):
"overridden": overridden,
})
return fields
+
+
+setup_manager = SetupManager()
diff --git a/tasks/song_alchemy.py b/tasks/song_alchemy.py
index 4810fcb3..a8b985ce 100644
--- a/tasks/song_alchemy.py
+++ b/tasks/song_alchemy.py
@@ -3,17 +3,13 @@
import numpy as np
from .voyager_manager import find_nearest_neighbors_by_vector, find_nearest_neighbors_by_id, get_vector_by_id
+from .alchemy_projections import (
+ _project_to_2d,
+ _project_with_discriminant,
+)
from app_helper import get_score_data_by_ids, load_map_projection
import config
-try:
- # sklearn is already a dependency; import lazily for environments where it's present
- from sklearn.decomposition import PCA
- from sklearn.linear_model import LogisticRegression
-except Exception:
- PCA = None
- LogisticRegression = None
-
logger = logging.getLogger(__name__)
@@ -152,208 +148,6 @@ def _compute_centroid_from_items(items: List[dict]) -> np.ndarray:
return weighted_centroid
-def _project_to_2d(vectors: List[np.ndarray]) -> List[Tuple[float, float]]:
- """Simple PCA via SVD to project a list of vectors to 2D.
- Returns a list of (x, y) tuples in the same order as input vectors.
- If there are fewer than 2 vectors, returns zeros for all.
- """
- if not vectors:
- return []
- mat = np.vstack(vectors)
- # Center
- mean = np.mean(mat, axis=0)
- mat_c = mat - mean
- # SVD
- try:
- u, s, vh = np.linalg.svd(mat_c, full_matrices=False)
- except Exception:
- # Fallback: return zeros
- return [(0.0, 0.0) for _ in vectors]
- # Take first two principal components
- pcs = vh[:2]
- proj = mat_c.dot(pcs.T)
- # Normalize projection for nicer plotting
- if proj.size == 0:
- return [(0.0, 0.0) for _ in vectors]
- # Normalize preserving aspect ratio: use a single global scale so x/y units are comparable
- # center at zero
- proj_centered = proj - proj.mean(axis=0)
- max_abs = np.max(np.abs(proj_centered))
- if max_abs == 0:
- return [(0.0, 0.0) for _ in vectors]
- scaled = proj_centered / max_abs
- # clamp to [-1,1] for safety
- scaled = np.clip(scaled, -1.0, 1.0)
- return [(float(x), float(y)) for x, y in scaled]
-
-
-def _project_aligned_add_sub(vectors: List[np.ndarray], add_centroid: np.ndarray, subtract_centroid: np.ndarray) -> List[Tuple[float, float]]:
- """Project vectors to 2D where the x-axis is aligned with the vector
- from add_centroid -> subtract_centroid. The y-axis is the leading
- orthogonal component (first PC of residuals).
- This emphasizes separation along the add-vs-subtract direction.
- """
- if not vectors:
- return []
- # Convert list to matrix and center relative to add_centroid
- mat = np.vstack(vectors)
- rel = mat - add_centroid
- axis = subtract_centroid - add_centroid
- axis_norm = np.linalg.norm(axis)
- if axis_norm == 0:
- # Fallback to PCA if centroids coincide
- return _project_to_2d(vectors)
- axis_u = axis / axis_norm
-
- # Compute x coordinates as projection on axis
- x_coords = rel.dot(axis_u)
-
- # Remove axis component to get residuals for y-axis computation
- proj_on_axis = np.outer(x_coords, axis_u)
- residuals = rel - proj_on_axis
-
- # Find leading direction in residuals via SVD
- try:
- # If residuals are all near-zero, SVD will still succeed but produce small values
- u, s, vh = np.linalg.svd(residuals, full_matrices=False)
- if vh.shape[0] >= 1:
- y_u = vh[0]
- else:
- y_u = None
- except Exception:
- y_u = None
-
- if y_u is None or np.linalg.norm(y_u) == 0:
- # Create an arbitrary orthogonal vector to axis_u
- # pick an index where axis_u has smallest absolute value
- idx = int(np.argmin(np.abs(axis_u)))
- e = np.zeros_like(axis_u)
- e[idx] = 1.0
- y_u = e - np.dot(e, axis_u) * axis_u
- norm_y = np.linalg.norm(y_u)
- if norm_y == 0:
- # fallback
- return _project_to_2d(vectors)
- y_u = y_u / norm_y
- else:
- # ensure orthogonal to axis_u (numerical stability)
- y_u = y_u - np.dot(y_u, axis_u) * axis_u
- y_u_norm = np.linalg.norm(y_u)
- if y_u_norm == 0:
- return _project_to_2d(vectors)
- y_u = y_u / y_u_norm
-
- y_coords = residuals.dot(y_u)
-
- coords = np.vstack([x_coords, y_coords]).T
- # Center and scale uniformly so x and y share same units
- coords_centered = coords - coords.mean(axis=0)
- max_abs = np.max(np.abs(coords_centered))
- if max_abs == 0:
- return [(0.0, 0.0) for _ in vectors]
- scaled = coords_centered / max_abs
- scaled = np.clip(scaled, -1.0, 1.0)
- return [(float(x), float(y)) for x, y in scaled]
-
-
-def _project_with_umap(vectors: List[np.ndarray], n_components: int = 2) -> List[Tuple[float, float]]:
- """Try to project using UMAP if available. Raises ImportError if umap is not installed."""
- import umap
- if not vectors:
- return []
- mat = np.vstack(vectors)
- reducer = umap.UMAP(n_components=n_components, random_state=None, n_jobs=-1)
- embedding = reducer.fit_transform(mat)
- # Center and scale uniformly so x and y share same units
- emb_centered = embedding - embedding.mean(axis=0)
- max_abs = np.max(np.abs(emb_centered))
- if max_abs == 0:
- return [(0.0, 0.0) for _ in vectors]
- scaled = emb_centered / max_abs
- scaled = np.clip(scaled, -1.0, 1.0)
- return [(float(x), float(y)) for x, y in scaled]
-
-
-def _project_with_discriminant(add_vectors: List[np.ndarray], sub_vectors: List[np.ndarray], all_vectors: List[np.ndarray]) -> List[Tuple[float, float]]:
- """Compute a discriminant direction separating add and sub using PCA+LogisticRegression.
- Returns 2D coords for all_vectors projected onto (discriminant axis, residual axis).
- Falls back (raises) if sklearn not available or insufficient samples.
- """
- if LogisticRegression is None or PCA is None:
- raise RuntimeError('sklearn not available')
- # Need at least one sample in each class
- if not add_vectors or not sub_vectors:
- raise RuntimeError('Insufficient classes for discriminant')
-
- X_train = np.vstack([np.vstack(add_vectors), np.vstack(sub_vectors)])
- y_train = np.array([1] * len(add_vectors) + [0] * len(sub_vectors))
-
- n_samples, n_features = X_train.shape
- # Reduce dimensionality so training is stable (components <= n_samples-1)
- max_components = min(32, n_samples - 1, n_features)
- if max_components < 1:
- raise RuntimeError('Not enough samples for discriminant PCA')
-
- pca = PCA(n_components=max_components, random_state=42)
- Xp = pca.fit_transform(X_train)
-
- # Fit logistic regression with regularization for robustness
- try:
- clf = LogisticRegression(l1_ratio=0, C=1.0, solver='saga', max_iter=1000)
- clf.fit(Xp, y_train)
- except Exception:
- # Fallback with less regularization if solver fails
- clf = LogisticRegression(l1_ratio=0, C=0.1, solver='saga', max_iter=1000)
- clf.fit(Xp, y_train)
-
- # direction in PCA space
- coef = clf.coef_.ravel()
- norm = np.linalg.norm(coef)
- if norm == 0:
- raise RuntimeError('Discriminant produced zero vector')
- dir_pca = coef / norm
-
- # Project all vectors into PCA space then onto discriminant for x coords
- all_mat = np.vstack(all_vectors)
- all_pca = pca.transform(all_mat)
- x_coords = all_pca.dot(dir_pca)
-
- # Residuals in PCA space
- proj_on_dir = np.outer(x_coords, dir_pca)
- residuals = all_pca - proj_on_dir
- # y direction: leading PC of residuals
- try:
- u, s, vh = np.linalg.svd(residuals, full_matrices=False)
- if vh.shape[0] >= 1:
- y_u = vh[0]
- else:
- y_u = None
- except Exception:
- y_u = None
-
- if y_u is None or np.linalg.norm(y_u) == 0:
- # fallback: arbitrary orthogonal
- idx = int(np.argmin(np.abs(dir_pca)))
- e = np.zeros_like(dir_pca)
- e[idx] = 1.0
- y_u = e - np.dot(e, dir_pca) * dir_pca
- y_u = y_u / (np.linalg.norm(y_u) or 1.0)
- else:
- y_u = y_u - np.dot(y_u, dir_pca) * dir_pca
- y_u = y_u / (np.linalg.norm(y_u) or 1.0)
-
- y_coords = residuals.dot(y_u)
-
- coords = np.vstack([x_coords, y_coords]).T
- coords_centered = coords - coords.mean(axis=0)
- max_abs = np.max(np.abs(coords_centered))
- if max_abs == 0:
- return [(0.0, 0.0) for _ in all_vectors]
- scaled = coords_centered / max_abs
- scaled = np.clip(scaled, -1.0, 1.0)
- return [(float(x), float(y)) for x, y in scaled]
-
-
def song_alchemy(add_items=None, subtract_items=None, add_ids=None, subtract_ids=None, n_results: int = None, subtract_distance: float = None, temperature: float = None) -> dict:
"""Perform Song Alchemy:
- add_items: list of dicts with 'type' ('song'/'artist') and 'id'
diff --git a/tasks/voyager_manager.py b/tasks/voyager_manager.py
index 534334b1..17420d1c 100644
--- a/tasks/voyager_manager.py
+++ b/tasks/voyager_manager.py
@@ -21,11 +21,8 @@
from concurrent.futures import ThreadPoolExecutor, as_completed
from functools import lru_cache
import threading
-import math # Import math for ceiling function
from config import EMBEDDING_DIMENSION, INDEX_NAME, VOYAGER_METRIC, VOYAGER_QUERY_EF, VOYAGER_MAX_PART_SIZE_MB, MAX_SONGS_PER_ARTIST, DUPLICATE_DISTANCE_THRESHOLD_COSINE, DUPLICATE_DISTANCE_THRESHOLD_EUCLIDEAN, DUPLICATE_DISTANCE_CHECK_LOOKBACK, MOOD_SIMILARITY_THRESHOLD, SIMILARITY_ELIMINATE_DUPLICATES_DEFAULT, SIMILARITY_RADIUS_DEFAULT, MOOD_SIMILARITY_ENABLE
-# Import from other project modules
-from .mediaserver import create_instant_playlist
logger = logging.getLogger(__name__)
@@ -1322,8 +1319,7 @@ def create_playlist_from_ids(playlist_name: str, track_ids: list, user_creds: di
Creates a new playlist on the configured media server with the provided name and track IDs.
"""
try:
- # Use the mediaserver dispatcher (imported at module top) to create the playlist.
- # This avoids importing app_external which may not export the helper.
+ from .mediaserver import create_instant_playlist
created_playlist = create_instant_playlist(playlist_name, track_ids, user_creds=user_creds)
if not created_playlist:
diff --git a/templates/provider_migration.html b/templates/provider_migration.html
index 422b4f37..316e9f96 100644
--- a/templates/provider_migration.html
+++ b/templates/provider_migration.html
@@ -930,7 +930,7 @@ 6 Execution
sessionId = s.session_id;
}
// Populate the library checkbox list once we have a session. Hidden
- // for providers without a listable folder API (e.g. MPD).
+ // for providers without a listable folder API.
fetchMigLibraries();
unlock(3);
setActive(3);
diff --git a/test/README.md b/test/README.md
index bf91f4c3..e44c60c1 100644
--- a/test/README.md
+++ b/test/README.md
@@ -1,6 +1,12 @@
# AudioMuse-AI Developer Tests
-These integration tests are for developer purposes to verify the functionality of the AudioMuse-AI API endpoints. They are not included in the production Docker container and should be run from a local development machine.
+This folder contains the whole test suite:
+
+- `unit/` — fast, mock-based unit tests (run in CI by `.github/workflows/tests.yml` with `pytest test/unit/`)
+- `integration/` — integration tests and developer scripts that exercise the real models, database or a running AudioMuse-AI instance (run in CI by `.github/workflows/test.yml`)
+- support assets shared by the integration tests: `songs/`, `models/` (downloaded by CI), `lyrics_expected*.json`, `requirements.txt`, `docker-compose.yaml`, `nginx-confd/`, `provider_testing_stack/`
+
+The API endpoint tests below are for developer purposes to verify the functionality of the AudioMuse-AI API endpoints. They are not included in the production Docker container and should be run from a local development machine.
## Prerequisites
@@ -50,9 +56,9 @@ Once you are inside the `test` directory, follow these steps:
```
3. **Configure the API endpoint:**
- Open the `test.py` file and update the `BASE_URL` to point to your running AudioMuse-AI instance.
+ Open the `integration/test.py` file and update the `BASE_URL` to point to your running AudioMuse-AI instance.
```python
- # test/test.py
+ # test/integration/test.py
BASE_URL = 'http://YOUR_AUDIOMUSE_IP:8000'
```
@@ -61,7 +67,7 @@ Once you are inside the `test` directory, follow these steps:
To run all tests, execute the following command from your terminal (while in the `test` directory with the virtual environment activated):
```bash
-python test.py
+python integration/test.py
```
## Succesfull result example
@@ -111,5 +117,5 @@ See `./nginx-confd/reverse-proxy.conf` and the line with `proxy_pass http://audi
Do run the tests against it you have to start execute: `docker compose up`.
Set the base url to `BASE_URL = 'http://localhost:7777/am'`
Your audiomuse instance should have set the following configuration to true `ENABLE_PROXY_FIX`.
-And run the test by executing: `python test.py`.
+And run the test by executing: `python integration/test.py`.
diff --git a/tests/__init__.py b/test/__init__.py
similarity index 100%
rename from tests/__init__.py
rename to test/__init__.py
diff --git a/test/integration/__init__.py b/test/integration/__init__.py
new file mode 100644
index 00000000..a2650482
--- /dev/null
+++ b/test/integration/__init__.py
@@ -0,0 +1 @@
+# Integration tests package
diff --git a/test/test.py b/test/integration/test.py
similarity index 100%
rename from test/test.py
rename to test/integration/test.py
diff --git a/test/test_analysis_integration.py b/test/integration/test_analysis_integration.py
similarity index 99%
rename from test/test_analysis_integration.py
rename to test/integration/test_analysis_integration.py
index faeb91dc..2ea829b8 100644
--- a/test/test_analysis_integration.py
+++ b/test/integration/test_analysis_integration.py
@@ -8,7 +8,7 @@
# 3. Install requirements:
# pip install -r test/requirements.txt
# 4. Run this script:
-# pytest test/test_analysis_integration.py -s -q
+# pytest test/integration/test_analysis_integration.py -s -q
#
# Note: Test audio files should be in test/songs/
# ONNX models should be in test/models/
@@ -128,7 +128,7 @@ def test_real_analysis_runs_and_returns_expected_shape():
importable in the environment. It injects lightweight stubs for optional
AI/voyager libraries so module import succeeds.
"""
- project_root = Path(__file__).resolve().parents[1]
+ project_root = Path(__file__).resolve().parents[2]
models_dir = project_root / 'test' / 'models'
required = [
'musicnn_embedding.onnx', 'musicnn_prediction.onnx',
diff --git a/test/test_app_endpoints_integration.py b/test/integration/test_app_endpoints_integration.py
similarity index 98%
rename from test/test_app_endpoints_integration.py
rename to test/integration/test_app_endpoints_integration.py
index 8150e0a9..e2265b69 100644
--- a/test/test_app_endpoints_integration.py
+++ b/test/integration/test_app_endpoints_integration.py
@@ -13,7 +13,7 @@
Run locally:
pip install pgserver
- pytest test/test_app_endpoints_integration.py -m integration -s -v --tb=short
+ pytest test/integration/test_app_endpoints_integration.py -m integration -s -v --tb=short
"""
import os
import sys
@@ -25,7 +25,7 @@
import pytest
from flask import Flask
-_REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..'))
+_REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..'))
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
diff --git a/test/test_auth_users_integration.py b/test/integration/test_auth_users_integration.py
similarity index 97%
rename from test/test_auth_users_integration.py
rename to test/integration/test_auth_users_integration.py
index 9822e637..d52594db 100644
--- a/test/test_auth_users_integration.py
+++ b/test/integration/test_auth_users_integration.py
@@ -13,7 +13,7 @@
Run locally:
pip install pgserver
- pytest test/test_auth_users_integration.py -m integration -s -v --tb=short
+ pytest test/integration/test_auth_users_integration.py -m integration -s -v --tb=short
"""
import os
import sys
@@ -21,7 +21,7 @@
import pytest
-_REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..'))
+_REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..'))
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
diff --git a/test/test_clap_analysis_integration.py b/test/integration/test_clap_analysis_integration.py
similarity index 97%
rename from test/test_clap_analysis_integration.py
rename to test/integration/test_clap_analysis_integration.py
index 4f0b7804..0bbbcd9a 100644
--- a/test/test_clap_analysis_integration.py
+++ b/test/integration/test_clap_analysis_integration.py
@@ -8,7 +8,7 @@
# 3. Install requirements:
# pip install -r test/requirements.txt
# 4. Run this script:
-# pytest test/test_clap_analysis_integration.py -s -q
+# pytest test/integration/test_clap_analysis_integration.py -s -q
#
# Note: Test audio files should be in test/songs/
# CLAP ONNX models:
@@ -62,7 +62,7 @@ def test_clap_analysis_runs_and_shows_output():
'acoustic': 0.116278,
},
}
- project_root = Path(__file__).resolve().parents[1]
+ project_root = Path(__file__).resolve().parents[2]
models_dir = project_root / 'test' / 'models'
clap_audio_model = models_dir / 'model_epoch_36.onnx'
clap_text_model = models_dir / 'clap_text_model.onnx'
@@ -212,5 +212,5 @@ def test_clap_analysis_runs_and_shows_output():
if __name__ == '__main__':
- # Allow running directly with: python test/test_clap_analysis_integration.py
+ # Allow running directly with: python test/integration/test_clap_analysis_integration.py
pytest.main([__file__, '-s', '-v'])
diff --git a/test/test_gpu_status.py b/test/integration/test_gpu_status.py
similarity index 99%
rename from test/test_gpu_status.py
rename to test/integration/test_gpu_status.py
index 584d5650..f32ecd3d 100644
--- a/test/test_gpu_status.py
+++ b/test/integration/test_gpu_status.py
@@ -8,7 +8,7 @@
3. cuML/cupy for clustering
Usage:
- docker exec audiomuse-ai-worker-instance-dev python3 /app/test/test_gpu_status.py
+ docker exec audiomuse-ai-worker-instance-dev python3 /app/test/integration/test_gpu_status.py
"""
import sys
diff --git a/test/test_lyrics_analysis_integration.py b/test/integration/test_lyrics_analysis_integration.py
similarity index 99%
rename from test/test_lyrics_analysis_integration.py
rename to test/integration/test_lyrics_analysis_integration.py
index f352ff17..50a1b23f 100644
--- a/test/test_lyrics_analysis_integration.py
+++ b/test/integration/test_lyrics_analysis_integration.py
@@ -134,7 +134,7 @@ def test_real_lyrics_analysis_runs_and_matches_expected_vectors(monkeypatch):
and checks the gte-multilingual-base embedding + axis vector against
pre-recorded values via cosine similarity (threshold = 0.98).
"""
- project_root = Path(__file__).resolve().parents[1]
+ project_root = Path(__file__).resolve().parents[2]
models_dir = project_root / 'test' / 'models'
expected_path = project_root / 'test' / 'lyrics_expected_gte_512.json'
diff --git a/test/test_provider_migration_integration.py b/test/integration/test_provider_migration_integration.py
similarity index 99%
rename from test/test_provider_migration_integration.py
rename to test/integration/test_provider_migration_integration.py
index c4da0abb..a3319605 100644
--- a/test/test_provider_migration_integration.py
+++ b/test/integration/test_provider_migration_integration.py
@@ -43,7 +43,7 @@
Run locally:
pip install pgserver
- pytest test/test_provider_migration_integration.py -s -v --tb=short
+ pytest test/integration/test_provider_migration_integration.py -s -v --tb=short
"""
import importlib.util
import json
@@ -68,7 +68,7 @@ def _load_module(mod_name, *rel_parts):
if mod_name in sys.modules:
return sys.modules[mod_name]
repo_root = os.path.normpath(
- os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..')
)
if repo_root not in sys.path:
sys.path.insert(0, repo_root)
diff --git a/test/verify_onnx_embeddings.py b/test/integration/verify_onnx_embeddings.py
similarity index 99%
rename from test/verify_onnx_embeddings.py
rename to test/integration/verify_onnx_embeddings.py
index 93099a70..d4667a9a 100644
--- a/test/verify_onnx_embeddings.py
+++ b/test/integration/verify_onnx_embeddings.py
@@ -11,7 +11,7 @@
import librosa.feature
# Add parent directory to path
-sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
def compare_pytorch_vs_onnx():
"""Compare embeddings from PyTorch .pt model vs ONNX model"""
@@ -21,7 +21,7 @@ def compare_pytorch_vs_onnx():
print("=" * 80)
# Find all test audio files
- test_songs_dir = os.path.join(os.path.dirname(__file__), "songs")
+ test_songs_dir = os.path.join(os.path.dirname(__file__), "..", "songs")
test_audio_files = []
if os.path.exists(test_songs_dir):
diff --git a/test/requirements.txt b/test/requirements.txt
index 67657f7c..d6ba75ca 100644
--- a/test/requirements.txt
+++ b/test/requirements.txt
@@ -41,7 +41,6 @@ sqlglot
six
flasgger
umap-learn
-python-mpd2
google-genai==1.57.0
argon2-cffi==25.1.0
PyJWT==2.12.1
diff --git a/tests/unit/__init__.py b/test/unit/__init__.py
similarity index 100%
rename from tests/unit/__init__.py
rename to test/unit/__init__.py
diff --git a/tests/conftest.py b/test/unit/conftest.py
similarity index 91%
rename from tests/conftest.py
rename to test/unit/conftest.py
index 7a0dfd0a..08762e32 100644
--- a/tests/conftest.py
+++ b/test/unit/conftest.py
@@ -2,10 +2,16 @@
Centralises duplicated helpers across test files:
- importlib bypass loader (avoids tasks/__init__.py -> pydub -> audioop chain)
-- Session-scoped module fixtures for mcp_server, ai_mcp_client, mediaserver_localfiles
+- Session-scoped module fixtures for mcp_server, ai_mcp_client
- FakeRow / mock-connection helpers
- Autouse config restoration fixture
"""
+import sys as _sys
+if _sys.platform == 'win32':
+ import multiprocessing as _mp
+ _o = _mp.get_context
+ _mp.get_context = lambda m=None: _o('spawn') if m == 'fork' else _o(m)
+
import os
import sys
import importlib.util
@@ -27,7 +33,7 @@ def _import_module(mod_name: str, relative_path: str):
(e.g. 'tasks/mcp_helper.py').
"""
repo_root = os.path.normpath(
- os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..')
)
mod_path = os.path.normpath(os.path.join(repo_root, relative_path))
@@ -53,15 +59,6 @@ def mcp_server_mod():
return _import_module('tasks.mcp_helper', 'tasks/mcp_helper.py')
-@pytest.fixture(scope='session')
-def localfiles_mod():
- """Load tasks.mediaserver_localfiles directly (session-scoped)."""
- return _import_module(
- 'tasks.mediaserver_localfiles',
- 'tasks/mediaserver_localfiles.py',
- )
-
-
# ---------------------------------------------------------------------------
# DB mock helpers
# ---------------------------------------------------------------------------
diff --git a/tests/unit/test_ai.py b/test/unit/test_ai.py
similarity index 100%
rename from tests/unit/test_ai.py
rename to test/unit/test_ai.py
diff --git a/tests/unit/test_analysis.py b/test/unit/test_analysis.py
similarity index 99%
rename from tests/unit/test_analysis.py
rename to test/unit/test_analysis.py
index 4dcb462e..11e937d6 100644
--- a/tests/unit/test_analysis.py
+++ b/test/unit/test_analysis.py
@@ -7,7 +7,7 @@
# 3. Install requirements:
# pip install -r test/requirements.txt
# 4. Run this script:
-# python -m pytest tests/unit/test_analysis.py --tb=short
+# python -m pytest test/unit/test_analysis.py --tb=short
"""Unit tests for tasks/analysis.py"""
import numpy as np
diff --git a/tests/unit/test_api_sync.py b/test/unit/test_api_sync.py
similarity index 97%
rename from tests/unit/test_api_sync.py
rename to test/unit/test_api_sync.py
index 7d8298dc..b84e4a8b 100644
--- a/tests/unit/test_api_sync.py
+++ b/test/unit/test_api_sync.py
@@ -161,19 +161,6 @@ def _setup_ids(cur, tracks=None):
cur._fetchall_queue.append(tracks if tracks is not None else [])
-# --------------------------------------------------------------------------- #
-# Entry gate
-# --------------------------------------------------------------------------- #
-
-class TestMpdGate:
- def test_mpd_returns_501(self, bp_mod, client):
- import config
- config.MEDIASERVER_TYPE = 'mpd'
- resp = client.get('/api/sync?limit=1')
- assert resp.status_code == 501
- assert 'mpd' in resp.get_json()['error'].lower()
-
-
# --------------------------------------------------------------------------- #
# Envelope (payload)
# --------------------------------------------------------------------------- #
diff --git a/tests/unit/test_app_alchemy_anchor.py b/test/unit/test_app_alchemy_anchor.py
similarity index 100%
rename from tests/unit/test_app_alchemy_anchor.py
rename to test/unit/test_app_alchemy_anchor.py
diff --git a/tests/unit/test_app_alchemy_payload.py b/test/unit/test_app_alchemy_payload.py
similarity index 100%
rename from tests/unit/test_app_alchemy_payload.py
rename to test/unit/test_app_alchemy_payload.py
diff --git a/tests/unit/test_app_alchemy_radio.py b/test/unit/test_app_alchemy_radio.py
similarity index 99%
rename from tests/unit/test_app_alchemy_radio.py
rename to test/unit/test_app_alchemy_radio.py
index 74003e52..2d478df3 100644
--- a/tests/unit/test_app_alchemy_radio.py
+++ b/test/unit/test_app_alchemy_radio.py
@@ -242,8 +242,8 @@ def test_radio_with_no_results_creates_no_playlist(self, mock_alchemy, mock_upse
class TestDeletePlaylistsBySuffix:
@patch('tasks.mediaserver.config')
- @patch('tasks.mediaserver.jellyfin_get_all_playlists')
- @patch('tasks.mediaserver.jellyfin_delete_playlist')
+ @patch('tasks.mediaserver.jellyfin.get_all_playlists')
+ @patch('tasks.mediaserver.jellyfin.delete_playlist')
def test_only_deletes_radio_suffix_playlists(self, mock_delete, mock_get, mock_config):
from tasks.mediaserver import delete_playlists_by_suffix
diff --git a/tests/unit/test_app_analysis.py b/test/unit/test_app_analysis.py
similarity index 100%
rename from tests/unit/test_app_analysis.py
rename to test/unit/test_app_analysis.py
diff --git a/tests/unit/test_app_auth.py b/test/unit/test_app_auth.py
similarity index 100%
rename from tests/unit/test_app_auth.py
rename to test/unit/test_app_auth.py
diff --git a/tests/unit/test_app_backup_restore.py b/test/unit/test_app_backup_restore.py
similarity index 100%
rename from tests/unit/test_app_backup_restore.py
rename to test/unit/test_app_backup_restore.py
diff --git a/tests/unit/test_app_chat.py b/test/unit/test_app_chat.py
similarity index 100%
rename from tests/unit/test_app_chat.py
rename to test/unit/test_app_chat.py
diff --git a/tests/unit/test_app_chat_ssrf.py b/test/unit/test_app_chat_ssrf.py
similarity index 100%
rename from tests/unit/test_app_chat_ssrf.py
rename to test/unit/test_app_chat_ssrf.py
diff --git a/tests/unit/test_app_clustering.py b/test/unit/test_app_clustering.py
similarity index 100%
rename from tests/unit/test_app_clustering.py
rename to test/unit/test_app_clustering.py
diff --git a/tests/unit/test_app_cron.py b/test/unit/test_app_cron.py
similarity index 95%
rename from tests/unit/test_app_cron.py
rename to test/unit/test_app_cron.py
index f0d410fa..b29cc9bc 100644
--- a/tests/unit/test_app_cron.py
+++ b/test/unit/test_app_cron.py
@@ -3,7 +3,7 @@
Verifies:
- Empty fingerprint results → previous playlist is preserved (no upsert call)
- Non-empty results → create_or_replace_playlist called with the constant name
-- Backend that raises NotImplementedError (e.g. MPD) → falls back to legacy
+- Backend that raises NotImplementedError → falls back to legacy
date-suffixed create_playlist_from_ids path
"""
from unittest.mock import MagicMock, patch
@@ -72,7 +72,7 @@ def test_sonic_fingerprint_branch_calls_upsert_with_constant_name(mock_get_db, _
@patch('app_cron.cron_matches_now', return_value=True)
@patch('app_cron.get_db')
-def test_sonic_fingerprint_branch_falls_back_for_mpd(mock_get_db, _matches):
+def test_sonic_fingerprint_branch_falls_back_for_unsupported_backend(mock_get_db, _matches):
"""Backend raising NotImplementedError → legacy create_playlist_from_ids called."""
from app_cron import run_due_cron_jobs
diff --git a/tests/unit/test_app_cron_parsing.py b/test/unit/test_app_cron_parsing.py
similarity index 100%
rename from tests/unit/test_app_cron_parsing.py
rename to test/unit/test_app_cron_parsing.py
diff --git a/tests/unit/test_app_dashboard_parsing.py b/test/unit/test_app_dashboard_parsing.py
similarity index 100%
rename from tests/unit/test_app_dashboard_parsing.py
rename to test/unit/test_app_dashboard_parsing.py
diff --git a/tests/unit/test_app_helper_enrichment.py b/test/unit/test_app_helper_enrichment.py
similarity index 100%
rename from tests/unit/test_app_helper_enrichment.py
rename to test/unit/test_app_helper_enrichment.py
diff --git a/tests/unit/test_app_helper_task_note.py b/test/unit/test_app_helper_task_note.py
similarity index 100%
rename from tests/unit/test_app_helper_task_note.py
rename to test/unit/test_app_helper_task_note.py
diff --git a/tests/unit/test_app_map_helpers.py b/test/unit/test_app_map_helpers.py
similarity index 100%
rename from tests/unit/test_app_map_helpers.py
rename to test/unit/test_app_map_helpers.py
diff --git a/tests/unit/test_artist_gmm_manager.py b/test/unit/test_artist_gmm_manager.py
similarity index 100%
rename from tests/unit/test_artist_gmm_manager.py
rename to test/unit/test_artist_gmm_manager.py
diff --git a/tests/unit/test_artist_metadata_codec.py b/test/unit/test_artist_metadata_codec.py
similarity index 98%
rename from tests/unit/test_artist_metadata_codec.py
rename to test/unit/test_artist_metadata_codec.py
index 08a59a91..df7e0c91 100644
--- a/tests/unit/test_artist_metadata_codec.py
+++ b/test/unit/test_artist_metadata_codec.py
@@ -1,4 +1,4 @@
-# tests/unit/test_artist_metadata_codec.py
+# test/unit/test_artist_metadata_codec.py
"""
Unit tests for the artist metadata storage helpers in
``tasks/index_build_helpers.py``:
@@ -267,7 +267,7 @@ def test_store_segmented_path(self):
n_parts = len(inserts)
for idx, (sql, params) in enumerate(inserts, start=1):
assert "INSERT INTO artist_metadata_data" in sql
- assert "ON CONFLICT" not in sql
+ assert "ON CONFLICT" in sql # segmented inserts are now idempotent (ON CONFLICT DO UPDATE)
assert params[0] == f"artist_metadata_{idx}_{n_parts}"
def test_load_returns_none_when_no_rows(self):
diff --git a/tests/unit/test_clap_text_search.py b/test/unit/test_clap_text_search.py
similarity index 100%
rename from tests/unit/test_clap_text_search.py
rename to test/unit/test_clap_text_search.py
diff --git a/tests/unit/test_clustering.py b/test/unit/test_clustering.py
similarity index 100%
rename from tests/unit/test_clustering.py
rename to test/unit/test_clustering.py
diff --git a/tests/unit/test_clustering_helper.py b/test/unit/test_clustering_helper.py
similarity index 100%
rename from tests/unit/test_clustering_helper.py
rename to test/unit/test_clustering_helper.py
diff --git a/tests/unit/test_clustering_postprocessing.py b/test/unit/test_clustering_postprocessing.py
similarity index 100%
rename from tests/unit/test_clustering_postprocessing.py
rename to test/unit/test_clustering_postprocessing.py
diff --git a/tests/unit/test_commons.py b/test/unit/test_commons.py
similarity index 100%
rename from tests/unit/test_commons.py
rename to test/unit/test_commons.py
diff --git a/tests/unit/test_config_obsolete_fields.py b/test/unit/test_config_obsolete_fields.py
similarity index 100%
rename from tests/unit/test_config_obsolete_fields.py
rename to test/unit/test_config_obsolete_fields.py
diff --git a/tests/unit/test_error_manager.py b/test/unit/test_error_manager.py
similarity index 100%
rename from tests/unit/test_error_manager.py
rename to test/unit/test_error_manager.py
diff --git a/tests/unit/test_external_search_validation.py b/test/unit/test_external_search_validation.py
similarity index 100%
rename from tests/unit/test_external_search_validation.py
rename to test/unit/test_external_search_validation.py
diff --git a/tests/unit/test_flask_app.py b/test/unit/test_flask_app.py
similarity index 100%
rename from tests/unit/test_flask_app.py
rename to test/unit/test_flask_app.py
diff --git a/test/unit/test_import_architecture.py b/test/unit/test_import_architecture.py
new file mode 100644
index 00000000..d8c0fd18
--- /dev/null
+++ b/test/unit/test_import_architecture.py
@@ -0,0 +1,204 @@
+"""Architecture gate for the module-level import graph.
+
+Only module-level (eager) imports count here; function-level imports are the
+sanctioned escape hatch used across the codebase (mediaserver providers,
+voyager/app_helper consumers, config's DB-override loader). Three invariants
+keep the graph flat and acyclic so deep chains and cycles cannot creep back in:
+
+1. Foundation modules stay leaves: they import nothing internal at module level.
+2. No module-level import cycles, except the lyrics package init whose
+ try/except fallback design is deliberately order-dependent.
+3. No eager import chain may exceed MAX_CHAIN modules (app -> blueprint ->
+ hub/manager -> leaf). Anything deeper must use a function-level import.
+"""
+
+import ast
+import os
+from collections import defaultdict
+from functools import lru_cache
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+
+EXCLUDED_DIRS = {
+ ".git", ".venv", ".venv-windows", "node_modules", "__pycache__",
+ "build", "dist", "pginstall", "native-build", "test",
+}
+
+LEAF_MODULES = {
+ "config",
+ "tz_helper",
+ "error.error_dictionary",
+}
+
+ALLOWED_CYCLES = {
+ frozenset({"lyrics", "lyrics.lyrics_transcriber"}),
+ frozenset({"error", "error.error_manager"}),
+}
+
+MAX_CHAIN = 6 # allows for package __init__.py → submodule edges adding 1-2 phantom hops
+
+
+def _collect_modules():
+ modules = {}
+ for dirpath, dirnames, filenames in os.walk(REPO_ROOT):
+ dirnames[:] = [d for d in dirnames if d not in EXCLUDED_DIRS and not d.startswith(".")]
+ for filename in filenames:
+ if not filename.endswith(".py"):
+ continue
+ path = Path(dirpath) / filename
+ parts = list(path.relative_to(REPO_ROOT).parts)
+ if parts[-1] == "__init__.py":
+ parts = parts[:-1]
+ else:
+ parts[-1] = parts[-1][:-3]
+ if not parts:
+ continue
+ modules[".".join(parts)] = path
+ return modules
+
+
+def _resolve_relative(module, level, current, is_package):
+ base = current.split(".") if is_package else current.split(".")[:-1]
+ if level > 1:
+ base = base[: len(base) - (level - 1)]
+ if module:
+ base = base + module.split(".")
+ return ".".join(base)
+
+
+def _build_eager_graph(modules):
+ graph = defaultdict(set)
+ for name, path in modules.items():
+ tree = ast.parse(path.read_text(encoding="utf-8", errors="replace"))
+ nested = set()
+ for node in ast.walk(tree):
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
+ for child in ast.walk(node):
+ if child is not node:
+ nested.add(id(child))
+ is_package = path.name == "__init__.py"
+ for node in ast.walk(tree):
+ if id(node) in nested:
+ continue
+ if isinstance(node, ast.Import):
+ targets = [alias.name for alias in node.names]
+ elif isinstance(node, ast.ImportFrom):
+ base = _resolve_relative(node.module or "", node.level, name, is_package) if node.level else (node.module or "")
+ targets = [base] + [f"{base}.{alias.name}" for alias in node.names if base]
+ else:
+ continue
+ for target in targets:
+ parts = target.split(".")
+ for i in range(1, len(parts) + 1):
+ candidate = ".".join(parts[:i])
+ if candidate in modules and candidate != name:
+ graph[name].add(candidate)
+ return graph
+
+
+def _find_cycles(graph, modules):
+ index = {}
+ low = {}
+ on_stack = {}
+ stack = []
+ sccs = []
+ counter = [0]
+ for root in sorted(modules):
+ if root in index:
+ continue
+ work = [(root, 0)]
+ while work:
+ node, pointer = work[-1]
+ if pointer == 0:
+ index[node] = low[node] = counter[0]
+ counter[0] += 1
+ stack.append(node)
+ on_stack[node] = True
+ advanced = False
+ successors = sorted(graph.get(node, ()))
+ for i in range(pointer, len(successors)):
+ succ = successors[i]
+ if succ not in index:
+ work[-1] = (node, i + 1)
+ work.append((succ, 0))
+ advanced = True
+ break
+ if on_stack.get(succ):
+ low[node] = min(low[node], index[succ])
+ if advanced:
+ continue
+ work.pop()
+ if low[node] == index[node]:
+ component = []
+ while True:
+ member = stack.pop()
+ on_stack[member] = False
+ component.append(member)
+ if member == node:
+ break
+ if len(component) > 1:
+ sccs.append(frozenset(component))
+ if work:
+ parent = work[-1][0]
+ low[parent] = min(low[parent], low[node])
+ return sccs
+
+
+def _longest_chain(graph, modules):
+ cache = {}
+
+ def depth_from(node, path):
+ if node in cache:
+ return cache[node]
+ best = (1, (node,))
+ for succ in graph.get(node, ()):
+ if succ in path:
+ continue
+ sub_len, sub_chain = depth_from(succ, path | {node})
+ if 1 + sub_len > best[0]:
+ best = (1 + sub_len, (node,) + sub_chain)
+ if not any(s in path for s in graph.get(node, ())):
+ cache[node] = best
+ return best
+
+ overall = (0, ())
+ for node in modules:
+ candidate = depth_from(node, frozenset())
+ if candidate[0] > overall[0]:
+ overall = candidate
+ return overall
+
+
+@lru_cache(maxsize=1)
+def _graph():
+ modules = _collect_modules()
+ return modules, _build_eager_graph(modules)
+
+
+def test_foundation_modules_are_leaves():
+ _, graph = _graph()
+ violations = {leaf: sorted(graph.get(leaf, ())) for leaf in LEAF_MODULES if graph.get(leaf)}
+ assert not violations, (
+ f"Foundation modules must not import project modules at module level "
+ f"(move the import inside the function that uses it): {violations}"
+ )
+
+
+def test_no_module_level_import_cycles():
+ modules, graph = _graph()
+ cycles = [set(c) for c in _find_cycles(graph, modules) if c not in ALLOWED_CYCLES]
+ assert not cycles, (
+ f"Module-level import cycles detected (break them with a function-level "
+ f"import on one side): {cycles}"
+ )
+
+
+def test_eager_import_chains_stay_shallow():
+ modules, graph = _graph()
+ length, chain = _longest_chain(graph, modules)
+ assert length <= MAX_CHAIN, (
+ f"Eager import chain of {length} modules exceeds the maximum of "
+ f"{MAX_CHAIN}: {' -> '.join(chain)}. Convert one edge to a "
+ f"function-level import to flatten it."
+ )
diff --git a/tests/unit/test_index_build_helpers.py b/test/unit/test_index_build_helpers.py
similarity index 93%
rename from tests/unit/test_index_build_helpers.py
rename to test/unit/test_index_build_helpers.py
index ba7df568..641a3a62 100644
--- a/tests/unit/test_index_build_helpers.py
+++ b/test/unit/test_index_build_helpers.py
@@ -1,4 +1,4 @@
-# tests/unit/test_index_build_helpers.py
+# test/unit/test_index_build_helpers.py
"""
Unit tests for tasks/index_build_helpers.py
@@ -6,8 +6,6 @@
- stream_embeddings_to_buffer: side-connection streaming into a pre-allocated
numpy buffer; identifier validation; NULL/wrong-dim skipping; buffer
growth when the COUNT hint under-estimates due to concurrent writes.
-- build_voyager_index_bytes: rejects empty/wrong-shape buffers, coerces
- non-float32 input, round-trips through voyager.Index.load.
- store_voyager_index_segmented: single-row vs segmented persistence,
identifier validation, empty-bytes guard.
- build_id_map / _split_bytes / _resolve_voyager_space / _validate_sql_identifier.
@@ -269,65 +267,6 @@ def test_none_defaults_to_angular(self):
assert _helpers._resolve_voyager_space(None) == voyager.Space.Cosine
-class TestBuildVoyagerIndexBytes:
- def test_rejects_empty_buffer(self):
- try:
- import voyager # noqa: F401
- except ImportError:
- pytest.skip("voyager not installed")
- with pytest.raises(ValueError, match="empty"):
- _helpers.build_voyager_index_bytes(
- np.empty((0, 8), dtype=np.float32), 8,
- )
-
- def test_rejects_dim_mismatch(self):
- try:
- import voyager # noqa: F401
- except ImportError:
- pytest.skip("voyager not installed")
- with pytest.raises(ValueError, match="dim"):
- _helpers.build_voyager_index_bytes(
- np.zeros((3, 7), dtype=np.float32), 8,
- )
-
- def test_rejects_one_dim_buffer(self):
- try:
- import voyager # noqa: F401
- except ImportError:
- pytest.skip("voyager not installed")
- with pytest.raises(ValueError, match="2-D"):
- _helpers.build_voyager_index_bytes(
- np.zeros(8, dtype=np.float32), 8,
- )
-
- def test_round_trip_load(self):
- try:
- import voyager
- except ImportError:
- pytest.skip("voyager not installed")
-
- rng = np.random.default_rng(42)
- buf = rng.standard_normal((10, 16)).astype(np.float32)
-
- index_bytes = _helpers.build_voyager_index_bytes(buf, 16, metric="angular")
- assert isinstance(index_bytes, (bytes, bytearray)) and len(index_bytes) > 0
-
- import io
- loaded = voyager.Index.load(io.BytesIO(index_bytes))
- assert len(loaded) == 10
- neighbour_ids, _ = loaded.query(buf[3], k=1)
- assert int(neighbour_ids[0]) == 3
-
- def test_coerces_non_float32_input(self):
- try:
- import voyager # noqa: F401
- except ImportError:
- pytest.skip("voyager not installed")
- buf64 = np.random.default_rng(0).standard_normal((4, 8)).astype(np.float64)
- out = _helpers.build_voyager_index_bytes(buf64, 8, metric="angular")
- assert len(out) > 0
-
-
class TestStoreVoyagerIndexSegmented:
def _mock_conn(self, captured):
mock_cur = MagicMock()
diff --git a/tests/unit/test_index_rebuild_delegation.py b/test/unit/test_index_rebuild_delegation.py
similarity index 100%
rename from tests/unit/test_index_rebuild_delegation.py
rename to test/unit/test_index_rebuild_delegation.py
diff --git a/tests/unit/test_index_rebuild_integration.py b/test/unit/test_index_rebuild_integration.py
similarity index 87%
rename from tests/unit/test_index_rebuild_integration.py
rename to test/unit/test_index_rebuild_integration.py
index 16919f1b..8cf2a8d7 100644
--- a/tests/unit/test_index_rebuild_integration.py
+++ b/test/unit/test_index_rebuild_integration.py
@@ -1,4 +1,4 @@
-# tests/unit/test_index_rebuild_integration.py
+# test/unit/test_index_rebuild_integration.py
"""
Integration-level tests for the index rebuild/load glue that the helper-level
unit tests don't reach:
@@ -25,9 +25,10 @@
import json
import sys
import types
+from contextlib import ExitStack, contextmanager
import pytest
-from unittest.mock import MagicMock, patch, DEFAULT
+from unittest.mock import MagicMock, patch
# artist_gmm_manager imports voyager + sklearn at module load; skip cleanly if absent.
@@ -184,6 +185,10 @@ def test_num_elements_mismatch_resets_cache(self):
analysis_mod = None
try:
import tasks.analysis as analysis_mod # noqa: E402 (heavy: librosa/onnx)
+ import tasks.voyager_manager # noqa: F401 (builder modules patched in _patched)
+ import tasks.clap_text_search # noqa: F401
+ import tasks.lyrics_manager # noqa: F401
+ import tasks.sem_grove_manager # noqa: F401
except Exception:
analysis_mod = None
@@ -199,18 +204,38 @@ def test_num_elements_mismatch_resets_cache(self):
"build_and_store_artist_projection",
]
+_BUILDER_SOURCE_MODULES = {
+ "build_and_store_voyager_index": "tasks.voyager_manager",
+ "build_and_store_clap_index": "tasks.clap_text_search",
+ "build_and_store_lyrics_index": "tasks.lyrics_manager",
+ "build_and_store_lyrics_axes_index": "tasks.lyrics_manager",
+ "build_and_store_sem_grove_index": "tasks.sem_grove_manager",
+ "build_and_store_artist_index": "tasks.artist_gmm_manager",
+ "build_and_store_map_projection": "tasks.analysis",
+ "build_and_store_artist_projection": "tasks.analysis",
+}
+
@pytest.mark.skipif(analysis_mod is None, reason="tasks.analysis (librosa/onnx) unavailable in this env")
class TestRunAllIndexBuilds:
"""The single rebuild entry point shared by analysis, cleaning, collection_manager."""
+ @contextmanager
def _patched(self):
- """patch.multiple over the orchestrator's module-level deps + all 8 builders."""
- targets = {name: DEFAULT for name in _BUILDER_NAMES}
- targets["get_db"] = DEFAULT
- targets["redis_conn"] = DEFAULT
- targets["_release_freed_ram_to_os"] = DEFAULT
- return patch.multiple(analysis_mod, **targets)
+ """Patch the orchestrator's deps and yield {name: mock}.
+
+ The six index builders are patched at their defining modules because
+ ``_run_all_index_builds`` imports them at call time; the projection
+ builders and DB/Redis deps remain module-level names on
+ ``tasks.analysis``.
+ """
+ with ExitStack() as stack:
+ mocks = {}
+ for name, module in _BUILDER_SOURCE_MODULES.items():
+ mocks[name] = stack.enter_context(patch(f"{module}.{name}"))
+ for name in ("get_db", "redis_conn", "_release_freed_ram_to_os"):
+ mocks[name] = stack.enter_context(patch.object(analysis_mod, name))
+ yield mocks
def test_all_eight_builders_run_with_log_fn_none(self):
with self._patched() as mocks:
diff --git a/tests/unit/test_lyrics_transcriber.py b/test/unit/test_lyrics_transcriber.py
similarity index 99%
rename from tests/unit/test_lyrics_transcriber.py
rename to test/unit/test_lyrics_transcriber.py
index d803d258..ee53c9e3 100644
--- a/tests/unit/test_lyrics_transcriber.py
+++ b/test/unit/test_lyrics_transcriber.py
@@ -9,7 +9,7 @@
These tests do NOT load whisper, transformers, or torch heavyweights. The
embedding integration is covered separately by
-``test/test_lyrics_analysis_integration.py``.
+``test/integration/test_lyrics_analysis_integration.py``.
"""
from __future__ import annotations
diff --git a/tests/unit/test_mcp_server.py b/test/unit/test_mcp_server.py
similarity index 100%
rename from tests/unit/test_mcp_server.py
rename to test/unit/test_mcp_server.py
diff --git a/tests/unit/test_mediaserver.py b/test/unit/test_mediaserver.py
similarity index 82%
rename from tests/unit/test_mediaserver.py
rename to test/unit/test_mediaserver.py
index 2d9da000..e720b1b2 100644
--- a/tests/unit/test_mediaserver.py
+++ b/test/unit/test_mediaserver.py
@@ -17,7 +17,7 @@ class TestJellyfinSelectBestArtist:
def test_prioritizes_artist_items_over_album_artist(self):
"""ArtistItems should be preferred over AlbumArtist"""
- from tasks.mediaserver_jellyfin import _select_best_artist
+ from tasks.mediaserver.jellyfin import _select_best_artist
item = {
'ArtistItems': [{'Name': 'Track Artist', 'Id': 'artist-123'}],
@@ -32,7 +32,7 @@ def test_prioritizes_artist_items_over_album_artist(self):
def test_falls_back_to_artists_array(self):
"""If no ArtistItems, use Artists array"""
- from tasks.mediaserver_jellyfin import _select_best_artist
+ from tasks.mediaserver.jellyfin import _select_best_artist
item = {
'ArtistItems': [],
@@ -47,7 +47,7 @@ def test_falls_back_to_artists_array(self):
def test_falls_back_to_album_artist(self):
"""If no Artists, use AlbumArtist"""
- from tasks.mediaserver_jellyfin import _select_best_artist
+ from tasks.mediaserver.jellyfin import _select_best_artist
item = {
'AlbumArtist': 'The Album Artist'
@@ -60,7 +60,7 @@ def test_falls_back_to_album_artist(self):
def test_returns_unknown_when_no_artist_info(self):
"""Returns 'Unknown Artist' when no artist info available"""
- from tasks.mediaserver_jellyfin import _select_best_artist
+ from tasks.mediaserver.jellyfin import _select_best_artist
item = {}
@@ -71,7 +71,7 @@ def test_returns_unknown_when_no_artist_info(self):
def test_handles_empty_artist_items(self):
"""Empty ArtistItems should fall back"""
- from tasks.mediaserver_jellyfin import _select_best_artist
+ from tasks.mediaserver.jellyfin import _select_best_artist
item = {
'ArtistItems': [],
@@ -86,11 +86,11 @@ def test_handles_empty_artist_items(self):
class TestJellyfinResolveUser:
"""Test user resolution with mocked HTTP"""
- @patch('tasks.mediaserver_jellyfin.requests.get')
- @patch('tasks.mediaserver_jellyfin.config')
+ @patch('tasks.mediaserver.jellyfin.requests.get')
+ @patch('tasks.mediaserver.jellyfin.config')
def test_resolves_username_to_id(self, mock_config, mock_get):
"""Username should be resolved to User ID"""
- from tasks.mediaserver_jellyfin import resolve_user
+ from tasks.mediaserver.jellyfin import resolve_user
mock_config.JELLYFIN_URL = 'http://jellyfin:8096'
mock_response = Mock()
@@ -109,11 +109,11 @@ def test_resolves_username_to_id(self, mock_config, mock_get):
call_url = mock_get.call_args[0][0]
assert '/Users' in call_url
- @patch('tasks.mediaserver_jellyfin.requests.get')
- @patch('tasks.mediaserver_jellyfin.config')
+ @patch('tasks.mediaserver.jellyfin.requests.get')
+ @patch('tasks.mediaserver.jellyfin.config')
def test_returns_identifier_if_no_match(self, mock_config, mock_get):
"""If username not found, return original identifier (assumed to be ID)"""
- from tasks.mediaserver_jellyfin import resolve_user
+ from tasks.mediaserver.jellyfin import resolve_user
mock_config.JELLYFIN_URL = 'http://jellyfin:8096'
mock_response = Mock()
@@ -127,11 +127,11 @@ def test_returns_identifier_if_no_match(self, mock_config, mock_get):
assert result == 'direct-user-id'
- @patch('tasks.mediaserver_jellyfin.requests.get')
- @patch('tasks.mediaserver_jellyfin.config')
+ @patch('tasks.mediaserver.jellyfin.requests.get')
+ @patch('tasks.mediaserver.jellyfin.config')
def test_handles_http_error(self, mock_config, mock_get):
"""HTTP errors should return original identifier"""
- from tasks.mediaserver_jellyfin import resolve_user
+ from tasks.mediaserver.jellyfin import resolve_user
mock_config.JELLYFIN_URL = 'http://jellyfin:8096'
mock_get.side_effect = requests.exceptions.RequestException("Connection failed")
@@ -145,11 +145,11 @@ def test_handles_http_error(self, mock_config, mock_get):
class TestJellyfinGetTracksFromAlbum:
"""Test track fetching with artist enrichment - verifies exact behavior"""
- @patch('tasks.mediaserver_jellyfin.requests.get')
- @patch('tasks.mediaserver_jellyfin.config')
+ @patch('tasks.mediaserver.jellyfin.requests.get')
+ @patch('tasks.mediaserver.jellyfin.config')
def test_uses_correct_url_and_params(self, mock_config, mock_get):
"""CRITICAL: Must use /Users/{id}/Items with ParentId - catches if URL changes"""
- from tasks.mediaserver_jellyfin import get_tracks_from_album
+ from tasks.mediaserver.jellyfin import get_tracks_from_album
mock_config.JELLYFIN_URL = 'http://jellyfin:8096'
mock_config.JELLYFIN_USER_ID = 'user123'
@@ -172,11 +172,11 @@ def test_uses_correct_url_and_params(self, mock_config, mock_get):
assert call_params.get('ParentId') == 'album-xyz', "ParentId param missing or wrong"
assert call_params.get('IncludeItemTypes') == 'Audio', "IncludeItemTypes param wrong"
- @patch('tasks.mediaserver_jellyfin.requests.get')
- @patch('tasks.mediaserver_jellyfin.config')
+ @patch('tasks.mediaserver.jellyfin.requests.get')
+ @patch('tasks.mediaserver.jellyfin.config')
def test_enriches_tracks_with_artist_info(self, mock_config, mock_get):
"""CRITICAL: Must add AlbumArtist and ArtistId fields - catches if enrichment changes"""
- from tasks.mediaserver_jellyfin import get_tracks_from_album
+ from tasks.mediaserver.jellyfin import get_tracks_from_album
mock_config.JELLYFIN_URL = 'http://jellyfin:8096'
mock_config.JELLYFIN_USER_ID = 'user123'
@@ -215,11 +215,11 @@ def test_enriches_tracks_with_artist_info(self, mock_config, mock_get):
"Should fall back to AlbumArtist when no ArtistItems"
assert tracks[1]['ArtistId'] is None
- @patch('tasks.mediaserver_jellyfin.requests.get')
- @patch('tasks.mediaserver_jellyfin.config')
+ @patch('tasks.mediaserver.jellyfin.requests.get')
+ @patch('tasks.mediaserver.jellyfin.config')
def test_returns_empty_on_http_error(self, mock_config, mock_get):
"""HTTP error should return empty list, not raise"""
- from tasks.mediaserver_jellyfin import get_tracks_from_album
+ from tasks.mediaserver.jellyfin import get_tracks_from_album
mock_config.JELLYFIN_URL = 'http://jellyfin:8096'
mock_config.JELLYFIN_USER_ID = 'user123'
@@ -230,11 +230,11 @@ def test_returns_empty_on_http_error(self, mock_config, mock_get):
assert tracks == []
- @patch('tasks.mediaserver_jellyfin.requests.get')
- @patch('tasks.mediaserver_jellyfin.config')
+ @patch('tasks.mediaserver.jellyfin.requests.get')
+ @patch('tasks.mediaserver.jellyfin.config')
def test_handles_empty_items_response(self, mock_config, mock_get):
"""Empty Items array should return empty list"""
- from tasks.mediaserver_jellyfin import get_tracks_from_album
+ from tasks.mediaserver.jellyfin import get_tracks_from_album
mock_config.JELLYFIN_URL = 'http://jellyfin:8096'
mock_config.JELLYFIN_USER_ID = 'user123'
@@ -253,11 +253,11 @@ def test_handles_empty_items_response(self, mock_config, mock_get):
class TestJellyfinGetAllPlaylists:
"""Test playlist fetching - verifies exact URL and response parsing"""
- @patch('tasks.mediaserver_jellyfin.requests.get')
- @patch('tasks.mediaserver_jellyfin.config')
+ @patch('tasks.mediaserver.jellyfin.requests.get')
+ @patch('tasks.mediaserver.jellyfin.config')
def test_uses_correct_url_and_params(self, mock_config, mock_get):
"""CRITICAL: Must use /Users/{id}/Items with IncludeItemTypes=Playlist"""
- from tasks.mediaserver_jellyfin import get_all_playlists
+ from tasks.mediaserver.jellyfin import get_all_playlists
mock_config.JELLYFIN_URL = 'http://jellyfin:8096'
mock_config.JELLYFIN_USER_ID = 'user123'
@@ -282,11 +282,11 @@ def test_uses_correct_url_and_params(self, mock_config, mock_get):
assert call_params.get('Recursive') == True, \
"Recursive must be True"
- @patch('tasks.mediaserver_jellyfin.requests.get')
- @patch('tasks.mediaserver_jellyfin.config')
+ @patch('tasks.mediaserver.jellyfin.requests.get')
+ @patch('tasks.mediaserver.jellyfin.config')
def test_parses_items_array_from_response(self, mock_config, mock_get):
"""CRITICAL: Must extract Items[] from response - catches if parsing changes"""
- from tasks.mediaserver_jellyfin import get_all_playlists
+ from tasks.mediaserver.jellyfin import get_all_playlists
mock_config.JELLYFIN_URL = 'http://jellyfin:8096'
mock_config.JELLYFIN_USER_ID = 'user123'
@@ -309,11 +309,11 @@ def test_parses_items_array_from_response(self, mock_config, mock_get):
assert playlists[0]['Id'] == 'pl1'
assert playlists[0]['Name'] == 'Rock_automatic'
- @patch('tasks.mediaserver_jellyfin.requests.get')
- @patch('tasks.mediaserver_jellyfin.config')
+ @patch('tasks.mediaserver.jellyfin.requests.get')
+ @patch('tasks.mediaserver.jellyfin.config')
def test_returns_empty_on_error(self, mock_config, mock_get):
"""Error should return empty list, not raise"""
- from tasks.mediaserver_jellyfin import get_all_playlists
+ from tasks.mediaserver.jellyfin import get_all_playlists
mock_config.JELLYFIN_URL = 'http://jellyfin:8096'
mock_config.JELLYFIN_USER_ID = 'user123'
@@ -328,11 +328,11 @@ def test_returns_empty_on_error(self, mock_config, mock_get):
class TestJellyfinDeletePlaylist:
"""Test playlist deletion - verifies exact URL construction and HTTP method"""
- @patch('tasks.mediaserver_jellyfin.requests.delete')
- @patch('tasks.mediaserver_jellyfin.config')
+ @patch('tasks.mediaserver.jellyfin.requests.delete')
+ @patch('tasks.mediaserver.jellyfin.config')
def test_uses_correct_url_and_method(self, mock_config, mock_delete):
"""CRITICAL: Must use DELETE method to /Items/{id} - catches if someone changes to POST"""
- from tasks.mediaserver_jellyfin import delete_playlist
+ from tasks.mediaserver.jellyfin import delete_playlist
mock_config.JELLYFIN_URL = 'http://jellyfin:8096'
mock_config.HEADERS = {'X-Emby-Token': 'test-token'}
@@ -353,11 +353,11 @@ def test_uses_correct_url_and_method(self, mock_config, mock_delete):
call_kwargs = mock_delete.call_args[1]
assert call_kwargs.get('headers') == {'X-Emby-Token': 'test-token'}
- @patch('tasks.mediaserver_jellyfin.requests.delete')
- @patch('tasks.mediaserver_jellyfin.config')
+ @patch('tasks.mediaserver.jellyfin.requests.delete')
+ @patch('tasks.mediaserver.jellyfin.config')
def test_returns_false_on_http_error(self, mock_config, mock_delete):
"""HTTP error returns False - catches if error handling changes"""
- from tasks.mediaserver_jellyfin import delete_playlist
+ from tasks.mediaserver.jellyfin import delete_playlist
mock_config.JELLYFIN_URL = 'http://jellyfin:8096'
mock_config.HEADERS = {}
@@ -367,11 +367,11 @@ def test_returns_false_on_http_error(self, mock_config, mock_delete):
assert result is False
- @patch('tasks.mediaserver_jellyfin.requests.delete')
- @patch('tasks.mediaserver_jellyfin.config')
+ @patch('tasks.mediaserver.jellyfin.requests.delete')
+ @patch('tasks.mediaserver.jellyfin.config')
def test_returns_false_on_raise_for_status(self, mock_config, mock_delete):
"""raise_for_status exception returns False - catches if error handling changes"""
- from tasks.mediaserver_jellyfin import delete_playlist
+ from tasks.mediaserver.jellyfin import delete_playlist
mock_config.JELLYFIN_URL = 'http://jellyfin:8096'
mock_config.HEADERS = {}
@@ -388,11 +388,11 @@ def test_returns_false_on_raise_for_status(self, mock_config, mock_delete):
class TestJellyfinGetLastPlayedTime:
"""Test last played time extraction"""
- @patch('tasks.mediaserver_jellyfin.requests.get')
- @patch('tasks.mediaserver_jellyfin.config')
+ @patch('tasks.mediaserver.jellyfin.requests.get')
+ @patch('tasks.mediaserver.jellyfin.config')
def test_extracts_last_played_date(self, mock_config, mock_get):
"""LastPlayedDate should be extracted from UserData"""
- from tasks.mediaserver_jellyfin import get_last_played_time
+ from tasks.mediaserver.jellyfin import get_last_played_time
mock_config.JELLYFIN_URL = 'http://jellyfin:8096'
mock_config.JELLYFIN_USER_ID = 'user123'
@@ -412,11 +412,11 @@ def test_extracts_last_played_date(self, mock_config, mock_get):
assert result == '2024-01-15T10:30:00Z'
- @patch('tasks.mediaserver_jellyfin.requests.get')
- @patch('tasks.mediaserver_jellyfin.config')
+ @patch('tasks.mediaserver.jellyfin.requests.get')
+ @patch('tasks.mediaserver.jellyfin.config')
def test_returns_none_if_never_played(self, mock_config, mock_get):
"""Returns None if no LastPlayedDate"""
- from tasks.mediaserver_jellyfin import get_last_played_time
+ from tasks.mediaserver.jellyfin import get_last_played_time
mock_config.JELLYFIN_URL = 'http://jellyfin:8096'
mock_config.JELLYFIN_USER_ID = 'user123'
@@ -443,7 +443,7 @@ class TestNavidromeSelectBestArtist:
def test_prioritizes_track_artist(self):
"""Track artist should be preferred over album artist"""
- from tasks.mediaserver_navidrome import _select_best_artist
+ from tasks.mediaserver.navidrome import _select_best_artist
song = {
'artist': 'Track Artist',
@@ -459,7 +459,7 @@ def test_prioritizes_track_artist(self):
def test_falls_back_to_album_artist(self):
"""Falls back to albumArtist if no artist field"""
- from tasks.mediaserver_navidrome import _select_best_artist
+ from tasks.mediaserver.navidrome import _select_best_artist
song = {
'albumArtist': 'Album Artist',
@@ -473,7 +473,7 @@ def test_falls_back_to_album_artist(self):
def test_returns_unknown_when_no_artist(self):
"""Returns 'Unknown Artist' when no artist info"""
- from tasks.mediaserver_navidrome import _select_best_artist
+ from tasks.mediaserver.navidrome import _select_best_artist
song = {'title': 'Some Song'}
@@ -486,10 +486,10 @@ def test_returns_unknown_when_no_artist(self):
class TestNavidromeAuthParams:
"""Test auth parameter generation"""
- @patch('tasks.mediaserver_navidrome.config')
+ @patch('tasks.mediaserver.navidrome.config')
def test_generates_hex_encoded_password(self, mock_config):
"""Password should be hex-encoded"""
- from tasks.mediaserver_navidrome import get_navidrome_auth_params
+ from tasks.mediaserver.navidrome import get_navidrome_auth_params
mock_config.NAVIDROME_USER = 'testuser'
mock_config.NAVIDROME_PASSWORD = 'secret123'
@@ -504,10 +504,10 @@ def test_generates_hex_encoded_password(self, mock_config):
decoded = bytes.fromhex(hex_password).decode('utf-8')
assert decoded == 'secret123'
- @patch('tasks.mediaserver_navidrome.config')
+ @patch('tasks.mediaserver.navidrome.config')
def test_returns_empty_when_no_credentials(self, mock_config):
"""Returns empty dict when credentials missing"""
- from tasks.mediaserver_navidrome import get_navidrome_auth_params
+ from tasks.mediaserver.navidrome import get_navidrome_auth_params
mock_config.NAVIDROME_USER = ''
mock_config.NAVIDROME_PASSWORD = ''
@@ -520,11 +520,11 @@ def test_returns_empty_when_no_credentials(self, mock_config):
class TestNavidromeRequest:
"""Test the core request helper - verifies URL construction and response parsing"""
- @patch('tasks.mediaserver_navidrome.requests.request')
- @patch('tasks.mediaserver_navidrome.config')
+ @patch('tasks.mediaserver.navidrome.requests.request')
+ @patch('tasks.mediaserver.navidrome.config')
def test_constructs_correct_url_with_view_suffix(self, mock_config, mock_request):
"""CRITICAL: URL must end with .view - Subsonic API requirement"""
- from tasks.mediaserver_navidrome import _navidrome_request
+ from tasks.mediaserver.navidrome import _navidrome_request
mock_config.NAVIDROME_URL = 'http://navidrome:4533'
mock_config.NAVIDROME_USER = 'admin'
@@ -547,11 +547,11 @@ def test_constructs_correct_url_with_view_suffix(self, mock_config, mock_request
assert url == 'http://navidrome:4533/rest/getPlaylists.view', \
f"URL format changed! Expected '/rest/getPlaylists.view', got '{url}'"
- @patch('tasks.mediaserver_navidrome.requests.request')
- @patch('tasks.mediaserver_navidrome.config')
+ @patch('tasks.mediaserver.navidrome.requests.request')
+ @patch('tasks.mediaserver.navidrome.config')
def test_parses_subsonic_response_wrapper(self, mock_config, mock_request):
"""CRITICAL: Must extract 'subsonic-response' key - catches if parsing changes"""
- from tasks.mediaserver_navidrome import _navidrome_request
+ from tasks.mediaserver.navidrome import _navidrome_request
mock_config.NAVIDROME_URL = 'http://navidrome:4533'
mock_config.NAVIDROME_USER = 'admin'
@@ -577,11 +577,11 @@ def test_parses_subsonic_response_wrapper(self, mock_config, mock_request):
# Make sure we don't return the wrapper
assert 'subsonic-response' not in result
- @patch('tasks.mediaserver_navidrome.requests.request')
- @patch('tasks.mediaserver_navidrome.config')
+ @patch('tasks.mediaserver.navidrome.requests.request')
+ @patch('tasks.mediaserver.navidrome.config')
def test_checks_status_field_for_failure(self, mock_config, mock_request):
"""CRITICAL: Must check status=='failed' - catches if error detection changes"""
- from tasks.mediaserver_navidrome import _navidrome_request
+ from tasks.mediaserver.navidrome import _navidrome_request
mock_config.NAVIDROME_URL = 'http://navidrome:4533'
mock_config.NAVIDROME_USER = 'admin'
@@ -603,11 +603,11 @@ def test_checks_status_field_for_failure(self, mock_config, mock_request):
# MUST return None on API-level failure
assert result is None, "Failed status should return None, not the response"
- @patch('tasks.mediaserver_navidrome.requests.request')
- @patch('tasks.mediaserver_navidrome.config')
+ @patch('tasks.mediaserver.navidrome.requests.request')
+ @patch('tasks.mediaserver.navidrome.config')
def test_includes_auth_params_in_request(self, mock_config, mock_request):
"""CRITICAL: Auth params must be in query string - catches if auth method changes"""
- from tasks.mediaserver_navidrome import _navidrome_request
+ from tasks.mediaserver.navidrome import _navidrome_request
mock_config.NAVIDROME_URL = 'http://navidrome:4533'
mock_config.NAVIDROME_USER = 'testuser'
@@ -630,11 +630,11 @@ def test_includes_auth_params_in_request(self, mock_config, mock_request):
assert params.get('f') == 'json', "Format must be json"
assert 'extra' in params, "Custom params not passed through"
- @patch('tasks.mediaserver_navidrome.requests.request')
- @patch('tasks.mediaserver_navidrome.config')
+ @patch('tasks.mediaserver.navidrome.requests.request')
+ @patch('tasks.mediaserver.navidrome.config')
def test_returns_none_on_http_error(self, mock_config, mock_request):
"""HTTP errors must return None - catches if error handling changes"""
- from tasks.mediaserver_navidrome import _navidrome_request
+ from tasks.mediaserver.navidrome import _navidrome_request
mock_config.NAVIDROME_URL = 'http://navidrome:4533'
mock_config.NAVIDROME_USER = 'admin'
@@ -651,10 +651,10 @@ def test_returns_none_on_http_error(self, mock_config, mock_request):
class TestNavidromeGetTracksFromAlbum:
"""Test track fetching with parsing - verifies field transformations"""
- @patch('tasks.mediaserver_navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
def test_calls_getAlbum_endpoint(self, mock_request):
"""CRITICAL: Must use getAlbum endpoint - catches if API changes"""
- from tasks.mediaserver_navidrome import get_tracks_from_album
+ from tasks.mediaserver.navidrome import get_tracks_from_album
mock_request.return_value = {
'status': 'ok',
@@ -669,10 +669,10 @@ def test_calls_getAlbum_endpoint(self, mock_request):
assert call_args[0][1] == {'id': 'album123'}, \
f"Params changed! Expected {{'id': 'album123'}}"
- @patch('tasks.mediaserver_navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
def test_normalizes_field_names_to_capitalized(self, mock_request):
"""CRITICAL: Must transform id->Id, title->Name - catches if normalization changes"""
- from tasks.mediaserver_navidrome import get_tracks_from_album
+ from tasks.mediaserver.navidrome import get_tracks_from_album
mock_request.return_value = {
'status': 'ok',
@@ -704,10 +704,10 @@ def test_normalizes_field_names_to_capitalized(self, mock_request):
assert 'AlbumArtist' in tracks[0], "Missing 'AlbumArtist' - enrichment broken"
assert 'ArtistId' in tracks[0], "Missing 'ArtistId' - enrichment broken"
- @patch('tasks.mediaserver_navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
def test_artist_prioritization_applied(self, mock_request):
"""CRITICAL: Track artist > album artist - catches if priority changes"""
- from tasks.mediaserver_navidrome import get_tracks_from_album
+ from tasks.mediaserver.navidrome import get_tracks_from_album
mock_request.return_value = {
'status': 'ok',
@@ -744,10 +744,10 @@ def test_artist_prioritization_applied(self, mock_request):
"Should fall back to album artist when track artist missing"
assert tracks[1]['ArtistId'] == 'album-only-id'
- @patch('tasks.mediaserver_navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
def test_returns_empty_on_missing_songs(self, mock_request):
"""Returns empty list if no songs in album"""
- from tasks.mediaserver_navidrome import get_tracks_from_album
+ from tasks.mediaserver.navidrome import get_tracks_from_album
mock_request.return_value = {
'status': 'ok',
@@ -758,10 +758,10 @@ def test_returns_empty_on_missing_songs(self, mock_request):
assert tracks == []
- @patch('tasks.mediaserver_navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
def test_returns_empty_on_api_failure(self, mock_request):
"""Returns empty list on API failure"""
- from tasks.mediaserver_navidrome import get_tracks_from_album
+ from tasks.mediaserver.navidrome import get_tracks_from_album
mock_request.return_value = None
@@ -783,13 +783,13 @@ def _album_list_response(album_ids):
def _tracks_for(album_id, count):
return [{'Id': f'{album_id}_track{i}', 'Album': album_id} for i in range(count)]
- @patch('tasks.mediaserver_navidrome.get_tracks_from_album')
- @patch('tasks.mediaserver_navidrome._navidrome_request')
- @patch('tasks.mediaserver_navidrome.config')
+ @patch('tasks.mediaserver.navidrome.get_tracks_from_album')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome.config')
def test_single_album_capped_even_with_large_limit(self, mock_config, mock_request, mock_get_tracks):
"""A 100-track album with cap=2 must contribute at most 2 seeds, even
when limit//10 (the old floor) would have allowed 6."""
- from tasks.mediaserver_navidrome import get_top_played_songs
+ from tasks.mediaserver.navidrome import get_top_played_songs
mock_config.SONIC_FINGERPRINT_MAX_SONGS_PER_ALBUM = 2
mock_request.return_value = self._album_list_response(['big'])
@@ -800,12 +800,12 @@ def test_single_album_capped_even_with_large_limit(self, mock_config, mock_reque
assert len(result) == 2, \
f"Expected configured cap of 2, got {len(result)} (limit//10 floor regressed)"
- @patch('tasks.mediaserver_navidrome.get_tracks_from_album')
- @patch('tasks.mediaserver_navidrome._navidrome_request')
- @patch('tasks.mediaserver_navidrome.config')
+ @patch('tasks.mediaserver.navidrome.get_tracks_from_album')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome.config')
def test_cap_honored_per_album_across_multiple_albums(self, mock_config, mock_request, mock_get_tracks):
"""No album may contribute more than the configured cap to the pool."""
- from tasks.mediaserver_navidrome import get_top_played_songs
+ from tasks.mediaserver.navidrome import get_top_played_songs
mock_config.SONIC_FINGERPRINT_MAX_SONGS_PER_ALBUM = 2
mock_request.return_value = self._album_list_response(['a1', 'a2', 'a3'])
@@ -820,13 +820,13 @@ def test_cap_honored_per_album_across_multiple_albums(self, mock_config, mock_re
assert all(count <= 2 for count in per_album.values()), \
f"Some album exceeded the cap of 2: {per_album}"
- @patch('tasks.mediaserver_navidrome.get_tracks_from_album')
- @patch('tasks.mediaserver_navidrome._navidrome_request')
- @patch('tasks.mediaserver_navidrome.config')
+ @patch('tasks.mediaserver.navidrome.get_tracks_from_album')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome.config')
def test_fetches_enough_albums_to_reach_limit_under_tight_cap(self, mock_config, mock_request, mock_get_tracks):
"""With a tight cap the album fetch size must scale so the pool can
still reach the requested limit."""
- from tasks.mediaserver_navidrome import get_top_played_songs
+ from tasks.mediaserver.navidrome import get_top_played_songs
mock_config.SONIC_FINGERPRINT_MAX_SONGS_PER_ALBUM = 2
mock_request.return_value = self._album_list_response([f'a{i}' for i in range(40)])
@@ -838,13 +838,13 @@ def test_fetches_enough_albums_to_reach_limit_under_tight_cap(self, mock_config,
assert requested_size >= 60 // 2, \
f"Album fetch size {requested_size} too small to reach limit under cap=2"
- @patch('tasks.mediaserver_navidrome.get_tracks_from_album')
- @patch('tasks.mediaserver_navidrome._navidrome_request')
- @patch('tasks.mediaserver_navidrome.config')
+ @patch('tasks.mediaserver.navidrome.get_tracks_from_album')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome.config')
def test_final_selection_keeps_most_recently_played(self, mock_config, mock_request, mock_get_tracks):
"""Step 3: from the capped pool, the most recently played tracks win.
Never-played tracks (no ``played``) fall to the bottom."""
- from tasks.mediaserver_navidrome import get_top_played_songs
+ from tasks.mediaserver.navidrome import get_top_played_songs
mock_config.SONIC_FINGERPRINT_MAX_SONGS_PER_ALBUM = 5
mock_request.return_value = self._album_list_response(['a1'])
@@ -861,13 +861,13 @@ def test_final_selection_keeps_most_recently_played(self, mock_config, mock_requ
assert {s['Id'] for s in result} == {'newest', 'recent'}, \
f"Expected the 2 most recently played, got {[s['Id'] for s in result]}"
- @patch('tasks.mediaserver_navidrome.get_tracks_from_album')
- @patch('tasks.mediaserver_navidrome._navidrome_request')
- @patch('tasks.mediaserver_navidrome.config')
+ @patch('tasks.mediaserver.navidrome.get_tracks_from_album')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome.config')
def test_final_selection_falls_back_to_lastPlayed_field(self, mock_config, mock_request, mock_get_tracks):
"""Non-OpenSubsonic servers expose recency as ``lastPlayed``; it must
still drive the ordering when ``played`` is absent."""
- from tasks.mediaserver_navidrome import get_top_played_songs
+ from tasks.mediaserver.navidrome import get_top_played_songs
mock_config.SONIC_FINGERPRINT_MAX_SONGS_PER_ALBUM = 5
mock_request.return_value = self._album_list_response(['a1'])
@@ -881,13 +881,13 @@ def test_final_selection_falls_back_to_lastPlayed_field(self, mock_config, mock_
assert [s['Id'] for s in result] == ['newer'], \
f"Expected the most recently played via lastPlayed, got {[s['Id'] for s in result]}"
- @patch('tasks.mediaserver_navidrome.get_tracks_from_album')
- @patch('tasks.mediaserver_navidrome._navidrome_request')
- @patch('tasks.mediaserver_navidrome.config')
+ @patch('tasks.mediaserver.navidrome.get_tracks_from_album')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome.config')
def test_never_played_songs_handled_without_error(self, mock_config, mock_request, mock_get_tracks):
"""A pool where no track was ever played (no played/lastPlayed) must
sort and return cleanly instead of raising."""
- from tasks.mediaserver_navidrome import get_top_played_songs
+ from tasks.mediaserver.navidrome import get_top_played_songs
mock_config.SONIC_FINGERPRINT_MAX_SONGS_PER_ALBUM = 3
mock_request.return_value = self._album_list_response(['a1', 'a2'])
@@ -900,12 +900,12 @@ def test_never_played_songs_handled_without_error(self, mock_config, mock_reques
assert len(result) == 5
assert all('Id' in s for s in result)
- @patch('tasks.mediaserver_navidrome._navidrome_request')
- @patch('tasks.mediaserver_navidrome.config')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome.config')
def test_no_frequent_albums_returns_empty(self, mock_config, mock_request):
"""An empty 'frequent' response (e.g. a fresh library) yields an empty
seed pool, not an error."""
- from tasks.mediaserver_navidrome import get_top_played_songs
+ from tasks.mediaserver.navidrome import get_top_played_songs
mock_config.SONIC_FINGERPRINT_MAX_SONGS_PER_ALBUM = 3
mock_request.return_value = {'status': 'ok', 'albumList2': {}}
@@ -918,10 +918,10 @@ def test_no_frequent_albums_returns_empty(self, mock_config, mock_request):
class TestNavidromeGetAllPlaylists:
"""Test playlist fetching and normalization - verifies exact response parsing"""
- @patch('tasks.mediaserver_navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
def test_calls_getPlaylists_endpoint(self, mock_request):
"""CRITICAL: Must call getPlaylists - catches if endpoint changes"""
- from tasks.mediaserver_navidrome import get_all_playlists
+ from tasks.mediaserver.navidrome import get_all_playlists
mock_request.return_value = {
'status': 'ok',
@@ -932,10 +932,10 @@ def test_calls_getPlaylists_endpoint(self, mock_request):
mock_request.assert_called_once_with('getPlaylists')
- @patch('tasks.mediaserver_navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
def test_parses_nested_playlist_structure(self, mock_request):
"""CRITICAL: Response is playlists.playlist[] - catches if parsing changes"""
- from tasks.mediaserver_navidrome import get_all_playlists
+ from tasks.mediaserver.navidrome import get_all_playlists
mock_request.return_value = {
'status': 'ok',
@@ -957,10 +957,10 @@ def test_parses_nested_playlist_structure(self, mock_request):
assert playlists[0]['id'] == 'pl1', "Original 'id' should be preserved"
assert playlists[0]['name'] == 'Rock_automatic', "Original 'name' should be preserved"
- @patch('tasks.mediaserver_navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
def test_handles_missing_playlists_key(self, mock_request):
"""Missing playlists key should return empty list"""
- from tasks.mediaserver_navidrome import get_all_playlists
+ from tasks.mediaserver.navidrome import get_all_playlists
mock_request.return_value = {'status': 'ok'}
@@ -968,10 +968,10 @@ def test_handles_missing_playlists_key(self, mock_request):
assert playlists == []
- @patch('tasks.mediaserver_navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
def test_handles_missing_playlist_array(self, mock_request):
"""Missing playlist array should return empty list"""
- from tasks.mediaserver_navidrome import get_all_playlists
+ from tasks.mediaserver.navidrome import get_all_playlists
mock_request.return_value = {'status': 'ok', 'playlists': {}}
@@ -979,10 +979,10 @@ def test_handles_missing_playlist_array(self, mock_request):
assert playlists == []
- @patch('tasks.mediaserver_navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
def test_returns_empty_on_failure(self, mock_request):
"""Returns empty list on API failure"""
- from tasks.mediaserver_navidrome import get_all_playlists
+ from tasks.mediaserver.navidrome import get_all_playlists
mock_request.return_value = None
@@ -994,10 +994,10 @@ def test_returns_empty_on_failure(self, mock_request):
class TestNavidromeDeletePlaylist:
"""Test playlist deletion - verifies exact endpoint and params"""
- @patch('tasks.mediaserver_navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
def test_calls_correct_endpoint_with_id_param(self, mock_request):
"""CRITICAL: Must call deletePlaylist with id param - catches if endpoint changes"""
- from tasks.mediaserver_navidrome import delete_playlist
+ from tasks.mediaserver.navidrome import delete_playlist
mock_request.return_value = {'status': 'ok'}
@@ -1016,10 +1016,10 @@ def test_calls_correct_endpoint_with_id_param(self, mock_request):
assert call_args[1].get('method') == 'post', \
"Method changed! Must be POST for deletePlaylist"
- @patch('tasks.mediaserver_navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
def test_checks_status_ok_for_success(self, mock_request):
"""CRITICAL: Must check status=='ok' - catches if success detection changes"""
- from tasks.mediaserver_navidrome import delete_playlist
+ from tasks.mediaserver.navidrome import delete_playlist
# Return response without 'ok' status
mock_request.return_value = {'status': 'something_else'}
@@ -1029,10 +1029,10 @@ def test_checks_status_ok_for_success(self, mock_request):
# Should return False because status is not 'ok'
assert result is False
- @patch('tasks.mediaserver_navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
def test_returns_false_on_none_response(self, mock_request):
"""None response (API failure) returns False"""
- from tasks.mediaserver_navidrome import delete_playlist
+ from tasks.mediaserver.navidrome import delete_playlist
mock_request.return_value = None
@@ -1044,10 +1044,10 @@ def test_returns_false_on_none_response(self, mock_request):
class TestNavidromeGetPlaylistByName:
"""Test playlist lookup by name"""
- @patch('tasks.mediaserver_navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
def test_finds_playlist_by_exact_name(self, mock_request):
"""Should find playlist with exact name match"""
- from tasks.mediaserver_navidrome import get_playlist_by_name
+ from tasks.mediaserver.navidrome import get_playlist_by_name
mock_request.return_value = {
'status': 'ok',
@@ -1066,10 +1066,10 @@ def test_finds_playlist_by_exact_name(self, mock_request):
assert result['id'] == 'pl2'
assert result['name'] == 'Jazz Favorites'
- @patch('tasks.mediaserver_navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
def test_returns_none_if_not_found(self, mock_request):
"""Returns None if no matching playlist"""
- from tasks.mediaserver_navidrome import get_playlist_by_name
+ from tasks.mediaserver.navidrome import get_playlist_by_name
mock_request.return_value = {
'status': 'ok',
@@ -1088,10 +1088,10 @@ def test_returns_none_if_not_found(self, mock_request):
class TestNavidromeCreatePlaylist:
"""Test playlist creation with batching"""
- @patch('tasks.mediaserver_navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
def test_extracts_playlist_id_from_response(self, mock_request):
"""Should extract playlist ID from creation response"""
- from tasks.mediaserver_navidrome import _create_playlist_batched
+ from tasks.mediaserver.navidrome import _create_playlist_batched
mock_request.return_value = {
'status': 'ok',
@@ -1109,10 +1109,10 @@ def test_extracts_playlist_id_from_response(self, mock_request):
assert result['Id'] == 'new-pl-123' # Normalized key
assert result['Name'] == 'Test Playlist'
- @patch('tasks.mediaserver_navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
def test_create_playlist_sets_public_after_creation(self, mock_request):
"""Should call updatePlaylist(public=true) right after createPlaylist"""
- from tasks.mediaserver_navidrome import _create_playlist_batched
+ from tasks.mediaserver.navidrome import _create_playlist_batched
mock_request.return_value = {
'status': 'ok',
@@ -1134,10 +1134,10 @@ def test_create_playlist_sets_public_after_creation(self, mock_request):
assert update_params.get('playlistId') == 'new-pl-456'
assert update_params.get('public') == 'true'
- @patch('tasks.mediaserver_navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
def test_returns_none_on_creation_failure(self, mock_request):
"""Returns None when creation fails"""
- from tasks.mediaserver_navidrome import _create_playlist_batched
+ from tasks.mediaserver.navidrome import _create_playlist_batched
mock_request.return_value = None
@@ -1145,10 +1145,10 @@ def test_returns_none_on_creation_failure(self, mock_request):
assert result is None
- @patch('tasks.mediaserver_navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
def test_handles_malformed_response(self, mock_request):
"""Returns None on malformed response"""
- from tasks.mediaserver_navidrome import _create_playlist_batched
+ from tasks.mediaserver.navidrome import _create_playlist_batched
mock_request.return_value = {'status': 'ok'} # Missing playlist key
@@ -1160,10 +1160,10 @@ def test_handles_malformed_response(self, mock_request):
class TestNavidromeGetLastPlayedTime:
"""Test last played time extraction"""
- @patch('tasks.mediaserver_navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
def test_extracts_last_played(self, mock_request):
"""Should extract lastPlayed from song response"""
- from tasks.mediaserver_navidrome import get_last_played_time
+ from tasks.mediaserver.navidrome import get_last_played_time
mock_request.return_value = {
'status': 'ok',
@@ -1178,10 +1178,10 @@ def test_extracts_last_played(self, mock_request):
assert result == '2024-01-15T10:30:00Z'
- @patch('tasks.mediaserver_navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
def test_returns_none_if_never_played(self, mock_request):
"""Returns None if no lastPlayed field"""
- from tasks.mediaserver_navidrome import get_last_played_time
+ from tasks.mediaserver.navidrome import get_last_played_time
mock_request.return_value = {
'status': 'ok',
@@ -1199,11 +1199,11 @@ def test_returns_none_if_never_played(self, mock_request):
class TestNavidromeGetRecentAlbums:
"""Test recent albums parsing"""
- @patch('tasks.mediaserver_navidrome._get_target_music_folder_ids')
- @patch('tasks.mediaserver_navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome._get_target_music_folder_ids')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
def test_normalizes_album_keys(self, mock_request, mock_folders):
"""Albums should have Id and Name normalized"""
- from tasks.mediaserver_navidrome import get_recent_albums
+ from tasks.mediaserver.navidrome import get_recent_albums
mock_folders.return_value = None # No folder filtering
mock_request.return_value = {
@@ -1222,11 +1222,11 @@ def test_normalizes_album_keys(self, mock_request, mock_folders):
assert albums[0]['Id'] == 'album1'
assert albums[0]['Name'] == 'First Album'
- @patch('tasks.mediaserver_navidrome._get_target_music_folder_ids')
- @patch('tasks.mediaserver_navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome._get_target_music_folder_ids')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
def test_returns_empty_when_no_matching_folders(self, mock_request, mock_folders):
"""Returns empty list when folder filter matches nothing"""
- from tasks.mediaserver_navidrome import get_recent_albums
+ from tasks.mediaserver.navidrome import get_recent_albums
mock_folders.return_value = set() # Empty set = no matches
@@ -1252,11 +1252,11 @@ class TestNavidromeGetAllSongsApplyFilter:
in code rather than implied from the presence of ``user_creds``.
"""
- @patch('tasks.mediaserver_navidrome._get_target_music_folder_ids')
- @patch('tasks.mediaserver_navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome._get_target_music_folder_ids')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
def test_apply_filter_false_skips_folder_lookup(self, mock_request, mock_filter):
"""apply_filter=False must NOT call _get_target_music_folder_ids."""
- from tasks.mediaserver_navidrome import get_all_songs
+ from tasks.mediaserver.navidrome import get_all_songs
mock_request.return_value = {
'status': 'ok',
@@ -1273,11 +1273,11 @@ def test_apply_filter_false_skips_folder_lookup(self, mock_request, mock_filter)
for c in mock_request.call_args_list:
assert c.kwargs.get('user_creds') == creds
- @patch('tasks.mediaserver_navidrome._get_target_music_folder_ids')
- @patch('tasks.mediaserver_navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome._get_target_music_folder_ids')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
def test_apply_filter_true_default_honors_filter(self, mock_request, mock_filter):
"""apply_filter defaults to True, preserving live-provider semantics."""
- from tasks.mediaserver_navidrome import get_all_songs
+ from tasks.mediaserver.navidrome import get_all_songs
mock_filter.return_value = set() # Filter active but no matches.
mock_request.return_value = {'status': 'ok'}
@@ -1288,15 +1288,15 @@ def test_apply_filter_true_default_honors_filter(self, mock_request, mock_filter
assert songs == []
mock_request.assert_not_called()
- @patch('tasks.mediaserver_navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
def test_get_target_music_folder_ids_forwards_user_creds(self, mock_request):
"""The folder-lookup helper must thread user_creds through to the API
request so callers (e.g. live-provider code paths receiving session
creds) hit ``getMusicFolders`` with valid auth instead of falling back
to empty config globals."""
- from tasks.mediaserver_navidrome import _get_target_music_folder_ids
+ from tasks.mediaserver.navidrome import _get_target_music_folder_ids
- with patch('tasks.mediaserver_navidrome.config') as mock_config:
+ with patch('tasks.mediaserver.navidrome.config') as mock_config:
mock_config.MUSIC_LIBRARIES = 'Music'
mock_request.return_value = {
'musicFolders': {'musicFolder': [{'id': 1, 'name': 'Music'}]}
@@ -1357,8 +1357,8 @@ class TestDispatcherAutomaticPlaylistDeletion:
"""Test the filtering logic in delete_automatic_playlists"""
@patch('tasks.mediaserver.config')
- @patch('tasks.mediaserver.jellyfin_get_all_playlists')
- @patch('tasks.mediaserver.jellyfin_delete_playlist')
+ @patch('tasks.mediaserver.jellyfin.get_all_playlists')
+ @patch('tasks.mediaserver.jellyfin.delete_playlist')
def test_only_deletes_automatic_suffix_playlists(self, mock_delete, mock_get, mock_config):
"""Only playlists ending with '_automatic' should be deleted"""
from tasks.mediaserver import delete_automatic_playlists
@@ -1384,8 +1384,8 @@ def test_only_deletes_automatic_suffix_playlists(self, mock_delete, mock_get, mo
assert '3' not in deleted_ids
@patch('tasks.mediaserver.config')
- @patch('tasks.mediaserver.navidrome_get_all_playlists')
- @patch('tasks.mediaserver.navidrome_delete_playlist')
+ @patch('tasks.mediaserver.navidrome.get_all_playlists')
+ @patch('tasks.mediaserver.navidrome.delete_playlist')
def test_handles_both_id_and_Id_keys(self, mock_delete, mock_get, mock_config):
"""Should handle both 'id' and 'Id' keys (Navidrome uses lowercase)"""
from tasks.mediaserver import delete_automatic_playlists
@@ -1417,7 +1417,7 @@ def test_artist_priority_order(self):
# The logic is inline in get_tracks_from_album, so we test expected behavior
# by examining the field priority order from the code
- # Priority fields in order (from examining mediaserver_lyrion.py):
+ # Priority fields in order (from examining tasks/mediaserver/lyrion.py):
priority_fields = ['trackartist', 'contributor', 'artist', 'albumartist', 'band']
# This verifies our understanding of the priority
@@ -1428,11 +1428,11 @@ def test_artist_priority_order(self):
class TestLyrionJsonRpcRequest:
"""Test the core JSON-RPC request helper"""
- @patch('tasks.mediaserver_lyrion.requests.Session')
- @patch('tasks.mediaserver_lyrion.config')
+ @patch('tasks.mediaserver.lyrion.requests.Session')
+ @patch('tasks.mediaserver.lyrion.config')
def test_constructs_correct_url(self, mock_config, mock_session_class):
"""CRITICAL: URL must be /jsonrpc.js - catches if endpoint changes"""
- from tasks.mediaserver_lyrion import _jsonrpc_request
+ from tasks.mediaserver.lyrion import _jsonrpc_request
mock_config.LYRION_URL = 'http://lyrion:9000'
@@ -1454,11 +1454,11 @@ def test_constructs_correct_url(self, mock_config, mock_session_class):
assert call_args[0][0] == 'http://lyrion:9000/jsonrpc.js', \
f"URL changed! Expected '/jsonrpc.js', got '{call_args[0][0]}'"
- @patch('tasks.mediaserver_lyrion.requests.Session')
- @patch('tasks.mediaserver_lyrion.config')
+ @patch('tasks.mediaserver.lyrion.requests.Session')
+ @patch('tasks.mediaserver.lyrion.config')
def test_uses_slim_request_method(self, mock_config, mock_session_class):
"""CRITICAL: Must use 'slim.request' method - catches if protocol changes"""
- from tasks.mediaserver_lyrion import _jsonrpc_request
+ from tasks.mediaserver.lyrion import _jsonrpc_request
mock_config.LYRION_URL = 'http://lyrion:9000'
@@ -1486,11 +1486,11 @@ def test_uses_slim_request_method(self, mock_config, mock_session_class):
assert payload.get('params')[1][0] == 'albums', \
"Command not passed correctly"
- @patch('tasks.mediaserver_lyrion.requests.Session')
- @patch('tasks.mediaserver_lyrion.config')
+ @patch('tasks.mediaserver.lyrion.requests.Session')
+ @patch('tasks.mediaserver.lyrion.config')
def test_extracts_result_field(self, mock_config, mock_session_class):
"""CRITICAL: Must return 'result' field - catches if response parsing changes"""
- from tasks.mediaserver_lyrion import _jsonrpc_request
+ from tasks.mediaserver.lyrion import _jsonrpc_request
mock_config.LYRION_URL = 'http://lyrion:9000'
@@ -1515,11 +1515,11 @@ def test_extracts_result_field(self, mock_config, mock_session_class):
assert 'albums_loop' in result
assert 'id' not in result # Should not include top-level id
- @patch('tasks.mediaserver_lyrion.requests.Session')
- @patch('tasks.mediaserver_lyrion.config')
+ @patch('tasks.mediaserver.lyrion.requests.Session')
+ @patch('tasks.mediaserver.lyrion.config')
def test_raises_on_jsonrpc_error(self, mock_config, mock_session_class):
"""CRITICAL: Must raise LyrionAPIError on error response"""
- from tasks.mediaserver_lyrion import _jsonrpc_request, LyrionAPIError
+ from tasks.mediaserver.lyrion import _jsonrpc_request, LyrionAPIError
mock_config.LYRION_URL = 'http://lyrion:9000'
@@ -1543,10 +1543,10 @@ def test_raises_on_jsonrpc_error(self, mock_config, mock_session_class):
class TestLyrionGetAllPlaylists:
"""Test playlist fetching and normalization"""
- @patch('tasks.mediaserver_lyrion._jsonrpc_request')
+ @patch('tasks.mediaserver.lyrion._jsonrpc_request')
def test_calls_playlists_command(self, mock_request):
"""CRITICAL: Must call 'playlists' command - catches if API changes"""
- from tasks.mediaserver_lyrion import get_all_playlists
+ from tasks.mediaserver.lyrion import get_all_playlists
mock_request.return_value = {'playlists_loop': []}
@@ -1556,10 +1556,10 @@ def test_calls_playlists_command(self, mock_request):
assert call_args[0][0] == 'playlists', \
f"Command changed! Expected 'playlists', got '{call_args[0][0]}'"
- @patch('tasks.mediaserver_lyrion._jsonrpc_request')
+ @patch('tasks.mediaserver.lyrion._jsonrpc_request')
def test_normalizes_playlist_keys(self, mock_request):
"""CRITICAL: Must normalize 'id'->Id, 'playlist'->Name"""
- from tasks.mediaserver_lyrion import get_all_playlists
+ from tasks.mediaserver.lyrion import get_all_playlists
mock_request.return_value = {
'playlists_loop': [
@@ -1575,10 +1575,10 @@ def test_normalizes_playlist_keys(self, mock_request):
assert playlists[0]['Id'] == 'pl1', "Missing 'Id' normalization"
assert playlists[0]['Name'] == 'Rock_automatic', "Missing 'Name' normalization from 'playlist' field"
- @patch('tasks.mediaserver_lyrion._jsonrpc_request')
+ @patch('tasks.mediaserver.lyrion._jsonrpc_request')
def test_returns_empty_on_no_playlists(self, mock_request):
"""Returns empty list when no playlists_loop"""
- from tasks.mediaserver_lyrion import get_all_playlists
+ from tasks.mediaserver.lyrion import get_all_playlists
mock_request.return_value = {}
@@ -1590,10 +1590,10 @@ def test_returns_empty_on_no_playlists(self, mock_request):
class TestLyrionDeletePlaylist:
"""Test playlist deletion"""
- @patch('tasks.mediaserver_lyrion._jsonrpc_request')
+ @patch('tasks.mediaserver.lyrion._jsonrpc_request')
def test_calls_playlists_delete_command(self, mock_request):
"""CRITICAL: Must use 'playlists' with 'delete' param - catches if API changes"""
- from tasks.mediaserver_lyrion import delete_playlist
+ from tasks.mediaserver.lyrion import delete_playlist
mock_request.return_value = {'count': 1}
@@ -1608,10 +1608,10 @@ def test_calls_playlists_delete_command(self, mock_request):
assert 'delete' in params, "Must include 'delete' param"
assert 'playlist_id:playlist-123' in params, "Must include playlist_id param"
- @patch('tasks.mediaserver_lyrion._jsonrpc_request')
+ @patch('tasks.mediaserver.lyrion._jsonrpc_request')
def test_returns_true_on_success(self, mock_request):
"""Returns True when deletion succeeds"""
- from tasks.mediaserver_lyrion import delete_playlist
+ from tasks.mediaserver.lyrion import delete_playlist
mock_request.return_value = {'count': 1}
@@ -1619,10 +1619,10 @@ def test_returns_true_on_success(self, mock_request):
assert result is True
- @patch('tasks.mediaserver_lyrion._jsonrpc_request')
+ @patch('tasks.mediaserver.lyrion._jsonrpc_request')
def test_returns_false_on_failure(self, mock_request):
"""Returns False when deletion fails"""
- from tasks.mediaserver_lyrion import delete_playlist
+ from tasks.mediaserver.lyrion import delete_playlist
mock_request.return_value = None
@@ -1634,10 +1634,10 @@ def test_returns_false_on_failure(self, mock_request):
class TestLyrionGetTracksFromAlbum:
"""Test track fetching from albums"""
- @patch('tasks.mediaserver_lyrion._jsonrpc_request')
+ @patch('tasks.mediaserver.lyrion._jsonrpc_request')
def test_calls_titles_command_with_album_id(self, mock_request):
"""CRITICAL: Must use 'titles' with album_id filter"""
- from tasks.mediaserver_lyrion import get_tracks_from_album
+ from tasks.mediaserver.lyrion import get_tracks_from_album
mock_request.return_value = {'titles_loop': []}
@@ -1650,10 +1650,10 @@ def test_calls_titles_command_with_album_id(self, mock_request):
assert any('album_id:album-123' in str(p) for p in params), \
"Must include album_id filter"
- @patch('tasks.mediaserver_lyrion._jsonrpc_request')
+ @patch('tasks.mediaserver.lyrion._jsonrpc_request')
def test_normalizes_track_fields(self, mock_request):
"""CRITICAL: Must normalize id->Id, title->Name, add AlbumArtist"""
- from tasks.mediaserver_lyrion import get_tracks_from_album
+ from tasks.mediaserver.lyrion import get_tracks_from_album
mock_request.return_value = {
'titles_loop': [
@@ -1676,10 +1676,10 @@ def test_normalizes_track_fields(self, mock_request):
"trackartist should be prioritized for AlbumArtist"
assert tracks[0]['Path'] == '/music/song1.mp3', "Missing 'Path' from 'url'"
- @patch('tasks.mediaserver_lyrion._jsonrpc_request')
+ @patch('tasks.mediaserver.lyrion._jsonrpc_request')
def test_artist_fallback_priority(self, mock_request):
"""Tests artist field fallback: trackartist > contributor > artist > albumartist"""
- from tasks.mediaserver_lyrion import get_tracks_from_album
+ from tasks.mediaserver.lyrion import get_tracks_from_album
mock_request.return_value = {
'titles_loop': [
@@ -1708,10 +1708,10 @@ def test_artist_fallback_priority(self, mock_request):
assert tracks[1]['AlbumArtist'] == 'Album Artist Only', \
"Should fall back to albumartist when no higher priority fields"
- @patch('tasks.mediaserver_lyrion._jsonrpc_request')
+ @patch('tasks.mediaserver.lyrion._jsonrpc_request')
def test_filters_spotify_tracks(self, mock_request):
"""CRITICAL: Spotify tracks should be filtered out"""
- from tasks.mediaserver_lyrion import get_tracks_from_album
+ from tasks.mediaserver.lyrion import get_tracks_from_album
mock_request.return_value = {
'titles_loop': [
@@ -1740,7 +1740,7 @@ class TestEmbySelectBestArtist:
def test_prioritizes_artist_items_over_album_artist(self):
"""ArtistItems should be preferred over AlbumArtist"""
- from tasks.mediaserver_emby import _select_best_artist
+ from tasks.mediaserver.emby import _select_best_artist
item = {
'ArtistItems': [{'Name': 'Track Artist', 'Id': 'artist-123'}],
@@ -1755,7 +1755,7 @@ def test_prioritizes_artist_items_over_album_artist(self):
def test_falls_back_to_artists_array(self):
"""If no ArtistItems, use Artists array"""
- from tasks.mediaserver_emby import _select_best_artist
+ from tasks.mediaserver.emby import _select_best_artist
item = {
'ArtistItems': [],
@@ -1770,7 +1770,7 @@ def test_falls_back_to_artists_array(self):
def test_falls_back_to_album_artist(self):
"""If no Artists, use AlbumArtist"""
- from tasks.mediaserver_emby import _select_best_artist
+ from tasks.mediaserver.emby import _select_best_artist
item = {
'AlbumArtist': 'The Album Artist'
@@ -1783,7 +1783,7 @@ def test_falls_back_to_album_artist(self):
def test_returns_unknown_when_no_artist_info(self):
"""Returns 'Unknown Artist' when no artist info available"""
- from tasks.mediaserver_emby import _select_best_artist
+ from tasks.mediaserver.emby import _select_best_artist
item = {}
@@ -1796,11 +1796,11 @@ def test_returns_unknown_when_no_artist_info(self):
class TestEmbyGetAllPlaylists:
"""Test playlist fetching - verifies URL and response parsing"""
- @patch('tasks.mediaserver_emby.requests.get')
- @patch('tasks.mediaserver_emby.config')
+ @patch('tasks.mediaserver.emby.requests.get')
+ @patch('tasks.mediaserver.emby.config')
def test_uses_correct_url_with_emby_prefix(self, mock_config, mock_get):
"""CRITICAL: URL must include /emby/ prefix - catches if path changes"""
- from tasks.mediaserver_emby import get_all_playlists
+ from tasks.mediaserver.emby import get_all_playlists
mock_config.EMBY_URL = 'http://emby:8096'
mock_config.EMBY_USER_ID = 'user123'
@@ -1818,11 +1818,11 @@ def test_uses_correct_url_with_emby_prefix(self, mock_config, mock_get):
assert call_url == 'http://emby:8096/emby/Users/user123/Items', \
f"URL changed! Got '{call_url}'"
- @patch('tasks.mediaserver_emby.requests.get')
- @patch('tasks.mediaserver_emby.config')
+ @patch('tasks.mediaserver.emby.requests.get')
+ @patch('tasks.mediaserver.emby.config')
def test_includes_playlist_item_type(self, mock_config, mock_get):
"""CRITICAL: Must filter by IncludeItemTypes=Playlist"""
- from tasks.mediaserver_emby import get_all_playlists
+ from tasks.mediaserver.emby import get_all_playlists
mock_config.EMBY_URL = 'http://emby:8096'
mock_config.EMBY_USER_ID = 'user123'
@@ -1839,11 +1839,11 @@ def test_includes_playlist_item_type(self, mock_config, mock_get):
assert call_params.get('IncludeItemTypes') == 'Playlist', \
"Must filter by Playlist item type"
- @patch('tasks.mediaserver_emby.requests.get')
- @patch('tasks.mediaserver_emby.config')
+ @patch('tasks.mediaserver.emby.requests.get')
+ @patch('tasks.mediaserver.emby.config')
def test_parses_items_array(self, mock_config, mock_get):
"""CRITICAL: Must extract Items[] from response"""
- from tasks.mediaserver_emby import get_all_playlists
+ from tasks.mediaserver.emby import get_all_playlists
mock_config.EMBY_URL = 'http://emby:8096'
mock_config.EMBY_USER_ID = 'user123'
@@ -1869,11 +1869,11 @@ def test_parses_items_array(self, mock_config, mock_get):
class TestEmbyDeletePlaylist:
"""Test playlist deletion - Emby uses different endpoint than Jellyfin!"""
- @patch('tasks.mediaserver_emby.requests.post')
- @patch('tasks.mediaserver_emby.config')
+ @patch('tasks.mediaserver.emby.requests.post')
+ @patch('tasks.mediaserver.emby.config')
def test_uses_items_delete_endpoint(self, mock_config, mock_post):
"""CRITICAL: Emby uses /Items/Delete with POST, not DELETE to /Items/{id}"""
- from tasks.mediaserver_emby import delete_playlist
+ from tasks.mediaserver.emby import delete_playlist
mock_config.EMBY_URL = 'http://emby:8096'
mock_config.HEADERS = {'X-Emby-Token': 'token'}
@@ -1890,11 +1890,11 @@ def test_uses_items_delete_endpoint(self, mock_config, mock_post):
assert call_url == 'http://emby:8096/emby/Items/Delete', \
f"Emby deletion URL changed! Expected '/emby/Items/Delete', got '{call_url}'"
- @patch('tasks.mediaserver_emby.requests.post')
- @patch('tasks.mediaserver_emby.config')
+ @patch('tasks.mediaserver.emby.requests.post')
+ @patch('tasks.mediaserver.emby.config')
def test_passes_id_as_query_param(self, mock_config, mock_post):
"""CRITICAL: Playlist ID must be in 'Ids' query param"""
- from tasks.mediaserver_emby import delete_playlist
+ from tasks.mediaserver.emby import delete_playlist
mock_config.EMBY_URL = 'http://emby:8096'
mock_config.HEADERS = {}
@@ -1909,11 +1909,11 @@ def test_passes_id_as_query_param(self, mock_config, mock_post):
assert call_params.get('Ids') == 'playlist-xyz', \
"Playlist ID must be passed as 'Ids' query param"
- @patch('tasks.mediaserver_emby.requests.post')
- @patch('tasks.mediaserver_emby.config')
+ @patch('tasks.mediaserver.emby.requests.post')
+ @patch('tasks.mediaserver.emby.config')
def test_returns_false_on_error(self, mock_config, mock_post):
"""HTTP error returns False"""
- from tasks.mediaserver_emby import delete_playlist
+ from tasks.mediaserver.emby import delete_playlist
mock_config.EMBY_URL = 'http://emby:8096'
mock_config.HEADERS = {}
@@ -1927,11 +1927,11 @@ def test_returns_false_on_error(self, mock_config, mock_post):
class TestEmbyGetTracksFromAlbum:
"""Test track fetching with artist enrichment"""
- @patch('tasks.mediaserver_emby.requests.get')
- @patch('tasks.mediaserver_emby.config')
+ @patch('tasks.mediaserver.emby.requests.get')
+ @patch('tasks.mediaserver.emby.config')
def test_uses_emby_url_prefix(self, mock_config, mock_get):
"""CRITICAL: URL must include /emby/ prefix"""
- from tasks.mediaserver_emby import get_tracks_from_album
+ from tasks.mediaserver.emby import get_tracks_from_album
mock_config.EMBY_URL = 'http://emby:8096'
mock_config.EMBY_USER_ID = 'user123'
@@ -1947,11 +1947,11 @@ def test_uses_emby_url_prefix(self, mock_config, mock_get):
call_url = mock_get.call_args[0][0]
assert '/emby/' in call_url, "URL must include /emby/ prefix"
- @patch('tasks.mediaserver_emby.requests.get')
- @patch('tasks.mediaserver_emby.config')
+ @patch('tasks.mediaserver.emby.requests.get')
+ @patch('tasks.mediaserver.emby.config')
def test_enriches_tracks_with_artist(self, mock_config, mock_get):
"""CRITICAL: Must add AlbumArtist and ArtistId fields"""
- from tasks.mediaserver_emby import get_tracks_from_album
+ from tasks.mediaserver.emby import get_tracks_from_album
mock_config.EMBY_URL = 'http://emby:8096'
mock_config.EMBY_USER_ID = 'user123'
@@ -1978,11 +1978,11 @@ def test_enriches_tracks_with_artist(self, mock_config, mock_get):
assert tracks[0]['AlbumArtist'] == 'Artist A'
assert tracks[0]['ArtistId'] == 'artist-a'
- @patch('tasks.mediaserver_emby.requests.get')
- @patch('tasks.mediaserver_emby.config')
+ @patch('tasks.mediaserver.emby.requests.get')
+ @patch('tasks.mediaserver.emby.config')
def test_handles_standalone_track_pseudo_albums(self, mock_config, mock_get):
"""CRITICAL: Must handle standalone_ prefix for pseudo-albums"""
- from tasks.mediaserver_emby import get_tracks_from_album
+ from tasks.mediaserver.emby import get_tracks_from_album
mock_config.EMBY_URL = 'http://emby:8096'
mock_config.EMBY_USER_ID = 'user123'
@@ -2009,11 +2009,11 @@ def test_handles_standalone_track_pseudo_albums(self, mock_config, mock_get):
class TestEmbyCreatePlaylist:
"""Test playlist creation - Emby uses query params, not JSON body!"""
- @patch('tasks.mediaserver_emby.requests.post')
- @patch('tasks.mediaserver_emby.config')
+ @patch('tasks.mediaserver.emby.requests.post')
+ @patch('tasks.mediaserver.emby.config')
def test_uses_query_params_not_json_body(self, mock_config, mock_post):
"""CRITICAL: Emby expects query params, not JSON body"""
- from tasks.mediaserver_emby import create_playlist
+ from tasks.mediaserver.emby import create_playlist
mock_config.EMBY_URL = 'http://emby:8096'
mock_config.EMBY_USER_ID = 'user123'
@@ -2037,11 +2037,11 @@ def test_uses_query_params_not_json_body(self, mock_config, mock_post):
call_kwargs = mock_post.call_args[1]
assert 'json' not in call_kwargs, "Emby should NOT receive JSON body"
- @patch('tasks.mediaserver_emby.requests.post')
- @patch('tasks.mediaserver_emby.config')
+ @patch('tasks.mediaserver.emby.requests.post')
+ @patch('tasks.mediaserver.emby.config')
def test_url_encodes_playlist_name(self, mock_config, mock_post):
"""Playlist names with special chars must be URL encoded"""
- from tasks.mediaserver_emby import create_playlist
+ from tasks.mediaserver.emby import create_playlist
mock_config.EMBY_URL = 'http://emby:8096'
mock_config.EMBY_USER_ID = 'user123'
@@ -2070,10 +2070,10 @@ def test_url_encodes_playlist_name(self, mock_config, mock_post):
# =============================================================================
class TestJellyfinListLibraries:
- @patch('tasks.mediaserver_jellyfin.requests.get')
- @patch('tasks.mediaserver_jellyfin.config')
+ @patch('tasks.mediaserver.jellyfin.requests.get')
+ @patch('tasks.mediaserver.jellyfin.config')
def test_returns_music_libraries_with_id_and_name(self, mock_config, mock_get):
- from tasks.mediaserver_jellyfin import list_libraries
+ from tasks.mediaserver.jellyfin import list_libraries
mock_config.JELLYFIN_URL = 'http://jelly:8096'
mock_config.JELLYFIN_TOKEN = 'admin-token'
@@ -2095,11 +2095,11 @@ def test_returns_music_libraries_with_id_and_name(self, mock_config, mock_get):
{'id': 'lib-3', 'name': 'Podcasts'},
]
- @patch('tasks.mediaserver_jellyfin.requests.get')
- @patch('tasks.mediaserver_jellyfin.config')
+ @patch('tasks.mediaserver.jellyfin.requests.get')
+ @patch('tasks.mediaserver.jellyfin.config')
def test_forwards_user_creds_to_url_and_token(self, mock_config, mock_get):
"""Migration target probe must use session creds, not config globals."""
- from tasks.mediaserver_jellyfin import list_libraries
+ from tasks.mediaserver.jellyfin import list_libraries
mock_config.JELLYFIN_URL = 'http://SHOULD-NOT-BE-USED:0000'
mock_config.JELLYFIN_TOKEN = 'SHOULD-NOT-BE-USED'
@@ -2122,10 +2122,10 @@ def test_forwards_user_creds_to_url_and_token(self, mock_config, mock_get):
class TestEmbyListLibraries:
- @patch('tasks.mediaserver_emby.requests.get')
- @patch('tasks.mediaserver_emby.config')
+ @patch('tasks.mediaserver.emby.requests.get')
+ @patch('tasks.mediaserver.emby.config')
def test_returns_music_libraries_only(self, mock_config, mock_get):
- from tasks.mediaserver_emby import list_libraries
+ from tasks.mediaserver.emby import list_libraries
mock_config.EMBY_URL = 'http://emby:8096'
mock_config.EMBY_TOKEN = 'admin-token'
@@ -2143,10 +2143,10 @@ def test_returns_music_libraries_only(self, mock_config, mock_get):
assert result == [{'id': 'e1', 'name': 'Music'}]
- @patch('tasks.mediaserver_emby.requests.get')
- @patch('tasks.mediaserver_emby.config')
+ @patch('tasks.mediaserver.emby.requests.get')
+ @patch('tasks.mediaserver.emby.config')
def test_forwards_user_creds(self, mock_config, mock_get):
- from tasks.mediaserver_emby import list_libraries
+ from tasks.mediaserver.emby import list_libraries
mock_config.EMBY_URL = 'http://SHOULD-NOT-BE-USED:0000'
mock_config.EMBY_TOKEN = 'SHOULD-NOT-BE-USED'
@@ -2169,14 +2169,14 @@ def test_forwards_user_creds(self, mock_config, mock_get):
class TestNavidromeListLibraries:
- @patch('tasks.mediaserver_navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
def test_returns_every_folder_without_reading_music_libraries(self, mock_req):
"""
Does NOT call _get_target_music_folder_ids (which would read
config.MUSIC_LIBRARIES and break when the filter is set for the source
provider but doesn't apply to the target). Returns every folder.
"""
- from tasks.mediaserver_navidrome import list_libraries
+ from tasks.mediaserver.navidrome import list_libraries
mock_req.return_value = {
'musicFolders': {
@@ -2199,11 +2199,11 @@ def test_returns_every_folder_without_reading_music_libraries(self, mock_req):
assert args[0] == 'getMusicFolders'
assert kwargs.get('user_creds') is None
- @patch('tasks.mediaserver_navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
def test_handles_single_dict_response(self, mock_req):
"""Some Subsonic-compatible servers return a single dict (not a list)
when only one folder exists. The function must coerce to a list."""
- from tasks.mediaserver_navidrome import list_libraries
+ from tasks.mediaserver.navidrome import list_libraries
mock_req.return_value = {
'musicFolders': {
@@ -2215,14 +2215,14 @@ def test_handles_single_dict_response(self, mock_req):
assert result == [{'id': '1', 'name': 'OnlyFolder'}]
- @patch('tasks.mediaserver_navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
def test_forwards_user_creds_to_getmusicfolders(self, mock_req):
"""
Migration-target path: user_creds must reach _navidrome_request so the
request uses the session's URL/username/password rather than
config.NAVIDROME_* (which are empty for a target that isn't live yet).
"""
- from tasks.mediaserver_navidrome import list_libraries
+ from tasks.mediaserver.navidrome import list_libraries
mock_req.return_value = {'musicFolders': {'musicFolder': []}}
@@ -2235,9 +2235,9 @@ def test_forwards_user_creds_to_getmusicfolders(self, mock_req):
class TestLyrionListLibraries:
- @patch('tasks.mediaserver_lyrion._jsonrpc_request')
+ @patch('tasks.mediaserver.lyrion._jsonrpc_request')
def test_returns_every_folder(self, mock_rpc):
- from tasks.mediaserver_lyrion import list_libraries
+ from tasks.mediaserver.lyrion import list_libraries
mock_rpc.return_value = {
'folder_loop': [
@@ -2258,11 +2258,11 @@ def test_returns_every_folder(self, mock_rpc):
assert args[0] == 'musicfolder'
assert kwargs.get('user_creds') is None
- @patch('tasks.mediaserver_lyrion._jsonrpc_request')
+ @patch('tasks.mediaserver.lyrion._jsonrpc_request')
def test_handles_lyrion_9_x_filename_field(self, mock_rpc):
"""Lyrion 9.0.x returns folder entries with ``filename`` (not ``name``).
Older versions used ``name`` / ``folder``. Accept all three."""
- from tasks.mediaserver_lyrion import list_libraries
+ from tasks.mediaserver.lyrion import list_libraries
mock_rpc.return_value = {
'folder_loop': [
@@ -2279,14 +2279,14 @@ def test_handles_lyrion_9_x_filename_field(self, mock_rpc):
{'id': '686', 'name': 'Library_B'},
]
- @patch('tasks.mediaserver_lyrion._jsonrpc_request')
+ @patch('tasks.mediaserver.lyrion._jsonrpc_request')
def test_prefers_path_over_name_when_available(self, mock_rpc):
"""Lyrion's scan-time filter (_get_target_paths_for_filtering) does a
substring match against album file URLs, so when the server reports
a real path we persist it (more deterministic match). Otherwise we
fall back to the folder display name (which Lyrion treats as a path
substring at scan time on standard layouts)."""
- from tasks.mediaserver_lyrion import list_libraries
+ from tasks.mediaserver.lyrion import list_libraries
mock_rpc.return_value = {
'folder_loop': [
@@ -2304,9 +2304,9 @@ def test_prefers_path_over_name_when_available(self, mock_rpc):
{'id': '12', 'name': 'NoPath'}, # falls back when path absent
]
- @patch('tasks.mediaserver_lyrion._jsonrpc_request')
+ @patch('tasks.mediaserver.lyrion._jsonrpc_request')
def test_forwards_user_creds(self, mock_rpc):
- from tasks.mediaserver_lyrion import list_libraries
+ from tasks.mediaserver.lyrion import list_libraries
mock_rpc.return_value = {'folder_loop': []}
@@ -2325,10 +2325,10 @@ def test_forwards_user_creds(self, mock_rpc):
class TestNavidromeCreateOrReplacePlaylist:
"""Navidrome upsert: create when missing, clear+add when existing (preserve ID)."""
- @patch('tasks.mediaserver_navidrome._create_playlist_batched')
- @patch('tasks.mediaserver_navidrome.get_playlist_by_name')
+ @patch('tasks.mediaserver.navidrome._create_playlist_batched')
+ @patch('tasks.mediaserver.navidrome.get_playlist_by_name')
def test_missing_playlist_creates_via_batched(self, mock_get, mock_create):
- from tasks.mediaserver_navidrome import create_or_replace_playlist
+ from tasks.mediaserver.navidrome import create_or_replace_playlist
mock_get.return_value = None
mock_create.return_value = {'Id': 'new-pl-1', 'Name': 'SF', 'id': 'new-pl-1'}
@@ -2338,11 +2338,11 @@ def test_missing_playlist_creates_via_batched(self, mock_get, mock_create):
mock_create.assert_called_once_with('SF', ['s1', 's2'], user_creds=None)
assert result['Id'] == 'new-pl-1'
- @patch('tasks.mediaserver_navidrome._add_to_playlist')
- @patch('tasks.mediaserver_navidrome._navidrome_request')
- @patch('tasks.mediaserver_navidrome.get_playlist_by_name')
+ @patch('tasks.mediaserver.navidrome._add_to_playlist')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome.get_playlist_by_name')
def test_existing_playlist_preserves_id(self, mock_get, mock_request, mock_add):
- from tasks.mediaserver_navidrome import create_or_replace_playlist
+ from tasks.mediaserver.navidrome import create_or_replace_playlist
mock_get.return_value = {'id': 'pl-existing', 'name': 'SF'}
# 1st call: getPlaylist returns 3 songs. 2nd+: updatePlaylist returns ok.
@@ -2371,11 +2371,11 @@ def test_existing_playlist_preserves_id(self, mock_get, mock_request, mock_add):
# Returned dict carries the existing ID
assert result['Id'] == 'pl-existing'
- @patch('tasks.mediaserver_navidrome._add_to_playlist')
- @patch('tasks.mediaserver_navidrome._navidrome_request')
- @patch('tasks.mediaserver_navidrome.get_playlist_by_name')
+ @patch('tasks.mediaserver.navidrome._add_to_playlist')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome.get_playlist_by_name')
def test_clear_batches_above_40(self, mock_get, mock_request, mock_add):
- from tasks.mediaserver_navidrome import create_or_replace_playlist
+ from tasks.mediaserver.navidrome import create_or_replace_playlist
mock_get.return_value = {'id': 'pl-100', 'name': 'SF'}
mock_request.side_effect = [
@@ -2393,10 +2393,10 @@ def test_clear_batches_above_40(self, mock_get, mock_request, mock_add):
assert len(update_calls[1][0][1]['songIndexToRemove']) == 40
assert len(update_calls[2][0][1]['songIndexToRemove']) == 20
- @patch('tasks.mediaserver_navidrome._navidrome_request')
- @patch('tasks.mediaserver_navidrome.get_playlist_by_name')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome.get_playlist_by_name')
def test_empty_item_ids_returns_none_without_calls(self, mock_get, mock_request):
- from tasks.mediaserver_navidrome import create_or_replace_playlist
+ from tasks.mediaserver.navidrome import create_or_replace_playlist
result = create_or_replace_playlist('SF', [])
@@ -2404,12 +2404,12 @@ def test_empty_item_ids_returns_none_without_calls(self, mock_get, mock_request)
mock_get.assert_not_called()
mock_request.assert_not_called()
- @patch('tasks.mediaserver_navidrome._add_to_playlist')
- @patch('tasks.mediaserver_navidrome._navidrome_request')
- @patch('tasks.mediaserver_navidrome.get_playlist_by_name')
+ @patch('tasks.mediaserver.navidrome._add_to_playlist')
+ @patch('tasks.mediaserver.navidrome._navidrome_request')
+ @patch('tasks.mediaserver.navidrome.get_playlist_by_name')
def test_returns_none_when_add_fails_after_clear(self, mock_get, mock_request, mock_add):
"""If clear succeeds but add fails, return None so the cron handler doesn't log success."""
- from tasks.mediaserver_navidrome import create_or_replace_playlist
+ from tasks.mediaserver.navidrome import create_or_replace_playlist
mock_get.return_value = {'id': 'pl-1', 'name': 'SF'}
mock_request.side_effect = [
@@ -2426,11 +2426,11 @@ def test_returns_none_when_add_fails_after_clear(self, mock_get, mock_request, m
class TestJellyfinCreateOrReplacePlaylist:
"""Jellyfin upsert via /Playlists/{Id}/Items POST/DELETE/GET."""
- @patch('tasks.mediaserver_jellyfin.requests')
- @patch('tasks.mediaserver_jellyfin.get_playlist_by_name')
- @patch('tasks.mediaserver_jellyfin.config')
+ @patch('tasks.mediaserver.jellyfin.requests')
+ @patch('tasks.mediaserver.jellyfin.get_playlist_by_name')
+ @patch('tasks.mediaserver.jellyfin.config')
def test_missing_playlist_creates_and_returns_id(self, mock_config, mock_get, mock_requests):
- from tasks.mediaserver_jellyfin import create_or_replace_playlist
+ from tasks.mediaserver.jellyfin import create_or_replace_playlist
mock_config.JELLYFIN_URL = 'http://jf'
mock_config.JELLYFIN_USER_ID = 'admin-user'
@@ -2450,11 +2450,11 @@ def test_missing_playlist_creates_and_returns_id(self, mock_config, mock_get, mo
assert post_call[1]['json'] == {'Name': 'SF', 'Ids': ['s1', 's2'], 'UserId': 'admin-user'}
assert result['Id'] == 'new-jf-1'
- @patch('tasks.mediaserver_jellyfin.requests')
- @patch('tasks.mediaserver_jellyfin.get_playlist_by_name')
- @patch('tasks.mediaserver_jellyfin.config')
+ @patch('tasks.mediaserver.jellyfin.requests')
+ @patch('tasks.mediaserver.jellyfin.get_playlist_by_name')
+ @patch('tasks.mediaserver.jellyfin.config')
def test_existing_playlist_clears_and_adds_preserving_id(self, mock_config, mock_get, mock_requests):
- from tasks.mediaserver_jellyfin import create_or_replace_playlist
+ from tasks.mediaserver.jellyfin import create_or_replace_playlist
mock_config.JELLYFIN_URL = 'http://jf'
mock_config.JELLYFIN_USER_ID = 'admin-user'
@@ -2489,25 +2489,25 @@ def test_existing_playlist_clears_and_adds_preserving_id(self, mock_config, mock
# Same ID preserved
assert result['Id'] == 'pl-existing'
- @patch('tasks.mediaserver_jellyfin.get_playlist_by_name')
+ @patch('tasks.mediaserver.jellyfin.get_playlist_by_name')
def test_empty_item_ids_returns_none(self, mock_get):
- from tasks.mediaserver_jellyfin import create_or_replace_playlist
+ from tasks.mediaserver.jellyfin import create_or_replace_playlist
result = create_or_replace_playlist('SF', [])
assert result is None
mock_get.assert_not_called()
- @patch('tasks.mediaserver_jellyfin._add_items_to_playlist')
- @patch('tasks.mediaserver_jellyfin._remove_playlist_entries')
- @patch('tasks.mediaserver_jellyfin._get_playlist_entry_ids')
- @patch('tasks.mediaserver_jellyfin.get_playlist_by_name')
- @patch('tasks.mediaserver_jellyfin.config')
+ @patch('tasks.mediaserver.jellyfin._add_items_to_playlist')
+ @patch('tasks.mediaserver.jellyfin._remove_playlist_entries')
+ @patch('tasks.mediaserver.jellyfin._get_playlist_entry_ids')
+ @patch('tasks.mediaserver.jellyfin.get_playlist_by_name')
+ @patch('tasks.mediaserver.jellyfin.config')
def test_returns_none_when_add_fails_after_clear(
self, mock_config, mock_get, mock_get_entries, mock_remove, mock_add
):
"""If clear succeeds but add fails, return None instead of misreporting success."""
- from tasks.mediaserver_jellyfin import create_or_replace_playlist
+ from tasks.mediaserver.jellyfin import create_or_replace_playlist
mock_config.JELLYFIN_URL = 'http://jf'
mock_config.JELLYFIN_USER_ID = 'admin-user'
@@ -2521,18 +2521,18 @@ def test_returns_none_when_add_fails_after_clear(
assert result is None
- @patch('tasks.mediaserver_jellyfin._create_fresh_playlist')
- @patch('tasks.mediaserver_jellyfin.delete_playlist')
- @patch('tasks.mediaserver_jellyfin._remove_playlist_entries')
- @patch('tasks.mediaserver_jellyfin._get_playlist_entry_ids')
- @patch('tasks.mediaserver_jellyfin.get_playlist_by_name')
- @patch('tasks.mediaserver_jellyfin.config')
+ @patch('tasks.mediaserver.jellyfin._create_fresh_playlist')
+ @patch('tasks.mediaserver.jellyfin.delete_playlist')
+ @patch('tasks.mediaserver.jellyfin._remove_playlist_entries')
+ @patch('tasks.mediaserver.jellyfin._get_playlist_entry_ids')
+ @patch('tasks.mediaserver.jellyfin.get_playlist_by_name')
+ @patch('tasks.mediaserver.jellyfin.config')
def test_falls_back_to_recreate_when_remove_fails(
self, mock_config, mock_get, mock_get_entries, mock_remove, mock_delete, mock_create
):
"""Jellyfin <10.11 + API token rejects DELETE /Playlists/{Id}/Items (jellyfin#13476).
When that happens, delete the whole playlist and recreate. Id will change."""
- from tasks.mediaserver_jellyfin import create_or_replace_playlist
+ from tasks.mediaserver.jellyfin import create_or_replace_playlist
mock_config.JELLYFIN_URL = 'http://jf'
mock_config.JELLYFIN_USER_ID = 'admin-user'
@@ -2549,17 +2549,17 @@ def test_falls_back_to_recreate_when_remove_fails(
mock_create.assert_called_once_with('SF', ['n1', 'n2'])
assert result['Id'] == 'new-pl'
- @patch('tasks.mediaserver_jellyfin._create_fresh_playlist')
- @patch('tasks.mediaserver_jellyfin.delete_playlist')
- @patch('tasks.mediaserver_jellyfin._remove_playlist_entries')
- @patch('tasks.mediaserver_jellyfin._get_playlist_entry_ids')
- @patch('tasks.mediaserver_jellyfin.get_playlist_by_name')
- @patch('tasks.mediaserver_jellyfin.config')
+ @patch('tasks.mediaserver.jellyfin._create_fresh_playlist')
+ @patch('tasks.mediaserver.jellyfin.delete_playlist')
+ @patch('tasks.mediaserver.jellyfin._remove_playlist_entries')
+ @patch('tasks.mediaserver.jellyfin._get_playlist_entry_ids')
+ @patch('tasks.mediaserver.jellyfin.get_playlist_by_name')
+ @patch('tasks.mediaserver.jellyfin.config')
def test_fallback_returns_none_when_delete_playlist_fails(
self, mock_config, mock_get, mock_get_entries, mock_remove, mock_delete, mock_create
):
"""If the fallback delete itself fails, don't try to recreate — bail out."""
- from tasks.mediaserver_jellyfin import create_or_replace_playlist
+ from tasks.mediaserver.jellyfin import create_or_replace_playlist
mock_config.JELLYFIN_URL = 'http://jf'
mock_config.JELLYFIN_USER_ID = 'admin-user'
@@ -2578,11 +2578,11 @@ def test_fallback_returns_none_when_delete_playlist_fails(
class TestEmbyCreateOrReplacePlaylist:
"""Emby upsert under /emby/ prefix with uppercase params."""
- @patch('tasks.mediaserver_emby.requests')
- @patch('tasks.mediaserver_emby.get_playlist_by_name')
- @patch('tasks.mediaserver_emby.config')
+ @patch('tasks.mediaserver.emby.requests')
+ @patch('tasks.mediaserver.emby.get_playlist_by_name')
+ @patch('tasks.mediaserver.emby.config')
def test_existing_playlist_clears_and_adds_preserving_id(self, mock_config, mock_get, mock_requests):
- from tasks.mediaserver_emby import create_or_replace_playlist
+ from tasks.mediaserver.emby import create_or_replace_playlist
mock_config.EMBY_URL = 'http://emby'
mock_config.EMBY_USER_ID = 'admin-emby'
@@ -2609,15 +2609,15 @@ def test_existing_playlist_clears_and_adds_preserving_id(self, mock_config, mock
assert post_call[1]['params']['UserId'] == 'admin-emby'
assert result['Id'] == 'emby-pl'
- @patch('tasks.mediaserver_emby._add_items_to_playlist')
- @patch('tasks.mediaserver_emby._remove_playlist_entries')
- @patch('tasks.mediaserver_emby._get_playlist_entry_ids')
- @patch('tasks.mediaserver_emby.get_playlist_by_name')
- @patch('tasks.mediaserver_emby.config')
+ @patch('tasks.mediaserver.emby._add_items_to_playlist')
+ @patch('tasks.mediaserver.emby._remove_playlist_entries')
+ @patch('tasks.mediaserver.emby._get_playlist_entry_ids')
+ @patch('tasks.mediaserver.emby.get_playlist_by_name')
+ @patch('tasks.mediaserver.emby.config')
def test_returns_none_when_add_fails_after_clear(
self, mock_config, mock_get, mock_get_entries, mock_remove, mock_add
):
- from tasks.mediaserver_emby import create_or_replace_playlist
+ from tasks.mediaserver.emby import create_or_replace_playlist
mock_config.EMBY_URL = 'http://emby'
mock_config.EMBY_USER_ID = 'admin-emby'
@@ -2635,11 +2635,11 @@ def test_returns_none_when_add_fails_after_clear(
class TestLyrionCreateOrReplacePlaylist:
"""Lyrion upsert: deletes existing then creates fresh (ID may change — known limitation)."""
- @patch('tasks.mediaserver_lyrion._create_playlist_batched')
- @patch('tasks.mediaserver_lyrion.delete_playlist')
- @patch('tasks.mediaserver_lyrion.get_playlist_by_name')
+ @patch('tasks.mediaserver.lyrion._create_playlist_batched')
+ @patch('tasks.mediaserver.lyrion.delete_playlist')
+ @patch('tasks.mediaserver.lyrion.get_playlist_by_name')
def test_existing_deletes_then_creates(self, mock_get, mock_delete, mock_create):
- from tasks.mediaserver_lyrion import create_or_replace_playlist
+ from tasks.mediaserver.lyrion import create_or_replace_playlist
mock_get.return_value = {'Id': 99, 'Name': 'SF'}
mock_delete.return_value = True
@@ -2651,11 +2651,11 @@ def test_existing_deletes_then_creates(self, mock_get, mock_delete, mock_create)
mock_create.assert_called_once_with('SF', ['t1'])
assert result['Name'] == 'SF'
- @patch('tasks.mediaserver_lyrion._create_playlist_batched')
- @patch('tasks.mediaserver_lyrion.delete_playlist')
- @patch('tasks.mediaserver_lyrion.get_playlist_by_name')
+ @patch('tasks.mediaserver.lyrion._create_playlist_batched')
+ @patch('tasks.mediaserver.lyrion.delete_playlist')
+ @patch('tasks.mediaserver.lyrion.get_playlist_by_name')
def test_missing_creates_without_delete(self, mock_get, mock_delete, mock_create):
- from tasks.mediaserver_lyrion import create_or_replace_playlist
+ from tasks.mediaserver.lyrion import create_or_replace_playlist
mock_get.return_value = None
mock_create.return_value = {'Id': 50, 'Name': 'SF'}
@@ -2665,12 +2665,12 @@ def test_missing_creates_without_delete(self, mock_get, mock_delete, mock_create
mock_delete.assert_not_called()
mock_create.assert_called_once_with('SF', ['t1'])
- @patch('tasks.mediaserver_lyrion._create_playlist_batched')
- @patch('tasks.mediaserver_lyrion.delete_playlist')
- @patch('tasks.mediaserver_lyrion.get_playlist_by_name')
+ @patch('tasks.mediaserver.lyrion._create_playlist_batched')
+ @patch('tasks.mediaserver.lyrion.delete_playlist')
+ @patch('tasks.mediaserver.lyrion.get_playlist_by_name')
def test_aborts_when_delete_fails(self, mock_get, mock_delete, mock_create):
"""If delete fails, return None instead of creating a duplicate playlist."""
- from tasks.mediaserver_lyrion import create_or_replace_playlist
+ from tasks.mediaserver.lyrion import create_or_replace_playlist
mock_get.return_value = {'Id': 99, 'Name': 'SF'}
mock_delete.return_value = False
@@ -2695,15 +2695,15 @@ def test_requires_name_and_ids(self, mock_config):
create_or_replace_playlist('Name', [])
@patch('tasks.mediaserver.config')
- def test_mpd_raises_not_implemented(self, mock_config):
+ def test_unsupported_backend_raises_not_implemented(self, mock_config):
from tasks.mediaserver import create_or_replace_playlist
- mock_config.MEDIASERVER_TYPE = 'mpd'
+ mock_config.MEDIASERVER_TYPE = 'unsupported'
with pytest.raises(NotImplementedError):
create_or_replace_playlist('SF', ['s1'])
- @patch('tasks.mediaserver.navidrome_create_or_replace_playlist')
+ @patch('tasks.mediaserver.navidrome.create_or_replace_playlist')
@patch('tasks.mediaserver.config')
def test_dispatches_to_navidrome(self, mock_config, mock_provider):
from tasks.mediaserver import create_or_replace_playlist
@@ -2716,7 +2716,7 @@ def test_dispatches_to_navidrome(self, mock_config, mock_provider):
mock_provider.assert_called_once_with('SF', ['s1'], None)
assert result['Id'] == 'pl-1'
- @patch('tasks.mediaserver.jellyfin_create_or_replace_playlist')
+ @patch('tasks.mediaserver.jellyfin.create_or_replace_playlist')
@patch('tasks.mediaserver.config')
def test_dispatches_to_jellyfin(self, mock_config, mock_provider):
from tasks.mediaserver import create_or_replace_playlist
@@ -2728,7 +2728,7 @@ def test_dispatches_to_jellyfin(self, mock_config, mock_provider):
mock_provider.assert_called_once()
- @patch('tasks.mediaserver.emby_create_or_replace_playlist')
+ @patch('tasks.mediaserver.emby.create_or_replace_playlist')
@patch('tasks.mediaserver.config')
def test_dispatches_to_emby(self, mock_config, mock_provider):
from tasks.mediaserver import create_or_replace_playlist
@@ -2740,7 +2740,7 @@ def test_dispatches_to_emby(self, mock_config, mock_provider):
mock_provider.assert_called_once()
- @patch('tasks.mediaserver.lyrion_create_or_replace_playlist')
+ @patch('tasks.mediaserver.lyrion.create_or_replace_playlist')
@patch('tasks.mediaserver.config')
def test_dispatches_to_lyrion(self, mock_config, mock_provider):
from tasks.mediaserver import create_or_replace_playlist
@@ -2782,11 +2782,11 @@ def _audio_page(n_items, start=0):
class TestJellyfinGetAllSongsPagination:
"""Jellyfin get_all_songs: paginate, and raise rather than truncate."""
- @patch('tasks.mediaserver_jellyfin.requests.get')
- @patch('tasks.mediaserver_jellyfin.config')
+ @patch('tasks.mediaserver.jellyfin.requests.get')
+ @patch('tasks.mediaserver.jellyfin.config')
def test_paginates_until_short_page(self, mock_config, mock_get):
"""A full page (== limit) triggers another request; a short page stops it."""
- from tasks.mediaserver_jellyfin import get_all_songs
+ from tasks.mediaserver.jellyfin import get_all_songs
mock_config.JELLYFIN_URL = 'http://jellyfin:8096'
mock_config.JELLYFIN_USER_ID = 'user123'
@@ -2803,11 +2803,11 @@ def test_paginates_until_short_page(self, mock_config, mock_get):
assert page2_params['StartIndex'] == 500
assert page2_params['Limit'] == 500
- @patch('tasks.mediaserver_jellyfin.requests.get')
- @patch('tasks.mediaserver_jellyfin.config')
+ @patch('tasks.mediaserver.jellyfin.requests.get')
+ @patch('tasks.mediaserver.jellyfin.config')
def test_raises_on_midscan_failure_instead_of_truncating(self, mock_config, mock_get):
"""A timeout on a later page must propagate, NOT return the partial list."""
- from tasks.mediaserver_jellyfin import get_all_songs
+ from tasks.mediaserver.jellyfin import get_all_songs
mock_config.JELLYFIN_URL = 'http://jellyfin:8096'
mock_config.JELLYFIN_USER_ID = 'user123'
@@ -2821,11 +2821,11 @@ def test_raises_on_midscan_failure_instead_of_truncating(self, mock_config, mock
with pytest.raises(requests.exceptions.ReadTimeout):
get_all_songs()
- @patch('tasks.mediaserver_jellyfin.requests.get')
- @patch('tasks.mediaserver_jellyfin.config')
+ @patch('tasks.mediaserver.jellyfin.requests.get')
+ @patch('tasks.mediaserver.jellyfin.config')
def test_empty_library_returns_empty_without_raising(self, mock_config, mock_get):
"""A genuinely empty library (first page empty) returns [], does not raise."""
- from tasks.mediaserver_jellyfin import get_all_songs
+ from tasks.mediaserver.jellyfin import get_all_songs
mock_config.JELLYFIN_URL = 'http://jellyfin:8096'
mock_config.JELLYFIN_USER_ID = 'user123'
@@ -2839,10 +2839,10 @@ class TestEmbyGetAllSongsRaisesOnFailure:
"""Emby is already paginated (limit=1000); on a page error it must raise,
not return the partial list it had accumulated."""
- @patch('tasks.mediaserver_emby.requests.get')
- @patch('tasks.mediaserver_emby.config')
+ @patch('tasks.mediaserver.emby.requests.get')
+ @patch('tasks.mediaserver.emby.config')
def test_raises_on_midscan_failure_instead_of_truncating(self, mock_config, mock_get):
- from tasks.mediaserver_emby import get_all_songs
+ from tasks.mediaserver.emby import get_all_songs
mock_config.EMBY_URL = 'http://emby:8096'
mock_config.EMBY_USER_ID = 'user123'
@@ -2856,10 +2856,10 @@ def test_raises_on_midscan_failure_instead_of_truncating(self, mock_config, mock
with pytest.raises(requests.exceptions.ReadTimeout):
get_all_songs()
- @patch('tasks.mediaserver_emby.requests.get')
- @patch('tasks.mediaserver_emby.config')
+ @patch('tasks.mediaserver.emby.requests.get')
+ @patch('tasks.mediaserver.emby.config')
def test_empty_library_returns_empty_without_raising(self, mock_config, mock_get):
- from tasks.mediaserver_emby import get_all_songs
+ from tasks.mediaserver.emby import get_all_songs
mock_config.EMBY_URL = 'http://emby:8096'
mock_config.EMBY_USER_ID = 'user123'
diff --git a/tests/unit/test_memory_cleanup.py b/test/unit/test_memory_cleanup.py
similarity index 100%
rename from tests/unit/test_memory_cleanup.py
rename to test/unit/test_memory_cleanup.py
diff --git a/tests/unit/test_memory_utils.py b/test/unit/test_memory_utils.py
similarity index 100%
rename from tests/unit/test_memory_utils.py
rename to test/unit/test_memory_utils.py
diff --git a/tests/unit/test_numeric_bootstrap.py b/test/unit/test_numeric_bootstrap.py
similarity index 100%
rename from tests/unit/test_numeric_bootstrap.py
rename to test/unit/test_numeric_bootstrap.py
diff --git a/tests/unit/test_path_manager.py b/test/unit/test_path_manager.py
similarity index 100%
rename from tests/unit/test_path_manager.py
rename to test/unit/test_path_manager.py
diff --git a/tests/unit/test_playlist_ordering.py b/test/unit/test_playlist_ordering.py
similarity index 99%
rename from tests/unit/test_playlist_ordering.py
rename to test/unit/test_playlist_ordering.py
index c041321f..a8106f17 100644
--- a/tests/unit/test_playlist_ordering.py
+++ b/test/unit/test_playlist_ordering.py
@@ -9,7 +9,7 @@
- Handling of songs missing from database
"""
-from tests.conftest import _import_module
+from test.unit.conftest import _import_module
def _load_playlist_ordering():
diff --git a/tests/unit/test_provider_migration_blueprint.py b/test/unit/test_provider_migration_blueprint.py
similarity index 98%
rename from tests/unit/test_provider_migration_blueprint.py
rename to test/unit/test_provider_migration_blueprint.py
index 0fd3e22f..fd3181ca 100644
--- a/tests/unit/test_provider_migration_blueprint.py
+++ b/test/unit/test_provider_migration_blueprint.py
@@ -213,7 +213,7 @@ def test_returns_warning_when_still_not_absolute(self, bp_mod, client):
def test_rejects_unsupported_current_provider(self, bp_mod, client):
import config
- config.MEDIASERVER_TYPE = 'mpd'
+ config.MEDIASERVER_TYPE = 'plex'
resp = client.post('/api/migration/source-paths/refresh',
json={'session_id': 1})
assert resp.status_code == 400
@@ -389,8 +389,8 @@ def test_rejects_unsafe_urls(self, bp_mod, url):
assert isinstance(reason, str) and reason # a human-readable reason
@pytest.mark.parametrize('creds', [{}, {'url': ''}, {'url': None}])
- def test_missing_url_is_allowed_for_mpd(self, bp_mod, creds):
- # MPD targets carry no URL; the wrapper lets the downstream probe handle it.
+ def test_missing_url_is_allowed(self, bp_mod, creds):
+ # The wrapper lets the downstream probe handle a missing URL.
ok, reason = bp_mod._validate_probe_url(creds)
assert ok is True
assert reason is None
diff --git a/tests/unit/test_provider_migration_execute.py b/test/unit/test_provider_migration_execute.py
similarity index 100%
rename from tests/unit/test_provider_migration_execute.py
rename to test/unit/test_provider_migration_execute.py
diff --git a/tests/unit/test_provider_migration_matcher.py b/test/unit/test_provider_migration_matcher.py
similarity index 100%
rename from tests/unit/test_provider_migration_matcher.py
rename to test/unit/test_provider_migration_matcher.py
diff --git a/tests/unit/test_provider_probe.py b/test/unit/test_provider_probe.py
similarity index 97%
rename from tests/unit/test_provider_probe.py
rename to test/unit/test_provider_probe.py
index eb50ed19..83a240c9 100644
--- a/tests/unit/test_provider_probe.py
+++ b/test/unit/test_provider_probe.py
@@ -116,7 +116,7 @@ def test_supported_providers_normalized_lowercase(self, probe):
def test_unsupported_provider_raises(self, probe):
with pytest.raises(ValueError) as ei:
- probe._normalize_provider_type('mpd')
+ probe._normalize_provider_type('plex')
assert 'not supported' in str(ei.value)
def test_empty_or_none_raises(self, probe):
@@ -158,7 +158,7 @@ def test_empty_result_is_handled(self, probe):
def test_unsupported_provider_raises_before_call(self, probe):
with patch.object(probe.mediaserver, 'get_all_songs') as m:
with pytest.raises(ValueError):
- probe.fetch_all_tracks('mpd', self.CREDS)
+ probe.fetch_all_tracks('plex', self.CREDS)
m.assert_not_called()
@@ -179,7 +179,7 @@ def test_delegates_to_mediaserver(self, probe):
def test_unsupported_provider_raises(self, probe):
with patch.object(probe.mediaserver, 'search_albums') as m:
with pytest.raises(ValueError):
- probe.search_albums('mpd', self.CREDS, 'q')
+ probe.search_albums('plex', self.CREDS, 'q')
m.assert_not_called()
@@ -224,5 +224,5 @@ def test_delegates_to_mediaserver(self, probe):
def test_unsupported_provider_raises(self, probe):
with patch.object(probe.mediaserver, 'test_connection') as m:
with pytest.raises(ValueError):
- probe.test_connection('mpd', self.CREDS)
+ probe.test_connection('plex', self.CREDS)
m.assert_not_called()
diff --git a/tests/unit/test_radius_walk_helper.py b/test/unit/test_radius_walk_helper.py
similarity index 100%
rename from tests/unit/test_radius_walk_helper.py
rename to test/unit/test_radius_walk_helper.py
diff --git a/tests/unit/test_restart_manager.py b/test/unit/test_restart_manager.py
similarity index 100%
rename from tests/unit/test_restart_manager.py
rename to test/unit/test_restart_manager.py
diff --git a/tests/unit/test_security_ssrf.py b/test/unit/test_security_ssrf.py
similarity index 100%
rename from tests/unit/test_security_ssrf.py
rename to test/unit/test_security_ssrf.py
diff --git a/tests/unit/test_sem_grove_manager.py b/test/unit/test_sem_grove_manager.py
similarity index 99%
rename from tests/unit/test_sem_grove_manager.py
rename to test/unit/test_sem_grove_manager.py
index 8c6dbf92..c35d8348 100644
--- a/tests/unit/test_sem_grove_manager.py
+++ b/test/unit/test_sem_grove_manager.py
@@ -1,4 +1,4 @@
-# tests/unit/test_sem_grove_manager.py
+# test/unit/test_sem_grove_manager.py
"""
Unit tests for tasks/sem_grove_manager.py
diff --git a/tests/unit/test_setup_manager.py b/test/unit/test_setup_manager.py
similarity index 100%
rename from tests/unit/test_setup_manager.py
rename to test/unit/test_setup_manager.py
diff --git a/tests/unit/test_song_alchemy.py b/test/unit/test_song_alchemy.py
similarity index 100%
rename from tests/unit/test_song_alchemy.py
rename to test/unit/test_song_alchemy.py
diff --git a/tests/unit/test_sonic_fingerprint_manager.py b/test/unit/test_sonic_fingerprint_manager.py
similarity index 100%
rename from tests/unit/test_sonic_fingerprint_manager.py
rename to test/unit/test_sonic_fingerprint_manager.py
diff --git a/tests/unit/test_sql_injection_params.py b/test/unit/test_sql_injection_params.py
similarity index 100%
rename from tests/unit/test_sql_injection_params.py
rename to test/unit/test_sql_injection_params.py
diff --git a/tests/unit/test_string_sanitization.py b/test/unit/test_string_sanitization.py
similarity index 100%
rename from tests/unit/test_string_sanitization.py
rename to test/unit/test_string_sanitization.py
diff --git a/tests/unit/test_taskqueue.py b/test/unit/test_taskqueue.py
similarity index 100%
rename from tests/unit/test_taskqueue.py
rename to test/unit/test_taskqueue.py
diff --git a/tests/unit/test_tool_plan.py b/test/unit/test_tool_plan.py
similarity index 100%
rename from tests/unit/test_tool_plan.py
rename to test/unit/test_tool_plan.py
diff --git a/tests/unit/test_tz_helper.py b/test/unit/test_tz_helper.py
similarity index 100%
rename from tests/unit/test_tz_helper.py
rename to test/unit/test_tz_helper.py
diff --git a/tests/unit/test_voyager_manager.py b/test/unit/test_voyager_manager.py
similarity index 99%
rename from tests/unit/test_voyager_manager.py
rename to test/unit/test_voyager_manager.py
index da1fa985..ff809c4e 100644
--- a/tests/unit/test_voyager_manager.py
+++ b/test/unit/test_voyager_manager.py
@@ -1,4 +1,4 @@
-# tests/unit/test_voyager_manager.py
+# test/unit/test_voyager_manager.py
"""
Unit tests for tasks/voyager_manager.py
@@ -479,7 +479,7 @@ def test_raises_when_id_map_not_loaded(self):
class TestCreatePlaylistFromIds:
"""Test playlist creation functionality"""
- @patch('tasks.voyager_manager.create_instant_playlist')
+ @patch('tasks.mediaserver.create_instant_playlist')
def test_calls_mediaserver_create_playlist(self, mock_create):
"""Should call mediaserver create_instant_playlist"""
from tasks.voyager_manager import create_playlist_from_ids
@@ -491,7 +491,7 @@ def test_calls_mediaserver_create_playlist(self, mock_create):
assert result == 'playlist-123'
mock_create.assert_called_once_with('Test Playlist', ['track-1', 'track-2'], user_creds=None)
- @patch('tasks.voyager_manager.create_instant_playlist')
+ @patch('tasks.mediaserver.create_instant_playlist')
def test_raises_on_creation_failure(self, mock_create):
"""Should raise exception if playlist creation fails"""
from tasks.voyager_manager import create_playlist_from_ids
@@ -501,7 +501,7 @@ def test_raises_on_creation_failure(self, mock_create):
with pytest.raises(Exception, match="Playlist creation failed"):
create_playlist_from_ids('Test Playlist', ['track-1'])
- @patch('tasks.voyager_manager.create_instant_playlist')
+ @patch('tasks.mediaserver.create_instant_playlist')
def test_raises_on_missing_playlist_id(self, mock_create):
"""Should raise exception if response has no Id"""
from tasks.voyager_manager import create_playlist_from_ids
@@ -511,7 +511,7 @@ def test_raises_on_missing_playlist_id(self, mock_create):
with pytest.raises(Exception, match="did not include a playlist ID"):
create_playlist_from_ids('Test Playlist', ['track-1'])
- @patch('tasks.voyager_manager.create_instant_playlist')
+ @patch('tasks.mediaserver.create_instant_playlist')
def test_passes_user_credentials(self, mock_create):
"""Should pass user credentials to mediaserver"""
from tasks.voyager_manager import create_playlist_from_ids
diff --git a/tests/unit/test_windows_paths.py b/test/unit/test_windows_paths.py
similarity index 95%
rename from tests/unit/test_windows_paths.py
rename to test/unit/test_windows_paths.py
index b6022ae7..5ffd9305 100644
--- a/tests/unit/test_windows_paths.py
+++ b/test/unit/test_windows_paths.py
@@ -1,11 +1,11 @@
import os
from unittest.mock import patch
-from tests.conftest import _import_module
+from test.unit.conftest import _import_module
def _load_paths():
- return _import_module('windows.paths', 'windows/paths.py')
+ return _import_module('windows.paths', 'native-build/windows/paths.py')
def _norm(path):
diff --git a/testing_suite/instant_playlist_optimize_config.yaml b/testing_suite/instant_playlist_optimize_config.yaml
deleted file mode 100644
index 7c5c49c5..00000000
--- a/testing_suite/instant_playlist_optimize_config.yaml
+++ /dev/null
@@ -1,252 +0,0 @@
-# Instant Playlist - Optimization Test Config
-# Single instance, iterative testing to improve prompt + code quality
-
-instance:
- api_url: "http://localhost:8000"
-
-test_config:
- timeout_per_request: 300
- retry_on_error: 1
- retry_delay: 5
-
-models:
- # --- OpenRouter models ---
- - provider: "openrouter"
- name: "Claude Sonnet 4.6"
- model_id: "anthropic/claude-sonnet-4.6"
- enabled: false
-
- - provider: "openrouter"
- name: "Claude 4.5 Haiku"
- model_id: "anthropic/claude-haiku-4.5"
- enabled: false
-
- - provider: "openrouter"
- name: "Gemini 3 Flash"
- model_id: "google/gemini-3-flash-preview"
- enabled: false
-
- - provider: "openrouter"
- name: "GPT-4o Mini"
- model_id: "openai/gpt-4o-mini"
- enabled: false
-
- # --- Ollama models ---
- # Benchmark #1: 0.960 agent score, perfect restraint
- - provider: "ollama"
- name: "Qwen 3 1.7B"
- model_id: "qwen3:1.7b"
- url: "http://192.168.1.71:11434/api/generate"
- enabled: false
-
- # Benchmark #2: 0.920, fastest model (1.6s), perfect restraint
- - provider: "ollama"
- name: "LFM 2.5 1.2B"
- model_id: "lfm2.5-thinking:1.2b"
- url: "http://192.168.1.71:11434/api/generate"
- enabled: false
-
- # Benchmark #4: 0.800, perfect restraint
- - provider: "ollama"
- name: "Qwen 2.5 1.5B"
- model_id: "qwen2.5:1.5b"
- url: "http://192.168.1.71:11434/api/generate"
- enabled: false
-
- # Rank 3: avg 2.7, 21.7s avg response
- - provider: "ollama"
- name: "Ministral 3 3B"
- model_id: "ministral-3:3b"
- url: "http://192.168.1.71:11434/api/generate"
- enabled: true
-
- # Benchmark #7: 0.780, perfect restraint
- - provider: "ollama"
- name: "Phi 4 Mini 3.8B"
- model_id: "phi4-mini:3.8b"
- url: "http://192.168.1.71:11434/api/generate"
- enabled: false
-
- # Benchmark #10: 0.660, high action but 0.000 restraint
- - provider: "ollama"
- name: "Llama 3.2 3B"
- model_id: "llama3.2:3b"
- url: "http://192.168.1.71:11434/api/generate"
- enabled: false
-
- # Rank 1: avg 2.9, 6.3s avg response (fastest top model)
- - provider: "ollama"
- name: "Gemma 3 4B"
- model_id: "gemma3:4b"
- url: "http://192.168.1.71:11434/api/generate"
- enabled: true
-
- - provider: "ollama"
- name: "Qwen 3.5 0.8B"
- model_id: "qwen3.5:0.8b"
- url: "http://192.168.1.71:11434/api/generate"
- enabled: false
-
- - provider: "ollama"
- name: "Qwen 3.5 2B"
- model_id: "qwen3.5:2b"
- url: "http://192.168.1.71:11434/api/generate"
- enabled: false
-
- # Rank 3: avg 2.7, 57.4s avg response
- - provider: "ollama"
- name: "Qwen 3.5 4B"
- model_id: "qwen3.5:4b"
- url: "http://192.168.1.71:11434/api/generate"
- enabled: true
-
- # Rank 1: avg 2.9, 46.7s avg response
- - provider: "ollama"
- name: "Qwen 3.5 9B"
- model_id: "qwen3.5:9b"
- url: "http://192.168.1.71:11434/api/generate"
- enabled: true
-
-test_prompts:
- # ===== BUG FIX VALIDATION =====
- # These test the 4 bugs we just fixed
-
- # Bug 1: Year filter - was setting year_min=1, year_max=2026
- - prompt: "2026 songs"
- category: "year_filter"
- expected:
- min_songs: 50
- expected_tools: ["search_database"]
- must_have_filter: ["year="]
- no_extra_filters: true
- allowed_filters: ["year_min", "year_max"]
-
- - prompt: "give me 100 songs from 2026"
- category: "year_filter"
- expected:
- min_songs: 80
- expected_tools: ["search_database"]
- must_have_filter: ["year="]
- no_extra_filters: true
- allowed_filters: ["year_min", "year_max"]
-
- - prompt: "songs from 2024"
- category: "year_filter"
- expected:
- min_songs: 30
- expected_tools: ["search_database"]
- must_have_filter: ["year="]
-
- - prompt: "90s rock"
- category: "year_filter"
- expected:
- expected_tools: ["search_database"]
- must_have_filter: ["year="]
-
- # Bug 2: Random rating filter added
- - prompt: "electronic music"
- category: "no_extra_filter"
- expected:
- expected_tools: ["search_database"]
- no_extra_filters: true
- allowed_filters: ["genres"]
-
- # Bug 3: Random genre filter added
- - prompt: "songs from 2020-2025"
- category: "no_extra_filter"
- expected:
- expected_tools: ["search_database"]
- no_extra_filters: true
- allowed_filters: ["year_min", "year_max"]
-
- # Bug 4: Per-artist cap reducing results below 100
- - prompt: "2026 songs"
- category: "artist_cap"
- expected:
- min_songs: 80
-
- # ===== TOOL SELECTION =====
-
- - prompt: "Songs similar to By the Way by Red Hot Chili Peppers"
- category: "song_similarity"
- expected:
- expected_tools: ["song_similarity"]
- min_songs: 50
-
- - prompt: "calm piano music"
- category: "text_search"
- expected:
- expected_tools: ["text_search"]
- min_songs: 50
-
- - prompt: "songs like AC/DC"
- category: "artist_similarity"
- expected:
- expected_tools: ["artist_similarity"]
- min_songs: 50
-
- - prompt: "songs from blink-182"
- category: "artist_filter"
- expected:
- expected_tools: ["search_database"]
-
- - prompt: "top songs of Madonna"
- category: "ai_brainstorm"
- expected:
- expected_tools: ["ai_brainstorm"]
-
- - prompt: "sounds like Iron Maiden and Metallica combined"
- category: "song_alchemy"
- expected:
- expected_tools: ["song_alchemy"]
- min_songs: 50
-
- # ===== FILTER COMBINATIONS =====
-
- - prompt: "rock 5 star songs"
- category: "combined_filter"
- expected:
- expected_tools: ["search_database"]
- must_have_filter: ["min_rating"]
-
- - prompt: "sad jazz songs"
- category: "combined_filter"
- expected:
- expected_tools: ["search_database"]
-
- - prompt: "fast metal songs"
- category: "combined_filter"
- expected:
- expected_tools: ["search_database"]
-
- - prompt: "songs in minor key"
- category: "scale_filter"
- expected:
- expected_tools: ["search_database"]
-
- # ===== COMPLEX / MULTI-TOOL =====
-
- - prompt: "energetic rock music for working out"
- category: "multi_tool"
- expected:
- min_songs: 50
-
- - prompt: "mix of Daft Punk and Gorillaz"
- category: "song_alchemy"
- expected:
- expected_tools: ["song_alchemy"]
- min_songs: 50
-
- - prompt: "High-energy metal and hard rock from 2000-2015, in minor scale, between 120-180 BPM"
- category: "multi_filter"
- expected:
- expected_tools: ["search_database"]
- must_have_filter: ["year="]
-
- - prompt: "songs similar to Metallica, I want a huge playlist with lots of variety"
- category: "diversity_stress"
- expected:
- min_songs: 80
-
-output:
- directory: "testing_suite/reports/optimization"
diff --git a/windows/__init__.py b/windows/__init__.py
deleted file mode 100644
index 5bc9e002..00000000
--- a/windows/__init__.py
+++ /dev/null
@@ -1,4 +0,0 @@
-"""Windows standalone build package.
-
-See windows/README.md for build instructions.
-"""