From eae4e952f5fd4cd5ed4c43202431f0b09adea0ff Mon Sep 17 00:00:00 2001 From: Angelos Bouklis Date: Mon, 30 Jun 2025 16:37:01 +0300 Subject: [PATCH] feat(synced-lyrics): Musixmatch (#3261) --- .prettierrc | 3 +- src/plugins/synced-lyrics/backend.ts | 27 ++ src/plugins/synced-lyrics/index.ts | 4 +- .../synced-lyrics/providers/LyricsGenius.ts | 2 +- .../synced-lyrics/providers/MusixMatch.ts | 310 +++++++++++++++++- src/plugins/synced-lyrics/providers/index.ts | 16 +- src/plugins/synced-lyrics/renderer/index.ts | 8 +- 7 files changed, 356 insertions(+), 14 deletions(-) create mode 100644 src/plugins/synced-lyrics/backend.ts diff --git a/.prettierrc b/.prettierrc index e97ea1bf..a4e22569 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,5 +1,6 @@ { "tabWidth": 2, "useTabs": false, - "singleQuote": true + "singleQuote": true, + "quoteProps": "consistent" } diff --git a/src/plugins/synced-lyrics/backend.ts b/src/plugins/synced-lyrics/backend.ts new file mode 100644 index 00000000..5ff7d28c --- /dev/null +++ b/src/plugins/synced-lyrics/backend.ts @@ -0,0 +1,27 @@ +import { createBackend } from '@/utils'; +import { net } from 'electron'; + +const handlers = { + // Note: This will only be used for Forbidden headers, e.g. User-Agent, Authority, Cookie, etc. + // See: https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_request_header + async fetch( + url: string, + init: RequestInit + ): Promise<[number, string, Record]> { + const res = await net.fetch(url, init); + return [ + res.status, + await res.text(), + Object.fromEntries(res.headers.entries()), + ]; + }, +}; + +export const backend = createBackend({ + start(ctx) { + ctx.ipc.handle('synced-lyrics:fetch', handlers.fetch); + }, + stop(ctx) { + ctx.ipc.removeHandler('synced-lyrics:fetch'); + }, +}); diff --git a/src/plugins/synced-lyrics/index.ts b/src/plugins/synced-lyrics/index.ts index 1dbc2d55..533f05bf 100644 --- a/src/plugins/synced-lyrics/index.ts +++ b/src/plugins/synced-lyrics/index.ts @@ -4,13 +4,14 @@ import { t } from '@/i18n'; import { menu } from './menu'; import { renderer } from './renderer'; +import { backend } from './backend'; import type { SyncedLyricsPluginConfig } from './types'; export default createPlugin({ name: () => t('plugins.synced-lyrics.name'), description: () => t('plugins.synced-lyrics.description'), - authors: ['Non0reo', 'ArjixWasTaken', 'KimJammer'], + authors: ['Non0reo', 'ArjixWasTaken', 'KimJammer', 'Strvm'], restartNeeded: true, addedVersion: '3.5.X', config: { @@ -25,5 +26,6 @@ export default createPlugin({ menu, renderer, + backend, stylesheets: [style], }); diff --git a/src/plugins/synced-lyrics/providers/LyricsGenius.ts b/src/plugins/synced-lyrics/providers/LyricsGenius.ts index 56063d96..0ebed628 100644 --- a/src/plugins/synced-lyrics/providers/LyricsGenius.ts +++ b/src/plugins/synced-lyrics/providers/LyricsGenius.ts @@ -46,7 +46,7 @@ export class LyricsGenius implements LyricProvider { ); const closestHit = hits.at(0); - if (!closestHit) { + if (!closestHit || closestHit.result.primary_artist.url === 'https://genius.com/artists/Deleted-artist') { return null; } diff --git a/src/plugins/synced-lyrics/providers/MusixMatch.ts b/src/plugins/synced-lyrics/providers/MusixMatch.ts index 0262f240..2962554b 100644 --- a/src/plugins/synced-lyrics/providers/MusixMatch.ts +++ b/src/plugins/synced-lyrics/providers/MusixMatch.ts @@ -1,10 +1,316 @@ +import { z } from 'zod'; + +import { LRC } from '../parsers/lrc'; +import { netFetch } from '../renderer'; + import type { LyricProvider, LyricResult, SearchSongInfo } from '../types'; export class MusixMatch implements LyricProvider { name = 'MusixMatch'; baseUrl = 'https://www.musixmatch.com/'; - search(_: SearchSongInfo): Promise { - throw new Error('Not implemented'); + private api: MusixMatchAPI | undefined; + + async search(info: SearchSongInfo): Promise { + // late-init the API, to avoid an electron IPC issue + // an added benefit is that if it has an error during init, the user can hit the retry button + this.api ??= await MusixMatchAPI.new(); + await this.api.reinit(); + + const data = await this.api.query(Endpoint.getMacroSubtitles, { + q_track: info.alternativeTitle || info.title, + q_artist: info.artist, + q_duration: info.songDuration.toString(), + ...(info.album ? { q_album: info.album } : {}), + namespace: 'lyrics_richsynched', + subtitle_format: 'lrc', + }); + + const { macro_calls: macroCalls } = data.body; + + // prettier-ignore + const getter = (key: T): typeof macroCalls[T]['message']['body'] => macroCalls[key].message.body; + + const track = getter('matcher.track.get')?.track; + const lyrics = getter('track.lyrics.get')?.lyrics?.lyrics_body; + const subtitle = getter('track.subtitles.get')?.subtitle_list?.[0]; + + // either no track found, or musixmatch's algorithm returned "Coldplay - Paradise" for no reason whatsoever + if (!track || track.track_id === 115264642) return null; + + return { + title: track.track_name, + artists: [track.artist_name], + lines: subtitle + ? LRC.parse(subtitle.subtitle.subtitle_body).lines.map((l) => ({ + ...l, + status: 'upcoming' as const, + })) + : undefined, + lyrics: lyrics, + }; } } + +// API Implementation, based on https://github.com/Strvm/musicxmatch-api/blob/main/src/musicxmatch_api/main.py + +const zBoolean = z.union([z.literal(0), z.literal(1)]); +const Track = z.object({ + track_id: z.number(), + track_name: z.string(), + artist_name: z.string(), +}); + +const Lyrics = z.object({ + instrumental: zBoolean, + lyrics_body: z.string(), + lyrics_language: z.string(), + lyrics_language_description: z.string(), +}); + +const Subtitle = z.object({ + subtitle_body: z.string(), + subtitle_length: z.number(), + subtitle_language: z.string(), +}); + +enum Endpoint { + getMacroSubtitles = 'macro.subtitles.get', + searchTrack = 'track.search', +} + +type Query = { + q?: string; + q_track?: string; + q_artist?: string; + q_album?: string; + q_duration?: string; +}; + +type Params = { + [Endpoint.getMacroSubtitles]: Query & { + namespace: 'lyrics_richsynched'; + subtitle_format: 'lrc'; + }; + [Endpoint.searchTrack]: { + q: string; + f_has_lyrics: 'true' | 'false'; + page_size: string; + page: string; + }; +}; + +const ResponseSchema = { + [Endpoint.searchTrack]: z.object({ + track_list: z.array(z.object({ track: Track })), + }), + [Endpoint.getMacroSubtitles]: z.object({ + macro_calls: z.object({ + 'track.lyrics.get': z.object({ + message: z.object({ + body: z + .object({ lyrics: Lyrics }) + .or( + z + .instanceof(Array) + .describe('default response for 404 status') + .transform(() => undefined) + .or(z.string().transform(() => undefined)), + ) + .optional(), + }), + }), + 'track.subtitles.get': z.object({ + message: z.object({ + body: z + .object({ + subtitle_list: z.array(z.object({ subtitle: Subtitle })), + }) + .or( + z + .instanceof(Array) + .describe('default response for 404 status') + .transform(() => undefined) + .or(z.string().transform(() => undefined)), + ) + + .optional(), + }), + }), + 'matcher.track.get': z.object({ + message: z.object({ + body: z + .object({ track: Track }) + .or( + z + .instanceof(Array) + .describe('default response for 404 status') + .transform(() => undefined) + .or(z.string().transform(() => undefined)), + ) + .optional(), + }), + }), + }), + }), +} as const; + +class MusixMatchAPI { + private initPromise: Promise; + private cookie = 'x-mxm-user-id='; + private token: string | null = null; + + private constructor() { + this.initPromise = this.init(); + } + + public static async new() { + const api = new MusixMatchAPI(); + await api.initPromise; + return api; + } + + public async reinit() { + const [{ status }] = await Promise.allSettled([this.initPromise]); + if (status === 'rejected') { + this.cookie = 'x-mxm-user-id='; + localStorage.removeItem(this.key); + this.initPromise = this.init(); + await this.initPromise; + } + } + + // god I love typescript generics, they're so useful + public async query< + T extends Endpoint, + R = { + header: { status_code: number }; + body: T extends keyof typeof ResponseSchema + ? z.infer<(typeof ResponseSchema)[T]> + : unknown; + }, + >(endpoint: T, params: Params[T]): Promise { + await this.initPromise; + if (!this.token) throw new Error('Token not initialized'); + + const url = `${this.baseUrl}${endpoint}`; + + const clonedParams = new URLSearchParams( + Object.assign( + { + app_id: this.app_id, + format: 'json', + usertoken: this.token, + }, + >params, + ), + ); + + const [, json, headers] = await netFetch(`${url}?${clonedParams}`, { + headers: { Cookie: this.cookie }, + }); + + const setCookie = Object.entries(headers).find( + ([key]) => key.toLowerCase() === 'set-cookie', + ); + if (setCookie) { + this.cookie = setCookie[1]; + } + + const response = JSON.parse(json); + // prettier-ignore + if ( + response && typeof response === 'object' && + 'message' in response && response.message && typeof response.message === 'object' && + 'header' in response.message && response.message.header && typeof response.message.header === 'object' && + 'status_code' in response.message.header && typeof response.message.header.status_code === 'number' && + response.message.header.status_code === 401 + ) { + await this.reinit(); + return this.query(endpoint, params); + } + + const parsed = z + .object({ + message: z.object({ body: ResponseSchema[endpoint] }), + }) + .safeParse(response); + + if (!parsed.success) { + console.error('Malformed response', response, parsed.error); + throw new Error('Failed to parse response from MusixMatch API'); + } + + return parsed.data.message as R; + } + + private savedTokenSchema = z.union([ + z.object({ + token: z.literal(null), + expires: z.number().optional(), + }), + z.object({ + token: z.string(), + expires: z.number(), + }), + ]); + + private key = 'ytm:synced-lyrics:mxm:token'; + private async init() { + const { token, expires } = this.savedTokenSchema.parse( + JSON.parse(localStorage.getItem(this.key) ?? '{ "token": null }'), + ); + if (token && expires > Date.now()) { + this.token = token; + return; + } + + localStorage.removeItem(this.key); + + this.token = await this.getToken(); + if (!this.token) throw new Error('Failed to get token'); + + localStorage.setItem( + this.key, + JSON.stringify({ token: this.token, expires: Date.now() + 60 * 1000 }), + ); + } + + private tokenSchema = z.object({ + message: z.object({ + body: z + .object({ + user_token: z.string(), + }) + .optional(), + }), + }); + private async getToken() { + const endpoint = 'token.get'; + const params = new URLSearchParams({ app_id: this.app_id }); + const [_, json, headers] = await netFetch( + `${this.baseUrl}${endpoint}?${params}`, + { + headers: Object.assign({ Cookie: this.cookie }, this.headers), + }, + ); + + const setCookie = Object.entries(headers).find( + ([key]) => key.toLowerCase() === 'set-cookie', + ); + if (setCookie) { + this.cookie = setCookie[1]; + } + + const { + message: { body }, + } = this.tokenSchema.parse(JSON.parse(json)); + return body?.user_token ?? ''; + } + + private readonly baseUrl = 'https://apic-desktop.musixmatch.com/ws/1.1/'; + private readonly app_id = 'web-desktop-app-v1.0'; + private readonly headers = { + Authority: 'apic-desktop.musixmatch.com', + }; +} diff --git a/src/plugins/synced-lyrics/providers/index.ts b/src/plugins/synced-lyrics/providers/index.ts index 3f413c96..f67da1be 100644 --- a/src/plugins/synced-lyrics/providers/index.ts +++ b/src/plugins/synced-lyrics/providers/index.ts @@ -6,6 +6,7 @@ import { SongInfo } from '@/providers/song-info'; import { LRCLib } from './LRCLib'; import { LyricsGenius } from './LyricsGenius'; +import { MusixMatch } from './MusixMatch'; import { YTMusic } from './YTMusic'; import { getSongInfo } from '@/providers/song-info-front'; @@ -15,8 +16,8 @@ import type { LyricProvider, LyricResult } from '../types'; export const providers = { YTMusic: new YTMusic(), LRCLib: new LRCLib(), + MusixMatch: new MusixMatch(), LyricsGenius: new LyricsGenius(), - // MusixMatch: new MusixMatch(), // Megalobiz: new Megalobiz(), // Disabled because it is too unstable and slow } as const; @@ -36,13 +37,10 @@ type LyricsStore = { }; const initialData = () => - providerNames.reduce( - (acc, name) => { - acc[name] = { state: 'fetching', data: null, error: null }; - return acc; - }, - {} as LyricsStore['lyrics'], - ); + providerNames.reduce((acc, name) => { + acc[name] = { state: 'fetching', data: null, error: null }; + return acc; + }, {} as LyricsStore['lyrics']); export const [lyricsStore, setLyricsStore] = createStore({ provider: providerNames[0], @@ -136,6 +134,8 @@ export const fetchLyrics = (info: SongInfo) => { pCache.state = 'error'; pCache.error = error; + console.error(error); + if (getSongInfo().videoId === info.videoId) { setLyricsStore('lyrics', (old) => { return { diff --git a/src/plugins/synced-lyrics/renderer/index.ts b/src/plugins/synced-lyrics/renderer/index.ts index 06762b49..bfcd249b 100644 --- a/src/plugins/synced-lyrics/renderer/index.ts +++ b/src/plugins/synced-lyrics/renderer/index.ts @@ -13,6 +13,10 @@ import type { SongInfo } from '@/providers/song-info'; import type { SyncedLyricsPluginConfig } from '../types'; export let _ytAPI: YoutubePlayer | null = null; +export let netFetch: ( + url: string, + init?: RequestInit +) => Promise<[number, string, Record]>; export const renderer = createRenderer< { @@ -53,7 +57,7 @@ export const renderer = createRenderer< if (!this.updateTimestampInterval) { this.updateTimestampInterval = setInterval( () => setCurrentTime((_ytAPI?.getCurrentTime() ?? 0) * 1000), - 100, + 100 ); } @@ -73,6 +77,8 @@ export const renderer = createRenderer< }, async start(ctx: RendererContext) { + netFetch = ctx.ipc.invoke.bind(ctx.ipc, 'synced-lyrics:fetch'); + setConfig(await ctx.getConfig()); ctx.ipc.on('ytmd:update-song-info', (info: SongInfo) => {