From 767cca722508ecf7d62405bcc0b04184bd48dc81 Mon Sep 17 00:00:00 2001 From: lachlan Date: Mon, 27 Jul 2026 16:02:06 +1000 Subject: [PATCH 01/19] Add mediaserver\ampache --- app_music_servers.py | 2 +- app_provider_migration.py | 2 +- app_setup.py | 1 + config.py | 9 + tasks/mediaserver/__init__.py | 2 +- tasks/mediaserver/ampache.py | 422 ++++++++++++++++++++++++++++++++++ tasks/provider_probe.py | 4 +- 7 files changed, 437 insertions(+), 5 deletions(-) create mode 100644 tasks/mediaserver/ampache.py diff --git a/app_music_servers.py b/app_music_servers.py index d9a986513..451cf39b5 100644 --- a/app_music_servers.py +++ b/app_music_servers.py @@ -45,7 +45,7 @@ music_servers_bp = Blueprint('music_servers_bp', __name__) -_SUPPORTED_TYPES = ('jellyfin', 'emby', 'navidrome', 'lyrion', 'plex') +_SUPPORTED_TYPES = ('jellyfin', 'emby', 'navidrome', 'lyrion', 'plex', 'ampache') def _setup_in_progress(): diff --git a/app_provider_migration.py b/app_provider_migration.py index 6d458939b..6e8c1ea50 100644 --- a/app_provider_migration.py +++ b/app_provider_migration.py @@ -81,7 +81,7 @@ def __getattr__(self, name): # Supported target providers (what the tool knows how to talk to) # --------------------------------------------------------------------------- -_SUPPORTED_TARGETS = frozenset({'jellyfin', 'navidrome', 'emby', 'lyrion', 'plex'}) +_SUPPORTED_TARGETS = frozenset({'jellyfin', 'navidrome', 'emby', 'lyrion', 'plex', 'ampache'}) # --------------------------------------------------------------------------- diff --git a/app_setup.py b/app_setup.py index dd0eec53f..389add5dd 100644 --- a/app_setup.py +++ b/app_setup.py @@ -69,6 +69,7 @@ def _plex_pin_headers(client_id): "JELLYFIN_TOKEN", "EMBY_TOKEN", "NAVIDROME_PASSWORD", + "AMPACHE_PASSWORD", "PLEX_TOKEN", "JWT_SECRET", "AI_CHAT_DB_USER_PASSWORD", diff --git a/config.py b/config.py index 93d29efd3..db1476b22 100644 --- a/config.py +++ b/config.py @@ -90,6 +90,13 @@ def _compute_headers(): NAVIDROME_USER = os.environ.get("NAVIDROME_USER", "") NAVIDROME_PASSWORD = os.environ.get("NAVIDROME_PASSWORD", "") # Use the password directly +# --- Ampache Constants --- +# These are used only if MEDIASERVER_TYPE is "ampache". AMPACHE_PASSWORD takes either the +# account password or an API key; the handshake tries both, so no separate key field exists. +AMPACHE_URL = os.environ.get("AMPACHE_URL", "") # e.g. http://your-ampache-server +AMPACHE_USER = os.environ.get("AMPACHE_USER", "") +AMPACHE_PASSWORD = os.environ.get("AMPACHE_PASSWORD", "") + # --- Lyrion (LMS) Constants --- # These are used only if MEDIASERVER_TYPE is "lyrion". LYRION_URL = os.environ.get("LYRION_URL", "") @@ -105,6 +112,7 @@ def _compute_headers(): 'lyrion': ['LYRION_URL'], 'emby': ['EMBY_URL', 'EMBY_USER_ID', 'EMBY_TOKEN'], 'plex': ['PLEX_URL', 'PLEX_TOKEN'], + 'ampache': ['AMPACHE_URL', 'AMPACHE_USER', 'AMPACHE_PASSWORD'], } MEDIASERVER_OBSOLETE_FIELDS_BY_TYPE = { @@ -128,6 +136,7 @@ def _compute_headers(): 'NAVIDROME_URL': 'url', 'NAVIDROME_USER': 'user', 'NAVIDROME_PASSWORD': 'password', 'LYRION_URL': 'url', 'PLEX_URL': 'url', 'PLEX_TOKEN': 'token', + 'AMPACHE_URL': 'url', 'AMPACHE_USER': 'user', 'AMPACHE_PASSWORD': 'password', } # The ONLY persistent home of these settings is the music_servers registry diff --git a/tasks/mediaserver/__init__.py b/tasks/mediaserver/__init__.py index 5e4bf2597..354bea530 100644 --- a/tasks/mediaserver/__init__.py +++ b/tasks/mediaserver/__init__.py @@ -30,7 +30,7 @@ logger = logging.getLogger(__name__) -_PROVIDER_NAMES = ('jellyfin', 'navidrome', 'lyrion', 'emby', 'plex') +_PROVIDER_NAMES = ('jellyfin', 'navidrome', 'lyrion', 'emby', 'plex', 'ampache') _warned_unsupported = set() _PLAYLIST_NAME_REQUIRED = "Playlist name is required." diff --git a/tasks/mediaserver/ampache.py b/tasks/mediaserver/ampache.py new file mode 100644 index 000000000..a6fae76f2 --- /dev/null +++ b/tasks/mediaserver/ampache.py @@ -0,0 +1,422 @@ +# Copyright (C) 2025 NeptuneHub +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Ampache media-server backend, speaking Ampache's own JSON API. + +Ampache also serves a Subsonic API, so it can be driven through the ``navidrome`` +backend instead. This backend exists because the native API answers in one call +what Subsonic needs several for, and it returns fields Subsonic has no room for +(replay gain, r128, multiple artists, stream format). + +Two things differ from the Subsonic path and both matter to callers: + +* Track ids here are Ampache's own row ids (``1``), not the prefixed Subsonic + form (``so-1``). A library analysed through one backend is therefore keyed + differently from the same library analysed through the other. +* Auth is a handshake that returns a session token, rather than credentials sent + on every request. The token is cached per server and re-issued on expiry. +""" + +from . import http as requests +import hashlib +import logging +import os +import re +import threading +import time + +import config +from . import context +from .helper import detect_path_format + +logger = logging.getLogger(__name__) + +# Ampache expires idle sessions server-side; re-handshake a little before that so +# a long analysis run never fails midway on a token that lapsed between calls. +_TOKEN_TTL_SECONDS = 3000 +_token_cache = {} +_token_lock = threading.Lock() + +_SECRET_QUERY_PARAM = re.compile(r'(?i)([?&](?:auth|passphrase|password)=)[^&\s]*') + + +def _redact_ampache_secrets(text): + return _SECRET_QUERY_PARAM.sub(r'\1[REDACTED]', str(text)) + + +def _creds(user_creds=None): + user_creds = context.active_creds(user_creds) or {} + url = (user_creds.get('url') or config.AMPACHE_URL or '').rstrip('/') + user = user_creds.get('user') or config.AMPACHE_USER + password = user_creds.get('password') or config.AMPACHE_PASSWORD + return url, user, password + + +def _cache_key(url, user): + return f"{url}|{user}" + + +def _handshake(url, user, password): + """Trade credentials for a session token. + + Ampache accepts either an API key or a time-salted password hash as ``auth``. + An API key is passed through untouched; anything else is treated as a + password and hashed as ``sha256(timestamp + sha256(password))``, which is + what lets the same field hold either. + """ + timestamp = int(time.time()) + pass_hash = hashlib.sha256(password.encode('utf-8')).hexdigest() + passphrase = hashlib.sha256(f"{timestamp}{pass_hash}".encode('utf-8')).hexdigest() + + for params in ( + {'action': 'handshake', 'user': user, 'timestamp': timestamp, 'auth': passphrase}, + # An API key needs neither user nor timestamp; try it second so a real + # password is never sent as a key. + {'action': 'handshake', 'auth': password}, + ): + params['version'] = '8.0.0' + try: + response = requests.get(f"{url}/server/json.server.php", params=params, timeout=30) + body = response.json() + except Exception as e: + logger.error(f"Ampache handshake failed: {_redact_ampache_secrets(e)}") + return None, {'kind': 'network', 'message': str(_redact_ampache_secrets(e))} + + if isinstance(body, dict) and body.get('auth'): + return body, None + + error = (body or {}).get('error') if isinstance(body, dict) else None + message = (error or {}).get('message') or 'Ampache handshake was rejected' + return None, {'kind': 'auth', 'message': message} + + +def _token(user_creds=None, force=False): + url, user, password = _creds(user_creds) + if not url or not password: + logger.warning("Ampache URL or password is not configured.") + return None, None, {'kind': 'config', 'message': 'Ampache URL or password is not configured.'} + + key = _cache_key(url, user) + with _token_lock: + cached = _token_cache.get(key) + if cached and not force and cached['expires'] > time.time(): + return url, cached['token'], None + + body, err = _handshake(url, user, password) + if not body: + return url, None, err + + with _token_lock: + _token_cache[key] = {'token': body['auth'], 'expires': time.time() + _TOKEN_TTL_SECONDS} + return url, body['auth'], None + + +def _request_ex(action, params=None, stream=False, user_creds=None, timeout=None): + """Call one Ampache action, re-handshaking once if the session has lapsed.""" + for attempt in (0, 1): + url, token, err = _token(user_creds, force=bool(attempt)) + if not token: + return None, err + + all_params = {'action': action, 'auth': token, 'version': '8.0.0', **(params or {})} + try: + response = requests.get( + f"{url}/server/json.server.php", + params=all_params, + stream=stream, + timeout=timeout or 60, + ) + except Exception as e: + logger.error(f"Ampache request '{action}' failed: {_redact_ampache_secrets(e)}") + return None, {'kind': 'network', 'message': str(_redact_ampache_secrets(e))} + + if stream: + return response, None + + try: + body = response.json() + except Exception as e: + return None, {'kind': 'parse', 'message': str(_redact_ampache_secrets(e))} + + error = body.get('error') if isinstance(body, dict) else None + if error: + code = str(error.get('errorCode') or error.get('code') or '') + # 4701 is Ampache's "session expired"; anything else is not worth a retry. + if code == '4701' and attempt == 0: + continue + kind = 'auth' if code in ('4701', '4742', '4704') else 'api' + return None, {'kind': kind, 'message': error.get('errorMessage') or error.get('message') or 'Ampache error'} + + return body, None + + return None, {'kind': 'auth', 'message': 'Ampache session could not be renewed'} + + +def _request(action, params=None, stream=False, user_creds=None, timeout=None): + body, _err = _request_ex(action, params, stream=stream, user_creds=user_creds, timeout=timeout) + return body + + +def _target_catalog_ids(user_creds=None): + """Catalog ids the configured library filter selects, or None for everything.""" + libraries = (context.active_libraries(config.MUSIC_LIBRARIES) or '').strip() + if not libraries: + return None + + wanted = {name.strip().lower() for name in libraries.split(',') if name.strip()} + if not wanted: + return None + + body = _request('catalogs', {'filter': 'music'}, user_creds=user_creds) + catalogs = (body or {}).get('catalog') or [] + ids = { + str(c.get('id')) + for c in catalogs + if str(c.get('name', '')).lower() in wanted or str(c.get('id')) in wanted + } + if not ids: + logger.warning("Ampache library filter matched no catalogs; returning no songs.") + return ids + + +def list_libraries(user_creds=None): + body = _request('catalogs', {'filter': 'music'}, user_creds=user_creds) + catalogs = (body or {}).get('catalog') or [] + return [{'Id': str(c.get('id')), 'Name': c.get('name') or f"Catalog {c.get('id')}"} for c in catalogs] + + +def _map_song(song): + """Normalise one Ampache song row into the shape every backend returns.""" + artist = (song.get('artist') or {}) if isinstance(song.get('artist'), dict) else {} + albumartist = (song.get('albumartist') or {}) if isinstance(song.get('albumartist'), dict) else {} + album = (song.get('album') or {}) if isinstance(song.get('album'), dict) else {} + path = song.get('filename') or '' + + return { + **song, + 'Id': str(song.get('id')), + 'Name': song.get('title') or song.get('name') or 'Unknown', + 'AlbumArtist': albumartist.get('name') or artist.get('name') or 'Unknown', + 'ArtistId': str(artist.get('id')) if artist.get('id') is not None else None, + 'OriginalAlbumArtist': albumartist.get('name'), + 'Album': album.get('name'), + 'Path': path, + 'FilePath': path, + 'Year': song.get('year'), + 'Rating': song.get('rating') or None, + 'DurationSeconds': song.get('time'), + # `suffix` is what download_track uses to name the temp file; Ampache + # calls the same thing `format`. + 'suffix': song.get('format') or song.get('stream_format'), + 'title': song.get('title'), + } + + +def get_all_songs(user_creds=None, apply_filter=True): + catalog_ids = _target_catalog_ids(user_creds=user_creds) if apply_filter else None + if isinstance(catalog_ids, set) and not catalog_ids: + return [] + + songs = [] + offset = 0 + page = 500 + while True: + params = {'offset': offset, 'limit': page} + body = _request('songs', params, user_creds=user_creds) + rows = (body or {}).get('song') or [] + if not rows: + break + + for row in rows: + if catalog_ids is not None and str(row.get('catalog')) not in catalog_ids: + continue + songs.append(_map_song(row)) + + offset += len(rows) + if len(rows) < page: + break + + logger.info(f"Fetched {len(songs)} songs from Ampache.") + return songs + + +def get_recent_albums(limit): + fetch_all = not limit or int(limit) <= 0 + params = {'limit': 0 if fetch_all else int(limit), 'sort': 'addition_time,DESC'} + body = _request('albums', params) + albums = (body or {}).get('album') or [] + + catalog_ids = _target_catalog_ids() + if isinstance(catalog_ids, set): + if not catalog_ids: + return [] + albums = [a for a in albums if str(a.get('catalog')) in catalog_ids] + + mapped = [ + { + **a, + 'Id': str(a.get('id')), + 'Name': a.get('name'), + 'AlbumArtist': ((a.get('artist') or {}) if isinstance(a.get('artist'), dict) else {}).get('name'), + } + for a in albums + ] + return mapped if fetch_all else mapped[: int(limit)] + + +def get_tracks_from_album(album_id, user_creds=None): + body = _request('album_songs', {'filter': album_id}, user_creds=user_creds) + return [_map_song(s) for s in ((body or {}).get('song') or [])] + + +def search_albums(query, user_creds=None): + body = _request( + 'advanced_search', + { + 'type': 'album', + 'operator': 'and', + 'rule_1': 'title', + 'rule_1_operator': 0, + 'rule_1_input': query, + 'limit': 100, + }, + user_creds=user_creds, + ) + albums = (body or {}).get('album') or [] + return [{**a, 'Id': str(a.get('id')), 'Name': a.get('name')} for a in albums] + + +def download_track(temp_dir, item): + """Stream one track to disk, returning the local path.""" + try: + track_id = item.get('id') or item.get('Id') + + suffix = item.get('suffix') or item.get('format') + if suffix and isinstance(suffix, str) and suffix.strip(): + file_extension = '.' + suffix.strip().replace('/', '').replace('\\', '') + elif item.get('Path'): + file_extension = os.path.splitext(item['Path'])[1] or '.tmp' + else: + file_extension = '.tmp' + + local_filename = os.path.join(temp_dir, f"{track_id}{file_extension}") + + # `download` hands back the original file; `stream` would transcode it and + # analysis must see the real audio. + response = _request('download', {'id': track_id, 'type': 'song'}, stream=True) + if response is None: + return None + + with response: + with open(local_filename, 'wb') as handle: + for chunk in response.iter_content(chunk_size=8192): + handle.write(chunk) + + logger.info(f"Downloaded '{item.get('Name') or item.get('title') or 'Unknown'}' to '{local_filename}'") + return local_filename + except Exception as e: + logger.error( # noqa: TRY400 - .exception would leak the unredacted URL creds via the traceback + f"Failed to download Ampache track {item.get('Name', 'Unknown')}: {_redact_ampache_secrets(e)}" + ) + return None + + +def test_connection(user_creds=None): + warnings = [] + body, err = _request_ex('songs', {'limit': 100}, user_creds=user_creds) + if body is None: + return { + 'ok': False, + 'error': (err or {}).get('message') or 'Ampache test_connection failed', + 'auth_failed': bool(err and err.get('kind') == 'auth'), + 'sample_count': 0, + 'path_format': 'none', + 'warnings': warnings, + } + + songs = [_map_song(s) for s in (body.get('song') or [])] + return { + 'ok': True, + 'error': None, + 'auth_failed': False, + 'sample_count': len(songs), + 'path_format': detect_path_format(songs), + 'warnings': warnings, + } + + +def get_all_playlists(): + body = _request('playlists', {'limit': 0}) + playlists = (body or {}).get('playlist') or [] + return [{**p, 'Id': str(p.get('id')), 'Name': p.get('name')} for p in playlists] + + +def get_playlist_by_name(playlist_name, user_creds=None): + for playlist in get_all_playlists(): + if playlist.get('Name') == playlist_name: + return playlist + return None + + +def get_playlist_track_ids(playlist_id, user_creds=None): + body = _request('playlist_songs', {'filter': playlist_id, 'limit': 0}, user_creds=user_creds) + return [str(s.get('id')) for s in ((body or {}).get('song') or [])] + + +def delete_playlist(playlist_id): + return _request('playlist_delete', {'filter': playlist_id}) is not None + + +def create_playlist(base_name, item_ids): + body = _request('playlist_create', {'name': base_name, 'type': 'private'}) + playlist = (body or {}).get('playlist') or {} + playlist_id = playlist.get('id') or (body or {}).get('id') + if not playlist_id: + logger.error(f"Ampache refused to create playlist '{base_name}'.") + return None + + for item_id in item_ids: + _request('playlist_add_song', {'filter': playlist_id, 'song': item_id, 'check': 1}) + + return str(playlist_id) + + +def create_instant_playlist(playlist_name, item_ids, user_creds=None): + return create_playlist(playlist_name, item_ids) + + +def create_or_replace_playlist(playlist_name, item_ids): + existing = get_playlist_by_name(playlist_name) + if existing: + delete_playlist(existing['Id']) + return create_playlist(playlist_name, item_ids) + + +def get_top_played_songs(limit, user_creds): + body = _request('stats', {'type': 'song', 'filter': 'highest', 'limit': limit}, user_creds=user_creds) + return [_map_song(s) for s in ((body or {}).get('song') or [])] + + +def get_last_played_time(item_id, user_creds): + """Ampache exposes no per-track last-played timestamp, so callers get None.""" + return None + + +def get_lyrics(track_id: str, timeout: float = 2.5): + body = _request('song', {'filter': track_id}, timeout=timeout) + songs = (body or {}).get('song') or [] + if isinstance(songs, list) and songs: + return songs[0].get('lyrics') or None + return None diff --git a/tasks/provider_probe.py b/tasks/provider_probe.py index 43553d562..611378a63 100644 --- a/tasks/provider_probe.py +++ b/tasks/provider_probe.py @@ -13,7 +13,7 @@ enumerate libraries, and pull whole catalogues. Main Features: -* Supports jellyfin, emby, navidrome, lyrion, and plex, rejecting any other +* Supports jellyfin, emby, navidrome, lyrion, plex and ampache, rejecting any other provider type early. * Normalises heterogeneous provider fields (Jellyfin/Emby PascalCase, Subsonic camelCase, and lower-case variants) into one flat track dict, coercing the @@ -87,7 +87,7 @@ def _try(*keys): } -_SUPPORTED_PROVIDERS = {'jellyfin', 'emby', 'navidrome', 'lyrion', 'plex'} +_SUPPORTED_PROVIDERS = {'jellyfin', 'emby', 'navidrome', 'lyrion', 'plex', 'ampache'} def _normalize_provider_type(provider_type): From 87cd6397b7cd4d3cf57168f899bf352e734a365c Mon Sep 17 00:00:00 2001 From: lachlan Date: Mon, 27 Jul 2026 16:12:08 +1000 Subject: [PATCH 02/19] Update test_provider_migration_integration.py --- test/integration/test_provider_migration_integration.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/integration/test_provider_migration_integration.py b/test/integration/test_provider_migration_integration.py index 5bce3e8c7..b310216d5 100644 --- a/test/integration/test_provider_migration_integration.py +++ b/test/integration/test_provider_migration_integration.py @@ -54,7 +54,7 @@ def _load_module(mod_name, *rel_parts): mig = _load_module('tasks.provider_migration_tasks', 'tasks', 'provider_migration_tasks.py') -PROVIDERS = ('jellyfin', 'emby', 'navidrome', 'lyrion', 'plex') +PROVIDERS = ('jellyfin', 'emby', 'navidrome', 'lyrion', 'plex', 'ampache') _ID_BASE = { 'jellyfin': 0x10000, @@ -62,6 +62,7 @@ def _load_module(mod_name, *rel_parts): 'navidrome': 0xABCD00, 'lyrion': 90000, 'plex': 70000, + 'ampache': 120000, } _CROSS_TARGET_SHIFT = 1_000_000 @@ -74,6 +75,7 @@ def _load_module(mod_name, *rel_parts): 'navidrome': ['NAVIDROME_URL', 'NAVIDROME_USER', 'NAVIDROME_PASSWORD'], 'lyrion': ['LYRION_URL'], 'plex': ['PLEX_URL', 'PLEX_TOKEN'], + 'ampache': ['AMPACHE_URL', 'AMPACHE_USER', 'AMPACHE_PASSWORD'], } _TARGET_CREDS = { @@ -82,6 +84,7 @@ def _load_module(mod_name, *rel_parts): 'navidrome': {'url': 'http://nav.test:4533', 'user': 'navuser', 'password': 'navpass'}, 'lyrion': {'url': 'http://lms.test:9000'}, 'plex': {'url': 'http://plex.test:32400', 'token': 'plextoken'}, + 'ampache': {'url': 'http://ampache.test', 'user': 'ampuser', 'password': 'amppass'}, } @@ -150,6 +153,9 @@ def _provider_path(provider, rel): return 'file:///media/music/MyTunes/' + quote(rel) if provider == 'plex': return '/data/music/MyTunes/' + rel + if provider == 'ampache': + # Ampache reports the file's absolute path on disk, not a library-relative one. + return '/var/lib/ampache/music/MyTunes/' + rel return rel From 29bd88b6672124b9bfcb77011cfc1e52d0ff406e Mon Sep 17 00:00:00 2001 From: neptunehub Date: Wed, 29 Jul 2026 19:05:33 +0200 Subject: [PATCH 03/19] Complete Ampache support: fix dispatcher contract bugs, register it in setup/migration UI, add provider-contract tests --- README.md | 4 +- app_provider_migration.py | 39 +-- docs/MULTI_SERVER.md | 7 +- docs/PARAMETERS.md | 5 +- static/music_servers_admin.js | 5 + static/setup.js | 9 +- tasks/mediaserver/ampache.py | 51 ++-- tasks/setup_manager.py | 3 + templates/provider_migration.html | 19 +- templates/setup.html | 4 +- test/unit/test_mediaserver_ampache.py | 266 ++++++++++++++++++ .../test_provider_registration_contract.py | 223 +++++++++++++++ 12 files changed, 579 insertions(+), 56 deletions(-) create mode 100644 test/unit/test_mediaserver_ampache.py create mode 100644 test/unit/test_provider_registration_contract.py diff --git a/README.md b/README.md index 813befd08..616e7a94e 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ You can run it locally with Docker Compose or Podman, deploy it at scale in a Ku > **Prefer not to self-host?** [Elestio](https://elest.io/open-source/audiomuse-ai) offers AudioMuse-AI as a managed cloud service, and their [YouTube video](https://www.youtube.com/watch?v=Ow89q6gQ1mM) is a good introduction to the project and its features. AudioMuse-AI lets you explore your music library in innovative ways, just **start with an initial analysis**, and you’ll unlock features like: -* **Multiple Music Servers** (from `v3.0.0`): connect several media servers - any mix of Navidrome, Jellyfin, LMS, Lyrion, Emby and Plex - to a **single AudioMuse-AI deployment**. Built-in duplicate detection recognizes the same song across servers, so each track is **analyzed only once** and every server shares the result. +* **Multiple Music Servers** (from `v3.0.0`): connect several media servers - any mix of Navidrome, Jellyfin, LMS, Lyrion, Emby, Plex and Ampache - to a **single AudioMuse-AI deployment**. Built-in duplicate detection recognizes the same song across servers, so each track is **analyzed only once** and every server shares the result. * **Clustering**: Automatically groups sonically similar songs, creating genre-defying playlists based on the music's actual sound. * **Instant Playlists**: Simply tell the AI what you want to hear-like "high-tempo, low-energy music" and it will instantly generate a playlist for you. * **Music Map**: Discover your music collection visually with a vibrant, genre-based 2D map. @@ -84,7 +84,7 @@ From `v1.0.0`, only PostgreSQL, Redis and `TZ` are configured via environment va **Prerequisites:** * Docker and Docker Compose installed -* A running media server (Navidrome, Jellyfin, Lyrion, Emby, or Plex) +* A running media server (Navidrome, Jellyfin, Lyrion, Emby, Plex, or Ampache) * See [Hardware Requirements](#hardware-requirements) **Steps:** diff --git a/app_provider_migration.py b/app_provider_migration.py index 3b59c1a80..98a2ab44d 100644 --- a/app_provider_migration.py +++ b/app_provider_migration.py @@ -156,32 +156,15 @@ def _current_provider_creds(): import config as cfg t = (getattr(cfg, 'MEDIASERVER_TYPE', '') or '').lower() - if t == 'jellyfin': - return t, { - 'url': getattr(cfg, 'JELLYFIN_URL', ''), - 'user_id': getattr(cfg, 'JELLYFIN_USER_ID', ''), - 'token': getattr(cfg, 'JELLYFIN_TOKEN', ''), - } - if t == 'emby': - return t, { - 'url': getattr(cfg, 'EMBY_URL', ''), - 'user_id': getattr(cfg, 'EMBY_USER_ID', ''), - 'token': getattr(cfg, 'EMBY_TOKEN', ''), - } - if t == 'navidrome': - return t, { - 'url': getattr(cfg, 'NAVIDROME_URL', ''), - 'user': getattr(cfg, 'NAVIDROME_USER', ''), - 'password': getattr(cfg, 'NAVIDROME_PASSWORD', ''), - } - if t == 'lyrion': - return t, {'url': getattr(cfg, 'LYRION_URL', '')} - if t == 'plex': - return t, { - 'url': getattr(cfg, 'PLEX_URL', ''), - 'token': getattr(cfg, 'PLEX_TOKEN', ''), - } - return None, {} + fields = cfg.MEDIASERVER_FIELDS_BY_TYPE.get(t) + if not fields: + return None, {} + creds = {} + for field in fields: + key = cfg.MEDIASERVER_CRED_KEY_BY_FIELD.get(field) + if key: + creds[key] = getattr(cfg, field, '') + return t, creds def _apply_source_path_overrides(old_rows, overrides): @@ -270,7 +253,7 @@ def session_start(): properties: target_type: type: string - enum: [jellyfin, emby, navidrome, lyrion, plex] + enum: [jellyfin, emby, navidrome, lyrion, plex, ampache] target_creds: type: object additionalProperties: true @@ -484,7 +467,7 @@ def probe_test(): properties: type: type: string - enum: [jellyfin, emby, navidrome, lyrion, plex] + enum: [jellyfin, emby, navidrome, lyrion, plex, ampache] creds: type: object additionalProperties: true diff --git a/docs/MULTI_SERVER.md b/docs/MULTI_SERVER.md index d26e5d00e..f52e1bb70 100644 --- a/docs/MULTI_SERVER.md +++ b/docs/MULTI_SERVER.md @@ -1,8 +1,8 @@ # Multiple Music Servers AudioMuse-AI can talk to several media servers at once - for example a Navidrome -plus two Jellyfins plus a Plex, in any combination, including several instances -of the same type. This is fully backward compatible: an install that only ever +plus two Jellyfins plus a Plex plus an Ampache, in any combination, including +several instances of the same type. This is fully backward compatible: an install that only ever configures one server behaves exactly as it always has. ## The model in one picture @@ -92,7 +92,8 @@ partial and pruning is skipped, so a transient provider error never mass-deletes valid mappings. Only map rows are ever removed, never analyzed tracks. A server's library filter is honoured by every provider: Jellyfin and Emby fetch only the selected libraries, Plex only the selected sections, -Navidrome only the selected music folders, and Lyrion only the selected paths - +Navidrome only the selected music folders, Ampache only the selected catalogs, +and Lyrion only the selected paths - so nothing outside the libraries you picked is ever mapped, counted or pruned. Matching runs in bounded memory even on very large libraries: the fetched diff --git a/docs/PARAMETERS.md b/docs/PARAMETERS.md index 8ac6b1135..411c8a08d 100644 --- a/docs/PARAMETERS.md +++ b/docs/PARAMETERS.md @@ -26,7 +26,7 @@ The **mandatory** parameter that you need to change from the example are this: | Parameter | Description | Default Value | |----------------------|-------------------------------------------------------------------------|-----------------------------------| | **Mediaserver General** | | | -| `MEDIASERVER_TYPE` | (Required) Which media server to use: `jellyfin`, `navidrome`, `emby`, `lyrion` or `plex`. | `jellyfin` | +| `MEDIASERVER_TYPE` | (Required) Which media server to use: `jellyfin`, `navidrome`, `emby`, `lyrion`, `plex` or `ampache`. | `jellyfin` | | `NAVIDROME_URL` | (Required) Your Navidrome server's full URL | `http://YOUR_NAVIDROME_IP:4533` | | `NAVIDROME_USER` | (Required) Navidrome User ID. | *(N/A - from Secret)* | | `NAVIDROME_PASSWORD` | (Required) Navidrome user Password. | *(N/A - from Secret)* | @@ -39,6 +39,9 @@ The **mandatory** parameter that you need to change from the example are this: | `LYRION_URL` | (Required) Your Lyrion server's full URL | `http://YOUR_LYRION_IP:9000` | | `PLEX_URL` | (Required) Your Plex Media Server's full URL | `http://YOUR_PLEX_IP:32400` | | `PLEX_TOKEN` | (Required) Plex API token (X-Plex-Token). | *(N/A - from Secret)* | +| `AMPACHE_URL` | (Required) Your Ampache server's full URL | `http://YOUR_AMPACHE_IP` | +| `AMPACHE_USER` | (Required) Ampache username. Leave empty when using an API key. | *(N/A - from Secret)* | +| `AMPACHE_PASSWORD` | (Required) Ampache user password or API key. | *(N/A - from Secret)* | | `POSTGRES_USER` | (Required) PostgreSQL username. | *(N/A - from Secret)* | | `POSTGRES_PASSWORD` | (Required) PostgreSQL password. | *(N/A - from Secret)* | | `POSTGRES_DB` | (Required) PostgreSQL database name. | *(N/A - from Secret)* | diff --git a/static/music_servers_admin.js b/static/music_servers_admin.js index f0a8c16a9..44bce7d5f 100644 --- a/static/music_servers_admin.js +++ b/static/music_servers_admin.js @@ -32,6 +32,11 @@ plex: [ { key: 'url', label: 'Server URL', placeholder: 'http://plex:32400' }, { key: 'token', label: 'Plex Token', secret: true } + ], + ampache: [ + { key: 'url', label: 'Server URL', placeholder: 'http://ampache' }, + { key: 'user', label: 'Username' }, + { key: 'password', label: 'Password or API key', secret: true } ] }; diff --git a/static/setup.js b/static/setup.js index 6f5499e4c..d16c61eaf 100644 --- a/static/setup.js +++ b/static/setup.js @@ -20,6 +20,11 @@ var serverFields = { plex: [ {name: 'PLEX_URL', label: 'Plex URL', placeholder: 'http://your-plex-server:32400', tooltip: 'Base URL of your Plex Media Server, including http:// or https:// and the port (default 32400). Must be reachable from the AudioMuse-AI container.'}, {name: 'PLEX_TOKEN', label: 'Plex API token', placeholder: 'your-plex-token', tooltip: 'Your X-Plex-Token for the server. See https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/ to find it.'} + ], + ampache: [ + {name: 'AMPACHE_URL', label: 'Ampache URL', placeholder: 'http://your-ampache-server', tooltip: 'Base URL of your Ampache server, including http:// or https:// and the port if it is not the default.'}, + {name: 'AMPACHE_USER', label: 'Ampache username', placeholder: 'your-username', tooltip: 'Username of an Ampache account that can read the music library. Leave empty if you authenticate with an API key instead of a password.'}, + {name: 'AMPACHE_PASSWORD', label: 'Ampache password or API key', placeholder: 'your-password-or-api-key', tooltip: 'Password for the Ampache user above, or an Ampache API key. The handshake accepts either, so there is no separate API key field.'} ] }; @@ -276,7 +281,7 @@ function renderServerFields(serverType, values, hasValueMap) { value = values[field.name]; } var secret = false; - var secretKeys = ['NAVIDROME_PASSWORD', 'AUDIOMUSE_PASSWORD', 'API_TOKEN', 'JELLYFIN_TOKEN', 'EMBY_TOKEN', 'PLEX_TOKEN']; + var secretKeys = ['NAVIDROME_PASSWORD', 'AUDIOMUSE_PASSWORD', 'API_TOKEN', 'JELLYFIN_TOKEN', 'EMBY_TOKEN', 'PLEX_TOKEN', 'AMPACHE_PASSWORD']; for (var i = 0; i < secretKeys.length; i++) { if (secretKeys[i] === field.name) { secret = true; @@ -625,7 +630,7 @@ function loadSetupData() { function saveCurrentServerValues() { var currentServerType = document.getElementById('MEDIASERVER_TYPE').value; - var keys = ['JELLYFIN_URL', 'JELLYFIN_USER_ID', 'JELLYFIN_TOKEN', 'NAVIDROME_URL', 'NAVIDROME_USER', 'NAVIDROME_PASSWORD', 'LYRION_URL', 'EMBY_URL', 'EMBY_USER_ID', 'EMBY_TOKEN', 'PLEX_URL', 'PLEX_TOKEN']; + var keys = ['JELLYFIN_URL', 'JELLYFIN_USER_ID', 'JELLYFIN_TOKEN', 'NAVIDROME_URL', 'NAVIDROME_USER', 'NAVIDROME_PASSWORD', 'LYRION_URL', 'EMBY_URL', 'EMBY_USER_ID', 'EMBY_TOKEN', 'PLEX_URL', 'PLEX_TOKEN', 'AMPACHE_URL', 'AMPACHE_USER', 'AMPACHE_PASSWORD']; keys.forEach(function(key) { var input = document.getElementById(key); if (input) { diff --git a/tasks/mediaserver/ampache.py b/tasks/mediaserver/ampache.py index a6fae76f2..488f3a4c8 100644 --- a/tasks/mediaserver/ampache.py +++ b/tasks/mediaserver/ampache.py @@ -1,17 +1,10 @@ +# AudioMuse-AI - https://github.com/NeptuneHub/AudioMuse-AI # Copyright (C) 2025 NeptuneHub +# SPDX-License-Identifier: AGPL-3.0-only # -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License v3.0. See the LICENSE file +# in the project root or """Ampache media-server backend, speaking Ampache's own JSON API. @@ -27,6 +20,16 @@ differently from the same library analysed through the other. * Auth is a handshake that returns a session token, rather than credentials sent on every request. The token is cached per server and re-issued on expiry. + +Main Features: +* Trades credentials for a session token that accepts either an API key or a + time-salted password hash in the same field, caching it per server and + re-handshaking once when a session lapses mid-run. +* Fetches catalogues, recent albums, album tracks, search results and the whole + song list with pagination, honouring MUSIC_LIBRARIES by resolving it to + Ampache catalog ids. +* Downloads the original file rather than a transcoded stream, reads play stats + and lyrics, and manages playlists through the shared dispatcher contract. """ from . import http as requests @@ -91,7 +94,9 @@ def _handshake(url, user, password): response = requests.get(f"{url}/server/json.server.php", params=params, timeout=30) body = response.json() except Exception as e: - logger.error(f"Ampache handshake failed: {_redact_ampache_secrets(e)}") + logger.error( # noqa: TRY400 - .exception would leak the unredacted URL creds via the traceback + f"Ampache handshake failed: {_redact_ampache_secrets(e)}" + ) return None, {'kind': 'network', 'message': str(_redact_ampache_secrets(e))} if isinstance(body, dict) and body.get('auth'): @@ -139,10 +144,19 @@ def _request_ex(action, params=None, stream=False, user_creds=None, timeout=None timeout=timeout or 60, ) except Exception as e: - logger.error(f"Ampache request '{action}' failed: {_redact_ampache_secrets(e)}") + logger.error( # noqa: TRY400 - .exception would leak the unredacted URL creds via the traceback + f"Ampache request '{action}' failed: {_redact_ampache_secrets(e)}" + ) return None, {'kind': 'network', 'message': str(_redact_ampache_secrets(e))} if stream: + try: + response.raise_for_status() + except Exception as e: + logger.error( # noqa: TRY400 - .exception would leak the unredacted URL creds via the traceback + f"Ampache stream '{action}' failed: {_redact_ampache_secrets(e)}" + ) + return None, {'kind': 'network', 'message': str(_redact_ampache_secrets(e))} return response, None try: @@ -394,18 +408,19 @@ def create_playlist(base_name, item_ids): def create_instant_playlist(playlist_name, item_ids, user_creds=None): - return create_playlist(playlist_name, item_ids) + return create_playlist(f"{playlist_name.strip()}_instant", item_ids) -def create_or_replace_playlist(playlist_name, item_ids): - existing = get_playlist_by_name(playlist_name) +def create_or_replace_playlist(playlist_name, item_ids, user_creds=None): + user_creds = context.active_creds(user_creds) + existing = get_playlist_by_name(playlist_name, user_creds=user_creds) if existing: delete_playlist(existing['Id']) return create_playlist(playlist_name, item_ids) def get_top_played_songs(limit, user_creds): - body = _request('stats', {'type': 'song', 'filter': 'highest', 'limit': limit}, user_creds=user_creds) + body = _request('stats', {'type': 'song', 'filter': 'frequent', 'limit': limit}, user_creds=user_creds) return [_map_song(s) for s in ((body or {}).get('song') or [])] diff --git a/tasks/setup_manager.py b/tasks/setup_manager.py index d028ba665..f1ce0d6bd 100644 --- a/tasks/setup_manager.py +++ b/tasks/setup_manager.py @@ -51,6 +51,9 @@ 'EMBY_TOKEN', 'PLEX_URL', 'PLEX_TOKEN', + 'AMPACHE_URL', + 'AMPACHE_USER', + 'AMPACHE_PASSWORD', } AUTH_FIELDS = {'AUTH_ENABLED', 'AUDIOMUSE_USER', 'AUDIOMUSE_PASSWORD', 'API_TOKEN'} diff --git a/templates/provider_migration.html b/templates/provider_migration.html index 46dae7891..7633d0102 100644 --- a/templates/provider_migration.html +++ b/templates/provider_migration.html @@ -343,7 +343,7 @@

