import 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; 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 { 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 = { 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 { this.setStatus('Waiting for Plays'); } getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new NowPlayingPlayerState(logger, id, opts); }