m5, subsonic/jellyfin support

This commit is contained in:
Boof2015
2026-06-27 22:03:37 -04:00
parent 0e3e46293c
commit 3ec3f5ecf2
37 changed files with 3322 additions and 35 deletions
+575
View File
@@ -0,0 +1,575 @@
// Jellyfin client — ported from desktop Astra (src/main/services/jellyfin.ts).
// Changes vs desktop: Node `crypto` (sha1 device id) -> src/lib/hash; the
// ArrayBuffer stream/cover fetchers are dropped (ExoPlayer + expo-image fetch the
// URLs directly). Stream/transcode/cover URLs already embed `api_key`, so they are
// self-contained — no per-track auth headers needed at playback time.
import { sha1Hex } from '@/lib/hash';
import type {
RemoteCatalogTrack,
RemoteConnectionConfig,
RemoteSyncProgress,
} from '@/types/remote';
const DEFAULT_TIMEOUT_MS = 12_000;
const DEFAULT_RETRIES = 1;
const DEFAULT_PAGE_SIZE = 500;
const CLIENT_NAME = 'Astra';
const CLIENT_VERSION = '0.4.0';
const DEVICE_NAME = 'Astra Mobile';
const TRANSCODE_AUDIO_CODEC = 'mp3';
const TRANSCODE_CONTAINER = 'mp3';
export interface JellyfinAuthContext {
accessToken: string;
userId: string;
}
export interface JellyfinRequestOptions {
timeoutMs?: number;
retries?: number;
signal?: AbortSignal;
}
export interface JellyfinCatalogSyncOptions extends JellyfinRequestOptions {
authContext?: JellyfinAuthContext;
onProgress?: (progress: RemoteSyncProgress) => void;
}
export interface JellyfinCatalogSyncResult {
itemsScanned: number;
tracksScanned: number;
tracks: RemoteCatalogTrack[];
}
interface JellyfinAuthenticateResponse {
AccessToken?: unknown;
User?: { Id?: unknown };
}
interface JellyfinItemsResponse {
Items?: unknown;
TotalRecordCount?: unknown;
}
interface JellyfinAudioStream {
Type?: unknown;
Codec?: unknown;
Profile?: unknown;
Channels?: unknown;
BitRate?: unknown;
SampleRate?: unknown;
BitDepth?: unknown;
}
interface JellyfinAudioItem {
Id?: unknown;
Name?: unknown;
Path?: unknown;
Artists?: unknown;
ArtistItems?: unknown;
Album?: unknown;
AlbumArtist?: unknown;
AlbumArtists?: unknown;
AlbumId?: unknown;
AlbumPrimaryImageTag?: unknown;
ImageTags?: unknown;
RunTimeTicks?: unknown;
IndexNumber?: unknown;
ParentIndexNumber?: unknown;
ProductionYear?: unknown;
Genres?: unknown;
Container?: unknown;
Bitrate?: unknown;
MediaStreams?: unknown;
}
function asArray<T>(value: unknown): T[] {
if (!value) return [];
return Array.isArray(value) ? (value as T[]) : [value as T];
}
function toTrimmedText(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function toFiniteNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function toFiniteInteger(value: unknown): number | null {
const parsed = toFiniteNumber(value);
if (parsed == null) return null;
const intValue = Math.trunc(parsed);
return Number.isFinite(intValue) ? intValue : null;
}
function normalizeBooleanQueryValue(value: boolean): string {
return value ? 'true' : 'false';
}
export function normalizeJellyfinBaseUrl(rawBaseUrl: string): string {
const trimmed = rawBaseUrl.trim();
if (!trimmed) {
throw new Error('Server URL is required.');
}
let parsedUrl: URL;
try {
parsedUrl = new URL(trimmed);
} catch {
throw new Error('Server URL is invalid.');
}
const protocol = parsedUrl.protocol.toLowerCase();
if (protocol !== 'http:' && protocol !== 'https:') {
throw new Error('Server URL must use http:// or https://');
}
parsedUrl.hash = '';
parsedUrl.search = '';
parsedUrl.pathname = parsedUrl.pathname.replace(/\/+$/, '');
return parsedUrl.toString().replace(/\/+$/, '');
}
export function buildJellyfinDeviceId(config: RemoteConnectionConfig): string {
const base = normalizeJellyfinBaseUrl(config.baseUrl);
const username = config.username.trim().toLowerCase();
return sha1Hex(`${base}|${username}|${CLIENT_NAME}`);
}
function escapeHeaderTokenValue(value: string): string {
return value.replace(/"/g, '\\"');
}
function buildJellyfinAuthorizationHeader(
config: RemoteConnectionConfig,
options: { token?: string } = {}
): string {
const parts = [
`Client="${escapeHeaderTokenValue(CLIENT_NAME)}"`,
`Device="${escapeHeaderTokenValue(DEVICE_NAME)}"`,
`DeviceId="${escapeHeaderTokenValue(buildJellyfinDeviceId(config))}"`,
`Version="${escapeHeaderTokenValue(CLIENT_VERSION)}"`,
];
if (options.token) {
parts.push(`Token="${escapeHeaderTokenValue(options.token)}"`);
}
return `MediaBrowser ${parts.join(', ')}`;
}
function buildJellyfinUrl(
config: RemoteConnectionConfig,
endpoint: string,
params: Record<string, string | number | boolean | null | undefined>
): URL {
const baseUrl = normalizeJellyfinBaseUrl(config.baseUrl);
const normalizedEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
const url = new URL(`${baseUrl}${normalizedEndpoint}`);
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null) continue;
if (typeof value === 'boolean') {
url.searchParams.set(key, normalizeBooleanQueryValue(value));
} else {
url.searchParams.set(key, String(value));
}
}
return url;
}
function mergeAbortSignals(
signal: AbortSignal | undefined,
timeoutMs: number
): { signal: AbortSignal; cleanup: () => void } {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
const onAbort = () => controller.abort();
if (signal) {
if (signal.aborted) {
controller.abort();
} else {
signal.addEventListener('abort', onAbort, { once: true });
}
}
return {
signal: controller.signal,
cleanup: () => {
clearTimeout(timeoutId);
if (signal) signal.removeEventListener('abort', onAbort);
},
};
}
async function requestJellyfinJson(
config: RemoteConnectionConfig,
authContext: JellyfinAuthContext,
endpoint: string,
params: Record<string, string | number | boolean | null | undefined>,
options: JellyfinRequestOptions = {}
): Promise<Record<string, unknown>> {
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const retries = Math.max(0, options.retries ?? DEFAULT_RETRIES);
const url = buildJellyfinUrl(config, endpoint, params);
let lastError: unknown = null;
for (let attempt = 0; attempt <= retries; attempt += 1) {
const merged = mergeAbortSignals(options.signal, timeoutMs);
try {
const response = await fetch(url, {
method: 'GET',
signal: merged.signal,
headers: {
Accept: 'application/json',
'X-Emby-Authorization': buildJellyfinAuthorizationHeader(config, {
token: authContext.accessToken,
}),
'X-Emby-Token': authContext.accessToken,
},
});
if (!response.ok) {
throw new Error(`Jellyfin request failed (${response.status})`);
}
return (await response.json()) as Record<string, unknown>;
} catch (error) {
lastError = error;
if (attempt >= retries) throw error;
} finally {
merged.cleanup();
}
}
throw lastError instanceof Error ? lastError : new Error('Jellyfin request failed.');
}
export async function authenticateJellyfin(
config: RemoteConnectionConfig,
options: JellyfinRequestOptions = {}
): Promise<JellyfinAuthContext> {
const baseUrl = normalizeJellyfinBaseUrl(config.baseUrl);
const username = config.username.trim();
const password = config.password;
if (!username) {
throw new Error('Username is required.');
}
if (!password) {
throw new Error('Password is required.');
}
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const retries = Math.max(0, options.retries ?? DEFAULT_RETRIES);
const url = new URL(`${baseUrl}/Users/AuthenticateByName`);
let lastError: unknown = null;
for (let attempt = 0; attempt <= retries; attempt += 1) {
const merged = mergeAbortSignals(options.signal, timeoutMs);
try {
const response = await fetch(url, {
method: 'POST',
signal: merged.signal,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-Emby-Authorization': buildJellyfinAuthorizationHeader(config),
},
body: JSON.stringify({ Username: username, Pw: password }),
});
if (!response.ok) {
throw new Error(`Jellyfin authentication failed (${response.status})`);
}
const payload = (await response.json()) as JellyfinAuthenticateResponse;
const accessToken = toTrimmedText(payload.AccessToken);
const userId = toTrimmedText(payload.User?.Id);
if (!accessToken || !userId) {
throw new Error('Invalid Jellyfin authentication response.');
}
return { accessToken, userId };
} catch (error) {
lastError = error;
if (attempt >= retries) throw error;
} finally {
merged.cleanup();
}
}
throw lastError instanceof Error ? lastError : new Error('Jellyfin authentication failed.');
}
export async function testJellyfinConnection(
config: RemoteConnectionConfig,
options: JellyfinRequestOptions = {}
): Promise<void> {
await authenticateJellyfin(config, options);
}
function resolveJellyfinArtist(item: JellyfinAudioItem): string {
const artists = asArray<string>(item.Artists)
.map((value) => toTrimmedText(value))
.filter((value): value is string => Boolean(value));
if (artists.length > 0) return artists[0];
const artistItems = asArray<Record<string, unknown>>(item.ArtistItems);
for (const artistItem of artistItems) {
const candidate = toTrimmedText(artistItem.Name);
if (candidate) return candidate;
}
return 'Unknown Artist';
}
function resolveJellyfinAlbumArtist(item: JellyfinAudioItem): string | null {
const direct = toTrimmedText(item.AlbumArtist);
if (direct) return direct;
const albumArtists = asArray<string>(item.AlbumArtists)
.map((value) => toTrimmedText(value))
.filter((value): value is string => Boolean(value));
if (albumArtists.length > 0) return albumArtists[0];
return null;
}
function resolveJellyfinArtworkSourceId(
item: JellyfinAudioItem,
sourceTrackId: string
): string | null {
const albumId = toTrimmedText(item.AlbumId);
const albumPrimaryImageTag = toTrimmedText(item.AlbumPrimaryImageTag);
if (albumId && albumPrimaryImageTag) {
return albumId;
}
const imageTags = item.ImageTags;
if (imageTags && typeof imageTags === 'object') {
const primary = toTrimmedText((imageTags as Record<string, unknown>).Primary);
if (primary) {
return sourceTrackId;
}
}
return null;
}
function normalizeJellyfinFormat(item: JellyfinAudioItem): string {
const container = toTrimmedText(item.Container);
if (container) return container.toLowerCase();
const pathValue = toTrimmedText(item.Path);
if (pathValue && pathValue.includes('.')) {
const ext = pathValue.split('.').pop()?.trim().toLowerCase();
if (ext) return ext;
}
return 'unknown';
}
function isAtmosJoc(codec: string | null, profile: string | null): boolean {
const combined = `${codec ?? ''} ${profile ?? ''}`.toLowerCase();
return combined.includes('atmos') || combined.includes('joc');
}
function mapJellyfinItemToCatalogTrack(
sourceId: number,
item: JellyfinAudioItem
): RemoteCatalogTrack | null {
const sourceTrackId = toTrimmedText(item.Id);
if (!sourceTrackId) return null;
const title = toTrimmedText(item.Name) ?? `Track ${sourceTrackId}`;
const artist = resolveJellyfinArtist(item);
const album = toTrimmedText(item.Album) ?? 'Unknown Album';
const albumArtist = resolveJellyfinAlbumArtist(item);
const durationTicks = toFiniteNumber(item.RunTimeTicks);
const duration = durationTicks && durationTicks > 0 ? durationTicks / 10_000_000 : 0;
const trackNumber = toFiniteInteger(item.IndexNumber);
const discNumber = toFiniteInteger(item.ParentIndexNumber);
const year = toFiniteInteger(item.ProductionYear);
const genre =
asArray<string>(item.Genres)
.map((value) => toTrimmedText(value))
.find((value): value is string => Boolean(value)) ?? null;
const sourcePath = toTrimmedText(item.Path);
const mediaStreams = asArray<JellyfinAudioStream>(item.MediaStreams);
const audioStream = mediaStreams.find(
(stream) => toTrimmedText(stream.Type)?.toLowerCase() === 'audio'
);
const codec = toTrimmedText(audioStream?.Codec);
const codecProfile = toTrimmedText(audioStream?.Profile);
const channels = toFiniteInteger(audioStream?.Channels);
const sampleRate = toFiniteInteger(audioStream?.SampleRate);
const bitDepth = toFiniteInteger(audioStream?.BitDepth);
const streamBitrate = toFiniteInteger(audioStream?.BitRate);
const itemBitrate = toFiniteInteger(item.Bitrate);
const artworkSourceId = resolveJellyfinArtworkSourceId(item, sourceTrackId);
return {
path: buildJellyfinTrackPath(sourceId, sourceTrackId),
source_track_id: sourceTrackId,
source_path: sourcePath,
artwork_source_id: artworkSourceId,
title,
artist,
album,
album_artist: albumArtist,
duration,
track_number: trackNumber,
disc_number: discNumber,
year,
genre,
artwork_hash: null,
format: normalizeJellyfinFormat(item),
sample_rate: sampleRate,
bit_depth: bitDepth,
bitrate: streamBitrate ?? itemBitrate,
channels,
codec,
codec_profile: codecProfile,
is_atmos_joc: isAtmosJoc(codec, codecProfile) ? 1 : null,
replaygain_track_gain_db: null,
replaygain_album_gain_db: null,
bpm: null,
musical_key: null,
};
}
export async function syncJellyfinCatalog(
sourceId: number,
config: RemoteConnectionConfig,
options: JellyfinCatalogSyncOptions = {}
): Promise<JellyfinCatalogSyncResult> {
const authContext = options.authContext ?? (await authenticateJellyfin(config, options));
const byTrackId = new Map<string, RemoteCatalogTrack>();
let startIndex = 0;
let totalRecordCount: number | null = null;
while (true) {
const response = (await requestJellyfinJson(
config,
authContext,
`/Users/${encodeURIComponent(authContext.userId)}/Items`,
{
Recursive: true,
IncludeItemTypes: 'Audio',
Fields:
'Path,Genres,Container,Bitrate,RunTimeTicks,ProductionYear,IndexNumber,ParentIndexNumber,Album,AlbumArtist,AlbumArtists,AlbumId,AlbumPrimaryImageTag,ImageTags,MediaStreams',
SortBy: 'SortName',
SortOrder: 'Ascending',
StartIndex: startIndex,
Limit: DEFAULT_PAGE_SIZE,
},
options
)) as JellyfinItemsResponse;
const total = toFiniteInteger(response.TotalRecordCount);
if (totalRecordCount === null && total !== null && total >= 0) {
totalRecordCount = total;
}
const items = asArray<JellyfinAudioItem>(response.Items);
for (const item of items) {
const mapped = mapJellyfinItemToCatalogTrack(sourceId, item);
if (!mapped) continue;
byTrackId.set(mapped.source_track_id, mapped);
}
options.onProgress?.({
phase: 'items',
current: startIndex + items.length,
total: totalRecordCount ?? startIndex + items.length,
detail: null,
});
if (items.length === 0) break;
startIndex += items.length;
if (totalRecordCount !== null && startIndex >= totalRecordCount) break;
if (items.length < DEFAULT_PAGE_SIZE) break;
}
const tracks = Array.from(byTrackId.values());
return {
itemsScanned: totalRecordCount ?? tracks.length,
tracksScanned: tracks.length,
tracks,
};
}
export function buildJellyfinTrackPath(sourceId: number, sourceTrackId: string): string {
return `jellyfin://${sourceId}/track/${encodeURIComponent(sourceTrackId)}`;
}
export function parseJellyfinTrackPath(
path: string
): { sourceId: number; sourceTrackId: string } | null {
const match = /^jellyfin:\/\/(\d+)\/track\/(.+)$/.exec(path);
if (!match) return null;
const sourceId = Number.parseInt(match[1], 10);
if (!Number.isInteger(sourceId) || sourceId <= 0) return null;
const sourceTrackIdRaw = match[2];
if (!sourceTrackIdRaw) return null;
try {
const sourceTrackId = decodeURIComponent(sourceTrackIdRaw);
if (!sourceTrackId) return null;
return { sourceId, sourceTrackId };
} catch {
return null;
}
}
export function buildJellyfinStreamUrl(
config: RemoteConnectionConfig,
sourceTrackId: string,
accessToken: string
): string {
return buildJellyfinUrl(config, `/Items/${encodeURIComponent(sourceTrackId)}/Download`, {
api_key: accessToken,
}).toString();
}
export function buildJellyfinTranscodeStreamUrl(
config: RemoteConnectionConfig,
sourceTrackId: string,
authContext: JellyfinAuthContext,
maxBitRateKbps: number
): string {
const normalizedMaxBitrate = Math.max(16, Math.trunc(maxBitRateKbps)) * 1000;
return buildJellyfinUrl(config, `/Audio/${encodeURIComponent(sourceTrackId)}/universal`, {
UserId: authContext.userId,
DeviceId: buildJellyfinDeviceId(config),
api_key: authContext.accessToken,
AudioCodec: TRANSCODE_AUDIO_CODEC,
Container: TRANSCODE_CONTAINER,
TranscodingContainer: TRANSCODE_CONTAINER,
MaxStreamingBitrate: normalizedMaxBitrate,
}).toString();
}
export function buildJellyfinCoverArtUrl(
config: RemoteConnectionConfig,
itemId: string,
accessToken: string,
options: { quality?: number; maxWidth?: number } = {}
): string {
return buildJellyfinUrl(config, `/Items/${encodeURIComponent(itemId)}/Images/Primary`, {
api_key: accessToken,
quality: options.quality ?? 90,
maxWidth: options.maxWidth,
}).toString();
}
+45
View File
@@ -0,0 +1,45 @@
// In-memory registry of resolved remote-source configs (incl. the decrypted password
// and cached Jellyfin token), keyed by remote_sources.id. Populated by the remote
// sources store on init / create / update; read synchronously by remoteUrls.ts so the
// library UI and playback can build stream/cover URLs without an async hop.
//
// This is a plain module Map (not zustand) on purpose — secrets never enter store
// state / devtools, and lookups are synchronous.
import type { RemoteSourceType } from '@/types/remote';
export interface ResolvedRemoteConfig {
id: number;
type: RemoteSourceType;
baseUrl: string;
username: string;
password: string;
/** Jellyfin only. */
accessToken?: string;
userId?: string;
}
const registry = new Map<number, ResolvedRemoteConfig>();
export function setResolvedRemoteConfig(config: ResolvedRemoteConfig): void {
registry.set(config.id, config);
}
export function getResolvedRemoteConfig(id: number): ResolvedRemoteConfig | undefined {
return registry.get(id);
}
export function updateResolvedRemoteAuth(
id: number,
auth: { accessToken: string; userId: string }
): void {
const existing = registry.get(id);
if (existing) {
existing.accessToken = auth.accessToken;
existing.userId = auth.userId;
}
}
export function clearResolvedRemoteConfig(id: number): void {
registry.delete(id);
}
+22
View File
@@ -0,0 +1,22 @@
// Server passwords live in the Android Keystore (expo-secure-store), keyed by the
// remote_sources row id. Subsonic needs the plaintext password at request time to
// compute the per-request salted token; Jellyfin needs it to (re)authenticate.
import * as SecureStore from 'expo-secure-store';
function secretKey(sourceId: number): string {
// SecureStore keys must be alphanumeric + ".-_" — this satisfies that.
return `remote_secret_${sourceId}`;
}
export async function getRemoteSecret(sourceId: number): Promise<string | null> {
return SecureStore.getItemAsync(secretKey(sourceId));
}
export async function setRemoteSecret(sourceId: number, password: string): Promise<void> {
await SecureStore.setItemAsync(secretKey(sourceId), password);
}
export async function deleteRemoteSecret(sourceId: number): Promise<void> {
await SecureStore.deleteItemAsync(secretKey(sourceId));
}
+53
View File
@@ -0,0 +1,53 @@
// Synchronous stream/cover URL resolution for remote tracks. Reads the decrypted
// config from the in-memory registry (remoteConfig) and delegates to the client URL
// builders. Returns null when the source isn't loaded yet (e.g. Jellyfin not
// authenticated) — callers fall back to no-art / the identity path.
//
// Audiophile default: original-quality streams (no Subsonic maxBitRate, Jellyfin
// Download endpoint rather than transcode).
import type { Track } from '@/types/audio';
import type { RemoteConnectionConfig } from '@/types/remote';
import { getResolvedRemoteConfig, type ResolvedRemoteConfig } from './remoteConfig';
import { buildSubsonicCoverArtUrl, buildSubsonicStreamUrl } from './subsonic';
import { buildJellyfinCoverArtUrl, buildJellyfinStreamUrl } from './jellyfin';
function connection(cfg: ResolvedRemoteConfig): RemoteConnectionConfig {
return { baseUrl: cfg.baseUrl, username: cfg.username, password: cfg.password };
}
/** Build the playable HTTP stream URL for a remote track, or null if unavailable. */
export function streamUrlForTrack(track: Track): string | null {
if (!track.sourceType || track.sourceType === 'local') return null;
if (track.sourceId == null || !track.sourceTrackId) return null;
const cfg = getResolvedRemoteConfig(track.sourceId);
if (!cfg) return null;
if (cfg.type === 'subsonic') {
return buildSubsonicStreamUrl(connection(cfg), track.sourceTrackId);
}
if (cfg.type === 'jellyfin') {
if (!cfg.accessToken) return null;
return buildJellyfinStreamUrl(connection(cfg), track.sourceTrackId, cfg.accessToken);
}
return null;
}
/** Build the cover-art URL for a remote track, or null if unavailable. */
export function artworkUrlForTrack(
track: Pick<Track, 'sourceType' | 'sourceId' | 'artworkSourceId'>
): string | null {
if (!track.sourceType || track.sourceType === 'local') return null;
if (track.sourceId == null || !track.artworkSourceId) return null;
const cfg = getResolvedRemoteConfig(track.sourceId);
if (!cfg) return null;
if (cfg.type === 'subsonic') {
return buildSubsonicCoverArtUrl(connection(cfg), track.artworkSourceId);
}
if (cfg.type === 'jellyfin') {
if (!cfg.accessToken) return null;
return buildJellyfinCoverArtUrl(connection(cfg), track.artworkSourceId, cfg.accessToken);
}
return null;
}
+632
View File
@@ -0,0 +1,632 @@
// Subsonic client — ported from desktop Astra (src/main/services/subsonic.ts).
// Changes vs desktop: Node `crypto` (md5 token + random salt) -> src/lib/hash;
// the ArrayBuffer stream/cover fetchers are dropped (ExoPlayer + expo-image fetch
// the URLs directly), and a sync `buildSubsonicCoverArtUrl` is added.
import { md5Hex, randomSaltHex } from '@/lib/hash';
import type {
RemoteCatalogTrack,
RemoteConnectionConfig,
RemotePlaylist,
RemotePlaylistTrack,
RemoteSyncProgress,
} from '@/types/remote';
const SUBSONIC_API_VERSION = '1.16.1';
const SUBSONIC_CLIENT_ID = 'astra';
const DEFAULT_TIMEOUT_MS = 12_000;
const DEFAULT_RETRIES = 1;
const MAX_SYNC_CONCURRENCY = 4;
export interface SubsonicRequestOptions {
timeoutMs?: number;
retries?: number;
signal?: AbortSignal;
}
export interface SubsonicCatalogSyncOptions extends SubsonicRequestOptions {
onProgress?: (progress: RemoteSyncProgress) => void;
}
export interface SubsonicCatalogSyncResult {
artistsScanned: number;
albumsScanned: number;
tracksScanned: number;
tracks: RemoteCatalogTrack[];
}
interface SubsonicResponseEnvelope {
'subsonic-response'?: {
status?: string;
error?: { code?: number; message?: string };
[key: string]: unknown;
};
}
interface SubsonicArtistRef {
id: string;
}
interface SubsonicAlbumRef {
id: string;
}
interface SubsonicPlaylistRef {
id: string;
name: string;
}
interface SubsonicAlbumSongs {
coverArtId: string | null;
songs: SubsonicSong[];
}
interface SubsonicSong {
id?: unknown;
title?: unknown;
artist?: unknown;
album?: unknown;
albumArtist?: unknown;
coverArt?: unknown;
duration?: unknown;
track?: unknown;
discNumber?: unknown;
year?: unknown;
genre?: unknown;
suffix?: unknown;
contentType?: unknown;
bitRate?: unknown;
sampleRate?: unknown;
bitDepth?: unknown;
channelCount?: unknown;
path?: unknown;
}
function asArray<T>(value: unknown): T[] {
if (!value) return [];
return Array.isArray(value) ? (value as T[]) : [value as T];
}
function toTrimmedText(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function toFiniteNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function toFiniteInteger(value: unknown): number | null {
const parsed = toFiniteNumber(value);
if (parsed == null) return null;
const integer = Math.trunc(parsed);
return Number.isFinite(integer) ? integer : null;
}
function normalizeSubsonicFormat(song: SubsonicSong): string {
const suffix = toTrimmedText(song.suffix);
if (suffix) return suffix.toLowerCase();
const pathValue = toTrimmedText(song.path);
if (pathValue && pathValue.includes('.')) {
const ext = pathValue.split('.').pop()?.trim().toLowerCase();
if (ext) return ext;
}
const contentType = toTrimmedText(song.contentType);
if (contentType?.includes('/')) {
const subtype = contentType.split('/')[1]?.trim().toLowerCase();
if (subtype) return subtype;
}
return 'unknown';
}
export function normalizeSubsonicBaseUrl(rawBaseUrl: string): string {
const trimmed = rawBaseUrl.trim();
if (!trimmed) {
throw new Error('Server URL is required.');
}
let parsedUrl: URL;
try {
parsedUrl = new URL(trimmed);
} catch {
throw new Error('Server URL is invalid.');
}
const protocol = parsedUrl.protocol.toLowerCase();
if (protocol !== 'http:' && protocol !== 'https:') {
throw new Error('Server URL must use http:// or https://');
}
parsedUrl.hash = '';
parsedUrl.search = '';
parsedUrl.pathname = parsedUrl.pathname.replace(/\/+$/, '');
return parsedUrl.toString().replace(/\/+$/, '');
}
function buildAuthQuery(
config: RemoteConnectionConfig,
options: { includeFormat?: boolean } = {}
): Record<string, string> {
const username = config.username.trim();
const password = config.password;
if (!username) {
throw new Error('Username is required.');
}
if (!password) {
throw new Error('Password is required.');
}
const salt = randomSaltHex(6);
const token = md5Hex(`${password}${salt}`);
const query: Record<string, string> = {
u: username,
t: token,
s: salt,
v: SUBSONIC_API_VERSION,
c: SUBSONIC_CLIENT_ID,
};
if (options.includeFormat !== false) {
query.f = 'json';
}
return query;
}
function buildSubsonicEndpointUrl(
config: RemoteConnectionConfig,
endpoint: string,
params: Record<string, string | number | boolean | null | undefined>,
options: { includeFormat?: boolean } = {}
): URL {
const baseUrl = normalizeSubsonicBaseUrl(config.baseUrl);
const url = new URL(`${baseUrl}/rest/${endpoint}.view`);
const auth = buildAuthQuery(config, options);
for (const [key, value] of Object.entries(auth)) {
url.searchParams.set(key, value);
}
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null) continue;
url.searchParams.set(key, String(value));
}
return url;
}
function mergeAbortSignals(
signal: AbortSignal | undefined,
timeoutMs: number
): { signal: AbortSignal; cleanup: () => void } {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
const onAbort = () => controller.abort();
if (signal) {
if (signal.aborted) {
controller.abort();
} else {
signal.addEventListener('abort', onAbort, { once: true });
}
}
return {
signal: controller.signal,
cleanup: () => {
clearTimeout(timeoutId);
if (signal) signal.removeEventListener('abort', onAbort);
},
};
}
async function requestSubsonicJson(
config: RemoteConnectionConfig,
endpoint: string,
params: Record<string, string | number | boolean | null | undefined>,
options: SubsonicRequestOptions = {}
): Promise<Record<string, unknown>> {
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const retries = Math.max(0, options.retries ?? DEFAULT_RETRIES);
const url = buildSubsonicEndpointUrl(config, endpoint, params, { includeFormat: true });
let lastError: unknown = null;
for (let attempt = 0; attempt <= retries; attempt += 1) {
const merged = mergeAbortSignals(options.signal, timeoutMs);
try {
const response = await fetch(url, { method: 'GET', signal: merged.signal });
if (!response.ok) {
throw new Error(`Subsonic request failed (${response.status})`);
}
const json = (await response.json()) as SubsonicResponseEnvelope;
const envelope = json['subsonic-response'];
if (!envelope || typeof envelope !== 'object') {
throw new Error('Invalid Subsonic response payload.');
}
if (envelope.status !== 'ok') {
const message = toTrimmedText(envelope.error?.message) ?? 'Subsonic request failed.';
throw new Error(message);
}
return envelope as Record<string, unknown>;
} catch (error) {
lastError = error;
if (attempt >= retries) throw error;
} finally {
merged.cleanup();
}
}
throw lastError instanceof Error ? lastError : new Error('Subsonic request failed.');
}
async function runWithConcurrency<T, R>(
values: T[],
concurrency: number,
worker: (value: T, index: number) => Promise<R>
): Promise<R[]> {
if (values.length === 0) return [];
const maxWorkers = Math.max(1, Math.min(concurrency, values.length));
const results: R[] = new Array(values.length);
let nextIndex = 0;
const runWorker = async (): Promise<void> => {
while (nextIndex < values.length) {
const currentIndex = nextIndex;
nextIndex += 1;
results[currentIndex] = await worker(values[currentIndex], currentIndex);
}
};
await Promise.all(Array.from({ length: maxWorkers }, () => runWorker()));
return results;
}
export async function testSubsonicConnection(
config: RemoteConnectionConfig,
options: SubsonicRequestOptions = {}
): Promise<void> {
await requestSubsonicJson(config, 'ping', {}, options);
}
function toArtistRefs(indexResponse: Record<string, unknown>): SubsonicArtistRef[] {
const artistsContainer = indexResponse.artists as Record<string, unknown> | undefined;
if (!artistsContainer || typeof artistsContainer !== 'object') return [];
const indexEntries = asArray<Record<string, unknown>>(artistsContainer.index);
const artistIds: SubsonicArtistRef[] = [];
for (const indexEntry of indexEntries) {
for (const artist of asArray<Record<string, unknown>>(indexEntry.artist)) {
const id = toTrimmedText(artist.id);
if (!id) continue;
artistIds.push({ id });
}
}
return artistIds;
}
function toAlbumRefs(artistResponse: Record<string, unknown>): SubsonicAlbumRef[] {
const artistContainer = artistResponse.artist as Record<string, unknown> | undefined;
if (!artistContainer || typeof artistContainer !== 'object') return [];
const albums = asArray<Record<string, unknown>>(artistContainer.album);
return albums
.map((album) => toTrimmedText(album.id))
.filter((id): id is string => Boolean(id))
.map((id) => ({ id }));
}
function mapSongToCatalogTrack(
sourceId: number,
song: SubsonicSong,
fallbackCoverArtId: string | null
): RemoteCatalogTrack | null {
const sourceTrackId = toTrimmedText(song.id);
if (!sourceTrackId) return null;
const title = toTrimmedText(song.title) ?? `Track ${sourceTrackId}`;
const artist = toTrimmedText(song.artist) ?? 'Unknown Artist';
const album = toTrimmedText(song.album) ?? 'Unknown Album';
const albumArtist = toTrimmedText(song.albumArtist);
const duration = toFiniteNumber(song.duration);
const bitrate = toFiniteInteger(song.bitRate);
const sampleRate = toFiniteInteger(song.sampleRate);
const bitDepth = toFiniteInteger(song.bitDepth);
const channels = toFiniteInteger(song.channelCount);
const trackNumber = toFiniteInteger(song.track);
const discNumber = toFiniteInteger(song.discNumber);
const year = toFiniteInteger(song.year);
const genre = toTrimmedText(song.genre);
const sourcePath = toTrimmedText(song.path);
const contentType = toTrimmedText(song.contentType);
const artworkSourceId = toTrimmedText(song.coverArt) ?? fallbackCoverArtId;
return {
path: buildSubsonicTrackPath(sourceId, sourceTrackId),
source_track_id: sourceTrackId,
source_path: sourcePath,
artwork_source_id: artworkSourceId,
title,
artist,
album,
album_artist: albumArtist,
duration: duration && duration > 0 ? duration : 0,
track_number: trackNumber,
disc_number: discNumber,
year,
genre,
artwork_hash: null,
format: normalizeSubsonicFormat(song),
sample_rate: sampleRate,
bit_depth: bitDepth,
bitrate,
channels,
codec: contentType,
codec_profile: null,
is_atmos_joc: null,
replaygain_track_gain_db: null,
replaygain_album_gain_db: null,
bpm: null,
musical_key: null,
};
}
export async function syncSubsonicCatalog(
sourceId: number,
config: RemoteConnectionConfig,
options: SubsonicCatalogSyncOptions = {}
): Promise<SubsonicCatalogSyncResult> {
const indexResponse = await requestSubsonicJson(config, 'getArtists', {}, options);
const artistRefs = toArtistRefs(indexResponse);
options.onProgress?.({ phase: 'artists', current: 0, total: artistRefs.length, detail: null });
if (artistRefs.length === 0) {
return { artistsScanned: 0, albumsScanned: 0, tracksScanned: 0, tracks: [] };
}
let artistsProcessed = 0;
const albumRefsByArtist = await runWithConcurrency(
artistRefs,
MAX_SYNC_CONCURRENCY,
async (artistRef) => {
const artistResponse = await requestSubsonicJson(
config,
'getArtist',
{ id: artistRef.id },
options
);
const albums = toAlbumRefs(artistResponse);
artistsProcessed += 1;
options.onProgress?.({
phase: 'artists',
current: artistsProcessed,
total: artistRefs.length,
detail: artistRef.id,
});
return albums;
}
);
const allAlbumRefs = albumRefsByArtist.flat();
const uniqueAlbumIds = Array.from(new Set(allAlbumRefs.map((album) => album.id)));
options.onProgress?.({ phase: 'albums', current: 0, total: uniqueAlbumIds.length, detail: null });
let albumsProcessed = 0;
const albumSongLists = await runWithConcurrency(
uniqueAlbumIds,
MAX_SYNC_CONCURRENCY,
async (albumId): Promise<SubsonicAlbumSongs> => {
const albumResponse = await requestSubsonicJson(config, 'getAlbum', { id: albumId }, options);
const albumContainer = albumResponse.album as Record<string, unknown> | undefined;
if (!albumContainer || typeof albumContainer !== 'object') {
albumsProcessed += 1;
options.onProgress?.({
phase: 'albums',
current: albumsProcessed,
total: uniqueAlbumIds.length,
detail: albumId,
});
return { coverArtId: null, songs: [] };
}
albumsProcessed += 1;
options.onProgress?.({
phase: 'albums',
current: albumsProcessed,
total: uniqueAlbumIds.length,
detail: albumId,
});
return {
coverArtId: toTrimmedText(albumContainer.coverArt),
songs: asArray<SubsonicSong>(albumContainer.song),
};
}
);
const byTrackId = new Map<string, RemoteCatalogTrack>();
options.onProgress?.({ phase: 'tracks', current: 0, total: albumSongLists.length, detail: null });
let trackAlbumProcessed = 0;
for (const albumSongs of albumSongLists) {
for (const song of albumSongs.songs) {
const mapped = mapSongToCatalogTrack(sourceId, song, albumSongs.coverArtId);
if (!mapped) continue;
byTrackId.set(mapped.source_track_id, mapped);
}
trackAlbumProcessed += 1;
options.onProgress?.({
phase: 'tracks',
current: trackAlbumProcessed,
total: albumSongLists.length,
detail: null,
});
}
const tracks = Array.from(byTrackId.values());
return {
artistsScanned: artistRefs.length,
albumsScanned: uniqueAlbumIds.length,
tracksScanned: tracks.length,
tracks,
};
}
export function buildSubsonicTrackPath(sourceId: number, sourceTrackId: string): string {
return `subsonic://${sourceId}/track/${encodeURIComponent(sourceTrackId)}`;
}
export function parseSubsonicTrackPath(
path: string
): { sourceId: number; sourceTrackId: string } | null {
const match = /^subsonic:\/\/(\d+)\/track\/(.+)$/.exec(path);
if (!match) return null;
const sourceId = Number.parseInt(match[1], 10);
if (!Number.isInteger(sourceId) || sourceId <= 0) return null;
const sourceTrackIdRaw = match[2];
if (!sourceTrackIdRaw) return null;
try {
const sourceTrackId = decodeURIComponent(sourceTrackIdRaw);
if (!sourceTrackId) return null;
return { sourceId, sourceTrackId };
} catch {
return null;
}
}
export function buildSubsonicStreamUrl(
config: RemoteConnectionConfig,
sourceTrackId: string,
options: { maxBitRateKbps?: number } = {}
): string {
const maxBitRateKbps = options.maxBitRateKbps;
return buildSubsonicEndpointUrl(
config,
'stream',
{
id: sourceTrackId,
maxBitRate:
typeof maxBitRateKbps === 'number' && Number.isFinite(maxBitRateKbps) && maxBitRateKbps > 0
? Math.trunc(maxBitRateKbps)
: undefined,
},
{ includeFormat: false }
).toString();
}
export function buildSubsonicCoverArtUrl(
config: RemoteConnectionConfig,
coverArtId: string,
options: { size?: number } = {}
): string {
return buildSubsonicEndpointUrl(
config,
'getCoverArt',
{ id: coverArtId, size: options.size },
{ includeFormat: false }
).toString();
}
// --- Favorites (starred) + playlists -----------------------------------------
function toStarredTrackIds(starredResponse: Record<string, unknown>): string[] {
const starredContainer = starredResponse.starred as Record<string, unknown> | undefined;
if (!starredContainer || typeof starredContainer !== 'object') return [];
const ids = new Set<string>();
for (const song of asArray<SubsonicSong>(starredContainer.song)) {
const id = toTrimmedText(song.id);
if (id) ids.add(id);
}
return Array.from(ids);
}
function toPlaylistRefs(playlistsResponse: Record<string, unknown>): SubsonicPlaylistRef[] {
const playlistsContainer = playlistsResponse.playlists as Record<string, unknown> | undefined;
if (!playlistsContainer || typeof playlistsContainer !== 'object') return [];
return asArray<Record<string, unknown>>(playlistsContainer.playlist)
.map((playlist) => {
const id = toTrimmedText(playlist.id);
if (!id) return null;
return { id, name: toTrimmedText(playlist.name) ?? `Playlist ${id}` };
})
.filter((playlist): playlist is SubsonicPlaylistRef => playlist !== null);
}
function toPlaylistTracks(
sourceId: number,
playlistResponse: Record<string, unknown>
): RemotePlaylistTrack[] {
const playlistContainer = playlistResponse.playlist as Record<string, unknown> | undefined;
if (!playlistContainer || typeof playlistContainer !== 'object') return [];
const entries = asArray<SubsonicSong>(playlistContainer.entry ?? playlistContainer.song);
const tracks: RemotePlaylistTrack[] = [];
const seen = new Set<string>();
for (const entry of entries) {
const sourceTrackId = toTrimmedText(entry.id);
if (!sourceTrackId || seen.has(sourceTrackId)) continue;
seen.add(sourceTrackId);
tracks.push({
path: buildSubsonicTrackPath(sourceId, sourceTrackId),
source_track_id: sourceTrackId,
title: toTrimmedText(entry.title),
artist: toTrimmedText(entry.artist),
album: toTrimmedText(entry.album),
});
}
return tracks;
}
/** Server-side starred (favorite) track ids. */
export async function fetchSubsonicStarredTrackIds(
config: RemoteConnectionConfig,
options: SubsonicRequestOptions = {}
): Promise<string[]> {
const starredResponse = await requestSubsonicJson(config, 'getStarred', {}, options);
return toStarredTrackIds(starredResponse);
}
/** Server playlists with their track lists (getPlaylists -> getPlaylist per playlist). */
export async function syncSubsonicPlaylists(
sourceId: number,
config: RemoteConnectionConfig,
options: SubsonicCatalogSyncOptions = {}
): Promise<RemotePlaylist[]> {
const playlistsResponse = await requestSubsonicJson(config, 'getPlaylists', {}, options);
const playlistRefs = toPlaylistRefs(playlistsResponse);
options.onProgress?.({ phase: 'playlists', current: 0, total: playlistRefs.length, detail: null });
let processed = 0;
return runWithConcurrency(
playlistRefs,
MAX_SYNC_CONCURRENCY,
async (playlistRef): Promise<RemotePlaylist> => {
const playlistResponse = await requestSubsonicJson(
config,
'getPlaylist',
{ id: playlistRef.id },
options
);
processed += 1;
options.onProgress?.({
phase: 'playlists',
current: processed,
total: playlistRefs.length,
detail: playlistRef.id,
});
return {
source_playlist_id: playlistRef.id,
name: playlistRef.name,
tracks: toPlaylistTracks(sourceId, playlistResponse),
};
}
);
}