Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<track_matcher::tracklist::Separator>,
) -> Result<Vec<track_matcher::MatchInput>, 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<MatchInputDto>,
stores: Vec<track_matcher::store_links::Store>,
) -> Vec<track_matcher::store_links::TrackLinks> {
let pairs: Vec<(String, Option<String>)> =
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<Vec<String>, String> {
track_matcher::csv_input::parse_headers(&content).map_err(|e| e.to_string())
Expand Down Expand Up @@ -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,
Expand Down
185 changes: 185 additions & 0 deletions apps/desktop/src/components/TrackMatcherView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
matchTracks,
parseCsvForMatcher,
parseCsvHeadersForMatcher,
parseTracklistForMatcher,
storeLinksForTracks,
} from "../ipc";
import { WithProviders } from "../test-utils/providers";

Expand All @@ -15,6 +17,8 @@ vi.mock("../ipc", () => ({
createPlaylistFromTracks: vi.fn(),
parseCsvForMatcher: vi.fn(),
parseCsvHeadersForMatcher: vi.fn(),
parseTracklistForMatcher: vi.fn(),
storeLinksForTracks: vi.fn(),
}));

beforeEach(() => {
Expand Down Expand Up @@ -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();
});
});
Loading
Loading