From 357824fb1a4711ad35cefa3640babd84f0dcbfd5 Mon Sep 17 00:00:00 2001 From: JannisMCMXCV Date: Wed, 28 Oct 2026 01:28:05 +0100 Subject: [PATCH 1/3] feat: add lyrics search feature to custom text section (@JannisMCMXCV) Co-Authored-By: Claude Sonnet 5.5 --- .../__tests__/utils/lyrics-filter.spec.ts | 254 +++++++++ .../__tests__/utils/lyrics-provider.spec.ts | 356 ++++++++++++ frontend/src/ts/commandline/lists.ts | 9 + .../components/modals/LyricsSearchModal.tsx | 531 ++++++++++++++++++ frontend/src/ts/components/modals/Modals.tsx | 2 + .../ts/components/pages/test/TestConfig.tsx | 6 + frontend/src/ts/states/modals.ts | 1 + frontend/src/ts/test/lyrics-settings.ts | 28 + frontend/src/ts/utils/lyrics-filter.ts | 196 +++++++ frontend/src/ts/utils/lyrics-provider.ts | 343 +++++++++++ 10 files changed, 1726 insertions(+) create mode 100644 frontend/__tests__/utils/lyrics-filter.spec.ts create mode 100644 frontend/__tests__/utils/lyrics-provider.spec.ts create mode 100644 frontend/src/ts/components/modals/LyricsSearchModal.tsx create mode 100644 frontend/src/ts/test/lyrics-settings.ts create mode 100644 frontend/src/ts/utils/lyrics-filter.ts create mode 100644 frontend/src/ts/utils/lyrics-provider.ts diff --git a/frontend/__tests__/utils/lyrics-filter.spec.ts b/frontend/__tests__/utils/lyrics-filter.spec.ts new file mode 100644 index 000000000000..ccbf7f60e0ac --- /dev/null +++ b/frontend/__tests__/utils/lyrics-filter.spec.ts @@ -0,0 +1,254 @@ +import { describe, it, expect } from "vitest"; + +import { + defaultLyricsFilterOptions, + filterLyrics, + findFoldableChars, + getFoldReplacement, + lyricsToWords, + type LyricsFilterOptions, +} from "../../src/ts/utils/lyrics-filter"; + +// all filters off, so each test enables only what it checks +const off: LyricsFilterOptions = { + removeSectionHeaders: false, + brackets: "keep", + removeSpecialChars: false, + removePunctuation: false, + normalizeTypography: false, + foldDiacritics: false, + keepChars: "", + lowercase: false, + removeRepeatedLines: false, + lineBreaks: "space", +}; + +describe("lyrics-filter", () => { + it("joins lines with spaces and drops empty lines", () => { + expect(filterLyrics("one two\n\nthree\r\nfour", off)).toBe( + "one two three four", + ); + }); + + it("uses the custom text newline convention for enter mode", () => { + const text = filterLyrics("one two\nthree", { + ...off, + lineBreaks: "enter", + }); + expect(text).toBe("one two\n three"); + expect(text.split(" ")).toEqual(["one", "two\n", "three"]); + }); + + describe("section headers", () => { + it("removes tag-only lines", () => { + expect( + filterLyrics("[Chorus]\nhello\n [Verse 2] \nworld", { + ...off, + removeSectionHeaders: true, + }), + ).toBe("hello world"); + }); + + it("keeps brackets that are part of a lyric line", () => { + expect( + filterLyrics("hello [yeah] world", { + ...off, + removeSectionHeaders: true, + }), + ).toBe("hello [yeah] world"); + }); + }); + + describe("brackets", () => { + it("removes content, including nested and multi-line", () => { + expect( + filterLyrics("hello (ooh (yeah)) world {x} [y]\nfoo (bar\nbaz) end", { + ...off, + brackets: "removeContent", + }), + ).toBe("hello world foo end"); + }); + + it("strips only the bracket characters", () => { + expect( + filterLyrics("hello (ooh) [yeah] {no}", { + ...off, + brackets: "stripChars", + }), + ).toBe("hello ooh yeah no"); + }); + + it("a line that is only a parenthetical disappears", () => { + expect( + filterLyrics("(ooh)\nhello", { ...off, brackets: "removeContent" }), + ).toBe("hello"); + }); + }); + + describe("special characters", () => { + it("removes symbols but keeps letters, digits and basic punctuation", () => { + expect( + filterLyrics("it's #1 * ~ hit, yeah! <3 100% ok?", { + ...off, + removeSpecialChars: true, + }), + ).toBe("it's 1 hit, yeah! 3 100 ok?"); + }); + + it("replaces & and slashes with readable text", () => { + expect( + filterLyrics("rock & roll, black/white", { + ...off, + removeSpecialChars: true, + }), + ).toBe("rock and roll, black white"); + }); + + it("keeps non-latin letters", () => { + expect( + filterLyrics("привет, мир ★ 愛してる", { + ...off, + removeSpecialChars: true, + }), + ).toBe("привет, мир 愛してる"); + }); + }); + + describe("punctuation", () => { + it("removes punctuation, keeps apostrophes and in-word hyphens", () => { + expect( + filterLyrics(`"Don't stop", she said - well-known: yes!`, { + ...off, + removePunctuation: true, + }), + ).toBe("Don't stop she said well-known yes"); + }); + }); + + it("normalizes typography", () => { + expect( + filterLyrics("“Hello” – it’s me…", { ...off, normalizeTypography: true }), + ).toBe(`"Hello" - it's me...`); + }); + + it("folds diacritics", () => { + expect( + filterLyrics("Café Ünïcode Straße Søren", { + ...off, + foldDiacritics: true, + }), + ).toBe("Cafe Unicode Strasse Soren"); + }); + + describe("keepChars", () => { + it("folds cedillas in both cases", () => { + expect( + filterLyrics("Ça va, garçon", { ...off, foldDiacritics: true }), + ).toBe("Ca va, garcon"); + }); + + it("leaves chosen characters alone and folds the rest", () => { + expect( + filterLyrics("Straße über Café Ärger", { + ...off, + foldDiacritics: true, + keepChars: "ßü", + }), + ).toBe("Straße über Cafe Arger"); + }); + + it("keeps both cases of a chosen character", () => { + expect( + filterLyrics("Über über ÜBER", { + ...off, + foldDiacritics: true, + keepChars: "ü", + }), + ).toBe("Über über ÜBER"); + }); + + it("keepChars does nothing while folding is off", () => { + expect(filterLyrics("é", { ...off, keepChars: "" })).toBe("é"); + }); + }); + + describe("findFoldableChars", () => { + it("lists distinct lowercase characters that would be folded", () => { + expect(findFoldableChars("Straße Über café Ç plain ASCII")).toEqual([ + "ç", + "é", + "ß", + "ü", + ]); + }); + + it("finds decomposed accents too", () => { + expect(findFoldableChars("cafe\u0301")).toEqual(["é"]); + }); + + it("returns nothing for plain text", () => { + expect(findFoldableChars("just words")).toEqual([]); + }); + }); + + it("getFoldReplacement", () => { + expect(getFoldReplacement("Ç")).toBe("C"); + expect(getFoldReplacement("ç")).toBe("c"); + expect(getFoldReplacement("ß")).toBe("ss"); + expect(getFoldReplacement("a")).toBe("a"); + }); + + it("does not destroy non-latin combining marks when folding", () => { + expect(filterLyrics("हिन्दी", { ...off, foldDiacritics: true })).toBe( + "हिन्दी", + ); + }); + + it("lowercases", () => { + expect(filterLyrics("Hello WORLD", { ...off, lowercase: true })).toBe( + "hello world", + ); + }); + + describe("repeated lines", () => { + it("collapses only consecutive duplicates, ignoring case", () => { + expect( + filterLyrics("la la\nLa La\nla la\nother\nla la", { + ...off, + removeRepeatedLines: true, + }), + ).toBe("la la other la la"); + }); + + it("compares after cleaning", () => { + expect( + filterLyrics("oh yeah (x2)\noh yeah", { + ...off, + brackets: "removeContent", + removeRepeatedLines: true, + }), + ).toBe("oh yeah"); + }); + }); + + describe("lyricsToWords", () => { + it("returns no empty words", () => { + expect(lyricsToWords(" a b \n c ", off)).toEqual(["a", "b", "c"]); + }); + + it("returns [] for lyrics that are only filtered content", () => { + expect( + lyricsToWords("[Intro]\n(instrumental)", defaultLyricsFilterOptions), + ).toEqual([]); + }); + + it("defaults produce clean typeable text", () => { + expect( + lyricsToWords( + "[Verse 1]\n“I’m here” (yeah)\n★ you & me ★", + defaultLyricsFilterOptions, + ), + ).toEqual(["\"I'm", 'here"', "you", "and", "me"]); + }); + }); +}); diff --git a/frontend/__tests__/utils/lyrics-provider.spec.ts b/frontend/__tests__/utils/lyrics-provider.spec.ts new file mode 100644 index 000000000000..3e755339dc79 --- /dev/null +++ b/frontend/__tests__/utils/lyrics-provider.spec.ts @@ -0,0 +1,356 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + clearTrackInfoCache, + fetchTrackInfo, + RateLimitError, + resetLrclibQueue, + searchTracks, + ServerBusyError, + type LyricsTrack, +} from "../../src/ts/utils/lyrics-provider"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status }); +} + +const track = (over: Partial = {}): LyricsTrack => ({ + id: "1", + title: "Song", + artist: "Artist", + album: "Album", + lyrics: "la la", + ...over, +}); + +beforeEach(() => { + resetLrclibQueue(); + clearTrackInfoCache(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("lyrics-provider", () => { + describe("searchTracks", () => { + it("maps results with lyrics and uses a single request", async () => { + const fetchMock = vi.fn().mockImplementation(async () => + jsonResponse([ + { + id: 7, + trackName: " A ", + artistName: "X", + albumName: "Alb", + plainLyrics: " hello\nworld ", + }, + ]), + ); + vi.stubGlobal("fetch", fetchMock); + + expect(await searchTracks(" a ")).toEqual([ + { + id: "7", + title: "A", + artist: "X", + album: "Alb", + lyrics: "hello\nworld", + }, + ]); + expect(fetchMock).toHaveBeenCalledTimes(1); + const url = new URL(fetchMock.mock.calls[0]?.[0] as string); + expect(url.pathname).toBe("/api/search"); + expect(url.searchParams.get("q")).toBe("a"); + }); + + it("drops instrumentals, songs without lyrics and duplicates", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation(async () => + jsonResponse([ + { id: 1, trackName: "A", artistName: "X", plainLyrics: null }, + { + id: 2, + trackName: "B", + artistName: "X", + instrumental: true, + plainLyrics: "la", + }, + { id: 3, trackName: "C", artistName: "X", plainLyrics: "words" }, + { id: 4, trackName: "c", artistName: "x", plainLyrics: "again" }, + { id: 5, trackName: "D", artistName: null, plainLyrics: "words" }, + ]), + ), + ); + const found = await searchTracks("x"); + expect(found.map((t) => t.id)).toEqual(["3"]); + }); + + it("does not call the network for an empty query", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + expect(await searchTracks(" ")).toEqual([]); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("treats a 404 as no results", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation(async () => jsonResponse({}, 404)), + ); + expect(await searchTracks("a")).toEqual([]); + }); + + it("throws RateLimitError on 429", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation( + async () => + new Response("", { + status: 429, + headers: { "retry-after": "60" }, + }), + ), + ); + await expect(searchTracks("a")).rejects.toBeInstanceOf(RateLimitError); + }); + + describe("rate limit and busy server", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.spyOn(Math, "random").mockReturnValue(0); // no jitter, shortest gap + }); + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + const withHeader = (status: number, retryAfter?: string): Response => + new Response(JSON.stringify({ message: "x" }), { + status, + headers: + retryAfter === undefined ? {} : { "retry-after": retryAfter }, + }); + const hit = (): Response => + jsonResponse([ + { id: 1, trackName: "A", artistName: "X", plainLyrics: "w" }, + ]); + + it("503: waits for Retry-After, then succeeds", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(withHeader(503, "2")) + .mockResolvedValueOnce(hit()); + vi.stubGlobal("fetch", fetchMock); + + const result = searchTracks("a"); + await vi.advanceTimersByTimeAsync(1900); + expect(fetchMock).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(300); + expect(await result).toHaveLength(1); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("503 without a readable header waits one second", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(withHeader(503)) + .mockResolvedValueOnce(hit()); + vi.stubGlobal("fetch", fetchMock); + + const result = searchTracks("a"); + await vi.advanceTimersByTimeAsync(900); + expect(fetchMock).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(200); + await result; + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("adds up to 30% jitter to the wait", async () => { + vi.spyOn(Math, "random").mockReturnValue(1); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(withHeader(503, "10")) + .mockResolvedValueOnce(hit()); + vi.stubGlobal("fetch", fetchMock); + + const result = searchTracks("a"); + await vi.advanceTimersByTimeAsync(12_900); + expect(fetchMock).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(200); + await result; + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("503: gives up with ServerBusyError after the retries", async () => { + const fetchMock = vi + .fn() + .mockImplementation(async () => withHeader(503, "1")); + vi.stubGlobal("fetch", fetchMock); + + const result = searchTracks("a"); + const assertion = + expect(result).rejects.toBeInstanceOf(ServerBusyError); + await vi.advanceTimersByTimeAsync(30_000); + await assertion; + expect(fetchMock).toHaveBeenCalledTimes(4); // first try + 3 retries + }); + + it("429: a short Retry-After is waited for and retried", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(withHeader(429, "3")) + .mockResolvedValueOnce(hit()); + vi.stubGlobal("fetch", fetchMock); + + const result = searchTracks("a"); + await vi.advanceTimersByTimeAsync(3500); + expect(await result).toHaveLength(1); + }); + + it("429: a long Retry-After is not waited for, the user is told", async () => { + const fetchMock = vi + .fn() + .mockImplementation(async () => withHeader(429, "60")); + vi.stubGlobal("fetch", fetchMock); + + await expect(searchTracks("a")).rejects.toMatchObject({ + name: "RateLimitError", + retryAfterMs: 60_000, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("sends nothing while a Retry-After pause is running, not even other searches", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(withHeader(429, "60")) + .mockImplementation(async () => hit()); + vi.stubGlobal("fetch", fetchMock); + + await expect(searchTracks("a")).rejects.toBeInstanceOf(RateLimitError); + const next = searchTracks("b"); + await vi.advanceTimersByTimeAsync(59_000); + expect(fetchMock).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(2000); + await next; + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("sends requests one at a time with a 200-500 ms pause", async () => { + let running = 0; + let maxRunning = 0; + const times: number[] = []; + const fetchMock = vi.fn().mockImplementation(async () => { + running++; + maxRunning = Math.max(maxRunning, running); + times.push(Date.now()); + await new Promise((resolve) => setTimeout(resolve, 100)); + running--; + return hit(); + }); + vi.stubGlobal("fetch", fetchMock); + + const all = Promise.all([ + searchTracks("a"), + searchTracks("b"), + searchTracks("c"), + ]); + await vi.advanceTimersByTimeAsync(2000); + await all; + expect(maxRunning).toBe(1); + expect(times).toHaveLength(3); + // finished after 100 ms, then the shortest pause (Math.random = 0) + expect((times[1] ?? 0) - (times[0] ?? 0)).toBe(300); + expect((times[2] ?? 0) - (times[1] ?? 0)).toBe(300); + }); + + it("a search that was replaced while waiting is never sent", async () => { + const fetchMock = vi.fn().mockImplementation(async () => hit()); + vi.stubGlobal("fetch", fetchMock); + + await searchTracks("first"); // makes the next one wait for the pause + const controller = new AbortController(); + const second = searchTracks("second", controller.signal); + const assertion = expect(second).rejects.toThrow("stop"); + await vi.advanceTimersByTimeAsync(50); + controller.abort(new Error("stop")); + await assertion; + await vi.advanceTimersByTimeAsync(1000); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + }); + + it("throws on other http errors", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation(async () => jsonResponse({}, 500)), + ); + await expect(searchTracks("a")).rejects.toThrow("500"); + }); + }); + + describe("fetchTrackInfo", () => { + const itunes = { + results: [ + { trackName: "Other", artistName: "Someone" }, + { + trackName: "Song (Remastered)", + artistName: "Artist & Friends", + collectionName: "Greatest", + primaryGenreName: "Rock", + releaseDate: "1999-05-04T07:00:00Z", + artworkUrl100: "http://img", + }, + ], + }; + + it("picks the matching iTunes song", async () => { + const fetchMock = vi + .fn() + .mockImplementation(async () => jsonResponse(itunes)); + vi.stubGlobal("fetch", fetchMock); + + expect(await fetchTrackInfo(track())).toEqual({ + album: "Greatest", + genre: "Rock", + year: 1999, + artworkUrl: "http://img", + }); + const url = new URL(fetchMock.mock.calls[0]?.[0] as string); + expect(url.searchParams.get("term")).toBe("Song Artist"); + }); + + it("returns null if nothing matches", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockImplementation(async () => + jsonResponse({ results: itunes.results.slice(0, 1) }), + ), + ); + expect(await fetchTrackInfo(track())).toBeNull(); + }); + + it("asks iTunes only once per song", async () => { + const fetchMock = vi + .fn() + .mockImplementation(async () => jsonResponse(itunes)); + vi.stubGlobal("fetch", fetchMock); + await fetchTrackInfo(track()); + await fetchTrackInfo(track()); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("does not cache errors", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({}, 500)) + .mockResolvedValueOnce(jsonResponse(itunes)); + vi.stubGlobal("fetch", fetchMock); + await expect(fetchTrackInfo(track())).rejects.toThrow("500"); + expect((await fetchTrackInfo(track()))?.genre).toBe("Rock"); + }); + }); +}); diff --git a/frontend/src/ts/commandline/lists.ts b/frontend/src/ts/commandline/lists.ts index 2ed23b7eaa6a..dcaab53be730 100644 --- a/frontend/src/ts/commandline/lists.ts +++ b/frontend/src/ts/commandline/lists.ts @@ -73,6 +73,15 @@ export const commands: CommandsSubgroup = { }, shouldFocusTestUI: false, }, + { + id: "viewLyricsSearchPopup", + display: "Search for lyrics", + icon: "fa-music", + exec: (): void => { + showModal("LyricsSearch"); + }, + shouldFocusTestUI: false, + }, ...QuoteFavoriteCommands, ...BailOutCommands, { diff --git a/frontend/src/ts/components/modals/LyricsSearchModal.tsx b/frontend/src/ts/components/modals/LyricsSearchModal.tsx new file mode 100644 index 000000000000..f5a02691e6cd --- /dev/null +++ b/frontend/src/ts/components/modals/LyricsSearchModal.tsx @@ -0,0 +1,531 @@ +import { + batch, + createMemo, + createSignal, + For, + JSXElement, + onCleanup, + Show, + untrack, +} from "solid-js"; + +import { setConfig } from "../../config/setters"; +import { Config } from "../../config/store"; +import { hideModalAndClearChain } from "../../states/modals"; +import { + showErrorNotification, + showNoticeNotification, +} from "../../states/notifications"; +import { getLoadedChallenge, setLoadedChallenge } from "../../states/test"; +import * as CustomText from "../../test/custom-text"; +import { + getLyricsFilterOptions, + setLyricsFilterOptions, +} from "../../test/lyrics-settings"; +import * as PractiseWords from "../../test/practise-words"; +import { restartTest } from "../../test/test-logic"; +import { cn } from "../../utils/cn"; +import { + defaultLyricsFilterOptions, + filterLyrics, + findFoldableChars, + getFoldReplacement, + LyricsFilterOptions, + lyricsToWords, +} from "../../utils/lyrics-filter"; +import { + fetchTrackInfo, + LyricsTrack, + RateLimitError, + searchTracks, + ServerBusyError, + TrackInfo, +} from "../../utils/lyrics-provider"; +import { splitIntoCharacters } from "../../utils/strings"; +import { AnimatedModal } from "../common/AnimatedModal"; +import { Button } from "../common/Button"; + +const inputClass = cn( + "w-full rounded border-none bg-sub-alt p-[0.5em] text-em-base leading-[1.25em] caret-main outline-none", + "focus-visible:shadow-[0_0_0_0.1rem_var(--bg-color),0_0_0_0.2rem_var(--text-color)]", +); + +function lrclibProblemText(error: unknown): string { + return error instanceof RateLimitError + ? "LRCLIB is limiting requests right now. Wait a moment and try again." + : "LRCLIB is busy right now. This usually passes, try again in a moment."; +} + +function Toggle(props: { + label: string; + hint?: string; + checked: boolean; + onChange: (checked: boolean) => void; +}): JSXElement { + return ( + + ); +} + +function Choice(props: { + label: string; + value: T; + options: { value: T; text: string }[]; + onChange: (value: T) => void; +}): JSXElement { + return ( +
+
{props.label}
+
+ + {(option) => ( +
+
+ ); +} + +export function LyricsSearchModal(): JSXElement { + const [searchText, setSearchText] = createSignal(""); + const [tracks, setTracks] = createSignal([]); + const [hasSearched, setHasSearched] = createSignal(false); + const [searching, setSearching] = createSignal(false); + + const [selected, setSelected] = createSignal(null); + // details from iTunes; undefined = loading, null = not found + const [info, setInfo] = createSignal(undefined); + // a problem with LRCLIB, shown in the list instead of as a popup + const [problem, setProblem] = createSignal< + { text: string; retry: () => void } | undefined + >(undefined); + + const [options, setOptions] = createSignal( + getLyricsFilterOptions(), + ); + + let searchAbort: AbortController | undefined; + let infoAbort: AbortController | undefined; + let lastQuery = ""; + + onCleanup(() => { + searchAbort?.abort(); + infoAbort?.abort(); + }); + + const updateOptions = (patch: Partial): void => { + const next = { ...options(), ...patch }; + setOptions(next); + setLyricsFilterOptions(next); + }; + + // ---- searching ---- + + /** @returns the found songs, `undefined` if the search was replaced or failed */ + const runSearch = async ( + query: string, + ): Promise => { + if (query === lastQuery) return untrack(tracks); + lastQuery = query; + + searchAbort?.abort(); + if (query === "") { + batch(() => { + setTracks([]); + setHasSearched(false); + setSearching(false); + }); + return []; + } + + const abort = new AbortController(); + searchAbort = abort; + batch(() => { + setSearching(true); + setProblem(undefined); + }); + try { + const result = await searchTracks(query, abort.signal); + if (abort.signal.aborted) return undefined; + batch(() => { + setTracks(result); + setHasSearched(true); + }); + return result; + } catch (e) { + if (abort.signal.aborted) return undefined; + lastQuery = ""; + if (e instanceof RateLimitError || e instanceof ServerBusyError) { + setProblem({ + text: lrclibProblemText(e), + retry: () => void runSearch(query), + }); + } else { + showErrorNotification("Failed to search for songs", { error: e }); + } + return undefined; + } finally { + if (searchAbort === abort) setSearching(false); + } + }; + + // a search is only sent on enter, typing alone sends nothing to LRCLIB + const submitSearch = (): void => { + void runSearch(searchText().trim()); + }; + + // ---- song details ---- + + const selectTrack = (track: LyricsTrack): void => { + infoAbort?.abort(); + const abort = new AbortController(); + infoAbort = abort; + + batch(() => { + setSelected(track); + setInfo(undefined); + }); + fetchTrackInfo(track, abort.signal).then( + (result) => { + if (!abort.signal.aborted) setInfo(result); + }, + () => { + // details are optional, the lyrics are what matters + if (!abort.signal.aborted) setInfo(null); + }, + ); + }; + + const backToResults = (): void => { + infoAbort?.abort(); + setSelected(null); + setInfo(undefined); + }; + + // ---- typing text ---- + + const lyrics = (): string | null => selected()?.lyrics ?? null; + + const preview = createMemo(() => { + const raw = lyrics(); + return raw === null ? "" : filterLyrics(raw, options()); + }); + + const wordCount = createMemo(() => { + const raw = lyrics(); + return raw === null ? 0 : lyricsToWords(raw, options()).length; + }); + + const foldableChars = createMemo(() => { + const raw = lyrics(); + return raw === null ? [] : findFoldableChars(raw); + }); + + const isKept = (char: string): boolean => + options().keepChars.toLowerCase().includes(char); + + const toggleKept = (char: string): void => { + const current = options().keepChars; + updateOptions({ + keepChars: isKept(char) + ? splitIntoCharacters(current) + .filter((c) => c.toLowerCase() !== char) + .join("") + : current + char, + }); + }; + + const startTest = (): void => { + const raw = lyrics(); + if (raw === null) return; + const words = lyricsToWords(raw, options()); + if (words.length === 0) { + showNoticeNotification("Nothing left to type with the current filters"); + return; + } + + CustomText.setMode("repeat"); + CustomText.setPipeDelimiter(false); + CustomText.setText(words); + CustomText.setLimitMode("word"); + CustomText.setLimitValue(words.length); + + if (getLoadedChallenge() !== null) { + showNoticeNotification("Challenge cleared"); + setLoadedChallenge(null); + } + if (Config.mode !== "custom") { + setConfig("mode", "custom"); + } + PractiseWords.resetBefore(); + void restartTest(); + hideModalAndClearChain("LyricsSearch"); + }; + + const handleBeforeShow = (isChained: boolean): void => { + if (isChained) return; + searchAbort?.abort(); + backToResults(); + lastQuery = ""; + setSearchText(""); + setTracks([]); + setHasSearched(false); + setSearching(false); + setProblem(undefined); + setOptions(getLyricsFilterOptions()); + }; + + const emptyMessage = (): string => { + if (searching()) return "Searching..."; + if (!hasSearched()) { + return "Search for a song, artist or album and press enter."; + } + return "No songs with lyrics found"; + }; + + const infoLine = (): string => { + const track = selected(); + const details = info(); + if (track === null) return ""; + return [ + details?.album ?? (track.album === "" ? undefined : track.album), + details?.genre, + details?.year?.toString(), + ] + .filter((v) => v !== undefined && v !== "") + .join(" · "); + }; + + return ( + +
+
Lyrics search
+
+ +
+
+ + + setSearchText(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key !== "Enter" || e.isComposing) return; + e.preventDefault(); + submitSearch(); + }} + /> +
+ + {(p) => ( +
+ {p().text} +
+ )} +
+ 0} + fallback={ +
{emptyMessage()}
+ } + > + + {(track) => ( +
selectTrack(track)} + > +
+ {track.title} + - {track.artist} +
+
{track.album}
+
+ )} +
+
+
+ + } + > + {(track) => ( +
+
+
+
remove
+ updateOptions({ removeSectionHeaders: v })} + /> + updateOptions({ removeSpecialChars: v })} + /> + updateOptions({ removePunctuation: v })} + /> + updateOptions({ removeRepeatedLines: v })} + /> +
+ +
+
convert
+ updateOptions({ normalizeTypography: v })} + /> + updateOptions({ foldDiacritics: v })} + /> + 0} + > +
+
+ click a character to keep it as it is +
+
+ + {(char) => ( +
+
+
+ updateOptions({ lowercase: v })} + /> +
+ + updateOptions({ brackets: v })} + /> + updateOptions({ lineBreaks: v })} + /> +
+ +
+
+
{track().title}
+
{track().artist}
+ +
{infoLine()}
+
+
+ preview · {wordCount()} words +
+
+
+ {preview()} + + Nothing left after filtering. + +
+
+
+ )} +
+ +
+
+ Songs and lyrics: LRCLIB · Details: Apple iTunes Search API. Lyrics + are loaded for this session only and are not stored. +
+ +
+
+ ); +} diff --git a/frontend/src/ts/components/modals/Modals.tsx b/frontend/src/ts/components/modals/Modals.tsx index 9cb4b59c9bb0..d69d1377a7b8 100644 --- a/frontend/src/ts/components/modals/Modals.tsx +++ b/frontend/src/ts/components/modals/Modals.tsx @@ -12,6 +12,7 @@ import { EditResultTagsModal } from "./EditResultTagsModal"; import { ForgotPasswordModal } from "./ForgotPasswordModal"; import { GoogleSignupModal } from "./GoogleSignUpModal"; import { LastSignedOutResultModal } from "./LastSignedOutResultModal"; +import { LyricsSearchModal } from "./LyricsSearchModal"; import { MobileTestConfigModal } from "./MobileTestConfigModal"; import { PbTablesModal } from "./PbTablesModal"; import { PractiseWordsModal } from "./PractiseWordsModal"; @@ -43,6 +44,7 @@ export function Modals(): JSXElement { + diff --git a/frontend/src/ts/components/pages/test/TestConfig.tsx b/frontend/src/ts/components/pages/test/TestConfig.tsx index 380ecd5c5bb1..ee27cecd5bf5 100644 --- a/frontend/src/ts/components/pages/test/TestConfig.tsx +++ b/frontend/src/ts/components/pages/test/TestConfig.tsx @@ -367,6 +367,12 @@ function Mode2Custom(props: ComponentProps<"div">): JSXElement { showModal("CustomText"); }} /> + { + showModal("LyricsSearch"); + }} + /> ); } diff --git a/frontend/src/ts/states/modals.ts b/frontend/src/ts/states/modals.ts index 99a0215e3270..996a1c298fc0 100644 --- a/frontend/src/ts/states/modals.ts +++ b/frontend/src/ts/states/modals.ts @@ -17,6 +17,7 @@ export type ModalId = | "WordFilter" | "CustomGenerator" | "QuoteSearch" + | "LyricsSearch" | "QuoteRate" | "QuoteReport" | "QuoteSubmit" diff --git a/frontend/src/ts/test/lyrics-settings.ts b/frontend/src/ts/test/lyrics-settings.ts new file mode 100644 index 000000000000..6bdeb01dde9d --- /dev/null +++ b/frontend/src/ts/test/lyrics-settings.ts @@ -0,0 +1,28 @@ +import { LocalStorageWithSchema } from "../utils/local-storage-with-schema"; +import { + defaultLyricsFilterOptions, + LyricsFilterOptions, + LyricsFilterOptionsSchema, +} from "../utils/lyrics-filter"; + +const lyricsFilterOptionsLS = new LocalStorageWithSchema({ + key: "lyricsFilterOptions", + schema: LyricsFilterOptionsSchema, + fallback: defaultLyricsFilterOptions, + // options added later fall back to their defaults instead of resetting all + migrate: (oldData) => { + // stored value is an object; an array would be a corrupted entry + const stored = Array.isArray(oldData) ? {} : oldData; + const merged = { ...defaultLyricsFilterOptions, ...stored }; + const parsed = LyricsFilterOptionsSchema.safeParse(merged); + return parsed.success ? parsed.data : { ...defaultLyricsFilterOptions }; + }, +}); + +export function getLyricsFilterOptions(): LyricsFilterOptions { + return lyricsFilterOptionsLS.get(); +} + +export function setLyricsFilterOptions(options: LyricsFilterOptions): void { + lyricsFilterOptionsLS.set(options); +} diff --git a/frontend/src/ts/utils/lyrics-filter.ts b/frontend/src/ts/utils/lyrics-filter.ts new file mode 100644 index 000000000000..0819d7074e0b --- /dev/null +++ b/frontend/src/ts/utils/lyrics-filter.ts @@ -0,0 +1,196 @@ +import { z } from "zod"; + +import { cleanTypographySymbols } from "./strings"; + +export const LyricsFilterOptionsSchema = z.object({ + /** drop lines that are only a section tag, e.g. `[Chorus]` */ + removeSectionHeaders: z.boolean(), + /** what to do with `()`, `[]`, `{}` */ + brackets: z.enum(["keep", "stripChars", "removeContent"]), + /** remove characters like `* # @ ~ / \ | < > _ = + ^ %` */ + removeSpecialChars: z.boolean(), + /** remove `. , ! ? : ; "` and dangling dashes. Keeps `'` and in-word `-` */ + removePunctuation: z.boolean(), + /** curly quotes, long dashes, ellipsis -> ascii */ + normalizeTypography: z.boolean(), + /** é -> e, ß -> ss, ç -> c */ + foldDiacritics: z.boolean(), + /** + * characters that `foldDiacritics` leaves alone, e.g. "ßäöü". Case + * insensitive: "ä" also keeps "Ä". + */ + keepChars: z.string(), + lowercase: z.boolean(), + /** collapse identical consecutive lines (repeated hooks) */ + removeRepeatedLines: z.boolean(), + /** `enter`: user has to press enter at the end of each line */ + lineBreaks: z.enum(["space", "enter"]), +}); + +export type LyricsFilterOptions = z.infer; + +export const defaultLyricsFilterOptions: LyricsFilterOptions = { + removeSectionHeaders: true, + brackets: "removeContent", + removeSpecialChars: true, + removePunctuation: false, + normalizeTypography: true, + foldDiacritics: false, + keepChars: "", + lowercase: false, + removeRepeatedLines: false, + lineBreaks: "space", +}; + +const SECTION_HEADER = /^\s*\[[^\]]*\]\s*$/; +const BRACKET_PAIRS = /\([^()]*\)|\[[^[\]]*\]|\{[^{}]*\}/g; +const BRACKET_CHARS = /[()[\]{}]/g; + +// letters, numbers, combining marks, whitespace and the basic punctuation set +const NOT_ALLOWED_SPECIAL = /[^\p{L}\p{N}\p{M}\s.,!?'":;-]/gu; + +// latin combining marks (U+0300 - U+036F), written as escapes on purpose +const LATIN_COMBINING_MARKS = new RegExp("[\\u0300-\\u036f]", "g"); + +// letters that do not decompose into base letter + combining mark +const FOLD_MAP: Record = { + ß: "ss", + æ: "ae", + Æ: "AE", + œ: "oe", + Œ: "OE", + ø: "o", + Ø: "O", + ł: "l", + Ł: "L", + đ: "d", + Đ: "D", +}; + +function removeBracketContent(text: string): string { + // repeat so nested brackets like "(a (b))" are fully removed + let previous: string; + let current = text; + do { + previous = current; + current = current.replace(BRACKET_PAIRS, ""); + } while (current !== previous); + return current; +} + +/** + * What a single character becomes when accents are folded (é -> e, ç -> c, + * ß -> ss). Returns the character itself if there is nothing to fold. + */ +export function getFoldReplacement(char: string): string { + const mapped = FOLD_MAP[char]; + if (mapped !== undefined) return mapped; + return char + .normalize("NFD") + .replace(LATIN_COMBINING_MARKS, "") + .normalize("NFC"); +} + +function foldDiacritics(text: string, keep: ReadonlySet): string { + let result = ""; + // the text is NFC normalized, so accented letters are single characters + for (const char of text) { + result += keep.has(char.toLowerCase()) ? char : getFoldReplacement(char); + } + return result; +} + +/** + * Distinct (lowercase) characters in the text that accent folding would + * change, sorted. Used to let the user pick which ones to keep. + */ +export function findFoldableChars(text: string): string[] { + const found = new Set(); + for (const char of text.normalize()) { + if (getFoldReplacement(char) !== char) found.add(char.toLowerCase()); + } + return [...found].sort((a, b) => a.localeCompare(b)); +} + +function removePunctuation(text: string): string { + return text + .replace(/[.,!?:;"]/g, "") + .replace(/(^|\s)-+(?=\s|$)/g, "$1") // standalone dashes + .replace(/-{2,}/g, " "); +} + +function cleanLine( + line: string, + options: LyricsFilterOptions, + keep: ReadonlySet, +): string { + let result = line; + + if (options.foldDiacritics) result = foldDiacritics(result, keep); + + if (options.removeSpecialChars) { + result = result + .replace(/&/g, " and ") + .replace(/[/\\|]/g, " ") + .replace(NOT_ALLOWED_SPECIAL, ""); + } + + if (options.removePunctuation) result = removePunctuation(result); + if (options.lowercase) result = result.toLowerCase(); + + return result.replace(/\s+/g, " ").trim(); +} + +/** + * Turns raw lyrics into the text that is typed. Lines are joined with a plain + * space, or with "\n " when `lineBreaks` is `enter` (same convention as the + * custom text modal). + */ +export function filterLyrics( + rawLyrics: string, + options: LyricsFilterOptions, +): string { + let text = rawLyrics.replace(/\r\n?/g, "\n").normalize(); + + if (options.normalizeTypography) text = cleanTypographySymbols(text); + text = text.replace(new RegExp("[\\u200B-\\u200D\\u2060\\uFEFF]", "g"), ""); + + if (options.removeSectionHeaders) { + text = text + .split("\n") + .filter((line) => !SECTION_HEADER.test(line)) + .join("\n"); + } + + if (options.brackets === "removeContent") { + text = removeBracketContent(text); + } else if (options.brackets === "stripChars") { + text = text.replace(BRACKET_CHARS, ""); + } + + const keep = new Set(options.keepChars.normalize().toLowerCase()); + + let lines = text + .split("\n") + .map((line) => cleanLine(line, options, keep)) + .filter((line) => line !== ""); + + if (options.removeRepeatedLines) { + lines = lines.filter( + (line, i) => + i === 0 || line.toLowerCase() !== lines[i - 1]?.toLowerCase(), + ); + } + + return lines.join(options.lineBreaks === "enter" ? "\n " : " "); +} + +/** same split the custom text modal uses (space delimiter) */ +export function lyricsToWords( + rawLyrics: string, + options: LyricsFilterOptions, +): string[] { + return filterLyrics(rawLyrics, options) + .split(" ") + .filter((word) => word !== ""); +} diff --git a/frontend/src/ts/utils/lyrics-provider.ts b/frontend/src/ts/utils/lyrics-provider.ts new file mode 100644 index 000000000000..ea40cd1773f9 --- /dev/null +++ b/frontend/src/ts/utils/lyrics-provider.ts @@ -0,0 +1,343 @@ +import { z } from "zod"; + +/** + * Songs and lyrics come from LRCLIB (community database, no key needed). One + * search request returns the songs including their lyrics, so showing a result + * list needs no further requests. + * + * Extra details (year, genre, ...) come from Apple's public iTunes Search API + * and are only loaded for the one song the user opens. + * + * Lyrics are fetched for the current user and are never stored by Monkeytype. + * Both calls live in this file so a different source (or a backend proxy) can + * replace them without touching the UI. + */ + +const LRCLIB_SEARCH_URL = "https://lrclib.net/api/search"; +const ITUNES_SEARCH_URL = "https://itunes.apple.com/search"; +const REQUEST_TIMEOUT_MS = 10_000; + +// ---- polite LRCLIB client ---- +// +// Follows LRCLIB's guidance: requests go out one after another with a short +// pause, and `Retry-After` (sent with 429 and 503) is honored, plus a little +// random jitter so many clients do not retry at the same moment. + +/** pause between two requests, 200-500 ms */ +const LRCLIB_GAP_MIN_MS = 200; +const LRCLIB_GAP_MAX_MS = 500; +/** used when a 429 / 503 has no (readable) Retry-After header */ +const DEFAULT_BUSY_RETRY_AFTER_MS = 1000; +const DEFAULT_RATE_LIMIT_RETRY_AFTER_MS = 5000; +/** random extra wait of up to 30% of the delay */ +const JITTER_RATIO = 0.3; +const MAX_BUSY_RETRIES = 3; +/** a 429 is rarer and a sign to slow down: one retry, then tell the user */ +const MAX_RATE_LIMIT_RETRIES = 1; +/** a longer wait is not done automatically, the user is told instead */ +const MAX_AUTO_RETRY_WAIT_MS = 10_000; + +export type LyricsTrack = { + id: string; + title: string; + artist: string; + album: string; + lyrics: string; +}; + +export type TrackInfo = { + album: string | undefined; + genre: string | undefined; + year: number | undefined; + artworkUrl: string | undefined; +}; + +export class RateLimitError extends Error { + readonly retryAfterMs: number; + + constructor(retryAfterMs = 0) { + super("LRCLIB is limiting requests, try again in a moment"); + this.name = "RateLimitError"; + this.retryAfterMs = retryAfterMs; + } +} + +export class ServerBusyError extends Error { + readonly retryAfterMs: number; + + constructor(retryAfterMs = 0) { + super("LRCLIB is busy, try again in a moment"); + this.name = "ServerBusyError"; + this.retryAfterMs = retryAfterMs; + } +} + +/** waits, and stops early when the signal is aborted */ +export async function abortableSleep( + ms: number, + signal?: AbortSignal, +): Promise { + await new Promise((resolve, reject) => { + if (signal?.aborted === true) { + reject(signal.reason); + return; + } + const onAbort = (): void => { + clearTimeout(timer); + reject(signal?.reason); + }; + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +/** `Retry-After` in seconds (LRCLIB) */ +function parseRetryAfterMs(header: string | null): number | undefined { + if (header === null) return undefined; + const seconds = Number(header); + return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : undefined; +} + +function withJitter(ms: number): number { + return Math.round(ms * (1 + Math.random() * JITTER_RATIO)); +} + +// all LRCLIB requests wait for their turn in this queue +let queueTail: Promise = Promise.resolve(); +let nextRequestAt = 0; +let blockedUntil = 0; + +/** forgets queued state (for tests) */ +export function resetLrclibQueue(): void { + queueTail = Promise.resolve(); + nextRequestAt = 0; + blockedUntil = 0; +} + +async function inQueue( + task: () => Promise, + signal?: AbortSignal, +): Promise { + const result = queueTail.then(async () => { + signal?.throwIfAborted(); + for (;;) { + const waitMs = Math.max(nextRequestAt, blockedUntil) - Date.now(); + if (waitMs <= 0) break; + await abortableSleep(waitMs, signal); + } + try { + return await task(); + } finally { + const gap = + LRCLIB_GAP_MIN_MS + + Math.random() * (LRCLIB_GAP_MAX_MS - LRCLIB_GAP_MIN_MS); + nextRequestAt = Date.now() + gap; + } + }); + queueTail = result.then( + () => undefined, + () => undefined, + ); + return result; +} + +async function lrclibRequest( + url: string, + signal?: AbortSignal, +): Promise { + return inQueue(async () => { + const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS); + const response = await fetch(url, { + signal: signal ? AbortSignal.any([signal, timeout]) : timeout, + }); + + if (response.status === 429 || response.status === 503) { + const rateLimited = response.status === 429; + const retryAfterMs = + parseRetryAfterMs(response.headers.get("retry-after")) ?? + (rateLimited + ? DEFAULT_RATE_LIMIT_RETRY_AFTER_MS + : DEFAULT_BUSY_RETRY_AFTER_MS); + // nothing is sent until then, not even by other callers + blockedUntil = Math.max( + blockedUntil, + Date.now() + withJitter(retryAfterMs), + ); + throw rateLimited + ? new RateLimitError(retryAfterMs) + : new ServerBusyError(retryAfterMs); + } + if (response.status === 404) return null; + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`); + } + return (await response.json()) as unknown; + }, signal); +} + +const LrclibResponseSchema = z.array( + z.object({ + id: z.number(), + trackName: z.string().nullable().optional(), + artistName: z.string().nullable().optional(), + albumName: z.string().nullable().optional(), + instrumental: z.boolean().nullable().optional(), + plainLyrics: z.string().nullable().optional(), + }), +); + +const ItunesResponseSchema = z.object({ + results: z.array( + z.object({ + trackName: z.string().optional(), + artistName: z.string().optional(), + collectionName: z.string().optional(), + primaryGenreName: z.string().optional(), + releaseDate: z.string().optional(), + artworkUrl100: z.string().optional(), + }), + ), +}); + +async function fetchJson(url: string, signal?: AbortSignal): Promise { + const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS); + const response = await fetch(url, { + signal: signal ? AbortSignal.any([signal, timeout]) : timeout, + }); + if (response.status === 404) return null; + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`); + } + return response.json(); +} + +/** + * @returns lyrics of found songs, best matches first + * @throws RateLimitError if LRCLIB answers with 429 + * @throws ServerBusyError if LRCLIB keeps answering with 503 + */ +export async function searchTracks( + query: string, + signal?: AbortSignal, +): Promise { + const q = query.trim(); + if (q === "") return []; + + const url = `${LRCLIB_SEARCH_URL}?${new URLSearchParams({ q })}`; + let json: unknown; + for (let attempt = 0; ; attempt++) { + try { + json = await lrclibRequest(url, signal); + break; + } catch (e) { + // the wait itself is done by the queue (Retry-After + jitter) + const retryable = + (e instanceof ServerBusyError && attempt < MAX_BUSY_RETRIES) || + (e instanceof RateLimitError && + attempt < MAX_RATE_LIMIT_RETRIES && + e.retryAfterMs <= MAX_AUTO_RETRY_WAIT_MS); + if (!retryable) throw e; + } + } + const parsed = LrclibResponseSchema.safeParse(json ?? []); + if (!parsed.success) return []; + + const seen = new Set(); + const tracks: LyricsTrack[] = []; + for (const r of parsed.data) { + const lyrics = r.plainLyrics?.trim() ?? ""; + const title = r.trackName?.trim() ?? ""; + const artist = r.artistName?.trim() ?? ""; + if (r.instrumental === true || lyrics === "" || title === "") continue; + if (artist === "") continue; + + // the same song is often uploaded several times + const key = `${title}|${artist}`.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + + tracks.push({ + id: String(r.id), + title, + artist, + album: r.albumName?.trim() ?? "", + lyrics, + }); + } + return tracks; +} + +// ---- details from iTunes ---- + +const infoCache = new Map(); + +/** forgets cached details (for tests) */ +export function clearTrackInfoCache(): void { + infoCache.clear(); +} + +function normalize(text: string): string { + return text + .toLowerCase() + .normalize("NFKD") + .replace(/[^\p{L}\p{N}]+/gu, " ") + .trim(); +} + +function parseYear(releaseDate: string | undefined): number | undefined { + if (releaseDate === undefined) return undefined; + const year = new Date(releaseDate).getUTCFullYear(); + return Number.isNaN(year) ? undefined : year; +} + +/** + * Looks the song up on iTunes. iTunes can only search by text, so this takes + * the best title / artist match. + * @returns `null` if iTunes does not know the song + */ +export async function fetchTrackInfo( + track: LyricsTrack, + signal?: AbortSignal, +): Promise { + const cached = infoCache.get(track.id); + if (cached !== undefined) return cached; + + const params = new URLSearchParams({ + term: `${track.title} ${track.artist}`, + media: "music", + entity: "song", + limit: "10", + }); + const json = await fetchJson(`${ITUNES_SEARCH_URL}?${params}`, signal); + const parsed = ItunesResponseSchema.safeParse(json); + + const title = normalize(track.title); + const artist = normalize(track.artist); + const match = parsed.success + ? parsed.data.results.find((r) => { + const t = normalize(r.trackName ?? ""); + const a = normalize(r.artistName ?? ""); + return ( + t !== "" && + a !== "" && + (t === title || t.startsWith(title) || title.startsWith(t)) && + (a.includes(artist) || artist.includes(a)) + ); + }) + : undefined; + + const info: TrackInfo | null = + match === undefined + ? null + : { + album: match.collectionName, + genre: match.primaryGenreName, + year: parseYear(match.releaseDate), + artworkUrl: match.artworkUrl100, + }; + infoCache.set(track.id, info); + return info; +} From e1d6beda948ec10a18794969e24e52e702289c76 Mon Sep 17 00:00:00 2001 From: JannisMCMXCV Date: Thu, 29 Oct 2026 09:23:27 +0100 Subject: [PATCH 2/3] fix: adapt lyrics command to new Command.fa type (@JannisMCMXCV) --- frontend/src/ts/commandline/lists.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/ts/commandline/lists.ts b/frontend/src/ts/commandline/lists.ts index f22a026db0ef..5557c283ca14 100644 --- a/frontend/src/ts/commandline/lists.ts +++ b/frontend/src/ts/commandline/lists.ts @@ -76,7 +76,7 @@ export const commands: CommandsSubgroup = { { id: "viewLyricsSearchPopup", display: "Search for lyrics", - icon: "fa-music", + fa: { icon: "fa-music" }, exec: (): void => { showModal("LyricsSearch"); }, From 7398c2d5bd26c2509b103c9d3b019abda02a8b39 Mon Sep 17 00:00:00 2001 From: JannisMCMXCV Date: Thu, 29 Oct 2026 11:03:35 +0100 Subject: [PATCH 3/3] feat(improvement): add "load lyrics" button to custom text modal (@JannisMCMXCV) Co-Authored-By: Claude Sonnet 5.5 --- .../modals/CustomTextModal.spec.tsx | 84 +++++++++++++++++++ .../ts/components/modals/CustomTextModal.tsx | 8 ++ .../components/modals/LyricsSearchModal.tsx | 42 +++++++++- frontend/src/ts/components/modals/Modals.tsx | 2 - 4 files changed, 130 insertions(+), 6 deletions(-) diff --git a/frontend/__tests__/components/modals/CustomTextModal.spec.tsx b/frontend/__tests__/components/modals/CustomTextModal.spec.tsx index da775ec86f19..8cd7235ebd1a 100644 --- a/frontend/__tests__/components/modals/CustomTextModal.spec.tsx +++ b/frontend/__tests__/components/modals/CustomTextModal.spec.tsx @@ -14,6 +14,22 @@ vi.mock("../../../src/ts/test/test-logic", () => ({ restartTest: vi.fn(), })); +vi.mock("../../../src/ts/utils/lyrics-provider", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("../../../src/ts/utils/lyrics-provider") + >()), + searchTracks: vi.fn(async () => [ + { + id: "1", + title: "Song", + artist: "Artist", + album: "Album", + lyrics: "[Chorus]\nhello world\nfoo bar", + }, + ]), + fetchTrackInfo: vi.fn(async () => null), +})); + // The test state module fetches the input/keymap layout on module load and // the word filter modal fetches the language data, so stub the static files. vi.stubGlobal( @@ -91,6 +107,16 @@ describe("components/modals/CustomTextModal.tsx", () => { fireEvent.click(el); } + /** searches for the mocked song in the lyrics modal and opens it */ + async function openMockedSong(): Promise { + const searchInput = await screen.findByPlaceholderText( + "search by song, artist or album and press enter", + ); + fireEvent.input(searchInput, { target: { value: "song" } }); + fireEvent.keyDown(searchInput, { key: "Enter" }); + fireEvent.click(await screen.findByText("Song")); + } + function submitSaveDialog(): void { const saveDialog = document.getElementById("SaveCustomTextModal"); if (saveDialog === null) { @@ -206,4 +232,62 @@ describe("components/modals/CustomTextModal.tsx", () => { ]); expect(CustomText.getCustomTextIsPipeDelimiter("book", true)).toBe(true); }); + + it("fills the text with lyrics chosen from the lyrics search instead of starting a test", async () => { + const { restartTest } = await import("../../../src/ts/test/test-logic"); + vi.mocked(restartTest).mockClear(); + + showModal("CustomText"); + render(() => ); + + fireEvent.input(screen.getByPlaceholderText(textareaPlaceholder), { + target: { value: "old text" }, + }); + + fireEvent.click(screen.getByText("load lyrics")); + await openMockedSong(); + fireEvent.click(await screen.findByText("use lyrics")); + + // back in the custom text modal, with the cleaned up lyrics in the textarea + await waitFor(() => { + expect(screen.getByPlaceholderText(textareaPlaceholder)).toHaveValue( + "hello world foo bar", + ); + }); + expect(restartTest).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByText("ok")); + + await waitFor(() => { + expect(CustomText.getText()).toStrictEqual([ + "hello", + "world", + "foo", + "bar", + ]); + }); + expect(CustomText.getPipeDelimiter()).toBe(false); + expect(CustomText.getLimitValue()).toBe(4); + }); + + it("starts the test right away when the lyrics search is opened on its own", async () => { + const { restartTest } = await import("../../../src/ts/test/test-logic"); + vi.mocked(restartTest).mockClear(); + + showModal("LyricsSearch"); + render(() => ); + + await openMockedSong(); + fireEvent.click(await screen.findByText("start typing")); + + await waitFor(() => { + expect(CustomText.getText()).toStrictEqual([ + "hello", + "world", + "foo", + "bar", + ]); + }); + expect(restartTest).toHaveBeenCalled(); + }); }); diff --git a/frontend/src/ts/components/modals/CustomTextModal.tsx b/frontend/src/ts/components/modals/CustomTextModal.tsx index 3aad56d5da84..66e1d7e92ba8 100644 --- a/frontend/src/ts/components/modals/CustomTextModal.tsx +++ b/frontend/src/ts/components/modals/CustomTextModal.tsx @@ -29,6 +29,7 @@ import { Separator } from "../common/Separator"; import { SubmitButton } from "../ui/form/SubmitButton"; import { TextareaField } from "../ui/form/TextareaField"; import { CustomGeneratorModal } from "./CustomGeneratorModal"; +import { LyricsSearchModal } from "./LyricsSearchModal"; import { SaveCustomTextModal } from "./SaveCustomTextModal"; import { SavedTextsModal } from "./SavedTextsModal"; import { WordFilterModal } from "./WordFilterModal"; @@ -658,6 +659,12 @@ export function CustomTextModal(): JSXElement { text="open file" onClick={() => fileInputRef.click()} /> +