From 4f5468679d4c1404a6567ca06a65e92625c83c46 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 11 Sep 2025 14:51:39 +0000 Subject: [PATCH 1/2] Add placeholder source --- src/backend/sources/NavidromeSource.ts | 354 +++++++++++++++++++++++++ 1 file changed, 354 insertions(+) create mode 100644 src/backend/sources/NavidromeSource.ts diff --git a/src/backend/sources/NavidromeSource.ts b/src/backend/sources/NavidromeSource.ts new file mode 100644 index 00000000..3b8f36b3 --- /dev/null +++ b/src/backend/sources/NavidromeSource.ts @@ -0,0 +1,354 @@ +import * as crypto from 'crypto'; +import dayjs from "dayjs"; +import isSameOrAfter from "dayjs/plugin/isSameOrAfter.js"; +import EventEmitter from "events"; +import request, { Request } from 'superagent'; +import { PlayObject } from "../../core/Atomic.js"; +import { isNodeNetworkException } from "../common/errors/NodeErrors.js"; +import { UpstreamError } from "../common/errors/UpstreamError.js"; +import { DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js"; +import { SubSonicSourceConfig } from "../common/infrastructure/config/source/subsonic.js"; +import { getSubsonicResponse, SubsonicResponse, SubsonicResponseCommon } from "../common/vendor/subsonic/interfaces.js"; +import { parseRetryAfterSecsFromObj, removeDuplicates, sleep } from "../utils.js"; +import { findCauseByFunc } from "../utils/ErrorUtils.js"; +import { RecentlyPlayedOptions } from "./AbstractSource.js"; +import MemorySource from "./MemorySource.js"; + +dayjs.extend(isSameOrAfter); + +interface SourceIdentifierData { + /** Subsonic Version */ + version?: string, + /** Media Player name */ + type?: string, + /** Media Player version */ + serverVersion?: string, + openSubsonic?: boolean +} + +export class NavidromeSource extends MemorySource { + + requiresAuth = true; + + multiPlatform: boolean = true; + + declare config: SubSonicSourceConfig; + + usersAllow: string[] = []; + + sourceData: SourceIdentifierData = {}; + + constructor(name: any, config: SubSonicSourceConfig, internal: InternalConfig, emitter: EventEmitter) { + const { + data: { + ...restData + } = {} + } = config; + const subsonicConfig = {...config, data: {...restData}}; + super('subsonic', name, subsonicConfig, internal,emitter); + + this.canPoll = true; + } + + static formatPlayObj(obj: any, options: FormatPlayObjectOptions & { sourceData?: SourceIdentifierData } = {}): PlayObject { + const { + newFromSource = false, + sourceData: { + version, + type, + serverVersion, + openSubsonic + } = {}, + } = options; + const { + id, + title, + album, + artist, + duration, // seconds + minutesAgo, + playerId, + username, + } = obj; + + return { + data: { + artists: [artist], + album, + track: title, + duration, + // subsonic doesn't return an exact datetime, only how many whole minutes ago it was played + // so we need to force the time to be 0 seconds always so that when we compare against scrobbles from client the time isn't off + playDate: minutesAgo === 0 ? dayjs().startOf('minute') : dayjs().startOf('minute').subtract(minutesAgo, 'minute'), + }, + meta: { + source: 'Subsonic', + trackId: id, + newFromSource, + user: username, + deviceId: playerId, + mediaPlayerName: type ?? `${openSubsonic ? 'Open ' : ''}Subsonic`, + mediaPlayerVersion: type !== undefined && serverVersion !== undefined ? serverVersion : version + } + } + } + + callApi = async (req: Request, retries = 0): Promise => { + const { + data: { + user, + password + } = {}, + options: { + maxRequestRetries = 1, + retryMultiplier = DEFAULT_RETRY_MULTIPLIER + } = {}, + } = this.config; + + const queryOpts: Record = { + u: user, + v: '1.15.0', + c: `multi-scrobbler - ${this.name}`, + f: 'json' + }; + if((this.config?.data?.legacyAuthentication ?? false)) { + //queryOpts.p = password; + queryOpts.p = `enc:${Buffer.from(password).toString('hex')}` + } else { + const salt = crypto.randomBytes(10).toString('hex'); + const hash = crypto.createHash('md5').update(`${password}${salt}`).digest('hex') + queryOpts.t = hash; + queryOpts.s = salt; + } + + req.query(queryOpts); + + if((this.config?.data?.ignoreTlsErrors ?? false)) { + req.disableTLSCerts(); + } + + try { + const resp = await req as SubsonicResponse; + + let errorTxt: string | undefined; + + const { + body, + status: httpStatus, + text, + headers: { + ['content-type']: ct = undefined, + } = {} + } = resp; + + if(ct === undefined || !ct.includes('json')) { + errorTxt = `Subsonic Server response (${httpStatus}) was unexpected. Expected content-type to be json but found '${ct}`; + } else if(Object.keys(body).length === 0) { + errorTxt = `Subsonic Server response (${httpStatus}) was unexpected. Body is empty.`; + } + if(errorTxt !== undefined && text !== undefined) { + errorTxt = `${errorTxt} | Text Response Sample: ${text.substring(0, 500)}`; + } + if(errorTxt !== undefined) { + throw new UpstreamError(errorTxt, {showStopper: true}); + } + + const { + "subsonic-response": { + status, + }, + "subsonic-response": ssResp + } = body; + + if (status === 'failed') { + const uError = new UpstreamError(`Subsonic API returned an error => ${parseApiResponseErrorToThrowable(resp)}`, {response: resp}); + if(uError.message.includes('Subsonic Api Response => (41)')) { + const tokenError = 'This server does not support token-based authentication and must use the legacy authentication approach with sends your password in CLEAR TEXT.'; + if(this.config.data.legacyAuthentication !== undefined) { + if(this.config.data.legacyAuthentication === true) { + this.logger.error(`${tokenError} MS has already tried to use legacy authentication but it has failed. There is likely a different reason the server is rejecting authentication.`); + } else { + this.logger.error(`${tokenError} Your config settings do not allow legacy authentication to be used.`); + } + throw uError; + } else { + this.logger.warn(`${parseApiResponseErrorToThrowable(resp)} | ${tokenError} MS will attempt to use legacy authentication since 'legacyAuthentication' is not explicitly defined (or disabled) in config.`); + this.config.data.legacyAuthentication = true; + return await this.callApi(req); + } + } + } + + // @ts-expect-error it is assignable to T idk + return ssResp; + } catch (e) { + if(e instanceof UpstreamError) { + throw e; + } + + if((isNodeNetworkException(e) || e.status >= 500) && retries < maxRequestRetries) { + const retryAfter = parseRetryAfterSecsFromObj(e) ?? (retryMultiplier * (retries + 1)); + this.logger.warn(`Request failed but retries (${retries}) less than max (${maxRequestRetries}), retrying request after ${retryAfter} seconds...`); + await sleep(retryAfter * 1000); + return await this.callApi(req, retries + 1) + } + + if(isNodeNetworkException(e)) { + throw new UpstreamError('Could not communicate with Subsonic Server', {cause: e, showStopper: true}); + } + + if(e.message.includes('self-signed certificate')) { + throw new UpstreamError(`Subsonic server uses self-signed certs which MS does not allow by default. This error can be ignored by setting 'ignoreTlsErrors: true' in config. WARNING this can result in cleartext communication which is insecure.`, {cause: e, showStopper: true}); + } + + throw new UpstreamError('Subsonic server response was unexpected', {cause: e}); + } + } + + protected async doBuildInitData(): Promise { + const {data: {user, password, url} = {}} = this.config; + + if (user === undefined) { + throw new Error(`Cannot setup Subsonic source, 'user' is not defined`); + } + if (password === undefined) { + throw new Error(`Cannot setup Subsonic source, 'password' is not defined`); + } + if (url === undefined) { + throw new Error(`Cannot setup Subsonic source, 'url' is not defined`); + } + + let usersAllowVal = this.config.data.usersAllow; + if(usersAllowVal !== undefined && usersAllowVal !== null) { + if(!Array.isArray(usersAllowVal)) { + usersAllowVal = [usersAllowVal]; + } + if(usersAllowVal.filter(x => x.toString().trim() !== '').length > 0) { + this.usersAllow = usersAllowVal.map(x => x.toString().trim()); + } + } + + if(this.usersAllow.length === 0) { + this.logger.verbose('Will monitor plays by all users'); + } else { + this.logger.verbose(`Will only monitor plays for the following users: ${this.usersAllow.join(', ')}`); + } + + return true; + } + + protected async doCheckConnection(): Promise { + const {url} = this.config.data; + try { + const resp = await this.callApi(request.get(`${url}/rest/ping`)); + this.sourceData = resp as SourceIdentifierData; + this.logger.info(`Subsonic Server reachable: ${identifiersFromResponse(resp)}`); + return true; + } catch (e) { + + const subResponseError = getSubsonicResponseFromError(e); + if(subResponseError !== undefined) { + const resp = getSubsonicResponse(subResponseError.response) + this.logger.info(`Subsonic Server reachable: ${identifiersFromResponse(resp)}`); + this.sourceData = resp as SourceIdentifierData; + return true; + } + + if(e instanceof UpstreamError) { + throw e; + } else if(isNodeNetworkException(e)) { + throw new UpstreamError('Could not communicate with Subsonic server', {cause: e}); + } else if(e.status >= 500) { + throw new UpstreamError('Subsonic server returning an unexpected response', {cause: e}) + } else { + throw new Error('Unexpected error occurred', {cause: e}) + } + } + } + + doAuthentication = async () => { + const {url} = this.config.data; + try { + await this.callApi(request.get(`${url}/rest/ping`)); + this.logger.info('Subsonic API Status: ok'); + return true; + } catch (e) { + throw e; + } + } + + getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { + const {formatted = false} = options; + const {url} = this.config.data; + const resp = await this.callApi(request.get(`${url}/rest/getNowPlaying`)); + const { + nowPlaying: { + entry = [] + } = {} + } = resp; + // sometimes subsonic sources will return the same track as being played twice on the same player, need to remove this so we don't duplicate plays + const deduped = removeDuplicates(entry.map(x => NavidromeSource.formatPlayObj(x, {sourceData: this.sourceData}))); + const userFiltered = this.usersAllow.length == 0 ? deduped : deduped.filter(x => x.meta.user === undefined || this.usersAllow.map(x => x.toLocaleLowerCase()).includes(x.meta.user.toLocaleLowerCase())); + return this.processRecentPlays(userFiltered); + } +} + +export const getSubsonicResponseFromError = (error: unknown): UpstreamError => findCauseByFunc(error, (err) => { + if(err instanceof UpstreamError && err.response !== undefined) { + return getSubsonicResponse(err.response) !== undefined; + } + return false; + }) as UpstreamError | undefined + +export const parseApiResponseErrorToThrowable = (resp: SubsonicResponse) => { + const { + status, + text, + body: { + "subsonic-response": { + status: ssStatus, + version, + type, + serverVersion, + error: { + code, + message: ssMessage, + } = {}, + } = {}, + "subsonic-response": ssResp = {} + } = {}, + body = {}, + } = resp; + if(Object.keys(ssResp).length > 0) { + return `(${identifiersFromResponse(body['subsonic-response'])}) Subsonic Api Response => (${code}) ${ssStatus}: ${ssMessage}`; + } + if(Object.keys(body).length > 0) { + return `Subsonic Server Response => (${status}) ${JSON.stringify(body)}`; + } + if(text !== undefined && text.trim() !== '') { + return `Subsonic Server Response => (${status}) ${text.substring(0, 100)}`; + } + return `Subsonic Server HTTP Response ${status} (no response content)`; +} + +export const identifiersFromResponse = (data: SubsonicResponseCommon) => { + const { + version, + type, + serverVersion, + } = data; + const identifiers = []; + if(type !== undefined) { + identifiers.push(type); + } + if(version !== undefined) { + identifiers.push(`v${version}`); + } + if(serverVersion !== undefined) { + identifiers.push(`server v${serverVersion}`); + } + if(identifiers.length === 0) { + return 'No Server Identifiers'; + } + return identifiers.join(' | '); +} From 76e0ac2adb02c8aeebd34bf45bd8a6082e7f6a8a Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 11 Sep 2025 16:04:19 +0000 Subject: [PATCH 2/2] poc navidrome source --- src/backend/common/infrastructure/Atomic.ts | 4 +- .../infrastructure/config/source/navidrome.ts | 31 +++++++ .../infrastructure/config/source/sources.ts | 3 + .../vendor/navidrome/NavidromeApiClient.ts | 92 +++++++++++++++++++ src/backend/sources/NavidromeSource.ts | 16 +++- src/backend/sources/ScrobbleSources.ts | 8 ++ 6 files changed, 150 insertions(+), 4 deletions(-) create mode 100644 src/backend/common/infrastructure/config/source/navidrome.ts create mode 100644 src/backend/common/vendor/navidrome/NavidromeApiClient.ts diff --git a/src/backend/common/infrastructure/Atomic.ts b/src/backend/common/infrastructure/Atomic.ts index 90902dcb..46191d0d 100644 --- a/src/backend/common/infrastructure/Atomic.ts +++ b/src/backend/common/infrastructure/Atomic.ts @@ -29,6 +29,7 @@ export type SourceType = | 'maloja' | 'musikcube' | 'mpd' + | 'navidrome' | 'vlc' | 'icecast' | 'azuracast' @@ -56,6 +57,7 @@ export const sourceTypes: SourceType[] = [ 'maloja', 'musikcube', 'mpd', + 'navidrome', 'vlc', 'icecast', 'azuracast', @@ -66,7 +68,7 @@ export const isSourceType = (data: string): data is SourceType => { return sourceTypes.includes(data as SourceType); } -export const lowGranularitySources: SourceType[] = ['subsonic', 'ytmusic']; +export const lowGranularitySources: SourceType[] = ['subsonic', 'navidrome', 'ytmusic']; export type ClientType = 'maloja' diff --git a/src/backend/common/infrastructure/config/source/navidrome.ts b/src/backend/common/infrastructure/config/source/navidrome.ts new file mode 100644 index 00000000..a1710872 --- /dev/null +++ b/src/backend/common/infrastructure/config/source/navidrome.ts @@ -0,0 +1,31 @@ +import { PollingOptions } from "../common.js"; +import { CommonSourceConfig, CommonSourceData } from "./index.js"; + +export interface NavidromeData extends CommonSourceData, PollingOptions { + /** + * URL of the subsonic media server to query + * + * @examples ["http://airsonic.local"] + * */ + url: string + /** + * Username to login to the server with + * + * @example ["MyUser"] + * */ + user: string + + /** + * Password for the user to login to the server with + * + * @examples ["MyPassword"] + * */ + password: string +} +export interface NavidromeSourceConfig extends CommonSourceConfig { + data: NavidromeData +} + +export interface NavidromeSourceAIOConfig extends NavidromeSourceConfig { + type: 'navidrome' +} diff --git a/src/backend/common/infrastructure/config/source/sources.ts b/src/backend/common/infrastructure/config/source/sources.ts index 7958c99b..1dfe4140 100644 --- a/src/backend/common/infrastructure/config/source/sources.ts +++ b/src/backend/common/infrastructure/config/source/sources.ts @@ -23,6 +23,7 @@ import { YTMusicSourceAIOConfig, YTMusicSourceConfig } from "./ytmusic.js"; import { IcecastSourceAIOConfig, IcecastSourceConfig } from "./icecast.js"; import { KoitoSourceAIOConfig, KoitoSourceConfig } from "./koito.js"; import { MalojaSourceAIOConfig, MalojaSourceConfig } from "./maloja.js"; +import { NavidromeSourceAIOConfig, NavidromeSourceConfig } from "./navidrome.js"; export type SourceConfig = @@ -50,6 +51,7 @@ export type SourceConfig = | MusikcubeSourceConfig | MusicCastSourceConfig | MPDSourceConfig + | NavidromeSourceConfig | VLCSourceConfig | IcecastSourceConfig | AzuracastSourceConfig @@ -80,6 +82,7 @@ export type SourceAIOConfig = | MusikcubeSourceAIOConfig | MusicCastSourceAIOConfig | MPDSourceAIOConfig + | NavidromeSourceAIOConfig | VLCSourceAIOConfig | IcecastSourceAIOConfig | AzuracastSourceAIOConfig diff --git a/src/backend/common/vendor/navidrome/NavidromeApiClient.ts b/src/backend/common/vendor/navidrome/NavidromeApiClient.ts new file mode 100644 index 00000000..848214c5 --- /dev/null +++ b/src/backend/common/vendor/navidrome/NavidromeApiClient.ts @@ -0,0 +1,92 @@ +import dayjs from "dayjs"; +import { PlayObject, URLData } from "../../../../core/Atomic.js"; +import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER } from "../../infrastructure/Atomic.js"; +import { KoitoData, ListenObjectResponse, ListensResponse } from "../../infrastructure/config/client/koito.js"; +import AbstractApiClient from "../AbstractApiClient.js"; +import { getBaseFromUrl, isPortReachableConnect, joinedUrl, normalizeWebAddress } from "../../../utils/NetworkUtils.js"; +import request, { Request, Response } from 'superagent'; +import { UpstreamError } from "../../errors/UpstreamError.js"; +import { playToListenPayload } from "../ListenbrainzApiClient.js"; +import { SubmitPayload } from '../listenbrainz/interfaces.js'; +import { ListenType } from '../listenbrainz/interfaces.js'; +import { parseRegexSingleOrFail } from "../../../utils.js"; +import { NavidromeData } from "../../infrastructure/config/source/navidrome.js"; +import { isSuperAgentResponseError } from "../../errors/ErrorUtils.js"; + +export class NavidromeApiClient extends AbstractApiClient { + + declare config: NavidromeData; + url: URLData; + + token?: string; + subsonicToken?: string; + subsonicSalt?: String + userId?: string + + constructor(name: any, config: NavidromeData, options: AbstractApiOptions) { + super('Navidrome', name, config, options); + + const { + url + } = this.config; + + this.url = normalizeWebAddress(url); + this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${this.url.url}'`) + } + + callApi = async (req: Request): Promise => { + + let resp: Response; + try { + req.set('x-nd-authorization', `Bearer ${this.token}`); + req.set('x-nd-client-unique-id', '2297e6c3-4f63-45ef-b60b-5a959e23e666') + resp = await req; + return resp as T; + } catch (e) { + if(isSuperAgentResponseError(e)) { + resp = e.response; + } + throw e; + } finally { + if(resp.headers !== undefined && resp.headers['x-nd-authorization'] !== undefined) { + this.token = resp.headers['x-nd-authorization']; + } + } + } + + testConnection = async () => { + try { + await isPortReachableConnect(this.url.port, { host: this.url.url.hostname }); + } catch (e) { + throw new Error(`Navidrome server is not reachable at ${this.url.url.hostname}:${this.url.port}`, { cause: e }); + } + } + + testAuth = async () => { + try { + const resp = await request.post(`${joinedUrl(this.url.url, '/auth/login')}`) + .type('json') + .send({ + username: this.config.user, + password: this.config.password + }); + this.token = resp.body.token; + this.subsonicToken = resp.body.subsonicToken; + this.subsonicSalt = resp.body.subsonicSalt; + this.userId = resp.body.id + + return true; + } catch (e) { + throw new Error('Could not validate Navidrome user/password', { cause: e }); + } + } + + getRecentlyPlayed = async (maxTracks: number): Promise => { + try { + return []; + } catch (e) { + this.logger.error(`Error encountered while getting User listens | Error => ${e.message}`); + return []; + } + } +} \ No newline at end of file diff --git a/src/backend/sources/NavidromeSource.ts b/src/backend/sources/NavidromeSource.ts index 3b8f36b3..03fa587c 100644 --- a/src/backend/sources/NavidromeSource.ts +++ b/src/backend/sources/NavidromeSource.ts @@ -13,6 +13,9 @@ import { parseRetryAfterSecsFromObj, removeDuplicates, sleep } from "../utils.js import { findCauseByFunc } from "../utils/ErrorUtils.js"; import { RecentlyPlayedOptions } from "./AbstractSource.js"; import MemorySource from "./MemorySource.js"; +import { NavidromeApiClient } from '../common/vendor/navidrome/NavidromeApiClient.js'; +import { joinedUrl } from '../utils/NetworkUtils.js'; +import { NavidromeSourceConfig } from '../common/infrastructure/config/source/navidrome.js'; dayjs.extend(isSameOrAfter); @@ -32,22 +35,25 @@ export class NavidromeSource extends MemorySource { multiPlatform: boolean = true; - declare config: SubSonicSourceConfig; + declare config: NavidromeSourceConfig; usersAllow: string[] = []; sourceData: SourceIdentifierData = {}; - constructor(name: any, config: SubSonicSourceConfig, internal: InternalConfig, emitter: EventEmitter) { + naviApi: NavidromeApiClient; + + constructor(name: any, config: NavidromeSourceConfig, internal: InternalConfig, emitter: EventEmitter) { const { data: { ...restData } = {} } = config; const subsonicConfig = {...config, data: {...restData}}; - super('subsonic', name, subsonicConfig, internal,emitter); + super('navidrome', name, subsonicConfig, internal,emitter); this.canPoll = true; + this.naviApi = new NavidromeApiClient(name, this.config.data, {logger: this.logger}); } static formatPlayObj(obj: any, options: FormatPlayObjectOptions & { sourceData?: SourceIdentifierData } = {}): PlayObject { @@ -243,6 +249,8 @@ export class NavidromeSource extends MemorySource { const resp = await this.callApi(request.get(`${url}/rest/ping`)); this.sourceData = resp as SourceIdentifierData; this.logger.info(`Subsonic Server reachable: ${identifiersFromResponse(resp)}`); + + await this.naviApi.testConnection(); return true; } catch (e) { @@ -271,6 +279,7 @@ export class NavidromeSource extends MemorySource { try { await this.callApi(request.get(`${url}/rest/ping`)); this.logger.info('Subsonic API Status: ok'); + await this.naviApi.testAuth(); return true; } catch (e) { throw e; @@ -281,6 +290,7 @@ export class NavidromeSource extends MemorySource { const {formatted = false} = options; const {url} = this.config.data; const resp = await this.callApi(request.get(`${url}/rest/getNowPlaying`)); + const queue = await this.naviApi.callApi(request.get(`${joinedUrl(this.naviApi.url.url, '/api/queue')}`)); const { nowPlaying: { entry = [] diff --git a/src/backend/sources/ScrobbleSources.ts b/src/backend/sources/ScrobbleSources.ts index 88f89565..21c71507 100644 --- a/src/backend/sources/ScrobbleSources.ts +++ b/src/backend/sources/ScrobbleSources.ts @@ -69,6 +69,8 @@ import DeezerInternalSource from './DeezerInternalSource.js'; import KoitoSource from './KoitoSource.js'; import { KoitoSourceConfig } from '../common/infrastructure/config/source/koito.js'; import MalojaSource from './MalojaSource.js'; +import { NavidromeSource } from './NavidromeSource.js'; +import { NavidromeSourceConfig } from '../common/infrastructure/config/source/navidrome.js'; type groupedNamedConfigs = {[key: string]: ParsedConfig[]}; @@ -200,6 +202,9 @@ export default class ScrobbleSources { case 'mpd': this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("MPDSourceConfig"); break; + case 'navidrome': + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("NavidromeSourceConfig"); + break; case 'vlc': this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("VLCSourceConfig"); break; @@ -808,6 +813,9 @@ export default class ScrobbleSources { case 'subsonic': newSource = new SubsonicSource(name, compositeConfig as SubSonicSourceConfig, this.internalConfig, this.emitter); break; + case 'navidrome': + newSource = new NavidromeSource(name, compositeConfig as NavidromeSourceConfig, this.internalConfig, this.emitter); + break; case 'jellyfin': const jfConfig = compositeConfig as (JellySourceConfig | JellyApiSourceConfig); if(jfConfig.data.user !== undefined) {