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 }