Skip to content

Commit 2036da9

Browse files
author
Your Name
committed
feat(subsonic): MD5 token auth via API keys; update changelog and readme for v1.5.5
1 parent c09da7a commit 2036da9

File tree

4 files changed

+64
-14
lines changed

4 files changed

+64
-14
lines changed

CHANGELOG.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12-
- **OpenSubsonic / Subsonic API**: Native client support for Symfonium, DSub, Ultrasonic, Finamp, and any other Subsonic-compatible app
12+
- **OpenSubsonic / Subsonic API**: Native client support for Amperfy, Symfonium, DSub, Ultrasonic, Finamp, and any other Subsonic-compatible app
1313
- Full Subsonic REST API v1.16.1 compatibility, with OpenSubsonic extensions declared
14+
- **MD5 token auth** — standard Subsonic auth now supported; enter your Kima API token as the password in your client app; the server verifies `md5(token + salt)` against stored API keys, avoiding any need to store plaintext login passwords
1415
- **OpenSubsonic `apiKey` auth** — generate per-client tokens in Settings > Native Apps; tokens can be named and revoked individually
1516
- **Endpoints implemented**: `ping`, `getArtists`, `getIndexes`, `getArtist`, `getAlbum`, `getSong`, `getAlbumList2`, `getAlbumList`, `getGenres`, `search3`, `search2`, `getRandomSongs`, `stream`, `download`, `getCoverArt`, `scrobble`, `getPlaylists`, `getPlaylist`, `createPlaylist`, `updatePlaylist`, `deletePlaylist`, `getUser`, `getStarred`, `getStarred2`, `star`, `unstar`, `getArtistInfo2`
1617
- **Enrichment-aware genres** — genre fields on albums, songs, and search results are sourced from Last.fm-enriched artist tags rather than static file tags; `getGenres` aggregates across the enriched artist catalogue
1718
- **Enrichment-aware biographies**`getArtistInfo2` returns the user-edited summary when present, otherwise the Last.fm biography
1819
- **HTTP 206 range support** on `stream.view` for seek-capable clients and Firefox/Safari
1920
- Scrobbles recorded as `SUBSONIC` listen source
2021
- DISCOVER-location albums are excluded from all library views
21-
- MD5 token auth intentionally rejected (error 41) — OpenSubsonic `apiKey` is the preferred auth method
22-
- **Named API tokens** — Settings > Native Apps token generator now accepts a client name (e.g., "Symfonium", "DSub"); previously all tokens were named "Subsonic"
22+
- **Named API tokens** — Settings > Native Apps token generator now accepts a client name (e.g., "Amperfy", "Symfonium"); previously all tokens were named "Subsonic"
23+
- **Public server URL setting**admins can pin a persistent server URL in Settings > Storage; the Native Apps panel reads this URL and falls back to the browser origin when unset
2324

2425
### Fixed
2526

27+
- **Subsonic `contentType` and `suffix` wrong for FLAC/MP3**: The library scanner stores codec names (`FLAC`, `MPEG 1 Layer 3`) rather than MIME types. Added `normalizeMime()` to translate codec names to proper MIME types before surfacing them to clients — fixes clients that refused to play tracks due to unrecognised content types
28+
- **`createPlaylist` returned empty response**: Per OpenSubsonic spec (since 1.14.0), `createPlaylist` must return the full playlist object. Now returns the same shape as `getPlaylist`
29+
- **DISCOVER albums leaking into search and random**: `getRandomSongs` raw SQL and the `search3`/`search2` shared service had no location filter, allowing DISCOVER-only albums to appear in results. Both are now filtered to `LIBRARY` location only
30+
- **PWA icons**: Replaced placeholder icons with the Kima brand — amber diagonal gradient with radial bloom; solid black background for maskable variants; `apple-touch-icon` added; MediaSession fallback artwork wired up
2631
- **Frontend lint errors** (pre-existing): `let sectionIndex` changed to `const` in three pages; `setPreviewLoadState` moved inside the async function to avoid calling setState synchronously in a `useEffect`
32+
- **Vibe orphaned-completed tracks**: Tracks where `vibeAnalysisStatus = 'completed'` but no embedding row exists (left over from the `reduce_embedding_dimension` migration) are now detected and reset each enrichment cycle so they re-enter the CLAP queue
2733

