feat(uploads): match release groups with MusicBrainz and AcoustID
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

This commit is contained in:
mrleo1nid committed 2026-10-29 20:44:59 +03:00
1 parent 1e3a149aef
commit 39bf7d4b70
45 files changed
+4792 -417

No files matched your search

+298
View File
@@ -0,0 +1,298 @@
package uploads
import (
"context"
"errors"
"net/http"
"os"
"sort"
"strconv"
"time"
"github.com/google/uuid"
"pipu/api/internal/auth"
"pipu/api/internal/musicbrainz"
"pipu/api/internal/platform/apperr"
)
func (s *Service) lookupNextRelease(ctx context.Context) bool {
job, err := s.store.ClaimRelease(ctx)
if errors.Is(err, ErrNotFound) {
return false
}
if err != nil {
s.log.Error("uploads: release job", "err", err)
return false
}
run, cancel := context.WithTimeout(ctx, 4*time.Minute)
defer cancel()
files, err := s.store.AllInReleaseGroup(run, job.ID, job.GroupKey)
report := ReleaseReport{State: "error", Candidates: []ReleaseCandidate{}, GroupID: job.GroupKey}
if err == nil {
report.InputHash = releaseHash(files)
q := job.Query
var audio map[uuid.UUID]AudioRecognition
if q.Audio {
report.AudioResults, audio, q.RecordingIDs, err = s.identifyAudio(run, files)
}
valid := true
if !q.Audio && q.Artist == "" && q.Album == "" && q.ReleaseID == "" {
q, valid = batchReleaseQuery(files)
}
if err != nil {
report.State = "error"
} else if q.Audio && len(q.RecordingIDs) == 0 {
report.State = "not_found"
} else if !q.Audio && (!valid || q.ReleaseID == "" && (q.Artist == "" || q.Album == "")) {
report.State = "needs_input"
} else {
var releases []musicbrainz.Release
releases, err = s.catalogue.Search(run, q)
if err == nil {
report.State = "not_found"
for _, release := range releases {
candidate := matchReleaseWithAudio(files, release, audio)
candidate.Existing, err = s.store.ExistingReleaseTracks(run, candidate.Missing)
if err != nil {
break
}
report.Candidates = append(report.Candidates, candidate)
}
if len(report.Candidates) > 0 {
report.State = "ready"
}
sort.SliceStable(report.Candidates, func(i, j int) bool {
a, b := report.Candidates[i], report.Candidates[j]
if a.Complete != b.Complete {
return a.Complete
}
if a.Matched != b.Matched {
return a.Matched > b.Matched
}
return len(a.Unmatched) < len(b.Unmatched)
})
}
}
}
if err != nil {
report.State = "error"
report.Candidates = []ReleaseCandidate{}
s.log.Warn("uploads: release lookup", "batch_id", job.ID, "err", err)
}
if err = s.store.SaveRelease(ctx, job, report); err != nil {
s.log.Error("uploads: save release lookup", "err", err)
}
return true
}
func (s *Service) GetReleaseReport(ctx context.Context, id uuid.UUID, scope ...string) (ReleaseReport, error) {
if _, err := principal(ctx, auth.PermReviewUploads); err != nil {
return ReleaseReport{}, err
}
files, err := s.store.AllInBatch(ctx, id)
if err != nil {
return ReleaseReport{}, notFound(err)
}
files, groups, key, err := s.releaseScope(ctx, id, firstGroup(scope))
if err != nil {
return ReleaseReport{}, err
}
if s.catalogue == nil {
return ReleaseReport{State: "disabled", Candidates: []ReleaseCandidate{}, GroupID: key, Groups: groups}, nil
}
if len(groups) > 1 && key == "" {
return ReleaseReport{State: "needs_input", Candidates: []ReleaseCandidate{}, Groups: groups, AudioAvailable: s.identifier != nil}, nil
}
report, err := s.store.ReleaseReport(ctx, id, key)
if err != nil {
return report, err
}
if report.InputHash != "" && report.InputHash != releaseHash(files) && report.State != "queued" && report.State != "searching" {
if err = s.store.QueueRelease(ctx, id, musicbrainz.Query{GroupID: key}); err != nil {
return report, err
}
report = ReleaseReport{State: "queued", Candidates: []ReleaseCandidate{}, GroupID: key}
}
report.AudioAvailable = s.identifier != nil
report.Groups = groups
return report, nil
}
func (s *Service) SearchReleases(ctx context.Context, id uuid.UUID, q musicbrainz.Query) (ReleaseReport, error) {
if _, err := principal(ctx, auth.PermReviewUploads); err != nil {
return ReleaseReport{}, err
}
if s.catalogue == nil {
return ReleaseReport{}, apperr.Conflict("uploads.metadata_disabled", "external metadata is disabled")
}
if q.Audio && s.identifier == nil {
return ReleaseReport{}, apperr.Conflict("uploads.audio_disabled", "AcoustID application key is not configured")
}
files, groups, key, err := s.releaseScope(ctx, id, q.GroupID)
if err != nil {
return ReleaseReport{}, notFound(err)
}
if key == "" {
return ReleaseReport{}, apperr.Conflict("uploads.release_group_required", "choose a release group")
}
q.GroupID = key
editable := false
for _, f := range files {
editable = editable || f.Status == StatusPendingReview || f.Status == StatusImportFailed
}
if !editable {
return ReleaseReport{}, notFound(ErrWrongStatus)
}
if q.Audio {
count := 0
for _, f := range releaseFiles(files) {
if f.Status == StatusPendingReview || f.Status == StatusImportFailed {
count++
}
}
if count > maxAudioFiles {
return ReleaseReport{}, apperr.Validation("audio", "uploads.audio_batch_limit", "at most 50 editable tracks per audio lookup", nil)
}
q = musicbrainz.Query{Audio: true, GroupID: key}
}
if len(q.Artist) > 500 || len(q.Album) > 500 {
return ReleaseReport{}, apperr.Validation("query", "common.bad_request", "query too long", nil)
}
if q.ReleaseID != "" {
if _, err = uuid.Parse(q.ReleaseID); err != nil {
return ReleaseReport{}, apperr.Validation("release_id", "common.bad_request", "invalid release ID", nil)
}
}
if err = s.store.QueueRelease(ctx, id, q); err != nil {
return ReleaseReport{}, err
}
return ReleaseReport{State: "queued", Candidates: []ReleaseCandidate{}, AudioAvailable: s.identifier != nil, GroupID: key, Groups: groups}, nil
}
func (s *Service) releaseCandidate(ctx context.Context, batch uuid.UUID, release string, scope ...string) (ReleaseCandidate, []File, ReleaseReport, error) {
if _, err := principal(ctx, auth.PermReviewUploads); err != nil {
return ReleaseCandidate{}, nil, ReleaseReport{}, err
}
files, groups, key, err := s.releaseScope(ctx, batch, firstGroup(scope))
if err != nil {
return ReleaseCandidate{}, nil, ReleaseReport{}, notFound(err)
}
if len(groups) > 1 && key == "" {
return ReleaseCandidate{}, nil, ReleaseReport{}, apperr.Conflict("uploads.release_group_required", "choose a release group")
}
report, err := s.store.ReleaseReport(ctx, batch, key)
if err != nil {
return ReleaseCandidate{}, files, report, err
}
report.Groups = groups
if report.State != "ready" || report.InputHash != releaseHash(files) {
return ReleaseCandidate{}, files, report, apperr.Conflict("uploads.release_stale", "metadata changed; repeat release lookup")
}
for _, candidate := range report.Candidates {
if candidate.Release.ID == release {
return candidate, files, report, nil
}
}
return ReleaseCandidate{}, files, report, apperr.NotFound("uploads.release_not_found", "release is not in this batch's candidates")
}
func (s *Service) ReleaseCover(ctx context.Context, batch uuid.UUID, release string, scope ...string) (musicbrainz.Cover, error) {
c, _, _, err := s.releaseCandidate(ctx, batch, release, scope...)
if err != nil {
return musicbrainz.Cover{}, err
}
if s.catalogue == nil {
return musicbrainz.Cover{}, apperr.Conflict("uploads.metadata_disabled", "external metadata is disabled")
}
cover, err := s.catalogue.Cover(ctx, c.Release)
if errors.Is(err, musicbrainz.ErrNotFound) {
return cover, apperr.NotFound("uploads.release_cover_missing", "release has no cover")
}
if err != nil {
return cover, apperr.New(http.StatusServiceUnavailable, "uploads.metadata_unavailable", "cover provider is unavailable")
}
return cover, err
}
type releaseChange struct {
File File
Meta Meta
Placement placement
Tags map[string]string
}
func (s *Service) ApplyRelease(ctx context.Context, batch uuid.UUID, release string, metadata, cover bool, scope ...string) (ReleaseReport, error) {
candidate, files, report, err := s.releaseCandidate(ctx, batch, release, scope...)
if err != nil {
return report, err
}
if !metadata && !cover {
return report, apperr.Validation("apply_metadata", "common.bad_request", "choose metadata or cover", nil)
}
var coverPath *string
if cover {
image, err := s.ReleaseCover(ctx, batch, release, report.GroupID)
if err != nil {
return report, err
}
coverPath, err = s.createCover(image.Data)
if err != nil {
return report, err
}
}
committed := false
defer func() {
if !committed && coverPath != nil {
_ = os.Remove(s.stagingAbs(*coverPath))
}
}()
changes := []releaseChange{}
byID := map[uuid.UUID]int{}
for i, f := range files {
byID[f.ID] = i
}
for _, match := range candidate.Matches {
i := byID[match.FileID]
f := files[i]
if !metadata || f.Meta == nil || f.Status != StatusPendingReview && f.Status != StatusImportFailed {
continue
}
t := candidate.Release.Tracks[match.TrackIndex]
m := *f.Meta
m.Artist, m.AlbumArtist, m.Album, m.Title = t.Artist, candidate.Release.Artist, candidate.Release.Title, t.Title
m.Disc, m.Track = &t.Disc, &t.Position
if len(candidate.Release.Date) >= 4 {
if y, e := strconv.Atoi(candidate.Release.Date[:4]); e == nil {
m.Year = &y
}
}
m = normalizeMeta(m)
tags := map[string]string{}
for k, v := range f.Tags {
tags[k] = v
}
tags["musicbrainz_albumid"], tags["musicbrainz_releasegroupid"], tags["musicbrainz_trackid"] = candidate.Release.ID, candidate.Release.GroupID, t.RecordingID
changes = append(changes, releaseChange{f, m, s.plan(ctx, f, m, f.TargetSourceID), tags})
files[i].Meta, files[i].Tags = &m, tags
}
if metadata && len(changes) == 0 {
return report, apperr.Conflict(CodeWrongStatus, "no matched editable tracks")
}
report.SelectedRelease = &release
next := report
next.InputHash = releaseHash(files)
reviewer, _ := principal(ctx, auth.PermReviewUploads)
if err = s.store.ApplyRelease(ctx, batch, report.InputHash, changes, coverPath, next, reviewer.UserID); err != nil {
return report, notFound(err)
}
committed = true
next.AudioAvailable = s.identifier != nil
for i, g := range next.Groups {
if g.ID == next.GroupID {
if cover {
next.Groups[i].HasCover = true
}
if metadata {
next.Groups[i].Artist = candidate.Release.Artist
next.Groups[i].Album = candidate.Release.Title
}
}
}
return next, nil
}