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
299 lines
10 KiB
Go
299 lines
10 KiB
Go
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
|
|
}
|