AudioMuse-AI - Provider Migration

AudioMuse-AI rewrites every track’s internal ID from your old media provider to - the matching ID on a new one (Jellyfin, Navidrome, Emby, Lyrion, Plex…) + the matching ID on a new one (Jellyfin, Navidrome, Emby, Lyrion, Plex, Ampache…) so your analysis data, embeddings, and local playlists keep pointing at the right songs.

@@ -400,6 +400,7 @@

2 Choose the new provider

+
@@ -479,6 +480,22 @@

2 Choose the new provider

+ +
+
+ + +
+
+ + +
+
+ + +
+
+
diff --git a/templates/setup.html b/templates/setup.html index f3e58aaa9..69c81dffd 100644 --- a/templates/setup.html +++ b/templates/setup.html @@ -226,6 +226,7 @@

Add a server

+
@@ -246,7 +247,7 @@

Edit default server

Media server type - Pick the music server AudioMuse-AI should connect to. Each option asks for slightly different credentials below: Jellyfin/Emby use URL + user ID + API token, Navidrome uses URL + username + password, Lyrion uses just a URL, Plex uses URL + API token. + Pick the music server AudioMuse-AI should connect to. Each option asks for slightly different credentials below: Jellyfin/Emby use URL + user ID + API token, Navidrome uses URL + username + password, Lyrion uses just a URL, Plex uses URL + API token, Ampache uses URL + username + password or API key.
diff --git a/test/unit/test_mediaserver_ampache.py b/test/unit/test_mediaserver_ampache.py new file mode 100644 index 000000000..2a6484f15 --- /dev/null +++ b/test/unit/test_mediaserver_ampache.py @@ -0,0 +1,266 @@ +# AudioMuse-AI - https://github.com/NeptuneHub/AudioMuse-AI +# Copyright (C) 2025 NeptuneHub +# SPDX-License-Identifier: AGPL-3.0-only +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License v3.0. See the LICENSE file +# in the project root or + +"""Ampache backend behaviour, focused on the shared provider contract. + +The dispatcher and its callers assume every backend behaves the same way in +places the Ampache API itself has no opinion about: the instant-playlist naming +suffix, refusing to hand a failed HTTP response to the downloader, and the +track dict shape the analysis pipeline reads. + +Main Features: +* Instant playlists get the _instant suffix the other five backends append +* A streamed download that returns an HTTP error yields no file +* Handshake caches its token, re-handshakes once on a lapsed session, and + falls back from the password hash to an API key +* _map_song exposes the keys analysis and provider_probe read +""" + +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture(autouse=True) +def _clear_token_cache(): + from tasks.mediaserver import ampache + + ampache._token_cache.clear() + yield + ampache._token_cache.clear() + + +@pytest.fixture +def creds(): + return {'url': 'http://ampache.test', 'user': 'amp', 'password': 'secret'} + + +def _json_response(payload, status_ok=True): + response = MagicMock() + response.json.return_value = payload + if status_ok: + response.raise_for_status.return_value = None + else: + response.raise_for_status.side_effect = Exception('404 Not Found') + return response + + +class TestInstantPlaylistNaming: + def test_create_instant_playlist_appends_the_instant_suffix(self, creds): + from tasks.mediaserver import ampache + + with patch.object(ampache, 'create_playlist', return_value='7') as created: + result = ampache.create_instant_playlist('My Mix', ['1', '2'], user_creds=creds) + + assert created.call_args[0][0] == 'My Mix_instant' + assert result == '7' + + def test_create_instant_playlist_strips_before_appending_the_suffix(self, creds): + from tasks.mediaserver import ampache + + with patch.object(ampache, 'create_playlist', return_value='7') as created: + ampache.create_instant_playlist(' Spaced ', ['1'], user_creds=creds) + + assert created.call_args[0][0] == 'Spaced_instant' + + +class TestDispatcherArity: + def test_create_or_replace_playlist_accepts_the_user_creds_the_dispatcher_passes(self, creds): + from tasks.mediaserver import ampache + + with patch.object(ampache, 'get_playlist_by_name', return_value=None), \ + patch.object(ampache, 'create_playlist', return_value='9') as created: + result = ampache.create_or_replace_playlist('Nightly', ['1'], creds) + + assert created.call_args[0][0] == 'Nightly' + assert result == '9' + + def test_create_or_replace_playlist_deletes_the_existing_playlist_first(self, creds): + from tasks.mediaserver import ampache + + with patch.object(ampache, 'get_playlist_by_name', return_value={'Id': '3'}), \ + patch.object(ampache, 'delete_playlist') as deleted, \ + patch.object(ampache, 'create_playlist', return_value='9'): + ampache.create_or_replace_playlist('Nightly', ['1'], creds) + + deleted.assert_called_once_with('3') + + +class TestStreamedDownload: + def test_a_streamed_download_that_errors_writes_no_file(self, tmp_path): + from tasks.mediaserver import ampache + + handshake = _json_response({'auth': 'tok'}) + stream = _json_response({'error': {'errorCode': '4704'}}, status_ok=False) + stream.iter_content.return_value = [b'{"error":"Require: 100"}'] + stream.__enter__.return_value = stream + stream.__exit__.return_value = False + + with patch.object(ampache.config, 'AMPACHE_URL', 'http://ampache.test'), \ + patch.object(ampache.config, 'AMPACHE_USER', 'amp'), \ + patch.object(ampache.config, 'AMPACHE_PASSWORD', 'secret'), \ + patch.object(ampache, 'requests') as http: + http.get.side_effect = [handshake, stream] + path = ampache.download_track(str(tmp_path), {'Id': '1', 'suffix': 'mp3'}) + + assert path is None + assert list(tmp_path.iterdir()) == [] + + def test_a_successful_stream_is_written_with_the_format_extension(self, tmp_path): + from tasks.mediaserver import ampache + + handshake = _json_response({'auth': 'tok'}) + stream = _json_response({}, status_ok=True) + stream.iter_content.return_value = [b'audio-bytes'] + stream.__enter__.return_value = stream + stream.__exit__.return_value = False + + with patch.object(ampache.config, 'AMPACHE_URL', 'http://ampache.test'), \ + patch.object(ampache.config, 'AMPACHE_USER', 'amp'), \ + patch.object(ampache.config, 'AMPACHE_PASSWORD', 'secret'), \ + patch.object(ampache, 'requests') as http: + http.get.side_effect = [handshake, stream] + path = ampache.download_track(str(tmp_path), {'Id': '1', 'suffix': 'mp3'}) + + assert path is not None + assert path.endswith('1.mp3') + assert (tmp_path / '1.mp3').read_bytes() == b'audio-bytes' + + +class TestHandshake: + def test_a_successful_handshake_is_cached_and_not_repeated(self, creds): + from tasks.mediaserver import ampache + + with patch.object(ampache, 'requests') as http: + http.get.side_effect = [ + _json_response({'auth': 'tok'}), + _json_response({'song': []}), + _json_response({'song': []}), + ] + ampache._request('songs', user_creds=creds) + ampache._request('songs', user_creds=creds) + + assert http.get.call_count == 3 + + def test_an_expired_session_triggers_exactly_one_rehandshake(self, creds): + from tasks.mediaserver import ampache + + with patch.object(ampache, 'requests') as http: + http.get.side_effect = [ + _json_response({'auth': 'tok'}), + _json_response({'error': {'errorCode': '4701'}}), + _json_response({'auth': 'tok2'}), + _json_response({'song': [{'id': 1}]}), + ] + body, err = ampache._request_ex('songs', user_creds=creds) + + assert err is None + assert body == {'song': [{'id': 1}]} + + def test_the_password_hash_falls_back_to_an_api_key(self, creds): + from tasks.mediaserver import ampache + + with patch.object(ampache, 'requests') as http: + http.get.side_effect = [ + _json_response({'error': {'message': 'bad password'}}), + _json_response({'auth': 'key-session'}), + ] + url, token, err = ampache._token(user_creds=creds) + + assert err is None + assert token == 'key-session' + assert http.get.call_args_list[1].kwargs['params']['auth'] == 'secret' + + def test_a_missing_url_or_password_is_reported_as_a_config_error(self): + from tasks.mediaserver import ampache + + with patch.object(ampache.config, 'AMPACHE_URL', ''), \ + patch.object(ampache.config, 'AMPACHE_PASSWORD', ''): + url, token, err = ampache._token(user_creds={'url': '', 'user': '', 'password': ''}) + + assert token is None + assert err['kind'] == 'config' + + +class TestPlayStats: + def test_top_played_asks_for_most_played_not_highest_rated(self, creds): + from tasks.mediaserver import ampache + + with patch.object(ampache, 'requests') as http: + http.get.side_effect = [ + _json_response({'auth': 'tok'}), + _json_response({'song': [{'id': 1, 'title': 'S'}]}), + ] + songs = ampache.get_top_played_songs(10, creds) + + params = http.get.call_args_list[1].kwargs['params'] + assert params['filter'] == 'frequent' + assert params['type'] == 'song' + assert [s['Id'] for s in songs] == ['1'] + + def test_last_played_time_is_none_because_ampache_exposes_no_such_field(self, creds): + from tasks.mediaserver import ampache + + assert ampache.get_last_played_time('1', creds) is None + + +class TestTrackMapping: + def test_map_song_exposes_the_keys_analysis_and_the_probe_read(self): + from tasks.mediaserver import ampache + + mapped = ampache._map_song({ + 'id': 12, + 'title': 'Song', + 'artist': {'id': 3, 'name': 'Artist'}, + 'albumartist': {'id': 4, 'name': 'Album Artist'}, + 'album': {'id': 5, 'name': 'Album'}, + 'filename': '/music/song.flac', + 'time': 210, + 'year': 1999, + 'format': 'flac', + }) + + assert mapped['Id'] == '12' + assert mapped['Name'] == 'Song' + assert mapped['AlbumArtist'] == 'Album Artist' + assert mapped['ArtistId'] == '3' + assert mapped['Album'] == 'Album' + assert mapped['Path'] == '/music/song.flac' + assert mapped['FilePath'] == '/music/song.flac' + assert mapped['DurationSeconds'] == 210 + assert mapped['suffix'] == 'flac' + + def test_map_song_falls_back_to_the_track_artist_when_there_is_no_album_artist(self): + from tasks.mediaserver import ampache + + mapped = ampache._map_song({'id': 1, 'title': 'S', 'artist': {'id': 2, 'name': 'Only'}}) + + assert mapped['AlbumArtist'] == 'Only' + + def test_map_song_survives_a_row_with_no_artist_objects(self): + from tasks.mediaserver import ampache + + mapped = ampache._map_song({'id': 1}) + + assert mapped['Id'] == '1' + assert mapped['Name'] == 'Unknown' + assert mapped['AlbumArtist'] == 'Unknown' + assert mapped['ArtistId'] is None + + +class TestSecretRedaction: + @pytest.mark.parametrize('secret_param', ['auth', 'passphrase', 'password']) + def test_query_string_secrets_are_redacted_from_log_text(self, secret_param): + from tasks.mediaserver import ampache + + redacted = ampache._redact_ampache_secrets( + f"http://amp.test/server/json.server.php?action=songs&{secret_param}=supersecret&limit=1" + ) + + assert 'supersecret' not in redacted + assert '[REDACTED]' in redacted diff --git a/test/unit/test_provider_registration_contract.py b/test/unit/test_provider_registration_contract.py new file mode 100644 index 000000000..18b05fb87 --- /dev/null +++ b/test/unit/test_provider_registration_contract.py @@ -0,0 +1,223 @@ +# AudioMuse-AI - https://github.com/NeptuneHub/AudioMuse-AI +# Copyright (C) 2025 NeptuneHub +# SPDX-License-Identifier: AGPL-3.0-only +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License v3.0. See the LICENSE file +# in the project root or + +"""Every media server must be registered in EVERY layer, not just config.py. + +config.MEDIASERVER_FIELDS_BY_TYPE is the single source of truth for which +providers exist. A provider added there but missed in one of the hardcoded +lists elsewhere (the dispatcher, the supported-type gates, the setup wizard +JavaScript, the HTML dropdowns, the parameters doc) fails silently: the backend +accepts the type while the UI cannot offer it, or the dispatcher calls a +backend function with a signature it does not have. + +Main Features: +* Every backend module binds against every dispatcher call site, so an arity + mismatch is caught at test time instead of crashing a cron playlist run +* The four Python supported-type gates agree with config +* The setup wizard and multi-server admin JavaScript define credential fields + for every provider, and mark every secret field secret +* Both HTML dropdowns and the provider-migration credential blocks cover every + provider, and docs/PARAMETERS.md documents every media-server config field +""" + +import inspect +import re +from importlib import import_module +from pathlib import Path + +import pytest + +import config + +REPO_ROOT = Path(__file__).resolve().parents[2] + +PROVIDERS = sorted(config.MEDIASERVER_FIELDS_BY_TYPE) + +# How tasks/mediaserver/__init__.py calls into a backend: the attribute name, +# the number of POSITIONAL arguments it passes, and the keyword arguments. +DISPATCHER_CALLS = ( + ('get_recent_albums', 1, ()), + ('get_tracks_from_album', 1, ('user_creds',)), + ('download_track', 2, ()), + ('get_all_songs', 0, ('user_creds', 'apply_filter')), + ('list_libraries', 0, ('user_creds',)), + ('search_albums', 1, ('user_creds',)), + ('test_connection', 0, ('user_creds',)), + ('get_playlist_by_name', 1, ()), + ('get_all_playlists', 0, ()), + ('get_playlist_track_ids', 1, ('user_creds',)), + ('create_playlist', 2, ()), + ('delete_playlist', 1, ()), + ('create_instant_playlist', 3, ()), + ('create_or_replace_playlist', 3, ()), + ('get_top_played_songs', 2, ()), + ('get_last_played_time', 2, ()), + ('get_lyrics', 1, ('timeout',)), +) + +# Lyrion is special-cased by the dispatcher and receives no user_creds. +LYRION_NO_CREDS = { + 'get_playlist_track_ids', + 'create_instant_playlist', + 'get_top_played_songs', + 'get_last_played_time', +} + + +def _read(relative_path): + return (REPO_ROOT / relative_path).read_text(encoding='utf-8') + + +def _js_object_keys(source, object_name): + start = source.index(object_name) + depth = 0 + end = start + for index in range(source.index('{', start), len(source)): + if source[index] == '{': + depth += 1 + elif source[index] == '}': + depth -= 1 + if depth == 0: + end = index + break + block = source[start:end] + return set(re.findall(r'^\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*\[', block, re.MULTILINE)) + + +@pytest.mark.parametrize('provider', PROVIDERS) +def test_every_provider_has_a_backend_module(provider): + assert import_module('tasks.mediaserver.' + provider) is not None + + +@pytest.mark.parametrize('provider', PROVIDERS) +def test_every_backend_binds_to_every_dispatcher_call_site(provider): + backend = import_module('tasks.mediaserver.' + provider) + failures = [] + for attribute, positional, keywords in DISPATCHER_CALLS: + function = getattr(backend, attribute, None) + if function is None: + failures.append(f'{attribute} is missing') + continue + if provider == 'lyrion' and attribute in LYRION_NO_CREDS: + keywords = tuple(k for k in keywords if k != 'user_creds') + if attribute != 'get_playlist_track_ids': + positional -= 1 + try: + inspect.signature(function).bind(*range(positional), **{k: None for k in keywords}) + except TypeError as error: + failures.append( + f'{attribute}{inspect.signature(function)} does not accept ' + f'{positional} positional + {list(keywords)}: {error}' + ) + assert not failures, f'{provider} backend does not match the dispatcher: ' + '; '.join(failures) + + +def test_dispatcher_provider_names_match_config(): + from tasks.mediaserver import _PROVIDER_NAMES + + assert set(_PROVIDER_NAMES) == set(PROVIDERS) + + +def test_music_servers_supported_types_match_config(): + from app_music_servers import _SUPPORTED_TYPES + + assert set(_SUPPORTED_TYPES) == set(PROVIDERS) + + +def test_provider_migration_supported_targets_match_config(): + from app_provider_migration import _SUPPORTED_TARGETS + + assert set(_SUPPORTED_TARGETS) == set(PROVIDERS) + + +def test_provider_probe_supported_providers_match_config(): + from tasks.provider_probe import _SUPPORTED_PROVIDERS + + assert set(_SUPPORTED_PROVIDERS) == set(PROVIDERS) + + +@pytest.mark.parametrize('provider', PROVIDERS) +def test_every_provider_field_maps_to_a_cred_key(provider): + missing = [ + field + for field in config.MEDIASERVER_FIELDS_BY_TYPE[provider] + if field not in config.MEDIASERVER_CRED_KEY_BY_FIELD + ] + assert not missing, f'{provider} fields absent from MEDIASERVER_CRED_KEY_BY_FIELD: {missing}' + + +def test_setup_wizard_javascript_defines_fields_for_every_provider(): + keys = _js_object_keys(_read('static/setup.js'), 'serverFields') + assert set(PROVIDERS) <= keys, f'static/setup.js serverFields is missing: {set(PROVIDERS) - keys}' + + +def test_music_servers_admin_javascript_defines_creds_for_every_provider(): + keys = _js_object_keys(_read('static/music_servers_admin.js'), 'CRED_FIELDS') + assert set(PROVIDERS) <= keys, f'music_servers_admin.js CRED_FIELDS is missing: {set(PROVIDERS) - keys}' + + +def test_setup_wizard_javascript_preserves_every_provider_field_on_type_change(): + source = _read('static/setup.js') + every_field = {f for fields in config.MEDIASERVER_FIELDS_BY_TYPE.values() for f in fields} + block = re.search(r'var keys = \[(.*?)\];', source, re.DOTALL).group(1) + listed = set(re.findall(r"'([A-Z0-9_]+)'", block)) + assert every_field <= listed, f'saveCurrentServerValues drops: {every_field - listed}' + + +def test_setup_wizard_javascript_marks_every_secret_field_secret(): + import app_setup + + source = _read('static/setup.js') + block = re.search(r'var secretKeys = \[(.*?)\];', source, re.DOTALL).group(1) + listed = set(re.findall(r"'([A-Z0-9_]+)'", block)) + mediaserver_secrets = { + field + for fields in config.MEDIASERVER_FIELDS_BY_TYPE.values() + for field in fields + if field in app_setup.SECRET_FIELDS + } + assert mediaserver_secrets <= listed, f'rendered in plain text: {mediaserver_secrets - listed}' + + +@pytest.mark.parametrize('provider', PROVIDERS) +def test_setup_html_offers_every_provider_in_both_dropdowns(provider): + source = _read('templates/setup.html') + assert source.count(f'