[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.

feat(icecast): Switch to metadata-only parsing

FoxxMD (Mar 19, 2025, 4:34 PM UTC) efbf2da8 38d1db2f

+188 -113
+16 -1
package-lock.json
··· 53 53 "glob": "^11.0.0", 54 54 "google-auth-library": "^9.15.0", 55 55 "gotify": "^1.1.0", 56 - "icecast-metadata-js": "^1.2.9", 56 + "icecast-metadata-stats": "^0.1.12", 57 57 "iso-websocket": "^0.3.0", 58 58 "iti": "^0.6.0", 59 59 "json5": "^2.2.3", ··· 6048 6048 "integrity": "sha512-8YqPrJ4AjM64O28xF9TSUUFczxnTKwXwnIPmZKRxdbaZb6hn0nP+ke1OGNA+UsIfLpNRW4acDDBkIkbynYVQig==", 6049 6049 "dependencies": { 6050 6050 "codec-parser": "2.5.0" 6051 + }, 6052 + "funding": { 6053 + "type": "individual", 6054 + "url": "https://github.com/sponsors/eshaz" 6055 + } 6056 + }, 6057 + "node_modules/icecast-metadata-stats": { 6058 + "version": "0.1.12", 6059 + "resolved": "https://registry.npmjs.org/icecast-metadata-stats/-/icecast-metadata-stats-0.1.12.tgz", 6060 + "integrity": "sha512-qywYIIvxjAmZIFNUXMVZ/IgIJh87z0W6oOmJ5htPw3SUauXcYoY6rRexvzN5Ibct8hXsqoTcB+k8m6Wa53bfJg==", 6061 + "dependencies": { 6062 + "icecast-metadata-js": "1.2.9" 6063 + }, 6064 + "engines": { 6065 + "node": ">=18.0.0" 6051 6066 }, 6052 6067 "funding": { 6053 6068 "type": "individual",
+1 -1
package.json
··· 85 85 "glob": "^11.0.0", 86 86 "google-auth-library": "^9.15.0", 87 87 "gotify": "^1.1.0", 88 - "icecast-metadata-js": "^1.2.9", 88 + "icecast-metadata-stats": "^0.1.12", 89 89 "iso-websocket": "^0.3.0", 90 90 "iti": "^0.6.0", 91 91 "json5": "^2.2.3",
+44
patches/icecast-metadata-stats+0.1.12.patch
··· 1 + diff --git a/node_modules/icecast-metadata-stats/src/IcecastMetadataStats.js b/node_modules/icecast-metadata-stats/src/IcecastMetadataStats.js 2 + index 7b70725..8836a99 100644 3 + --- a/node_modules/icecast-metadata-stats/src/IcecastMetadataStats.js 4 + +++ b/node_modules/icecast-metadata-stats/src/IcecastMetadataStats.js 5 + @@ -59,6 +59,7 @@ const sources = Symbol(); 6 + const interval = Symbol(); 7 + const onStats = Symbol(); 8 + const onStatsFetch = Symbol(); 9 + +const onError = Symbol(); 10 + 11 + const state = Symbol(); 12 + const intervalId = Symbol(); 13 + @@ -106,6 +107,7 @@ export default class IcecastMetadataStats { 14 + [interval]: (options.interval || 30) * 1000, 15 + [onStats]: options.onStats || noOp, 16 + [onStatsFetch]: options.onStatsFetch || noOp, 17 + + [onError]: options.onError, 18 + [icyMetaInt]: options.icyMetaInt, 19 + [icyCharacterEncoding]: options.icyCharacterEncoding, 20 + [icyDetectionTimeout]: options.icyDetectionTimeout, 21 + @@ -425,13 +427,21 @@ export default class IcecastMetadataStats { 22 + signal: p.get(this)[controller].signal, 23 + }) 24 + .then((res) => { 25 + - if (!res.ok) throw new Error(`HTTP Error ${res.status}`); 26 + + if (!res.ok) { 27 + + const responseError = new Error(`HTTP Error ${res.status}`); 28 + + responseError.res = res; 29 + + throw responseError; 30 + + } 31 + return res; 32 + }) 33 + .then(mapper) 34 + .catch((e) => { 35 + if (e.name !== "AbortError") { 36 + - console.warn(`Failed to fetch ${p.get(this)[endpoint]}`, e); 37 + + if(p.get(this)[onError] !== undefined) { 38 + + p.get(this)[onError](new Error(`Failed to fetch ${p.get(this)[endpoint]}`, {cause: e})); 39 + + } else { 40 + + console.warn(`Failed to fetch ${p.get(this)[endpoint]}`, e); 41 + + } 42 + } 43 + }) 44 + .finally(() => {
+33 -13
src/backend/common/infrastructure/config/source/icecast.ts
··· 1 - import { CommonSourceConfig, CommonSourceData } from "./index.js"; 1 + import { CommonSourceConfig, CommonSourceData, CommonSourceOptions } from "./index.js"; 2 2 3 3 4 4 export interface IcecastMetadata { 5 - /** Title of the ICY metadata update, usually Artist - Title */ 6 - StreamTitle?: string 7 - /** URL of the ICY metadata update, usually album art */ 8 - StreamUrl?: string 9 - /** Title of the OGG metadata update, usually Artist - Title */ 10 - TITLE?: string 5 + icy?: { 6 + /** Title of the ICY metadata update, usually Artist - Title */ 7 + StreamTitle?: string 8 + /** URL of the ICY metadata update, usually album art */ 9 + StreamUrl?: string 10 + } 11 + ogg?: { 12 + /** Title of the OGG metadata update, usually Artist - Title */ 13 + TITLE?: string 14 + ALBUM?: string 15 + ARTIST?: string 16 + } 11 17 } 12 18 13 - export interface IcecastMetadataResponse { 14 - metadata: IcecastMetadata 15 - stats: { 16 - [key:string]: number 17 - } 19 + export type IcecastSource = 'icy' | 'ogg' | 'icestats' | 'stats' | 'sevenhtml' | 'nextsongs'; 20 + 21 + export interface IcecastOptions { 22 + sources?: IcecastSource[] 23 + icestatsEndpoint?: string 24 + statsEndpoint?: string 25 + nextsongsEndpoint?: string 26 + sevenhtmlEndpoint?: string 27 + icyMetaInt?: number 18 28 } 19 29 20 - export interface IcecastData extends CommonSourceData { 30 + export interface IcecastData extends CommonSourceData, IcecastOptions { 21 31 /** 22 32 * The Icecast stream URL 23 33 * */ 24 34 url: string 25 35 } 26 36 37 + export interface IcecastSourceOptions extends CommonSourceOptions { 38 + /** 39 + * For Sources that support manual listening, should MS default to scrobbling when no manual listening flag is set? 40 + * 41 + * @default false 42 + */ 43 + scrobbleOnStart?: boolean 44 + } 45 + 27 46 export interface IcecastSourceConfig extends CommonSourceConfig { 28 47 data: IcecastData 48 + options?: IcecastSourceOptions 29 49 } 30 50 31 51 export interface IcecastSourceAIOConfig extends IcecastSourceConfig {
+89 -97
src/backend/sources/IcecastSource.ts
··· 10 10 } from "../common/infrastructure/Atomic.js"; 11 11 import { isPortReachableConnect, normalizeWebAddress } from "../utils/NetworkUtils.js"; 12 12 import MemorySource from "./MemorySource.js"; 13 - import { IcecastMetadata, IcecastMetadataResponse, IcecastSourceConfig } from "../common/infrastructure/config/source/icecast.js"; 14 - import { IcecastReadableStream } from 'icecast-metadata-js'; 15 - import { parseArtistCredits, parseCredits, parseTrackCredits } from "../utils/StringUtils.js"; 16 - import { sleep } from "../utils.js"; 13 + import { IcecastMetadata, IcecastSourceConfig } from "../common/infrastructure/config/source/icecast.js"; 14 + import IcecastMetadataStats from "icecast-metadata-stats"; 15 + import { parseArtistCredits, parseTrackCredits } from "../utils/StringUtils.js"; 16 + import { isDebugMode, sleep } from "../utils.js"; 17 17 18 18 19 19 export class IcecastSource extends MemorySource { ··· 22 22 23 23 urlData!: URLData; 24 24 25 - currentMetadata?: IcecastMetadataResponse 26 - streamAbort?: AbortController; 25 + currentMetadata?: IcecastMetadata 26 + statsListener?: IcecastMetadataStats 27 27 28 28 streamError?: Error; 29 29 streaming: boolean = false; 30 30 31 31 constructor(name: any, config: IcecastSourceConfig, internal: InternalConfig, emitter: EventEmitter) { 32 32 const { 33 - data = {} 33 + data, 34 + options = {}, 34 35 } = config; 35 36 const { 36 37 ...rest 37 - } = data; 38 - super('icecast', name, { ...config, data: { ...rest } }, internal, emitter); 38 + } = data || {}; 39 + super('icecast', name, { ...config, options: {scrobbleOnStart: false, ...options}, data: { ...rest } }, internal, emitter); 39 40 40 41 this.requiresAuth = false; 41 42 this.canPoll = true; ··· 70 71 } 71 72 72 73 readIcecastStream = () => { 73 - this.streamAbort = new AbortController(); 74 - this.streamError = undefined; 74 + if (this.statsListener !== undefined) { 75 + this.statsListener.stop(); 76 + } 75 77 76 - fetch(this.urlData.url.toString(), { 77 - signal: this.streamAbort.signal, 78 - method: "GET", 79 - headers: { 80 - "Icy-MetaData": "1", 81 - } 82 - }) 83 - .then(async (response) => { 78 + const { 79 + url, 80 + ...icecastUserOpts 81 + } = this.config.data; 84 82 85 - const contentType = response.headers.get('Content-Type'); 86 - if(!['audio','ogg','aac','mp3'].some(x => contentType.includes(x))) { 87 - this.logger.warn(`Icecase URL response content-type does not look like audio! It will probably fail. Check your URL? Content-Type: ${contentType}`); 88 - } 83 + const icecastOpts = {sources: ["icy", 'ogg'], ...icecastUserOpts}; 89 84 90 - const opts: Record<string, any> = { 91 - metadataTypes: ["icy", "ogg"] 92 - }; 93 - 94 - const icyIntHeader = response.headers.get('Icy-MetaInt'); 95 - if(icyIntHeader !== null) { 96 - const icyInt = Number.parseInt(icyIntHeader); 97 - if(Number.isNaN(icyInt)) { 98 - this.logger.warn(`Could not parse value of header 'Icy-MetaInt' to a number: ${icyIntHeader}`); 99 - } else { 100 - opts.icyMetaInt = icyInt; 85 + this.statsListener = new IcecastMetadataStats(this.urlData.url.toString(), { 86 + ...icecastOpts, 87 + onStats: (stats) => { 88 + if(isDebugMode()) { 89 + this.logger.debug(stats); 101 90 } 102 - } 103 - 104 - const icecast = new IcecastReadableStream( 105 - response, 106 - { 107 - ...opts, 108 - onStream: (stream) => { 109 - this.streaming = true; 110 - if(!this.polling || this.userPollingStopSignal !== undefined) { 111 - this.streamAbort.abort(); 112 - } 113 - }, 114 - onMetadata: (meta) => { 115 - console.log(meta) 116 - this.currentMetadata = meta; 117 - }, 118 - onMetadataFailed: (metatype) => { 119 - console.log(metatype) 120 - }, 121 - onError: (err) => { 122 - if(typeof err === 'string' && err === 'This stream is not an Ogg stream. No Ogg metadata will be returned.') { 123 - this.logger.debug('Stream is ICY only, no OGG metadata will be returned'); 124 - } else { 125 - this.streaming = false; 126 - this.streamError = typeof err === 'string' ? new Error(err) : err; 127 - } 91 + this.currentMetadata = stats; 92 + if (this.polling === false) { 93 + this.statsListener.stop(); 94 + this.streaming = false; 128 95 } 129 - } 130 - ); 131 - 132 - await icecast.startReading(); 133 - }) 134 - .catch((err) => { 135 - if(err.name !== 'AbortError') { 136 - this.streamError = new Error('Error occurred while trying to communicate with Icecast URL', {cause: err}); 137 - } 138 - this.streaming = false; 139 - }); 96 + }, 97 + onError: (e) => { 98 + this.streaming = false; 99 + this.streamError = e; 100 + this.statsListener.stop(); 101 + }, 102 + interval: 10 103 + }); 104 + this.statsListener.start(); 105 + this.streaming = true; 106 + 140 107 } 141 108 142 109 getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { 143 110 144 - if(this.streaming === false) { 111 + if (this.streaming === false) { 145 112 this.readIcecastStream(); 146 113 // wait a bit so stream can start and parse metadata 147 114 await sleep(1000); 148 115 } 149 116 150 - if(this.streamError !== undefined) { 151 - throw new Error('Icecast stream produced an error', {cause: this.streamError}); 117 + if (this.streamError !== undefined) { 118 + if (this.streamError.cause !== undefined && ('res' in (this.streamError.cause as object))) { 119 + const res = (this.streamError.cause as Error & { res: Response }).res as Response 120 + } 121 + throw new Error('Icecast stream produced an error', { cause: this.streamError }); 152 122 } 153 123 154 - if(this.currentMetadata === undefined || this.currentMetadata.metadata === undefined) { 124 + if (this.currentMetadata === undefined) { 155 125 return this.processRecentPlays([]); 156 126 } 157 127 158 - if(this.manualListening === false) { 128 + if (this.manualListening === false || (this.config.options.scrobbleOnStart === false && this.manualListening === undefined)) { 159 129 const playerState: PlayerStateData = { 160 130 platformId: SINGLE_USER_PLATFORM_ID, 161 131 status: REPORTED_PLAYER_STATUSES.stopped, 162 132 play: undefined, 163 133 } 164 - 134 + 165 135 return this.processRecentPlays([playerState]); 166 136 } 167 137 168 - let play: PlayObject | undefined = formatPlayObj(this.currentMetadata.metadata); 169 - if(play.data.track === undefined) { 138 + let play: PlayObject | undefined = formatPlayObj(this.currentMetadata); 139 + if (play.data.track === undefined) { 170 140 play = undefined; 171 141 } 172 142 ··· 182 152 183 153 const formatPlayObj = (obj: IcecastMetadata, options: FormatPlayObjectOptions = {}): PlayObject => { 184 154 185 - const value: string = obj.StreamTitle ?? obj.TITLE; 186 155 187 156 let artist: string, 188 - track: string = value; 189 - let artists: string[] = []; 157 + track: string, 158 + artists: string[] = [], 159 + album: string; 190 160 191 - // naive implementation for now 192 - const splitStr = value.split('-').map(x => x.trim()); 161 + if (obj.ogg?.TITLE !== undefined) { 162 + const { 163 + TITLE: oggTitle, 164 + ARTIST: oggArtist, 165 + ALBUM: oggAlbum 166 + } = obj.ogg; 193 167 194 - if(splitStr.length > 1) { 195 - artist = splitStr[0]; 196 - track = value.substring(value.indexOf('-') + 1).trim(); 168 + track = oggTitle; 169 + album = oggAlbum; 197 170 198 - const artistCred = parseArtistCredits(artist); 199 - if(artistCred !== undefined) { 171 + const artistCred = parseArtistCredits(oggArtist); 172 + if (artistCred !== undefined) { 200 173 artists.push(artistCred.primary); 201 - if(artistCred.secondary !== undefined) { 174 + if (artistCred.secondary !== undefined) { 202 175 artists = artists.concat(artistCred.secondary); 203 176 } 204 177 } else { 205 - artists.push(artist); 178 + artists.push(oggArtist); 206 179 } 180 + } else if(obj.icy?.StreamTitle !== undefined) { 181 + const value: string = obj.icy.StreamTitle; 207 182 208 - const trackCred = parseTrackCredits(track); 209 - if(trackCred !== undefined) { 210 - track = trackCred.primary; 211 - if(trackCred.secondary !== undefined) { 212 - artists = artists.concat(trackCred.secondary); 183 + // naive implementation for now 184 + const splitStr = value.split('-').map(x => x.trim()); 185 + 186 + if (splitStr.length > 1) { 187 + artist = splitStr[0]; 188 + track = value.substring(value.indexOf('-') + 1).trim(); 189 + 190 + const artistCred = parseArtistCredits(artist); 191 + if (artistCred !== undefined) { 192 + artists.push(artistCred.primary); 193 + if (artistCred.secondary !== undefined) { 194 + artists = artists.concat(artistCred.secondary); 195 + } 196 + } else { 197 + artists.push(artist); 198 + } 199 + 200 + const trackCred = parseTrackCredits(track); 201 + if (trackCred !== undefined) { 202 + track = trackCred.primary; 203 + if (trackCred.secondary !== undefined) { 204 + artists = artists.concat(trackCred.secondary); 205 + } 213 206 } 214 207 } 215 - 216 208 } 217 209 218 210 return {
+5 -1
src/backend/sources/ScrobbleSources.ts
··· 480 480 case 'icecast': 481 481 const icecast = { 482 482 url: process.env.ICECAST_URL, 483 + scrobbleOnStart: process.env.ICECAST_SCROBBLE_START, 483 484 } 484 485 if (!Object.values(icecast).every(x => x === undefined)) { 485 486 configs.push({ ··· 488 489 source: 'ENV', 489 490 mode: 'single', 490 491 configureAs: defaultConfigureAs, 491 - data: icecast as IcecastData 492 + data: icecast as IcecastData, 493 + options: { 494 + scrobbleOnStart: parseBool(icecast.scrobbleOnStart) 495 + } 492 496 }); 493 497 } 494 498 break;