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/__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 ab788b2bfe4a..5557c283ca14 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", + fa: { icon: "fa-music" }, + exec: (): void => { + showModal("LyricsSearch"); + }, + shouldFocusTestUI: false, + }, ...QuoteFavoriteCommands, ...BailOutCommands, { 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()} /> +