diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index d61a0e9..44e9aac 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2268,6 +2268,41 @@ fn parse_csv_for_matcher( .map_err(|e| e.to_string()) } +/// Read a pasted or uploaded `.txt` / `.m3u8` tracklist. +/// +/// The CSV path stays separate (`parse_csv_for_matcher`) because CSV has +/// columns to map and these formats do not — one entry per line, and a +/// separator the user chooses rather than one we guess at. Per +/// `docs/lexicon/08-streaming.md §Track Matcher`. +#[tauri::command] +fn parse_tracklist_for_matcher( + content: String, + separator: Option, +) -> Result, String> { + Ok(track_matcher::tracklist::parse( + &content, + &separator.unwrap_or_default(), + )) +} + +/// Search URLs for entries the library did not have. +/// +/// The honest half of Lexicon's onward-search and Store Links: constructing the +/// right search URL per store, per track, so a DJ working through fifty +/// unmatched request-list entries opens fifty right-first-time searches instead +/// of typing fifty queries. **Price comparison and playlist push are not built** +/// — both need a registered application and a per-user token for each service. +/// See `docs/lexicon/08-streaming.md` and the Epic 7 note in `GAPS.md`. +#[tauri::command] +fn store_links_for_tracks( + tracks: Vec, + stores: Vec, +) -> Vec { + let pairs: Vec<(String, Option)> = + tracks.into_iter().map(|t| (t.title, t.artist)).collect(); + track_matcher::store_links::links_for(&pairs, &stores) +} + #[tauri::command] fn parse_csv_headers_for_matcher(content: String) -> Result, String> { track_matcher::csv_input::parse_headers(&content).map_err(|e| e.to_string()) @@ -3181,6 +3216,8 @@ pub fn run() { sync_execute_accepted, match_tracks, parse_csv_for_matcher, + parse_tracklist_for_matcher, + store_links_for_tracks, parse_csv_headers_for_matcher, create_playlist_from_tracks, stage_track_delete, diff --git a/apps/desktop/src/components/TrackMatcherView.test.tsx b/apps/desktop/src/components/TrackMatcherView.test.tsx index b8d435d..a45d274 100644 --- a/apps/desktop/src/components/TrackMatcherView.test.tsx +++ b/apps/desktop/src/components/TrackMatcherView.test.tsx @@ -7,6 +7,8 @@ import { matchTracks, parseCsvForMatcher, parseCsvHeadersForMatcher, + parseTracklistForMatcher, + storeLinksForTracks, } from "../ipc"; import { WithProviders } from "../test-utils/providers"; @@ -15,6 +17,8 @@ vi.mock("../ipc", () => ({ createPlaylistFromTracks: vi.fn(), parseCsvForMatcher: vi.fn(), parseCsvHeadersForMatcher: vi.fn(), + parseTracklistForMatcher: vi.fn(), + storeLinksForTracks: vi.fn(), })); beforeEach(() => { @@ -138,4 +142,185 @@ describe("TrackMatcherView", () => { ["t1"], ); }); + + it("passes the chosen separator to parseTracklistForMatcher, which is what turns a raw tracklist into query candidates", async () => { + vi.mocked(parseTracklistForMatcher).mockResolvedValue([ + { title: "Title", artist: "Artist" }, + ]); + vi.mocked(matchTracks).mockResolvedValue([ + { + input_title: "Title", + input_artist: "Artist", + track: { id: "t1", title: "Title", artist: "Artist" }, + score: 1.0, + status: "Exact", + }, + ]); + render_(); + const sourceSelect = screen.getAllByRole("combobox")[0]; + await userEvent.selectOptions(sourceSelect, "txt"); + const separatorSelect = screen.getAllByRole("combobox")[1]; + await userEvent.selectOptions(separatorSelect, "em_dash"); + await userEvent.type( + screen.getByPlaceholderText(/split by the separator above/), + "Artist — Title", + ); + await userEvent.click(screen.getByRole("button", { name: "Match" })); + expect(parseTracklistForMatcher).toHaveBeenCalledWith( + "Artist — Title", + "em_dash", + ); + }); + + it("sends the typed delimiter as a Custom separator, because free-text splitting must not silently reinterpret it as one of the presets", async () => { + vi.mocked(parseTracklistForMatcher).mockResolvedValue([ + { title: "Title", artist: "Artist" }, + ]); + vi.mocked(matchTracks).mockResolvedValue([ + { + input_title: "Title", + input_artist: "Artist", + track: { id: "t1", title: "Title", artist: "Artist" }, + score: 1.0, + status: "Exact", + }, + ]); + render_(); + const sourceSelect = screen.getAllByRole("combobox")[0]; + await userEvent.selectOptions(sourceSelect, "txt"); + const separatorSelect = screen.getAllByRole("combobox")[1]; + await userEvent.selectOptions(separatorSelect, "custom"); + await userEvent.type( + screen.getByPlaceholderText(/Custom separator/), + "::", + ); + await userEvent.type( + screen.getByPlaceholderText(/split by the separator above/), + "Artist::Title", + ); + await userEvent.click(screen.getByRole("button", { name: "Match" })); + expect(parseTracklistForMatcher).toHaveBeenCalledWith("Artist::Title", { + custom: "::", + }); + }); + + it("stages a playlist from only the exact and fuzzy hits, keeping unmatched rows out of a playlist that would otherwise misrepresent what was found", async () => { + vi.mocked(matchTracks).mockResolvedValue([ + { + input_title: "A", + input_artist: null, + track: { id: "t1", title: "A", artist: null }, + score: 1.0, + status: "Exact", + }, + { + input_title: "B", + input_artist: null, + track: { id: "t2", title: "B", artist: null }, + score: 0.8, + status: "Fuzzy", + }, + { + input_title: "C", + input_artist: null, + track: null, + score: 0, + status: "Unmatched", + }, + ]); + vi.mocked(createPlaylistFromTracks).mockResolvedValue("pl-new"); + render_(); + await userEvent.type( + screen.getByPlaceholderText(/Artist - Title/), + "A{enter}B{enter}C", + ); + await userEvent.click(screen.getByRole("button", { name: "Match" })); + await screen.findByText(/2 \/ 3 tracks matched/); + await userEvent.click( + screen.getByRole("button", { name: /Create playlist/ }), + ); + await userEvent.click(await screen.findByRole("button", { name: "OK" })); + expect(createPlaylistFromTracks).toHaveBeenCalledWith( + "/db", + "Imported (paste)", + ["t1", "t2"], + ); + }); + + it("disables Create playlist when nothing matched, so there is nothing for the button to stage", async () => { + vi.mocked(matchTracks).mockResolvedValue([ + { + input_title: "Lone Title", + input_artist: null, + track: null, + score: 0, + status: "Unmatched", + }, + ]); + render_(); + await userEvent.type( + screen.getByPlaceholderText(/Artist - Title/), + "Lone Title", + ); + await userEvent.click(screen.getByRole("button", { name: "Match" })); + await screen.findByText(/0 \/ 1 tracks matched/); + expect( + screen.getByRole("button", { name: /Create playlist/ }), + ).toBeDisabled(); + }); + + it("opens store search links in a new tab without granting the target page a window.opener reference back into the app", async () => { + vi.mocked(matchTracks).mockResolvedValue([ + { + input_title: "Ghost Track", + input_artist: "Nobody", + track: null, + score: 0, + status: "Unmatched", + }, + ]); + vi.mocked(storeLinksForTracks).mockResolvedValue([ + { + title: "Ghost Track", + artist: "Nobody", + links: [["Beatport", "https://www.beatport.com/search?q=Ghost+Track"]], + }, + ]); + render_(); + await userEvent.type( + screen.getByPlaceholderText(/Artist - Title/), + "Nobody - Ghost Track", + ); + await userEvent.click(screen.getByRole("button", { name: "Match" })); + await screen.findByText(/0 \/ 1 tracks matched/); + await userEvent.click( + screen.getByRole("button", { name: "Find store links" }), + ); + const link = await screen.findByRole("link", { name: "Beatport" }); + expect(link).toHaveAttribute("target", "_blank"); + expect(link.getAttribute("rel")).toContain("noreferrer"); + expect(storeLinksForTracks).toHaveBeenCalledWith( + [{ title: "Ghost Track", artist: "Nobody" }], + expect.arrayContaining(["beatport", "spotify"]), + ); + }); + + it("tells the user these are search links only, since decks does not compare prices or push playlists without a registered per-user token", async () => { + vi.mocked(matchTracks).mockResolvedValue([ + { + input_title: "Ghost Track", + input_artist: null, + track: null, + score: 0, + status: "Unmatched", + }, + ]); + render_(); + await userEvent.type( + screen.getByPlaceholderText(/Artist - Title/), + "Ghost Track", + ); + await userEvent.click(screen.getByRole("button", { name: "Match" })); + expect(await screen.findByText(/Search links only/)).toBeInTheDocument(); + }); }); diff --git a/apps/desktop/src/components/TrackMatcherView.tsx b/apps/desktop/src/components/TrackMatcherView.tsx index 6020457..ce24aad 100644 --- a/apps/desktop/src/components/TrackMatcherView.tsx +++ b/apps/desktop/src/components/TrackMatcherView.tsx @@ -4,9 +4,12 @@ import { matchTracks, parseCsvForMatcher, parseCsvHeadersForMatcher, + parseTracklistForMatcher, + storeLinksForTracks, type MatchInput, type MatchResult, } from "../ipc"; +import type { Separator, Store } from "../types"; import { useDialog } from "../hooks/useDialog"; import { readTextFile } from "../lib/read-file"; import { useToast } from "./Toast"; @@ -18,12 +21,38 @@ interface Props { type Source = "paste" | "txt" | "csv"; +/** + * What the separator ` {source === "txt" && ( - + )} {source === "csv" && ( @@ -204,7 +286,7 @@ export function TrackMatcherView({ libraryPath, onGoToSync }: Props) { - {source !== "csv" && ( + {source === "paste" && (