navidrome/utils/slice/slice.go
Deluan Quintão 659d067aba
fix(archiver): give same-named albums their own folder in artist zips (#6225)
* refactor: add Tags.First and slice.GroupOrdered helpers

Tags.First returns the first value of a tag or an empty string, replacing the inline len-check-then-index pattern in FullTitle, FullAlbumName, Album.FullName and the Subsonic album version mapping.

slice.GroupOrdered is slice.Group returning the groups in first-seen order, for callers that need a deterministic order the map-based Group cannot give.

* fix(archiver): give same-named albums their own folder in artist zips

Artist zips put every album in a folder named after the album, so two albums with the same name (an original and a deluxe edition, or names that only differ in characters the sanitizer replaces) were merged into one folder, with tracks mixed together and duplicate zip entries when file names collided.

The folder is now named after FullAlbumName(), so with AppendAlbumVersion on (the default) the version is part of the name, matching what clients display. Albums whose sanitized names still clash get a " [suffix]" taken from the first field that has a distinct, non-empty value for all of them: album version, year, release type, record label, catalog number, then a short album id. This follows the shape of beets' %aunique{} path function.

Albums are also grouped with slice.GroupOrdered instead of a map, so the zip is deterministic.

* fix(archiver): use the release year to tell same-named albums apart

Taggers often write an edition's date to the Date tag next to an original date, and the scanner then stores the original year in Year and the edition's year in ReleaseYear. Reissues of the same album therefore share Year, so the year disambiguator could not tell them apart and they fell through to the album id suffix. Prefer ReleaseYear and fall back to Year when it is not set.

Found by downloading an artist zip from a live server built from this branch.

* fix(archiver): let one clashing album keep the plain folder name

A disambiguator was only accepted when every clashing album had a non-empty value, so an original and its deluxe edition (with the version not appended to the name) fell through to the album id suffix. Accept a field whose values are distinct across the group even when one of them is empty, as beets' %aunique{} does: that album keeps the plain name, which the suffixed folders cannot clash with. Two or more empty values still count as a tie.

* test(archiver): refactor tests for album naming conventions and query order
2026-09-25 16:19:30 -04:00

208 lines
4.4 KiB
Go

package slice
import (
"bufio"
"bytes"
"cmp"
"io"
"iter"
"maps"
"slices"
)
func Map[T any, R any](t []T, mapFunc func(T) R) []R {
r := make([]R, len(t))
for i, e := range t {
r[i] = mapFunc(e)
}
return r
}
func MapWithArg[I any, O any, A any](t []I, arg A, mapFunc func(A, I) O) []O {
return Map(t, func(e I) O {
return mapFunc(arg, e)
})
}
func Group[T any, K comparable](s []T, keyFunc func(T) K) map[K][]T {
m := map[K][]T{}
for _, item := range s {
k := keyFunc(item)
m[k] = append(m[k], item)
}
return m
}
// GroupOrdered is Group with the groups in first-seen order.
func GroupOrdered[T any, K comparable](s []T, keyFunc func(T) K) [][]T {
var groups [][]T
index := map[K]int{}
for _, item := range s {
k := keyFunc(item)
i, ok := index[k]
if !ok {
i = len(groups)
index[k] = i
groups = append(groups, nil)
}
groups[i] = append(groups[i], item)
}
return groups
}
func ToMap[T any, K comparable, V any](s []T, transformFunc func(T) (K, V)) map[K]V {
m := make(map[K]V, len(s))
for _, item := range s {
k, v := transformFunc(item)
m[k] = v
}
return m
}
// ToSet builds a set (a map keyed by the slice's elements) for O(1) membership tests. Duplicate
// elements collapse to a single key.
func ToSet[T comparable](s []T) map[T]struct{} {
m := make(map[T]struct{}, len(s))
for _, item := range s {
m[item] = struct{}{}
}
return m
}
func CompactByFrequency[T comparable](list []T) []T {
counters := make(map[T]int)
for _, item := range list {
counters[item]++
}
return slices.SortedFunc(maps.Keys(counters), func(i, j T) int {
return cmp.Compare(counters[j], counters[i])
})
}
func MostFrequent[T comparable](list []T) T {
var zero T
if len(list) == 0 {
return zero
}
counters := make(map[T]int)
var topItem T
var topCount int
for _, value := range list {
if value == zero {
continue
}
counters[value]++
if counters[value] > topCount {
topItem = value
topCount = counters[value]
}
}
return topItem
}
func Insert[T any](slice []T, value T, index int) []T {
return append(slice[:index], append([]T{value}, slice[index:]...)...)
}
func Remove[T any](slice []T, index int) []T {
return append(slice[:index], slice[index+1:]...)
}
func Move[T any](slice []T, srcIndex int, dstIndex int) []T {
value := slice[srcIndex]
return Insert(Remove(slice, srcIndex), value, dstIndex)
}
func Unique[T comparable](list []T) []T {
seen := make(map[T]struct{})
var result []T
for _, item := range list {
if _, ok := seen[item]; !ok {
seen[item] = struct{}{}
result = append(result, item)
}
}
return result
}
// LinesFrom returns a Seq that reads lines from the given reader
func LinesFrom(reader io.Reader) iter.Seq[string] {
return func(yield func(string) bool) {
scanner := bufio.NewScanner(reader)
scanner.Split(scanLines)
for scanner.Scan() {
if !yield(scanner.Text()) {
return
}
}
}
}
// From https://stackoverflow.com/a/41433698
func scanLines(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
if i := bytes.IndexAny(data, "\r\n"); i >= 0 {
if data[i] == '\n' {
// We have a line terminated by single newline.
return i + 1, data[0:i], nil
}
advance = i + 1
if len(data) > i+1 && data[i+1] == '\n' {
advance += 1
}
return advance, data[0:i], nil
}
// If we're at EOF, we have a final, non-terminated line. Return it.
if atEOF {
return len(data), data, nil
}
// Request more data.
return 0, nil, nil
}
// CollectChunks collects chunks of n elements from the input sequence and return a Seq of chunks
func CollectChunks[T any](it iter.Seq[T], n int) iter.Seq[[]T] {
return func(yield func([]T) bool) {
s := make([]T, 0, n)
for x := range it {
s = append(s, x)
if len(s) >= n {
if !yield(s) {
return
}
s = make([]T, 0, n)
}
}
if len(s) > 0 {
yield(s)
}
}
}
// SeqFunc returns a Seq that iterates over the slice with the given mapping function
func SeqFunc[I, O any](s []I, f func(I) O) iter.Seq[O] {
return func(yield func(O) bool) {
for _, x := range s {
if !yield(f(x)) {
return
}
}
}
}
// Filter returns a new slice containing only the elements of s for which filterFunc returns true
func Filter[T any](s []T, filterFunc func(T) bool) []T {
var result []T
for _, item := range s {
if filterFunc(item) {
result = append(result, item)
}
}
return result
}