[READ-ONLY] Mirror of https://github.com/FoxxMD/multi-scrobbler. Scrobble plays from multiple sources to multiple clients docs.multi-scrobbler.app
deezer docker jellyfin koito lastfm listenbrainz maloja mopidy mpris music music-assistant plex scrobble self-hosted spotify subsonic tautulli youtube-music
0

Configure Feed

Select the types of activity you want to include in your feed.

poc navidrome source

FoxxMD (Sep 11, 2025, 4:04 PM UTC) 76e0ac2a 4f546867

+150 -4
+3 -1
src/backend/common/infrastructure/Atomic.ts
··· 29 29 | 'maloja' 30 30 | 'musikcube' 31 31 | 'mpd' 32 + | 'navidrome' 32 33 | 'vlc' 33 34 | 'icecast' 34 35 | 'azuracast' ··· 56 57 'maloja', 57 58 'musikcube', 58 59 'mpd', 60 + 'navidrome', 59 61 'vlc', 60 62 'icecast', 61 63 'azuracast', ··· 66 68 return sourceTypes.includes(data as SourceType); 67 69 } 68 70 69 - export const lowGranularitySources: SourceType[] = ['subsonic', 'ytmusic']; 71 + export const lowGranularitySources: SourceType[] = ['subsonic', 'navidrome', 'ytmusic']; 70 72 71 73 export type ClientType = 72 74 'maloja'
+31
src/backend/common/infrastructure/config/source/navidrome.ts
··· 1 + import { PollingOptions } from "../common.js"; 2 + import { CommonSourceConfig, CommonSourceData } from "./index.js"; 3 + 4 + export interface NavidromeData extends CommonSourceData, PollingOptions { 5 + /** 6 + * URL of the subsonic media server to query 7 + * 8 + * @examples ["http://airsonic.local"] 9 + * */ 10 + url: string 11 + /** 12 + * Username to login to the server with 13 + * 14 + * @example ["MyUser"] 15 + * */ 16 + user: string 17 + 18 + /** 19 + * Password for the user to login to the server with 20 + * 21 + * @examples ["MyPassword"] 22 + * */ 23 + password: string 24 + } 25 + export interface NavidromeSourceConfig extends CommonSourceConfig { 26 + data: NavidromeData 27 + } 28 + 29 + export interface NavidromeSourceAIOConfig extends NavidromeSourceConfig { 30 + type: 'navidrome' 31 + }
+3
src/backend/common/infrastructure/config/source/sources.ts
··· 23 23 import { IcecastSourceAIOConfig, IcecastSourceConfig } from "./icecast.js"; 24 24 import { KoitoSourceAIOConfig, KoitoSourceConfig } from "./koito.js"; 25 25 import { MalojaSourceAIOConfig, MalojaSourceConfig } from "./maloja.js"; 26 + import { NavidromeSourceAIOConfig, NavidromeSourceConfig } from "./navidrome.js"; 26 27 27 28 28 29 export type SourceConfig = ··· 50 51 | MusikcubeSourceConfig 51 52 | MusicCastSourceConfig 52 53 | MPDSourceConfig 54 + | NavidromeSourceConfig 53 55 | VLCSourceConfig 54 56 | IcecastSourceConfig 55 57 | AzuracastSourceConfig ··· 80 82 | MusikcubeSourceAIOConfig 81 83 | MusicCastSourceAIOConfig 82 84 | MPDSourceAIOConfig 85 + | NavidromeSourceAIOConfig 83 86 | VLCSourceAIOConfig 84 87 | IcecastSourceAIOConfig 85 88 | AzuracastSourceAIOConfig
+92
src/backend/common/vendor/navidrome/NavidromeApiClient.ts
··· 1 + import dayjs from "dayjs"; 2 + import { PlayObject, URLData } from "../../../../core/Atomic.js"; 3 + import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER } from "../../infrastructure/Atomic.js"; 4 + import { KoitoData, ListenObjectResponse, ListensResponse } from "../../infrastructure/config/client/koito.js"; 5 + import AbstractApiClient from "../AbstractApiClient.js"; 6 + import { getBaseFromUrl, isPortReachableConnect, joinedUrl, normalizeWebAddress } from "../../../utils/NetworkUtils.js"; 7 + import request, { Request, Response } from 'superagent'; 8 + import { UpstreamError } from "../../errors/UpstreamError.js"; 9 + import { playToListenPayload } from "../ListenbrainzApiClient.js"; 10 + import { SubmitPayload } from '../listenbrainz/interfaces.js'; 11 + import { ListenType } from '../listenbrainz/interfaces.js'; 12 + import { parseRegexSingleOrFail } from "../../../utils.js"; 13 + import { NavidromeData } from "../../infrastructure/config/source/navidrome.js"; 14 + import { isSuperAgentResponseError } from "../../errors/ErrorUtils.js"; 15 + 16 + export class NavidromeApiClient extends AbstractApiClient { 17 + 18 + declare config: NavidromeData; 19 + url: URLData; 20 + 21 + token?: string; 22 + subsonicToken?: string; 23 + subsonicSalt?: String 24 + userId?: string 25 + 26 + constructor(name: any, config: NavidromeData, options: AbstractApiOptions) { 27 + super('Navidrome', name, config, options); 28 + 29 + const { 30 + url 31 + } = this.config; 32 + 33 + this.url = normalizeWebAddress(url); 34 + this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${this.url.url}'`) 35 + } 36 + 37 + callApi = async <T = Response>(req: Request): Promise<T> => { 38 + 39 + let resp: Response; 40 + try { 41 + req.set('x-nd-authorization', `Bearer ${this.token}`); 42 + req.set('x-nd-client-unique-id', '2297e6c3-4f63-45ef-b60b-5a959e23e666') 43 + resp = await req; 44 + return resp as T; 45 + } catch (e) { 46 + if(isSuperAgentResponseError(e)) { 47 + resp = e.response; 48 + } 49 + throw e; 50 + } finally { 51 + if(resp.headers !== undefined && resp.headers['x-nd-authorization'] !== undefined) { 52 + this.token = resp.headers['x-nd-authorization']; 53 + } 54 + } 55 + } 56 + 57 + testConnection = async () => { 58 + try { 59 + await isPortReachableConnect(this.url.port, { host: this.url.url.hostname }); 60 + } catch (e) { 61 + throw new Error(`Navidrome server is not reachable at ${this.url.url.hostname}:${this.url.port}`, { cause: e }); 62 + } 63 + } 64 + 65 + testAuth = async () => { 66 + try { 67 + const resp = await request.post(`${joinedUrl(this.url.url, '/auth/login')}`) 68 + .type('json') 69 + .send({ 70 + username: this.config.user, 71 + password: this.config.password 72 + }); 73 + this.token = resp.body.token; 74 + this.subsonicToken = resp.body.subsonicToken; 75 + this.subsonicSalt = resp.body.subsonicSalt; 76 + this.userId = resp.body.id 77 + 78 + return true; 79 + } catch (e) { 80 + throw new Error('Could not validate Navidrome user/password', { cause: e }); 81 + } 82 + } 83 + 84 + getRecentlyPlayed = async (maxTracks: number): Promise<any> => { 85 + try { 86 + return []; 87 + } catch (e) { 88 + this.logger.error(`Error encountered while getting User listens | Error => ${e.message}`); 89 + return []; 90 + } 91 + } 92 + }
+13 -3
src/backend/sources/NavidromeSource.ts
··· 13 13 import { findCauseByFunc } from "../utils/ErrorUtils.js"; 14 14 import { RecentlyPlayedOptions } from "./AbstractSource.js"; 15 15 import MemorySource from "./MemorySource.js"; 16 + import { NavidromeApiClient } from '../common/vendor/navidrome/NavidromeApiClient.js'; 17 + import { joinedUrl } from '../utils/NetworkUtils.js'; 18 + import { NavidromeSourceConfig } from '../common/infrastructure/config/source/navidrome.js'; 16 19 17 20 dayjs.extend(isSameOrAfter); 18 21 ··· 32 35 33 36 multiPlatform: boolean = true; 34 37 35 - declare config: SubSonicSourceConfig; 38 + declare config: NavidromeSourceConfig; 36 39 37 40 usersAllow: string[] = []; 38 41 39 42 sourceData: SourceIdentifierData = {}; 40 43 41 - constructor(name: any, config: SubSonicSourceConfig, internal: InternalConfig, emitter: EventEmitter) { 44 + naviApi: NavidromeApiClient; 45 + 46 + constructor(name: any, config: NavidromeSourceConfig, internal: InternalConfig, emitter: EventEmitter) { 42 47 const { 43 48 data: { 44 49 ...restData 45 50 } = {} 46 51 } = config; 47 52 const subsonicConfig = {...config, data: {...restData}}; 48 - super('subsonic', name, subsonicConfig, internal,emitter); 53 + super('navidrome', name, subsonicConfig, internal,emitter); 49 54 50 55 this.canPoll = true; 56 + this.naviApi = new NavidromeApiClient(name, this.config.data, {logger: this.logger}); 51 57 } 52 58 53 59 static formatPlayObj(obj: any, options: FormatPlayObjectOptions & { sourceData?: SourceIdentifierData } = {}): PlayObject { ··· 243 249 const resp = await this.callApi(request.get(`${url}/rest/ping`)); 244 250 this.sourceData = resp as SourceIdentifierData; 245 251 this.logger.info(`Subsonic Server reachable: ${identifiersFromResponse(resp)}`); 252 + 253 + await this.naviApi.testConnection(); 246 254 return true; 247 255 } catch (e) { 248 256 ··· 271 279 try { 272 280 await this.callApi(request.get(`${url}/rest/ping`)); 273 281 this.logger.info('Subsonic API Status: ok'); 282 + await this.naviApi.testAuth(); 274 283 return true; 275 284 } catch (e) { 276 285 throw e; ··· 281 290 const {formatted = false} = options; 282 291 const {url} = this.config.data; 283 292 const resp = await this.callApi(request.get(`${url}/rest/getNowPlaying`)); 293 + const queue = await this.naviApi.callApi(request.get(`${joinedUrl(this.naviApi.url.url, '/api/queue')}`)); 284 294 const { 285 295 nowPlaying: { 286 296 entry = []
+8
src/backend/sources/ScrobbleSources.ts
··· 69 69 import KoitoSource from './KoitoSource.js'; 70 70 import { KoitoSourceConfig } from '../common/infrastructure/config/source/koito.js'; 71 71 import MalojaSource from './MalojaSource.js'; 72 + import { NavidromeSource } from './NavidromeSource.js'; 73 + import { NavidromeSourceConfig } from '../common/infrastructure/config/source/navidrome.js'; 72 74 73 75 type groupedNamedConfigs = {[key: string]: ParsedConfig[]}; 74 76 ··· 199 201 break; 200 202 case 'mpd': 201 203 this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("MPDSourceConfig"); 204 + break; 205 + case 'navidrome': 206 + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("NavidromeSourceConfig"); 202 207 break; 203 208 case 'vlc': 204 209 this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("VLCSourceConfig"); ··· 807 812 break; 808 813 case 'subsonic': 809 814 newSource = new SubsonicSource(name, compositeConfig as SubSonicSourceConfig, this.internalConfig, this.emitter); 815 + break; 816 + case 'navidrome': 817 + newSource = new NavidromeSource(name, compositeConfig as NavidromeSourceConfig, this.internalConfig, this.emitter); 810 818 break; 811 819 case 'jellyfin': 812 820 const jfConfig = compositeConfig as (JellySourceConfig | JellyApiSourceConfig);