From 9118c16fe31aa1b58685adbe1b23626d662f7315 Mon Sep 17 00:00:00 2001 From: Logan Date: Wed, 19 Aug 2026 02:33:36 -0400 Subject: [PATCH] feat(autotag): answer whether an album has a confident match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MatchForAlbum(albumID)` is the question the album detail page needs to ask on open: does the autotagger already have something confident to say about this album, and what would applying it do. **It costs no MusicBrainz request.** Everything it needs is on disk — `tagging_items` carries the top score and release from the background prefetch, `tagging_candidates` durably holds the scored list. The rate limiters here are shared with every page the user can open, so a lookup that fires on page load must not join that queue; a folder nobody has scored yet answers "nothing", rather than scoring it now. **The tier is computed, not read.** `tagging_items.score` is the raw number and `Recommend` is what turns it into a claim, capping it for an ambiguous runner-up, an incomplete alignment or a folder too small to corroborate itself. Filtering on the stored score would promise confidence the scorer had explicitly withheld — which the two-track test pins. **Nothing is said about an album the user has already answered for.** Only a `pending` group qualifies: `confirmed` covers both a finished apply and an explicit "leave as is", and arguing with the second would be actively wrong. The join is `audio_files.group_key`, not a key derived from the folder path, because a group carved out of a mixed-bag folder is keyed on its tags — so a path-derived key would find nothing for exactly the messiest libraries this helps. `GroupCount` is returned because a multi-disc album is one group per disc: a caller that applied to "the album" from a single button would retag one disc of three. --- backend/autotagservice/albummatch.go | 145 ++++++++ backend/autotagservice/albummatch_test.go | 320 ++++++++++++++++++ .../database/sql/queries/tagging_items.sql | 32 ++ .../database/sql/sqlcgen/tagging_items.sql.go | 76 +++++ .../backend/autotagservice/index.ts | 1 + .../backend/autotagservice/models.ts | 55 +++ .../backend/autotagservice/service.ts | 33 ++ 7 files changed, 662 insertions(+) create mode 100644 backend/autotagservice/albummatch.go create mode 100644 backend/autotagservice/albummatch_test.go diff --git a/backend/autotagservice/albummatch.go b/backend/autotagservice/albummatch.go new file mode 100644 index 0000000..f60eab1 --- /dev/null +++ b/backend/autotagservice/albummatch.go @@ -0,0 +1,145 @@ +package autotagservice + +import ( + "database/sql" + "fmt" + + "yellowjacket/backend/autotag" +) + +// AlbumMatchView is "the autotagger already has a confident match for +// the album you are looking at". +// +// It is deliberately not a score. The album page renders a suggestion, +// and a suggestion has to be actionable: which release, what it is +// called, and whether acting on it here would do the whole album or +// only part of it. +type AlbumMatchView struct { + // GroupKey is the tagging group the actions operate on. + GroupKey string `json:"groupKey"` + + // Recommendation is the tier, as a string, for a caller that + // wants to render the strength rather than trust the filter. + Recommendation string `json:"recommendation"` + + // Score is the top candidate's raw score, 0..1. + Score float64 `json:"score"` + + // ReleaseMBID is the release Apply would write. + ReleaseMBID string `json:"releaseMbid"` + + // Title and ArtistCredit name that release, so the banner can say + // what it is offering rather than "a match". + Title string `json:"title"` + ArtistCredit string `json:"artistCredit"` + + // TrackCount is the group's local track count. + TrackCount int64 `json:"trackCount"` + + // GroupCount is how many tagging groups this album spans. + // + // More than one means a multi-disc album (one group per disc), and + // it is the reason this is a field rather than an implementation + // detail: applying "the album" from a single button would retag + // one disc of three and leave the folder holding a mix of old and + // new tags. The caller offers review instead. + GroupCount int `json:"groupCount"` +} + +// MatchForAlbum answers "does the autotagger have something confident +// to say about this album", for the album detail page. +// +// Three things about it are load-bearing. +// +// **It costs no MusicBrainz request.** Everything it needs is already +// on disk: `tagging_items` carries the top score and release from the +// background prefetch, and `tagging_candidates` durably holds the +// scored list. The rate limiters here are shared with every page the +// user can open, so a lookup that fires on page load must not join +// that queue — which also means this returns nothing for a folder +// nobody has scored yet, rather than scoring it now. That is the +// right trade: the prefetch will get to it, and a page that silently +// spends a minute of somebody's MusicBrainz budget to draw a banner +// is worse than a page that says nothing. +// +// **The tier is computed, not read.** `tagging_items.score` is the raw +// number and `Recommend` is what turns it into a claim — capping it +// for an ambiguous runner-up, an incomplete alignment or a folder too +// small to corroborate itself. Filtering on the raw score would +// promise confidence the scorer had explicitly withheld. +// +// **Nothing is said about an album the user has already answered +// for.** Only a `pending` group qualifies: `confirmed` covers both a +// finished apply and an explicit "leave as is", and `skipped` is the +// user saying not now. Re-offering either is nagging, and "leave as +// is" would be actively wrong to argue with. +func (s *Service) MatchForAlbum(albumID int64) (*AlbumMatchView, error) { + if albumID <= 0 { + return nil, nil //nolint:nilnil // "no album" is not an error. + } + + rows, err := s.db.Queries.GetTaggingItemsForAlbum( + s.ctx, sql.NullInt64{Int64: albumID, Valid: true}, + ) + if err != nil { + return nil, fmt.Errorf("tagging items for album: %w", err) + } + + pending := rows[:0:0] + + for _, row := range rows { + if row.Status == "pending" { + pending = append(pending, row) + } + } + + if len(pending) == 0 { + return nil, nil //nolint:nilnil // nothing to say is not an error. + } + + // Rows arrive best-score-first, so the first pending one is the + // group worth describing. On a multi-disc album that is one disc + // of several and GroupCount says so. + best := pending[0] + + cands := s.lookupCachedCandidates(best.GroupKey) + if len(cands) == 0 { + return nil, nil //nolint:nilnil // not scored yet; see the doc comment. + } + + locals, err := s.scorer.LocalTracksForGroup(s.ctx, best.GroupKey) + if err != nil { + return nil, fmt.Errorf("local tracks for group: %w", err) + } + + group := autotag.Group{ + AlbumName: best.AlbumName, + AlbumArtist: best.AlbumArtist, + Tracks: locals, + Synthetic: best.Synthetic != 0, + } + + rec := autotag.Recommend(group, cands) + if !autotag.Confident(rec) { + return nil, nil //nolint:nilnil // not confident enough to interrupt. + } + + top := cands[0] + + // The release the banner names must be the release Apply would + // write. Apply with an empty MBID takes the top cached candidate, + // which is what this reads — but it is passed explicitly anyway, + // so a rescore between the page rendering and the user clicking + // cannot swap the album out from under a button they have already + // read. + return &AlbumMatchView{ + GroupKey: best.GroupKey, + Recommendation: string(rec), + Score: top.Score, + ReleaseMBID: top.ReleaseMBID, + Title: top.Title, + ArtistCredit: top.ArtistCredit, + TrackCount: best.TrackCount, + GroupCount: len(pending), + }, nil +} diff --git a/backend/autotagservice/albummatch_test.go b/backend/autotagservice/albummatch_test.go new file mode 100644 index 0000000..db6580e --- /dev/null +++ b/backend/autotagservice/albummatch_test.go @@ -0,0 +1,320 @@ +package autotagservice + +import ( + "encoding/json" + "testing" + + "yellowjacket/backend/autotag" + "yellowjacket/backend/database" +) + +// seedAlbumGroup writes one album's files, its tagging item and the +// durable candidate blob the prefetch would have left behind. +// +// The candidate list is what a real one looks like in the two ways +// that decide the tier: a per-track alignment for every local track, +// and a runner-up far enough away not to count as ambiguity. +func seedAlbumGroup( + t *testing.T, + db *database.DB, + groupKey string, + tracks int, + status string, + score float64, +) int64 { + t.Helper() + + for i := 1; i <= tracks; i++ { + database.InsertTestTrack(t, db, database.TestTrack{ + FilePath: filePathFor(groupKey, i), + Title: titleFor(i), + Artist: "Tideline", + Album: "Glass Harbour", + AlbumArtist: "Tideline", + TrackNumber: int64(i), + LengthMs: 200000, + LibraryID: 0, + GroupKey: groupKey, + }) + } + + if _, err := db.ExecContext(` + INSERT INTO tagging_items + (group_key, library_id, track_count, album_name, album_artist, + disc_number, status, score, best_match_release_mbid) + VALUES (?, 0, ?, 'Glass Harbour', 'Tideline', 0, ?, ?, 'rel-1') + `, groupKey, tracks, status, score); err != nil { + t.Fatalf("insert tagging item: %v", err) + } + + var albumID int64 + if err := db.QueryRowWriter( + `SELECT album_id FROM audio_files WHERE group_key = ? LIMIT 1`, groupKey, + ).Scan(&albumID); err != nil { + t.Fatalf("read album id: %v", err) + } + + return albumID +} + +func filePathFor(groupKey string, n int) string { + return "/music/" + groupKey + "/0" + string(rune('0'+n)) + ".mp3" +} + +func titleFor(n int) string { + return "Track " + string(rune('0'+n)) +} + +// storeCandidates writes the durable blob GetCandidates would have +// cached, with `top` as the winning score. +func storeCandidates( + t *testing.T, db *database.DB, groupKey string, tracks int, top float64, +) { + t.Helper() + + aligns := make([]autotag.TrackAlignment, 0, tracks) + for i := range tracks { + aligns = append(aligns, autotag.TrackAlignment{ + Status: autotag.AlignmentMatched, + LocalIndex: i, + }) + } + + cands := []autotag.Candidate{ + { + ReleaseMBID: "rel-1", + ReleaseGroupMBID: "rg-1", + Title: "Glass Harbour", + ArtistCredit: "Tideline", + TrackCount: tracks, + Alignments: aligns, + Score: top, + }, + { + ReleaseMBID: "rel-2", + ReleaseGroupMBID: "rg-2", + Title: "Something Else", + ArtistCredit: "Another Band", + TrackCount: tracks, + Score: 0.40, + }, + } + + blob, err := json.Marshal(cands) + if err != nil { + t.Fatalf("marshal candidates: %v", err) + } + + if _, err := db.ExecContext( + `INSERT INTO tagging_candidates (group_key, candidates) VALUES (?, ?)`, + groupKey, string(blob), + ); err != nil { + t.Fatalf("insert candidates: %v", err) + } +} + +// A confident match is what the album page exists to surface. +func TestMatchForAlbumSurfacesAConfidentMatch(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + svc := newTestService(t, db) + + albumID := seedAlbumGroup(t, db, "grp-1", 8, "pending", 0.95) + storeCandidates(t, db, "grp-1", 8, 0.95) + + got, err := svc.MatchForAlbum(albumID) + if err != nil { + t.Fatalf("MatchForAlbum: %v", err) + } + + if got == nil { + t.Fatal("no match returned for a strong candidate") + } + + if got.Recommendation != string(autotag.RecommendationStrong) { + t.Errorf("recommendation = %q, want strong", got.Recommendation) + } + + // The release named is the release Apply would write — the page + // must not offer one album and tag another. + if got.ReleaseMBID != "rel-1" || got.Title != "Glass Harbour" { + t.Errorf("named %q/%q, want rel-1/Glass Harbour", got.ReleaseMBID, got.Title) + } + + if got.GroupCount != 1 { + t.Errorf("groupCount = %d, want 1", got.GroupCount) + } +} + +// The tier is computed from the candidates, not read off the raw +// score — a high number the scorer would have capped must not reach +// the page as confidence it withheld. +func TestMatchForAlbumDoesNotTrustTheStoredScore(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + svc := newTestService(t, db) + + // Two tracks: below the evidence floor, so `Recommend` caps this + // at medium however well it scores. + albumID := seedAlbumGroup(t, db, "grp-2", 2, "pending", 0.99) + storeCandidates(t, db, "grp-2", 2, 0.99) + + got, err := svc.MatchForAlbum(albumID) + if err != nil { + t.Fatalf("MatchForAlbum: %v", err) + } + + if got != nil { + t.Errorf("surfaced %+v for a two-track folder, want nothing", got) + } +} + +// A weak match is not worth interrupting for. +func TestMatchForAlbumStaysQuietBelowTheTier(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + svc := newTestService(t, db) + + albumID := seedAlbumGroup(t, db, "grp-3", 8, "pending", 0.60) + storeCandidates(t, db, "grp-3", 8, 0.60) + + got, err := svc.MatchForAlbum(albumID) + if err != nil { + t.Fatalf("MatchForAlbum: %v", err) + } + + if got != nil { + t.Errorf("surfaced %+v for a 0.60 match, want nothing", got) + } +} + +// An album the user has already answered for is not re-offered. +// +// `confirmed` covers both a finished apply and an explicit "leave as +// is", and arguing with the second would be actively wrong. +func TestMatchForAlbumRespectsAnAnswerAlreadyGiven(t *testing.T) { + t.Parallel() + + for _, status := range []string{"confirmed", "skipped", "matched"} { + t.Run(status, func(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + svc := newTestService(t, db) + + albumID := seedAlbumGroup(t, db, "grp-"+status, 8, status, 0.95) + storeCandidates(t, db, "grp-"+status, 8, 0.95) + + got, err := svc.MatchForAlbum(albumID) + if err != nil { + t.Fatalf("MatchForAlbum: %v", err) + } + + if got != nil { + t.Errorf("surfaced %+v for a %s group, want nothing", got, status) + } + }) + } +} + +// A folder nobody has scored yet says nothing, rather than scoring it +// now: the MusicBrainz limiter is shared with every page the user can +// open, and this runs on page load. +func TestMatchForAlbumMakesNoNetworkCallForAnUnscoredFolder(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + svc := newTestService(t, db) + + // No storeCandidates: the prefetch has not reached this folder. + albumID := seedAlbumGroup(t, db, "grp-4", 8, "pending", 0.95) + + got, err := svc.MatchForAlbum(albumID) + if err != nil { + t.Fatalf("MatchForAlbum: %v", err) + } + + if got != nil { + t.Errorf("surfaced %+v with no cached candidates, want nothing", got) + } +} + +// A multi-disc album is several groups, and the count is what stops +// the page offering one button that would retag one disc of two. +func TestMatchForAlbumCountsEveryGroupOfTheAlbum(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + svc := newTestService(t, db) + + albumID := seedAlbumGroup(t, db, "grp-d1", 8, "pending", 0.95) + storeCandidates(t, db, "grp-d1", 8, 0.95) + + // Disc two: same album row, its own folder and tagging group. + for i := 1; i <= 6; i++ { + database.InsertTestTrack(t, db, database.TestTrack{ + FilePath: filePathFor("grp-d2", i), + Title: titleFor(i), + Artist: "Tideline", + Album: "Glass Harbour", + AlbumArtist: "Tideline", + TrackNumber: int64(i), + DiscNumber: 2, + LengthMs: 200000, + LibraryID: 0, + GroupKey: "grp-d2", + }) + } + + if _, err := db.ExecContext(` + INSERT INTO tagging_items + (group_key, library_id, track_count, album_name, album_artist, + disc_number, status, score) + VALUES ('grp-d2', 0, 6, 'Glass Harbour', 'Tideline', 2, 'pending', 0.93) + `); err != nil { + t.Fatalf("insert disc two: %v", err) + } + + storeCandidates(t, db, "grp-d2", 6, 0.93) + + got, err := svc.MatchForAlbum(albumID) + if err != nil { + t.Fatalf("MatchForAlbum: %v", err) + } + + if got == nil { + t.Fatal("no match returned") + } + + if got.GroupCount != 2 { + t.Errorf("groupCount = %d, want 2", got.GroupCount) + } + + // Best-first: the 0.95 disc is the one described. + if got.GroupKey != "grp-d1" { + t.Errorf("described %q, want the higher-scoring grp-d1", got.GroupKey) + } +} + +// An album with no local files at all — a pure catalog page — is not +// a question this can answer. +func TestMatchForAlbumSaysNothingWithoutAnAlbum(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + svc := newTestService(t, db) + + for _, id := range []int64{0, -1, 4242} { + got, err := svc.MatchForAlbum(id) + if err != nil { + t.Fatalf("MatchForAlbum(%d): %v", id, err) + } + + if got != nil { + t.Errorf("MatchForAlbum(%d) = %+v, want nil", id, got) + } + } +} diff --git a/backend/database/sql/queries/tagging_items.sql b/backend/database/sql/queries/tagging_items.sql index 9a6a2fa..1094f62 100644 --- a/backend/database/sql/queries/tagging_items.sql +++ b/backend/database/sql/queries/tagging_items.sql @@ -342,3 +342,35 @@ WHERE ti.status = 'pending' ) ORDER BY ti.group_key LIMIT 1; + +-- name: GetTaggingItemsForAlbum :many +-- Every tagging group holding a file of this album. +-- +-- The join is `audio_files.group_key`, not a key derived from the +-- album's folder path: a group carved out of a mixed-bag folder by +-- SplitMixedFolder is keyed on its tags rather than on a directory, +-- so a path-derived key finds nothing for exactly the messiest +-- libraries this is meant to help. +-- +-- Usually one row. A multi-disc album is one group per disc, which +-- the caller has to know about rather than average over -- applying +-- to "the album" would silently retag one disc of three. +SELECT + ti.group_key, + ti.status, + ti.score, + ti.best_match_release_mbid, + ti.track_count, + ti.album_name, + ti.album_artist, + ti.synthetic +FROM tagging_items ti +WHERE ti.group_key IN ( + SELECT DISTINCT af.group_key + FROM audio_files af + WHERE af.album_id = sqlc.arg(album_id) AND af.group_key != '' + ) + AND ti.cleared_at IS NULL +-- Best first, with an unscored group last rather than first: NULL +-- sorts low in SQLite and DESC would put it at the top. +ORDER BY ti.score IS NULL, ti.score DESC, ti.group_key; diff --git a/backend/database/sql/sqlcgen/tagging_items.sql.go b/backend/database/sql/sqlcgen/tagging_items.sql.go index da8425f..f3842fd 100644 --- a/backend/database/sql/sqlcgen/tagging_items.sql.go +++ b/backend/database/sql/sqlcgen/tagging_items.sql.go @@ -231,6 +231,82 @@ func (q *Queries) GetTaggingItem(ctx context.Context, groupKey string) (TaggingI return i, err } +const getTaggingItemsForAlbum = `-- name: GetTaggingItemsForAlbum :many +SELECT + ti.group_key, + ti.status, + ti.score, + ti.best_match_release_mbid, + ti.track_count, + ti.album_name, + ti.album_artist, + ti.synthetic +FROM tagging_items ti +WHERE ti.group_key IN ( + SELECT DISTINCT af.group_key + FROM audio_files af + WHERE af.album_id = ?1 AND af.group_key != '' + ) + AND ti.cleared_at IS NULL +ORDER BY ti.score IS NULL, ti.score DESC, ti.group_key +` + +type GetTaggingItemsForAlbumRow struct { + GroupKey string + Status string + Score sql.NullFloat64 + BestMatchReleaseMbid sql.NullString + TrackCount int64 + AlbumName string + AlbumArtist string + Synthetic int64 +} + +// Every tagging group holding a file of this album. +// +// The join is `audio_files.group_key`, not a key derived from the +// album's folder path: a group carved out of a mixed-bag folder by +// SplitMixedFolder is keyed on its tags rather than on a directory, +// so a path-derived key finds nothing for exactly the messiest +// libraries this is meant to help. +// +// Usually one row. A multi-disc album is one group per disc, which +// the caller has to know about rather than average over -- applying +// to "the album" would silently retag one disc of three. +// Best first, with an unscored group last rather than first: NULL +// sorts low in SQLite and DESC would put it at the top. +func (q *Queries) GetTaggingItemsForAlbum(ctx context.Context, albumID sql.NullInt64) ([]GetTaggingItemsForAlbumRow, error) { + rows, err := q.db.QueryContext(ctx, getTaggingItemsForAlbum, albumID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetTaggingItemsForAlbumRow + for rows.Next() { + var i GetTaggingItemsForAlbumRow + if err := rows.Scan( + &i.GroupKey, + &i.Status, + &i.Score, + &i.BestMatchReleaseMbid, + &i.TrackCount, + &i.AlbumName, + &i.AlbumArtist, + &i.Synthetic, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listAudioFilesInTaggingGroup = `-- name: ListAudioFilesInTaggingGroup :many SELECT af.id, diff --git a/frontend/bindings/yellowjacket/backend/autotagservice/index.ts b/frontend/bindings/yellowjacket/backend/autotagservice/index.ts index 2d8d648..f4a466e 100644 --- a/frontend/bindings/yellowjacket/backend/autotagservice/index.ts +++ b/frontend/bindings/yellowjacket/backend/autotagservice/index.ts @@ -7,6 +7,7 @@ export { }; export type { + AlbumMatchView, AlignmentView, ApplyResultView, CandidateView, diff --git a/frontend/bindings/yellowjacket/backend/autotagservice/models.ts b/frontend/bindings/yellowjacket/backend/autotagservice/models.ts index 2c4ae9d..48bce0f 100644 --- a/frontend/bindings/yellowjacket/backend/autotagservice/models.ts +++ b/frontend/bindings/yellowjacket/backend/autotagservice/models.ts @@ -1,6 +1,61 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +/** + * AlbumMatchView is "the autotagger already has a confident match for + * the album you are looking at". + * + * It is deliberately not a score. The album page renders a suggestion, + * and a suggestion has to be actionable: which release, what it is + * called, and whether acting on it here would do the whole album or + * only part of it. + */ +export interface AlbumMatchView { + /** + * GroupKey is the tagging group the actions operate on. + */ + "groupKey": string; + + /** + * Recommendation is the tier, as a string, for a caller that + * wants to render the strength rather than trust the filter. + */ + "recommendation": string; + + /** + * Score is the top candidate's raw score, 0..1. + */ + "score": number; + + /** + * ReleaseMBID is the release Apply would write. + */ + "releaseMbid": string; + + /** + * Title and ArtistCredit name that release, so the banner can say + * what it is offering rather than "a match". + */ + "title": string; + "artistCredit": string; + + /** + * TrackCount is the group's local track count. + */ + "trackCount": number; + + /** + * GroupCount is how many tagging groups this album spans. + * + * More than one means a multi-disc album (one group per disc), and + * it is the reason this is a field rather than an implementation + * detail: applying "the album" from a single button would retag + * one disc of three and leave the folder holding a mix of old and + * new tags. The caller offers review instead. + */ + "groupCount": number; +} + /** * AlignmentView mirrors autotag.TrackAlignment. LocalIndex of -1 * means "candidate has this track, folder doesn't" (status=missing). diff --git a/frontend/bindings/yellowjacket/backend/autotagservice/service.ts b/frontend/bindings/yellowjacket/backend/autotagservice/service.ts index d43281b..773e110 100644 --- a/frontend/bindings/yellowjacket/backend/autotagservice/service.ts +++ b/frontend/bindings/yellowjacket/backend/autotagservice/service.ts @@ -160,6 +160,39 @@ export function ListPendingFolders(libraryID: number): $CancellablePromise<$mode return $Call.ByID(617511590, libraryID); } +/** + * MatchForAlbum answers "does the autotagger have something confident + * to say about this album", for the album detail page. + * + * Three things about it are load-bearing. + * + * **It costs no MusicBrainz request.** Everything it needs is already + * on disk: `tagging_items` carries the top score and release from the + * background prefetch, and `tagging_candidates` durably holds the + * scored list. The rate limiters here are shared with every page the + * user can open, so a lookup that fires on page load must not join + * that queue — which also means this returns nothing for a folder + * nobody has scored yet, rather than scoring it now. That is the + * right trade: the prefetch will get to it, and a page that silently + * spends a minute of somebody's MusicBrainz budget to draw a banner + * is worse than a page that says nothing. + * + * **The tier is computed, not read.** `tagging_items.score` is the raw + * number and `Recommend` is what turns it into a claim — capping it + * for an ambiguous runner-up, an incomplete alignment or a folder too + * small to corroborate itself. Filtering on the raw score would + * promise confidence the scorer had explicitly withheld. + * + * **Nothing is said about an album the user has already answered + * for.** Only a `pending` group qualifies: `confirmed` covers both a + * finished apply and an explicit "leave as is", and `skipped` is the + * user saying not now. Re-offering either is nagging, and "leave as + * is" would be actively wrong to argue with. + */ +export function MatchForAlbum(albumID: number): $CancellablePromise<$models.AlbumMatchView | null> { + return $Call.ByID(514173221, albumID); +} + /** * RetagGroup flips a group back to 'pending' so the user can * re-review after an apply or skip. Drops the durably-cached