import 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 { artistsParseFrom?: ArtistParseSource[] artistsParseMonolithicOnly?: boolean ignoreArtistsRegex?: RegExp[] delimiters?: string[] } export interface NativeTransformerDataStage extends NativeTransformerDataStrong,PlayTransformNativeStage { } export type NativeTransformerConfig = TransformerCommon; 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 { 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 { 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 { 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 { return play.data.track; } protected async handleArtists(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise { 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 { return play.data.albumArtists; } protected async handleAlbum(play: PlayObject, parts: ExternalMetadataTerm, _transformData: undefined): Promise { return play.data.album; } public async notify(payload: WebhookPayload): Promise { } } 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 } }; }