From ec52d1686fda91a911f31b11236366a9bdccb4cc Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Sat, 11 Jul 2026 17:15:46 +0200 Subject: [PATCH] feat(discogs): artist-name autocomplete + artist-scoped album search (discogs specifics contained in discogs_client behind ArtistHit/search_artists) --- .../crate/discogs/searchArtists.json | 44 +++++++ .../crate/discogs/searchReleases.json | 4 + .../src/at_record_server/discogs_client.gleam | 75 ++++++++++++ .../at_record_server/handlers/discogs.gleam | 47 +++++++- server/src/at_record_server/router.gleam | 2 + server/test/discogs_client_test.gleam | 43 ++++++- web/src/at_record_web/effects.gleam | 59 ++++++++-- web/src/at_record_web/model.gleam | 12 ++ web/src/at_record_web/msg.gleam | 10 +- web/src/at_record_web/pages/add.gleam | 36 +++++- web/src/at_record_web/update.gleam | 109 ++++++++++++++---- web/test/at_record_web_test.gleam | 63 +++++++--- 12 files changed, 449 insertions(+), 55 deletions(-) create mode 100644 lexicons/dev/mokkenstorm/crate/discogs/searchArtists.json diff --git a/lexicons/dev/mokkenstorm/crate/discogs/searchArtists.json b/lexicons/dev/mokkenstorm/crate/discogs/searchArtists.json new file mode 100644 index 0000000..a93f14d --- /dev/null +++ b/lexicons/dev/mokkenstorm/crate/discogs/searchArtists.json @@ -0,0 +1,44 @@ +{ + "lexicon": 1, + "id": "dev.mokkenstorm.crate.discogs.searchArtists", + "defs": { + "main": { + "type": "query", + "description": "Search Discogs for candidate artists to scope a release search or link a crate entry.", + "parameters": { + "type": "params", + "required": ["q"], + "properties": { + "q": { "type": "string" }, + "page": { + "type": "integer", + "description": "1-based page number; defaults to 1." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["results"], + "properties": { + "results": { + "type": "array", + "items": { "type": "ref", "ref": "#artist" } + } + } + } + } + }, + "artist": { + "type": "object", + "description": "A Discogs artist candidate as returned by the Discogs API.", + "required": ["id", "name"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "thumbUrl": { "type": "string", "format": "uri" } + } + } + } +} diff --git a/lexicons/dev/mokkenstorm/crate/discogs/searchReleases.json b/lexicons/dev/mokkenstorm/crate/discogs/searchReleases.json index f7651eb..79c3d5e 100644 --- a/lexicons/dev/mokkenstorm/crate/discogs/searchReleases.json +++ b/lexicons/dev/mokkenstorm/crate/discogs/searchReleases.json @@ -16,6 +16,10 @@ "type": "string", "description": "Optional Discogs format filter (e.g. Vinyl)." }, + "artist": { + "type": "string", + "description": "Optional Discogs artist filter, scopes results via the Discogs artist= param." + }, "page": { "type": "integer", "description": "1-based page number; defaults to 1." diff --git a/server/src/at_record_server/discogs_client.gleam b/server/src/at_record_server/discogs_client.gleam index 2552d85..6fce75d 100644 --- a/server/src/at_record_server/discogs_client.gleam +++ b/server/src/at_record_server/discogs_client.gleam @@ -79,11 +79,19 @@ pub type SearchPage { SearchPage(releases: List(Release), page: Int, pages: Int) } +/// One Discogs artist search hit, normalized for the artist-scoped +/// autocomplete; `id` is the raw Discogs artist id, carried for later +/// catalog.artist linking. +pub type ArtistHit { + ArtistHit(id: String, name: String, thumb_url: Option(String)) +} + pub fn search( send: Sender, auth: Option(Auth), query: String, format: Option(String), + artist: Option(String), page: Int, ) -> Result(SearchPage, String) { let params = [ @@ -96,6 +104,10 @@ pub fn search( Some(f) -> [#("format", f), ..params] None -> params } + let params = case artist { + Some(a) if a != "" -> [#("artist", a), ..params] + _ -> params + } let base = request.new() |> request.set_method(http.Get) @@ -125,6 +137,69 @@ pub fn search( } } +pub fn search_artists( + send: Sender, + auth: Option(Auth), + query: String, + page: Int, +) -> Result(List(ArtistHit), String) { + let params = [ + #("q", query), + #("type", "artist"), + #("per_page", "10"), + #("page", int.to_string(page)), + ] + let base = + request.new() + |> request.set_method(http.Get) + |> request.set_scheme(http.Https) + |> request.set_host(host) + |> request.set_path("/database/search") + |> request.set_query(params) + |> request.set_header("user-agent", user_agent.value) + let req = case auth { + Some(Auth(key, secret)) -> + request.set_header( + base, + "authorization", + "Discogs key=" <> key <> ", secret=" <> secret, + ) + None -> base + } + case send(req) { + Error(e) -> Error(e) + Ok(#(200, body)) -> + json.parse(body, artist_search_decoder()) + |> map_error(fn(_) { "could not parse discogs response" }) + Ok(#(401, _)) -> Error("discogs rejected the credentials") + Ok(#(status, _)) -> + Error("discogs returned status " <> int.to_string(status)) + } +} + +fn artist_search_decoder() -> decode.Decoder(List(ArtistHit)) { + decode.field("results", decode.list(artist_hit_decoder()), decode.success) +} + +fn artist_hit_decoder() -> decode.Decoder(ArtistHit) { + use id <- decode.field("id", decode.int) + use name <- decode.field("title", decode.string) + use thumb <- decode.optional_field("thumb", "", decode.string) + decode.success(ArtistHit( + id: int.to_string(id), + name:, + thumb_url: non_empty(thumb), + )) +} + +pub fn encode_artist_hit(a: ArtistHit) -> json.Json { + json.object([ + #("id", json.string(a.id)), + #("name", json.string(a.name)), + #("thumbUrl", json.nullable(a.thumb_url, json.string)), + ]) +} + /// Look up a release by its barcode (EAN/UPC), one call. Discogs' barcode /// param does the type filtering; no separate `type=release` needed. pub fn search_by_barcode( diff --git a/server/src/at_record_server/handlers/discogs.gleam b/server/src/at_record_server/handlers/discogs.gleam index 9cfe54f..78fef27 100644 --- a/server/src/at_record_server/handlers/discogs.gleam +++ b/server/src/at_record_server/handlers/discogs.gleam @@ -39,7 +39,14 @@ pub fn search(req: Request, ctx: Context) -> Response { let query = wisp.get_query(req) case list.key_find(query, "q") |> result.unwrap("") { "" -> error_json(400, "missing q") - q -> do_search(ctx, q, format_param(query), page_param(query)) + q -> + do_search( + ctx, + q, + format_param(query), + artist_param(query), + page_param(query), + ) } } @@ -50,6 +57,13 @@ fn format_param(query: List(#(String, String))) -> Option(String) { } } +fn artist_param(query: List(#(String, String))) -> Option(String) { + case list.key_find(query, "artist") { + Ok(a) if a != "" -> Some(a) + _ -> None + } +} + fn page_param(query: List(#(String, String))) -> Int { case list.key_find(query, "page") { Ok(p) -> int.parse(p) |> result.unwrap(1) @@ -61,6 +75,7 @@ fn do_search( ctx: Context, query: String, format: Option(String), + artist: Option(String), page: Int, ) -> Response { case @@ -69,6 +84,7 @@ fn do_search( ctx.discogs.auth, query, format, + artist, page, ) { @@ -84,6 +100,34 @@ fn do_search( } } +pub fn search_artists(req: Request, ctx: Context) -> Response { + use _id, _session <- require_session(req, ctx) + let query = wisp.get_query(req) + case list.key_find(query, "q") |> result.unwrap("") { + "" -> error_json(400, "missing q") + q -> do_search_artists(ctx, q, page_param(query)) + } +} + +fn do_search_artists(ctx: Context, query: String, page: Int) -> Response { + case + discogs_client.search_artists( + ctx.discogs.send, + ctx.discogs.auth, + query, + page, + ) + { + Error(_) -> error_json(502, "discogs artist search failed") + Ok(hits) -> + json.object([ + #("results", json.array(hits, discogs_client.encode_artist_hit)), + ]) + |> json.to_string + |> wisp.json_response(200) + } +} + // --- barcode scan lookup --- /// The discogs ids already in the caller's crate, fetched once per scan @@ -253,6 +297,7 @@ fn suggestions_from_hint( ctx.discogs.auth, hint.title <> " " <> hint.artist, None, + None, 1, ) { diff --git a/server/src/at_record_server/router.gleam b/server/src/at_record_server/router.gleam index aaa192b..35a407e 100644 --- a/server/src/at_record_server/router.gleam +++ b/server/src/at_record_server/router.gleam @@ -71,6 +71,8 @@ fn dispatch_xrpc( amend.upload_cover(req, ctx) "dev.mokkenstorm.crate.discogs.searchReleases", Get -> discogs.search(req, ctx) + "dev.mokkenstorm.crate.discogs.searchArtists", Get -> + discogs.search_artists(req, ctx) "dev.mokkenstorm.crate.discogs.resolveBarcode", Get -> discogs.scan(req, ctx) "dev.mokkenstorm.crate.discogs.getScanSeen", Get -> diff --git a/server/test/discogs_client_test.gleam b/server/test/discogs_client_test.gleam index fa63b87..8582797 100644 --- a/server/test/discogs_client_test.gleam +++ b/server/test/discogs_client_test.gleam @@ -1,6 +1,8 @@ -import at_record_server/discogs_client.{Release, SearchPage} +import at_record_server/discogs_client.{ArtistHit, Release, SearchPage} +import gleam/http/request import gleam/list import gleam/option.{None, Some} +import gleam/result import gleam/set const collection_body = "{ @@ -186,3 +188,42 @@ pub fn barcode_variants_dedupes_test() { assert discogs_client.barcode_variants("825 646 350919") == ["825646350919", "0825646350919"] } + +// --- artist search --- + +const artist_search_body = "{\"pagination\":{\"page\":1,\"pages\":1},\"results\":[{\"id\":123,\"title\":\"Aphex Twin\",\"thumb\":\"https://i.discogs.com/a.jpg\"}]}" + +pub fn search_artists_parses_discogs_artist_json_test() { + let send = fn(_req) { Ok(#(200, artist_search_body)) } + let assert Ok([hit]) = discogs_client.search_artists(send, None, "aphex", 1) + assert hit + == ArtistHit( + id: "123", + name: "Aphex Twin", + thumb_url: Some("https://i.discogs.com/a.jpg"), + ) +} + +// --- search: artist param --- + +const empty_release_search_body = "{\"pagination\":{\"page\":1,\"pages\":1},\"results\":[]}" + +pub fn search_includes_artist_param_when_some_test() { + let send = fn(req) { + let query = request.get_query(req) |> result.unwrap([]) + assert list.key_find(query, "artist") == Ok("Slint") + Ok(#(200, empty_release_search_body)) + } + let assert Ok(_) = + discogs_client.search(send, None, "spiderland", None, Some("Slint"), 1) +} + +pub fn search_omits_artist_param_when_none_test() { + let send = fn(req) { + let query = request.get_query(req) |> result.unwrap([]) + assert list.key_find(query, "artist") == Error(Nil) + Ok(#(200, empty_release_search_body)) + } + let assert Ok(_) = + discogs_client.search(send, None, "spiderland", None, None, 1) +} diff --git a/web/src/at_record_web/effects.gleam b/web/src/at_record_web/effects.gleam index 00198f0..40b9f62 100644 --- a/web/src/at_record_web/effects.gleam +++ b/web/src/at_record_web/effects.gleam @@ -12,8 +12,8 @@ import at_record_web/model.{ import at_record_web/money import at_record_web/msg.{ type Msg, AppliedProposal, BarcodeDetected, CameraUnsupported, CoverUploaded, - GotAction, GotAdd, GotAmend, GotApplyProposal, GotAvatar, GotBrowse, - GotBrowseAdd, GotDiscogs, GotDiscogsDisconnect, GotDiscogsImport, + GotAction, GotAdd, GotAmend, GotApplyProposal, GotArtists, GotAvatar, + GotBrowse, GotBrowseAdd, GotDiscogs, GotDiscogsDisconnect, GotDiscogsImport, GotDiscogsStatus, GotEditInbox, GotHandleSuggestions, GotLogout, GotScanResult, GotScanSeen, GotShelf, GotTimeline, ScanItemAdded, ScanLookup, ShelfData, TimelineData, @@ -23,7 +23,7 @@ import gleam/dynamic/decode import gleam/int import gleam/json.{type Json} import gleam/list -import gleam/option.{None} +import gleam/option.{type Option, None, Some} import gleam/result import gleam/set import gleam/string @@ -121,10 +121,34 @@ pub fn oauth_login(handle: String) -> Effect(Msg) { effect.from(fn(_dispatch) { browser.navigate(url) }) } +pub fn discogs_search_url( + query: String, + vinyl_only: Bool, + page: Int, + artist: Option(String), +) -> String { + let format = case vinyl_only { + True -> "&format=Vinyl" + False -> "" + } + let artist_param = case artist { + Some(a) if a != "" -> "&artist=" <> uri.percent_encode(a) + _ -> "" + } + xrpc("discogs.searchReleases") + <> "?q=" + <> uri.percent_encode(query) + <> "&page=" + <> int.to_string(page) + <> format + <> artist_param +} + pub fn discogs_search( query: String, vinyl_only: Bool, page: Int, + artist: Option(String), ) -> Effect(Msg) { let decoder = { use results <- decode.field("results", decode.list(discogs_decoder())) @@ -132,18 +156,33 @@ pub fn discogs_search( use pages <- decode.field("pages", decode.int) decode.success(DiscogsSearchPage(results:, page:, pages:)) } - let format = case vinyl_only { - True -> "&format=Vinyl" - False -> "" - } + rsvp.get( + discogs_search_url(query, vinyl_only, page, artist), + rsvp.expect_json(decoder, GotDiscogs), + ) +} + +pub fn discogs_search_artists(query: String, page: Int) -> Effect(Msg) { + let decoder = + decode.field("results", decode.list(artist_decoder()), decode.success) let url = - xrpc("discogs.searchReleases") + xrpc("discogs.searchArtists") <> "?q=" <> uri.percent_encode(query) <> "&page=" <> int.to_string(page) - <> format - rsvp.get(url, rsvp.expect_json(decoder, GotDiscogs)) + rsvp.get(url, rsvp.expect_json(decoder, GotArtists)) +} + +fn artist_decoder() -> decode.Decoder(model.ArtistHit) { + use id <- decode.field("id", decode.string) + use name <- decode.field("name", decode.string) + use thumb_url <- decode.optional_field( + "thumbUrl", + None, + decode.optional(decode.string), + ) + decode.success(model.ArtistHit(id:, name:, thumb_url:)) } pub fn handle_search(query: String) -> Effect(Msg) { diff --git a/web/src/at_record_web/model.gleam b/web/src/at_record_web/model.gleam index d5dbc02..e7dc859 100644 --- a/web/src/at_record_web/model.gleam +++ b/web/src/at_record_web/model.gleam @@ -152,6 +152,12 @@ pub type DiscogsSearchPage { DiscogsSearchPage(results: List(DiscogsResult), page: Int, pages: Int) } +/// A Discogs artist search hit for the artist-scoped autocomplete; `id` is +/// the raw Discogs artist id, carried for later catalog.artist linking. +pub type ArtistHit { + ArtistHit(id: String, name: String, thumb_url: Option(String)) +} + /// Display-relevant fields off the entry's resolved catalog release. pub type ReleaseInfo { ReleaseInfo( @@ -397,6 +403,9 @@ pub type Discogs { pages: Int, username: Option(String), importing: Bool, + artist_query: String, + artist_results: List(ArtistHit), + artist_id: Option(String), ) } @@ -410,5 +419,8 @@ pub fn blank_discogs() -> Discogs { pages: 0, username: None, importing: False, + artist_query: "", + artist_results: [], + artist_id: None, ) } diff --git a/web/src/at_record_web/msg.gleam b/web/src/at_record_web/msg.gleam index 10b7d8e..bf6db9c 100644 --- a/web/src/at_record_web/msg.gleam +++ b/web/src/at_record_web/msg.gleam @@ -1,8 +1,9 @@ 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 NetworkMatch, type ReleaseInfo, type Route, type Suggestion, + type ArtistHit, type BrowseRelease, type DiscogsResult, type DiscogsSearchPage, + type Display, type EditProposal, type Entry, type HandleSuggestion, + type ImportRun, type NetworkMatch, type ReleaseInfo, type Route, + type Suggestion, } import gleam/dict import gleam/option.{type Option} @@ -83,6 +84,9 @@ pub type Msg { DiscogsLoadMore GotDiscogs(Result(DiscogsSearchPage, rsvp.Error(String))) UseDiscogs(DiscogsResult) + ArtistSearch + GotArtists(Result(List(ArtistHit), rsvp.Error(String))) + UseArtist(ArtistHit) GotDiscogsStatus(Result(Option(String), rsvp.Error(String))) DiscogsConnect DiscogsDisconnect diff --git a/web/src/at_record_web/pages/add.gleam b/web/src/at_record_web/pages/add.gleam index 95345fb..2733108 100644 --- a/web/src/at_record_web/pages/add.gleam +++ b/web/src/at_record_web/pages/add.gleam @@ -1,12 +1,14 @@ //// The Add page: the title field autocompletes from Discogs (picking a //// suggestion fills the whole form), plus the account connect/import card. -import at_record_web/model.{type DiscogsResult, type Form, type Model} +import at_record_web/model.{ + type ArtistHit, type DiscogsResult, type Form, type Model, +} import at_record_web/msg.{ type Msg, DiscogsConnect, DiscogsDisconnect, DiscogsImport, DiscogsImportWantlist, FormArtist, FormCounterparty, FormFolder, FormFormat, FormPriceAmount, FormPriceCurrency, FormRating, FormSleeveGrade, FormStatus, - FormTitle, FormYear, SubmitAdd, UseDiscogs, + FormTitle, FormYear, SubmitAdd, UseArtist, UseDiscogs, } import at_record_web/ui/components as c import gleam/int @@ -30,7 +32,7 @@ fn add_form_view(model: Model) -> Element(Msg) { c.card([ c.section_label("ADD A RECORD"), title_autocomplete(model), - c.field("ARTIST", "Slint", form.artist, FormArtist, "text"), + artist_autocomplete(model), html.div([attr.class("field-row")], [ c.field("FORMAT", "LP", form.format, FormFormat, "text"), c.field("YEAR", "1991", form.year, FormYear, "number"), @@ -142,6 +144,34 @@ fn suggestion_row(r: DiscogsResult) -> Element(Msg) { ]) } +/// Type an artist name, get Discogs suggestions; picking one locks the +/// artist filter used to scope the title search (typing without picking +/// still free-types the name into the form). +fn artist_autocomplete(model: Model) -> Element(Msg) { + html.label([attr.class("field autocomplete")], [ + html.span([], [text("ARTIST")]), + html.input([ + attr.type_("text"), + attr.placeholder("Slint (searches Discogs)"), + attr.value(model.form.artist), + attr.autocomplete("off"), + event.on_input(FormArtist), + ]), + c.suggestions(model.discogs.artist_results, artist_suggestion_row), + ]) +} + +fn artist_suggestion_row(a: ArtistHit) -> Element(Msg) { + html.li([], [ + html.button([attr.type_("button"), event.on_click(UseArtist(a))], [ + c.thumb(a.thumb_url), + html.div([attr.class("suggestion__main")], [ + html.strong([], [text(a.name)]), + ]), + ]), + ]) +} + fn discogs_account_view(model: Model) -> Element(Msg) { c.card([ c.section_label("DISCOGS ACCOUNT"), diff --git a/web/src/at_record_web/update.gleam b/web/src/at_record_web/update.gleam index 671b725..e3b1e80 100644 --- a/web/src/at_record_web/update.gleam +++ b/web/src/at_record_web/update.gleam @@ -14,20 +14,20 @@ import at_record_web/model.{ import at_record_web/money import at_record_web/msg.{ type Msg, type ScanLookup, AddScannedItem, AddSuggestion, AmendField, - AppliedProposal, ApplyProposal, ArmRemove, BarcodeDetected, BrowseAdd, - CameraUnsupported, ClearNotice, CoverFileChosen, CoverUploaded, DiscogsConnect, - DiscogsDisconnect, DiscogsImport, DiscogsImportWantlist, DiscogsLoadMore, - DiscogsSearch, DiscogsVinylOnly, EntryAction, FormArtist, FormCounterparty, - FormFolder, FormFormat, FormPriceAmount, FormPriceCurrency, FormRating, - FormSleeveGrade, FormStatus, FormTitle, FormYear, GotAction, GotAdd, GotAmend, - GotApplyProposal, GotAvatar, GotBrowse, GotBrowseAdd, GotDiscogs, - GotDiscogsDisconnect, GotDiscogsImport, GotDiscogsStatus, GotEditInbox, - GotHandleSuggestions, GotLogout, GotScanResult, GotScanSeen, GotShelf, - GotTimeline, HandleChanged, HandleSearch, IgnoreProposal, Logout, - OnRouteChange, Rate, Regrade, RemoveScanRow, RetryShelf, ScanItemAdded, - SetDisplay, SetView, StartLogin, SubmitAdd, SubmitAmend, ToggleAmend, - ToggleAmendCover, ToggleEdit, ToggleEntry, ToggleMenu, UseDiscogs, - UseHandleSuggestion, + AppliedProposal, ApplyProposal, ArmRemove, ArtistSearch, BarcodeDetected, + BrowseAdd, CameraUnsupported, ClearNotice, CoverFileChosen, CoverUploaded, + DiscogsConnect, DiscogsDisconnect, DiscogsImport, DiscogsImportWantlist, + DiscogsLoadMore, DiscogsSearch, DiscogsVinylOnly, EntryAction, FormArtist, + FormCounterparty, FormFolder, FormFormat, FormPriceAmount, FormPriceCurrency, + FormRating, FormSleeveGrade, FormStatus, FormTitle, FormYear, GotAction, + GotAdd, GotAmend, GotApplyProposal, GotArtists, GotAvatar, GotBrowse, + GotBrowseAdd, GotDiscogs, GotDiscogsDisconnect, GotDiscogsImport, + GotDiscogsStatus, GotEditInbox, GotHandleSuggestions, GotLogout, GotScanResult, + GotScanSeen, GotShelf, GotTimeline, HandleChanged, HandleSearch, + IgnoreProposal, Logout, OnRouteChange, Rate, Regrade, RemoveScanRow, + RetryShelf, ScanItemAdded, SetDisplay, SetView, StartLogin, SubmitAdd, + SubmitAmend, ToggleAmend, ToggleAmendCover, ToggleEdit, ToggleEntry, + ToggleMenu, UseArtist, UseDiscogs, UseHandleSuggestion, } import gleam/bool import gleam/int @@ -231,10 +231,30 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { ) } } - FormArtist(value) -> #( - Model(..model, form: Form(..model.form, artist: value)), - effect.none(), - ) + // Free typing always clears any previously picked artist_id - the filter + // only re-locks when a suggestion is picked again via UseArtist. + FormArtist(value) -> { + let updated = + Model( + ..model, + form: Form(..model.form, artist: value), + discogs: Discogs( + ..model.discogs, + artist_query: value, + artist_id: None, + ), + ) + case string.length(string.trim(value)) >= 3 { + True -> #(updated, effects.debounce("artist-search", 350, ArtistSearch)) + False -> #( + Model( + ..updated, + discogs: Discogs(..updated.discogs, artist_results: []), + ), + effect.none(), + ) + } + } FormFormat(value) -> #( Model(..model, form: Form(..model.form, format: value)), effect.none(), @@ -450,6 +470,26 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { ), effect.none(), ) + ArtistSearch -> run_artist_search(model, 1) + GotArtists(Ok(hits)) -> #( + Model(..model, discogs: Discogs(..model.discogs, artist_results: hits)), + effect.none(), + ) + // Transient failure: leave the typed artist name as-is, just no suggestions. + GotArtists(Error(_)) -> #(model, effect.none()) + UseArtist(hit) -> #( + Model( + ..model, + form: Form(..model.form, artist: hit.name), + discogs: Discogs( + ..model.discogs, + artist_query: hit.name, + artist_id: Some(hit.id), + artist_results: [], + ), + ), + effect.none(), + ) GotDiscogsStatus(Ok(username)) -> #( Model(..model, discogs: Discogs(..model.discogs, username:)), effect.none(), @@ -592,7 +632,12 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { UseDiscogs(result) -> #( Model( ..model, - discogs: Discogs(..model.discogs, results: []), + discogs: Discogs( + ..model.discogs, + results: [], + artist_id: None, + artist_results: [], + ), form: model.form_from_discogs(result), ), effect.none(), @@ -924,8 +969,8 @@ fn leaving_scan(model: Model) -> Effect(Msg) { } } -/// Search Discogs for a page with the current query + vinyl filter, or no-op on -/// an empty query. +/// Search Discogs for a page with the current query + vinyl filter, scoped by +/// the typed/picked artist, or no-op on an empty query. fn run_discogs_search(model: Model, page: Int) -> #(Model, Effect(Msg)) { case string.trim(model.discogs.query) { "" -> #(model, effect.none()) @@ -935,11 +980,31 @@ fn run_discogs_search(model: Model, page: Int) -> #(Model, Effect(Msg)) { discogs: Discogs(..model.discogs, searching: True), notice: None, ), - discogs_search(query, model.discogs.vinyl_only, page), + discogs_search( + query, + model.discogs.vinyl_only, + page, + artist_filter(model.form), + ), ) } } +fn artist_filter(form: model.Form) -> Option(String) { + case string.trim(form.artist) { + "" -> None + trimmed -> Some(trimmed) + } +} + +/// Search Discogs artists for the current typed query, or no-op on an empty one. +fn run_artist_search(model: Model, page: Int) -> #(Model, Effect(Msg)) { + case string.trim(model.discogs.artist_query) { + "" -> #(model, effect.none()) + query -> #(model, effects.discogs_search_artists(query, page)) + } +} + fn import_summary(run: model.ImportRun) -> String { let updated = case run.updated { 0 -> "" diff --git a/web/test/at_record_web_test.gleam b/web/test/at_record_web_test.gleam index 6fd1308..a351f86 100644 --- a/web/test/at_record_web_test.gleam +++ b/web/test/at_record_web_test.gleam @@ -1,25 +1,26 @@ import at_record/gen/defs.{type Snapshot, Price, Snapshot} +import at_record_web/effects import at_record_web/model.{ type BrowseRelease, type DiscogsResult, type EditProposal, type InboxCard, - 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, + type Model, type NetworkMatch, type ReleaseInfo, Added, Adding, ArtistHit, + 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, } import at_record_web/msg.{ AddScannedItem, AddSuggestion, AppliedProposal, ApplyProposal, ArmRemove, BarcodeDetected, BrowseAdd, ClearNotice, DiscogsImportWantlist, EntryAction, - FormCounterparty, FormFolder, FormPriceAmount, FormPriceCurrency, FormRating, - FormSleeveGrade, FormTitle, GotAdd, GotApplyProposal, GotAvatar, GotBrowse, - GotBrowseAdd, GotDiscogsImport, GotDiscogsStatus, GotEditInbox, GotLogout, - GotScanResult, GotScanSeen, GotShelf, GotTimeline, HandleChanged, - IgnoreProposal, Logout, OnRouteChange, RemoveScanRow, RetryShelf, - ScanItemAdded, ScanLookup, SetDisplay, SetView, ShelfData, SubmitAdd, - TimelineData, + FormArtist, FormCounterparty, FormFolder, FormPriceAmount, FormPriceCurrency, + FormRating, FormSleeveGrade, FormTitle, GotAdd, GotApplyProposal, GotArtists, + GotAvatar, GotBrowse, GotBrowseAdd, GotDiscogsImport, GotDiscogsStatus, + GotEditInbox, GotLogout, GotScanResult, GotScanSeen, GotShelf, GotTimeline, + HandleChanged, IgnoreProposal, Logout, OnRouteChange, RemoveScanRow, + RetryShelf, ScanItemAdded, ScanLookup, SetDisplay, SetView, ShelfData, + SubmitAdd, TimelineData, UseArtist, } import at_record_web/pages/crate import at_record_web/pages/edit_inbox @@ -449,6 +450,38 @@ pub fn got_avatar_stores_it_and_logout_clears_it_test() { assert after_logout.avatar == None } +pub fn form_artist_debounces_and_clears_id_test() { + let seeded = + Model(..base(), discogs: Discogs(..blank_discogs(), artist_id: Some("123"))) + let #(model, effect) = update(seeded, FormArtist("Slint")) + assert model.form.artist == "Slint" + assert model.discogs.artist_id == None + assert effect != empty_effect() +} + +pub fn use_artist_sets_name_and_id_test() { + let hit = ArtistHit(id: "42", name: "Slint", thumb_url: None) + let #(model, _effect) = update(base(), UseArtist(hit)) + assert model.form.artist == "Slint" + assert model.discogs.artist_id == Some("42") +} + +pub fn got_artists_populates_suggestions_test() { + let hit = ArtistHit(id: "42", name: "Slint", thumb_url: None) + let #(model, _effect) = update(base(), GotArtists(Ok([hit]))) + assert model.discogs.artist_results == [hit] +} + +pub fn discogs_search_url_includes_artist_when_present_test() { + let url = effects.discogs_search_url("spiderland", True, 1, Some("Slint")) + assert string.contains(url, "artist=Slint") +} + +pub fn discogs_search_url_omits_artist_when_absent_test() { + let url = effects.discogs_search_url("spiderland", True, 1, None) + assert !string.contains(url, "artist=") +} + pub fn discogs_status_sets_username_test() { let #(model, _) = update(logged_in(), GotDiscogsStatus(Ok(Some("crate-digger")))) -- 2.51.2