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 }