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
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:
1 parent
1e3a149aef
commit
39bf7d4b70
45 files changed
+4792
-417
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user