mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-18 19:54:26 +02:00
m5, subsonic/jellyfin support
This commit is contained in:
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user