diff --git a/lexicons/dev/mokkenstorm/crate/discogs/resolveBarcode.json b/lexicons/dev/mokkenstorm/crate/discogs/resolveBarcode.json index 37de6db..87f298f 100644 --- a/lexicons/dev/mokkenstorm/crate/discogs/resolveBarcode.json +++ b/lexicons/dev/mokkenstorm/crate/discogs/resolveBarcode.json @@ -23,10 +23,34 @@ "type": "ref", "ref": "dev.mokkenstorm.crate.discogs.searchReleases#discogsRelease", "description": "The best match, or null when the barcode resolves to nothing." + }, + "suggestions": { + "type": "array", + "items": { + "type": "ref", + "ref": "#suggestion" + }, + "description": "Best-effort 'did you mean' candidates when the barcode itself resolved to nothing, ranked by descending confidence. Absent when there are none." } } } } + }, + "suggestion": { + "type": "object", + "description": "A candidate release for a barcode that did not resolve directly, with a 0-100 confidence score.", + "required": ["release", "confidence"], + "properties": { + "release": { + "type": "ref", + "ref": "dev.mokkenstorm.crate.discogs.searchReleases#discogsRelease" + }, + "confidence": { + "type": "integer", + "minimum": 0, + "maximum": 100 + } + } } } } diff --git a/server/src/at_record_server/discogs_client.gleam b/server/src/at_record_server/discogs_client.gleam index 24061a2..fa79f4c 100644 --- a/server/src/at_record_server/discogs_client.gleam +++ b/server/src/at_record_server/discogs_client.gleam @@ -160,6 +160,36 @@ pub fn search_by_barcode( } } +/// Retry candidates for a barcode that missed an exact lookup: the de-spaced +/// form, plus the UPC-A(12) <-> EAN-13(13-with-leading-zero) sibling that +/// differs only by a leading check zero. The original input is never re-tried +/// (the caller already did) and duplicates collapse, keeping order. Exported +/// so the miss-recovery policy is unit-testable on its own. +pub fn barcode_variants(barcode: String) -> List(String) { + let despaced = string.replace(barcode, " ", "") + [despaced, ..gtin_siblings(despaced)] + |> list.filter(fn(v) { v != "" && v != barcode }) + |> dedupe +} + +fn gtin_siblings(barcode: String) -> List(String) { + case string.length(barcode), string.starts_with(barcode, "0") { + 12, _ -> ["0" <> barcode] + 13, True -> [string.drop_start(barcode, 1)] + _, _ -> [] + } +} + +fn dedupe(items: List(String)) -> List(String) { + list.fold(items, [], fn(acc, item) { + case list.contains(acc, item) { + True -> acc + False -> [item, ..acc] + } + }) + |> list.reverse +} + fn map_error(r: Result(a, b), f: fn(b) -> c) -> Result(a, c) { case r { Ok(v) -> Ok(v) diff --git a/server/src/at_record_server/handlers/discogs.gleam b/server/src/at_record_server/handlers/discogs.gleam index f9595bd..cc1f3ed 100644 --- a/server/src/at_record_server/handlers/discogs.gleam +++ b/server/src/at_record_server/handlers/discogs.gleam @@ -8,7 +8,9 @@ import at_record_server/discogs_client import at_record_server/discogs_creds.{type Creds} import at_record_server/discogs_oauth.{TokenPair} import at_record_server/event_log +import at_record_server/musicbrainz_client import at_record_server/oauth/sessions.{type OauthSession} +import at_record_server/release_similarity import atproto/xrpc.{type Client} import gleam/dynamic/decode import gleam/int @@ -105,8 +107,10 @@ pub fn scan_seen(req: Request, ctx: Context) -> Response { } } -/// One barcode scan: just the best Discogs match, if any. Ownership is -/// decided client-side against the seen-ids set fetched once at scan start. +/// One barcode scan: the best Discogs match, if any. Ownership is decided +/// client-side against the seen-ids set fetched once at scan start. On a +/// clean miss (Discogs found nothing), a recovery flow tries barcode variants +/// and, failing that, MusicBrainz-seeded "did you mean" suggestions. pub fn scan(req: Request, ctx: Context) -> Response { use _id, _session <- require_session(req, ctx) case list.key_find(wisp.get_query(req), "barcode") { @@ -121,21 +125,125 @@ pub fn scan(req: Request, ctx: Context) -> Response { { Error(_) -> error_json(502, "discogs lookup failed") Ok(found) -> - json.object([ - #( - "result", - json.nullable( + case found.releases { + [_, ..] -> + scan_response( list.first(found.releases) |> option.from_result, - discogs_client.encode_release, - ), - ), - ]) - |> json.to_string - |> wisp.json_response(200) + [], + ) + [] -> recover_no_match(ctx, barcode) + } + } + } +} + +/// A scan result: the plain `{result}` shape today's hit/miss uses, plus an +/// optional `suggestions` array (omitted entirely when empty, per lexicon). +fn scan_response( + result: Option(discogs_client.Release), + suggestions: List(#(discogs_client.Release, Int)), +) -> Response { + let base = [ + #("result", json.nullable(result, discogs_client.encode_release)), + ] + let fields = case suggestions { + [] -> base + _ -> [#("suggestions", json.array(suggestions, encode_suggestion)), ..base] + } + json.object(fields) |> json.to_string |> wisp.json_response(200) +} + +fn encode_suggestion(entry: #(discogs_client.Release, Int)) -> json.Json { + json.object([ + #("release", discogs_client.encode_release(entry.0)), + #("confidence", json.int(entry.1)), + ]) +} + +/// Discogs found nothing for the exact barcode: try close variants first +/// (a variant hit is returned as a normal `result`), then fall back to a +/// MusicBrainz-seeded suggestion search, then to the plain no-match shape. +fn recover_no_match(ctx: Context, barcode: String) -> Response { + case try_variants(ctx, discogs_client.barcode_variants(barcode)) { + Some(releases) -> + scan_response(list.first(releases) |> option.from_result, []) + None -> suggest_from_musicbrainz(ctx, barcode) + } +} + +fn try_variants( + ctx: Context, + variants: List(String), +) -> Option(List(discogs_client.Release)) { + case variants { + [] -> None + [barcode, ..rest] -> + case + discogs_client.search_by_barcode( + ctx.discogs.send, + ctx.discogs.auth, + barcode, + ) + { + Ok(found) -> + case found.releases { + [_, ..] -> Some(found.releases) + [] -> try_variants(ctx, rest) + } + Error(_) -> try_variants(ctx, rest) } } } +fn suggest_from_musicbrainz(ctx: Context, barcode: String) -> Response { + case musicbrainz_client.barcode_hint(ctx.atproto.client, barcode) { + None -> scan_response(None, []) + Some(hint) -> suggestions_from_hint(ctx, hint) + } +} + +fn suggestions_from_hint( + ctx: Context, + hint: musicbrainz_client.ReleaseHint, +) -> Response { + case + discogs_client.search( + ctx.discogs.send, + ctx.discogs.auth, + hint.title <> " " <> hint.artist, + None, + 1, + ) + { + Error(_) -> scan_response(None, []) + Ok(found) -> + case rank_suggestions(hint, found.releases) { + [] -> scan_response(None, []) + ranked -> scan_response(None, ranked) + } + } +} + +fn rank_suggestions( + hint: musicbrainz_client.ReleaseHint, + releases: List(discogs_client.Release), +) -> List(#(discogs_client.Release, Int)) { + releases + |> list.map(fn(release) { + #( + release, + release_similarity.score( + hint.title, + hint.artist, + release.title, + release.artist, + ), + ) + }) + |> list.sort(fn(a, b) { int.compare(b.1, a.1) }) + |> list.take(3) +} + // --- account connection (three-legged OAuth 1.0a) --- pub fn connect(req: Request, ctx: Context) -> Response { diff --git a/server/src/at_record_server/musicbrainz_client.gleam b/server/src/at_record_server/musicbrainz_client.gleam index 4e4eb2a..e62cfbb 100644 --- a/server/src/at_record_server/musicbrainz_client.gleam +++ b/server/src/at_record_server/musicbrainz_client.gleam @@ -64,6 +64,85 @@ fn maybe_sleep(first: Bool) -> Nil { } } +/// What the scan "did you mean" bridge needs off a MusicBrainz release: a +/// title and a flattened primary-artist string to seed a Discogs text search. +pub type ReleaseHint { + ReleaseHint(title: String, artist: String) +} + +/// Best-effort title/artist for a scanned barcode Discogs could not resolve, +/// via a single MusicBrainz barcode search (one request, no sleep: unlike the +/// mint path this never chains multiple attempts). Returns None when the +/// barcode is not a plausible candidate (no request made), on any HTTP +/// failure, or when no exact (score 100) release comes back. +pub fn barcode_hint(client: Client, barcode: String) -> Option(ReleaseHint) { + case usable_barcodes([barcode]) { + [candidate, ..] -> fetch_hint(client, candidate) + [] -> None + } +} + +fn fetch_hint(client: Client, barcode: String) -> Option(ReleaseHint) { + case xrpc.send_text(client, barcode_request(barcode)) { + Ok(resp) -> parse_barcode_hint(resp.body) + Error(e) -> { + wisp.log_warning( + "musicbrainz hint lookup failed for barcode:" <> barcode <> ": " <> e, + ) + None + } + } +} + +/// The title/artist of the first exact-match (`score` 100) release in a +/// barcode search response, pure so it can be tested against a fixture +/// without a network call. Mirrors `parse_barcode_response`: fuzzy matches +/// scored below 100 are treated as a miss. +pub fn parse_barcode_hint(body: String) -> Option(ReleaseHint) { + let decoder = { + use releases <- decode.field( + "releases", + decode.list(hint_release_decoder()), + ) + decode.success(releases) + } + json.parse(body, decoder) + |> result.unwrap([]) + |> list.filter(fn(release) { release.score == 100 }) + |> list.first + |> result.map(fn(release) { + ReleaseHint(title: release.title, artist: release.artist) + }) + |> option.from_result +} + +type ScoredHint { + ScoredHint(score: Int, title: String, artist: String) +} + +fn hint_release_decoder() -> decode.Decoder(ScoredHint) { + use score <- decode.optional_field("score", 0, decode.int) + use title <- decode.optional_field("title", "", decode.string) + use names <- decode.optional_field( + "artist-credit", + [], + decode.list(artist_name_decoder()), + ) + decode.success(ScoredHint(score:, title:, artist: first_name(names))) +} + +fn artist_name_decoder() -> decode.Decoder(String) { + use name <- decode.optional_field("name", "", decode.string) + decode.success(name) +} + +fn first_name(names: List(String)) -> String { + case names { + [name, ..] -> name + [] -> "" + } +} + /// Discogs barcodes often carry spaces (stripped here, since MusicBrainz /// stores them bare and a spaced query degrades to fuzzy token matching) or /// outright junk annotations ("Not printed on packaging", "Text on spine: diff --git a/server/src/at_record_server/release_similarity.gleam b/server/src/at_record_server/release_similarity.gleam new file mode 100644 index 0000000..c56aef1 --- /dev/null +++ b/server/src/at_record_server/release_similarity.gleam @@ -0,0 +1,51 @@ +//// Scoring a MusicBrainz title/artist against a Discogs candidate, so the +//// barcode-scan "did you mean" flow can rank Discogs search hits. Pure +//// token-overlap (Jaccard over the combined title+artist token sets): +//// simple, order-insensitive, and robust to reissue suffixes like +//// "(Deluxe Edition)" that add tokens without removing the shared ones. + +import gleam/int +import gleam/list +import gleam/set.{type Set} +import gleam/string + +const punctuation = [ + "(", ")", "[", "]", "{", "}", ".", ",", ":", ";", "!", "?", "\"", "'", "-", + "_", "/", "\\", "&", "+", "*", "|", "~", "`", "@", "#", "%", "^", "=", +] + +/// A 0-100 similarity between a reference (MusicBrainz) release and a Discogs +/// candidate, folding both title and artist into one bag of tokens. +pub fn score( + ref_title: String, + ref_artist: String, + candidate_title: String, + candidate_artist: String, +) -> Int { + jaccard( + tokens(ref_title <> " " <> ref_artist), + tokens(candidate_title <> " " <> candidate_artist), + ) +} + +fn tokens(text: String) -> Set(String) { + text + |> string.lowercase + |> normalize_punctuation + |> string.split(" ") + |> list.filter(fn(token) { token != "" }) + |> set.from_list +} + +fn normalize_punctuation(text: String) -> String { + list.fold(punctuation, text, fn(acc, mark) { string.replace(acc, mark, " ") }) +} + +fn jaccard(a: Set(String), b: Set(String)) -> Int { + let intersection = set.intersection(a, b) |> set.size + let union = set.union(a, b) |> set.size + case union { + 0 -> 0 + _ -> int.clamp(intersection * 100 / union, 0, 100) + } +} diff --git a/server/test/discogs_client_test.gleam b/server/test/discogs_client_test.gleam index cb35fe7..fa63b87 100644 --- a/server/test/discogs_client_test.gleam +++ b/server/test/discogs_client_test.gleam @@ -156,3 +156,33 @@ pub fn release_details_other_status_is_error_test() { assert discogs_client.release_details(send, None, 4577) == Error("discogs returned status 500") } + +// --- barcode variants --- + +pub fn barcode_variants_despaces_and_excludes_original_test() { + // 13-digit spaced barcode: de-spaced is the only useful variant; the + // original (with spaces) is excluded, and a 13-digit code with no leading + // zero has no UPC-A sibling. + assert discogs_client.barcode_variants("5 099930 291623") == ["5099930291623"] +} + +pub fn barcode_variants_upc_a_gains_ean_13_zero_test() { + // 12-digit UPC-A: its EAN-13 sibling is the same digits with a leading zero. + assert discogs_client.barcode_variants("825646350919") == ["0825646350919"] +} + +pub fn barcode_variants_ean_13_with_leading_zero_strips_to_upc_a_test() { + assert discogs_client.barcode_variants("0825646350919") == ["825646350919"] +} + +pub fn barcode_variants_no_spaces_and_no_sibling_is_empty_test() { + // 13 digits, no leading zero, no spaces: nothing to try that differs. + assert discogs_client.barcode_variants("5099930291623") == [] +} + +pub fn barcode_variants_dedupes_test() { + // Spaces around a 12-digit UPC: de-spaced form plus its EAN-13 sibling, + // each appearing once. + assert discogs_client.barcode_variants("825 646 350919") + == ["825646350919", "0825646350919"] +} diff --git a/server/test/discogs_scan_test.gleam b/server/test/discogs_scan_test.gleam new file mode 100644 index 0000000..d17a77c --- /dev/null +++ b/server/test/discogs_scan_test.gleam @@ -0,0 +1,156 @@ +//// End-to-end scan handler tests for the "did you mean" recovery flow: the +//// exact barcode misses on Discogs, variants miss too, then MusicBrainz seeds +//// a Discogs text search whose hits become ranked suggestions. The Discogs +//// sender is stubbed once and branches on whether the request carries a +//// `barcode` param (lookup) or a `q` param (text search); the MusicBrainz +//// call goes through the atproto xrpc client, stubbed independently. + +import at_record_server/catalog_deps +import at_record_server/context.{type Context, Atproto, Context, Discogs, Web} +import at_record_server/discogs_client +import at_record_server/handlers/discogs as discogs_handler +import at_record_server/known_users +import at_record_server/oauth/config +import at_record_server/oauth/keys +import at_record_server/oauth/session_store +import at_record_server/oauth/sessions +import at_record_server/oauth/sessions_memory +import at_record_server/oauth/store +import atproto/xrpc +import gleam/bit_array +import gleam/http +import gleam/http/request +import gleam/http/response +import gleam/list +import gleam/option.{None} +import gleam/result +import gleam/string +import gose +import kryptos/ec +import wisp +import wisp/simulate + +const session_cookie = "ar_oauth_sid" + +const far_future = 9_999_999_999 + +const empty_search_body = "{\"pagination\":{\"page\":1,\"pages\":1},\"results\":[]}" + +const search_hit_body = "{\"pagination\":{\"page\":1,\"pages\":1},\"results\":[{\"id\":123,\"title\":\"Muse - Black Holes And Revelations\",\"year\":\"2006\",\"format\":[\"CD\",\"Album\"],\"thumb\":\"\",\"cover_image\":\"\"},{\"id\":456,\"title\":\"Nobody - Totally Unrelated Thing\",\"year\":\"2001\",\"format\":[\"CD\"],\"thumb\":\"\",\"cover_image\":\"\"}]}" + +const mb_barcode_body = "{\"count\":1,\"offset\":0,\"releases\":[{\"id\":\"9ec684cf-7529-300f-b6c4-6c56086dfe93\",\"score\":100,\"title\":\"Black Holes and Revelations\",\"artist-credit\":[{\"name\":\"Muse\"}]}]}" + +/// A Discogs sender that never finds a barcode but does find the text search +/// seeded from the MusicBrainz hint. +fn discogs_recovers_via_text_search( + req: request.Request(String), +) -> Result(#(Int, String), String) { + case has_barcode_param(req) { + True -> Ok(#(200, empty_search_body)) + False -> Ok(#(200, search_hit_body)) + } +} + +fn discogs_always_misses( + _req: request.Request(String), +) -> Result(#(Int, String), String) { + Ok(#(200, empty_search_body)) +} + +fn has_barcode_param(req: request.Request(String)) -> Bool { + request.get_query(req) + |> result.unwrap([]) + |> list.key_find("barcode") + |> result.is_ok +} + +fn mb_returns_exact_match() -> xrpc.Client { + xrpc.Client(send: fn(_req) { + Ok(response.Response(200, [], bit_array.from_string(mb_barcode_body))) + }) +} + +fn mb_fails() -> xrpc.Client { + xrpc.Client(send: fn(_req) { Error("musicbrainz unreachable") }) +} + +fn a_session() -> sessions.OauthSession { + sessions.OauthSession( + did: "did:plc:x", + handle: "h.test", + pds: "https://pds.example", + issuer: "https://as.example", + token_endpoint: "https://as.example/token", + client_id: "cid", + confidential: False, + dpop_key: gose.generate_ec(ec.P256), + access_token: "at", + refresh_token: "rt", + expires_at: far_future, + ) +} + +fn test_context( + mb: xrpc.Client, + discogs_send: discogs_client.Sender, +) -> #(Context, config.Config) { + let assert Ok(st) = store.start() + let assert Ok(ss) = sessions_memory.start() + let cfg = + config.new( + client: mb, + resolver: "r", + store: st, + sessions: ss, + key: keys.load(), + base_url: "http://localhost:8080", + ) + let ctx = + Context( + web: Web(static_directory: "", base_url: "http://localhost:8080"), + atproto: Atproto(client: mb, resolver: "r"), + discogs: Discogs(auth: None, creds: cfg.sessions, send: discogs_send), + catalog: catalog_deps.Deps( + backlinks: fn(_subject, _source) { panic as "not exercised by scan" }, + fetch_release: fn(_at_uri) { None }, + release_mbid: fn(_barcodes, _discogs_id) { None }, + ), + known_users: known_users.Store(upsert: fn(_) { Nil }, list: fn() { [] }), + oauth: cfg, + ) + #(ctx, cfg) +} + +fn authenticated_scan( + barcode: String, + ctx: Context, + cfg: config.Config, +) -> String { + let assert Ok(id) = session_store.create(cfg.sessions, a_session()) + let req = + simulate.request( + http.Get, + "/xrpc/dev.mokkenstorm.crate.discogs.resolveBarcode?barcode=" <> barcode, + ) + |> simulate.cookie(session_cookie, id, wisp.Signed) + let resp = discogs_handler.scan(req, ctx) + assert resp.status == 200 + simulate.read_body(resp) +} + +pub fn scan_miss_bridges_to_musicbrainz_suggestions_test() { + let #(ctx, cfg) = + test_context(mb_returns_exact_match(), discogs_recovers_via_text_search) + let body = authenticated_scan("825646350919", ctx, cfg) + assert string.contains(body, "\"result\":null") + assert string.contains(body, "\"suggestions\"") + // The exact-title hit scores a full 100 against the MusicBrainz hint. + assert string.contains(body, "\"confidence\":100") +} + +pub fn scan_musicbrainz_failure_degrades_to_plain_no_match_test() { + let #(ctx, cfg) = test_context(mb_fails(), discogs_always_misses) + let body = authenticated_scan("825646350919", ctx, cfg) + assert string.contains(body, "\"result\":null") + assert !string.contains(body, "suggestions") +} diff --git a/server/test/musicbrainz_client_test.gleam b/server/test/musicbrainz_client_test.gleam index c8f4101..fd7cf8d 100644 --- a/server/test/musicbrainz_client_test.gleam +++ b/server/test/musicbrainz_client_test.gleam @@ -1,4 +1,4 @@ -import at_record_server/musicbrainz_client +import at_record_server/musicbrainz_client.{ReleaseHint} import gleam/option.{None, Some} // Fixture shapes verified against a live call: @@ -38,6 +38,24 @@ pub fn parse_barcode_response_none_on_malformed_json_test() { assert musicbrainz_client.parse_barcode_response("not json") == None } +pub fn parse_barcode_hint_picks_title_and_first_artist_test() { + assert musicbrainz_client.parse_barcode_hint(barcode_response) + == Some(ReleaseHint(title: "Black Holes and Revelations", artist: "Muse")) +} + +pub fn parse_barcode_hint_none_when_score_below_100_test() { + assert musicbrainz_client.parse_barcode_hint(low_score_barcode_response) + == None +} + +pub fn parse_barcode_hint_none_when_no_releases_test() { + assert musicbrainz_client.parse_barcode_hint(empty_barcode_response) == None +} + +pub fn parse_barcode_hint_none_on_malformed_json_test() { + assert musicbrainz_client.parse_barcode_hint("not json") == None +} + pub fn usable_barcodes_filters_junk_and_despaces_test() { assert musicbrainz_client.usable_barcodes([ "825 646 350919", diff --git a/server/test/release_similarity_test.gleam b/server/test/release_similarity_test.gleam new file mode 100644 index 0000000..c3b2126 --- /dev/null +++ b/server/test/release_similarity_test.gleam @@ -0,0 +1,43 @@ +import at_record_server/release_similarity + +pub fn exact_title_and_artist_scores_full_test() { + assert release_similarity.score( + "Kind of Blue", + "Miles Davis", + "Kind of Blue", + "Miles Davis", + ) + == 100 +} + +pub fn reissue_suffix_scores_high_test() { + let score = + release_similarity.score( + "Kind of Blue", + "Miles Davis", + "Kind of Blue (Deluxe Edition)", + "Miles Davis", + ) + assert score >= 60 + assert score < 100 +} + +pub fn unrelated_pair_scores_near_zero_test() { + assert release_similarity.score( + "Kind of Blue", + "Miles Davis", + "Nevermind", + "Nirvana", + ) + == 0 +} + +pub fn punctuation_and_case_do_not_matter_test() { + assert release_similarity.score( + "OK Computer", + "Radiohead", + "ok computer.", + "RADIOHEAD", + ) + == 100 +} diff --git a/web/priv/static/app.css b/web/priv/static/app.css index 5c9f071..2ecb9df 100644 --- a/web/priv/static/app.css +++ b/web/priv/static/app.css @@ -878,6 +878,14 @@ input[type="number"]::-webkit-inner-spin-button { .scan-row__dismiss { padding: 6px 10px; } +.scan-suggestions { + margin-top: 6px; + padding-left: 12px; + border-left: 2px solid var(--line); +} +.scan-suggestions .section-label { + margin-top: 4px; +} .results-scroll { margin-top: 12px; max-height: 320px; diff --git a/web/src/at_record_web/effects.gleam b/web/src/at_record_web/effects.gleam index 939f1d5..a2bb007 100644 --- a/web/src/at_record_web/effects.gleam +++ b/web/src/at_record_web/effects.gleam @@ -4,8 +4,8 @@ import at_record_web/appview import at_record_web/browser import at_record_web/model.{ type AmendDraft, type BrowseRelease, type DiscogsResult, type Entry, type Form, - type ReleaseInfo, BrowseRelease, DiscogsResult, DiscogsSearchPage, Entry, - HandleSuggestion, ImportRun, ReleaseInfo, + type ReleaseInfo, type Suggestion, BrowseRelease, DiscogsResult, + DiscogsSearchPage, Entry, HandleSuggestion, ImportRun, ReleaseInfo, Suggestion, } import at_record_web/msg.{ type Msg, BarcodeDetected, CameraUnsupported, CoverUploaded, GotAction, GotAdd, @@ -190,13 +190,24 @@ pub fn stop_scanner() -> Effect(Msg) { pub fn scan_lookup(barcode: String) -> Effect(Msg) { let decoder = { use result <- decode.field("result", decode.optional(discogs_decoder())) - decode.success(ScanLookup(result:)) + use suggestions <- decode.optional_field( + "suggestions", + [], + decode.list(suggestion_decoder()), + ) + decode.success(ScanLookup(result:, suggestions:)) } let url = xrpc("discogs.resolveBarcode") <> "?barcode=" <> uri.percent_encode(barcode) rsvp.get(url, rsvp.expect_json(decoder, GotScanResult(barcode, _))) } +fn suggestion_decoder() -> decode.Decoder(Suggestion) { + use release <- decode.field("release", discogs_decoder()) + use confidence <- decode.field("confidence", decode.int) + decode.success(Suggestion(release:, confidence:)) +} + /// The discogs ids already in the crate, fetched once per scan session /// (not once per scan) so a shot-tray session doesn't refetch the whole /// event log for every barcode. diff --git a/web/src/at_record_web/model.gleam b/web/src/at_record_web/model.gleam index fd771ea..6a13fac 100644 --- a/web/src/at_record_web/model.gleam +++ b/web/src/at_record_web/model.gleam @@ -139,11 +139,18 @@ pub type ReleaseInfo { ) } -/// One scanned barcode's outcome, from lookup through commit. +/// A "did you mean" candidate for a barcode that resolved to nothing, with a +/// 0-100 confidence the server scored it at. +pub type Suggestion { + Suggestion(release: DiscogsResult, confidence: Int) +} + +/// One scanned barcode's outcome, from lookup through commit. `NoMatch` carries +/// any "did you mean" suggestions (empty when there are none). pub type ScanOutcome { Pending Matched(result: DiscogsResult, owned: Bool) - NoMatch + NoMatch(suggestions: List(Suggestion)) Adding(result: DiscogsResult) Added } diff --git a/web/src/at_record_web/msg.gleam b/web/src/at_record_web/msg.gleam index 46af232..4cb64c0 100644 --- a/web/src/at_record_web/msg.gleam +++ b/web/src/at_record_web/msg.gleam @@ -2,6 +2,7 @@ import at_record/gen/shelf/entry.{type ShelfEntry} import at_record_web/model.{ type BrowseRelease, type DiscogsResult, type DiscogsSearchPage, type Entry, type HandleSuggestion, type ImportRun, type ReleaseInfo, type Route, + type Suggestion, } import gleam/dict import gleam/option.{type Option} @@ -21,10 +22,11 @@ pub type TimelineData { TimelineData(events: List(ShelfEntry), release: Option(ReleaseInfo)) } -/// A barcode lookup's result: just the best Discogs match, if any. Ownership +/// A barcode lookup's result: the best Discogs match if any, plus "did you +/// mean" suggestions when the barcode itself resolved to nothing. Ownership /// is decided client-side against the scan session's seen-ids set. pub type ScanLookup { - ScanLookup(result: Option(DiscogsResult)) + ScanLookup(result: Option(DiscogsResult), suggestions: List(Suggestion)) } pub type Msg { @@ -81,6 +83,7 @@ pub type Msg { BarcodeDetected(String) GotScanResult(barcode: String, result: Result(ScanLookup, rsvp.Error(String))) AddScannedItem(barcode: String) + AddSuggestion(barcode: String, release: DiscogsResult) ScanItemAdded(barcode: String, result: Result(Nil, rsvp.Error(String))) RemoveScanRow(barcode: String) GotBrowse(Result(List(BrowseRelease), rsvp.Error(String))) diff --git a/web/src/at_record_web/pages/scan.gleam b/web/src/at_record_web/pages/scan.gleam index bcefa88..70f307d 100644 --- a/web/src/at_record_web/pages/scan.gleam +++ b/web/src/at_record_web/pages/scan.gleam @@ -4,10 +4,10 @@ //// matching has no backend yet, per the roadmap. import at_record_web/model.{ - type Model, type ScanOutcome, type ScanRow, Added, Adding, Matched, NoMatch, - Pending, + type Model, type ScanOutcome, type ScanRow, type Suggestion, Added, Adding, + Matched, NoMatch, Pending, } -import at_record_web/msg.{type Msg, AddScannedItem, RemoveScanRow} +import at_record_web/msg.{type Msg, AddScannedItem, AddSuggestion, RemoveScanRow} import at_record_web/ui/components as c import gleam/int import gleam/list @@ -80,14 +80,19 @@ fn outcome_view( html.span([attr.class("item-meta")], [text(barcode <> "…")]), html.span([attr.class("item-meta")], [text("looking up…")]), ]) - NoMatch -> - html.div([attr.class("scan-row__body")], [ - html.div([], [ - html.strong([], [text(barcode)]), - html.div([attr.class("item-meta")], [text("no Discogs match")]), + NoMatch(suggestions:) -> + html.div([], [ + html.div([attr.class("scan-row__body")], [ + html.div([], [ + html.strong([], [text(barcode)]), + html.div([attr.class("item-meta")], [text("no Discogs match")]), + ]), + c.link_button("SEARCH", c.Ghost, "/add"), + c.button("DISMISS", c.Ghost, [ + event.on_click(RemoveScanRow(barcode)), + ]), ]), - c.link_button("SEARCH", c.Ghost, "/add"), - c.button("DISMISS", c.Ghost, [event.on_click(RemoveScanRow(barcode))]), + suggestions_view(barcode, suggestions, busy), ]) Added -> html.div([attr.class("scan-row__body scan-row__body--added")], [ @@ -130,3 +135,46 @@ fn outcome_view( } } } + +/// "Did you mean?" rows under a no-match barcode: at most two MusicBrainz-seeded +/// Discogs candidates, each addable exactly like a matched row. +fn suggestions_view( + barcode: String, + suggestions: List(Suggestion), + busy: Bool, +) -> Element(Msg) { + case suggestions { + [] -> element.none() + _ -> + html.div([attr.class("scan-suggestions")], [ + c.section_label("DID YOU MEAN?"), + ..list.map(list.take(suggestions, 2), suggestion_view(barcode, _, busy)) + ]) + } +} + +fn suggestion_view( + barcode: String, + suggestion: Suggestion, + busy: Bool, +) -> Element(Msg) { + let result = suggestion.release + let meta = + [result.year |> option.map(int.to_string), result.format] + |> option.values + |> string.join(" · ") + html.div([attr.class("scan-row__body")], [ + c.thumb(result.thumb_url), + html.div([attr.class("scan-row__main")], [ + html.strong([], [text(result.title)]), + html.div([attr.class("item-meta")], [ + text(result.artist <> " · " <> meta), + ]), + ]), + c.chip(int.to_string(suggestion.confidence) <> "%", c.Surface), + c.button("ADD", c.Primary, [ + event.on_click(AddSuggestion(barcode, result)), + attr.disabled(busy), + ]), + ]) +} diff --git a/web/src/at_record_web/update.gleam b/web/src/at_record_web/update.gleam index a4595af..cf73905 100644 --- a/web/src/at_record_web/update.gleam +++ b/web/src/at_record_web/update.gleam @@ -10,18 +10,18 @@ import at_record_web/model.{ ShelfFailed, ShelfLoaded, ShelfLoading, Success, Warning, blank_form, } import at_record_web/msg.{ - type Msg, type ScanLookup, AddScannedItem, AmendField, ArmRemove, - BarcodeDetected, BrowseAdd, CameraUnsupported, ClearNotice, CoverFileChosen, - CoverUploaded, DiscogsConnect, DiscogsDisconnect, DiscogsImport, - DiscogsImportWantlist, DiscogsLoadMore, DiscogsSearch, DiscogsVinylOnly, - EntryAction, FormArtist, FormFormat, FormStatus, FormTitle, FormYear, - GotAction, GotAdd, GotAmend, GotAvatar, GotBrowse, GotBrowseAdd, GotDiscogs, - GotDiscogsDisconnect, GotDiscogsImport, GotDiscogsStatus, GotHandleSuggestions, - GotLogout, GotScanResult, GotScanSeen, GotShelf, GotTimeline, HandleChanged, - HandleSearch, Logout, OnRouteChange, Rate, Regrade, RemoveScanRow, RetryShelf, - ScanItemAdded, SetView, StartLogin, SubmitAdd, SubmitAmend, ToggleAmend, - ToggleAmendCover, ToggleEdit, ToggleEntry, ToggleMenu, UseDiscogs, - UseHandleSuggestion, + type Msg, type ScanLookup, AddScannedItem, AddSuggestion, AmendField, + ArmRemove, BarcodeDetected, BrowseAdd, CameraUnsupported, ClearNotice, + CoverFileChosen, CoverUploaded, DiscogsConnect, DiscogsDisconnect, + DiscogsImport, DiscogsImportWantlist, DiscogsLoadMore, DiscogsSearch, + DiscogsVinylOnly, EntryAction, FormArtist, FormFormat, FormStatus, FormTitle, + FormYear, GotAction, GotAdd, GotAmend, GotAvatar, GotBrowse, GotBrowseAdd, + GotDiscogs, GotDiscogsDisconnect, GotDiscogsImport, GotDiscogsStatus, + GotHandleSuggestions, GotLogout, GotScanResult, GotScanSeen, GotShelf, + GotTimeline, HandleChanged, HandleSearch, Logout, OnRouteChange, Rate, Regrade, + RemoveScanRow, RetryShelf, ScanItemAdded, SetView, StartLogin, SubmitAdd, + SubmitAmend, ToggleAmend, ToggleAmendCover, ToggleEdit, ToggleEntry, + ToggleMenu, UseDiscogs, UseHandleSuggestion, } import gleam/bool import gleam/int @@ -592,7 +592,7 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { effect.none(), ) GotScanResult(barcode, Error(_)) -> #( - Model(..model, scan: update_row(model.scan, barcode, model.NoMatch)), + Model(..model, scan: update_row(model.scan, barcode, model.NoMatch([]))), effect.none(), ) // Busy-guarded against a double-tap firing a second write for the same @@ -610,6 +610,21 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { ) _, _ -> #(model, effect.none()) } + // A "did you mean" suggestion carries its release directly (a NoMatch row + // has no single "the" match to look up), but otherwise commits exactly + // like AddScannedItem: same Adding transition, effect, and completion. + AddSuggestion(barcode, release) -> + case model.busy { + True -> #(model, effect.none()) + False -> #( + Model( + ..model, + busy: True, + scan: update_row(model.scan, barcode, model.Adding(release)), + ), + effects.add_scanned(barcode, model.form_from_discogs(release)), + ) + } ScanItemAdded(barcode, Ok(Nil)) -> #( Model( ..model, @@ -702,7 +717,7 @@ fn scan_outcome( result:, owned: set.contains(seen_ids, int.to_string(result.discogs_id)), ) - None -> model.NoMatch + None -> model.NoMatch(lookup.suggestions) } } diff --git a/web/test/at_record_web_test.gleam b/web/test/at_record_web_test.gleam index 4268bad..b5fc09c 100644 --- a/web/test/at_record_web_test.gleam +++ b/web/test/at_record_web_test.gleam @@ -1,20 +1,21 @@ import at_record_web/model.{ type BrowseRelease, type DiscogsResult, type Model, type ReleaseInfo, Added, - BrowseRelease, Crate, Discogs, DiscogsResult, Failure, Form, ImportRun, + Adding, BrowseRelease, Crate, Discogs, DiscogsResult, Failure, Form, ImportRun, LoggedIn, LoggedOut, Matched, Model, NoMatch, Notice, Pending, ReleaseInfo, - ScanRow, ScanState, ShelfFailed, ShelfLoaded, ShelfLoading, Success, Warning, - blank_amend, blank_discogs, blank_form, blank_scan, + ScanRow, ScanState, ShelfFailed, ShelfLoaded, ShelfLoading, Success, + Suggestion, Warning, blank_amend, blank_discogs, blank_form, blank_scan, } import at_record_web/msg.{ - AddScannedItem, ArmRemove, BarcodeDetected, BrowseAdd, ClearNotice, - DiscogsImportWantlist, EntryAction, FormTitle, GotAdd, GotAvatar, GotBrowse, - GotBrowseAdd, GotDiscogsImport, GotDiscogsStatus, GotLogout, GotScanResult, - GotScanSeen, GotShelf, GotTimeline, HandleChanged, Logout, OnRouteChange, - RemoveScanRow, RetryShelf, ScanItemAdded, ScanLookup, SetView, ShelfData, - SubmitAdd, TimelineData, + AddScannedItem, AddSuggestion, ArmRemove, BarcodeDetected, BrowseAdd, + ClearNotice, DiscogsImportWantlist, EntryAction, FormTitle, GotAdd, GotAvatar, + GotBrowse, GotBrowseAdd, GotDiscogsImport, GotDiscogsStatus, GotLogout, + GotScanResult, GotScanSeen, GotShelf, GotTimeline, HandleChanged, Logout, + OnRouteChange, RemoveScanRow, RetryShelf, ScanItemAdded, ScanLookup, SetView, + ShelfData, SubmitAdd, TimelineData, } import at_record_web/pages/crate import at_record_web/pages/login +import at_record_web/pages/scan import at_record_web/update.{update} import gleam/dict import gleam/http/response.{Response} @@ -363,7 +364,7 @@ pub fn got_scan_result_marks_owned_from_seen_ids_test() { let #(model, _) = update( seeded, - GotScanResult("b1", Ok(ScanLookup(Some(discogs_result(4577))))), + GotScanResult("b1", Ok(ScanLookup(Some(discogs_result(4577)), []))), ) assert model.scan.rows == [ScanRow(barcode: "b1", outcome: Matched(discogs_result(4577), True))] @@ -378,7 +379,10 @@ pub fn got_scan_result_unowned_match_test() { ]), ) let #(model, _) = - update(seeded, GotScanResult("b1", Ok(ScanLookup(Some(discogs_result(9)))))) + update( + seeded, + GotScanResult("b1", Ok(ScanLookup(Some(discogs_result(9)), []))), + ) assert model.scan.rows == [ScanRow(barcode: "b1", outcome: Matched(discogs_result(9), False))] } @@ -391,8 +395,89 @@ pub fn got_scan_result_no_match_test() { ScanRow(barcode: "b1", outcome: Pending), ]), ) - let #(model, _) = update(seeded, GotScanResult("b1", Ok(ScanLookup(None)))) - assert model.scan.rows == [ScanRow(barcode: "b1", outcome: NoMatch)] + let #(model, _) = + update(seeded, GotScanResult("b1", Ok(ScanLookup(None, [])))) + assert model.scan.rows == [ScanRow(barcode: "b1", outcome: NoMatch([]))] +} + +pub fn got_scan_result_no_match_carries_suggestions_test() { + let seeded = + Model( + ..logged_in(), + scan: ScanState(..blank_scan(), rows: [ + ScanRow(barcode: "b1", outcome: Pending), + ]), + ) + let suggestions = [Suggestion(release: discogs_result(9), confidence: 62)] + let #(model, _) = + update(seeded, GotScanResult("b1", Ok(ScanLookup(None, suggestions)))) + assert model.scan.rows + == [ScanRow(barcode: "b1", outcome: NoMatch(suggestions))] +} + +pub fn add_suggestion_transitions_row_to_adding_and_sets_busy_test() { + let release = discogs_result(9) + let seeded = + Model( + ..logged_in(), + scan: ScanState(..blank_scan(), rows: [ + ScanRow( + barcode: "b1", + outcome: NoMatch([Suggestion(release:, confidence: 62)]), + ), + ]), + ) + let #(model, _) = update(seeded, AddSuggestion("b1", release)) + assert model.busy == True + assert model.scan.rows == [ScanRow(barcode: "b1", outcome: Adding(release))] +} + +pub fn scan_page_renders_did_you_mean_suggestions_test() { + let model = + Model( + ..logged_in(), + scan: ScanState(..blank_scan(), rows: [ + ScanRow( + barcode: "825646350919", + outcome: NoMatch([ + Suggestion(release: discogs_result(9), confidence: 62), + ]), + ), + ]), + ) + let html = model |> scan.view |> element.to_string + assert string.contains(html, "DID YOU MEAN?") + assert string.contains(html, "62%") + assert string.contains(html, "Spiderland") +} + +pub fn scan_page_no_suggestions_omits_did_you_mean_test() { + let model = + Model( + ..logged_in(), + scan: ScanState(..blank_scan(), rows: [ + ScanRow(barcode: "825646350919", outcome: NoMatch([])), + ]), + ) + let html = model |> scan.view |> element.to_string + assert !string.contains(html, "DID YOU MEAN?") +} + +pub fn add_suggestion_ignores_tap_while_busy_test() { + let release = discogs_result(9) + let seeded = + Model( + ..logged_in(), + busy: True, + scan: ScanState(..blank_scan(), rows: [ + ScanRow( + barcode: "b1", + outcome: NoMatch([Suggestion(release:, confidence: 62)]), + ), + ]), + ) + let #(model, _) = update(seeded, AddSuggestion("b1", release)) + assert model.scan.rows == seeded.scan.rows } pub fn got_scan_result_error_marks_no_match_test() { @@ -405,7 +490,7 @@ pub fn got_scan_result_error_marks_no_match_test() { ) let #(model, _) = update(seeded, GotScanResult("b1", Error(rsvp.NetworkError))) - assert model.scan.rows == [ScanRow(barcode: "b1", outcome: NoMatch)] + assert model.scan.rows == [ScanRow(barcode: "b1", outcome: NoMatch([]))] } pub fn add_scanned_item_ignores_tap_while_busy_test() { @@ -452,7 +537,7 @@ pub fn remove_scan_row_drops_it_test() { Model( ..logged_in(), scan: ScanState(..blank_scan(), rows: [ - ScanRow(barcode: "b1", outcome: NoMatch), + ScanRow(barcode: "b1", outcome: NoMatch([])), ScanRow(barcode: "b2", outcome: Pending), ]), )