mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-11 18:41:47 +00:00
feat(synced-lyrics): Musixmatch (#3261)
This commit is contained in:
@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"useTabs": false,
|
"useTabs": false,
|
||||||
"singleQuote": true
|
"singleQuote": true,
|
||||||
|
"quoteProps": "consistent"
|
||||||
}
|
}
|
||||||
|
|||||||
27
src/plugins/synced-lyrics/backend.ts
Normal file
27
src/plugins/synced-lyrics/backend.ts
Normal file
@ -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<string, string>]> {
|
||||||
|
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');
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -4,13 +4,14 @@ import { t } from '@/i18n';
|
|||||||
|
|
||||||
import { menu } from './menu';
|
import { menu } from './menu';
|
||||||
import { renderer } from './renderer';
|
import { renderer } from './renderer';
|
||||||
|
import { backend } from './backend';
|
||||||
|
|
||||||
import type { SyncedLyricsPluginConfig } from './types';
|
import type { SyncedLyricsPluginConfig } from './types';
|
||||||
|
|
||||||
export default createPlugin({
|
export default createPlugin({
|
||||||
name: () => t('plugins.synced-lyrics.name'),
|
name: () => t('plugins.synced-lyrics.name'),
|
||||||
description: () => t('plugins.synced-lyrics.description'),
|
description: () => t('plugins.synced-lyrics.description'),
|
||||||
authors: ['Non0reo', 'ArjixWasTaken', 'KimJammer'],
|
authors: ['Non0reo', 'ArjixWasTaken', 'KimJammer', 'Strvm'],
|
||||||
restartNeeded: true,
|
restartNeeded: true,
|
||||||
addedVersion: '3.5.X',
|
addedVersion: '3.5.X',
|
||||||
config: {
|
config: {
|
||||||
@ -25,5 +26,6 @@ export default createPlugin({
|
|||||||
|
|
||||||
menu,
|
menu,
|
||||||
renderer,
|
renderer,
|
||||||
|
backend,
|
||||||
stylesheets: [style],
|
stylesheets: [style],
|
||||||
});
|
});
|
||||||
|
|||||||
@ -46,7 +46,7 @@ export class LyricsGenius implements LyricProvider {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const closestHit = hits.at(0);
|
const closestHit = hits.at(0);
|
||||||
if (!closestHit) {
|
if (!closestHit || closestHit.result.primary_artist.url === 'https://genius.com/artists/Deleted-artist') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,316 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { LRC } from '../parsers/lrc';
|
||||||
|
import { netFetch } from '../renderer';
|
||||||
|
|
||||||
import type { LyricProvider, LyricResult, SearchSongInfo } from '../types';
|
import type { LyricProvider, LyricResult, SearchSongInfo } from '../types';
|
||||||
|
|
||||||
export class MusixMatch implements LyricProvider {
|
export class MusixMatch implements LyricProvider {
|
||||||
name = 'MusixMatch';
|
name = 'MusixMatch';
|
||||||
baseUrl = 'https://www.musixmatch.com/';
|
baseUrl = 'https://www.musixmatch.com/';
|
||||||
|
|
||||||
search(_: SearchSongInfo): Promise<LyricResult | null> {
|
private api: MusixMatchAPI | undefined;
|
||||||
throw new Error('Not implemented');
|
|
||||||
|
async search(info: SearchSongInfo): Promise<LyricResult | null> {
|
||||||
|
// 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 = <T extends keyof typeof macroCalls>(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<void>;
|
||||||
|
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<R> {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
<Record<string, string>>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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { SongInfo } from '@/providers/song-info';
|
|||||||
|
|
||||||
import { LRCLib } from './LRCLib';
|
import { LRCLib } from './LRCLib';
|
||||||
import { LyricsGenius } from './LyricsGenius';
|
import { LyricsGenius } from './LyricsGenius';
|
||||||
|
import { MusixMatch } from './MusixMatch';
|
||||||
import { YTMusic } from './YTMusic';
|
import { YTMusic } from './YTMusic';
|
||||||
|
|
||||||
import { getSongInfo } from '@/providers/song-info-front';
|
import { getSongInfo } from '@/providers/song-info-front';
|
||||||
@ -15,8 +16,8 @@ import type { LyricProvider, LyricResult } from '../types';
|
|||||||
export const providers = {
|
export const providers = {
|
||||||
YTMusic: new YTMusic(),
|
YTMusic: new YTMusic(),
|
||||||
LRCLib: new LRCLib(),
|
LRCLib: new LRCLib(),
|
||||||
|
MusixMatch: new MusixMatch(),
|
||||||
LyricsGenius: new LyricsGenius(),
|
LyricsGenius: new LyricsGenius(),
|
||||||
// MusixMatch: new MusixMatch(),
|
|
||||||
// Megalobiz: new Megalobiz(), // Disabled because it is too unstable and slow
|
// Megalobiz: new Megalobiz(), // Disabled because it is too unstable and slow
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@ -36,13 +37,10 @@ type LyricsStore = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const initialData = () =>
|
const initialData = () =>
|
||||||
providerNames.reduce(
|
providerNames.reduce((acc, name) => {
|
||||||
(acc, name) => {
|
acc[name] = { state: 'fetching', data: null, error: null };
|
||||||
acc[name] = { state: 'fetching', data: null, error: null };
|
return acc;
|
||||||
return acc;
|
}, {} as LyricsStore['lyrics']);
|
||||||
},
|
|
||||||
{} as LyricsStore['lyrics'],
|
|
||||||
);
|
|
||||||
|
|
||||||
export const [lyricsStore, setLyricsStore] = createStore<LyricsStore>({
|
export const [lyricsStore, setLyricsStore] = createStore<LyricsStore>({
|
||||||
provider: providerNames[0],
|
provider: providerNames[0],
|
||||||
@ -136,6 +134,8 @@ export const fetchLyrics = (info: SongInfo) => {
|
|||||||
pCache.state = 'error';
|
pCache.state = 'error';
|
||||||
pCache.error = error;
|
pCache.error = error;
|
||||||
|
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
if (getSongInfo().videoId === info.videoId) {
|
if (getSongInfo().videoId === info.videoId) {
|
||||||
setLyricsStore('lyrics', (old) => {
|
setLyricsStore('lyrics', (old) => {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -13,6 +13,10 @@ import type { SongInfo } from '@/providers/song-info';
|
|||||||
import type { SyncedLyricsPluginConfig } from '../types';
|
import type { SyncedLyricsPluginConfig } from '../types';
|
||||||
|
|
||||||
export let _ytAPI: YoutubePlayer | null = null;
|
export let _ytAPI: YoutubePlayer | null = null;
|
||||||
|
export let netFetch: (
|
||||||
|
url: string,
|
||||||
|
init?: RequestInit
|
||||||
|
) => Promise<[number, string, Record<string, string>]>;
|
||||||
|
|
||||||
export const renderer = createRenderer<
|
export const renderer = createRenderer<
|
||||||
{
|
{
|
||||||
@ -53,7 +57,7 @@ export const renderer = createRenderer<
|
|||||||
if (!this.updateTimestampInterval) {
|
if (!this.updateTimestampInterval) {
|
||||||
this.updateTimestampInterval = setInterval(
|
this.updateTimestampInterval = setInterval(
|
||||||
() => setCurrentTime((_ytAPI?.getCurrentTime() ?? 0) * 1000),
|
() => setCurrentTime((_ytAPI?.getCurrentTime() ?? 0) * 1000),
|
||||||
100,
|
100
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -73,6 +77,8 @@ export const renderer = createRenderer<
|
|||||||
},
|
},
|
||||||
|
|
||||||
async start(ctx: RendererContext<SyncedLyricsPluginConfig>) {
|
async start(ctx: RendererContext<SyncedLyricsPluginConfig>) {
|
||||||
|
netFetch = ctx.ipc.invoke.bind(ctx.ipc, 'synced-lyrics:fetch');
|
||||||
|
|
||||||
setConfig(await ctx.getConfig());
|
setConfig(await ctx.getConfig());
|
||||||
|
|
||||||
ctx.ipc.on('ytmd:update-song-info', (info: SongInfo) => {
|
ctx.ipc.on('ytmd:update-song-info', (info: SongInfo) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user