Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
767cca7
Add mediaserver\ampache
lachlan-00 Jul 27, 2026
87cd639
Update test_provider_migration_integration.py
lachlan-00 Jul 27, 2026
e1a5d96
Merge branch 'main' into AmpacheConnector
NeptuneHub Jul 29, 2026
29bd88b
Complete Ampache support: fix dispatcher contract bugs, register it i…
NeptuneHub Jul 29, 2026
e6013fd
Fix 13 Ampache backend defects: library-picker shape, API-key-only au…
NeptuneHub Jul 29, 2026
c44c0f8
SonarCloud fix
NeptuneHub Jul 29, 2026
64cc792
Merge branch 'main' into AmpacheConnector
NeptuneHub Jul 29, 2026
cd855c3
issues, docs and tests
lachlan-00 Jul 29, 2026
199266a
gate on API8+. Albums carry catalogs now
lachlan-00 Jul 29, 2026
e928514
Read tracks from a mounted library instead of downloading them
lachlan-00 Jul 30, 2026
bb3a411
Merge branch 'LocalFileAccess' into Ampache+localfile
lachlan-00 Jul 30, 2026
8941afe
stop handshaking all the time
lachlan-00 Jul 30, 2026
aa29c44
Merge branch 'AmpacheConnector' into Ampache+localfile
lachlan-00 Jul 30, 2026
c527433
use APIkey bearer token if possible first
lachlan-00 Jul 30, 2026
c97a9af
Merge branch 'AmpacheConnector' into Ampache+localfile
lachlan-00 Jul 30, 2026
618c2ee
allow larger page sizes
lachlan-00 Jul 30, 2026
66a25bc
Merge branch 'AmpacheConnector' into Ampache+localfile
lachlan-00 Jul 30, 2026
eee7d41
reduce lyrics calls using the album_songs call
lachlan-00 Jul 30, 2026
6744507
Merge branch 'AmpacheConnector' into Ampache+localfile
lachlan-00 Jul 30, 2026
9f9fc0d
simplify id lookup with browse
lachlan-00 Jul 30, 2026
4b51147
simplify id lookup with browse
lachlan-00 Jul 30, 2026
81ab481
Merge branch 'Ampache+localfile' into AmpacheConnector
lachlan-00 Jul 30, 2026
576d222
Revert "Read tracks from a mounted library instead of downloading them"
lachlan-00 Jul 30, 2026
3621258
sonarcube issues
lachlan-00 Jul 30, 2026
9043273
tests codestyle
lachlan-00 Jul 30, 2026
a217afb
test scripts (will remove)
lachlan-00 Jul 30, 2026
f27149b
Revert "test scripts (will remove)"
lachlan-00 Jul 30, 2026
bab0d88
Merge branch 'main' into AmpacheConnector
NeptuneHub Aug 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:**
Expand Down
2 changes: 1 addition & 1 deletion app_music_servers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
41 changes: 12 additions & 29 deletions app_provider_migration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'})


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions app_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
28 changes: 28 additions & 0 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", "")
Expand All @@ -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 = {
Expand All @@ -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
Expand Down Expand Up @@ -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',
Expand Down
8 changes: 4 additions & 4 deletions database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
123 changes: 123 additions & 0 deletions docs/AMPACHE.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 4 additions & 3 deletions docs/MULTI_SERVER.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion docs/PARAMETERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)* |
Expand All @@ -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)* |
Expand Down
Loading
Loading