Skip to content

Commit

Permalink
Support spotify built-in lyrics
Browse files Browse the repository at this point in the history
When using built-in lyrics, lyrics uploaded by users themselves will not take effect

Closed #55, #118
Related #25
  • Loading branch information
mantou132 committed May 19, 2024
1 parent d2a7de4 commit 92c7e55
Show file tree
Hide file tree
Showing 14 changed files with 679 additions and 86 deletions.
20 changes: 10 additions & 10 deletions functions/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';

import { Lyric, LyricsResponse, Config } from './type';
import { LyricRecord, LyricsResponse, Config } from './type';

admin.initializeApp();
const db = admin.firestore();
Expand All @@ -23,14 +23,14 @@ const corsHandler = (req: functions.https.Request, res: functions.Response) => {
}
};

const isValidRequest = (params: Lyric) => {
const isValidRequest = (params: LyricRecord) => {
return params?.user && params.name && params.artists && params.platform;
};

export const getLyric = functions.https.onRequest(
async (req, res: functions.Response<LyricsResponse<any>>) => {
if (corsHandler(req, res)) return;
const params: Lyric = req.body;
const params: LyricRecord = req.body;
if (!isValidRequest(params)) {
res.status(400).send({ message: 'Params error' });
return;
Expand All @@ -43,11 +43,11 @@ export const getLyric = functions.https.onRequest(
.where('platform', '==', params.platform);
let snapshot = await query.where('user', '==', params.user).get();
let doc = snapshot.docs[0];
let data = doc?.data() as Lyric | undefined;
let data = doc?.data() as LyricRecord | undefined;
if (snapshot.empty || (!data?.lyric && !data?.neteaseID)) {
snapshot = await query.get();
doc = snapshot.docs[0];
data = doc?.data() as Lyric | undefined;
data = doc?.data() as LyricRecord | undefined;
}
res.send({ data, message: 'OK' });
},
Expand All @@ -56,7 +56,7 @@ export const getLyric = functions.https.onRequest(
export const setLyric = functions.https.onRequest(
async (req, res: functions.Response<LyricsResponse<any>>) => {
if (corsHandler(req, res)) return;
const params: Lyric = req.body;
const params: LyricRecord = req.body;
if (!isValidRequest(params)) {
return;
}
Expand All @@ -71,21 +71,21 @@ export const setLyric = functions.https.onRequest(
if (snapshot.empty) {
if (params.neteaseID || params.lyric) {
await lyricsRef.add(
Object.assign({ neteaseID: 0, lyric: '' } as Lyric, params, {
Object.assign({ neteaseID: 0, lyric: '' } as LyricRecord, params, {
reviewed,
createdTime: Date.now(),
} as Lyric),
} as LyricRecord),
);
}
} else {
const doc = snapshot.docs[0];
const data = Object.assign(doc.data(), params);
if (data.neteaseID || data.lyric) {
await doc.ref.update(
Object.assign({ neteaseID: 0, lyric: '' } as Lyric, params, {
Object.assign({ neteaseID: 0, lyric: '' } as LyricRecord, params, {
reviewed,
updatedTime: Date.now(),
} as Lyric),
} as LyricRecord),
);
} else {
await doc.ref.delete();
Expand Down
2 changes: 1 addition & 1 deletion functions/src/type.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export interface Lyric {
export interface LyricRecord {
name: string;
artists: string;
platform: string;
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "spotify-lyrics",
"version": "1.6.1",
"version": "1.6.2",
"description": "Desktop Spotify Web Player Instant Synchronized Lyrics",
"scripts": {
"lint": "tsc --noEmit && eslint --ext .ts --fix src/",
Expand All @@ -25,6 +25,7 @@
"@sentry/browser": "^5.25.0",
"@webcomponents/webcomponentsjs": "^2.8.0",
"chinese-conv": "^1.0.1",
"duoyun-ui": "^1.1.20",
"webextension-polyfill": "^0.12.0"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion public/_locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@
},

"optionsToggleShortcutDetail": {
"message": "When webapp is in focus, you can use shortcuts to open and close lyrics",
"message": "When webapp is in focus, you can use shortcuts to open and close lyrics, global shortcut: chrome://extensions/shortcuts",
"description": "Toggle show lyrics shortcut detail"
},

Expand Down
2 changes: 1 addition & 1 deletion public/_locales/zh/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@
},

"optionsToggleShortcutDetail": {
"message": "当 WebApp 处于焦点时,可以使用快捷方式来打开歌词和关闭歌词"
"message": "当 WebApp 处于焦点时,可以使用快捷方式来打开歌词和关闭歌词,全局快捷键:chrome://extensions/shortcuts"
},

"menusFeedback": {
Expand Down
2 changes: 1 addition & 1 deletion public/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/extend-chrome/manifest-json-schema/main/schema/manifest.schema.json",
"name": "__MSG_extensionName__",
"version": "1.6.1",
"version": "1.6.2",
"manifest_version": 3,
"description": "__MSG_extensionDescription__",
"default_locale": "en",
Expand Down
2 changes: 1 addition & 1 deletion src/page/btn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ export const insetLyricsBtn = async () => {
sharedData.resetData();
} else {
await openLyrics();
sharedData.updateTrack(true);
sharedData.dispatchTrackElementUpdateEvent(true);
}
} catch (e) {
captureException(e);
Expand Down
50 changes: 27 additions & 23 deletions src/page/lyrics.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { sify, tify } from 'chinese-conv';
import { sify as toSimplified, tify as toTraditional } from 'chinese-conv';

import { isProd } from '../common/constants';

Expand All @@ -10,6 +10,8 @@ import { captureException } from './utils';
export interface Query {
name: string;
artists: string;
/**sec */
duration?: number;
}

export interface Artist {
Expand Down Expand Up @@ -83,7 +85,7 @@ const ignoreAccented = (s: string) => {
};

const simplifiedText = (s: string) => {
return ignoreAccented(plainText(sify(normalize(s)).toLowerCase()));
return ignoreAccented(plainText(toSimplified(normalize(s)).toLowerCase()));
};

const removeSongFeat = (s: string) => {
Expand Down Expand Up @@ -132,9 +134,9 @@ async function fetchChineseName(s: string, fetchOptions?: RequestInit) {
artists.forEach((artist) => {
const alias = [...artist.alias, ...(artist.transNames || [])].map(simplifiedText).sort();
// Chinese singer's English name as an alias
alias.forEach((alia) => {
if (s.includes(alia)) {
singerAlias[alia] = artist.name;
alias.forEach((n) => {
if (s.includes(n)) {
singerAlias[n] = artist.name;
}
});
});
Expand Down Expand Up @@ -179,6 +181,7 @@ export async function matchingLyrics(
query: Query,
options: MatchingLyricsOptions = {},
): Promise<{ list: Song[]; id: number; score: number }> {
const { name = '', artists = '' } = query;
const {
getAudioElement,
onlySearchName = false,
Expand All @@ -187,18 +190,18 @@ export async function matchingLyrics(
fetchOptions,
} = options;

let audio: HTMLAudioElement | null = null;
if (getAudioElement) {
audio = await getAudioElement();
let duration = query.duration || 0;
if (getAudioElement && !duration) {
const audio = await getAudioElement();
if (!audio.duration) {
await new Promise((res) => audio!.addEventListener('loadedmetadata', res, { once: true }));
await new Promise((res) => audio.addEventListener('loadedmetadata', res, { once: true }));
duration = audio.duration;
}
}
const { name = '', artists = '' } = query;

const queryName = normalize(name);
const queryName1 = queryName.toLowerCase();
const queryName2 = sify(queryName1);
const queryName2 = toSimplified(queryName1);
const queryName3 = plainText(queryName2);
const queryName4 = ignoreAccented(queryName3);
const queryName5 = removeSongFeat(queryName4);
Expand All @@ -208,7 +211,7 @@ export async function matchingLyrics(
.map((e) => normalize(e.trim()))
.sort();
const queryArtistsArr1 = queryArtistsArr.map((e) => e.toLowerCase());
const queryArtistsArr2 = queryArtistsArr1.map((e) => sify(e));
const queryArtistsArr2 = queryArtistsArr1.map((e) => toSimplified(e));
const queryArtistsArr3 = queryArtistsArr2.map((e) => ignoreAccented(plainText(e)));

const singerAlias = await fetchTransName(
Expand All @@ -219,7 +222,7 @@ export async function matchingLyrics(

const queryArtistsArr4 = queryArtistsArr3
.map((e) => singerAlias[e] || buildInSingerAlias[e] || e)
.map((e) => sify(e).toLowerCase());
.map((e) => toSimplified(e).toLowerCase());

const searchString = onlySearchName
? removeSongFeat(name)
Expand All @@ -235,10 +238,10 @@ export async function matchingLyrics(
let currentScore = 0;

if (
!audio ||
(!isProd && audio.duration < 40) ||
!duration ||
(!isProd && duration < 40) ||
!song.duration ||
Math.abs(audio.duration - song.duration / 1000) < 2
Math.abs(duration - song.duration / 1000) < 2
) {
currentScore += DURATION_WEIGHT;
}
Expand All @@ -251,7 +254,7 @@ export async function matchingLyrics(
if (songName === queryName1) {
currentScore += 9.1;
} else {
songName = sify(songName);
songName = toSimplified(songName);
if (
songName === queryName2 ||
songName.endsWith(`(${queryName2})`) ||
Expand All @@ -273,7 +276,7 @@ export async function matchingLyrics(
} else {
songName = getText(
// without `plainText`
removeSongFeat(ignoreAccented(sify(normalize(song.name).toLowerCase()))),
removeSongFeat(ignoreAccented(toSimplified(normalize(song.name).toLowerCase()))),
);
if (songName === queryName6) {
// name & name (abc)
Expand Down Expand Up @@ -305,7 +308,7 @@ export async function matchingLyrics(
} else if (new Set([...queryArtistsArr1, ...songArtistsArr]).size < len) {
currentScore += 5.4;
} else {
songArtistsArr = songArtistsArr.map((e) => sify(e));
songArtistsArr = songArtistsArr.map((e) => toSimplified(e));
if (queryArtistsArr2.join() === songArtistsArr.join()) {
currentScore += 5.3;
} else {
Expand Down Expand Up @@ -381,6 +384,7 @@ export async function fetchLyric(songId: number, fetchOptions?: RequestInit) {
}

class Line {
/**sec */
startTime: number | null = null;
text = '';
constructor(text = '', starTime: number | null = null) {
Expand Down Expand Up @@ -425,20 +429,20 @@ export function parseLyrics(lyricStr: string, options: ParseLyricsOptions = {})
if (textIndex > -1) {
text = matchResult.splice(textIndex, 1)[0];
text = capitalize(normalize(text, false));
text = sify(text).replace(/\.|,|\?|!|;$/u, '');
text = toSimplified(text).replace(/\.|,|\?|!|;$/u, '');
}
if (!matchResult.length && options.keepPlainText) {
return [new Line(text)];
}
return matchResult.map((slice) => {
const result = new Line();
const matchResut = slice.match(/[^\[\]]+/g);
const [key, value] = matchResut?.[0].split(':') || [];
const matchResult = slice.match(/[^\[\]]+/g);
const [key, value] = matchResult?.[0].split(':') || [];
const [min, sec] = [parseFloat(key), parseFloat(value)];
if (!isNaN(min)) {
if (!options.cleanLyrics || !otherInfoRegexp.test(text)) {
result.startTime = min * 60 + sec;
result.text = options.useTChinese ? tify(text) : text;
result.text = options.useTChinese ? toTraditional(text) : text;
}
} else if (!options.cleanLyrics && key && value) {
result.text = `${key.toUpperCase()}: ${value}`;
Expand Down
56 changes: 51 additions & 5 deletions src/page/observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { insetLyricsBtn } from './btn';
import { sharedData } from './share-data';
import { generateCover } from './cover';
import { captureException, documentQueryHasSelector } from './utils';
import { SpotifyTrackLyrics, SpotifyTrackMetadata } from './types';

let loginResolve: (value?: unknown) => void;
export const loggedPromise = new Promise((res) => (loginResolve = res));
Expand Down Expand Up @@ -74,23 +75,24 @@ configPromise.then(
anonymous.src = this.currentSrc || this.src;
}

const update = () => {
const infoElementUpdate = () => {
// Assuming that cover is loaded after the song information is updated
const cover = document.querySelector(ALBUM_COVER_SELECTOR) as HTMLImageElement | null;
if (cover) {
cover.addEventListener('load', coverUpdated);
}

if (!lyricVideoIsOpen) return;
const likeBtn = documentQueryHasSelector(BTN_LIKE_SELECTOR);
const likeBtnRect = likeBtn?.getBoundingClientRect();
if (!likeBtnRect?.width || !likeBtnRect.height) {
// advertisement
return sharedData.resetData();
}

sharedData.updateTrack();
sharedData.dispatchTrackElementUpdateEvent();

if (lyricVideoIsOpen && !cover) {
if (!cover) {
captureException(new Error('Cover not found'));
}
};
Expand All @@ -104,10 +106,10 @@ configPromise.then(
const prevInfoElement = infoElement;
infoElement = document.querySelector(TRACK_INFO_SELECTOR);
if (!infoElement) return;
if (!prevInfoElement || prevInfoElement !== infoElement) update();
if (!prevInfoElement || prevInfoElement !== infoElement) infoElementUpdate();

if (!weakMap.has(infoElement)) {
const infoEleObserver = new MutationObserver(update);
const infoEleObserver = new MutationObserver(infoElementUpdate);
infoEleObserver.observe(infoElement, {
childList: true,
characterData: true,
Expand All @@ -123,3 +125,47 @@ configPromise.then(
htmlEleObserver.observe(document.documentElement, { childList: true, subtree: true });
},
);

const originFetch = globalThis.fetch;

let latestHeader = new Headers();

// Priority to detect track switching through API
// Priority to use build-in lyrics through API
globalThis.fetch = async (...rest) => {
const res = await originFetch(...rest);
const url = new URL(rest[0] instanceof Request ? rest[0].url : rest[0], location.origin);
latestHeader = new Headers(rest[0] instanceof Request ? rest[0].headers : rest[1]?.headers);
const spotifyAPI = 'https://spclient.wg.spotify.com';
if (url.origin === spotifyAPI && url.pathname.startsWith('/metadata/4/track/')) {
const metadata: SpotifyTrackMetadata = await res.clone().json();
const { name = '', artist = [], duration = 0, canonical_uri, has_lyrics } = metadata || {};
const trackId = canonical_uri?.match(/spotify:track:([^:]*)/)?.[1];
// match artists element textContent
const artists = artist?.map((e) => e?.name).join(', ');
sharedData.cacheTrackAndLyrics({
name,
artists,
duration: duration / 1000,
getLyrics: has_lyrics
? async () => {
const res = await fetch(`${spotifyAPI}/lyrics/v1/track/${trackId}?market=from_token`, {
headers: latestHeader,
});
const spLyrics: SpotifyTrackLyrics = await res.json();
if (spLyrics.kind === 'LINE') {
return spLyrics.lines
.map(({ time, words }) =>
words.map(({ string }) => ({
startTime: time / 1000,
text: string,
})),
)
.flat();
}
}
: undefined,
});
}
return res;
};
Loading

0 comments on commit 92c7e55

Please sign in to comment.