🏡 index : quine-global/quintodrome.git

package artwork

import (
	"cmp"
	"context"
	"errors"
	"path"
	"slices"
	"strings"
	"time"

	"github.com/Masterminds/squirrel"
	"github.com/navidrome/navidrome/log"
	"github.com/navidrome/navidrome/model"
	"github.com/navidrome/navidrome/utils/natural"
	"github.com/navidrome/navidrome/utils/slice"
)

func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, album model.Album) ([]string, []string, *time.Time, error) {
	folders, err := loadFolders(ctx, ds, album.FolderIDs)
	if err != nil {
		return nil, nil, nil, err
	}

	parent, err := albumRootParent(ctx, ds, folders, album.FolderIDs)
	if err != nil {
		return nil, nil, nil, err
	}
	if parent != nil {
		folders = append(folders, *parent)
	}

	paths := slice.Map(folders, func(f model.Folder) string { return f.AbsolutePath() })
	imgFiles, updatedAt := folderImages(folders)
	return paths, imgFiles, &updatedAt, nil
}

func loadFolders(ctx context.Context, ds model.DataStore, folderIDs []string) ([]model.Folder, error) {
	return ds.Folder().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderIDs, "missing": false}})
}

// folderImages collects the folders' image files, sorted so files without
// numeric suffixes win (e.g. cover.jpg over cover.1.jpg).
func folderImages(folders []model.Folder) ([]string, time.Time) {
	var imgFiles []string
	var updatedAt time.Time
	for _, f := range folders {
		if f.ImagesUpdatedAt.After(updatedAt) {
			updatedAt = f.ImagesUpdatedAt
		}
		rel := strings.TrimPrefix(path.Join(f.Path, f.Name), "/")
		for _, img := range f.ImageFiles {
			imgFiles = append(imgFiles, path.Join(rel, img))
		}
	}
	slices.SortFunc(imgFiles, compareImageFiles)
	return imgFiles, updatedAt
}

// albumRootParent returns the common parent of the album's folders when it
// qualifies as the album's root folder (e.g. "Artist/Album" above disc
// subfolders), or nil when there is no such parent. This finds cover art in
// the album root folder when tracks live in disc subfolders, like
// "Artist/Album/cover.jpg" with tracks in "Artist/Album/CD1/" and
// "Artist/Album/CD2/". The parent must look like an album root, not an
// artist-level folder — it qualifies only when it holds no audio belonging to
// other albums — so artist images are never served as album art.
func albumRootParent(ctx context.Context, ds model.DataStore, folders []model.Folder, folderIDs []string) (*model.Folder, error) {
	folderIDSet := make(map[string]bool, len(folderIDs))
	for _, id := range folderIDs {
		folderIDSet[id] = true
	}
	commonParentID := commonParentFolder(folders, folderIDSet)
	if commonParentID == "" {
		return nil, nil
	}
	// Single-folder albums only use the parent when the folder has no images
	// of its own (indicating a disc subfolder needing parent artwork).
	if len(folders) < 2 && anyFolderHasImages(folders) {
		return nil, nil
	}
	parent, err := ds.Folder().Get(ctx, commonParentID)
	if errors.Is(err, model.ErrNotFound) {
		log.Warn(ctx, "Artwork: Parent folder not found for album cover art lookup", "parentID", commonParentID)
		return nil, nil
	}
	if err != nil {
		return nil, err
	}
	if parent.ParentID == "" {
		// The library root can never be an album root
		return nil, nil
	}
	hasOtherAudio, err := ds.Folder().HasAudioOutsideFolders(ctx, *parent, folderIDs)
	if err != nil {
		return nil, err
	}
	if hasOtherAudio {
		return nil, nil
	}
	return parent, nil
}

func anyFolderHasImages(folders []model.Folder) bool {
	for _, f := range folders {
		if len(f.ImageFiles) > 0 {
			return true
		}
	}
	return false
}

// commonParentFolder returns the shared parent folder ID when all folders have the
// same parent and that parent is not already in folderIDSet. Returns "" otherwise.
func commonParentFolder(folders []model.Folder, folderIDSet map[string]bool) string {
	if len(folders) == 0 {
		return ""
	}
	parentID := folders[0].ParentID
	if parentID == "" || folderIDSet[parentID] {
		return ""
	}
	for _, f := range folders[1:] {
		if f.ParentID != parentID {
			return ""
		}
	}
	return parentID
}

// compareImageFiles sorts image paths by: base filename (natural order),
// then path depth (shallower first), then full path (stable tiebreaker).
func compareImageFiles(a, b string) int {
	// Case-insensitive comparison
	a = strings.ToLower(a)
	b = strings.ToLower(b)

	// Extract base filenames without extensions
	baseA := strings.TrimSuffix(path.Base(a), path.Ext(a))
	baseB := strings.TrimSuffix(path.Base(b), path.Ext(b))

	// Compare base names first, then prefer shallower paths, then full path as tiebreaker
	return cmp.Or(
		natural.Compare(baseA, baseB),
		cmp.Compare(strings.Count(a, "/"), strings.Count(b, "/")),
		natural.Compare(a, b),
	)
}