diff --git a/README.md b/README.md index 813befd0..616e7a94 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_music_servers.py b/app_music_servers.py index 39e18368..cbdf5dea 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 72919b88..f62de052 100644 --- a/app_provider_migration.py +++ b/app_provider_migration.py @@ -82,7 +82,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'}) # --------------------------------------------------------------------------- @@ -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 _overrides_by_catalogue_id(by_provider_id): @@ -284,7 +267,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 @@ -498,7 +481,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/app_setup.py b/app_setup.py index 61cdd305..ef5dc362 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 67d95477..2d1b83be 100644 --- a/config.py +++ b/config.py @@ -90,6 +90,24 @@ 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; an API key is preferred, because it is sent as an +# Authorization header and Ampache opens the session itself - no handshake, and no secret +# in the query string. A password has to handshake and travels as a query parameter. +AMPACHE_URL = os.environ.get("AMPACHE_URL", "") # e.g. https://your-ampache-server +# Optional: an API key authenticates on its own, so leave this empty in that mode. +AMPACHE_USER = os.environ.get("AMPACHE_USER", "") +AMPACHE_PASSWORD = os.environ.get("AMPACHE_PASSWORD", "") +# Rows per page when paging Ampache browse results: the full catalogue enumeration used by the +# sweep and cleaning, and album discovery. Ampache does per-object work on every row it +# serialises, so a bigger page cuts the number of requests but not the server's cost, and makes +# each request longer. HARD CEILING: requests time out at _REQUEST_TIMEOUT_SECONDS (60), and a +# page too big to serve inside that budget makes EVERY page fail, retry once and abort the +# enumeration - a sweep that fails outright rather than running slowly. At ~38 songs/second that +# is ~2300 rows, so keep well under it; 1000 is a safe step up on a fast local server. +AMPACHE_PAGE_SIZE = int(os.environ.get("AMPACHE_PAGE_SIZE", "500") or 500) + # --- Lyrion (LMS) Constants --- # These are used only if MEDIASERVER_TYPE is "lyrion". LYRION_URL = os.environ.get("LYRION_URL", "") @@ -105,6 +123,14 @@ 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'], +} + +# Fields the provider offers but does not require. Ampache's handshake takes an +# API key in the password field, and an API key needs no username, so demanding +# one would make the documented API-key-only install impossible to save. +MEDIASERVER_OPTIONAL_FIELDS_BY_TYPE = { + 'ampache': ['AMPACHE_USER'], } MEDIASERVER_OBSOLETE_FIELDS_BY_TYPE = { @@ -128,6 +154,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 @@ -168,6 +195,7 @@ def _compute_headers(): 'POSTGRES_DB', 'REDIS_URL', 'MEDIASERVER_FIELDS_BY_TYPE', + 'MEDIASERVER_OPTIONAL_FIELDS_BY_TYPE', 'MEDIASERVER_OBSOLETE_FIELDS_BY_TYPE', 'MEDIASERVER_CRED_KEY_BY_FIELD', 'MEDIASERVER_CONFIG_KEYS', diff --git a/database.py b/database.py index e985463c..a50d8b94 100644 --- a/database.py +++ b/database.py @@ -870,12 +870,12 @@ def purge_media_keys_from_app_config(cur): def missing_required_creds(server_type, creds): """Required-but-empty credential keys for ``server_type``.""" + server_type = (server_type or '').strip().lower() + optional = set(config.MEDIASERVER_OPTIONAL_FIELDS_BY_TYPE.get(server_type, [])) required = [ config.MEDIASERVER_CRED_KEY_BY_FIELD[field] - for field in config.MEDIASERVER_FIELDS_BY_TYPE.get( - (server_type or '').strip().lower(), [] - ) - if field in config.MEDIASERVER_CRED_KEY_BY_FIELD + for field in config.MEDIASERVER_FIELDS_BY_TYPE.get(server_type, []) + if field in config.MEDIASERVER_CRED_KEY_BY_FIELD and field not in optional ] creds = creds or {} return [key for key in required if not creds.get(key)] diff --git a/docs/AMPACHE.md b/docs/AMPACHE.md new file mode 100644 index 00000000..c4cb21d2 --- /dev/null +++ b/docs/AMPACHE.md @@ -0,0 +1,123 @@ +# AudioMuse-AI with Ampache + +This guide shows how to deploy AudioMuse-AI next to [Ampache](https://ampache.org) +and how to enable the Ampache plugin so that sonic similarity shows up inside +Ampache itself. Once it works you get similar-song results in the Ampache web UI +and, through the Subsonic API, in compatible clients such as Symfonium, +Substreamer and Tempo. + +Two projects are involved: + +- Core app: https://github.com/NeptuneHub/AudioMuse-AI +- Ampache, which ships the AudioMuse plugin in its own tree: https://github.com/ampache/ampache + +## Which connector to use + +AudioMuse-AI can talk to Ampache two ways, and they are not equivalent: + +- **`ampache`** - the native connector, using Ampache's own JSON API. This is the + one to pick. It reads catalogs, playlists and play statistics the way Ampache + models them, so catalog filtering and playlist creation behave the way they do + in the Ampache UI. +- **`navidrome`** - the OpenSubsonic connector. Ampache serves that API too, so + this works, but everything is seen through the Subsonic model: ids are the + prefixed Subsonic form (`so-123`), and Ampache-specific concepts are not + visible. + +Pick one and stay on it. Track ids differ between the two, so switching after an +analysis means the stored results no longer line up with what the server reports. + +## What you need + +- Ampache **8.0.0 or later** for the full feature set. The connector works + against Ampache 7, but see *Server version* below for what is missing there. +- Docker and Docker Compose, or one of the native builds. +- Ampache and AudioMuse-AI able to reach each other over the network. + +## Step 1 - Deploy AudioMuse-AI + +AudioMuse-AI runs as a small stack: the Flask app, a worker, PostgreSQL and +Redis. Follow Step 1 of [the Navidrome guide](NAVIDROME.md), which is identical +here - only the media server chosen in the Setup Wizard differs. + +In the Setup Wizard pick **Ampache** as the media server and enter: + +- **URL** - the base address of your Ampache server, for example + `http://192.168.1.50`. Do not include `/server/json.server.php`; the connector + appends its own paths. +- **User** - an Ampache username. +- **Password** - either that user's password **or** their API key. The connector + tries the API key form first and falls back to password authentication, so + whichever you have works without any extra setting. + +The equivalent environment variables, if you configure the container directly +rather than through the wizard, are `AMPACHE_URL`, `AMPACHE_USER` and +`AMPACHE_PASSWORD`. + +**Run a first analysis before anything else.** AudioMuse-AI can only find similar +songs after it has analysed them. Start an analysis from the main page and let it +finish, then confirm it works with **Similar Song** on any track in the +AudioMuse-AI UI. + +The connector downloads each track with Ampache's `download` action rather than +`stream`, so analysis always sees the original file instead of a transcode. + +## Step 2 - Enable the AudioMuse plugin in Ampache + +The plugin ships with Ampache; there is nothing to download. + +1. In Ampache go to **Admin > Modules > Plugins** and activate **AudioMuse**. +2. Open your account preferences and set the **AudioMuse server URL** to the + address where Ampache reaches the core app, for example + `http://192.168.1.50:8000`. Use a host and port the Ampache container can + actually reach, not `localhost`, unless the two share a network namespace. + +Ampache advertises the OpenSubsonic `sonicSimilarity` extension only while a +sonic-analysis plugin is enabled for the requesting user, so a server with the +plugin switched off reports the feature as unsupported rather than answering with +metadata similarity, which is a different thing and would give wrong results. + +## Step 3 - Check that it works + +- In AudioMuse-AI, use **Similar Song** on a track. It should return results. +- In Ampache, the same track should offer similar songs. Over the Subsonic API, + `getSonicSimilarTracks` should return `sonicMatch` entries; over Ampache's own + API 8, `sonic_match` (REST: `songs/{song_id}/sonic-match`) returns the same + matches with a `similarity` score. + +Both APIs use the same scale: `0.0`-`1.0` where `1.0` is the same recording, and +`-1` when the backend gives no comparable score for that row. + +## Server version + +The connector degrades cleanly on an older Ampache, but two things need +**Ampache 8**: + +- **Last played time** (`get_last_played_time`) needs database version `800029` + or later, which added the maintained `last_played` column. Earlier servers omit + the field and the connector returns `None`, so anything ranking by recency + simply has nothing to sort on. The value is server-wide rather than per-user, + matching how Ampache scopes `playcount` - `starred` and `userRating` are its + per-user pair. An upgraded database backfills the column from the play history + it already has, so it is populated for old plays too, not just new ones. +- **The `sonic_match` API method** is API 8 only. A client pinned to an older API + version by the `api_force_version` preference gets `4705 Invalid Request` for + it, which is the usual first cause when the endpoint works in one client and + not another. + +## Troubleshooting + +- **`4701 Session Expired`**: the handshake token has lapsed. The connector + re-handshakes automatically on that error; seeing it repeatedly means the + credentials are wrong rather than stale. +- **`4705 Invalid Request` from `sonic-match`**: the session is on an older API + version. Check the user's `api_force_version` preference and the `version` + parameter the client sends. +- **Empty or poor results**: the library is not analysed yet, or not fully. Run + the analysis in AudioMuse-AI and wait for it to finish. +- **Analysis finds nothing to fetch**: check the catalog filter. The connector + only walks the catalogs it is pointed at, so a library in an unselected catalog + is invisible to it. +- **Results do not line up with the server**: the analysis was probably run + through the other connector. Ampache ids and Subsonic ids are different, so + re-run the analysis after switching. diff --git a/docs/MULTI_SERVER.md b/docs/MULTI_SERVER.md index d26e5d00..f52e1bb7 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 8ac6b113..03d0cfad 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,10 @@ 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. Needs Ampache API8 or newer. | `https://YOUR_AMPACHE_HOST` | +| `AMPACHE_USER` | (Optional) Ampache username. Leave empty when using an API key. | *(N/A - from Secret)* | +| `AMPACHE_PASSWORD` | (Required) Ampache user password or API key. **An API key is preferred.** A key is sent as an `Authorization: Bearer` header and Ampache opens the session itself, so there is no handshake and the secret never reaches the URL or the web server's access log. A password must handshake and travels as a query parameter. | *(N/A - from Secret)* | +| `AMPACHE_PAGE_SIZE` | Rows per page when paging Ampache browse results - the full catalogue enumeration used by the sweep and cleaning, and album discovery. Ampache does per-object work on every row it serialises, so a larger page reduces the number of requests but **not** the server's cost, and lengthens each request. **There is a hard ceiling: requests time out after 60 seconds.** A server serialising ~38 songs/second cannot deliver more than ~2300 rows inside that budget, and an oversized page makes *every* page time out, retry once, then abort the enumeration - so the sweep fails outright rather than running slowly. Measure your server's rate (compare timestamps between consecutive pages in its access log) and keep a page under half the budget. `1000` is a safe step up on a fast local server; treat `1500` as the practical limit. | `500` | | `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 f0a8c16a..e271df36 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: 'https://ampache' }, + { key: 'user', label: 'Username' }, + { key: 'password', label: 'Password or API key', secret: true } ] }; @@ -156,8 +161,10 @@ } boxes.appendChild(row('No restriction (use all libraries)', { 'data-lib-all': '1' }, selected.length === 0)); libraries.forEach(function (lib) { - var name = lib.name || lib; - boxes.appendChild(row(name, { 'data-lib-name': name }, selected.indexOf(String(name).toLowerCase()) !== -1)); + var name = (lib && typeof lib === 'object') ? lib.name : lib; + name = name ? String(name) : ''; + if (!name) { return; } + boxes.appendChild(row(name, { 'data-lib-name': name }, selected.includes(name.toLowerCase()))); }); boxes.style.display = 'flex'; syncLibraryBoxesToInput(); diff --git a/static/setup.js b/static/setup.js index 6f5499e4..2054b44c 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: 'https://your-ampache-server', tooltip: 'Base URL of your Ampache server, including the scheme 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/analysis/main.py b/tasks/analysis/main.py index f98cfe33..c721b04a 100644 --- a/tasks/analysis/main.py +++ b/tasks/analysis/main.py @@ -50,7 +50,7 @@ from ..mediaserver import ( get_recent_albums, - get_tracks_from_album, + get_album_track_ids, download_track, registry, test_connection as mediaserver_test_connection, @@ -564,8 +564,11 @@ def report_progress(force=False): report_progress() time.sleep(5) - tracks = get_tracks_from_album(album['Id']) - if not tracks: + # Ids and a count are all this loop needs, so it asks for exactly that: + # a provider able to answer more cheaply than a full track fetch does, + # and the rest fall back to one inside the dispatcher. + ids = get_album_track_ids(album['Id']) + if not ids: albums_skipped += 1 albums_no_tracks += 1 logger.info( @@ -574,7 +577,6 @@ def report_progress(force=False): report_progress() continue - ids = [_ah.provider_item_id(t) for t in tracks] if work_map_bulk_ok: masks = [work_map.get(i, 0) for i in ids] else: @@ -599,16 +601,16 @@ def report_progress(force=False): needs_clap_analysis, needs_lyrics_analysis, ) = _ah.album_feature_needs(masks, done_bits, clap_available, LYRICS_ENABLED) - songs_seen += len(tracks) + songs_seen += len(ids) songs_done += album_done - if album_done == len(tracks): + if album_done == len(ids): albums_skipped += 1 status_parts = _ah.build_feature_status_parts( clap_available, LYRICS_ENABLED ) logger.info( - f"Skipping album '{album.get('Name')}' (ID: {album.get('Id')}) - all {len(tracks)} tracks already analyzed ({' + '.join(status_parts)})." + f"Skipping album '{album.get('Name')}' (ID: {album.get('Id')}) - all {len(ids)} tracks already analyzed ({' + '.join(status_parts)})." ) report_progress() continue diff --git a/tasks/mediaserver/__init__.py b/tasks/mediaserver/__init__.py index c92f6d66..130e4bf6 100644 --- a/tasks/mediaserver/__init__.py +++ b/tasks/mediaserver/__init__.py @@ -14,7 +14,8 @@ Main Features: * Lazily imports and dispatches to the active provider backend (jellyfin, emby, - navidrome, lyrion, plex), so importing this package does not load inactive backends. + navidrome, lyrion, plex, ampache), so importing this package does not load + inactive backends. * Centralizes the provider-agnostic public API; shared HTTP and metadata parsing live in http.py and helper.py. """ @@ -30,7 +31,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." @@ -38,7 +39,8 @@ _PUBLIC_SERVER_API = ( 'resolve_emby_jellyfin_user', 'delete_playlists_by_suffix', 'delete_automatic_playlists', - 'get_recent_albums', 'get_tracks_from_album', 'download_track', 'get_all_songs', + 'get_recent_albums', 'get_tracks_from_album', 'get_album_track_ids', 'download_track', + 'get_all_songs', 'list_libraries', 'search_albums', 'test_connection', 'get_playlist_by_name', 'get_all_playlists', 'get_playlist_track_ids', 'create_playlist', 'create_instant_playlist', 'create_or_replace_playlist', 'get_top_played_songs', 'get_last_played_time', 'get_lyrics', @@ -121,6 +123,32 @@ def get_tracks_from_album(album_id, user_creds=None, provider_type=None): return provider.get_tracks_from_album(album_id, user_creds=user_creds) +def get_album_track_ids(album_id, user_creds=None, provider_type=None): + """Provider ids for one album's tracks, for callers that need no track metadata. + + An OPTIONAL provider capability: a backend that can answer this with a lighter + call than a full track fetch implements ``get_album_track_ids``, and every other + backend falls back to ``get_tracks_from_album`` right here. Nothing has to be + added to a provider for this to work, which is why it is resolved with getattr + rather than pinned as a dispatcher call site. + + The analysis dispatcher needs ids and a count to decide what to enqueue and + nothing else, so on a provider that can skip the metadata it should not pay for + it. + """ + user_creds = context.active_creds(user_creds) + provider = _provider(provider_type) + if provider is None: + return [] + lightweight = getattr(provider, 'get_album_track_ids', None) + if lightweight is not None: + return lightweight(album_id, user_creds=user_creds) + return [ + str(track.get('Id') or track.get('id')) + for track in (provider.get_tracks_from_album(album_id, user_creds=user_creds) or []) + ] + + def download_track(temp_dir, item): provider = _provider() downloaded_path = provider.download_track(temp_dir, item) if provider is not None else None diff --git a/tasks/mediaserver/ampache.py b/tasks/mediaserver/ampache.py new file mode 100644 index 00000000..3134badd --- /dev/null +++ b/tasks/mediaserver/ampache.py @@ -0,0 +1,1228 @@ +# 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 media-server backend, speaking Ampache's own JSON API. + +Requires Ampache API 8 or newer: album browsing depends on the catalogue id being +present on album objects and on the ``cond`` browse filter. test_connection warns +when a server reports an older API rather than letting an analysis quietly find +nothing. + +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 session token rather than credentials sent on every request. An API + key skips the handshake entirely - Ampache takes it from an Authorization header + and opens the session itself. A password must handshake; that token is cached + per server, its window slides forward on every call the server accepts - the way + Ampache's own session does - and it is re-issued only once the window has lapsed. + +Main Features: +* Prefers an API key sent as ``Authorization: Bearer``, which Ampache resolves + with findByApiKey and answers by creating the session server-side - so there is + no handshake, and no secret in the query string or the web server's access log. + Tried on the first request per credential set and remembered, falling back to the + handshake on a server that refuses it. A password cannot use this path. +* Trades credentials for a session token that holds either an API key or a + time-salted password hash in the same field. The token is cached per + CREDENTIAL SET (a rotated password never reuses the old session) and + re-handshaked once when a session lapses mid-run; the API-key attempt is only + made for a key-shaped secret, so a real password never travels in a query + string. The caller's timeout bounds the handshake too, not just the data call. +* 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 and pushing that filter into the server query as + ``cond=catalog,`` - one browse per catalogue, since conditions combine + rather than alternate - while still enforcing it locally, because Ampache + IGNORES a condition it does not understand instead of refusing it. Every page + asks for a real page size so "analyse every album" is not capped at the + server's first page. +* Downloads the original file rather than a transcoded stream, refusing a + response that carries an Ampache JSON error under HTTP 200 instead of audio. +* Answers "which tracks are on this album" with a ``browse`` when only ids are + wanted, which returns a name array instead of hydrating every song. The analysis + dispatcher needs ids and a count to decide what to enqueue and nothing more. + Anything that cannot be trusted - an unknown catalogue id, a total that does not + match the rows returned - falls back to the full ``album_songs`` fetch, because a + short list would make an unfinished album look complete. +* Serves lyrics from the album fetch that already returned them. Ampache + serialises a single ``song`` and an ``album_songs`` row through the same + songs_array, so asking per track re-paid that row's whole hydration cost to + re-read one field. A caller with no album context still falls back to ``song``. +* Reads play stats and lyrics, and manages playlists through the shared + dispatcher contract: creation returns the ``{'Id', 'Name'}`` dict callers + dereference, a playlist that received none of its tracks is not reported as + created, and a failed delete aborts the replace rather than leaving two + playlists under one name. +""" + +from . import http as requests +import hashlib +import logging +import os +import re +import threading +import time +from datetime import datetime, timezone + +import config +from . import context +from .helper import detect_download_extension, detect_path_format + +logger = logging.getLogger(__name__) + +_API_VERSION = '8.0.0' +# Ampache 8 is the floor, not a preference: album browsing needs the catalogue id +# on album objects and the `cond` browse filter, and older servers answer the same +# calls differently (or not at all). test_connection warns when a server reports +# an older API so the setup wizard says so instead of an analysis finding nothing. +_MIN_API_MAJOR = 8 + +# Fallback session window, used only when a handshake reports no usable +# session_expire. The real length comes from the server. +_TOKEN_TTL_SECONDS = 3000 +# Ampache extends a session's expiry on every authenticated call, so the cached +# window slides forward on each success rather than counting down from the +# handshake. Renewing at a fraction of the window keeps a request from leaving +# just after the server has dropped the session. +_SESSION_RENEW_MARGIN = 0.9 +_HANDSHAKE_TIMEOUT_SECONDS = 30 +_REQUEST_TIMEOUT_SECONDS = 60 +_PAGE_SIZE = 500 +_SESSION_EXPIRED_CODE = '4701' +_AUTH_ERROR_CODES = ('4742', '4704') + +_token_cache = {} +_token_lock = threading.Lock() + +# Ampache reads an API key straight from an Authorization header and creates the +# session itself, so a key-shaped secret needs no handshake at all. Whether a +# given server accepts that is discovered on the first real request per credential +# set and remembered here - True: header auth works, False: it was refused and the +# handshake is used instead. Deliberately not configurable: there is nothing for an +# operator to decide that the first response does not answer. +_header_auth = {} +_header_auth_lock = threading.Lock() + +# Ampache serialises a single `song` and an `album_songs` row through the SAME +# Json8_Data::songs_array, so an album fetch has already returned the lyrics the +# lyrics stage would otherwise ask for one track at a time - and that per-song call +# repeats every bit of hydration (rating, userflag, art, album and artist lookups) +# the album fetch just paid for. The rows are kept here for the lyrics stage, which +# runs in the same job and process as the album fetch that filled it. +# +# Keyed by track id alone, with no credential set in the key: `lyrics` comes off the +# song row itself, not from the calling user (unlike rating and flag, which +# songs_array resolves per user), so it does not vary between callers. +_album_lyrics = {} +_album_lyrics_lock = threading.Lock() +# A whole-library run walks every album in one job, so the cache needs a ceiling. +_LYRICS_CACHE_MAX = 5000 +# A cached None means "Ampache says this song has no lyrics", which is an answer and +# needs no request. Only an absent entry justifies one, so absence needs a sentinel +# that None cannot be confused with. +_LYRICS_UNCACHED = object() + +# Stock Ampache 8 requires `catalog` on a sub-type browse alongside `filter` +# (BrowseMethod.php: `foreach (['filter', 'catalog'] as $parameter)`), so the album's +# catalogue id has to be known before its songs can be browsed. Album discovery +# already reads that field off every album row - the API-8 gate and the library +# filter both depend on it - so it is recorded here for the dispatch loop, which runs +# in the same process as the discovery that fills it. +_album_catalogs = {} +_album_catalogs_lock = threading.Lock() +# Bounded by the library's album count in practice. Unlike the lyrics cache this is +# NOT cleared wholesale on overflow: entries are added during discovery and read +# later, so clearing would strand albums the loop has not reached yet. Overflow stops +# recording instead, and the albums past the ceiling fall back to the heavier fetch. +_ALBUM_CATALOG_CACHE_MAX = 100000 + +_SECRET_QUERY_PARAM = re.compile(r'(?i)([?&](?:auth|passphrase|password)=)[^&\s]*') +_API_KEY_SHAPE = re.compile(r'\A[0-9a-fA-F]{32,}\Z') + +_CATALOGUE_KEYS = ( + 'Id', 'Name', 'AlbumArtist', 'ArtistId', 'OriginalAlbumArtist', 'Album', + 'Path', 'FilePath', 'Year', 'Rating', 'DurationSeconds', +) + + +def _redact_ampache_secrets(text): + return _SECRET_QUERY_PARAM.sub(r'\1[REDACTED]', str(text)) + + +def _log_error(message, error): + """Log a failure WITHOUT its traceback, redacting the exception text. + + Every Ampache call carries its session token (and the handshake its passphrase) + in the query string, so a traceback - which prints the request URL verbatim. + Logging lives in this helper rather than inline. + """ + logger.error("%s: %s", message, _redact_ampache_secrets(error)) + + +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, password): + secret = hashlib.sha256((password or '').encode('utf-8')).hexdigest() + return f"{url}|{user}|{secret}" + + +def _handshake_attempts(user, password, timestamp, passphrase): + attempts = [] + if user: + attempts.append( + {'action': 'handshake', 'user': user, 'timestamp': timestamp, 'auth': passphrase} + ) + if not user or _API_KEY_SHAPE.match(password or ''): + attempts.append({'action': 'handshake', 'auth': password}) + return attempts + + +def _handshake(url, user, password, timeout=None): + timestamp = int(time.time()) + pass_hash = hashlib.sha256(password.encode('utf-8')).hexdigest() + passphrase = hashlib.sha256(f"{timestamp}{pass_hash}".encode('utf-8')).hexdigest() + + body = None + for params in _handshake_attempts(user, password, timestamp, passphrase): + params['version'] = _API_VERSION + try: + response = requests.get( + f"{url}/server/json.server.php", + params=params, + timeout=timeout or _HANDSHAKE_TIMEOUT_SECONDS, + ) + body = response.json() + except Exception as e: + _log_error("Ampache handshake failed", 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 _session_lifetime(body): + """How long a fresh session lasts, in seconds, from the handshake body. + + Ampache reports an absolute ISO-8601 ``session_expire``. What the cache needs + is a LENGTH, because that expiry slides forward on every authenticated call, + so the timestamp is converted once and the duration reapplied on each + success. Falls back to the conservative default when the field is missing, + unparseable, or implausibly short - an older server, a proxy that rewrites + it, or a clock disagreement between us and the server. + """ + raw = str((body or {}).get('session_expire') or '').strip() + if not raw: + return _TOKEN_TTL_SECONDS + try: + expires_at = datetime.fromisoformat(raw.replace('Z', '+00:00')) + except ValueError: + return _TOKEN_TTL_SECONDS + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + seconds = (expires_at - datetime.now(timezone.utc)).total_seconds() + return seconds if seconds >= 60 else _TOKEN_TTL_SECONDS + + +def _token(user_creds=None, force=False, timeout=None): + 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, password) + with _token_lock: + cached = _token_cache.get(key) + # A header-auth entry exists for the version gate and may hold no token, + # so require one rather than handing back an empty session. + if cached and not force and cached.get('token') and cached.get('expires', 0) > time.time(): + return url, cached['token'], None + + body, err = _handshake(url, user, password, timeout=timeout) + if not body: + return url, None, err + + lifetime = _session_lifetime(body) + with _token_lock: + _token_cache[key] = { + 'token': body['auth'], + # Kept so every later success can reapply the same window. + 'lifetime': lifetime, + 'expires': time.time() + lifetime * _SESSION_RENEW_MARGIN, + # The handshake already reports the API version, so the version gate + # needs no extra round trip. + 'api': str(body.get('api') or body.get('version') or ''), + } + return url, body['auth'], None + + +def _extend_session(user_creds=None): + """Slide the cached expiry forward after a call the server accepted. + + Ampache resets a session's expiry on every authenticated request, so a token + that is kept busy never needs re-issuing. Without this the cache counts down + from the handshake instead, and a long analysis re-handshakes on a timer while + the session it already holds is still perfectly valid - once per window, per + worker process, for the whole run. + + Only called on success. A session that really has lapsed is what the 4701 + retry in ``_request_ex`` is for. + """ + url, user, password = _creds(user_creds) + if not url: + return + key = _cache_key(url, user, password) + with _token_lock: + cached = _token_cache.get(key) + if not cached: + return + lifetime = cached.get('lifetime') or _TOKEN_TTL_SECONDS + cached['expires'] = time.time() + lifetime * _SESSION_RENEW_MARGIN + + +def _header_auth_state(url, user, password): + with _header_auth_lock: + return _header_auth.get(_cache_key(url, user, password)) + + +def _remember_header_auth(url, user, password, works): + with _header_auth_lock: + _header_auth[_cache_key(url, user, password)] = works + + +def _bootstrap_header_auth(url, user, password, timeout=None): + """Settle header auth for these credentials with a single bearer ping. + + A bearer-authenticated ``ping`` answers with everything the handshake would + have returned - ``api``, ``session_expire`` and an ``auth`` token - so one call + both proves the key is accepted and fills the cache the version gate and the + expiry window read. It REPLACES the handshake rather than adding to it, and it + is cheaper: no password on the wire, and nothing secret in the query string. + + Returns True when header auth is available. A refusal is remembered so the + handshake is used from then on; a network failure is not, because it says + nothing about whether the header would be accepted. + """ + response, err = _fetch(url, 'ping', None, None, False, timeout, api_key=password) + if err: + return False + + try: + body = response.json() + except Exception: + body = None + if not isinstance(body, dict): + body = {} + error, _code = _error_from_body(body) + + # An accepted bearer ping carries the server_details payload. Ampache answers + # an unauthenticated ping with server/version only, so requiring `api` is what + # separates "the key was taken" from "the endpoint merely replied". + if error or not body.get('api'): + _remember_header_auth(url, user, password, False) + logger.info( + "Ampache would not take the API key as a bearer token (%s); using the " + "handshake for this server instead.", + (error or {}).get('message') or (error or {}).get('errorMessage') or 'no api in ping', + ) + return False + + lifetime = _session_lifetime(body) + with _token_lock: + _token_cache[_cache_key(url, user, password)] = { + 'token': str(body.get('auth') or ''), + 'lifetime': lifetime, + 'expires': time.time() + lifetime * _SESSION_RENEW_MARGIN, + 'api': str(body.get('api') or body.get('version') or ''), + } + _remember_header_auth(url, user, password, True) + logger.info( + "Ampache accepted the API key in an Authorization header (API %s), so this " + "server needs no handshake - it opens the session itself.", + body.get('api'), + ) + return True + + +def _header_auth_ready(url, user, password, timeout=None): + """True when requests for these credentials should carry a bearer header. + + Only a key-shaped secret qualifies: Ampache resolves a bearer token with + findByApiKey, so a real password would just be refused - and being refused + means having put the password on the wire for nothing. + """ + if not url or not password or not _API_KEY_SHAPE.match(password): + return False + state = _header_auth_state(url, user, password) + if state is not None: + return state + return _bootstrap_header_auth(url, user, password, timeout) + + +def _api_major(version): + """Major API version from either the '8.0.0' or the packed '600000' form.""" + text = str(version or '').strip() + if '.' in text: + head = text.split('.', 1)[0] + return int(head) if head.isdigit() else None + if text.isdigit(): + return int(text[0]) if len(text) >= 4 else int(text) + return None + + +def _cached_api_version(user_creds=None): + url, user, password = _creds(user_creds) + key = _cache_key(url, user, password) + with _token_lock: + cached = _token_cache.get(key) or {} + # Filled by the handshake, or by the bearer ping in header-auth mode. + return cached.get('api') + + +def _api_version_warning(user_creds=None): + """Warn when the server is older than the API this backend is written against.""" + major = _api_major(_cached_api_version(user_creds)) + if major is None or major >= _MIN_API_MAJOR: + return None + return ( + f'This server reports Ampache API {major}, but AudioMuse-AI targets API ' + f'{_MIN_API_MAJOR} or newer. Album browsing needs the catalogue id on album ' + 'objects and the "cond" browse filter that API 8 provides, so on an older ' + 'server an analysis can find no albums or ignore your library selection. ' + 'Upgrade Ampache before relying on this connection.' + ) + + +def _error_from_body(body): + if not isinstance(body, dict): + return None, '' + error = body.get('error') + if not error: + return None, '' + return error, str(error.get('errorCode') or error.get('code') or '') + + +def _err_message(err): + """The message from a ``_request_ex`` error, so a log line always says something.""" + return (err or {}).get('message') or 'unknown error' + + +def _stream_error_body(response): + try: + content_type = str((response.headers or {}).get('Content-Type') or '') + except Exception: + return None + if 'json' not in content_type.lower(): + return None + try: + return response.json() + except Exception: + return None + + +def _close_quietly(response): + try: + response.close() + except Exception: + logger.debug("Ampache: closing an errored stream response failed.", exc_info=True) + + +def _fetch(url, action, token, params, stream, timeout, api_key=None): + all_params = {'action': action, 'version': _API_VERSION, **(params or {})} + headers = None + if api_key: + # Ampache only looks at the Authorization header when there is NO auth + # parameter at all (ApiHandler: `if (!isset($input['auth']))`), so the + # token has to be absent from the query string, not merely empty. + headers = {'Authorization': f'Bearer {api_key}'} + else: + all_params['auth'] = token + try: + response = requests.get( + f"{url}/server/json.server.php", + params=all_params, + headers=headers, + stream=stream, + timeout=timeout or _REQUEST_TIMEOUT_SECONDS, + ) + except Exception as e: + _log_error(f"Ampache request '{action}' failed", e) + return None, {'kind': 'network', 'message': str(_redact_ampache_secrets(e))} + return response, None + + +def _stream_payload(action, response): + try: + response.raise_for_status() + except Exception as e: + _log_error(f"Ampache stream '{action}' failed", e) + return None, None, {'kind': 'network', 'message': str(_redact_ampache_secrets(e))} + + body = _stream_error_body(response) + if body is None: + return response, None, None + _close_quietly(response) + return None, body, None + + +def _body_error(action, body, stream): + error, code = _error_from_body(body) + if error: + if code == _SESSION_EXPIRED_CODE: + return 'retry', None + kind = 'auth' if code in _AUTH_ERROR_CODES else 'api' + message = error.get('errorMessage') or error.get('message') or 'Ampache error' + return 'error', {'kind': kind, 'message': message} + if stream: + return 'error', { + 'kind': 'api', + 'message': f"Ampache returned JSON instead of audio for '{action}'", + } + return 'ok', None + + +def _response_payload(action, response, stream): + """Split a response into (passthrough, parsed body, error). + + A streamed response that really is audio is handed back as ``passthrough`` + for the caller to consume; anything else is parsed so the Ampache error + envelope (which arrives under HTTP 200) can be inspected. + """ + if stream: + return _stream_payload(action, response) + try: + return None, response.json(), None + except Exception as e: + return None, None, {'kind': 'parse', 'message': str(_redact_ampache_secrets(e))} + + +def _evaluate(action, response, stream, user_creds, extend): + """A fetched response as ('ok' | 'retry' | 'error', payload, error).""" + passthrough, body, err = _response_payload(action, response, stream) + if err: + return 'error', None, err + if passthrough is not None: + if extend: + _extend_session(user_creds) + return 'ok', passthrough, None + + verdict, err = _body_error(action, body, stream) + if verdict == 'ok': + if extend: + _extend_session(user_creds) + return 'ok', body, None + return verdict, None, err + + +def _attempt_request(action, params, stream, user_creds, timeout, force): + """One authenticate-and-fetch attempt, reporting 'ok' / 'retry' / 'error'. + + 'retry' means Ampache reported an expired session, which the caller answers + by re-authenticating once with ``force=True``. + """ + url, user, password = _creds(user_creds) + if _header_auth_ready(url, user, password, timeout): + # Nothing to slide: Ampache opens and extends the session for a + # header-authenticated call itself, so the cached window is only there for + # the version gate. + response, err = _fetch(url, action, None, params, stream, timeout, api_key=password) + if err: + return 'error', None, err + return _evaluate(action, response, stream, user_creds, extend=False) + + url, token, err = _token(user_creds, force=force, timeout=timeout) + if not token: + return 'error', None, err + + response, err = _fetch(url, action, token, params, stream, timeout) + if err: + return 'error', None, err + return _evaluate(action, response, stream, user_creds, extend=True) + + +def _request_ex(action, params=None, stream=False, user_creds=None, timeout=None): + for attempt in (0, 1): + verdict, payload, err = _attempt_request( + action, params, stream, user_creds, timeout, force=bool(attempt) + ) + if verdict == 'ok': + return payload, None + if verdict == 'error': + return None, err + + 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): + 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): + 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': song.get('format') or song.get('stream_format'), + 'title': song.get('title'), + } + + +def _map_catalogue_song(song): + mapped = _map_song(song) + return {key: mapped[key] for key in _CATALOGUE_KEYS} + + +def _page_size(): + """Rows per browse page, from config so a fast server can raise it. + + Bigger pages cut request overhead but NOT the server's cost, which is + per-song: Ampache hydrates every row and looks up its rating, art, album and + artist names individually. Raising this shortens the request count, not the + work, and makes each request longer. + """ + size = config.AMPACHE_PAGE_SIZE + return size if size > 0 else _PAGE_SIZE + + +def _catalogue_query_plan(catalog_ids): + """One song browse per target catalogue, or a single unfiltered browse. + + A plain ``songs`` browse with ``cond=catalog,`` compiles to an indexed + ``song.catalog = `` with no join, where the equivalent ``advanced_search`` + with the catalogues OR'd together builds a much heavier query for the same + rows. Album discovery already browses one catalogue at a time for this reason, + and ``cond`` conditions combine rather than alternate, so several catalogues + are one browse each either way. + + Each entry is ``(params, catalog_id)``; ``catalog_id`` is None when nothing is + being filtered and there is therefore nothing to verify. + """ + if not catalog_ids: + return [({}, None)] + return [({'cond': f'catalog,{catalog_id}'}, catalog_id) for catalog_id in sorted(catalog_ids)] + + +def _cond_trusted(rows, catalog_id): + """False when a ``cond=catalog`` browse returned rows it should not have. + + Ampache IGNORES a ``cond`` it does not understand instead of refusing it, so a + browse that should be one catalogue can quietly be the whole library. That + matters more here than for albums: with one browse per catalogue, an ignored + condition would walk the entire library once per selected catalogue. Rows + carry their catalogue id, so the first page settles it. A page that reports no + catalogue id cannot be verified, which is treated the same as being ignored. + """ + reported = [row for row in rows if 'catalog' in row] + if not reported: + return False + return all(str(row.get('catalog')) == str(catalog_id) for row in reported) + + +def _in_catalogs(row, catalog_ids): + return catalog_ids is None or str(row.get('catalog')) in catalog_ids + + +def _filter_album_rows(rows, catalog_ids): + """Keep the rows inside the target catalogues; report a page that names none. + + Ampache IGNORES a ``cond`` it does not understand instead of failing, so a + filtered browse can come back as the whole library. Re-checking each row's + catalogue id is therefore not belt-and-braces, it is what stops an ignored + filter from silently analysing everything. A page that reports no catalogue + ids at all means the server cannot express the filter (API 8 added the field), + which the caller surfaces rather than passing off as an empty library. + + Returns ``(kept rows, server reported no catalogue ids)``. + """ + if not catalog_ids: + return rows, False + reported = [row for row in rows if 'catalog' in row] + if not reported: + return [], True + return [row for row in reported if str(row.get('catalog')) in catalog_ids], False + + +def _log_catalogue_fetch_failure(err, offset, collected): + logger.error( + "AMPACHE CATALOGUE FETCH FAILED at offset %d after %d songs (%s). The " + "returned catalogue is INCOMPLETE - do not treat missing tracks as deleted.", + offset, collected, _err_message(err), + ) + + +def _song_page(params, offset, page, user_creds, collected): + """One page of a song browse, retried once, as ``(rows, failed)``. + + The retry keeps the catalogue filter and the SAME offset. That is the whole point: + the previous behaviour abandoned the filter on the first failure and re-walked the + entire unfiltered library, so one transient error on page one cost a full-library + enumeration. The budget is per page, so a later page still gets its own retry. + """ + err = None + for attempt in (0, 1): + body, err = _request_ex( + 'songs', {**params, 'offset': offset, 'limit': page}, user_creds=user_creds + ) + if body is not None: + return body.get('song') or [], False + if attempt == 0: + logger.warning( + "Ampache song browse failed at offset %d (%s); retrying that page " + "once before giving up on it.", + offset, _err_message(err), + ) + _log_catalogue_fetch_failure(err, offset, collected) + return [], True + + +def _collect_songs_for(params, catalog_id, catalog_ids, songs, user_creds): + """Page one browse into ``songs``, reporting how it ended. + + ``'ok'`` - the browse was walked to the end. + ``'untrusted'`` - the catalogue condition was ignored, so the whole plan has + to be abandoned rather than repeated per catalogue. + ``'failed'`` - a page could not be fetched twice running, so what has been + collected is INCOMPLETE. + + A failed page is retried once before giving up. That matters: the previous + behaviour abandoned the catalogue filter on the first failure and re-walked the + entire unfiltered library, so one transient error on page one cost a full- + library enumeration. + """ + offset = 0 + page = _page_size() + while True: + rows, failed = _song_page(params, offset, page, user_creds, len(songs)) + if failed: + return 'failed' + if not rows: + return 'ok' + if catalog_id is not None and offset == 0 and not _cond_trusted(rows, catalog_id): + return 'untrusted' + + songs.extend(_map_catalogue_song(r) for r in rows if _in_catalogs(r, catalog_ids)) + offset += len(rows) + if len(rows) < page: + return 'ok' + + +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 = [] + for params, catalog_id in _catalogue_query_plan(catalog_ids): + verdict = _collect_songs_for(params, catalog_id, catalog_ids, songs, user_creds) + if verdict == 'failed': + break + if verdict == 'untrusted': + logger.warning( + "Ampache ignored the catalog condition on a song browse, so browsing " + "per catalogue would walk the whole library once for each of %d " + "catalogues. Falling back to one unfiltered fetch filtered locally.", + len(catalog_ids or ()), + ) + # Start clean: anything already collected came from a browse that was + # not filtering, and the replacement walk covers the same ground from + # offset 0. Reusing the partial list would double-count it. + songs.clear() + _collect_songs_for({}, None, catalog_ids, songs, user_creds) + break + + logger.info(f"Fetched {len(songs)} songs from Ampache.") + return songs + + +def _map_album(album): + artist = (album.get('artist') or {}) if isinstance(album.get('artist'), dict) else {} + return { + **album, + 'Id': str(album.get('id')), + 'Name': album.get('name'), + 'AlbumArtist': artist.get('name'), + } + + +def _album_query_plan(catalog_ids): + """One album browse per target catalogue, or a single unfiltered browse. + + Ampache filters a browse with ``cond=,``, and conditions + combine, so several catalogues are not expressible as one ``cond``. Issuing + one filtered browse per catalogue keeps each response's newest-first order + intact and merges cleanly; the common case of a single catalogue stays a + single query. + """ + if not catalog_ids: + return [{}] + return [{'cond': f'catalog,{catalog_id}'} for catalog_id in sorted(catalog_ids)] + + +def _remember_album_catalogs(rows): + """Record each album's catalogue id so its songs can be browsed later.""" + fresh = { + str(row.get('id')): str(row.get('catalog')) + for row in rows + if isinstance(row, dict) and row.get('id') is not None and row.get('catalog') is not None + } + if not fresh: + return + with _album_catalogs_lock: + if len(_album_catalogs) >= _ALBUM_CATALOG_CACHE_MAX: + return + _album_catalogs.update(fresh) + + +def _cached_album_catalog(album_id): + with _album_catalogs_lock: + return _album_catalogs.get(str(album_id)) + + +def _log_album_fetch_failure(err, offset, collected): + logger.error( + "AMPACHE ALBUM FETCH FAILED at offset %d after %d albums (%s). Album " + "discovery is INCOMPLETE - do not read this as an empty library. With a " + "library filter set, check that this server supports the 'cond' browse " + "filter on the albums action.", + offset, collected, _err_message(err), + ) + + +def _album_page(base_params, offset, page, collected): + params = {**base_params, 'offset': offset, 'limit': page, 'sort': 'addition_time,DESC'} + body, err = _request_ex('albums', params) + if body is None: + _log_album_fetch_failure(err, offset, collected) + return None + return body.get('album') or [] + + +def _collect_albums_for(base_params, catalog_ids, fetch_all, wanted, collected): + """Page one browse into ``collected``, keyed by album id so merges dedupe. + + Every page asks for a real page size: ``limit=0`` is not a portable "no + limit" on Ampache, and combining it with a single pass meant an install + configured to analyse EVERY album only ever saw the server's first page. + """ + offset = 0 + page = _page_size() if (fetch_all or catalog_ids) else max(wanted, 1) + while True: + rows = _album_page(base_params, offset, page, len(collected)) + if not rows: + return + kept, unreported = _filter_album_rows(rows, catalog_ids) + if unreported: + logger.error( + "AMPACHE REPORTED NO CATALOGUE IDS on its albums, so the library " + "filter %s cannot be verified and album discovery is stopping rather " + "than analysing the whole library. Ampache API %s or newer is " + "required for a library-filtered install.", + sorted(catalog_ids), _MIN_API_MAJOR, + ) + return + _remember_album_catalogs(kept) + for row in kept: + collected.setdefault(str(row.get('id')), _map_album(row)) + offset += len(rows) + if len(rows) < page or (not fetch_all and len(collected) >= wanted): + return + + +def _collect_recent_albums(catalog_ids, fetch_all, wanted): + collected = {} + for base_params in _album_query_plan(catalog_ids): + _collect_albums_for(base_params, catalog_ids, fetch_all, wanted, collected) + if not fetch_all and len(collected) >= wanted: + break + return list(collected.values()) + + +def get_recent_albums(limit): + fetch_all = not limit or int(limit) <= 0 + wanted = 0 if fetch_all else int(limit) + + catalog_ids = _target_catalog_ids() + if isinstance(catalog_ids, set) and not catalog_ids: + return [] + + mapped = _collect_recent_albums(catalog_ids, fetch_all, wanted) + if not mapped: + logger.warning( + "AMPACHE RETURNED NO ALBUMS%s, so there is nothing to analyse. Treat this " + "as a configuration or API problem unless the server really is empty.", + f" for catalogs {sorted(catalog_ids)}" if catalog_ids else '', + ) + return mapped if fetch_all else mapped[:wanted] + + +def _remember_album_lyrics(rows): + """Keep the lyrics an ``album_songs`` response already carried. + + Only a row that actually carries the field is remembered. ``lyrics: null`` is + Ampache answering "this song has none", which is worth caching; a row missing the + key entirely says nothing and must still fall back to a request. + """ + fresh = { + str(row.get('id')): row.get('lyrics') or None + for row in rows + if isinstance(row, dict) and 'lyrics' in row and row.get('id') is not None + } + if not fresh: + return + with _album_lyrics_lock: + # Dropped wholesale rather than by age: the lyrics stage reads an entry in the + # same job, right after the album is fetched, and the album just fetched + # survives because it is added after the clear. A dropped entry costs one + # fallback request, never a wrong answer. + if len(_album_lyrics) + len(fresh) > _LYRICS_CACHE_MAX: + _album_lyrics.clear() + _album_lyrics.update(fresh) + + +def _cached_album_lyrics(track_id): + with _album_lyrics_lock: + return _album_lyrics.get(str(track_id), _LYRICS_UNCACHED) + + +def get_tracks_from_album(album_id, user_creds=None): + body = _request('album_songs', {'filter': album_id}, user_creds=user_creds) + rows = (body or {}).get('song') or [] + _remember_album_lyrics(rows) + return [_map_song(s) for s in rows] + + +def _browse_total_count(body, ids): + """True when the browse reported a total that matches the rows it returned. + + A ``total_count`` that disagrees means the answer was truncated (or is not the + shape expected), and a short list is worse here than a failed request: the + dispatch loop compares this count against the number of tracks already analysed, + so a truncated album looks finished and is silently skipped. An absent + ``total_count`` is treated the same way, since there is then nothing to check + against. + """ + try: + return int(body.get('total_count')) == len(ids) + except (TypeError, ValueError): + return False + + +def _browse_album_track_ids(album_id, user_creds=None): + """Song ids for one album via ``browse``, or None when it cannot be trusted. + + ``browse`` answers through ``Catalog::get_name_array`` - id, name, prefix and + basename, nothing else - so it skips the per-song hydration (rating, userflag, art, + album name, artist names) that makes an ``album_songs`` row expensive to produce. + A caller that only needs ids and a count should not pay for the rest. + + Deliberately sends no ``offset``/``limit``: the album is asked for whole, and a + reply that does not account for every row is rejected rather than paged. Returns + None for every case a caller must not read as "this album has no tracks", so the + fallback stays responsible for that distinction. + """ + params = {'type': 'album', 'filter': album_id} + catalog_id = _cached_album_catalog(album_id) + if catalog_id: + params['catalog'] = catalog_id + body, err = _request_ex('browse', params, user_creds=user_creds) + if body is None: + logger.debug( + "Ampache browse for album %s failed (%s); falling back to album_songs.", + album_id, _err_message(err), + ) + return None + + rows = body.get('browse') + if not isinstance(rows, list) or not rows: + return None + ids = [ + str(row.get('id')) + for row in rows + if isinstance(row, dict) and row.get('id') is not None + ] + if not ids or not _browse_total_count(body, ids): + logger.debug( + "Ampache browse for album %s returned %d usable ids for a reported total " + "of %r; falling back to album_songs rather than risk a short list.", + album_id, len(ids), body.get('total_count'), + ) + return None + return ids + + +def get_album_track_ids(album_id, user_creds=None): + """Track ids for one album, without serialising each track's metadata. + + Falls back to the full ``album_songs`` fetch whenever ``browse`` cannot be + trusted - a server that will not browse without a catalogue id it never reported, + an album whose catalogue is unknown, or any answer that does not account for every + row. The fallback returns the same ids, just more expensively, so a refusal costs + speed and never correctness. + """ + ids = _browse_album_track_ids(album_id, user_creds=user_creds) + if ids is not None: + return ids + return [ + str(track.get('Id') or track.get('id')) + for track in (get_tracks_from_album(album_id, user_creds=user_creds) 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): + try: + track_id = item.get('id') or item.get('Id') + file_extension = detect_download_extension( + {**item, 'Container': item.get('suffix') or item.get('format')} + ) + local_filename = os.path.join(temp_dir, f"{track_id}{file_extension}") + + 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: + _log_error(f"Failed to download Ampache track {item.get('Name', 'Unknown')}", 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, + } + + version_warning = _api_version_warning(user_creds) + if version_warning: + warnings.append(version_warning) + + songs = [_map_song(s) for s in (body.get('song') or [])] + path_format = detect_path_format(songs) + if path_format != 'absolute': + warnings.append( + 'Ampache is returning relative paths or no paths at all. This happens when ' + 'the catalog was added with a relative path, or when the API user cannot ' + 'read the filename field. Automatic path-based matching will not work well, ' + 'so you will need to manually match most albums in Step 4.' + ) + return { + 'ok': True, + 'error': None, + 'auth_failed': False, + 'sample_count': len(songs), + 'path_format': path_format, + 'warnings': warnings, + } + + +def _playlists(user_creds=None): + body = _request('playlists', {'limit': 0}, user_creds=user_creds) + playlists = (body or {}).get('playlist') or [] + return [{**p, 'Id': str(p.get('id')), 'Name': p.get('name')} for p in playlists] + + +def get_all_playlists(): + return _playlists() + + +def get_playlist_by_name(playlist_name, user_creds=None): + for playlist in _playlists(user_creds=user_creds): + 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, user_creds=None): + return _request('playlist_delete', {'filter': playlist_id}, user_creds=user_creds) is not None + + +def create_playlist(base_name, item_ids, user_creds=None): + body = _request( + 'playlist_create', {'name': base_name, 'type': 'private'}, user_creds=user_creds + ) + 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 + + wanted = list(item_ids or []) + added = 0 + for item_id in wanted: + if _request( + 'playlist_add_song', + {'filter': playlist_id, 'song': item_id, 'check': 1}, + user_creds=user_creds, + ) is not None: + added += 1 + + if wanted and not added: + logger.error( + "AMPACHE PLAYLIST '%s' (id=%s) RECEIVED NONE OF ITS %d TRACKS - every " + "playlist_add_song call was rejected. The playlist exists on the server but " + "is EMPTY; the Ampache error is logged above.", + base_name, playlist_id, len(wanted), + ) + return None + if added < len(wanted): + logger.error( + "AMPACHE PLAYLIST '%s' (id=%s) IS INCOMPLETE: Ampache rejected %d of its %d tracks.", + base_name, playlist_id, len(wanted) - added, len(wanted), + ) + + return {'Id': str(playlist_id), 'Name': base_name} + + +def create_instant_playlist(playlist_name, item_ids, user_creds=None): + return create_playlist( + f"{playlist_name.strip()}_instant", item_ids, user_creds=context.active_creds(user_creds) + ) + + +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 and existing.get('Id') and not delete_playlist( + existing['Id'], user_creds=user_creds + ): + logger.error( + f"Ampache create_or_replace_playlist: failed to delete existing " + f"'{playlist_name}' (id={existing['Id']}); aborting to avoid creating a duplicate" + ) + return None + return create_playlist(playlist_name, item_ids, user_creds=user_creds) + + +def get_top_played_songs(limit, 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 [])] + + +def get_last_played_time(_item_id, _user_creds=None): + """Not available: Ampache reports play stats per library, not per track. + + ``stats`` can rank songs by play count but exposes no per-song "last played + at" timestamp, so there is nothing to return. The parameters are part of the + dispatcher contract and are deliberately unused (leading underscores). + Recency-weighted callers such as the Sonic Fingerprint treat ``None`` as + "unknown" and fall back to play counts. + """ + logger.debug("Ampache exposes no per-track last-played timestamp; returning None.") + return None + + +def get_lyrics(track_id: str, timeout: float = 2.5): + """Lyrics for one track, preferring what the album fetch already returned. + + The analysis path reaches every track through ``get_tracks_from_album``, whose + response carries this field already, so the common case costs no request at all. + A fetch that never happened (a caller with no album context, or an evicted entry) + still falls back to the single-song call. + """ + cached = _cached_album_lyrics(track_id) + if cached is not _LYRICS_UNCACHED: + return cached + 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 43553d56..611378a6 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): diff --git a/tasks/setup_manager.py b/tasks/setup_manager.py index 4d82dc11..c707bffb 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'} @@ -279,13 +282,21 @@ def server_required_fields(self): return config.MEDIASERVER_FIELDS_BY_TYPE + @property + def server_optional_fields(self): + import config + + return config.MEDIASERVER_OPTIONAL_FIELDS_BY_TYPE + def _is_valid_server_config(self, config_module): media_type = getattr(config_module, 'MEDIASERVER_TYPE', '').strip().lower() if media_type not in self.server_required_fields: return False + optional = set(self.server_optional_fields.get(media_type, [])) return all( self._is_valid_string(getattr(config_module, field, '')) for field in self.server_required_fields[media_type] + if field not in optional ) def _is_valid_auth_config(self, config_module): diff --git a/templates/provider_migration.html b/templates/provider_migration.html index 46dae789..4c79aa71 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 f3e58aaa..69c81dff 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/integration/test_provider_migration_integration.py b/test/integration/test_provider_migration_integration.py index c54c01b3..872eb4c8 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 diff --git a/test/unit/test_analysis.py b/test/unit/test_analysis.py index 99920064..0b421761 100644 --- a/test/unit/test_analysis.py +++ b/test/unit/test_analysis.py @@ -796,8 +796,14 @@ def _record_status(*args, **kwargs): monkeypatch.setattr(helper, 'save_task_status', _record_status) monkeypatch.setattr(analysis, 'clean_temp', lambda *args, **kwargs: None) monkeypatch.setattr(analysis, 'get_recent_albums', lambda limit: albums) + # The dispatch loop needs ids and a count, not track dicts, so it asks the + # dispatcher for ids. Mirrors the dispatcher's own fallback so the per-album track + # sets still drive the skip/enqueue decisions under test. monkeypatch.setattr( - analysis, 'get_tracks_from_album', lambda album_id: tracks_by_album[album_id] + analysis, 'get_album_track_ids', + lambda album_id: [ + str(track.get('Id') or track.get('id')) for track in tracks_by_album[album_id] + ], ) monkeypatch.setattr( analysis, 'get_failed_child_summary', lambda task_id: (0, []) diff --git a/test/unit/test_mediaserver_ampache.py b/test/unit/test_mediaserver_ampache.py new file mode 100644 index 00000000..202f6d9f --- /dev/null +++ b/test/unit/test_mediaserver_ampache.py @@ -0,0 +1,1315 @@ +# 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, the library and playlist dict shapes both the UI and the dispatcher +read, 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 +* Playlist creation returns the {'Id','Name'} dict callers dereference +* A streamed download that errors, or that carries an Ampache JSON error under + HTTP 200, yields no file +* list_libraries returns the lowercase id/name both JavaScript consumers read +* Handshake caches its token per credential SET, takes its window from the + server's session_expire and slides it forward on every accepted call rather than + re-handshaking on a timer, re-handshakes once on a genuinely lapsed session, and + never sends a real password as a plaintext API key +* The library filter is pushed into the query and still enforced locally +* _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() + ampache._header_auth.clear() + ampache._album_lyrics.clear() + ampache._album_catalogs.clear() + yield + ampache._token_cache.clear() + ampache._header_auth.clear() + ampache._album_lyrics.clear() + ampache._album_catalogs.clear() + + +@pytest.fixture +def creds(): + return {'url': 'http://ampache.test', 'user': 'amp', 'password': 'secret'} + + +@pytest.fixture +def key_creds(): + """Credentials whose secret is API-key shaped, so header auth is eligible.""" + return {'url': 'http://ampache.test', 'user': '', 'password': 'a' * 64} + + +def _ping_response(api='8.0.0', **extra): + """A bearer-authenticated ping, which carries the whole server_details payload.""" + return _json_response({ + 'session_expire': '2126-07-30T17:06:45+10:00', + 'server': api, + 'version': api, + 'api': api, + 'auth': 'a201455bfaecb00b082a5716d3dee64d', + 'username': 'user', + **extra, + }) + + +@pytest.fixture +def configured(): + from tasks.mediaserver import ampache + + with patch.object(ampache.config, 'AMPACHE_URL', 'http://ampache.test'), \ + patch.object(ampache.config, 'AMPACHE_USER', 'amp'), \ + patch.object(ampache.config, 'AMPACHE_PASSWORD', 'secret'): + yield + + +def _json_response(payload, status_ok=True, content_type='application/json'): + response = MagicMock() + response.json.return_value = payload + response.headers = {'Content-Type': content_type} + if status_ok: + response.raise_for_status.return_value = None + else: + response.raise_for_status.side_effect = Exception('404 Not Found') + return response + + +def _audio_response(chunks=(b'audio-bytes',)): + response = _json_response({}, content_type='audio/mpeg') + response.iter_content.return_value = list(chunks) + response.__enter__.return_value = response + response.__exit__.return_value = False + 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={'Id': '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 == {'Id': '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={'Id': '7'}) as created: + ampache.create_instant_playlist(' Spaced ', ['1'], user_creds=creds) + + assert created.call_args[0][0] == 'Spaced_instant' + + +class TestPlaylistReturnShape: + def test_create_playlist_returns_the_id_dict_callers_dereference(self, configured): + from tasks.mediaserver import ampache + + with patch.object(ampache, 'requests') as http: + http.get.side_effect = [ + _json_response({'auth': 'tok'}), + _json_response({'playlist': {'id': 42}}), + _json_response({'success': 'added'}), + ] + result = ampache.create_playlist('Nightly', ['1']) + + assert result == {'Id': '42', 'Name': 'Nightly'} + assert result.get('Id') == '42' + + def test_a_playlist_that_received_none_of_its_tracks_is_not_reported_created(self, configured): + from tasks.mediaserver import ampache + + with patch.object(ampache, 'requests') as http: + http.get.side_effect = [ + _json_response({'auth': 'tok'}), + _json_response({'playlist': {'id': 42}}), + _json_response({'error': {'errorCode': '4710', 'errorMessage': 'nope'}}), + _json_response({'error': {'errorCode': '4710', 'errorMessage': 'nope'}}), + ] + result = ampache.create_playlist('Nightly', ['1', '2']) + + assert result is None + + def test_a_partially_filled_playlist_is_still_returned(self, configured): + from tasks.mediaserver import ampache + + with patch.object(ampache, 'requests') as http: + http.get.side_effect = [ + _json_response({'auth': 'tok'}), + _json_response({'playlist': {'id': 42}}), + _json_response({'success': 'added'}), + _json_response({'error': {'errorCode': '4710', 'errorMessage': 'nope'}}), + ] + result = ampache.create_playlist('Nightly', ['1', '2']) + + assert result == {'Id': '42', 'Name': 'Nightly'} + + +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={'Id': '9'}) as created: + result = ampache.create_or_replace_playlist('Nightly', ['1'], creds) + + assert created.call_args[0][0] == 'Nightly' + assert result == {'Id': '9'} + + def test_playlist_writes_use_the_callers_creds_not_the_default_server(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={'Id': '9'}) as created: + ampache.create_or_replace_playlist('Nightly', ['1'], creds) + ampache.create_instant_playlist('My Mix', ['1'], user_creds=creds) + + assert [call.kwargs.get('user_creds') for call in created.call_args_list] == [creds, creds] + + 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', return_value=True) as deleted, \ + patch.object(ampache, 'create_playlist', return_value={'Id': '9'}): + ampache.create_or_replace_playlist('Nightly', ['1'], creds) + + deleted.assert_called_once_with('3', user_creds=creds) + + def test_a_failed_delete_aborts_instead_of_creating_a_duplicate_name(self, creds): + from tasks.mediaserver import ampache + + with patch.object(ampache, 'get_playlist_by_name', return_value={'Id': '3'}), \ + patch.object(ampache, 'delete_playlist', return_value=False), \ + patch.object(ampache, 'create_playlist') as created: + result = ampache.create_or_replace_playlist('Nightly', ['1'], creds) + + assert result is None + created.assert_not_called() + + def test_get_playlist_by_name_looks_the_playlist_up_with_the_callers_creds(self, creds): + from tasks.mediaserver import ampache + + with patch.object(ampache, '_request') as request: + request.return_value = {'playlist': [{'id': 5, 'name': 'Nightly'}]} + found = ampache.get_playlist_by_name('Nightly', user_creds=creds) + + assert found['Id'] == '5' + assert request.call_args.kwargs['user_creds'] == creds + + +class TestLibraryListing: + def test_list_libraries_returns_the_lowercase_id_and_name_the_ui_reads(self, creds): + from tasks.mediaserver import ampache + + with patch.object(ampache, '_request') as request: + request.return_value = {'catalog': [{'id': 2, 'name': 'Main'}]} + libraries = ampache.list_libraries(user_creds=creds) + + assert libraries == [{'id': '2', 'name': 'Main'}] + + def test_a_nameless_catalog_still_gets_a_usable_label(self, creds): + from tasks.mediaserver import ampache + + with patch.object(ampache, '_request') as request: + request.return_value = {'catalog': [{'id': 9}]} + libraries = ampache.list_libraries(user_creds=creds) + + assert libraries == [{'id': '9', 'name': 'Catalog 9'}] + + +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_json_error_served_as_http_200_is_not_written_as_audio(self, tmp_path): + from tasks.mediaserver import ampache + + handshake = _json_response({'auth': 'tok'}) + stream = _json_response({'error': {'errorCode': '4742', 'errorMessage': 'ACL'}}) + stream.iter_content.return_value = [b'{"error":{"errorCode":"4742"}}'] + 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_lapsed_session_on_a_download_rehandshakes_and_retries(self, tmp_path): + from tasks.mediaserver import ampache + + expired = _json_response({'error': {'errorCode': '4701'}}) + expired.iter_content.return_value = [b'{"error":{"errorCode":"4701"}}'] + expired.__enter__.return_value = expired + expired.__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 = [ + _json_response({'auth': 'tok'}), + expired, + _json_response({'auth': 'tok2'}), + _audio_response(), + ] + path = ampache.download_track(str(tmp_path), {'Id': '1', 'suffix': 'mp3'}) + + assert path is not None + assert (tmp_path / '1.mp3').read_bytes() == b'audio-bytes' + + def test_a_successful_stream_is_written_with_the_format_extension(self, tmp_path): + from tasks.mediaserver import ampache + + 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 = [_json_response({'auth': 'tok'}), _audio_response()] + 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' + + def test_a_track_with_no_format_falls_back_to_the_path_extension(self, tmp_path): + from tasks.mediaserver import ampache + + 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 = [_json_response({'auth': 'tok'}), _audio_response()] + path = ampache.download_track(str(tmp_path), {'Id': '1', 'Path': '/music/x.flac'}) + + assert path.endswith('1.flac') + + +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_a_changed_password_does_not_reuse_the_cached_token(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({'auth': 'tok2'}), + _json_response({'song': []}), + ] + ampache._request('songs', user_creds=creds) + ampache._request('songs', user_creds={**creds, 'password': 'rotated'}) + + assert http.get.call_count == 4 + + def test_the_cache_key_never_carries_the_password_in_the_clear(self): + from tasks.mediaserver import ampache + + key = ampache._cache_key('http://ampache.test', 'amp', 'secret') + + assert 'secret' not in key + + 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_a_session_that_stays_expired_reports_that_it_could_not_be_renewed(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({'error': {'errorCode': '4701'}}), + ] + body, err = ampache._request_ex('songs', user_creds=creds) + + assert body is None + assert err == {'kind': 'auth', 'message': 'Ampache session could not be renewed'} + + def test_an_api_key_shaped_secret_falls_back_to_the_key_handshake(self, creds): + from tasks.mediaserver import ampache + + api_key = 'a' * 64 + 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, 'password': api_key}) + + assert err is None + assert token == 'key-session' + assert http.get.call_args_list[1].kwargs['params']['auth'] == api_key + + def test_a_real_password_is_never_sent_as_a_plaintext_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'}})] + url, token, err = ampache._token(user_creds=creds) + + assert token is None + assert err['kind'] == 'auth' + assert http.get.call_count == 1 + assert http.get.call_args_list[0].kwargs['params']['auth'] != 'secret' + + def test_an_empty_username_authenticates_with_the_key_alone(self): + from tasks.mediaserver import ampache + + api_key = 'b' * 64 + with patch.object(ampache.config, 'AMPACHE_USER', ''), \ + patch.object(ampache, 'requests') as http: + http.get.side_effect = [_json_response({'auth': 'key-session'})] + url, token, err = ampache._token( + user_creds={'url': 'http://ampache.test', 'user': '', 'password': api_key} + ) + + assert err is None + assert token == 'key-session' + params = http.get.call_args_list[0].kwargs['params'] + assert params['auth'] == api_key + assert 'user' not in params + + 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' + + def test_the_callers_lyrics_budget_also_bounds_the_handshake(self, configured): + from tasks.mediaserver import ampache + + with patch.object(ampache, 'requests') as http: + http.get.side_effect = [ + _json_response({'auth': 'tok'}), + _json_response({'song': [{'lyrics': 'la'}]}), + ] + ampache.get_lyrics('1', timeout=2.5) + + assert http.get.call_args_list[0].kwargs['timeout'] == 2.5 + assert http.get.call_args_list[1].kwargs['timeout'] == 2.5 + + def test_an_ordinary_call_keeps_the_default_handshake_timeout(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': []})] + ampache._request('songs', user_creds=creds) + + assert http.get.call_args_list[0].kwargs['timeout'] == ampache._HANDSHAKE_TIMEOUT_SECONDS + + def test_a_successful_call_slides_the_session_window_forward(self, creds): + import time + + from tasks.mediaserver import ampache + + key = ampache._cache_key('http://ampache.test', 'amp', 'secret') + 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) + # Stand the cache up as it would look late in a long run, a moment + # before the window the handshake opened would have lapsed. + ampache._token_cache[key]['expires'] = time.time() + 1 + ampache._request('songs', user_creds=creds) + slid = ampache._token_cache[key]['expires'] + + handshakes = [ + call for call in http.get.call_args_list + if (call.kwargs.get('params') or {}).get('action') == 'handshake' + ] + # Ampache extends a session on every accepted call, so the second request + # must reuse the token AND push the expiry back out - not re-handshake on + # a timer while the session it holds is still valid. + assert len(handshakes) == 1 + assert slid > time.time() + 60 + + def test_the_session_window_comes_from_the_servers_session_expire(self, creds): + from datetime import datetime, timedelta, timezone + + from tasks.mediaserver import ampache + + expires_at = datetime.now(timezone.utc) + timedelta(seconds=1800) + with patch.object(ampache, 'requests') as http: + http.get.side_effect = [ + _json_response({'auth': 'tok', 'session_expire': expires_at.isoformat()}), + _json_response({'song': []}), + ] + ampache._request('songs', user_creds=creds) + + cached = ampache._token_cache[ampache._cache_key('http://ampache.test', 'amp', 'secret')] + assert 1700 < cached['lifetime'] <= 1800 + + @pytest.mark.parametrize( + 'session_expire', + [ + None, # older server, field absent + '', + 'not-a-date', # a proxy rewrote it + 0, # perpetual_api_session=true reports 0 + '1970-01-01T00:00:00+00:00', # already past, or our clock disagrees + ], + ) + def test_an_unusable_session_expire_falls_back_to_the_default_window(self, session_expire): + from tasks.mediaserver import ampache + + body = {'auth': 'tok'} + if session_expire is not None: + body['session_expire'] = session_expire + + assert ampache._session_lifetime(body) == ampache._TOKEN_TTL_SECONDS + + +def _authorization(call): + return (call.kwargs.get('headers') or {}).get('Authorization') + + +class TestHeaderAuth: + """An API key goes in an Authorization header, and Ampache opens the session. + + ApiHandler only reads the header when NO auth parameter is present, so these + tests assert the absence of `auth` from the query string as much as the + presence of the header. + """ + + def test_a_key_shaped_secret_pings_once_then_sends_a_bearer_header(self, key_creds): + from tasks.mediaserver import ampache + + with patch.object(ampache, 'requests') as http: + http.get.side_effect = [_ping_response(), _json_response({'song': []})] + body, err = ampache._request_ex('songs', user_creds=key_creds) + + assert err is None + assert body == {'song': []} + assert http.get.call_count == 2 + + ping, call = http.get.call_args_list + assert ping.kwargs['params']['action'] == 'ping' + assert _authorization(ping) == 'Bearer ' + 'a' * 64 + assert _authorization(call) == 'Bearer ' + 'a' * 64 + # The header is only honoured when the query string carries no auth at all. + assert 'auth' not in ping.kwargs['params'] + assert 'auth' not in call.kwargs['params'] + + def test_the_bearer_ping_fills_the_version_gate(self, key_creds): + from tasks.mediaserver import ampache + + with patch.object(ampache, 'requests') as http: + http.get.side_effect = [_ping_response(api='8.0.0'), _json_response({'song': []})] + ampache._request('songs', user_creds=key_creds) + + # Without this the API-8 warning would go quiet, since nothing handshaked. + assert ampache._cached_api_version(key_creds) == '8.0.0' + assert ampache._api_version_warning(key_creds) is None + + def test_an_old_server_still_warns_under_header_auth(self, key_creds): + from tasks.mediaserver import ampache + + with patch.object(ampache, 'requests') as http: + http.get.side_effect = [_ping_response(api='6.6.0'), _json_response({'song': []})] + ampache._request('songs', user_creds=key_creds) + + assert 'API 6' in (ampache._api_version_warning(key_creds) or '') + + def test_a_refused_bearer_ping_falls_back_to_the_handshake(self, key_creds): + from tasks.mediaserver import ampache + + with patch.object(ampache, 'requests') as http: + http.get.side_effect = [ + _json_response({'error': {'errorCode': '4742', 'errorMessage': 'Access Denied'}}), + _json_response({'auth': 'tok'}), + _json_response({'song': []}), + ] + body, err = ampache._request_ex('songs', user_creds=key_creds) + + assert err is None + assert body == {'song': []} + + ping, handshake, call = http.get.call_args_list + assert ping.kwargs['params']['action'] == 'ping' + assert handshake.kwargs['params']['action'] == 'handshake' + # Back on the session path: token in the query string, no header. + assert call.kwargs['params']['auth'] == 'tok' + assert _authorization(call) is None + + def test_an_unauthenticated_ping_reply_is_not_mistaken_for_acceptance(self, key_creds): + from tasks.mediaserver import ampache + + with patch.object(ampache, 'requests') as http: + # Ampache answers a ping it did not authenticate with server/version + # only - no `api`, no session. That must not read as success. + http.get.side_effect = [ + _json_response({'server': '8.0.0', 'version': '8.0.0'}), + _json_response({'auth': 'tok'}), + _json_response({'song': []}), + ] + ampache._request('songs', user_creds=key_creds) + + assert http.get.call_args_list[1].kwargs['params']['action'] == 'handshake' + assert ampache._header_auth[ampache._cache_key('http://ampache.test', '', 'a' * 64)] is False + + def test_the_bearer_ping_happens_once_per_credential_set(self, key_creds): + from tasks.mediaserver import ampache + + with patch.object(ampache, 'requests') as http: + http.get.side_effect = [ + _ping_response(), + _json_response({'song': []}), + _json_response({'song': []}), + ] + ampache._request('songs', user_creds=key_creds) + ampache._request('songs', user_creds=key_creds) + + pings = [ + call for call in http.get.call_args_list + if call.kwargs['params']['action'] == 'ping' + ] + assert len(pings) == 1 + assert http.get.call_count == 3 + + def test_a_password_never_travels_as_a_bearer_header(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': []})] + ampache._request('songs', user_creds=creds) + + # No ping, no header: a password would only be refused by findByApiKey, and + # being refused means having sent it for nothing. + assert http.get.call_count == 2 + assert http.get.call_args_list[0].kwargs['params']['action'] == 'handshake' + assert all(_authorization(call) is None for call in http.get.call_args_list) + + +class TestLibraryFilter: + def test_the_catalog_filter_is_pushed_into_the_query(self, creds): + from tasks.mediaserver import ampache + + with patch.object(ampache, '_target_catalog_ids', return_value={'2'}), \ + patch.object(ampache, '_request_ex') as request: + request.return_value = ({'song': [{'id': 1, 'catalog': 2}]}, None) + songs = ampache.get_all_songs(user_creds=creds) + + action, params = request.call_args[0][0], request.call_args[0][1] + # A plain browse with an indexed `song.catalog = N`, not an advanced_search + # building the same rows out of OR'd rules. + assert action == 'songs' + assert params['cond'] == 'catalog,2' + assert [s['Id'] for s in songs] == ['1'] + + def test_several_catalogues_are_one_browse_each(self, creds): + from tasks.mediaserver import ampache + + with patch.object(ampache, '_target_catalog_ids', return_value={'2', '3'}), \ + patch.object(ampache, '_request_ex') as request: + request.side_effect = [ + ({'song': [{'id': 1, 'catalog': 2}]}, None), + ({'song': [{'id': 2, 'catalog': 3}]}, None), + ] + songs = ampache.get_all_songs(user_creds=creds) + + assert [c[0][1]['cond'] for c in request.call_args_list] == ['catalog,2', 'catalog,3'] + assert sorted(s['Id'] for s in songs) == ['1', '2'] + + def test_an_ignored_cond_falls_back_to_one_unfiltered_walk(self, creds): + from tasks.mediaserver import ampache + + with patch.object(ampache, '_target_catalog_ids', return_value={'2', '3'}), \ + patch.object(ampache, '_request_ex') as request: + request.side_effect = [ + # Rows from outside catalogue 2 prove the cond was ignored, so the + # library must not be walked once per catalogue. + ({'song': [{'id': 1, 'catalog': 2}, {'id': 2, 'catalog': 7}]}, None), + ({'song': [{'id': 1, 'catalog': 2}, {'id': 2, 'catalog': 7}]}, None), + ] + songs = ampache.get_all_songs(user_creds=creds) + + assert request.call_count == 2 + assert 'cond' not in request.call_args_list[1][0][1] + # Filtered locally, and counted once rather than once per catalogue. + assert [s['Id'] for s in songs] == ['1'] + + def test_a_page_that_reports_no_catalogue_id_is_not_trusted(self, creds): + from tasks.mediaserver import ampache + + with patch.object(ampache, '_target_catalog_ids', return_value={'2'}), \ + patch.object(ampache, '_request_ex') as request: + request.side_effect = [ + ({'song': [{'id': 1}]}, None), + ({'song': [{'id': 1, 'catalog': 2}]}, None), + ] + ampache.get_all_songs(user_creds=creds) + + assert 'cond' not in request.call_args_list[1][0][1] + + def test_a_transient_failure_retries_the_page_instead_of_dropping_the_filter(self, creds): + from tasks.mediaserver import ampache + + with patch.object(ampache, '_target_catalog_ids', return_value={'2'}), \ + patch.object(ampache, '_request_ex') as request: + request.side_effect = [ + (None, {'kind': 'api', 'message': 'bad gateway'}), + ({'song': [{'id': 1, 'catalog': 2}]}, None), + ] + songs = ampache.get_all_songs(user_creds=creds) + + # Both calls keep the filter: one 500 must not cost a full-library walk. + assert [c[0][1].get('cond') for c in request.call_args_list] == ['catalog,2', 'catalog,2'] + assert [s['Id'] for s in songs] == ['1'] + + def test_a_page_that_fails_twice_reports_an_incomplete_catalogue(self, creds, caplog): + from tasks.mediaserver import ampache + + with patch.object(ampache, '_target_catalog_ids', return_value={'2'}), \ + patch.object(ampache, '_request_ex') as request: + request.side_effect = [ + (None, {'kind': 'api', 'message': 'gone'}), + (None, {'kind': 'api', 'message': 'gone'}), + ] + with caplog.at_level('ERROR'): + songs = ampache.get_all_songs(user_creds=creds) + + assert songs == [] + assert 'INCOMPLETE' in caplog.text + + def test_the_page_size_is_configurable(self, creds): + from tasks.mediaserver import ampache + + with patch.object(ampache, '_target_catalog_ids', return_value=None), \ + patch.object(ampache.config, 'AMPACHE_PAGE_SIZE', 25, create=True), \ + patch.object(ampache, '_request_ex') as request: + request.return_value = ({'song': []}, None) + ampache.get_all_songs(user_creds=creds) + + assert request.call_args[0][1]['limit'] == 25 + + def test_a_catalogue_row_carries_only_the_keys_consumers_read(self, creds): + from tasks.mediaserver import ampache + + with patch.object(ampache, '_target_catalog_ids', return_value=None), \ + patch.object(ampache, '_request_ex') as request: + request.side_effect = [ + ({'song': [{'id': 1, 'title': 'S', 'art': 'http://art', 'r128_track_gain': -7}]}, None), + ({'song': []}, None), + ] + songs = ampache.get_all_songs(user_creds=creds) + + assert set(songs[0]) == set(ampache._CATALOGUE_KEYS) + + def test_recent_albums_keeps_paging_until_the_filter_yields_enough(self): + from tasks.mediaserver import ampache + + first = [{'id': i, 'name': f'A{i}', 'catalog': 7} for i in range(500)] + second = [{'id': 500, 'name': 'Wanted', 'catalog': 2}] + with patch.object(ampache, '_target_catalog_ids', return_value={'2'}), \ + patch.object(ampache, '_request_ex') as request: + request.side_effect = [({'album': first}, None), ({'album': second}, None)] + albums = ampache.get_recent_albums(1) + + assert [a['Id'] for a in albums] == ['500'] + assert request.call_args_list[1][0][1]['offset'] == 500 + + def test_recent_albums_with_no_filter_asks_the_server_for_exactly_the_limit(self): + from tasks.mediaserver import ampache + + with patch.object(ampache, '_target_catalog_ids', return_value=None), \ + patch.object(ampache, '_request_ex') as request: + request.return_value = ( + {'album': [{'id': 1, 'name': 'A'}, {'id': 2, 'name': 'B'}]}, None + ) + albums = ampache.get_recent_albums(2) + + assert request.call_args_list[0][0][0] == 'albums' + assert request.call_args_list[0][0][1]['limit'] == 2 + assert [a['Id'] for a in albums] == ['1', '2'] + + def test_a_filter_matching_no_catalog_returns_no_albums(self): + from tasks.mediaserver import ampache + + with patch.object(ampache, '_target_catalog_ids', return_value=set()), \ + patch.object(ampache, '_request_ex') as request: + albums = ampache.get_recent_albums(10) + + assert albums == [] + request.assert_not_called() + + def test_a_library_filter_is_pushed_into_the_album_browse(self): + """Ampache album objects carry no catalog id, so the filter must be server-side.""" + from tasks.mediaserver import ampache + + with patch.object(ampache, '_target_catalog_ids', return_value={'2'}), \ + patch.object(ampache, '_request_ex') as request: + request.return_value = ( + {'album': [{'id': 9, 'name': 'Filtered', 'catalog': '2'}]}, None + ) + albums = ampache.get_recent_albums(1) + + action, params = request.call_args_list[0][0][0], request.call_args_list[0][0][1] + assert action == 'albums' + assert params['cond'] == 'catalog,2' + assert [a['Id'] for a in albums] == ['9'] + + def test_several_catalogues_are_browsed_separately_and_merged_without_duplicates(self): + """`cond` conditions combine, so one browse per catalogue is the portable form.""" + from tasks.mediaserver import ampache + + with patch.object(ampache, '_target_catalog_ids', return_value={'2', '3'}), \ + patch.object(ampache, '_request_ex') as request: + request.side_effect = [ + ({'album': [{'id': 1, 'name': 'From 2', 'catalog': '2'}]}, None), + ({'album': [ + {'id': 1, 'name': 'From 2', 'catalog': '2'}, + {'id': 5, 'name': 'From 3', 'catalog': '3'}, + ]}, None), + ] + albums = ampache.get_recent_albums(0) + + assert [c[0][1]['cond'] for c in request.call_args_list] == ['catalog,2', 'catalog,3'] + assert [a['Id'] for a in albums] == ['1', '5'] + + def test_an_ignored_cond_cannot_leak_albums_from_other_catalogues(self): + """Ampache ignores an unknown `cond` and returns everything, so rows are re-checked.""" + from tasks.mediaserver import ampache + + with patch.object(ampache, '_target_catalog_ids', return_value={'32'}), \ + patch.object(ampache, '_request_ex') as request: + request.return_value = ( + {'album': [ + {'id': 1, 'name': 'Wanted', 'catalog': '32'}, + {'id': 2, 'name': 'Other catalogue', 'catalog': '2'}, + ]}, + None, + ) + albums = ampache.get_recent_albums(0) + + assert [a['Id'] for a in albums] == ['1'] + + def test_albums_with_no_catalogue_ids_stop_instead_of_analysing_everything(self, caplog): + """A server too old to report catalogue ids must not be filtered by guesswork.""" + from tasks.mediaserver import ampache + + with patch.object(ampache, '_target_catalog_ids', return_value={'2'}), \ + patch.object(ampache, '_request_ex') as request: + request.return_value = ({'album': [{'id': 1, 'name': 'No catalog key'}]}, None) + albums = ampache.get_recent_albums(1) + + assert albums == [] + assert 'AMPACHE REPORTED NO CATALOGUE IDS' in caplog.text + + def test_fetching_every_album_pages_instead_of_asking_for_limit_zero(self): + from tasks.mediaserver import ampache + + first = [{'id': i, 'name': f'A{i}'} for i in range(ampache._PAGE_SIZE)] + second = [{'id': 9001, 'name': 'Last'}] + with patch.object(ampache, '_target_catalog_ids', return_value=None), \ + patch.object(ampache, '_request_ex') as request: + request.side_effect = [({'album': first}, None), ({'album': second}, None)] + albums = ampache.get_recent_albums(0) + + assert request.call_args_list[0][0][1]['limit'] == ampache._PAGE_SIZE + assert request.call_args_list[1][0][1]['offset'] == ampache._PAGE_SIZE + assert len(albums) == ampache._PAGE_SIZE + 1 + + def test_a_failed_album_page_is_reported_not_treated_as_an_empty_library(self, caplog): + from tasks.mediaserver import ampache + + with patch.object(ampache, '_target_catalog_ids', return_value=None), \ + patch.object(ampache, '_request_ex') as request: + request.return_value = (None, {'kind': 'api', 'message': 'boom'}) + albums = ampache.get_recent_albums(5) + + assert albums == [] + assert 'AMPACHE ALBUM FETCH FAILED' in caplog.text + + +class TestApiVersionGate: + """This backend targets Ampache API 8; older servers must be told, not guessed at.""" + + @pytest.mark.parametrize( + ('reported', 'expected'), + [('8.0.0', 8), ('6.6.6', 6), ('600000', 6), ('400001', 4), ('8', 8), ('', None), + ('nonsense', None)], + ) + def test_api_major_reads_both_version_forms(self, reported, expected): + from tasks.mediaserver import ampache + + assert ampache._api_major(reported) == expected + + def test_test_connection_warns_when_the_server_predates_api_8(self, configured): + from tasks.mediaserver import ampache + + with patch.object(ampache, 'requests') as http: + http.get.side_effect = [ + _json_response({'auth': 'tok', 'api': '6.6.6'}), + _json_response({'song': [{'id': 1, 'title': 'S', 'filename': '/music/a.mp3'}]}), + ] + result = ampache.test_connection() + + assert result['ok'] is True + assert any('API 8 or newer' in w for w in result['warnings']) + + def test_test_connection_is_quiet_on_api_8(self, configured): + from tasks.mediaserver import ampache + + with patch.object(ampache, 'requests') as http: + http.get.side_effect = [ + _json_response({'auth': 'tok', 'api': '8.0.0'}), + _json_response({'song': [{'id': 1, 'title': 'S', 'filename': '/music/a.mp3'}]}), + ] + result = ampache.test_connection() + + assert result['warnings'] == [] + + +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 TestConnectionTest: + def test_relative_paths_are_reported_as_a_warning_not_a_silent_pass(self, creds): + from tasks.mediaserver import ampache + + with patch.object(ampache, '_request_ex') as request: + request.return_value = ({'song': [{'id': 1, 'filename': 'music/song.mp3'}]}, None) + result = ampache.test_connection(user_creds=creds) + + assert result['ok'] is True + assert result['path_format'] == 'relative' + assert result['warnings'] + + def test_absolute_paths_produce_no_warning(self, creds): + from tasks.mediaserver import ampache + + with patch.object(ampache, '_request_ex') as request: + request.return_value = ({'song': [{'id': 1, 'filename': '/music/song.mp3'}]}, None) + result = ampache.test_connection(user_creds=creds) + + assert result['path_format'] == 'absolute' + assert result['warnings'] == [] + + +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 TestLyricsFromTheAlbumFetch: + """album_songs already carries lyrics, so the lyrics stage need not refetch. + + Ampache serialises `song` and `album_songs` through the same + Json8_Data::songs_array, so the per-track `song` call the lyrics stage used to make + asked for a field the album fetch had already returned - and repeated all of that + row's hydration to get it. + """ + + def _album_body(self, songs): + return _json_response({'song': songs}) + + def test_the_album_fetch_serves_the_lyrics_stage_with_no_further_request(self, configured): + from tasks.mediaserver import ampache + + with patch.object(ampache, 'requests') as http: + http.get.side_effect = [ + _json_response({'auth': 'tok'}), + self._album_body([{'id': '1368676', 'title': 'Feel Small', 'lyrics': 'la la'}]), + ] + ampache.get_tracks_from_album('214980') + calls_after_album = http.get.call_count + + assert ampache.get_lyrics('1368676') == 'la la' + + assert http.get.call_count == calls_after_album + + def test_a_null_lyrics_field_is_an_answer_and_costs_no_request(self, configured): + from tasks.mediaserver import ampache + + with patch.object(ampache, 'requests') as http: + http.get.side_effect = [ + _json_response({'auth': 'tok'}), + self._album_body([{'id': '1368676', 'lyrics': None}]), + ] + ampache.get_tracks_from_album('214980') + calls_after_album = http.get.call_count + + assert ampache.get_lyrics('1368676') is None + + assert http.get.call_count == calls_after_album + + def test_a_row_that_omits_the_field_entirely_still_falls_back_to_the_song_call( + self, configured + ): + """A missing key says nothing about the track, unlike an explicit null.""" + from tasks.mediaserver import ampache + + with patch.object(ampache, 'requests') as http: + http.get.side_effect = [ + _json_response({'auth': 'tok'}), + self._album_body([{'id': '1368676', 'title': 'Feel Small'}]), + _json_response({'song': [{'id': '1368676', 'lyrics': 'fetched'}]}), + ] + ampache.get_tracks_from_album('214980') + + assert ampache.get_lyrics('1368676') == 'fetched' + + assert http.get.call_args_list[-1].kwargs['params']['action'] == 'song' + + def test_a_track_no_album_fetch_ever_covered_still_falls_back(self, configured): + from tasks.mediaserver import ampache + + with patch.object(ampache, 'requests') as http: + http.get.side_effect = [ + _json_response({'auth': 'tok'}), + _json_response({'song': [{'id': '99', 'lyrics': 'fetched'}]}), + ] + + assert ampache.get_lyrics('99') == 'fetched' + + assert http.get.call_args_list[-1].kwargs['params']['action'] == 'song' + + def test_an_integer_row_id_is_cached_under_the_string_key_callers_use(self, configured): + from tasks.mediaserver import ampache + + with patch.object(ampache, 'requests') as http: + http.get.side_effect = [ + _json_response({'auth': 'tok'}), + self._album_body([{'id': 1368676, 'lyrics': 'la'}]), + ] + ampache.get_tracks_from_album('214980') + + assert ampache.get_lyrics(1368676) == 'la' + assert ampache.get_lyrics('1368676') == 'la' + + def test_the_cache_is_bounded_but_always_keeps_the_album_just_fetched(self, configured): + """Eviction must not drop the rows the caller is about to read.""" + from tasks.mediaserver import ampache + + ampache._album_lyrics.update( + {str(n): 'old' for n in range(ampache._LYRICS_CACHE_MAX)} + ) + with patch.object(ampache, 'requests') as http: + http.get.side_effect = [ + _json_response({'auth': 'tok'}), + self._album_body([{'id': 'fresh', 'lyrics': 'new'}]), + ] + ampache.get_tracks_from_album('214980') + calls_after_album = http.get.call_count + + assert len(ampache._album_lyrics) <= ampache._LYRICS_CACHE_MAX + assert ampache.get_lyrics('fresh') == 'new' + + assert http.get.call_count == calls_after_album + + def test_an_album_fetch_that_fails_caches_nothing(self, configured): + from tasks.mediaserver import ampache + + with patch.object(ampache, 'requests') as http: + http.get.side_effect = [ + _json_response({'auth': 'tok'}), + _json_response({'error': {'errorCode': '4704', 'errorMessage': 'no album'}}), + ] + ampache.get_tracks_from_album('214980') + + assert ampache._album_lyrics == {} + + +class TestAlbumTrackIds: + """The dispatch loop needs ids and a count, so it does not pay for metadata. + + `browse` answers through Catalog::get_name_array (id/name/prefix/basename), which + skips the per-song hydration an `album_songs` row costs. Every case that cannot be + trusted falls back to `album_songs`, because a SHORT list is worse than a slow one: + the dispatcher compares the count against the tracks already analysed, so a + truncated album looks finished and gets skipped. + """ + + def _browse(self, ids, total=None): + rows = [{'id': i, 'name': f'Track {i}'} for i in ids] + return _json_response({ + 'total_count': len(ids) if total is None else total, + 'parent_type': 'album', + 'child_type': 'song', + 'browse': rows, + }) + + def test_browse_returns_the_ids_without_an_album_songs_call(self, configured): + from tasks.mediaserver import ampache + + with patch.object(ampache, 'requests') as http: + http.get.side_effect = [ + _json_response({'auth': 'tok'}), + self._browse(['1121487', '1121488', '1121489', '1121490']), + ] + ids = ampache.get_album_track_ids('181398') + + assert ids == ['1121487', '1121488', '1121489', '1121490'] + actions = [c.kwargs['params']['action'] for c in http.get.call_args_list] + assert 'album_songs' not in actions + assert actions[-1] == 'browse' + + def test_the_browse_asks_for_the_album_whole_with_no_offset_or_limit(self, configured): + """The reply is validated against total_count instead of being paged.""" + from tasks.mediaserver import ampache + + with patch.object(ampache, 'requests') as http: + http.get.side_effect = [_json_response({'auth': 'tok'}), self._browse(['1', '2'])] + ampache.get_album_track_ids('181398') + + params = http.get.call_args_list[-1].kwargs['params'] + assert params['type'] == 'album' + assert params['filter'] == '181398' + assert 'offset' not in params + assert 'limit' not in params + + def test_the_catalogue_id_album_discovery_reported_is_sent(self, configured): + """Stock Ampache 8 refuses a sub-type browse without it.""" + from tasks.mediaserver import ampache + + ampache._remember_album_catalogs([{'id': '181398', 'catalog': '2'}]) + with patch.object(ampache, 'requests') as http: + http.get.side_effect = [_json_response({'auth': 'tok'}), self._browse(['1'])] + ampache.get_album_track_ids('181398') + + assert http.get.call_args_list[-1].kwargs['params']['catalog'] == '2' + + def test_an_unknown_catalogue_omits_the_parameter_rather_than_sending_a_blank( + self, configured + ): + from tasks.mediaserver import ampache + + with patch.object(ampache, 'requests') as http: + http.get.side_effect = [_json_response({'auth': 'tok'}), self._browse(['1'])] + ampache.get_album_track_ids('181398') + + assert 'catalog' not in http.get.call_args_list[-1].kwargs['params'] + + def test_a_total_count_that_disagrees_falls_back_instead_of_returning_short( + self, configured + ): + """The silent-skip guard: 2 rows for a reported 7 must not be believed.""" + from tasks.mediaserver import ampache + + with patch.object(ampache, 'requests') as http: + http.get.side_effect = [ + _json_response({'auth': 'tok'}), + self._browse(['1', '2'], total=7), + _json_response({'song': [{'id': str(n)} for n in range(1, 8)]}), + ] + ids = ampache.get_album_track_ids('181398') + + assert ids == [str(n) for n in range(1, 8)] + assert http.get.call_args_list[-1].kwargs['params']['action'] == 'album_songs' + + def test_a_missing_total_count_is_not_trusted_either(self, configured): + from tasks.mediaserver import ampache + + with patch.object(ampache, 'requests') as http: + http.get.side_effect = [ + _json_response({'auth': 'tok'}), + _json_response({'browse': [{'id': '1'}, {'id': '2'}]}), + _json_response({'song': [{'id': '1'}, {'id': '2'}]}), + ] + ids = ampache.get_album_track_ids('181398') + + assert ids == ['1', '2'] + assert http.get.call_args_list[-1].kwargs['params']['action'] == 'album_songs' + + def test_a_refused_browse_falls_back_to_album_songs(self, configured): + """A server that will not browse without a catalogue it never reported.""" + from tasks.mediaserver import ampache + + with patch.object(ampache, 'requests') as http: + http.get.side_effect = [ + _json_response({'auth': 'tok'}), + _json_response({'error': {'errorCode': '4710', 'errorMessage': 'Bad Request: catalog'}}), + _json_response({'song': [{'id': '55'}]}), + ] + ids = ampache.get_album_track_ids('181398') + + assert ids == ['55'] + assert http.get.call_args_list[-1].kwargs['params']['action'] == 'album_songs' + + def test_an_album_with_no_songs_reports_an_empty_list(self, configured): + from tasks.mediaserver import ampache + + with patch.object(ampache, 'requests') as http: + http.get.side_effect = [ + _json_response({'auth': 'tok'}), + _json_response({}), + _json_response({'song': []}), + ] + + assert ampache.get_album_track_ids('181398') == [] + + def test_album_discovery_records_the_catalogue_of_every_album_it_keeps(self): + from tasks.mediaserver import ampache + + ampache._remember_album_catalogs([ + {'id': 1, 'catalog': 2}, + {'id': '3', 'catalog': '4'}, + {'id': '5'}, + ]) + + assert ampache._cached_album_catalog('1') == '2' + assert ampache._cached_album_catalog(3) == '4' + assert ampache._cached_album_catalog('5') 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 + + +class TestApiKeyOnlyInstall: + def test_an_api_key_only_ampache_server_is_accepted_by_the_registry(self): + from database import missing_required_creds + + assert missing_required_creds( + 'ampache', {'url': 'http://amp', 'user': '', 'password': 'k' * 64} + ) == [] + + def test_a_missing_url_or_password_is_still_refused(self): + from database import missing_required_creds + + assert missing_required_creds('ampache', {'url': '', 'user': 'amp', 'password': ''}) == [ + 'url', + 'password', + ] diff --git a/test/unit/test_provider_registration_contract.py b/test/unit/test_provider_registration_contract.py new file mode 100644 index 00000000..6dfae5d3 --- /dev/null +++ b/test/unit/test_provider_registration_contract.py @@ -0,0 +1,331 @@ +# 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 +from unittest.mock import MagicMock, patch + +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) + + +# Optional provider capabilities: the dispatcher resolves these with getattr and +# supplies its own fallback, so a backend is NOT required to implement one. Pinning +# them in DISPATCHER_CALLS would force five backends to carry a wrapper they do not +# need; this instead checks that the ones which DO implement it match the call, and +# that the fallback covers the ones which do not. +OPTIONAL_DISPATCHER_CALLS = ( + ('get_album_track_ids', 1, ('user_creds',)), +) + + +@pytest.mark.parametrize('provider', PROVIDERS) +def test_optional_capabilities_bind_when_a_backend_implements_them(provider): + backend = import_module('tasks.mediaserver.' + provider) + for attribute, positional, keywords in OPTIONAL_DISPATCHER_CALLS: + function = getattr(backend, attribute, None) + if function is None: + continue + inspect.signature(function).bind(*range(positional), **{k: None for k in keywords}) + + +@pytest.mark.parametrize('provider', PROVIDERS) +def test_album_track_ids_falls_back_to_the_full_fetch_for_every_backend(provider): + """A backend without the lighter call must still answer with ids, not crash.""" + from tasks import mediaserver + + backend = import_module('tasks.mediaserver.' + provider) + tracks = [{'Id': '11'}, {'id': 22}] + with patch.object(mediaserver, '_provider', return_value=backend), \ + patch.object(backend, 'get_tracks_from_album', return_value=tracks, create=True), \ + patch.object(backend, 'get_album_track_ids', create=True, new=None): + assert mediaserver.get_album_track_ids('album-1') == ['11', '22'] + + +def _http_json(payload): + response = MagicMock() + response.json.return_value = payload + response.raise_for_status.return_value = None + return response + + +# What each backend has to be fed for list_libraries to reach its return, and the +# one library both JavaScript consumers must then be able to render. +LIST_LIBRARIES_FIXTURES = { + 'jellyfin': ( + 'requests', + lambda http: setattr( + http, 'get', MagicMock(return_value=_http_json( + [{'ItemId': '2', 'Name': 'Main', 'CollectionType': 'music'}] + )) + ), + ), + 'emby': ( + 'requests', + lambda http: setattr( + http, 'get', MagicMock(return_value=_http_json( + [{'ItemId': '2', 'Name': 'Main', 'CollectionType': 'music'}] + )) + ), + ), + 'plex': ( + 'requests', + lambda http: setattr( + http, 'get', MagicMock(return_value=_http_json( + {'MediaContainer': {'Directory': [ + {'key': '2', 'title': 'Main', 'type': 'artist'} + ]}} + )) + ), + ), + 'navidrome': ( + '_navidrome_request', + lambda stub: stub.configure_mock( + return_value={'musicFolders': {'musicFolder': [{'id': '2', 'name': 'Main'}]}} + ), + ), + 'lyrion': ( + '_jsonrpc_request', + lambda stub: stub.configure_mock( + return_value={'folder_loop': [{'id': '2', 'filename': 'Main'}]} + ), + ), + 'ampache': ( + '_request', + lambda stub: stub.configure_mock(return_value={'catalog': [{'id': 2, 'name': 'Main'}]}), + ), +} + + +@pytest.mark.parametrize('provider', PROVIDERS) +def test_every_backend_lists_libraries_with_the_lowercase_keys_the_ui_reads(provider): + backend = import_module('tasks.mediaserver.' + provider) + attribute, prime = LIST_LIBRARIES_FIXTURES[provider] + + with patch.object(backend, attribute) as stub: + prime(stub) + libraries = backend.list_libraries() + + assert libraries, f'{provider} list_libraries returned nothing for a music library' + for library in libraries: + assert set(library) == {'id', 'name'}, ( + f"{provider} list_libraries returns {sorted(library)}; static/setup.js and " + f"static/music_servers_admin.js both read lowercase 'id'/'name', so any " + f"other shape renders an empty or '[object Object]' library picker" + ) + assert library['name'], f'{provider} list_libraries returned a nameless library' + + +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'