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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558import { faker } from '@faker-js/faker';import dayjs, { type Dayjs } from "dayjs";import duration from "dayjs/plugin/duration.js";import isBetween from "dayjs/plugin/isBetween.js";import relativeTime from "dayjs/plugin/relativeTime.js";import timezone from "dayjs/plugin/timezone.js";import utc from "dayjs/plugin/utc.js";import { type ArtistCredit, type BrainzMeta, FEAT, JOINERS, JOINERS_FINAL, type JsonPlayObject, type MBID, type ObjectPlayData, type PlayMeta, type PlayObject, type SourcePlayerObj } from "../../Atomic.ts";import { genGroupIdStr } from '../../PlayUtils.ts';import { sortByNewestPlayDate } from '../../PlayUtils.ts';import type { PlayerStateDataMaybePlay } from '../../../backend/common/infrastructure/Atomic.ts';import { SINGLE_USER_PLATFORM_ID } from '../../Atomic.ts';import { NO_USER } from '../../Atomic.ts';import { NO_DEVICE } from '../../Atomic.ts';import { CALCULATED_PLAYER_STATUSES } from '../../Atomic.ts';import { REPORTED_PLAYER_STATUSES } from '../../Atomic.ts';import type {PlayPlatformId} from '../../Atomic.ts';import { arrayListAnd, artistNamesToCredits } from '../../StringUtils.ts';import { findDelimiters } from "../../StringUtils.ts";import type {ListRecord} from '../../../backend/common/infrastructure/config/client/tealfm.ts';import { nanoid } from 'nanoid';import type {LastFMTrackObject} from '../../../backend/common/vendor/LastfmApiClient.ts';import clone from 'clone';import { removeUndefinedKeys } from '../../DataUtils.ts';import type { FmTealFeedPlay } from '../../../backend/common/vendor/teal/lexicons/index.ts';
dayjs.extend(utc)dayjs.extend(isBetween);dayjs.extend(relativeTime);dayjs.extend(duration);dayjs.extend(timezone);
export const normalizePlays = (plays: PlayObject[], options?: { //sortFunc?: (a: PlayObject, b: PlayObject) => 0 | 1 | -1 initialDate?: Dayjs, endDate?: Dayjs defaultDuration?: number, defaultData?: ObjectPlayData, defaultMeta?: PlayMeta }): PlayObject[] => { const { initialDate, endDate, defaultDuration = 180, defaultData = {}, defaultMeta = {}, //sortFunc = sortByNewestPlayDate } = options || {};
const normalizedPlays: PlayObject[] = [];
let actualInitialDate: Dayjs | undefined = initialDate;
if(endDate !== undefined && actualInitialDate !== undefined) {
// need to redefine durations and listenedFor, if present const dur = dayjs.duration(endDate.diff(actualInitialDate)); let remaining = plays.length;
let index = 0; let lastDate = endDate; let remainingTime = dur.asSeconds() for(const play of plays) {
const cleanPlay = {...play};
const remainingAvgTime = remainingTime/remaining; cleanPlay.data.playDate = lastDate; cleanPlay.data.duration = faker.number.int({min: 30, max: remainingAvgTime}); if(cleanPlay.data.listenedFor !== undefined) { cleanPlay.data.listenedFor = faker.number.int({min: Math.floor(cleanPlay.data.duration * 0.9), max: cleanPlay.data.duration}); } cleanPlay.data = { ...cleanPlay.data, ...defaultData } cleanPlay.meta = { ...cleanPlay.meta, ...defaultMeta }
// if(cleanPlay.meta.lifecycle?.original?.data?.playDate !== undefined) { // cleanPlay.meta.lifecycle.original.data.playDate = lastDate; // } // if(cleanPlay.meta.lifecycle?.original?.data?.playDateCompleted !== undefined) { // cleanPlay.meta.lifecycle.original.data.playDate = lastDate.add(cleanPlay.data.listenedFor ?? cleanPlay.data.duration, 's'); // }
if(index + 1 <= plays.length - 1) { const listenTime = (plays[index+1].data.duration ?? defaultDuration) + faker.number.int({min: 0, max: 2}); lastDate = cleanPlay.data.playDate!.subtract(listenTime, 'seconds'); } remaining--; remainingTime -= cleanPlay.data.duration!; index++; }
} else { const progressDirection: 'newer' | 'older' = endDate !== undefined ? 'older' : 'newer'; if(progressDirection === 'newer' && actualInitialDate === undefined) { const firstDatedPlay = plays.find(x => x.data.playDate !== undefined); if (firstDatedPlay !== undefined) { actualInitialDate = firstDatedPlay.data.playDate; } else { throw new Error('No initial date specified and no play had a defined date'); } }
let lastDate: Dayjs = progressDirection === 'newer' ? actualInitialDate! : endDate!; let index = 0; for (const play of plays) {
const cleanPlay = {...play};
cleanPlay.data.playDate = lastDate; cleanPlay.data = { ...cleanPlay.data, ...defaultData } cleanPlay.meta = { ...cleanPlay.meta, ...defaultMeta }
// if(cleanPlay.meta.lifecycle?.original?.data?.playDate !== undefined) { // cleanPlay.meta.lifecycle.original.data.playDate = lastDate; // } // if(cleanPlay.meta.lifecycle?.original?.data?.playDateCompleted !== undefined) { // cleanPlay.meta.lifecycle.original.data.playDate = lastDate.add(cleanPlay.data.listenedFor ?? cleanPlay.data.duration, 's'); // }
if(progressDirection === 'newer') { const listenTime = (cleanPlay.data.duration ?? defaultDuration) + faker.number.int({min: 0, max: 2}); lastDate = cleanPlay.data.playDate!.add(listenTime, 'seconds'); } else if(index + 1 <= plays.length - 1) { const listenTime = (plays[index+1].data.duration ?? defaultDuration) + faker.number.int({min: 0, max: 2}); lastDate = cleanPlay.data.playDate!.subtract(listenTime, 'seconds'); }
normalizedPlays.push(cleanPlay); index++; }
if(progressDirection === 'older') { normalizedPlays.sort(sortByNewestPlayDate); } }
return normalizedPlays;}
export const generatePlayerStateData = (options: Omit<PlayerStateDataMaybePlay, 'platformId'> & {playData?: ObjectPlayData, playMeta?: PlayMeta, platformId?: PlayPlatformId} = {}): PlayerStateDataMaybePlay => { const play: PlayObject = options.play ?? generatePlay(options.playData, options.playMeta); if(options.position !== undefined) { play.meta.trackProgressPosition = options.position; } return { platformId: options.platformId ?? [NO_DEVICE, NO_USER], sessionId: options.sessionId, play, status: options.status, position: options.position, stateUpdatedAt: options.stateUpdatedAt ?? dayjs() }}
export interface GeneratePlayOpts { playDateCompleted?: boolean, listenRanges?: boolean}export const generatePlay = (data: ObjectPlayData = {}, meta: PlayMeta = {}, opts: GeneratePlayOpts = {}): PlayObject => { const { playDateCompleted = false, listenRanges = false, } = opts;
const duration = faker.number.int({min: 30, max: 300}); const play: PlayObject = { data: { track: faker.music.songName(), artists: artistNamesToCredits(faker.helpers.multiple(faker.music.artist, {count: {min: 1, max: 3}})), duration, playDate: dayjs().subtract(faker.number.int({min: 1, max: 800})), album: faker.music.album(), listenedFor: faker.number.int({min: duration * 0.5, max: duration}), ...data }, meta: { source: ['Spotify', 'Listenbrainz', 'Lastfm', 'Jellyfin', 'Plex'][faker.number.int({min: 0, max: 4})], seenAt: dayjs(), ...meta, url: { web: 'https://example.com', origin: 'https://example.com' }, } }
if(play.data.playDateCompleted === undefined && playDateCompleted) { play.data.playDateCompleted = play.data.playDate!.add(play.data.duration!) }
if(listenRanges) { play.data.listenRanges = []; const lf = play.data.listenedFor; const sessions = faker.number.int({min: 1, max: 3}); const sessionTime = lf! / sessions; let nextTime = play.data.playDate!; switch(faker.number.int({min: 1, max: 2})) { case 1: { // timestamps only for(let i = 0; i < sessions; i++) { const newTime = nextTime.add(sessionTime, 's'); play.data.listenRanges.push({start: {timestamp: clone(nextTime)}, end: {timestamp: newTime}}); nextTime = newTime; } break; } case 2: { // timestamps + position let position = 0; for(let i = 0; i < sessions; i++) { const newTime = nextTime.add(sessionTime, 's'); const nextPosition = position + sessionTime; play.data.listenRanges.push({start: {timestamp: clone(nextTime), position}, end: {timestamp: newTime, position: nextPosition}}); nextTime = newTime; position = nextPosition; } break; } } }
return play;}
export const generateJsonPlay = (...args: Parameters<typeof generatePlay>): JsonPlayObject => { const play = generatePlay(...args); return JSON.parse(JSON.stringify(play));}
export const generateJsonPlays = (...args: Parameters<typeof generatePlays>): JsonPlayObject[] => { const plays = generatePlays(...args); return JSON.parse(JSON.stringify(plays));}
export interface WithBrainzOptions { include: ('track' | 'artist' | 'album' | 'recording' | 'releaseGroup')[]}export const generateBrainz = (play: PlayObject, opts: WithBrainzOptions): BrainzMeta => { const {include} = opts; const brainz: BrainzMeta = {}; for(const i of include) { switch(i) { case 'track': if(play.data.meta?.brainz?.track === undefined) { brainz.track = generateMbid(); } break; case 'recording': if(play.data.meta?.brainz?.recording === undefined) { brainz.recording = generateMbid(); } break; case 'album': if(play.data.meta?.brainz?.album === undefined && play.data.album !== undefined) { brainz.album = generateMbid(); } break; case 'releaseGroup': if(play.data.meta?.brainz?.releaseGroup === undefined) { brainz.releaseGroup = generateMbid(); } break; case 'artist': if(play.data.meta?.brainz?.artist === undefined && (play.data.artists ?? []).length > 0) { const artistMbids = play.data.artists!.map(x => generateMbid()); brainz.artist = artistMbids; } break; } }
return brainz}
export const withBrainz = (play: PlayObject, opts: WithBrainzOptions): PlayObject => { const brainz = generateBrainz(play, opts); play.data.meta = { ...(play.data.meta ?? {}), brainz : { ...(play.data.meta?.brainz ?? {}), ...brainz } } return play;}
export const generatePlayPlatformId = (deviceId?: string, userId?: string): PlayPlatformId => { const did = deviceId ?? [NO_DEVICE, faker.hacker.noun(), faker.hacker.noun()][faker.number.int({min: 0, max: 2})]; const uid = userId ?? [NO_USER, faker.internet.username(), faker.internet.username()][faker.number.int({min: 0, max: 2})]; return [did, uid];}
export const generatePlays = (numberOfPlays: number, data: ObjectPlayData = {}, meta: PlayMeta = {}, opts: GeneratePlayOpts = {}): PlayObject[] => { return Array.from(Array(numberOfPlays), () => generatePlay(data, meta, opts));}
export const generateArtist = () => faker.music.artist;
export interface ArtistGenerationOptions{ ambiguousJoinedNames?: boolean, trailingAmpersand?: boolean}
export const generateArtists = (num?: number, max: number = 3, opts: ArtistGenerationOptions = {}) => { if(num === 0 || max === 0) { return []; } let artists = faker.helpers.multiple(faker.music.artist, {count: {min: num ?? 1, max: num ?? max}});
const { trailingAmpersand = false, ambiguousJoinedNames = false } = opts;
if(!trailingAmpersand) { // its really hard to parse an artist name that contains an '&' when it comes at the end of a list // because its ambigious if the list is joining the list with & or if & is part of the artist name // so by default don't generate these (we test for specific scenarios in playParsing.test.ts) while(artists[artists.length - 1].includes('&')) { artists = artists.slice(0, artists.length - 1).concat(faker.music.artist()); } } if(!ambiguousJoinedNames) { artists = artists.map(x => { let a = x; let foundDelims = findDelimiters(a); while(foundDelims !== undefined && foundDelims.length > 0 && !(foundDelims.length === 1 && foundDelims[0] === '&')) { a = faker.music.artist(); foundDelims = findDelimiters(a); } return a; }); } return artists;}
export const generateArtistCredit = (name: string = faker.music.artist(), mbidVal: MBID | boolean = true): ArtistCredit => { let mbid: MBID | undefined; if(mbidVal === true) { mbid = generateMbid(); } else if(typeof mbidVal === 'string') { mbid = mbidVal as MBID; } return removeUndefinedKeys<ArtistCredit>({name, mbid})!;}
export const generateArtistCredits = (num?: number, max?: number, opts: ArtistGenerationOptions & {mbidVal?: boolean} = {}): ArtistCredit[] => { const artistNames = generateArtists(num, max, opts); return artistNames.map(x => generateArtistCredit(x, opts.mbidVal));}
export interface ArtistGenerateOptions extends ArtistGenerationOptions { num?: number max?: number joiner?: string finalJoiner?: false | string spacedJoiners?: boolean}
export interface SecondaryArtistGenerateOptions extends ArtistGenerateOptions { ft?: string ftWrap?: boolean}
export interface CompoundArtistGenerateOptions { primary?: number | ArtistGenerateOptions secondary?: number | SecondaryArtistGenerateOptions}
export const generateArtistsStr = (options: CompoundArtistGenerateOptions = {}): [string, string[], string[]] => {
const {primary = {}, secondary = {}} = options;
const primaryOpts: ArtistGenerateOptions = typeof primary === 'number' ? {num: primary} : primary; const secondaryOpts: SecondaryArtistGenerateOptions = typeof secondary === 'number' ? {num: secondary} : secondary;
const primaryArt = generateArtists(primaryOpts.num, primaryOpts.max) const secondaryArt = generateArtists(secondaryOpts.num, secondaryOpts.max);
const joinerPrimary: string = primaryOpts.joiner ?? faker.helpers.arrayElement(JOINERS); let finalJoinerPrimary: string = joinerPrimary; if(primaryOpts.finalJoiner !== false) { if(primaryOpts.finalJoiner === undefined) { if(joinerPrimary === ',' && !primaryArt.some(x => x.includes('&'))) { finalJoinerPrimary = faker.helpers.arrayElement(JOINERS_FINAL); } } else { finalJoinerPrimary = primaryOpts.finalJoiner; } }
const primaryStr = arrayListAnd(primaryArt, joinerPrimary, finalJoinerPrimary, primaryOpts.spacedJoiners);
if(secondaryArt.length === 0) { return [primaryStr, primaryArt, []]; }
const joinerSecondary: string = secondaryOpts.joiner ?? faker.helpers.arrayElement(JOINERS); let finalJoinerSecondary: string = joinerSecondary; if(secondaryOpts.finalJoiner !== false) { if(secondaryOpts.finalJoiner === undefined) { if(joinerSecondary === ',' && !secondaryArt.some(x => x.includes('&'))) { finalJoinerSecondary = faker.helpers.arrayElement(JOINERS_FINAL); } } else { finalJoinerSecondary = secondaryOpts.finalJoiner; } }
const secondaryStr = arrayListAnd(secondaryArt, joinerSecondary, finalJoinerSecondary, secondaryOpts.spacedJoiners); const ft = secondaryOpts.ft ?? faker.helpers.arrayElement(FEAT); let sec = `${ft} ${secondaryStr}`; let wrap: boolean; if(secondaryOpts.ftWrap !== undefined) { wrap = secondaryOpts.ftWrap; } else { wrap = faker.datatype.boolean(); } if(wrap) { sec = `(${sec})`; } const artistStr = `${primaryStr} ${sec}`;
return [artistStr, primaryArt, secondaryArt];}
export const generateLastfmTrackObject = (): LastFMTrackObject => { const now = dayjs(); const artist = faker.music.artist(); return { album: { '#text': faker.music.album(), mbid: "" }, artist: { '#text': artist, mbid: "", name: artist }, date: { uts: now.unix().toString(), '#text': now.format('DD MMM YYYY, HH:ss') }, name: faker.music.songName(), url: "https://www.last.fm/music/test/_/test", '@attr': { nowplaying: 'false' }, mbid: "" }}
export const generateMbid = (): MBID => { return [ faker.string.alphanumeric({length: 8}), faker.string.alphanumeric({length: 4}), faker.string.alphanumeric({length: 4}), faker.string.alphanumeric({length: 4}), faker.string.alphanumeric({length: 12}) ].join('-') as MBID;}
export const generateTealPlayRecord = (opts: { withMbids?: boolean, withIsrc?: boolean} = {}): [ListRecord<FmTealFeedPlay.Main>, { did: string, tid: string }] => { const { withMbids = true, withIsrc = true, } = opts;
const now = dayjs(); const artists = generateArtists(2);
const did = nanoid(12); const tid = nanoid(10);
const rec: ListRecord<FmTealFeedPlay.Main> = { uri: `at://did:plc:${did}/fm.teal.feed.play/${tid}`, cid: nanoid(12), value: { '$type': 'fm.teal.feed.play', artists: artists.map(x => withMbids ? { artistName: x, artistMbId: `mbid:${generateMbid()}` } : { artistName: x }), releaseName: faker.music.album(), trackName: faker.music.songName(), playedTime: now.toISOString(), submissionClientAgent: 'test', duration: faker.number.int({ min: 1, max: 300 }) } }
if (withMbids) { rec.value.releaseMbId = `mbid:${generateMbid()}`; rec.value.recordingMbId = `mbid:${generateMbid()}`; }
if (withIsrc) { rec.value.isrc = nanoid(12); }
return [rec, { did, tid }];}
export interface GenerateSourcePlayerObjOptions { playOpts?: Parameters<typeof generatePlay>[0] play?: PlayObject playPlatform?: string, sourcePlayOpts?: Partial<SourcePlayerObj>}
export const generateSourcePlayerObj = (opts: GenerateSourcePlayerObjOptions): SourcePlayerObj => { let platformId: string; if(opts.play !== undefined) { platformId = opts.play.meta.deviceId!; } else { platformId = opts.playPlatform ?? genGroupIdStr(SINGLE_USER_PLATFORM_ID); } const play: PlayObject = opts.play ?? generatePlay(opts.playOpts, {deviceId:platformId});
const { status = {}, play: oPlay, platformId: oplat, ...rest } = opts.sourcePlayOpts ?? {};
return { platformId, play, listenedDuration: 60, playerLastUpdatedAt: dayjs().toISOString(), status: { reported: REPORTED_PLAYER_STATUSES.playing, calculated: CALCULATED_PLAYER_STATUSES.playing, stale: false, orphaned: false, ...status }, ...rest }}