2834
## [1.5.4] - 2026-02-21
2935

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ Import playlists from Spotify and Deezer, or browse and discover new music direc
147147
### Native Apps
148148
149149
- **OpenSubsonic API** - Use any Subsonic-compatible client (Symfonium, DSub, Ultrasonic, Finamp, etc.) to stream your Kima library
150+
- **Standard Subsonic auth** - MD5 token auth supported; enter your API token as the password — works with Amperfy, Symfonium, DSub, and any standard Subsonic client
150151
- **Per-client tokens** - Generate named API tokens in Settings > Native Apps; revoke them individually when a device is lost or replaced
151152
- **Enrichment-aware** - Genres and artist biographies exposed to clients come from Last.fm enrichment, not just file tags
152153
@@ -654,22 +655,22 @@ You can also configure Soulseek as a download source for playlist imports. In Se
654655
655656
Kima implements the [OpenSubsonic](https://opensubsonic.netlify.app/) REST API, making it compatible with any Subsonic client.
656657
657-
**Tested clients:** Symfonium, DSub, Ultrasonic, Finamp
658+
**Tested clients:** Amperfy (iOS), Symfonium, DSub, Ultrasonic, Finamp
658659
659660
**Setup:**
660661
661662
1. Go to Settings > Native Apps in Kima
662-
2. Enter a client name (e.g. "Symfonium on Pixel 9") and click **Generate Token**
663+
2. Enter a client name (e.g. "Amperfy on iPhone") and click **Generate Token**
663664
3. Copy and save the token — it is only shown once
664665
4. In your client app, configure:
665666
- **Server URL** — your Kima server address (e.g. `http://192.168.1.10:3030`)
666667
- **Username** — your Kima username
667-
- **Password / API key** — the token you just generated
668+
- **Password** — the token you just generated
668669
669670
**Notes:**
670671
672+
- Standard MD5 token auth is supported — clients that hash their password automatically will work correctly when you enter an API token as the password
671673
- Each client should have its own token so you can revoke access per device
672-
- MD5 token authentication is intentionally rejected; use the plaintext `p=` or OpenSubsonic `apiKey` parameter
673674
- Genres and biographies surfaced to clients come from Last.fm enrichment, not just file tags
674675
- DISCOVER-location albums are excluded from all library views
675676

backend/src/middleware/subsonicAuth.ts

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Request, Response, NextFunction } from "express";
22
import bcrypt from "bcrypt";
3+
import crypto from "crypto";
34
import { prisma } from "../utils/db";
45
import { subsonicError, SubsonicError } from "../utils/subsonicResponse";
56

@@ -18,13 +19,55 @@ export async function subsonicAuth(
1819
return;
1920
}
2021

21-
// Reject MD5 token auth — cryptographically insecure, not supported
22-
if (tokenMd5) {
23-
subsonicError(req, res, SubsonicError.TOKEN_AUTH_NOT_SUPPORTED, "Token-based auth is not supported. Use apiKey (OpenSubsonic) instead.");
24-
return;
25-
}
26-
2722
try {
23+
// MD5 token auth — verify against the user's API keys.
24+
// Standard Subsonic clients send t=md5(password+salt)&s=salt. Since Kima
25+
// stores bcrypt hashes it cannot verify against the login password, so the
26+
// user enters an API key as the "password" in their client. The server
27+
// computes md5(apiKey+salt) for each of the user's keys and checks for a match.
28+
if (tokenMd5) {
29+
const salt = req.query.s as string | undefined;
30+
if (!salt) {
31+
subsonicError(req, res, SubsonicError.MISSING_PARAM, "Required parameter is missing: s");
32+
return;
33+
}
34+
35+
const user = await prisma.user.findUnique({
36+
where: { username },
37+
select: { id: true, username: true, role: true },
38+
});
39+
40+
if (!user) {
41+
subsonicError(req, res, SubsonicError.WRONG_CREDENTIALS, "Wrong username or password");
42+
return;
43+
}
44+
45+
const apiKeys = await prisma.apiKey.findMany({
46+
where: { userId: user.id },
47+
select: { id: true, key: true },
48+
});
49+
50+
let matchedKeyId: string | null = null;
51+
for (const k of apiKeys) {
52+
const expected = crypto.createHash("md5").update(k.key + salt).digest("hex");
53+
if (expected === tokenMd5) {
54+
matchedKeyId = k.id;
55+
break;
56+
}
57+
}
58+
59+
if (!matchedKeyId) {
60+
subsonicError(req, res, SubsonicError.WRONG_CREDENTIALS, "Wrong username or password");
61+
return;
62+
}
63+
64+
prisma.apiKey.update({ where: { id: matchedKeyId }, data: { lastUsed: new Date() } }).catch(() => {});
65+
66+
req.user = user;
67+
next();
68+
return;
69+
}
70+
2871
// OpenSubsonic API key auth (preferred)
2972
if (apiKey) {
3073
const keyRecord = await prisma.apiKey.findUnique({

frontend/features/settings/components/sections/SubsonicSection.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ export function SubsonicSection() {
134134
type="text"
135135
value={deviceName}
136136
onChange={(e) => setDeviceName(e.target.value)}
137-
placeholder="Client name (e.g. Symfonium, DSub)"
137+
placeholder="Client name (e.g. Amperfy, Symfonium, DSub)"
138138
className="text-sm text-white bg-white/5 border border-white/10 px-3 py-2 rounded-lg font-mono
139139
placeholder:text-white/20 focus:outline-none focus:border-white/20 w-full"
140140
/>

0 commit comments

Comments
 (0)