From cbc08d6f036f652750bd15351f71a57bf52fedc0 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Fri, 9 Jun 2023 14:45:29 -0700 Subject: [PATCH] Improve lyrics match with scored searches --- package-lock.json | 16 +- package.json | 1 + src/main/features/core/lyrics/genius.ts | 158 +++++++++++------- src/main/features/core/lyrics/index.ts | 8 +- src/main/features/core/lyrics/netease.ts | 96 ++++++++--- src/main/features/core/lyrics/shared.ts | 34 ++++ src/renderer/api/types.ts | 10 +- .../lyrics/components/lyrics-search-form.tsx | 51 ++++-- 8 files changed, 272 insertions(+), 102 deletions(-) create mode 100644 src/main/features/core/lyrics/shared.ts diff --git a/package-lock.json b/package-lock.json index ec039e6c..87072014 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "fast-average-color": "^9.3.0", "format-duration": "^2.0.0", "framer-motion": "^9.1.7", + "fuse.js": "^6.6.2", "history": "^5.3.0", "i18next": "^21.6.16", "immer": "^9.0.21", @@ -10726,6 +10727,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuse.js": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-6.6.2.tgz", + "integrity": "sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA==", + "engines": { + "node": ">=10" + } + }, "node_modules/gauge": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", @@ -31631,6 +31640,11 @@ "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", "dev": true }, + "fuse.js": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-6.6.2.tgz", + "integrity": "sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA==" + }, "gauge": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", @@ -34842,7 +34856,7 @@ }, "node-mpv": { "version": "git+ssh://git@github.com/jeffvli/Node-MPV.git#c7f84d7966b82e5916c3b4bb47cac667bb895c22", - "from": "node-mpv@https://github.com/jeffvli/Node-MPV" + "from": "node-mpv@github:jeffvli/Node-MPV" }, "node-releases": { "version": "2.0.8", diff --git a/package.json b/package.json index 6a5b8d42..380039d4 100644 --- a/package.json +++ b/package.json @@ -275,6 +275,7 @@ "fast-average-color": "^9.3.0", "format-duration": "^2.0.0", "framer-motion": "^9.1.7", + "fuse.js": "^6.6.2", "history": "^5.3.0", "i18next": "^21.6.16", "immer": "^9.0.21", diff --git a/src/main/features/core/lyrics/genius.ts b/src/main/features/core/lyrics/genius.ts index cee6d086..a2823a80 100644 --- a/src/main/features/core/lyrics/genius.ts +++ b/src/main/features/core/lyrics/genius.ts @@ -1,69 +1,103 @@ import axios, { AxiosResponse } from 'axios'; import { load } from 'cheerio'; -import type { +import { + LyricSource, InternetProviderLyricResponse, InternetProviderLyricSearchResponse, LyricSearchQuery, -} from '/@/renderer/api/types'; -import { LyricSource } from '../../../../renderer/api/types'; +} from '../../../../renderer/api/types'; +import { orderSearchResults } from './shared'; const SEARCH_URL = 'https://genius.com/api/search/song'; // Adapted from https://github.com/NyaomiDEV/Sunamu/blob/master/src/main/lyricproviders/genius.ts -interface GeniusResponse { - artist: string; - name: string; +export interface GeniusResponse { + meta: Meta; + response: Response; +} + +export interface Meta { + status: number; +} + +export interface Response { + next_page: number; + sections: Section[]; +} + +export interface Section { + hits: Hit[]; + type: string; +} + +export interface Hit { + highlights: any[]; + index: string; + result: Result; + type: string; +} + +export interface Result { + _type: string; + annotation_count: number; + api_path: string; + artist_names: string; + featured_artists: any[]; + full_title: string; + header_image_thumbnail_url: string; + header_image_url: string; + id: number; + instrumental: boolean; + language: string; + lyrics_owner_id: number; + lyrics_state: string; + lyrics_updated_at: number; + path: string; + primary_artist: PrimaryArtist; + pyongs_count: null; + relationships_index_url: string; + release_date_components: ReleaseDateComponents; + release_date_for_display: string; + release_date_with_abbreviated_month_for_display: string; + song_art_image_thumbnail_url: string; + song_art_image_url: string; + stats: Stats; + title: string; + title_with_featured: string; + updated_by_human_at: number; url: string; } -interface GeniusSearchResponse { - response: { - sections: { - hits: { - highlights: any[]; - index: string; - result: { - _type: string; - annotation_count: number; - api_path: string; - artist_names: string; - featured_artits: any[]; - full_title: string; - header_image_thumbnail_url: string; - header_image_url: string; - id: number; - instrumental: boolean; - language: string; - lyrics_owner_id: number; - lyrics_state: string; - lyrics_updated_at: number; - path: string; - primary_artist: Record; - pyongs_count: number; - relationships_index_url: string; - release_date_components: Record; - release_date_for_display: string; - release_date_with_abbreviated_month_for_display: string; - song_art_image_thumbnail_url: string; - song_art_image_url: string; - stats: Record; - title: string; - title_with_featured: string; - updated_by_human_at: number; - url: string; - }; - type: string; - }[]; - type: string; - }[]; - }; +export interface PrimaryArtist { + _type: string; + api_path: string; + header_image_url: string; + id: number; + image_url: string; + index_character: string; + is_meme_verified: boolean; + is_verified: boolean; + name: string; + slug: string; + url: string; +} + +export interface ReleaseDateComponents { + day: number; + month: number; + year: number; +} + +export interface Stats { + hot: boolean; + unreviewed_annotations: number; } export async function getSearchResults( params: LyricSearchQuery, ): Promise { - let result: AxiosResponse; + let result: AxiosResponse; const searchQuery = [params.artist, params.name].join(' '); @@ -83,11 +117,11 @@ export async function getSearchResults( return null; } - const songs = result.data.response?.sections?.[0]?.hits?.map((hit) => hit.result); + const rawSongsResult = result.data.response?.sections?.[0]?.hits?.map((hit) => hit.result); - if (!songs) return null; + if (!rawSongsResult) return null; - return songs.map((song: any) => { + const songResults: InternetProviderLyricSearchResponse[] = rawSongsResult.map((song: any) => { return { artist: song.artist_names, id: song.url, @@ -95,10 +129,14 @@ export async function getSearchResults( source: LyricSource.GENIUS, }; }); + + return orderSearchResults({ params, results: songResults }); } -async function getSongURL(params: LyricSearchQuery): Promise { - let result: AxiosResponse; +async function getSongId( + params: LyricSearchQuery, +): Promise | null> { + let result: AxiosResponse; try { result = await axios.get(SEARCH_URL, { params: { @@ -108,23 +146,24 @@ async function getSongURL(params: LyricSearchQuery): Promise { +export async function getLyricsBySongId(url: string): Promise { let result: AxiosResponse; try { result = await axios.get(url, { responseType: 'text' }); @@ -148,13 +187,13 @@ export async function getLyricsByURL(url: string): Promise { export async function query( params: LyricSearchQuery, ): Promise { - const response = await getSongURL(params); + const response = await getSongId(params); if (!response) { console.error('Could not find the song on Genius!'); return null; } - const lyrics = await getLyricsByURL(response.url); + const lyrics = await getLyricsBySongId(response.id); if (!lyrics) { console.error('Could not get lyrics on Genius!'); return null; @@ -162,6 +201,7 @@ export async function query( return { artist: response.artist, + id: response.id, lyrics, name: response.name, source: LyricSource.GENIUS, diff --git a/src/main/features/core/lyrics/index.ts b/src/main/features/core/lyrics/index.ts index 943f9851..5b0d272c 100644 --- a/src/main/features/core/lyrics/index.ts +++ b/src/main/features/core/lyrics/index.ts @@ -1,3 +1,4 @@ +import { ipcMain } from 'electron'; import { InternetProviderLyricResponse, InternetProviderLyricSearchResponse, @@ -5,19 +6,18 @@ import { QueueSong, LyricGetQuery, LyricSource, -} from '/@/renderer/api/types'; +} from '../../../../renderer/api/types'; +import { store } from '../settings/index'; import { query as queryGenius, getSearchResults as searchGenius, - getLyricsByURL as getGenius, + getLyricsBySongId as getGenius, } from './genius'; import { query as queryNetease, getSearchResults as searchNetease, getLyricsBySongId as getNetease, } from './netease'; -import { ipcMain } from 'electron'; -import { store } from '../settings/index'; type SongFetcher = (params: LyricSearchQuery) => Promise; type SearchFetcher = ( diff --git a/src/main/features/core/lyrics/netease.ts b/src/main/features/core/lyrics/netease.ts index b5363c18..d2722e9a 100644 --- a/src/main/features/core/lyrics/netease.ts +++ b/src/main/features/core/lyrics/netease.ts @@ -1,5 +1,6 @@ import axios, { AxiosResponse } from 'axios'; import { LyricSource } from '../../../../renderer/api/types'; +import { orderSearchResults } from './shared'; import type { InternetProviderLyricResponse, InternetProviderLyricSearchResponse, @@ -11,16 +12,65 @@ const LYRICS_URL = 'https://music.163.com/api/song/lyric'; // Adapted from https://github.com/NyaomiDEV/Sunamu/blob/master/src/main/lyricproviders/netease.ts -interface NetEaseResponse { - artist: string; - id: string; +export interface NetEaseResponse { + code: number; + result: Result; +} + +export interface Result { + hasMore: boolean; + songCount: number; + songs: Song[]; +} + +export interface Song { + album: Album; + alias: string[]; + artists: Artist[]; + copyrightId: number; + duration: number; + fee: number; + ftype: number; + id: number; + mark: number; + mvid: number; name: string; + rUrl: null; + rtype: number; + status: number; + transNames?: string[]; +} + +export interface Album { + artist: Artist; + copyrightId: number; + id: number; + mark: number; + name: string; + picId: number; + publishTime: number; + size: number; + status: number; + transNames?: string[]; +} + +export interface Artist { + albumSize: number; + alias: any[]; + fansGroup: null; + id: number; + img1v1: number; + img1v1Url: string; + name: string; + picId: number; + picUrl: null; + trans: null; } export async function getSearchResults( params: LyricSearchQuery, ): Promise { - let result: AxiosResponse; + let result: AxiosResponse; const searchQuery = [params.artist, params.name].join(' '); @@ -42,11 +92,11 @@ export async function getSearchResults( return null; } - const songs = result?.data.result?.songs; + const rawSongsResult = result?.data.result?.songs; - if (!songs) return null; + if (!rawSongsResult) return null; - return songs.map((song: any) => { + const songResults: InternetProviderLyricSearchResponse[] = rawSongsResult.map((song: any) => { const artist = song.artists ? song.artists.map((artist: any) => artist.name).join(', ') : ''; return { @@ -56,19 +106,24 @@ export async function getSearchResults( source: LyricSource.NETEASE, }; }); + + return orderSearchResults({ params, results: songResults }); } -async function getSongId(params: LyricSearchQuery): Promise { +async function getMatchedLyrics( + params: LyricSearchQuery, +): Promise | null> { const results = await getSearchResults(params); - const song = results?.[0]; - if (!song) return undefined; + console.log('results', results); - return { - artist: song.artist, - id: song.id, - name: song.name, - }; + const firstMatch = results?.[0]; + + if (!firstMatch || (firstMatch?.score && firstMatch.score > 0.5)) { + return null; + } + + return firstMatch; } export async function getLyricsBySongId(songId: string): Promise { @@ -92,22 +147,23 @@ export async function getLyricsBySongId(songId: string): Promise export async function query( params: LyricSearchQuery, ): Promise { - const response = await getSongId(params); - if (!response) { + const lyricsMatch = await getMatchedLyrics(params); + if (!lyricsMatch) { console.error('Could not find the song on NetEase!'); return null; } - const lyrics = await getLyricsBySongId(response.id); + const lyrics = await getLyricsBySongId(lyricsMatch.id); if (!lyrics) { console.error('Could not get lyrics on NetEase!'); return null; } return { - artist: response.artist, + artist: lyricsMatch.artist, + id: lyricsMatch.id, lyrics, - name: response.name, + name: lyricsMatch.name, source: LyricSource.NETEASE, }; } diff --git a/src/main/features/core/lyrics/shared.ts b/src/main/features/core/lyrics/shared.ts new file mode 100644 index 00000000..3bf5e4d5 --- /dev/null +++ b/src/main/features/core/lyrics/shared.ts @@ -0,0 +1,34 @@ +import Fuse from 'fuse.js'; +import { + InternetProviderLyricSearchResponse, + LyricSearchQuery, +} from '../../../../renderer/api/types'; + +export const orderSearchResults = (args: { + params: LyricSearchQuery; + results: InternetProviderLyricSearchResponse[]; +}) => { + const { params, results } = args; + + const options: Fuse.IFuseOptions = { + fieldNormWeight: 1, + includeScore: true, + keys: [ + { getFn: (song) => song.name, name: 'name', weight: 3 }, + { getFn: (song) => song.artist, name: 'artist' }, + ], + threshold: 1.0, + }; + + const fuse = new Fuse(results, options); + + const searchResults = fuse.search({ + ...(params.artist && { artist: params.artist }), + ...(params.name && { name: params.name }), + }); + + return searchResults.map((result) => ({ + ...result.item, + score: result.score, + })); +}; diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index 40e9451a..b72e6344 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -6,7 +6,8 @@ import { JFAlbumArtistListSort, JFArtistListSort, JFPlaylistListSort, -} from '/@/renderer/api/jellyfin.types'; +} from './jellyfin.types'; +import { jfType } from './jellyfin/jellyfin-types'; import { NDSortOrder, NDOrder, @@ -15,9 +16,8 @@ import { NDPlaylistListSort, NDSongListSort, NDUserListSort, -} from '/@/renderer/api/navidrome.types'; -import { ndType } from '/@/renderer/api/navidrome/navidrome-types'; -import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types'; +} from './navidrome.types'; +import { ndType } from './navidrome/navidrome-types'; export enum LibraryItem { ALBUM = 'album', @@ -1031,6 +1031,7 @@ export type LyricsResponse = SynchronizedLyricsArray | string; export type InternetProviderLyricResponse = { artist: string; + id: string; lyrics: string; name: string; source: LyricSource; @@ -1040,6 +1041,7 @@ export type InternetProviderLyricSearchResponse = { artist: string; id: string; name: string; + score?: number; source: LyricSource; }; diff --git a/src/renderer/features/lyrics/components/lyrics-search-form.tsx b/src/renderer/features/lyrics/components/lyrics-search-form.tsx index 4803118f..c56cf19c 100644 --- a/src/renderer/features/lyrics/components/lyrics-search-form.tsx +++ b/src/renderer/features/lyrics/components/lyrics-search-form.tsx @@ -3,6 +3,7 @@ import { Divider, Group, Stack } from '@mantine/core'; import { useForm } from '@mantine/form'; import { useDebouncedValue } from '@mantine/hooks'; import { openModal } from '@mantine/modals'; +import orderBy from 'lodash/orderBy'; import styled from 'styled-components'; import { InternetProviderLyricSearchResponse, @@ -10,7 +11,7 @@ import { LyricsOverride, } from '../../../api/types'; import { useLyricSearch } from '../queries/lyric-search-query'; -import { Badge, ScrollArea, Spinner, Text, TextInput } from '/@/renderer/components'; +import { ScrollArea, Spinner, Text, TextInput } from '/@/renderer/components'; const SearchItem = styled.button` all: unset; @@ -27,12 +28,22 @@ const SearchItem = styled.button` `; interface SearchResultProps { - artist?: string; - name?: string; + data: InternetProviderLyricSearchResponse; onClick?: () => void; - source?: string; } -const SearchResult = ({ name, artist, source, onClick }: SearchResultProps) => { +const SearchResult = ({ data, onClick }: SearchResultProps) => { + const { artist, name, source, score, id } = data; + + const percentageScore = useMemo(() => { + if (!score) return 0; + return ((1 - score) * 100).toFixed(2); + }, [score]); + + const cleanId = + source === LyricSource.GENIUS + ? String(id).replace(/^((http[s]?|ftp):\/)?\/?([^:/\s]+)/g, '') + : id; + return ( { {name} {artist} + + + {[source, cleanId].join(' — ')} + + - {source} + {percentageScore}% ); @@ -86,21 +108,21 @@ export const LyricsSearchForm = ({ artist, name, onSearchOverride }: LyricSearch (data[key as keyof typeof data] || []).forEach((result) => results.push(result)); }); - return results; + const scoredResults = orderBy(results, ['score'], ['asc']); + + return scoredResults; }, [data]); return ( - +
@@ -112,15 +134,16 @@ export const LyricsSearchForm = ({ artist, name, onSearchOverride }: LyricSearch ) : ( {searchResults.map((result) => ( { onSearchOverride?.({ artist: result.artist, @@ -149,6 +172,6 @@ export const openLyricSearchModal = ({ artist, name, onSearchOverride }: LyricSe /> ), size: 'lg', - title: 'Search for lyrics', + title: 'Lyrics Search', }); };