diff --git a/lexicons/dev/mokkenstorm/crate/discogs/resolveBarcode.json b/lexicons/dev/mokkenstorm/crate/discogs/resolveBarcode.json index 87f298f..c6a73bd 100644 --- a/lexicons/dev/mokkenstorm/crate/discogs/resolveBarcode.json +++ b/lexicons/dev/mokkenstorm/crate/discogs/resolveBarcode.json @@ -31,11 +31,46 @@ "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." + }, + "network": { + "type": "ref", + "ref": "#networkMatch", + "description": "A release already known to the shared at-record catalog, found before any Discogs lookup was made. Present only on a network hit; when present, Discogs was never queried." } } } } }, + "networkMatch": { + "type": "object", + "description": "A catalog release resolved from the shared at-record network for a scanned barcode, ahead of any Discogs lookup.", + "required": ["uri", "cid", "title"], + "properties": { + "uri": { + "type": "string", + "format": "at-uri", + "description": "at-uri of the matched catalog release." + }, + "cid": { + "type": "string", + "format": "cid" + }, + "title": { + "type": "string" + }, + "artistDisplay": { + "type": "string" + }, + "thumbUrl": { + "type": "string", + "format": "uri" + }, + "publisherHandle": { + "type": "string", + "description": "Handle of the repo the release was found in, best-effort." + } + } + }, "suggestion": { "type": "object", "description": "A candidate release for a barcode that did not resolve directly, with a 0-100 confidence score.", diff --git a/server/src/at_record_server/browse.gleam b/server/src/at_record_server/browse.gleam index 489e591..68303b0 100644 --- a/server/src/at_record_server/browse.gleam +++ b/server/src/at_record_server/browse.gleam @@ -159,7 +159,13 @@ pub fn discogs_id(value: catalog_release.CatalogRelease) -> Option(String) { } /// Per-user read: public listRecords, no token; any failure drops the user. -pub fn fetch_user_releases(client: Client, user: KnownUser) -> List(BrowseRow) { +/// Shared by the browse fan-out and the network-first barcode search below, +/// so there is exactly one place that builds the listRecords query and +/// decodes the response. +fn fetch_user_records( + client: Client, + user: KnownUser, +) -> List(#(String, String, catalog_release.CatalogRelease)) { let query = gleam_uri.query_to_string([ #("repo", user.did), @@ -172,12 +178,88 @@ pub fn fetch_user_releases(client: Client, user: KnownUser) -> List(BrowseRow) { xrpc.parse(resp.body, records_decoder()) } |> result.unwrap([]) +} + +pub fn fetch_user_releases(client: Client, user: KnownUser) -> List(BrowseRow) { + fetch_user_records(client, user) |> list.map(fn(r) { let #(uri, cid, value) = r to_browse_row(user, uri, cid, value) }) } +/// A network-first barcode hit: the release plus enough of the publisher to +/// attribute it, shaped for `discogs.resolveBarcode`'s `network` field. +pub type NetworkMatch { + NetworkMatch( + uri: String, + cid: String, + title: String, + artist_display: Option(String), + thumb_url: Option(String), + publisher_handle: String, + ) +} + +/// Search the known-users fan-out for a catalog.release carrying `barcode` +/// among its identifiers (de-spaced comparison both sides), stopping at the +/// first hit. Reuses the same per-user cap as `browse`, so a scan is never a +/// heavier fan-out than the browse grid; a miss walks every known user +/// exactly once, same as a browse load does. Per-user fetch failures are +/// already absorbed as an empty result by `fetch_user_records`, so this is +/// best-effort by construction: any failure just looks like a miss. +pub fn find_by_barcode( + client: Client, + users: List(KnownUser), + barcode: String, +) -> Option(NetworkMatch) { + let target = despace(barcode) + users + |> list.find_map(fn(user) { + fetch_user_records(client, user) + |> list.find_map(fn(r) { + let #(uri, cid, value) = r + case identifiers_match(value, target) { + True -> Ok(to_network_match(user, uri, cid, value)) + False -> Error(Nil) + } + }) + }) + |> option.from_result +} + +// Barcode identifiers only ("Barcode", per the mint-time capture in +// `promotion`); other identifier types (matrix numbers, ...) never collide +// with a scanned barcode, so they are not worth comparing. +fn identifiers_match( + value: catalog_release.CatalogRelease, + target: String, +) -> Bool { + value.identifiers + |> option.unwrap([]) + |> list.any(fn(id) { id.type_ == "Barcode" && despace(id.value) == target }) +} + +fn despace(value: String) -> String { + string.replace(value, " ", "") +} + +fn to_network_match( + user: KnownUser, + uri: String, + cid: String, + value: catalog_release.CatalogRelease, +) -> NetworkMatch { + NetworkMatch( + uri:, + cid:, + title: value.title, + artist_display: value.artist_display, + thumb_url: value.thumb_url, + publisher_handle: user.handle, + ) +} + fn records_decoder() -> decode.Decoder( List(#(String, String, catalog_release.CatalogRelease)), ) { diff --git a/server/src/at_record_server/handlers/discogs.gleam b/server/src/at_record_server/handlers/discogs.gleam index cc1f3ed..9cfe54f 100644 --- a/server/src/at_record_server/handlers/discogs.gleam +++ b/server/src/at_record_server/handlers/discogs.gleam @@ -2,8 +2,10 @@ //// (OAuth 1.0a), and the thin route glue for collection/wantlist import //// (the engine itself lives in `catalog_import`). +import at_record_server/browse as browse_domain import at_record_server/catalog_import import at_record_server/context.{type Context, error_json, require_session} +import at_record_server/crate import at_record_server/discogs_client import at_record_server/discogs_creds.{type Creds} import at_record_server/discogs_oauth.{TokenPair} @@ -107,36 +109,75 @@ pub fn scan_seen(req: Request, ctx: Context) -> Response { } } -/// 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. +/// One barcode scan: network-first. The shared at-record catalog is checked +/// before Discogs is ever touched; a hit there is free (no Discogs spend) and +/// wins outright. Only a network miss falls through to the Discogs chain: +/// the exact barcode, then close variants, then MusicBrainz-seeded "did you +/// mean" suggestions. Ownership of a Discogs match is decided client-side +/// against the seen-ids set fetched once at scan start. pub fn scan(req: Request, ctx: Context) -> Response { use _id, _session <- require_session(req, ctx) case list.key_find(wisp.get_query(req), "barcode") { Error(Nil) -> error_json(400, "missing barcode") - Ok(barcode) -> - case - discogs_client.search_by_barcode( - ctx.discogs.send, - ctx.discogs.auth, - barcode, - ) - { - Error(_) -> error_json(502, "discogs lookup failed") - Ok(found) -> - case found.releases { - [_, ..] -> - scan_response( - list.first(found.releases) |> option.from_result, - [], - ) - [] -> recover_no_match(ctx, barcode) - } + Ok(barcode) -> scan_network_first(ctx, barcode) + } +} + +/// A network hit skips Discogs entirely; a miss (including any per-user +/// fetch failure inside the fan-out, already absorbed as empty by `browse`) +/// silently falls through to the unchanged Discogs chain. +fn scan_network_first(ctx: Context, barcode: String) -> Response { + case + browse_domain.find_by_barcode( + ctx.atproto.client, + ctx.known_users.list(), + barcode, + ) + { + Some(match) -> network_response(match) + None -> scan_discogs(ctx, barcode) + } +} + +fn scan_discogs(ctx: Context, barcode: String) -> Response { + case + discogs_client.search_by_barcode( + ctx.discogs.send, + ctx.discogs.auth, + barcode, + ) + { + Error(_) -> error_json(502, "discogs lookup failed") + Ok(found) -> + case found.releases { + [_, ..] -> + scan_response(list.first(found.releases) |> option.from_result, []) + [] -> recover_no_match(ctx, barcode) } } } +fn network_response(match: browse_domain.NetworkMatch) -> Response { + json.object([#("network", encode_network_match(match))]) + |> json.to_string + |> wisp.json_response(200) +} + +fn encode_network_match(match: browse_domain.NetworkMatch) -> json.Json { + json.object( + list.flatten([ + [ + #("uri", json.string(match.uri)), + #("cid", json.string(match.cid)), + #("title", json.string(match.title)), + #("publisherHandle", json.string(match.publisher_handle)), + ], + crate.opt("artistDisplay", match.artist_display, json.string), + crate.opt("thumbUrl", match.thumb_url, json.string), + ]), + ) +} + /// 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( diff --git a/server/test/browse_test.gleam b/server/test/browse_test.gleam index df9532e..6fee83a 100644 --- a/server/test/browse_test.gleam +++ b/server/test/browse_test.gleam @@ -1,7 +1,11 @@ import at_record/gen/defs.{CatalogRef, ExternalId} import at_record/gen/shelf/entry.{type ShelfEntry, ShelfEntry} import at_record/storage.{type StoredItem, StoredItem} -import at_record_server/browse.{type BrowseRow, BrowseRow} +import at_record_server/browse.{type BrowseRow, BrowseRow, NetworkMatch} +import at_record_server/known_users.{KnownUser} +import atproto/xrpc +import gleam/bit_array +import gleam/http/response import gleam/list import gleam/option.{type Option, None, Some} import gleam/string @@ -157,3 +161,116 @@ pub fn ownership_matches_by_discogs_id_across_status_test() { assert browse.flags(own, wanted_row) == #(False, True) assert browse.flags(own, unknown_row) == #(False, False) } + +// --- find_by_barcode: network-first fan-out --- + +fn a_known_user(handle: String, pds: String) -> known_users.KnownUser { + KnownUser(did: "did:plc:" <> handle, handle:, pds:) +} + +fn list_records_body( + uri: String, + cid: String, + identifiers_json: String, +) -> String { + "{\"records\":[{\"uri\":\"" + <> uri + <> "\",\"cid\":\"" + <> cid + <> "\",\"value\":{\"title\":\"Spiderland\",\"artistDisplay\":\"Slint\",\"createdAt\":\"2026-01-01T00:00:00Z\",\"identifiers\":" + <> identifiers_json + <> "}}]}" +} + +fn empty_records_body() -> String { + "{\"records\":[]}" +} + +fn client_with_body(body: String) -> xrpc.Client { + xrpc.Client(send: fn(_req) { + Ok(response.Response(200, [], bit_array.from_string(body))) + }) +} + +pub fn find_by_barcode_matches_despaced_identifiers_on_both_sides_test() { + let body = + list_records_body( + "at://did:plc:pub/dev.mokkenstorm.crate.catalog.release/r1", + "bafyrel", + "[{\"type\":\"Barcode\",\"value\":\"01234567 89011\"}]", + ) + let user = a_known_user("pub.test", "https://pds.test") + let found = + browse.find_by_barcode(client_with_body(body), [user], "0123456789011") + assert found + == Some(NetworkMatch( + uri: "at://did:plc:pub/dev.mokkenstorm.crate.catalog.release/r1", + cid: "bafyrel", + title: "Spiderland", + artist_display: Some("Slint"), + thumb_url: None, + publisher_handle: "pub.test", + )) +} + +pub fn find_by_barcode_ignores_non_barcode_identifiers_test() { + let body = + list_records_body( + "at://did:plc:pub/dev.mokkenstorm.crate.catalog.release/r1", + "bafyrel", + "[{\"type\":\"CatalogNumber\",\"value\":\"0123456789011\"}]", + ) + let user = a_known_user("pub.test", "https://pds.test") + let found = + browse.find_by_barcode(client_with_body(body), [user], "0123456789011") + assert found == None +} + +pub fn find_by_barcode_misses_across_every_known_user_test() { + let user_a = a_known_user("a.test", "https://a.test") + let user_b = a_known_user("b.test", "https://b.test") + let found = + browse.find_by_barcode( + client_with_body(empty_records_body()), + [user_a, user_b], + "0123456789011", + ) + assert found == None +} + +pub fn find_by_barcode_stops_at_the_first_hit_test() { + let hit_body = + list_records_body( + "at://did:plc:first/dev.mokkenstorm.crate.catalog.release/r1", + "bafyrel", + "[{\"type\":\"Barcode\",\"value\":\"0123456789011\"}]", + ) + let first = a_known_user("first.test", "https://first.test") + // A second known user whose client would panic if ever queried: the fan-out + // must short-circuit on the first hit rather than walking every user. + let unreachable = + KnownUser( + did: "did:plc:second", + handle: "second.test", + pds: "https://second.test", + ) + let client = + xrpc.Client(send: fn(req) { + case req.host { + "first.test" -> + Ok(response.Response(200, [], bit_array.from_string(hit_body))) + _ -> panic as "the fan-out should have stopped after the first hit" + } + }) + let found = + browse.find_by_barcode(client, [first, unreachable], "0123456789011") + assert found + == Some(NetworkMatch( + uri: "at://did:plc:first/dev.mokkenstorm.crate.catalog.release/r1", + cid: "bafyrel", + title: "Spiderland", + artist_display: Some("Slint"), + thumb_url: None, + publisher_handle: "first.test", + )) +} diff --git a/server/test/discogs_scan_test.gleam b/server/test/discogs_scan_test.gleam index 9ed1587..3684744 100644 --- a/server/test/discogs_scan_test.gleam +++ b/server/test/discogs_scan_test.gleam @@ -9,7 +9,7 @@ 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/known_users.{type KnownUser, KnownUser} import at_record_server/oauth/config import at_record_server/oauth/keys import at_record_server/oauth/session_store @@ -155,3 +155,155 @@ pub fn scan_musicbrainz_failure_degrades_to_plain_no_match_test() { assert string.contains(body, "\"result\":null") assert !string.contains(body, "suggestions") } + +// --- network-first: the shared catalog is checked before Discogs --- + +const network_hit_barcode = "01234567 89011" + +const network_hit_barcode_despaced = "0123456789011" + +fn network_hit_records_body() -> String { + "{\"records\":[{\"uri\":\"at://did:plc:pub/dev.mokkenstorm.crate.catalog.release/r1\",\"cid\":\"bafyrel\",\"value\":{\"title\":\"Spiderland\",\"artistDisplay\":\"Slint\",\"createdAt\":\"2026-01-01T00:00:00Z\",\"identifiers\":[{\"type\":\"Barcode\",\"value\":\"" + <> network_hit_barcode + <> "\"}]}}]}" +} + +fn empty_records_body() -> String { + "{\"records\":[]}" +} + +fn a_known_user() -> KnownUser { + KnownUser(did: "did:plc:pub", handle: "pub.test", pds: "https://pds.test") +} + +fn known_users_of(users: List(KnownUser)) -> known_users.Store { + known_users.Store(upsert: fn(_) { Nil }, list: fn() { users }) +} + +/// A client whose `send` answers a `listRecords` fan-out call with `body`; +/// used as `ctx.atproto.client` so it also stands in for the MusicBrainz +/// client (unreachable in these tests: a network hit never falls through, +/// and a network miss here always hits Discogs directly). +fn client_with_records(body: String) -> xrpc.Client { + xrpc.Client(send: fn(_req) { + Ok(response.Response(200, [], bit_array.from_string(body))) + }) +} + +fn discogs_panics_if_called( + _req: request.Request(String), +) -> Result(#(Int, String), String) { + panic as "discogs must not be called on a network hit" +} + +fn test_context_with_users( + atproto_client: xrpc.Client, + discogs_send: discogs_client.Sender, + users: List(KnownUser), +) -> #(Context, config.Config) { + let assert Ok(st) = store.start() + let assert Ok(ss) = sessions_memory.start() + let cfg = + config.new( + client: atproto_client, + 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: atproto_client, 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 }, + fetch_edit: fn(_at_uri) { None }, + release_mbid: fn(_barcodes, _discogs_id) { None }, + ), + known_users: known_users_of(users), + oauth: cfg, + ) + #(ctx, cfg) +} + +pub fn network_hit_returns_network_and_never_calls_discogs_test() { + let #(ctx, cfg) = + test_context_with_users( + client_with_records(network_hit_records_body()), + discogs_panics_if_called, + [a_known_user()], + ) + let body = authenticated_scan(network_hit_barcode_despaced, ctx, cfg) + assert string.contains(body, "\"network\"") + assert string.contains( + body, + "\"uri\":\"at://did:plc:pub/dev.mokkenstorm.crate.catalog.release/r1\"", + ) + assert string.contains(body, "\"title\":\"Spiderland\"") + assert string.contains(body, "\"publisherHandle\":\"pub.test\"") +} + +fn discogs_hits_immediately( + _req: request.Request(String), +) -> Result(#(Int, String), String) { + Ok(#(200, search_hit_body)) +} + +pub fn network_miss_falls_through_to_the_discogs_chain_test() { + let #(ctx, cfg) = + test_context_with_users( + client_with_records(empty_records_body()), + discogs_hits_immediately, + [a_known_user()], + ) + let body = authenticated_scan("825646350919", ctx, cfg) + assert !string.contains(body, "\"network\"") + assert string.contains(body, "Black Holes And Revelations") +} + +pub fn network_stage_failure_falls_through_to_discogs_test() { + let failing_client = xrpc.Client(send: fn(_req) { Error("unreachable") }) + let #(ctx, cfg) = + test_context_with_users(failing_client, discogs_always_misses, [ + a_known_user(), + ]) + let body = authenticated_scan("825646350919", ctx, cfg) + assert !string.contains(body, "\"network\"") + assert string.contains(body, "\"result\":null") +} + +pub fn network_stage_caps_the_per_user_fetch_like_browse_test() { + let checked_client = + xrpc.Client(send: fn(req) { + case req.host { + "pds.test" -> { + let query = request.get_query(req) |> result.unwrap([]) + assert list.key_find(query, "limit") == Ok("50") + assert list.key_find(query, "collection") + == Ok("dev.mokkenstorm.crate.catalog.release") + Ok(response.Response( + 200, + [], + bit_array.from_string(empty_records_body()), + )) + } + // The Discogs miss falls through to a MusicBrainz hint lookup on the + // same shared client; irrelevant to this cap assertion. + _ -> + Ok(response.Response( + 200, + [], + bit_array.from_string("{\"releases\":[]}"), + )) + } + }) + let #(ctx, cfg) = + test_context_with_users(checked_client, discogs_always_misses, [ + a_known_user(), + ]) + let body = authenticated_scan("825646350919", ctx, cfg) + assert !string.contains(body, "\"network\"") +} diff --git a/web/src/at_record_web/effects.gleam b/web/src/at_record_web/effects.gleam index 618cc24..00198f0 100644 --- a/web/src/at_record_web/effects.gleam +++ b/web/src/at_record_web/effects.gleam @@ -4,9 +4,10 @@ import at_record_web/appview import at_record_web/browser import at_record_web/model.{ type AmendDraft, type BrowseRelease, type DiscogsResult, type EditProposal, - type Entry, type Form, type ReleaseInfo, type Suggestion, BrowseRelease, - DiscogsResult, DiscogsSearchPage, EditProposal, Entry, HandleSuggestion, - ImportRun, ProposalFields, ReleaseInfo, Suggestion, + type Entry, type Form, type NetworkMatch, type ReleaseInfo, type Suggestion, + BrowseRelease, DiscogsResult, DiscogsSearchPage, EditProposal, Entry, + HandleSuggestion, ImportRun, NetworkMatch, ProposalFields, ReleaseInfo, + Suggestion, } import at_record_web/money import at_record_web/msg.{ @@ -206,17 +207,27 @@ pub fn stop_scanner() -> Effect(Msg) { effect.from(fn(_dispatch) { browser.stop_scanner() }) } -/// Look up a scanned barcode against Discogs; ownership is decided -/// client-side against the scan session's seen-ids set. +/// Look up a scanned barcode: network-first against the shared at-record +/// catalog, falling through to Discogs. Ownership of a Discogs match is +/// decided client-side against the scan session's seen-ids set. pub fn scan_lookup(barcode: String) -> Effect(Msg) { let decoder = { - use result <- decode.field("result", decode.optional(discogs_decoder())) + use result <- decode.optional_field( + "result", + None, + decode.optional(discogs_decoder()), + ) use suggestions <- decode.optional_field( "suggestions", [], decode.list(suggestion_decoder()), ) - decode.success(ScanLookup(result:, suggestions:)) + use network <- decode.optional_field( + "network", + None, + decode.optional(network_decoder()), + ) + decode.success(ScanLookup(result:, suggestions:, network:)) } let url = xrpc("discogs.resolveBarcode") <> "?barcode=" <> uri.percent_encode(barcode) @@ -229,6 +240,35 @@ fn suggestion_decoder() -> decode.Decoder(Suggestion) { decode.success(Suggestion(release:, confidence:)) } +fn network_decoder() -> decode.Decoder(NetworkMatch) { + use uri <- decode.field("uri", decode.string) + use cid <- decode.field("cid", decode.string) + use title <- decode.field("title", decode.string) + use artist_display <- decode.optional_field( + "artistDisplay", + None, + decode.optional(decode.string), + ) + use thumb_url <- decode.optional_field( + "thumbUrl", + None, + decode.optional(decode.string), + ) + use publisher_handle <- decode.optional_field( + "publisherHandle", + None, + decode.optional(decode.string), + ) + decode.success(NetworkMatch( + uri:, + cid:, + title:, + artist_display:, + thumb_url:, + publisher_handle:, + )) +} + /// 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. @@ -423,6 +463,24 @@ pub fn add_scanned(barcode: String, form: Form) -> Effect(Msg) { ) } +/// A network-matched scan row's ADD: the release is already known, so this +/// adopts it exactly like the browse grid's "I HAVE THIS" (owned status), +/// tagged with the barcode like `add_scanned` so the triggering row can be +/// found again when the response comes back. +pub fn adopt_scanned(barcode: String, uri: String, cid: String) -> Effect(Msg) { + let body = + json.object([ + #("uri", json.string(uri)), + #("cid", json.string(cid)), + #("status", json.string("owned")), + ]) + rsvp.post( + xrpc("catalog.adoptRelease"), + body, + rsvp.expect_json(nil_decoder(), ScanItemAdded(barcode, _)), + ) +} + /// The shared catalog grid: releases published by known at-record users, /// folded against the viewer's own crate. pub fn load_browse() -> Effect(Msg) { diff --git a/web/src/at_record_web/model.gleam b/web/src/at_record_web/model.gleam index 233e3da..d5dbc02 100644 --- a/web/src/at_record_web/model.gleam +++ b/web/src/at_record_web/model.gleam @@ -169,13 +169,37 @@ pub type Suggestion { Suggestion(release: DiscogsResult, confidence: Int) } +/// A release already known to the shared at-record catalog, found before any +/// Discogs lookup was made; `publisher_handle` is best-effort, mirroring how +/// `BrowseRelease` carries its own. +pub type NetworkMatch { + NetworkMatch( + uri: String, + cid: String, + title: String, + artist_display: Option(String), + thumb_url: Option(String), + publisher_handle: Option(String), + ) +} + +/// A scan row's add-eligible item: either a Discogs match (added via the +/// manual add path) or a network match (added via `adoptRelease`, same as +/// the browse grid's quick actions). `Adding` carries whichever one +/// triggered the write, so both flows share one in-flight state. +pub type ScanItem { + FromDiscogs(DiscogsResult) + FromNetwork(NetworkMatch) +} + /// 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) + NetworkMatched(release: NetworkMatch) NoMatch(suggestions: List(Suggestion)) - Adding(result: DiscogsResult) + Adding(item: ScanItem) Added } diff --git a/web/src/at_record_web/msg.gleam b/web/src/at_record_web/msg.gleam index 25fb29f..10b7d8e 100644 --- a/web/src/at_record_web/msg.gleam +++ b/web/src/at_record_web/msg.gleam @@ -2,7 +2,7 @@ import at_record/gen/shelf/entry.{type ShelfEntry} import at_record_web/model.{ type BrowseRelease, type DiscogsResult, type DiscogsSearchPage, type Display, type EditProposal, type Entry, type HandleSuggestion, type ImportRun, - type ReleaseInfo, type Route, type Suggestion, + type NetworkMatch, type ReleaseInfo, type Route, type Suggestion, } import gleam/dict import gleam/option.{type Option} @@ -28,11 +28,17 @@ pub type TimelineData { TimelineData(events: List(ShelfEntry), release: Option(ReleaseInfo)) } -/// 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. +/// A barcode lookup's result: a network match wins outright when present +/// (Discogs was never even queried); otherwise the best Discogs match if +/// any, plus "did you mean" suggestions when the barcode itself resolved to +/// nothing. Ownership of a Discogs match is decided client-side against the +/// scan session's seen-ids set. pub type ScanLookup { - ScanLookup(result: Option(DiscogsResult), suggestions: List(Suggestion)) + ScanLookup( + result: Option(DiscogsResult), + suggestions: List(Suggestion), + network: Option(NetworkMatch), + ) } pub type Msg { diff --git a/web/src/at_record_web/pages/scan.gleam b/web/src/at_record_web/pages/scan.gleam index 70f307d..e02dba4 100644 --- a/web/src/at_record_web/pages/scan.gleam +++ b/web/src/at_record_web/pages/scan.gleam @@ -4,8 +4,9 @@ //// matching has no backend yet, per the roadmap. import at_record_web/model.{ - type Model, type ScanOutcome, type ScanRow, type Suggestion, Added, Adding, - Matched, NoMatch, Pending, + type Model, type NetworkMatch, type ScanItem, type ScanOutcome, type ScanRow, + type Suggestion, Added, Adding, FromDiscogs, FromNetwork, Matched, + NetworkMatched, NoMatch, Pending, } import at_record_web/msg.{type Msg, AddScannedItem, AddSuggestion, RemoveScanRow} import at_record_web/ui/components as c @@ -98,14 +99,8 @@ fn outcome_view( html.div([attr.class("scan-row__body scan-row__body--added")], [ html.span([], [text("✓ added")]), ]) - Adding(result:) -> - 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("adding…")]), - ]), - ]) + Adding(item:) -> adding_view(item) + NetworkMatched(release:) -> network_view(barcode, release, busy) Matched(result:, owned:) -> { let meta = [result.year |> option.map(int.to_string), result.format] @@ -136,6 +131,55 @@ fn outcome_view( } } +/// The in-flight add row, shared by both a Discogs add and a network adopt. +fn adding_view(item: ScanItem) -> Element(Msg) { + let #(thumb_url, title) = case item { + FromDiscogs(result) -> #(result.thumb_url, result.title) + FromNetwork(release) -> #(release.thumb_url, release.title) + } + html.div([attr.class("scan-row__body")], [ + c.thumb(thumb_url), + html.div([attr.class("scan-row__main")], [ + html.strong([], [text(title)]), + html.div([attr.class("item-meta")], [text("adding…")]), + ]), + ]) +} + +/// A release already found in the shared at-record catalog: same card shape +/// as a Discogs match, with a browse-style "via @handle" attribution line; +/// ADD adopts it instead of running the manual add form. +fn network_view( + barcode: String, + release: NetworkMatch, + busy: Bool, +) -> Element(Msg) { + let artist = option.unwrap(release.artist_display, "") + html.div([attr.class("scan-row__body")], [ + c.thumb(release.thumb_url), + html.div([attr.class("scan-row__main")], [ + html.strong([], [text(release.title)]), + html.div([attr.class("item-meta")], [text(artist)]), + via_handle(release.publisher_handle), + ]), + c.button("ADD", c.Primary, [ + event.on_click(AddScannedItem(barcode)), + attr.disabled(busy), + ]), + c.button("×", c.Ghost, [ + attr.class("scan-row__dismiss"), + event.on_click(RemoveScanRow(barcode)), + ]), + ]) +} + +fn via_handle(handle: option.Option(String)) -> Element(Msg) { + case handle { + Some(h) -> html.span([attr.class("item-meta")], [text("via @" <> h)]) + None -> element.none() + } +} + /// "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( diff --git a/web/src/at_record_web/update.gleam b/web/src/at_record_web/update.gleam index a01f05b..671b725 100644 --- a/web/src/at_record_web/update.gleam +++ b/web/src/at_record_web/update.gleam @@ -643,17 +643,36 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { ) // Busy-guarded against a double-tap firing a second write for the same // barcode. The row switches to Adding right away so a batch of scans - // shows per-row progress instead of the whole list going inert. + // shows per-row progress instead of the whole list going inert. A + // network match adopts via `adoptRelease` (owned status), same write the + // browse grid's quick actions use; a Discogs match keeps today's path. AddScannedItem(barcode) -> case model.busy, find_row(model.scan, barcode) { False, Some(model.ScanRow(outcome: model.Matched(result:, ..), ..)) -> #( Model( ..model, busy: True, - scan: update_row(model.scan, barcode, model.Adding(result)), + scan: update_row( + model.scan, + barcode, + model.Adding(model.FromDiscogs(result)), + ), ), effects.add_scanned(barcode, model.form_from_discogs(result)), ) + False, Some(model.ScanRow(outcome: model.NetworkMatched(release:), ..)) + -> #( + Model( + ..model, + busy: True, + scan: update_row( + model.scan, + barcode, + model.Adding(model.FromNetwork(release)), + ), + ), + effects.adopt_scanned(barcode, release.uri, release.cid), + ) _, _ -> #(model, effect.none()) } // A "did you mean" suggestion carries its release directly (a NoMatch row @@ -666,7 +685,11 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { Model( ..model, busy: True, - scan: update_row(model.scan, barcode, model.Adding(release)), + scan: update_row( + model.scan, + barcode, + model.Adding(model.FromDiscogs(release)), + ), ), effects.add_scanned(barcode, model.form_from_discogs(release)), ) @@ -681,8 +704,8 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { ) ScanItemAdded(barcode, Error(e)) -> { let reverted = case find_row(model.scan, barcode) { - Some(model.ScanRow(outcome: model.Adding(result:), ..)) -> - update_row(model.scan, barcode, model.Matched(result:, owned: False)) + Some(model.ScanRow(outcome: model.Adding(item:), ..)) -> + update_row(model.scan, barcode, reverted_outcome(item)) _ -> model.scan } write_error( @@ -852,13 +875,23 @@ fn scan_outcome( lookup: ScanLookup, seen_ids: set.Set(String), ) -> model.ScanOutcome { - case lookup.result { - Some(result) -> + case lookup.network, lookup.result { + // A network hit wins outright: the server never even queried Discogs. + Some(release), _ -> model.NetworkMatched(release) + None, Some(result) -> model.Matched( result:, owned: set.contains(seen_ids, int.to_string(result.discogs_id)), ) - None -> model.NoMatch(lookup.suggestions) + None, None -> model.NoMatch(lookup.suggestions) + } +} + +/// The pre-write outcome a failed add reverts an `Adding` row back to. +fn reverted_outcome(item: model.ScanItem) -> model.ScanOutcome { + case item { + model.FromDiscogs(result) -> model.Matched(result:, owned: False) + model.FromNetwork(release) -> model.NetworkMatched(release) } } diff --git a/web/test/at_record_web_test.gleam b/web/test/at_record_web_test.gleam index 11a982b..6fd1308 100644 --- a/web/test/at_record_web_test.gleam +++ b/web/test/at_record_web_test.gleam @@ -1,10 +1,11 @@ import at_record/gen/defs.{type Snapshot, Price, Snapshot} import at_record_web/model.{ type BrowseRelease, type DiscogsResult, type EditProposal, type InboxCard, - type Model, type ReleaseInfo, Added, Adding, BrowseRelease, Crate, Discogs, - DiscogsResult, EditInbox, EditProposal, Entry, Failure, Form, Grid, ImportRun, - InboxCard, InboxFailed, InboxLoaded, InboxLoading, LoggedIn, LoggedOut, - Matched, Model, NoMatch, Notice, Pending, ProposalApplied, ProposalApplying, + type Model, type NetworkMatch, type ReleaseInfo, Added, Adding, BrowseRelease, + Crate, Discogs, DiscogsResult, EditInbox, EditProposal, Entry, Failure, Form, + FromDiscogs, FromNetwork, Grid, ImportRun, InboxCard, InboxFailed, InboxLoaded, + InboxLoading, LoggedIn, LoggedOut, Matched, Model, NetworkMatch, + NetworkMatched, NoMatch, Notice, Pending, ProposalApplied, ProposalApplying, ProposalFields, ProposalReviewing, ReleaseInfo, Rows, ScanRow, ScanState, ShelfFailed, ShelfLoaded, ShelfLoading, Success, Suggestion, Warning, blank_amend, blank_discogs, blank_form, blank_scan, @@ -553,7 +554,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)), [], None))), ) assert model.scan.rows == [ScanRow(barcode: "b1", outcome: Matched(discogs_result(4577), True))] @@ -570,7 +571,7 @@ pub fn got_scan_result_unowned_match_test() { let #(model, _) = update( seeded, - GotScanResult("b1", Ok(ScanLookup(Some(discogs_result(9)), []))), + GotScanResult("b1", Ok(ScanLookup(Some(discogs_result(9)), [], None))), ) assert model.scan.rows == [ScanRow(barcode: "b1", outcome: Matched(discogs_result(9), False))] @@ -585,7 +586,7 @@ pub fn got_scan_result_no_match_test() { ]), ) let #(model, _) = - update(seeded, GotScanResult("b1", Ok(ScanLookup(None, [])))) + update(seeded, GotScanResult("b1", Ok(ScanLookup(None, [], None)))) assert model.scan.rows == [ScanRow(barcode: "b1", outcome: NoMatch([]))] } @@ -599,7 +600,7 @@ pub fn got_scan_result_no_match_carries_suggestions_test() { ) let suggestions = [Suggestion(release: discogs_result(9), confidence: 62)] let #(model, _) = - update(seeded, GotScanResult("b1", Ok(ScanLookup(None, suggestions)))) + update(seeded, GotScanResult("b1", Ok(ScanLookup(None, suggestions, None)))) assert model.scan.rows == [ScanRow(barcode: "b1", outcome: NoMatch(suggestions))] } @@ -618,7 +619,8 @@ pub fn add_suggestion_transitions_row_to_adding_and_sets_busy_test() { ) let #(model, _) = update(seeded, AddSuggestion("b1", release)) assert model.busy == True - assert model.scan.rows == [ScanRow(barcode: "b1", outcome: Adding(release))] + assert model.scan.rows + == [ScanRow(barcode: "b1", outcome: Adding(FromDiscogs(release)))] } pub fn scan_page_renders_did_you_mean_suggestions_test() { @@ -740,6 +742,90 @@ pub fn got_scan_seen_stores_ids_test() { assert model.scan.seen_ids == set.from_list(["1", "2"]) } +// --- barcode scan: network-first match --- + +fn a_network_match() -> NetworkMatch { + NetworkMatch( + uri: "at://did:plc:pub/dev.mokkenstorm.crate.catalog.release/r1", + cid: "bafyrel", + title: "Spiderland", + artist_display: Some("Slint"), + thumb_url: None, + publisher_handle: Some("alice.test"), + ) +} + +pub fn got_scan_result_network_hit_wins_over_a_discogs_result_test() { + let seeded = + Model( + ..logged_in(), + scan: ScanState(..blank_scan(), rows: [ + ScanRow(barcode: "b1", outcome: Pending), + ]), + ) + let release = a_network_match() + let #(model, _) = + update( + seeded, + GotScanResult( + "b1", + Ok(ScanLookup(Some(discogs_result(9)), [], Some(release))), + ), + ) + assert model.scan.rows + == [ScanRow(barcode: "b1", outcome: NetworkMatched(release))] +} + +pub fn add_scanned_item_on_network_match_dispatches_adopt_and_marks_adding_test() { + let release = a_network_match() + let seeded = + Model( + ..logged_in(), + scan: ScanState(..blank_scan(), rows: [ + ScanRow(barcode: "b1", outcome: NetworkMatched(release)), + ]), + ) + let #(model, effect) = update(seeded, AddScannedItem("b1")) + assert model.busy == True + assert model.scan.rows + == [ScanRow(barcode: "b1", outcome: Adding(FromNetwork(release)))] + // The adopt effect, not the manual shelf.addEntry write. + assert effect != empty_effect() +} + +pub fn scan_item_added_error_reverts_a_network_row_test() { + let release = a_network_match() + let seeded = + Model( + ..logged_in(), + busy: True, + scan: ScanState(..blank_scan(), rows: [ + ScanRow(barcode: "b1", outcome: Adding(FromNetwork(release))), + ]), + ) + let #(model, _) = + update(seeded, ScanItemAdded("b1", Error(rsvp.NetworkError))) + assert model.busy == False + assert model.scan.rows + == [ScanRow(barcode: "b1", outcome: NetworkMatched(release))] +} + +pub fn scan_page_renders_network_match_with_via_handle_test() { + let release = a_network_match() + let model = + Model( + ..logged_in(), + scan: ScanState(..blank_scan(), rows: [ + ScanRow(barcode: "825646350919", outcome: NetworkMatched(release)), + ]), + ) + let html = model |> scan.view |> element.to_string + assert string.contains(html, "Spiderland") + assert string.contains(html, "Slint") + assert string.contains(html, "via @alice.test") + assert string.contains(html, "ADD") +} + // --- record timeline / release info --- fn a_release_info() -> ReleaseInfo {