Files
pipu-music/services/api/internal/uploads/release_match.go
T
mrleo1nid 39bf7d4b70
ci / module boundaries / boundaries (push) Successful in 10s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m30s
ci / api / api (push) Successful in 58s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m1s
ci / db / db (push) Failing after 25s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m36s
ci / ts / ts (packages + web) (push) Failing after 6m3s
feat(uploads): match release groups with MusicBrainz and AcoustID
2026-10-29 20:44:59 +03:00

202 lines
6.2 KiB
Go

package uploads
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"math"
"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() })
b, _ := json.Marshal(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 != ""
}
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) && meta.Key(m.Artist) == meta.Key(t.Artist) {
return 60
}
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
}