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
202 lines
6.2 KiB
Go
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
|
|
}
|