Skip to content

Commit 92c7e55

Browse files
committed
Support spotify built-in lyrics
When using built-in lyrics, lyrics uploaded by users themselves will not take effect Closed #55, #118 Related #25
1 parent d2a7de4 commit 92c7e55

File tree

14 files changed

+679
-86
lines changed

14 files changed

+679
-86
lines changed

Diff for: functions/src/index.ts

+10-10
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as functions from 'firebase-functions';
22
import * as admin from 'firebase-admin';
33

4-
import { Lyric, LyricsResponse, Config } from './type';
4+
import { LyricRecord, LyricsResponse, Config } from './type';
55

66
admin.initializeApp();
77
const db = admin.firestore();
@@ -23,14 +23,14 @@ const corsHandler = (req: functions.https.Request, res: functions.Response) => {
2323
}
2424
};
2525

26-
const isValidRequest = (params: Lyric) => {
26+
const isValidRequest = (params: LyricRecord) => {
2727
return params?.user && params.name && params.artists && params.platform;
2828
};
2929

3030
export const getLyric = functions.https.onRequest(
3131
async (req, res: functions.Response<LyricsResponse<any>>) => {
3232
if (corsHandler(req, res)) return;
33-
const params: Lyric = req.body;
33+
const params: LyricRecord = req.body;
3434
if (!isValidRequest(params)) {
3535
res.status(400).send({ message: 'Params error' });
3636
return;
@@ -43,11 +43,11 @@ export const getLyric = functions.https.onRequest(
4343
.where('platform', '==', params.platform);
4444
let snapshot = await query.where('user', '==', params.user).get();
4545
let doc = snapshot.docs[0];
46-
let data = doc?.data() as Lyric | undefined;
46+
let data = doc?.data() as LyricRecord | undefined;
4747
if (snapshot.empty || (!data?.lyric && !data?.neteaseID)) {
4848
snapshot = await query.get();
4949
doc = snapshot.docs[0];
50-
data = doc?.data() as Lyric | undefined;
50+
data = doc?.data() as LyricRecord | undefined;
5151
}
5252
res.send({ data, message: 'OK' });
5353
},
@@ -56,7 +56,7 @@ export const getLyric = functions.https.onRequest(
5656
export const setLyric = functions.https.onRequest(
5757
async (req, res: functions.Response<LyricsResponse<any>>) => {
5858
if (corsHandler(req, res)) return;
59-
const params: Lyric = req.body;
59+
const params: LyricRecord = req.body;
6060
if (!isValidRequest(params)) {
6161
return;
6262
}
@@ -71,21 +71,21 @@ export const setLyric = functions.https.onRequest(
7171
if (snapshot.empty) {
7272
if (params.neteaseID || params.lyric) {
7373
await lyricsRef.add(
74-
Object.assign({ neteaseID: 0, lyric: '' } as Lyric, params, {
74+
Object.assign({ neteaseID: 0, lyric: '' } as LyricRecord, params, {
7575
reviewed,
7676
createdTime: Date.now(),
77-
} as Lyric),
77+
} as LyricRecord),
7878
);
7979
}
8080
} else {
8181
const doc = snapshot.docs[0];
8282
const data = Object.assign(doc.data(), params);
8383
if (data.neteaseID || data.lyric) {
8484
await doc.ref.update(
85-
Object.assign({ neteaseID: 0, lyric: '' } as Lyric, params, {
85+
Object.assign({ neteaseID: 0, lyric: '' } as LyricRecord, params, {
8686
reviewed,
8787
updatedTime: Date.now(),
88-
} as Lyric),
88+
} as LyricRecord),
8989
);
9090
} else {
9191
await doc.ref.delete();

Diff for: functions/src/type.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export interface Lyric {
1+
export interface LyricRecord {
22
name: string;
33
artists: string;
44
platform: string;

Diff for: package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "spotify-lyrics",
3-
"version": "1.6.1",
3+
"version": "1.6.2",
44
"description": "Desktop Spotify Web Player Instant Synchronized Lyrics",
55
"scripts": {
66
"lint": "tsc --noEmit && eslint --ext .ts --fix src/",
@@ -25,6 +25,7 @@
2525
"@sentry/browser": "^5.25.0",
2626
"@webcomponents/webcomponentsjs": "^2.8.0",
2727
"chinese-conv": "^1.0.1",
28+
"duoyun-ui": "^1.1.20",
2829
"webextension-polyfill": "^0.12.0"
2930
},
3031
"devDependencies": {

Diff for: public/_locales/en/messages.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@
135135
},
136136

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

Diff for: public/_locales/zh/messages.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@
104104
},
105105

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

110110
"menusFeedback": {

Diff for: public/manifest.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://raw.githubusercontent.com/extend-chrome/manifest-json-schema/main/schema/manifest.schema.json",
33
"name": "__MSG_extensionName__",
4-
"version": "1.6.1",
4+
"version": "1.6.2",
55
"manifest_version": 3,
66
"description": "__MSG_extensionDescription__",
77
"default_locale": "en",

Diff for: src/page/btn.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ export const insetLyricsBtn = async () => {
138138
sharedData.resetData();
139139
} else {
140140
await openLyrics();
141-
sharedData.updateTrack(true);
141+
sharedData.dispatchTrackElementUpdateEvent(true);
142142
}
143143
} catch (e) {
144144
captureException(e);

Diff for: src/page/lyrics.ts

+27-23
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { sify, tify } from 'chinese-conv';
1+
import { sify as toSimplified, tify as toTraditional } from 'chinese-conv';
22

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

@@ -10,6 +10,8 @@ import { captureException } from './utils';
1010
export interface Query {
1111
name: string;
1212
artists: string;
13+
/**sec */
14+
duration?: number;
1315
}
1416

1517
export interface Artist {
@@ -83,7 +85,7 @@ const ignoreAccented = (s: string) => {
8385
};
8486

8587
const simplifiedText = (s: string) => {
86-
return ignoreAccented(plainText(sify(normalize(s)).toLowerCase()));
88+
return ignoreAccented(plainText(toSimplified(normalize(s)).toLowerCase()));
8789
};
8890

8991
const removeSongFeat = (s: string) => {
@@ -132,9 +134,9 @@ async function fetchChineseName(s: string, fetchOptions?: RequestInit) {
132134
artists.forEach((artist) => {
133135
const alias = [...artist.alias, ...(artist.transNames || [])].map(simplifiedText).sort();
134136
// Chinese singer's English name as an alias
135-
alias.forEach((alia) => {
136-
if (s.includes(alia)) {
137-
singerAlias[alia] = artist.name;
137+
alias.forEach((n) => {
138+
if (s.includes(n)) {
139+
singerAlias[n] = artist.name;
138140
}
139141
});
140142
});
@@ -179,6 +181,7 @@ export async function matchingLyrics(
179181
query: Query,
180182
options: MatchingLyricsOptions = {},
181183
): Promise<{ list: Song[]; id: number; score: number }> {
184+
const { name = '', artists = '' } = query;
182185
const {
183186
getAudioElement,
184187
onlySearchName = false,
@@ -187,18 +190,18 @@ export async function matchingLyrics(
187190
fetchOptions,
188191
} = options;
189192

190-
let audio: HTMLAudioElement | null = null;
191-
if (getAudioElement) {
192-
audio = await getAudioElement();
193+
let duration = query.duration || 0;
194+
if (getAudioElement && !duration) {
195+
const audio = await getAudioElement();
193196
if (!audio.duration) {
194-
await new Promise((res) => audio!.addEventListener('loadedmetadata', res, { once: true }));
197+
await new Promise((res) => audio.addEventListener('loadedmetadata', res, { once: true }));
198+
duration = audio.duration;
195199
}
196200
}
197-
const { name = '', artists = '' } = query;
198201

199202
const queryName = normalize(name);
200203
const queryName1 = queryName.toLowerCase();
201-
const queryName2 = sify(queryName1);
204+
const queryName2 = toSimplified(queryName1);
202205
const queryName3 = plainText(queryName2);
203206
const queryName4 = ignoreAccented(queryName3);
204207
const queryName5 = removeSongFeat(queryName4);
@@ -208,7 +211,7 @@ export async function matchingLyrics(
208211
.map((e) => normalize(e.trim()))
209212
.sort();
210213
const queryArtistsArr1 = queryArtistsArr.map((e) => e.toLowerCase());
211-
const queryArtistsArr2 = queryArtistsArr1.map((e) => sify(e));
214+
const queryArtistsArr2 = queryArtistsArr1.map((e) => toSimplified(e));
212215
const queryArtistsArr3 = queryArtistsArr2.map((e) => ignoreAccented(plainText(e)));
213216

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

220223
const queryArtistsArr4 = queryArtistsArr3
221224
.map((e) => singerAlias[e] || buildInSingerAlias[e] || e)
222-
.map((e) => sify(e).toLowerCase());
225+
.map((e) => toSimplified(e).toLowerCase());
223226

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

237240
if (
238-
!audio ||
239-
(!isProd && audio.duration < 40) ||
241+
!duration ||
242+
(!isProd && duration < 40) ||
240243
!song.duration ||
241-
Math.abs(audio.duration - song.duration / 1000) < 2
244+
Math.abs(duration - song.duration / 1000) < 2
242245
) {
243246
currentScore += DURATION_WEIGHT;
244247
}
@@ -251,7 +254,7 @@ export async function matchingLyrics(
251254
if (songName === queryName1) {
252255
currentScore += 9.1;
253256
} else {
254-
songName = sify(songName);
257+
songName = toSimplified(songName);
255258
if (
256259
songName === queryName2 ||
257260
songName.endsWith(`(${queryName2})`) ||
@@ -273,7 +276,7 @@ export async function matchingLyrics(
273276
} else {
274277
songName = getText(
275278
// without `plainText`
276-
removeSongFeat(ignoreAccented(sify(normalize(song.name).toLowerCase()))),
279+
removeSongFeat(ignoreAccented(toSimplified(normalize(song.name).toLowerCase()))),
277280
);
278281
if (songName === queryName6) {
279282
// name & name (abc)
@@ -305,7 +308,7 @@ export async function matchingLyrics(
305308
} else if (new Set([...queryArtistsArr1, ...songArtistsArr]).size < len) {
306309
currentScore += 5.4;
307310
} else {
308-
songArtistsArr = songArtistsArr.map((e) => sify(e));
311+
songArtistsArr = songArtistsArr.map((e) => toSimplified(e));
309312
if (queryArtistsArr2.join() === songArtistsArr.join()) {
310313
currentScore += 5.3;
311314
} else {
@@ -381,6 +384,7 @@ export async function fetchLyric(songId: number, fetchOptions?: RequestInit) {
381384
}
382385

383386
class Line {
387+
/**sec */
384388
startTime: number | null = null;
385389
text = '';
386390
constructor(text = '', starTime: number | null = null) {
@@ -425,20 +429,20 @@ export function parseLyrics(lyricStr: string, options: ParseLyricsOptions = {})
425429
if (textIndex > -1) {
426430
text = matchResult.splice(textIndex, 1)[0];
427431
text = capitalize(normalize(text, false));
428-
text = sify(text).replace(/\.|,|\?|!|;$/u, '');
432+
text = toSimplified(text).replace(/\.|,|\?|!|;$/u, '');
429433
}
430434
if (!matchResult.length && options.keepPlainText) {
431435
return [new Line(text)];
432436
}
433437
return matchResult.map((slice) => {
434438
const result = new Line();
435-
const matchResut = slice.match(/[^\[\]]+/g);
436-
const [key, value] = matchResut?.[0].split(':') || [];
439+
const matchResult = slice.match(/[^\[\]]+/g);
440+
const [key, value] = matchResult?.[0].split(':') || [];
437441
const [min, sec] = [parseFloat(key), parseFloat(value)];
438442
if (!isNaN(min)) {
439443
if (!options.cleanLyrics || !otherInfoRegexp.test(text)) {
440444
result.startTime = min * 60 + sec;
441-
result.text = options.useTChinese ? tify(text) : text;
445+
result.text = options.useTChinese ? toTraditional(text) : text;
442446
}
443447
} else if (!options.cleanLyrics && key && value) {
444448
result.text = `${key.toUpperCase()}: ${value}`;

Diff for: src/page/observer.ts

+51-5
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { insetLyricsBtn } from './btn';
44
import { sharedData } from './share-data';
55
import { generateCover } from './cover';
66
import { captureException, documentQueryHasSelector } from './utils';
7+
import { SpotifyTrackLyrics, SpotifyTrackMetadata } from './types';
78

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

77-
const update = () => {
78+
const infoElementUpdate = () => {
7879
// Assuming that cover is loaded after the song information is updated
7980
const cover = document.querySelector(ALBUM_COVER_SELECTOR) as HTMLImageElement | null;
8081
if (cover) {
8182
cover.addEventListener('load', coverUpdated);
8283
}
8384

85+
if (!lyricVideoIsOpen) return;
8486
const likeBtn = documentQueryHasSelector(BTN_LIKE_SELECTOR);
8587
const likeBtnRect = likeBtn?.getBoundingClientRect();
8688
if (!likeBtnRect?.width || !likeBtnRect.height) {
8789
// advertisement
8890
return sharedData.resetData();
8991
}
9092

91-
sharedData.updateTrack();
93+
sharedData.dispatchTrackElementUpdateEvent();
9294

93-
if (lyricVideoIsOpen && !cover) {
95+
if (!cover) {
9496
captureException(new Error('Cover not found'));
9597
}
9698
};
@@ -104,10 +106,10 @@ configPromise.then(
104106
const prevInfoElement = infoElement;
105107
infoElement = document.querySelector(TRACK_INFO_SELECTOR);
106108
if (!infoElement) return;
107-
if (!prevInfoElement || prevInfoElement !== infoElement) update();
109+
if (!prevInfoElement || prevInfoElement !== infoElement) infoElementUpdate();
108110

109111
if (!weakMap.has(infoElement)) {
110-
const infoEleObserver = new MutationObserver(update);
112+
const infoEleObserver = new MutationObserver(infoElementUpdate);
111113
infoEleObserver.observe(infoElement, {
112114
childList: true,
113115
characterData: true,
@@ -123,3 +125,47 @@ configPromise.then(
123125
htmlEleObserver.observe(document.documentElement, { childList: true, subtree: true });
124126
},
125127
);
128+
129+
const originFetch = globalThis.fetch;
130+
131+
let latestHeader = new Headers();
132+
133+
// Priority to detect track switching through API
134+
// Priority to use build-in lyrics through API
135+
globalThis.fetch = async (...rest) => {
136+
const res = await originFetch(...rest);
137+
const url = new URL(rest[0] instanceof Request ? rest[0].url : rest[0], location.origin);
138+
latestHeader = new Headers(rest[0] instanceof Request ? rest[0].headers : rest[1]?.headers);
139+
const spotifyAPI = 'https://spclient.wg.spotify.com';
140+
if (url.origin === spotifyAPI && url.pathname.startsWith('/metadata/4/track/')) {
141+
const metadata: SpotifyTrackMetadata = await res.clone().json();
142+
const { name = '', artist = [], duration = 0, canonical_uri, has_lyrics } = metadata || {};
143+
const trackId = canonical_uri?.match(/spotify:track:([^:]*)/)?.[1];
144+
// match artists element textContent
145+
const artists = artist?.map((e) => e?.name).join(', ');
146+
sharedData.cacheTrackAndLyrics({
147+
name,
148+
artists,
149+
duration: duration / 1000,
150+
getLyrics: has_lyrics
151+
? async () => {
152+
const res = await fetch(`${spotifyAPI}/lyrics/v1/track/${trackId}?market=from_token`, {
153+
headers: latestHeader,
154+
});
155+
const spLyrics: SpotifyTrackLyrics = await res.json();
156+
if (spLyrics.kind === 'LINE') {
157+
return spLyrics.lines
158+
.map(({ time, words }) =>
159+
words.map(({ string }) => ({
160+
startTime: time / 1000,
161+
text: string,
162+
})),
163+
)
164+
.flat();
165+
}
166+
}
167+
: undefined,
168+
});
169+
}
170+
return res;
171+
};

0 commit comments

Comments
 (0)