From 8ddfbda53bb14450a91bef6bc29bc9f373e46b62 Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Thu, 16 Jul 2026 21:56:36 +0200 Subject: [PATCH] feat(server): replace naive browse dedup with variant-set resolution --- server/src/at_record_server/browse.gleam | 65 ++++++++++++------- .../at_record_server/handlers/browse.gleam | 40 ++++++++---- 2 files changed, 69 insertions(+), 36 deletions(-) diff --git a/server/src/at_record_server/browse.gleam b/server/src/at_record_server/browse.gleam index db75d4a..acaf6ca 100644 --- a/server/src/at_record_server/browse.gleam +++ b/server/src/at_record_server/browse.gleam @@ -1,11 +1,16 @@ //// The browse domain: a live `listRecords` fan-out over every repo the server -//// has seen log in, merged and deduped by Discogs id, annotated with the +//// has seen log in, resolved to one winner per variant set (see +//// `catalog/variants`/`catalog/stats`/`catalog/strategy`), annotated with the //// viewer's owned/wanted state. The HTTP glue lives in `handlers/browse`. import at_record/gen/catalog/release as catalog_release import at_record/gen/client as generated_client import at_record/gen/shelf/entry.{type ShelfEntry} import at_record/storage.{type StoredItem} +import at_record_server/catalog/source.{type VariantRow} +import at_record_server/catalog/stats.{type VariantStats} +import at_record_server/catalog/strategy.{type Strategy} +import at_record_server/catalog/variants import at_record_server/crate import at_record_server/external_id import at_record_server/known_users.{type KnownUser} @@ -62,21 +67,35 @@ pub fn empty_ownership() -> Ownership { Ownership(set.new(), set.new(), set.new(), set.new()) } -/// Keep the first occurrence of each Discogs id; rows without one are all kept. -pub fn dedup(rows: List(BrowseRow)) -> List(BrowseRow) { - let #(_seen, kept) = - list.fold(rows, #(set.new(), []), fn(acc, row) { - let #(seen, kept) = acc - case row.discogs_id { - None -> #(seen, [row, ..kept]) - Some(id) -> - case set.contains(seen, id) { - True -> acc - False -> #(set.insert(seen, id), [row, ..kept]) - } - } - }) - list.reverse(kept) +/// One variant set's resolved winner, tagged with its own public stats and +/// how many variants it was picked from. +pub type ResolvedRelease { + ResolvedRelease(row: VariantRow, stats: VariantStats, variant_count: Int) +} + +/// Group candidate rows into variant sets (shared Discogs id or a +/// supersedes/basedOn chain edge) and resolve one winner per set under +/// `chosen`. A set only drops out if resolution somehow returns no winner, +/// which `variants.group` never hands it (every set is non-empty). +pub fn resolve_variants( + rows: List(VariantRow), + adoption_count: fn(String) -> Int, + chosen: Strategy, +) -> List(ResolvedRelease) { + rows + |> variants.group + |> list.filter_map(fn(variant_set) { + let all_stats = stats.compute_all(variant_set, adoption_count) + use winner <- result.try( + strategy.resolve(variant_set, all_stats, chosen) |> option.to_result(Nil), + ) + use winner_stats <- result.try(dict.get(all_stats, winner.uri)) + Ok(ResolvedRelease( + row: winner, + stats: winner_stats, + variant_count: list.length(variant_set), + )) + }) } /// The viewer's owned/wanted state, keyed by both release uri and Discogs id so @@ -109,14 +128,16 @@ pub fn ownership(stored: List(StoredItem(ShelfEntry))) -> Ownership { ) } -/// The #(owned, wanted) flags for a row against the viewer's crate. -pub fn flags(own: Ownership, row: BrowseRow) -> #(Bool, Bool) { +/// The #(owned, wanted) flags for a release against the viewer's crate. +pub fn flags( + own: Ownership, + uri: String, + discogs_id: Option(String), +) -> #(Bool, Bool) { let owned = - set.contains(own.owned_uris, row.uri) - || matches_id(own.owned_ids, row.discogs_id) + set.contains(own.owned_uris, uri) || matches_id(own.owned_ids, discogs_id) let wanted = - set.contains(own.wanted_uris, row.uri) - || matches_id(own.wanted_ids, row.discogs_id) + set.contains(own.wanted_uris, uri) || matches_id(own.wanted_ids, discogs_id) #(owned, wanted) } diff --git a/server/src/at_record_server/handlers/browse.gleam b/server/src/at_record_server/handlers/browse.gleam index 188bc01..7f7d6d1 100644 --- a/server/src/at_record_server/handlers/browse.gleam +++ b/server/src/at_record_server/handlers/browse.gleam @@ -1,12 +1,15 @@ -//// HTTP glue for cross-user browsing: the `catalog_index` read (filtered by -//// `q`/`genre`) and the add-from-browse write, which references the known -//// canonical release and skips the manual path's adopt-or-mint discovery. +//// HTTP glue for cross-user browsing: the `variant_source` read (filtered by +//// `q`/`genre`, resolved to one winner per variant set by `strategy`) and +//// the add-from-browse write, which references the known canonical release +//// and skips the manual path's adopt-or-mint discovery. import at_record/gen/catalog/release as catalog_release import at_record/gen/defs import at_record/gen/repo/strong_ref.{RepoStrongRef} import at_record/gen/shelf/entry.{ShelfEntry} import at_record_server/browse as browse_domain +import at_record_server/catalog/source.{type VariantRow} +import at_record_server/catalog/strategy as resolution_strategy import at_record_server/context.{ type Context, error_json, require_session, with_pds_client, } @@ -23,6 +26,7 @@ import gleam/dynamic/decode import gleam/json import gleam/list import gleam/option.{None, Some} +import gleam/result import gleam/string import wisp.{type Request, type Response} @@ -34,12 +38,17 @@ pub fn browse(req: Request, ctx: Context) -> Response { Error(_) -> browse_domain.empty_ownership() } let query = wisp.get_query(req) + let chosen = + query + |> list.key_find("strategy") + |> result.try(resolution_strategy.parse) + |> result.unwrap(resolution_strategy.default_strategy()) let rows = - ctx.catalog_index.list() + ctx.variant_source.releases() |> filter_query(list.key_find(query, "q")) |> filter_genre(list.key_find(query, "genre")) - |> list.sort(fn(a, b) { string.compare(b.created_at, a.created_at) }) - |> browse_domain.dedup + |> browse_domain.resolve_variants(ctx.variant_source.adoption_count, chosen) + |> list.sort(fn(a, b) { string.compare(b.row.created_at, a.row.created_at) }) |> list.take(100) json.object([#("releases", json.array(rows, encode_row(own, _)))]) |> json.to_string @@ -47,16 +56,16 @@ pub fn browse(req: Request, ctx: Context) -> Response { } fn filter_query( - rows: List(browse_domain.BrowseRow), + rows: List(VariantRow), q: Result(String, Nil), -) -> List(browse_domain.BrowseRow) { +) -> List(VariantRow) { case q { Ok(q) if q != "" -> list.filter(rows, matches_query(_, q)) _ -> rows } } -fn matches_query(row: browse_domain.BrowseRow, q: String) -> Bool { +fn matches_query(row: VariantRow, q: String) -> Bool { let needle = string.lowercase(q) string.contains(string.lowercase(row.title), needle) || case row.artist_display { @@ -66,25 +75,26 @@ fn matches_query(row: browse_domain.BrowseRow, q: String) -> Bool { } fn filter_genre( - rows: List(browse_domain.BrowseRow), + rows: List(VariantRow), genre: Result(String, Nil), -) -> List(browse_domain.BrowseRow) { +) -> List(VariantRow) { case genre { Ok(genre) if genre != "" -> list.filter(rows, matches_genre(_, genre)) _ -> rows } } -fn matches_genre(row: browse_domain.BrowseRow, genre: String) -> Bool { +fn matches_genre(row: VariantRow, genre: String) -> Bool { let needle = string.lowercase(genre) list.any(row.genres, fn(g) { string.contains(string.lowercase(g), needle) }) } pub fn encode_row( own: browse_domain.Ownership, - row: browse_domain.BrowseRow, + resolved: browse_domain.ResolvedRelease, ) -> json.Json { - let #(owned, wanted) = browse_domain.flags(own, row) + let row = resolved.row + let #(owned, wanted) = browse_domain.flags(own, row.uri, row.discogs_id) let cover_url = row.cover |> option.map(fn(b) { cover_proxy.url(row.publisher_did, b.cid) }) @@ -100,6 +110,8 @@ pub fn encode_row( #("publisherHandle", json.string(row.publisher_handle)), #("owned", json.bool(owned)), #("wanted", json.bool(wanted)), + #("adoptionCount", json.int(resolved.stats.adoption)), + #("variantCount", json.int(resolved.variant_count)), ], crate.opt("artistDisplay", row.artist_display, json.string), crate.opt("coverUrl", cover_url, json.string), -- 2.51.2