feat(autotag): answer whether an album has a confident match
`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.
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -342,3 +342,35 @@ WHERE ti.status = 'pending'
|
|||||||
)
|
)
|
||||||
ORDER BY ti.group_key
|
ORDER BY ti.group_key
|
||||||
LIMIT 1;
|
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;
|
||||||
|
|||||||
@@ -231,6 +231,82 @@ func (q *Queries) GetTaggingItem(ctx context.Context, groupKey string) (TaggingI
|
|||||||
return i, err
|
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
|
const listAudioFilesInTaggingGroup = `-- name: ListAudioFilesInTaggingGroup :many
|
||||||
SELECT
|
SELECT
|
||||||
af.id,
|
af.id,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
AlbumMatchView,
|
||||||
AlignmentView,
|
AlignmentView,
|
||||||
ApplyResultView,
|
ApplyResultView,
|
||||||
CandidateView,
|
CandidateView,
|
||||||
|
|||||||
@@ -1,6 +1,61 @@
|
|||||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
// This file is automatically generated. DO NOT EDIT
|
// 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
|
* AlignmentView mirrors autotag.TrackAlignment. LocalIndex of -1
|
||||||
* means "candidate has this track, folder doesn't" (status=missing).
|
* means "candidate has this track, folder doesn't" (status=missing).
|
||||||
|
|||||||
@@ -160,6 +160,39 @@ export function ListPendingFolders(libraryID: number): $CancellablePromise<$mode
|
|||||||
return $Call.ByID(617511590, libraryID);
|
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
|
* RetagGroup flips a group back to 'pending' so the user can
|
||||||
* re-review after an apply or skip. Drops the durably-cached
|
* re-review after an apply or skip. Drops the durably-cached
|
||||||
|
|||||||
Reference in New Issue
Block a user