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.
13 kB · 337 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337import 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 AmbPlayObject, type ArtistCredit, type PlayData, SCROBBLE_TS_SOC_END, SCROBBLE_TS_SOC_START, type ScrobbleTsSOC, type TrackStringOptions} from "./Atomic.ts";import { DELIMETERS_REGEX, DELIMITERS } from './Atomic.ts';import { parseRegexSingle } from "@foxxmd/regex-buddy-core";import { removeUndefinedKeys } from './DataUtils.ts';import { nanoid } from "nanoid";
dayjs.extend(utc)dayjs.extend(isBetween);dayjs.extend(relativeTime);dayjs.extend(duration);dayjs.extend(timezone);
export const longestString = (strings: any) => strings.reduce((acc: any, curr: any) => curr.length > acc ? curr.length : acc, 0);export const truncateStringArrToLength = (length: any, truncStr = '...') => { const truncater = truncateStringToLength(length, truncStr); return (strings: any) => strings.map(truncater);}export const truncateStringToLength = (length: any, truncStr = '...') => (val: any = '') => { if (val === null) { return ''; } const str = typeof val !== 'string' ? val.toString() : val; return str.length > length ? `${str.slice(0, length)}${truncStr}` : str;}export const defaultTrackTransformer = (input: any, data: AmbPlayObject, hasExistingParts: boolean = false) => hasExistingParts ? `- ${input}` : input;export const defaultReducer = (acc, curr) => `${acc} ${curr}`;export const defaultArtistFunc = (a: string[]) => a === undefined ? '' : a.join(' / ');export const defaultAlbumFunc = (input: any, data: AmbPlayObject, hasExistingParts: boolean = false) => { if(input === undefined) { return undefined; } return hasExistingParts ? `--- ${input}` : input};export const defaultTimeFunc = (t: Dayjs | undefined, i?: ScrobbleTsSOC) => t === undefined ? '@ N/A' : `@ ${t.local().format()} ${i === undefined ? '' : (i === SCROBBLE_TS_SOC_START ? '(S)' : '(C)')}`;export const defaultTimeFromNowFunc = (t: Dayjs | undefined) => t === undefined ? undefined : `(${t.local().fromNow()})`;export const defaultCommentFunc = (c: string | undefined) => c === undefined ? undefined : `(${c})`;// TODO replace with genGroupIdStr and refactor Platform types/etc. into core Atomicexport const defaultPlatformFunc = (d: string | undefined, u: string | undefined, s: string | undefined) => combinePartsToString([d ?? 'NoDevice', u ?? 'SingleUser',s !== undefined ? `Session${s}` : undefined]);export const defaultBuildTrackStringTransformers = { artists: defaultArtistFunc, track: defaultTrackTransformer, album: defaultAlbumFunc, time: defaultTimeFunc, timeFromNow: defaultTimeFromNowFunc, comment: defaultCommentFunc, platform: defaultPlatformFunc}export const buildTrackString = <T = string>(playObj: AmbPlayObject, options: TrackStringOptions<T> = {}): T => { const { include = ['time', 'artist', 'track'], transformers: { artists: artistsFunc = defaultBuildTrackStringTransformers.artists, album: albumFunc = defaultBuildTrackStringTransformers.album, track: trackFunc = defaultBuildTrackStringTransformers.track, time: timeFunc = defaultBuildTrackStringTransformers.time, timeFromNow = defaultBuildTrackStringTransformers.timeFromNow, comment: commentFunc = defaultBuildTrackStringTransformers.comment, platform: platformFunc = defaultBuildTrackStringTransformers.platform, reducer = arr => arr.join(' ') // (acc, curr) => `${acc} ${curr}` } = {}, } = options; const { data: { artists, album, track, playDate, playDateCompleted } = {}, meta: { trackId, scrobbleTsSOC = SCROBBLE_TS_SOC_START, comment, deviceId, user, sessionId } = {}, } = playObj;
let pd: Dayjs; let usedTsSOC: ScrobbleTsSOC = scrobbleTsSOC; if(scrobbleTsSOC === SCROBBLE_TS_SOC_END && playDateCompleted !== undefined) { pd = typeof playDateCompleted === 'string' ? dayjs(playDateCompleted) : playDateCompleted; } else { usedTsSOC = SCROBBLE_TS_SOC_START; pd = typeof playDate === 'string' ? dayjs(playDate) : playDate; }
const strParts: (T | string)[] = []; if(include.includes('platform')) { strParts.push(platformFunc(deviceId, user, include.includes('session') ? sessionId : undefined)) } else if(include.includes('session') && sessionId !== undefined) { strParts.push(`(Session ${sessionId})`); } if (include.includes('trackId') && trackId !== undefined) { strParts.push(`(${trackId})`); } if (include.includes('artist')) { strParts.push(artistsFunc(artistCreditsToNames(artists))) } if (include.includes('track')) { strParts.push(trackFunc(track, playObj, strParts.length > 0)); } if (include.includes('album')) { strParts.push(albumFunc(album, playObj, strParts.length > 0)); } if (include.includes('time')) { strParts.push(timeFunc(pd, usedTsSOC)); } if (include.includes('timeFromNow')) { const tfn = timeFromNow(pd); if (tfn !== undefined) { strParts.push(tfn) }
} if (include.includes('comment')) { const cfn = commentFunc(comment); if(cfn !== undefined) { strParts.push(cfn); } } // @ts-ignore return reducer(strParts); //strParts.join(' ');}
export const buildPlayHumanDiffable = (play: PlayData, options?: {expandMeta?: boolean}): string => { const { expandMeta = false } = options || {};
const meta: string[] = []; if(play.meta !== undefined) { for(const [metaType,metaObject] of Object.entries(play.meta)) { for(const [metaKey, metaValue] of Object.entries(metaObject)) { if(metaValue === undefined) { continue; } const id = `${metaType}-${metaKey}`; if(expandMeta) { meta.push(`${id}: ${metaValue}`); } else { meta.push(id); } } } } let metaStr = '(None)'; if(meta.length > 0) { if(expandMeta) { metaStr = `\n${meta.map(x => `* ${x}`).join('\n')}`; } else { metaStr = meta.join(', '); } } const parts: string[] = [ `${'Title'.padEnd(13)}: ${play.track ?? '(None)'}`, `${'Artists'.padEnd(13)}: ${play.artists === undefined || play.artists.length === 0 ? '(None)' : play.artists.join(', ')}`, `${'Album Artists'.padEnd(13)}: ${play.albumArtists === undefined || play.albumArtists.length === 0 ? '(None)' : play.albumArtists.join(', ')}`, `${'Album'.padEnd(13)}: ${play.album ?? '(None)'}`, `${'Meta'.padEnd(13)}: ${metaStr}` ];
const final = parts.join('\n'); return final;}
export const slice = (str: string, index: number, count: number, add?: string): string => { // We cannot pass negative indexes directly to the 2nd slicing operation. if (index < 0) { index = str.length + index; if (index < 0) { index = 0; } }
return str.slice(0, index) + (add || "") + str.slice(index + count);}
export const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1)
export const capitalizeWords = (str: string, delimiter = ' ') => str.split(delimiter).map(x => capitalize(x)).join(delimiter);
/** * Split a string-ish variable by a list of deliminators and return the first actually split array or default to returning the string as the first element. * * Returns empty array, or user defined value, if variable is undefined / null / not a string / or an empty string. * */export const splitByFirstFound = <T>(str: any, delims = [','], onNotAStringVal: T): string[] | T => { if(str === undefined || str === null || typeof str !== 'string' || str.trim() === '') { return onNotAStringVal; } for(const d of delims) { const split = str.split(d); if(split.length > 1) { return split; } } return [str];}
/** * Split a string-ish variable by a regex and return the first actually split array or default to returning the string as the first element. * * Returns empty array, or user defined value, if variable is undefined / null / not a string / or an empty string. * */export const splitByFirstRegexFound = <T>(str: any, onNotAStringVal: T, delimsReg: RegExp = DELIMETERS_REGEX): string[] | T => { if (str === undefined || str === null || typeof str !== 'string' || str.trim() === '') { return onNotAStringVal; } const res = parseRegexSingle(delimsReg, str); if (res !== undefined) { return [str.slice(0, res.index - 1), str.slice(res.index + 1)]; } return [str];}
/** * Returns value if it is a non-empty string or returns default value * */export const nonEmptyStringOrDefault = <T = undefined>(str: any, defaultVal: T = undefined): string | T => { if (str === undefined || str === null || typeof str !== 'string' || str.trim() === '') { return defaultVal; } return str;}export const combinePartsToString = (parts: any[], glue: string = '-'): string | undefined => { const cleanParts: string[] = []; for (const part of parts) { if (part === null || part === undefined) { continue; } if (Array.isArray(part)) { const nestedParts = combinePartsToString(part, glue); if (nestedParts !== undefined) { cleanParts.push(nestedParts); } } else if (typeof part === 'object') { // hope this works cleanParts.push(JSON.stringify(part)); } else if (typeof part === 'string') { if (part.trim() !== '') { cleanParts.push(part); } } else { cleanParts.push(part.toString()); } } if (cleanParts.length > 0) { return cleanParts.join(glue); } return undefined;}
export const arrayListOxfordAnd = (list: string[], joiner: string, finalJoiner: string, spaced: boolean = true): string => { if(list.length === 1) { return list[0]; } const start = list.slice(0, list.length - 1); const end = list.slice(list.length - 1);
const joinerProper = joiner === ',' ? ', ' : (spaced ? ` ${joiner} ` : joiner); const finalProper = spaced ? ` ${finalJoiner} ` : finalJoiner;
return [start.join(joinerProper), end].join(joiner === ',' && spaced ? `,${finalProper}` : finalProper);}
export const arrayListAnd = (list: string[], joiner: string, finalJoiner: string, spaced: boolean = true): string => { if(list.length === 1) { return list[0]; } const start = list.slice(0, list.length - 1); const end = list.slice(list.length - 1);
const joinerProper = joiner === ',' ? ', ' : (spaced ? ` ${joiner} ` : joiner); const finalProper = spaced ? ` ${finalJoiner} ` : finalJoiner;
return [start.join(joinerProper), end].join(finalProper);}
export const safeStringify = (json: unknown) => JSON.stringify(json, null, 2);export const findDelimiters = (str: string, delimiters = DELIMITERS) => { const found: string[] = []; for (const d of delimiters) { if (str.indexOf(d) !== -1) { found.push(d); } } if (found.length === 0) { return undefined; } return found;};
export const containsDelimiters = (str: string) => null !== str.match(/[,&/\\]+/i);
const NUMBERS_REGEX = new RegExp(/^\s*\d+\s*$/);export const stringIsOnlyNumbers = (str: string) => NUMBERS_REGEX.test(str);
export const artistNamesToCredits = (names: (string | Partial<ArtistCredit>)[] | undefined): ArtistCredit[] => { if(names === undefined) { return undefined; } return names.map(artistNameToCredit).filter(x => x !== undefined);};export const artistNameToCredit = (val: string | undefined | Partial<ArtistCredit>): ArtistCredit => { if(val === undefined) { return undefined; } if(typeof val === 'string') { return {name: val}; } const { name, mbid, ...rest } = val; return removeUndefinedKeys({name, mbid, ...rest});}export const artistCreditToName = (a: ArtistCredit): string => a.name;export const artistCreditsToNames = (a: ArtistCredit[]): string[] => a.map((x) => x.name);
export const generatePlayUid = () => nanoid(20);