package uploads import ( "context" "crypto/sha256" "encoding/hex" "path" "regexp" "sort" "strconv" "strings" "github.com/google/uuid" "pipu/api/internal/library" "pipu/api/internal/library/meta" "pipu/api/internal/platform/apperr" ) type ReleaseGroup struct { ID string `json:"id"` Artist string `json:"artist"` Album string `json:"album"` FileIDs []uuid.UUID `json:"file_ids"` HasCover bool `json:"has_cover"` State string `json:"state"` } type releaseGroupDraft struct { Key, Artist, Album string Files []uuid.UUID } var discDirectory = regexp.MustCompile(`(?i)^(?:cd|disc|disk|диск)[ ._-]*0*\d+(?:\s*[-(].*)?$`) func releaseFolder(f File) string { name := f.OriginalName if f.ClientRelPath != nil { name = *f.ClientRelPath } dir := path.Dir(strings.ReplaceAll(name, `\`, "/")) for dir != "." && dir != "/" && discDirectory.MatchString(path.Base(dir)) { dir = path.Dir(dir) } if dir == "." || dir == "/" { return "" } return strings.ToLower(dir) } func releaseBase(f File) string { m := f.Meta year := "" if m.Year != nil { year = strconv.Itoa(*m.Year) } if m.Album != "" && m.Album != library.UnknownAlbum { if m.AlbumArtist != "" && m.AlbumArtist != meta.UnknownArtist { return "tags\x00" + meta.Key(m.AlbumArtist) + "\x00" + meta.Key(m.Album) + "\x00" + year } if folder := releaseFolder(f); folder != "" { return "album-folder\x00" + folder + "\x00" + meta.Key(m.Album) + "\x00" + year } return "tags\x00" + meta.Key(m.Artist) + "\x00" + meta.Key(m.Album) + "\x00" + year } return "folder\x00" + releaseFolder(f) } func groupReleaseFiles(files []File) []releaseGroupDraft { files = releaseFiles(files) known := map[string]map[string]bool{} for _, f := range files { base := releaseBase(f) if known[base] == nil { known[base] = map[string]bool{} } if id := tagID(f.Tags, "musicbrainz_albumid", "musicbrainz album id", "musicbrainz release id", "musicbrainz_releaseid"); id != "" { known[base][id] = true } } groups := map[string]*releaseGroupDraft{} for _, f := range files { base := releaseBase(f) id := tagID(f.Tags, "musicbrainz_albumid", "musicbrainz album id", "musicbrainz release id", "musicbrainz_releaseid") if id == "" && len(known[base]) == 1 { for value := range known[base] { id = value } } key := base if id != "" { key = "mbid\x00" + id } sum := sha256.Sum256([]byte(key)) key = hex.EncodeToString(sum[:]) if groups[key] == nil { artist := f.Meta.AlbumArtist if artist == "" { artist = f.Meta.Artist } groups[key] = &releaseGroupDraft{Key: key, Artist: artist, Album: f.Meta.Album, Files: []uuid.UUID{}} } groups[key].Files = append(groups[key].Files, f.ID) } out := []releaseGroupDraft{} for _, g := range groups { sort.Slice(g.Files, func(i, j int) bool { return g.Files[i].String() < g.Files[j].String() }) out = append(out, *g) } sort.Slice(out, func(i, j int) bool { return out[i].Files[0].String() < out[j].Files[0].String() }) return out } // releaseScope resolves only a group belonging to this batch. A mixed batch requires explicit selection. func (s *Service) releaseScope(ctx context.Context, batch uuid.UUID, requested string) ([]File, []ReleaseGroup, string, error) { if err := s.store.EnsureReleaseGroups(ctx, batch); err != nil { return nil, nil, "", notFound(err) } groups, err := s.store.ReleaseGroups(ctx, batch) if err != nil { return nil, nil, "", err } if requested == "" && len(groups) == 1 { requested = groups[0].ID } if requested != "" { found := false for _, g := range groups { found = found || g.ID == requested } if !found { return nil, groups, "", apperr.NotFound("uploads.release_group_not_found", "release group does not belong to batch") } files, err := s.store.AllInReleaseGroup(ctx, batch, requested) return files, groups, requested, err } if len(groups) > 1 { return nil, groups, "", nil } files, err := s.store.AllInBatch(ctx, batch) return files, groups, "", err } func firstGroup(values []string) string { if len(values) > 0 { return values[0] } return "" }