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.
11 kB · 270 lines
TypeScript
at strict
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270import type {ArtistCredit, PlayObject, TransformerCommon} from "../../../core/Atomic.ts";import { isWhenCondition, testWhenConditions } from "../../utils/PlayTransformUtils.ts";import type {WebhookPayload} from "../infrastructure/config/health/webhooks.ts";import type {ExternalMetadataTerm, PlayTransformNativeStage} from "../../../core/Transform.ts";import AtomicPartsTransformer from "./AtomicPartsTransformer.ts";import { parseArtistCredits, parseTrackCredits, uniqueNormalizedStrArr } from "../../utils/StringUtils.ts";import { parseRegexSingle, parseToRegexOrLiteralSearch } from "@foxxmd/regex-buddy-core";import type {TransformerOptions} from "./AbstractTransformer.ts";import { DELIMITERS_NO_AMP } from '../../../core/Atomic.ts';import { asArray } from "../../utils/DataUtils.ts";import { MaybeLogger } from '../MaybeLogger.ts';import { childLogger } from "@foxxmd/logging";import { artistCreditToName, artistNameToCredit } from "../../../core/StringUtils.ts";
export type ArtistParseSource = 'artists' | 'title'
export const asArtistParseSource = (str: string): ArtistParseSource => { const clean = str.trim().toLocaleLowerCase(); switch(clean) { case 'track': case 'title': return 'title'; case 'artist': case 'artists': return 'artists'; } throw new Error(`ArtistParseSource must be one of 'artist' or 'title', given: ${clean}`);}
export interface NativeTransformerData { delimiters?: string[] delimitersExtra?: string[] artistsIgnore?: string[] artistsParseFrom?: ArtistParseSource[] artistsParseMonolithicOnly?: boolean titleClean?: boolean}
export interface NativeTransformerDataStrong extends Omit<NativeTransformerData, 'delimitersExtra'> { artistsParseFrom?: ArtistParseSource[] artistsParseMonolithicOnly?: boolean ignoreArtistsRegex?: RegExp[] delimiters?: string[]}
export interface NativeTransformerDataStage extends NativeTransformerDataStrong,PlayTransformNativeStage {}
export type NativeTransformerConfig = TransformerCommon<NativeTransformerData>;
export const parseStageConfig = (data: NativeTransformerData | undefined, logger: MaybeLogger = new MaybeLogger()): NativeTransformerDataStrong => {
if (data === undefined) { return {}; }
const config: NativeTransformerDataStrong = { };
if (data === null || typeof data !== 'object') { throw new Error('Native Transformer data should be an object or not defined.'); } if (data.artistsIgnore !== undefined) { data.artistsIgnore = asArray(data.artistsIgnore); const nonStr = data.artistsIgnore.filter(x => typeof x !== 'string'); if (nonStr.length > 0) { throw new Error(`ignoreArtists must be an array of strings but non-strings found: ${nonStr.map(x => (x as any).toString()).join(' | ')}`) } config.ignoreArtistsRegex = []; for (const i of data.artistsIgnore) { try { config.ignoreArtistsRegex.push(parseToRegexOrLiteralSearch(i)); } catch (e) { throw new Error(`Could not convert ignoreArtist string to regex (or literal): ${i}`); } } logger.debug(`Defaults - Ignoring artists using ${data.artistsIgnore.length} rules`); }
if (data.delimiters !== undefined) { config.delimiters = asArray(data.delimiters); logger.debug(`Defaults - Using user-defined delimiters '${config.delimiters.join(' ')}' instead of built-ins`); } else if (data.delimitersExtra !== undefined) { config.delimiters = [...DELIMITERS_NO_AMP, ...(asArray(data.delimitersExtra))]; logger.debug(`Defaults - Using extra delimiters '${asArray(data.delimitersExtra).join(' ')}' with built-in delimiters '${DELIMITERS_NO_AMP.join(' ')}'`); }
if (config.delimiters !== undefined) { config.delimiters.map(x => x.trim()); }
if (data.artistsParseFrom !== undefined) { const arr = asArray(data.artistsParseFrom); config.artistsParseFrom = arr.map(asArtistParseSource); logger.debug(`Defaults - Will try to parse artists from ${config.artistsParseFrom.join(' and ')} string`); }
return config;}
export default class NativeTransformer extends AtomicPartsTransformer<ExternalMetadataTerm, PlayObject | undefined, NativeTransformerDataStage> {
declare config: NativeTransformerConfig;
protected defaults: NativeTransformerDataStrong = {};
ignoreArtistsRegex: RegExp[] = []; delimiters?: string[] parseArtistsFrom!: ArtistParseSource[]
public constructor(config: NativeTransformerConfig, options: TransformerOptions) { super(config, options); }
protected async doBuildInitData(): Promise<true | string | undefined> { this.defaults = parseStageConfig(this.config.defaults, childLogger(this.logger, 'Defaults')); return true; }
protected doParseConfig(data: NativeTransformerDataStage) { if (data.type !== 'native') { throw new Error(`NativeTransformer is only usable with 'native' type stages`); }
const stage: NativeTransformerDataStage = { ...data, ...parseStageConfig(data), type: 'native' }
for (const k of ['artists', 'albumArtists', 'title', 'album'] as const) { if (!(k in stage)) { stage[k] = true; continue; } if (Array.isArray(stage[k])) { throw new Error(`${k} must be a boolean or when object`); } if (typeof stage[k] === 'boolean') { continue; } if (typeof stage[k] === 'object' && !isWhenCondition(stage[k])) { throw new Error(`${k} is not a valid when object`); } } return stage; }
public async getTransformerData(play: PlayObject, stageConfig: NativeTransformerDataStage): Promise<PlayObject> { const { artistsParseFrom: parseArtistsFrom = this.defaults.artistsParseFrom ?? ['artists', 'title'], artistsParseMonolithicOnly = this.defaults.artistsParseMonolithicOnly ?? true, ignoreArtistsRegex = this.defaults.ignoreArtistsRegex ?? [], delimiters = this.defaults.delimiters } = stageConfig || {};
const transformedPlay = nativeParse(play, { artistsParseFrom: parseArtistsFrom, artistsParseMonolithicOnly, ignoreArtistsRegex, delimiters, logger: this.logger });
return transformedPlay; }
protected async handleTitle(play: PlayObject, parts: ExternalMetadataTerm, _transformData: undefined): Promise<string | undefined> { return play.data.track; } protected async handleArtists(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise<ArtistCredit[] | undefined> { if (parts === false) { return play.data.artists; } if (typeof parts === 'object') { if (parts.when !== undefined) { if (!testWhenConditions(parts.when, play, { testMaybeRegex: this.regex.testMaybeRegex })) { this.logger.debug('When condition for artists not met, returning original artists'); return play.data.artists; } } }
return transformData.data.artists; } protected async handleAlbumArtists(play: PlayObject, parts: ExternalMetadataTerm, _transformData: undefined): Promise<ArtistCredit[] | undefined> { return play.data.albumArtists; } protected async handleAlbum(play: PlayObject, parts: ExternalMetadataTerm, _transformData: undefined): Promise<string | undefined> { return play.data.album; }
public async notify(payload: WebhookPayload): Promise<void> { }
}
export const nativeParse = (play: PlayObject, options?: NativeTransformerDataStrong & {logger?: MaybeLogger}): PlayObject => { const { artistsParseFrom = ['artists', 'title'], titleClean = false, artistsParseMonolithicOnly = true, ignoreArtistsRegex = [], delimiters, logger = new MaybeLogger() } = options || {};
let artists: ArtistCredit[] = []; let track = play.data.track;
if(artistsParseFrom.includes('artists')) {
const playArtists = play.data.artists ?? []; if(playArtists.length === 1 || (playArtists.length > 1 && artistsParseMonolithicOnly === false)) {
for(const artist of playArtists) { const matchedIgnoreArtists = ignoreArtistsRegex.flatMap(x => { const res = parseRegexSingle(x, artist.name); return res !== undefined ? [{reg: x.toString(), res}] : []; }); if(matchedIgnoreArtists.length > 0) { logger.debug(`Will not parse artist because it matched an ignore regex:\n${matchedIgnoreArtists.map(x => `Reg: ${x.reg} => ${x.res.match}`).join('\n')}`); artists.push(artist); } else { const artistCredits = parseArtistCredits(artist.name, delimiters); if (artistCredits !== undefined) { if (artistCredits.primary !== undefined) { artists.push({name: artistCredits.primary}); } if (artistCredits.secondary !== undefined) { artists = artists.concat(artistCredits.secondary.map((x) => artistNameToCredit(x))); } } else { // couldn't parse anything from artist string, use as-is artists.push(artist); } }
}
} else { // user does not want to try to parse artists when we already have more than one artist string // -- likely this is because the user knows the artist data is already good and shouldn't be modified artists = playArtists; }
}
if(artistsParseFrom.includes('title')) { const trackArtists = play.data.track !== undefined ? parseTrackCredits(play.data.track, delimiters) : undefined; if (trackArtists !== undefined && trackArtists.secondary !== undefined) { artists = artists.concat(trackArtists.secondary.map((x) => artistNameToCredit(x))); if(titleClean) { track = trackArtists.primary; } } }
artists = (uniqueNormalizedStrArr([...artists.map((x) => artistCreditToName(x))])).map((x) => artistNameToCredit(x));
return { ...play, data: { ...play.data, track, artists } };}