Something went wrong. Try again.
[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
Something went wrong. Try again.
8.6 kB · 216 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217import dayjs, { type Dayjs } from "dayjs";import type EventEmitter from "events";import { PARSED_FROM, type PlayObjectMinimal, SOURCE_SOT } from "../../core/Atomic.ts";import { type FormatPlayObjectOptions, type InternalConfig, type PlayerStateData,} from "../common/infrastructure/Atomic.ts";import { NO_USER } from '../../core/Atomic.ts';import { REPORTED_PLAYER_STATUSES } from '../../core/Atomic.ts';import type {ReportedPlayerStatus} from '../../core/Atomic.ts';import type {PlayPlatformId} from '../../core/Atomic.ts';import type {WebScrobblerSourceConfig} from "../common/infrastructure/config/source/webscrobbler.ts";import type {WebScrobblerHookEvent, WebScrobblerPayload, WebScrobblerSong} from "../common/vendor/webscrobbler/interfaces.ts";
import { joinedUrl } from "../utils/NetworkUtils.ts";import MemorySource from "./MemorySource.ts";import type {Logger} from "@foxxmd/logging";import type {PlayerStateOptions} from "./PlayerState/AbstractPlayerState.ts";import { NowPlayingPlayerState } from "./PlayerState/NowPlayingPlayerState.ts";import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts";import { artistNameToCredit } from "../../core/StringUtils.ts";
interface WebScrobbleMeta { scrobbleAllowed?: boolean}
type WebScrobblerPlayObject = PlayObjectMinimal<Dayjs, WebScrobbleMeta>;
export class WebScrobblerSource extends MemorySource {
declare config: WebScrobblerSourceConfig;
constructor(name: any, config: WebScrobblerSourceConfig, internal: InternalConfig, emitter: EventEmitter) { super('webscrobbler', name, config, internal, emitter); this.multiPlatform = true; this.playerSourceOfTruth = SOURCE_SOT.INGRESS; this.logger.info(`Note: The player for this source is an analogue for the 'Now Playing' status exposed by ${this.type} which is NOT used for scrobbling. Instead, the 'recently played' or 'history' information provided by this source is used for scrobbles.`)
const { data = {}, data: { whitelist = [], blacklist = [], slug, } = {} } = this.config; const wl = typeof whitelist === 'string' ? [whitelist] : whitelist; const bl = typeof blacklist === 'string' ? [blacklist] : blacklist; this.config.data = { ...data, slug: slug === null ? undefined : slug, blacklist: bl.map(x => x.toLocaleLowerCase().trim()), whitelist: wl.map(x => x.toLocaleLowerCase().trim()) }; }
protected async doBuildInitData(): Promise<true | string | undefined> { this.logger.info(`Accepting requests at ${joinedUrl(this.localUrl, 'api/webscrobbler', this.config.data.slug ?? '')}`); return true; }
matchSlug(slug: string | undefined) { if (this.config.data.slug === undefined) { return slug === undefined; }
return slug.toLowerCase() === this.config.data.slug.toLowerCase().trim(); }
static webhookEventAsPlayerStatus(event: WebScrobblerHookEvent): ReportedPlayerStatus { switch (event) { case 'nowplaying': case 'scrobble': case 'resumedplaying': return REPORTED_PLAYER_STATUSES.playing; case 'paused': return REPORTED_PLAYER_STATUSES.paused; default: return REPORTED_PLAYER_STATUSES.unknown; } }
static playStateFromRequest(obj: WebScrobblerPayload): PlayerStateData { const { eventName, time = dayjs().unix(), } = obj;
const play = WebScrobblerSource.formatPlayObj(obj.data.song, {nowPlaying: eventName !== 'scrobble'}); play.meta.sourceSOT = SOURCE_SOT.INGRESS; return { platformId: [play.meta.deviceId, NO_USER], play, status: WebScrobblerSource.webhookEventAsPlayerStatus(eventName), stateUpdatedAt: dayjs.unix(time) } }
static formatPlayObj(obj: WebScrobblerSong, options: FormatPlayObjectOptions & { nowPlaying?: boolean } = {}): WebScrobblerPlayObject { const { connectorLabel, connector: { label: connectorL } = {}, controllerTabId = 'UNK', processed, parsed: { isScrobblingAllowed = true, originUrl, uniqueID }, parsed, metadata: { startTimestamp, trackUrl, albumMbId, label, } } = obj;
const track = processed.track ?? parsed.track ?? undefined; const artist = processed.artist ?? parsed.artist ?? undefined; const album = processed.album ?? parsed.album ?? undefined; const albumArtist = processed.albumArtist ?? parsed.albumArtist ?? undefined; const duration = parsed.duration ?? processed.duration ?? undefined;
const play: PlayObjectMinimal<Dayjs, WebScrobbleMeta> = { data: { track, artists: artist !== undefined ? [artistNameToCredit(artist)] : [], album: album === null ? undefined : album, albumArtists: albumArtist === null ? undefined : albumArtist === undefined ? undefined : [artistNameToCredit(albumArtist)], playDate: dayjs.unix(startTimestamp), duration: duration === null ? undefined : duration, meta: { brainz: { album: albumMbId } } }, meta: { trackId: uniqueID, parsedFrom: PARSED_FROM.ingress, url: { web: trackUrl, origin: originUrl }, deviceId: `${connectorLabel}-${controllerTabId}`, musicService: connectorL, source: 'WebScrobbler', scrobbleAllowed: isScrobblingAllowed, nowPlaying: options.nowPlaying ?? false } } return baseFormatPlayObj(obj, play); }
getRecentlyPlayed = async (options = {}) => await this.getFlatRecentlyDiscoveredPlays()
isValidScrobble = (playObj: WebScrobblerPlayObject) => { if (playObj.meta?.scrobbleAllowed === false) { this.logger.debug(`Will not scrobble play because it was marked as 'Do Not Scrobble' by extension`); return false; }
if (playObj.meta.musicService !== undefined) { const lowerSource = playObj.meta.musicService.toLowerCase(); if (Array.isArray(this.config.data.blacklist) && this.config.data.blacklist.length > 0) { if (this.config.data.blacklist.some(x => x === lowerSource)) { this.logger.debug(`Will not scrobble play because it is from a blacklisted connector '${lowerSource}'`); return false; } } if (Array.isArray(this.config.data.whitelist) && this.config.data.whitelist.length > 0) { if (!this.config.data.whitelist.some(x => x === lowerSource)) { this.logger.debug(`Will not scrobble play because it is not from a whitelisted connector '${lowerSource}'`); return false; } } }
return true; }
handle = async (stateData: PlayerStateData) => {
await this.processRecentPlays([stateData]);
if(this.isValidScrobble(stateData.play)) { if(stateData.play.meta.nowPlaying === true) { this.setStatus('Received Now Playing'); } else { this.setStatus('Received Play'); } if (stateData.play.meta.nowPlaying === false) { await this.queuePlay([stateData.play]); // const discovered = await this.discover([stateData.play]); // if (discovered.length > 0) { // await this.scrobble(discovered); // } } }
this.componentRepo.updateById(this.dbComponent.id, {lastActiveAt: dayjs()}); this.setStatus('Waiting for Plays'); }
protected async postInitialize(): Promise<void> { this.setStatus('Waiting for Plays'); }
getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new NowPlayingPlayerState(logger, id, opts);}