Files
pipu-music/services/api/internal/uploads/release_match.go
T
mrleo1nid fd17c06397
ci / module boundaries / boundaries (push) Successful in 1m11s
ci / api / api (push) Successful in 1m54s
ci / mobile / mobile (Metro-бандл Android) (push) Failing after 6m50s
ci / ts / ts (packages + web) (push) Failing after 6m19s
ci / db / db (push) Failing after 11s
Fix release matching for featured artists and credit annotations
2026-10-30 10:23:12 +03:00

241 lines
7.3 KiB
Go

package uploads
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"math"
"regexp"
"sort"
"strings"
"github.com/google/uuid"
"pipu/api/internal/library"
"pipu/api/internal/library/meta"
"pipu/api/internal/musicbrainz"
)
type ReleaseMatch struct {
FileID uuid.UUID `json:"file_id"`
TrackIndex int `json:"track_index"`
Confidence string `json:"confidence"`
Method string `json:"method,omitempty"`
}
type ReleaseCandidate struct {
Release musicbrainz.Release `json:"release"`
Matches []ReleaseMatch `json:"matches"`
Missing []musicbrainz.Track `json:"missing"`
Existing []musicbrainz.Track `json:"existing"`
Unmatched []uuid.UUID `json:"unmatched"`
Matched int `json:"matched"`
Total int `json:"total"`
Complete bool `json:"complete"`
Confidence string `json:"confidence"`
}
type ReleaseReport struct {
GroupID string `json:"group_id,omitempty"`
Groups []ReleaseGroup `json:"groups,omitempty"`
State string `json:"state"`
InputHash string `json:"input_hash"`
Candidates []ReleaseCandidate `json:"candidates"`
SelectedRelease *string `json:"selected_release"`
AudioAvailable bool `json:"audio_available"`
AudioResults []AudioRecognition `json:"audio_results,omitempty"`
}
type AudioRecognition struct {
FileID uuid.UUID `json:"file_id"`
State string `json:"state"`
RecordingID string `json:"recording_id,omitempty"`
Score float64 `json:"score,omitempty"`
}
func releaseFiles(files []File) []File {
out := []File{}
for _, f := range files {
if f.Meta != nil && (f.Status == StatusPendingReview || f.Status == StatusImportFailed || f.Status == StatusDuplicate || f.Status == StatusImported || f.Status == StatusApproved || f.Status == StatusImporting) {
out = append(out, f)
}
}
return out
}
func releaseHash(files []File) string {
type entry struct {
ID uuid.UUID
Meta *Meta
Duration *int
Tags map[string]string
}
entries := []entry{}
for _, f := range releaseFiles(files) {
entries = append(entries, entry{f.ID, f.Meta, f.DurationMs, f.Tags})
}
sort.Slice(entries, func(i, j int) bool { return entries[i].ID.String() < entries[j].ID.String() })
// Invalidate persisted reports when matching rules change, as well as tags.
b, _ := json.Marshal(struct {
Version int
Files []entry
}{2, entries})
h := sha256.Sum256(b)
return hex.EncodeToString(h[:])
}
func tagID(tags map[string]string, keys ...string) string {
for _, key := range keys {
if id, err := uuid.Parse(strings.TrimSpace(tags[key])); err == nil {
return id.String()
}
}
return ""
}
func batchReleaseQuery(files []File) (musicbrainz.Query, bool) {
q := musicbrainz.Query{}
for _, f := range releaseFiles(files) {
if id := tagID(f.Tags, "musicbrainz_albumid", "musicbrainz album id", "musicbrainz release id", "musicbrainz_releaseid"); id != "" {
if q.ReleaseID != "" && q.ReleaseID != id {
return musicbrainz.Query{}, false
}
q.ReleaseID = id
}
}
if q.ReleaseID != "" {
return q, true
}
for _, f := range releaseFiles(files) {
m := f.Meta
a := m.AlbumArtist
if a == "" {
a = m.Artist
}
if a == "" || m.Album == "" || m.Album == library.UnknownAlbum || len(a) > 500 || len(m.Album) > 500 {
return q, false
}
if q.Artist != "" && (meta.Key(q.Artist) != meta.Key(a) || meta.Key(q.Album) != meta.Key(m.Album)) {
return q, false
}
q.Artist, q.Album = a, m.Album
}
return q, q.Artist != ""
}
// Only credit annotations are removed for tentative matching. Version labels
// such as live, remix or instrumental remain significant.
var releaseTitleCredits = regexp.MustCompile(`(?i)\s*[\(\[]\s*(?:feat\.?|ft\.?|featuring|prod\.?|produced by)\s+[^()\[\]]+[\)\]]\s*$`)
func releaseTitleKey(title string) string {
for {
cleaned := releaseTitleCredits.ReplaceAllString(title, "")
if cleaned == title {
return meta.Key(title)
}
title = cleaned
}
}
func sameReleaseArtists(a, b string) bool {
left := meta.SplitArtists(strings.ToLower(a), nil)
right := meta.SplitArtists(strings.ToLower(b), nil)
if len(left) == 0 || len(left) != len(right) {
return false
}
credits := make(map[string]bool, len(left))
for _, artist := range left {
credits[meta.Key(artist.Name)] = true
}
for _, artist := range right {
if !credits[meta.Key(artist.Name)] {
return false
}
}
return true
}
func trackScore(f File, t musicbrainz.Track, albumArtist string) int {
if f.Meta == nil {
return 0
}
m := f.Meta
delta := math.MaxFloat64
if f.DurationMs != nil && *f.DurationMs > 0 && t.DurationMs > 0 {
delta = math.Abs(float64(*f.DurationMs - t.DurationMs))
}
if id := tagID(f.Tags, "musicbrainz_trackid", "musicbrainz track id", "musicbrainz recording id", "musicbrainz_recordingid"); id != "" && id == t.RecordingID {
if delta <= 5000 {
return 100
}
return 70
}
title := meta.Key(m.Title) == meta.Key(t.Title) && meta.Key(t.Title) != ""
artistMatch := meta.Key(m.Artist) == meta.Key(t.Artist) || m.AlbumArtist != "" && meta.Key(m.AlbumArtist) == meta.Key(albumArtist)
if title && delta <= 3000 {
if !artistMatch {
return 70
}
return 95
}
if title && (t.DurationMs == 0 || f.DurationMs == nil) {
return 70
}
if delta <= 2000 && m.Track != nil && *m.Track == t.Position && (m.Disc == nil && t.Disc == 1 || m.Disc != nil && *m.Disc == t.Disc) &&
releaseTitleKey(m.Title) != "" && releaseTitleKey(m.Title) == releaseTitleKey(t.Title) && sameReleaseArtists(m.Artist, t.Artist) {
return 75
}
return 0
}
func matchRelease(files []File, r musicbrainz.Release) ReleaseCandidate {
return matchReleaseWithAudio(files, r, nil)
}
func matchReleaseWithAudio(files []File, r musicbrainz.Release, audio map[uuid.UUID]AudioRecognition) ReleaseCandidate {
c := ReleaseCandidate{Release: r, Matches: []ReleaseMatch{}, Missing: []musicbrainz.Track{}, Existing: []musicbrainz.Track{}, Unmatched: []uuid.UUID{}, Total: len(r.Tracks), Confidence: "low"}
covered := map[int]bool{}
strong := true
for _, f := range releaseFiles(files) {
index, best, tied := -1, 0, false
for i, t := range r.Tracks {
score := trackScore(f, t, r.Artist)
if a, ok := audio[f.ID]; ok && a.State == "matched" {
score = 0
if a.RecordingID == t.RecordingID {
score = 70
if f.DurationMs != nil && t.DurationMs > 0 && math.Abs(float64(*f.DurationMs-t.DurationMs)) <= 5000 {
score = 100
}
}
}
if score > best {
index, best, tied = i, score, false
} else if score > 0 && score == best {
tied = true
}
}
if index < 0 || tied {
c.Unmatched = append(c.Unmatched, f.ID)
strong = false
continue
}
confidence := "high"
if best < 90 {
confidence = "medium"
strong = false
}
method := "metadata"
if a, ok := audio[f.ID]; ok && a.State == "matched" {
method = "acoustid"
}
c.Matches = append(c.Matches, ReleaseMatch{FileID: f.ID, TrackIndex: index, Confidence: confidence, Method: method})
covered[index] = true
}
for i, t := range r.Tracks {
if !covered[i] {
c.Missing = append(c.Missing, t)
}
}
c.Matched = len(covered)
c.Complete = strong && c.Total > 0 && c.Matched == c.Total && len(c.Unmatched) == 0
if strong && c.Matched > 0 {
c.Confidence = "high"
} else if c.Matched > 0 {
c.Confidence = "medium"
}
return c
}