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

fix remaining erasure issues

FoxxMD (Jul 9, 2026, 1:59 AM UTC) 77de2a75 69b19da6

+35 -36
+2 -3
src/backend/common/errors/NodeErrors.ts
··· 1 1 import { findCauseByFunc } from "../../utils/ErrorUtils.js"; 2 2 import { isArbitraryObject } from "../infrastructure/Atomic.js"; 3 - import ErrnoException = NodeJS.ErrnoException; 4 3 5 4 export type NodeNetworkErrorCode = 'ENOTFOUND' | 'ETIMEDOUT' | 'EAI_AGAIN' | 'ECONNRESET' | 'ECONNREFUSED' | 'ERRADDRINUSE' | 'EADDRNOTAVAIL' | 'ECONNABORTED' | 'EHOSTUNREACH'; 6 5 export const NETWORK_ERROR_CODES: NodeNetworkErrorCode[] = ['ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN', 'ECONNRESET', 'ECONNREFUSED', 'ERRADDRINUSE', 'EADDRNOTAVAIL', 'ECONNABORTED', 'EHOSTUNREACH']; 7 6 8 7 export const NETWORK_ERROR_FAILURE_CODES: NodeNetworkErrorCode[] = ['ENOTFOUND', 'EAI_AGAIN', 'ECONNRESET', 'ECONNREFUSED', 'ERRADDRINUSE', 'EADDRNOTAVAIL', 'ECONNABORTED', 'EHOSTUNREACH']; 9 8 10 - export const isErrnoException = (error: unknown): error is ErrnoException => { 9 + export const isErrnoException = (error: unknown): error is NodeJS.ErrnoException => { 11 10 return isArbitraryObject(error) && 12 11 error instanceof Error && 13 12 (typeof error.errno === "number" || typeof error.errno === "undefined") && ··· 16 15 (typeof error.syscall === "string" || typeof error.syscall === "undefined"); 17 16 } 18 17 19 - export interface NodeNetworkException extends ErrnoException { 18 + export interface NodeNetworkException extends NodeJS.ErrnoException { 20 19 code: NodeNetworkErrorCode 21 20 } 22 21
+5 -5
src/backend/common/infrastructure/Atomic.ts
··· 1 - import { type Logger } from '@foxxmd/logging'; 2 - import { Dayjs, type ManipulateType } from "dayjs"; 1 + import type { Logger } from '@foxxmd/logging'; 2 + import type { Dayjs, ManipulateType } from "dayjs"; 3 3 import { type Request, type Response } from "express"; 4 - import { type NextFunction, type ParamsDictionary, type Query } from "express-serve-static-core"; 4 + import type { NextFunction, ParamsDictionary, Query } from "express-serve-static-core"; 5 5 import { FixedSizeList } from 'fixed-size-list'; 6 6 import { type ErrorLike, isPlayObject, type PlayMeta, type PlayObject, type PlayObjectMinimal, type UnixTimestamp } from "../../../core/Atomic.js"; 7 7 import TupleMap from "../TupleMap.js"; 8 8 import { MusicBrainzApi } from 'musicbrainz-api'; 9 - import { type SourceType } from './config/source/sources.js'; 9 + import type { SourceType } from './config/source/sources.js'; 10 10 import { type ClientType, clientTypes } from './config/client/clients.js'; 11 - import assert, { AssertionError } from 'assert'; 11 + import assert from 'assert'; 12 12 13 13 export const lowGranularitySources: SourceType[] = ['subsonic', 'ytmusic']; 14 14
+19 -19
src/backend/sources/SpotifySource.ts
··· 31 31 import { findCauseByFunc } from "../utils/ErrorUtils.js"; 32 32 import { joinedUrl } from "../utils/NetworkUtils.js"; 33 33 import { type RecentlyPlayedOptions } from "./AbstractSource.js"; 34 - import AlbumObjectSimplified = SpotifyApi.AlbumObjectSimplified; 35 - import ArtistObjectSimplified = SpotifyApi.ArtistObjectSimplified; 36 - import CurrentlyPlayingObject = SpotifyApi.CurrentlyPlayingObject; 37 - import PlayHistoryObject = SpotifyApi.PlayHistoryObject; 38 - import TrackObjectFull = SpotifyApi.TrackObjectFull; 39 - import UserDevice = SpotifyApi.UserDevice; 34 + // import SpotifyApi.AlbumObjectSimplified = SpotifyApi.SpotifyApi.AlbumObjectSimplified; 35 + // import SpotifyApi.ArtistObjectSimplified = SpotifyApi.SpotifyApi.ArtistObjectSimplified; 36 + // import SpotifyApi.CurrentlyPlayingObject = SpotifyApi.SpotifyApi.CurrentlyPlayingObject; 37 + // import SpotifyApi.PlayHistoryObject = SpotifyApi.SpotifyApi.PlayHistoryObject; 38 + // import SpotifyApi.TrackObjectFull = SpotifyApi.SpotifyApi.TrackObjectFull; 39 + // import SpotifyApi.UserDevice = SpotifyApi.SpotifyApi.UserDevice; 40 40 import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; 41 41 import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; 42 42 import { createGetScrobblesForTimeRangeFunc } from "../utils/ListenFetchUtils.js"; ··· 81 81 this.getScrobblesForTimeRange = createGetScrobblesForTimeRangeFunc(this, this.logger); 82 82 } 83 83 84 - static formatPlayObj(obj: PlayHistoryObject | CurrentlyPlayingObject, options: FormatPlayObjectOptions = {}): PlayObject { 84 + static formatPlayObj(obj: SpotifyApi.PlayHistoryObject | SpotifyApi.CurrentlyPlayingObject, options: FormatPlayObjectOptions = {}): PlayObject { 85 85 86 86 const { 87 87 newFromSource = false 88 88 } = options; 89 89 90 - let artists: ArtistObjectSimplified[]; 91 - let album: AlbumObjectSimplified; 90 + let artists: SpotifyApi.ArtistObjectSimplified[]; 91 + let album: SpotifyApi.AlbumObjectSimplified; 92 92 let name: string; 93 93 let duration_ms: number; 94 94 let played_at: Dayjs; ··· 165 165 isrc 166 166 } = {}, 167 167 track_number 168 - } = item as TrackObjectFull; 168 + } = item as SpotifyApi.TrackObjectFull; 169 169 170 - delete (obj.item as TrackObjectFull).available_markets; 171 - if((obj.item as TrackObjectFull).album !== undefined) { 172 - delete (obj.item as TrackObjectFull).album.available_markets; 170 + delete (obj.item as SpotifyApi.TrackObjectFull).available_markets; 171 + if((obj.item as SpotifyApi.TrackObjectFull).album !== undefined) { 172 + delete (obj.item as SpotifyApi.TrackObjectFull).album.available_markets; 173 173 } 174 174 175 175 scrobbleTsSOC = SCROBBLE_TS_SOC_START; ··· 196 196 images = [] 197 197 } = album || {}; 198 198 199 - let actualAlbumArtists: ArtistObjectSimplified[] = []; 199 + let actualAlbumArtists: SpotifyApi.ArtistObjectSimplified[] = []; 200 200 if ((artists.length !== albumArtists.length) || !artists.every(artist => albumArtists.some(albumArtist => artist.id === albumArtist.id))) { 201 201 // only include album artists if they are not the EXACT same as the track artists 202 202 // ...if they aren't the exact same then include all artists, even if they are duplicates of track artists ··· 438 438 const result = await this.callApi<ReturnType<typeof this.spotifyApi.getMyRecentlyPlayedTracks>>((api: SpotifyWebApi) => api.getMyRecentlyPlayedTracks(options)); 439 439 440 440 let more = true; 441 - let plays = result.body.items.map((x: PlayHistoryObject) => SpotifySource.formatPlayObj(x)).sort(sortByOldestPlayDate); 441 + let plays = result.body.items.map((x: SpotifyApi.PlayHistoryObject) => SpotifySource.formatPlayObj(x)).sort(sortByOldestPlayDate); 442 442 443 443 if(to !== undefined) { 444 444 const toDate = dayjs.unix(to); ··· 455 455 } 456 456 457 457 return { 458 - data: result.body.items.map((x: PlayHistoryObject) => SpotifySource.formatPlayObj(x)).sort(sortByOldestPlayDate), 458 + data: result.body.items.map((x: SpotifyApi.PlayHistoryObject) => SpotifySource.formatPlayObj(x)).sort(sortByOldestPlayDate), 459 459 meta: { 460 460 ...params, 461 461 total: result.body.total, ··· 493 493 return undefined; 494 494 } 495 495 496 - getCurrentPlaybackState = async (logError = true): Promise<{device?: UserDevice, playerState?: PlayerStateData}> => { 496 + getCurrentPlaybackState = async (logError = true): Promise<{device?: SpotifyApi.UserDevice, playerState?: PlayerStateData}> => { 497 497 try { 498 498 const funcState = (api: SpotifyWebApi) => api.getMyCurrentPlaybackState(); 499 499 const res = await this.callApi<ReturnType<typeof this.spotifyApi.getMyCurrentPlaybackState>>(funcState); ··· 634 634 protected getBackloggedPlays = async (options: RecentlyPlayedOptions = {}) => await this.getPlayHistory({formatted: true, ...options}) 635 635 } 636 636 637 - const asPlayHistoryObject = (obj: object): obj is PlayHistoryObject => 'played_at' in obj 637 + const asPlayHistoryObject = (obj: object): obj is SpotifyApi.PlayHistoryObject => 'played_at' in obj 638 638 639 - const asCurrentlyPlayingObject = (obj: object): obj is CurrentlyPlayingObject => 'is_playing' in obj 639 + const asCurrentlyPlayingObject = (obj: object): obj is SpotifyApi.CurrentlyPlayingObject => 'is_playing' in obj 640 640 641 641 const hasApiPermissionError = (e: Error): boolean => findCauseByFunc(e, (err) => err.message.includes('Permissions missing')) !== undefined 642 642
+9 -9
src/core/Atomic.ts
··· 1 - import { type LogDataPretty, type LogLevel } from "@foxxmd/logging"; 2 - import { Dayjs } from "dayjs"; 3 - import { type AdditionalTrackInfoResponse } from "../backend/common/vendor/listenbrainz/interfaces.js"; 4 - import type { DeepOmit, Merge, RequiredKeys, StrictOmit } from "ts-essentials"; 5 - import { type ErrorObject } from "serialize-error"; 6 - import { type PlayPlatformIdStr } from "../backend/common/infrastructure/Atomic.js"; 7 - import { type FlowControlTerm, type TransformHook } from "../backend/common/infrastructure/Transform.js"; 8 - import { type Changeset } from "json-diff-ts"; 9 - import {type IParseBaseOptions } from 'qs'; 1 + import type { LogDataPretty, LogLevel } from "@foxxmd/logging"; 2 + import type { Dayjs } from "dayjs"; 3 + import type { AdditionalTrackInfoResponse } from "../backend/common/vendor/listenbrainz/interfaces.js"; 4 + import type { Merge, RequiredKeys, StrictOmit } from "ts-essentials"; 5 + import type { ErrorObject } from "serialize-error"; 6 + import type { PlayPlatformIdStr } from "../backend/common/infrastructure/Atomic.js"; 7 + import type { FlowControlTerm, TransformHook } from "../backend/common/infrastructure/Transform.js"; 8 + import type { Changeset } from "json-diff-ts"; 9 + import type { IParseBaseOptions } from 'qs'; 10 10 11 11 export type ComponentTypeClient = 'client'; 12 12 export const COMPONENT_TYPE_CLIENT: ComponentTypeClient = 'client';