From 89fe26c558a700f5941ad25f0568fa49242afc1c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 23:21:19 +0000 Subject: [PATCH] feat(track-matcher): tracklist input, separators, and store search links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the Track Matcher row and the honest half of Store Links, per `docs/lexicon/08-streaming.md`. Scoping for the rest of Epic 7 is recorded in `GAPS.md` rather than guessed at. Track Matcher: - `.txt` and `.m3u8` through one reader, since an `.m3u8` is a text file whose non-track lines start with `#`. `#EXTINF` titles are preferred over the path lines beneath them — a path is a location, and matching by path is what Relocate is for. - Selectable separator as the manual specifies rather than a guess: hyphen, en dash, em dash, `Title by Artist` (the one form where the sides swap), none, or custom. Mis-splitting produces confident wrong matches, not obvious failures. - Numbered setlist indices stripped, because `1. Daft Punk` is not an artist and normalisation will not fix it. A digit alone is never an index — `99 Problems` and `1979` are titles — so it needs punctuation after it, or a leading `#`. - `#` is ambiguous between the formats: directive in `.m3u8`, index in a hand-written list. Letters after it mean directive, digits mean index. Both of these came out of failing tests and both now have regression tests. - Playlist creation from matches, wired to the existing command. Store Links / onward search, as generated search URLs across Beatport, Bandcamp, Discogs, Spotify, Tidal, SoundCloud and YouTube. This is the tedious part of both features and it is honest: a search link claims nothing about whether a track exists or what it costs. Price comparison and playlist push are NOT built — both need a registered application and a per-user token per service — and the UI says so rather than implying a comparison that is not happening. Not built, with the reasons kept distinct in GAPS.md: Beatport catalog/cart, Charts, Send To, SoundCloud playback and Track Discovery need credentials only the account owner can obtain; streaming tracks and Transfer Streaming To Local need a verified place to store a streaming reference, which `master.db` does not appear to offer and which no real database is available here to check. Parity: 62 done / 21 partial / 11 missing / 2 blocked / 16 deferred. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011Gn43w2xFL3JRBRkMv3vRo --- apps/desktop/src-tauri/src/lib.rs | 37 ++ .../src/components/TrackMatcherView.test.tsx | 185 ++++++++ .../src/components/TrackMatcherView.tsx | 203 ++++++++- apps/desktop/src/ipc.ts | 31 ++ apps/desktop/src/types.ts | 37 ++ crates/track-matcher/src/lib.rs | 2 + crates/track-matcher/src/store_links.rs | 257 +++++++++++ crates/track-matcher/src/tracklist.rs | 413 ++++++++++++++++++ docs/JOURNAL.md | 54 +++ docs/STATUS.md | 55 +++ docs/lexicon/08-streaming.md | 35 +- docs/lexicon/GAPS.md | 51 +++ docs/lexicon/PARITY.md | 21 +- 13 files changed, 1357 insertions(+), 24 deletions(-) create mode 100644 crates/track-matcher/src/store_links.rs create mode 100644 crates/track-matcher/src/tracklist.rs 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" && (