mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-06 18:28:39 +01:00
431 lines
14 KiB
Go
431 lines
14 KiB
Go
package persistence
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"iter"
|
|
"maps"
|
|
"path"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
. "github.com/Masterminds/squirrel"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/utils/slice"
|
|
"github.com/pocketbase/dbx"
|
|
)
|
|
|
|
type folderRepository struct {
|
|
sqlRepository
|
|
}
|
|
|
|
type dbFolder struct {
|
|
*model.Folder `structs:",flatten"`
|
|
ImageFiles string `structs:"-" json:"-"`
|
|
}
|
|
|
|
// String guards the promoted Folder.String(), which would dereference a nil Folder.
|
|
func (f dbFolder) String() string {
|
|
if f.Folder == nil {
|
|
return "<nil>"
|
|
}
|
|
return f.Folder.String()
|
|
}
|
|
|
|
func (f *dbFolder) PostScan() error {
|
|
var err error
|
|
if f.ImageFiles != "" {
|
|
if err = json.Unmarshal([]byte(f.ImageFiles), &f.Folder.ImageFiles); err != nil {
|
|
return fmt.Errorf("parsing folder image files from db: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *dbFolder) PostMapArgs(args map[string]any) error {
|
|
if f.Folder.ImageFiles == nil {
|
|
args["image_files"] = "[]"
|
|
} else {
|
|
imgFiles, err := json.Marshal(f.Folder.ImageFiles)
|
|
if err != nil {
|
|
return fmt.Errorf("marshalling image files: %w", err)
|
|
}
|
|
args["image_files"] = string(imgFiles)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type dbFolders []dbFolder
|
|
|
|
func (fs dbFolders) toModels() []model.Folder {
|
|
return slice.Map(fs, func(f dbFolder) model.Folder { return *f.Folder })
|
|
}
|
|
|
|
func newFolderRepository(db dbx.Builder) model.FolderRepository {
|
|
r := &folderRepository{}
|
|
r.db = db
|
|
r.tableName = "folder"
|
|
return r
|
|
}
|
|
|
|
func (r folderRepository) selectFolder(ctx context.Context, options ...model.QueryOptions) SelectBuilder {
|
|
sql := r.newSelect(ctx, options...).Columns("folder.*", "library.path as library_path").
|
|
Join("library on library.id = folder.library_id")
|
|
return r.applyLibraryFilter(ctx, sql)
|
|
}
|
|
|
|
func (r folderRepository) Get(ctx context.Context, id string) (*model.Folder, error) {
|
|
sq := r.selectFolder(ctx).Where(Eq{"folder.id": id})
|
|
var res dbFolder
|
|
err := r.queryOne(ctx, sq, &res)
|
|
return res.Folder, err
|
|
}
|
|
|
|
func (r folderRepository) GetByPath(ctx context.Context, lib model.Library, path string) (*model.Folder, error) {
|
|
id := model.NewFolder(lib, path).ID
|
|
return r.Get(ctx, id)
|
|
}
|
|
|
|
func (r folderRepository) GetAll(ctx context.Context, opt ...model.QueryOptions) ([]model.Folder, error) {
|
|
sq := r.selectFolder(ctx, opt...)
|
|
var res dbFolders
|
|
err := r.queryAll(ctx, sq, &res)
|
|
return res.toModels(), err
|
|
}
|
|
|
|
func (r folderRepository) CountAll(ctx context.Context, opt ...model.QueryOptions) (int64, error) {
|
|
query := r.newSelect(ctx, opt...).Columns("count(*)")
|
|
query = r.applyLibraryFilter(ctx, query)
|
|
return r.count(ctx, query)
|
|
}
|
|
|
|
func (r folderRepository) GetFolderUpdateInfo(ctx context.Context, lib model.Library, targetPaths ...string) (map[string]model.FolderUpdateInfo, error) {
|
|
// If no specific paths, return all folders in the library
|
|
if len(targetPaths) == 0 {
|
|
return r.getFolderUpdateInfoAll(ctx, lib)
|
|
}
|
|
|
|
// Check if any path is root (return all folders)
|
|
for _, targetPath := range targetPaths {
|
|
if targetPath == "" || targetPath == "." {
|
|
return r.getFolderUpdateInfoAll(ctx, lib)
|
|
}
|
|
}
|
|
|
|
// Process paths in batches to avoid SQLite's expression tree depth limit (max 1000).
|
|
// Each path generates ~3 conditions, so batch size of 100 keeps us well under the limit.
|
|
const batchSize = 100
|
|
result := make(map[string]model.FolderUpdateInfo)
|
|
|
|
for batch := range slices.Chunk(targetPaths, batchSize) {
|
|
batchResult, err := r.getFolderUpdateInfoBatch(ctx, lib, batch)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
maps.Copy(result, batchResult)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// getFolderUpdateInfoAll returns update info for all non-missing folders in the library
|
|
func (r folderRepository) getFolderUpdateInfoAll(ctx context.Context, lib model.Library) (map[string]model.FolderUpdateInfo, error) {
|
|
where := And{
|
|
Eq{"library_id": lib.ID},
|
|
Eq{"missing": false},
|
|
}
|
|
return r.queryFolderUpdateInfo(ctx, where)
|
|
}
|
|
|
|
// getFolderUpdateInfoBatch returns update info for a batch of target paths and their descendants
|
|
func (r folderRepository) getFolderUpdateInfoBatch(ctx context.Context, lib model.Library, targetPaths []string) (map[string]model.FolderUpdateInfo, error) {
|
|
where := And{
|
|
Eq{"library_id": lib.ID},
|
|
Eq{"missing": false},
|
|
}
|
|
|
|
// Collect folder IDs for exact target folders and path conditions for descendants
|
|
folderIDs := make([]string, 0, len(targetPaths))
|
|
pathConditions := make(Or, 0, len(targetPaths)*2)
|
|
|
|
for _, targetPath := range targetPaths {
|
|
// Slash-form like the stored paths; filepath.Clean would backslash them on Windows.
|
|
cleanPath := path.Clean(strings.TrimPrefix(targetPath, "/"))
|
|
|
|
// Include the target folder itself by ID
|
|
folderIDs = append(folderIDs, model.FolderID(lib, cleanPath))
|
|
|
|
// Include all descendants: folders whose path field equals or starts with the target path
|
|
// Note: Folder.Path is the directory path, so children have path = targetPath
|
|
pathConditions = append(pathConditions, Eq{"path": cleanPath})
|
|
pathConditions = append(pathConditions, Like{"path": cleanPath + "/%"})
|
|
}
|
|
|
|
// Combine conditions: exact folder IDs OR descendant path patterns
|
|
if len(folderIDs) > 0 {
|
|
where = append(where, Or{Eq{"id": folderIDs}, pathConditions})
|
|
} else if len(pathConditions) > 0 {
|
|
where = append(where, pathConditions)
|
|
}
|
|
|
|
return r.queryFolderUpdateInfo(ctx, where)
|
|
}
|
|
|
|
// queryFolderUpdateInfo executes the query and returns the result map
|
|
func (r folderRepository) queryFolderUpdateInfo(ctx context.Context, where And) (map[string]model.FolderUpdateInfo, error) {
|
|
sq := r.newSelect(ctx).Columns("id", "updated_at", "hash", "image_files", "images_updated_at").Where(where)
|
|
var res []struct {
|
|
ID string
|
|
UpdatedAt time.Time
|
|
Hash string
|
|
ImageFiles string
|
|
ImagesUpdatedAt time.Time
|
|
}
|
|
err := r.queryAll(ctx, sq, &res)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
m := make(map[string]model.FolderUpdateInfo, len(res))
|
|
for _, f := range res {
|
|
info := model.FolderUpdateInfo{UpdatedAt: f.UpdatedAt, Hash: f.Hash, ImagesUpdatedAt: f.ImagesUpdatedAt}
|
|
if f.ImageFiles != "" {
|
|
if err := json.Unmarshal([]byte(f.ImageFiles), &info.ImageFiles); err != nil {
|
|
return nil, fmt.Errorf("parsing folder image_files: %w", err)
|
|
}
|
|
}
|
|
m[f.ID] = info
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// subtreePathChunkSize bounds how many paths one folderSubtreeFilter may expand into: each adds
|
|
// 3 OR terms, and SQLite rejects an expression tree deeper than 1000 (measured: 166 paths).
|
|
const subtreePathChunkSize = 100
|
|
|
|
// folderSubtreeFilter matches the folders at the given library-relative paths and all their
|
|
// descendants. A path of "" or "." selects the whole library, so it drops the path conditions.
|
|
func folderSubtreeFilter(lib model.Library, paths []string) Sqlizer {
|
|
conds := make(Or, 0, len(paths)*3)
|
|
for _, p := range paths {
|
|
// Paths are io/fs slash-form; filepath.Clean would backslash them on Windows.
|
|
cleanPath := path.Clean(strings.TrimPrefix(p, "/"))
|
|
if cleanPath == "." {
|
|
return And{Eq{"folder.library_id": lib.ID}, Eq{"folder.missing": false}}
|
|
}
|
|
conds = append(conds,
|
|
Eq{"folder.id": model.FolderID(lib, cleanPath)},
|
|
// Direct children have path = cleanPath; deeper descendants match the prefix
|
|
Eq{"folder.path": cleanPath},
|
|
Expr(`folder.path LIKE ? ESCAPE '\'`, escapeLikePrefix(cleanPath)+"/%"),
|
|
)
|
|
}
|
|
return And{Eq{"folder.library_id": lib.ID}, Eq{"folder.missing": false}, conds}
|
|
}
|
|
|
|
// HasAudioOutsideFolders reports whether any folder in parent's subtree
|
|
// (including parent itself) contains audio files and is not one of the given
|
|
// folder IDs. LIKE wildcards in the parent path are escaped, so it is always
|
|
// matched as a literal prefix.
|
|
func (r folderRepository) HasAudioOutsideFolders(ctx context.Context, parent model.Folder, excludeFolderIDs []string) (bool, error) {
|
|
if parent.NumAudioFiles > 0 {
|
|
return true, nil
|
|
}
|
|
parentPath := strings.TrimPrefix(path.Join(parent.Path, parent.Name), "/")
|
|
return r.exists(ctx, And{
|
|
Eq{"library_id": parent.LibraryID, "missing": false},
|
|
Gt{"num_audio_files": 0},
|
|
NotEq{"id": excludeFolderIDs},
|
|
Or{
|
|
// Direct children have path = parentPath; deeper descendants match the prefix
|
|
Eq{"path": parentPath},
|
|
Expr(`path LIKE ? ESCAPE '\'`, escapeLikePrefix(parentPath)+"/%"),
|
|
},
|
|
})
|
|
}
|
|
|
|
// escapeLikePrefix escapes SQL LIKE wildcards so a string can be used as a
|
|
// literal prefix in a LIKE pattern (with ESCAPE '\').
|
|
func escapeLikePrefix(s string) string {
|
|
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
|
|
}
|
|
|
|
func (r folderRepository) Put(ctx context.Context, f *model.Folder) error {
|
|
dbf := dbFolder{Folder: f}
|
|
_, err := r.put(ctx, dbf.ID, &dbf)
|
|
return err
|
|
}
|
|
|
|
func (r folderRepository) MarkMissing(ctx context.Context, missing bool, ids ...string) error {
|
|
log.Debug(ctx, "Marking folders as missing", "ids", ids, "missing", missing)
|
|
for chunk := range slices.Chunk(ids, 200) {
|
|
sq := Update(r.tableName).
|
|
Set("missing", missing).
|
|
Set("updated_at", time.Now()).
|
|
Where(Eq{"id": chunk})
|
|
_, err := r.executeSQL(ctx, sq)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r folderRepository) GetTouchedWithPlaylists(ctx context.Context) (model.FolderCursor, error) {
|
|
query := r.selectFolder(ctx).Where(And{
|
|
Eq{"missing": false},
|
|
Gt{"num_playlists": 0},
|
|
ConcatExpr("folder.updated_at > library.last_scan_at"),
|
|
})
|
|
cursor, err := queryWithStableResults[dbFolder](ctx, r.sqlRepository, query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return wrapFolderCursor(cursor), nil
|
|
}
|
|
|
|
func (r folderRepository) GetAllWithPlaylists(ctx context.Context) (model.FolderCursor, error) {
|
|
query := r.selectFolder(ctx).Where(And{
|
|
Eq{"missing": false},
|
|
Gt{"num_playlists": 0},
|
|
})
|
|
cursor, err := queryWithStableResults[dbFolder](ctx, r.sqlRepository, query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return wrapFolderCursor(cursor), nil
|
|
}
|
|
|
|
const (
|
|
folderRelPathExpr = `CASE WHEN folder.path = '' OR folder.path = '.' THEN folder.name ELSE folder.path || '/' || folder.name END`
|
|
|
|
folderEscapedRelPathExpr = `REPLACE(REPLACE(REPLACE(` + folderRelPathExpr + `, '\', '\\'), '%', '\%'), '_', '\_')`
|
|
|
|
// folderHasAudioCond checks whether a folder has audio directly, or contains descendant subfolders with audio.
|
|
// Note: The EXISTS subquery performs a prefix check on descendant folders when a direct subfolder has
|
|
// num_audio_files == 0. Because the outer query restricts rows by parent_id (or root parent_id), the number
|
|
// of subquery evaluations is strictly bounded by the direct child count.
|
|
folderHasAudioCond = `(folder.num_audio_files > 0 OR EXISTS (` +
|
|
`SELECT 1 FROM folder sub ` +
|
|
`WHERE sub.library_id = folder.library_id ` +
|
|
`AND sub.missing = 0 ` +
|
|
`AND sub.num_audio_files > 0 ` +
|
|
`AND (sub.path = ` + folderRelPathExpr + ` OR sub.path LIKE ` + folderEscapedRelPathExpr + ` || '/%' ESCAPE '\')` +
|
|
`))`
|
|
)
|
|
|
|
func (r folderRepository) getSubfoldersWithAudio(ctx context.Context, parentCond Sqlizer, libraryIDs ...int) ([]model.Folder, error) {
|
|
cond := And{
|
|
parentCond,
|
|
Eq{"folder.missing": false},
|
|
ConcatExpr(folderHasAudioCond),
|
|
}
|
|
if len(libraryIDs) > 0 {
|
|
cond = append(cond, Eq{"folder.library_id": libraryIDs})
|
|
}
|
|
sq := r.selectFolder(ctx).Where(cond).OrderBy("folder.name COLLATE NOCASE ASC")
|
|
var res dbFolders
|
|
err := r.queryAll(ctx, sq, &res)
|
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
|
return nil, err
|
|
}
|
|
return res.toModels(), nil
|
|
}
|
|
|
|
func (r folderRepository) GetRootSubfoldersWithAudio(ctx context.Context, libraryIDs ...int) ([]model.Folder, error) {
|
|
return r.getSubfoldersWithAudio(ctx,
|
|
ConcatExpr("folder.parent_id IN (SELECT id FROM folder WHERE path = '' AND name = '.')"),
|
|
libraryIDs...,
|
|
)
|
|
}
|
|
|
|
func (r folderRepository) GetSubfoldersWithAudio(ctx context.Context, parentID string, libraryIDs ...int) ([]model.Folder, error) {
|
|
return r.getSubfoldersWithAudio(ctx,
|
|
Eq{"folder.parent_id": parentID},
|
|
libraryIDs...,
|
|
)
|
|
}
|
|
|
|
type folderCoverArtRow struct {
|
|
FolderID string `db:"folder_id"`
|
|
AlbumID string `db:"album_id"`
|
|
ImageHash string `db:"image_hash"`
|
|
ImageAbsent bool `db:"image_absent"`
|
|
}
|
|
|
|
func (r folderRepository) GetCoverArtForFolders(ctx context.Context, folderIDs ...string) (map[string]string, error) {
|
|
result := make(map[string]string, len(folderIDs))
|
|
if len(folderIDs) == 0 {
|
|
return result, nil
|
|
}
|
|
|
|
for chunk := range slices.Chunk(folderIDs, 200) {
|
|
sq := Select(
|
|
"f.id AS folder_id",
|
|
"al.id AS album_id",
|
|
"COALESCE(ia.hash, '') AS image_hash",
|
|
"CASE WHEN ia.item_id IS NOT NULL AND ia.hash = '' THEN 1 ELSE 0 END AS image_absent",
|
|
).
|
|
From("folder f").
|
|
Join("media_file mf ON (mf.folder_id = f.id OR mf.folder_id IN (SELECT d.id FROM folder d WHERE d.parent_id = f.id AND d.missing = 0))").
|
|
Join("album al ON mf.album_id = al.id").
|
|
LeftJoin("item_artwork ia ON ia.item_kind = 'al' AND ia.item_id = al.id AND ia.image_type = 'primary'").
|
|
Where(And{
|
|
Eq{"f.id": chunk},
|
|
Eq{"f.missing": false},
|
|
Eq{"mf.missing": false},
|
|
}).
|
|
GroupBy("f.id").
|
|
Having("f.num_audio_files > 0 OR COUNT(DISTINCT mf.album_id) = 1")
|
|
|
|
var rows []folderCoverArtRow
|
|
err := r.queryAll(ctx, sq, &rows)
|
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
|
return nil, err
|
|
}
|
|
|
|
for _, row := range rows {
|
|
if row.ImageAbsent {
|
|
continue
|
|
}
|
|
artID := model.ArtworkID{
|
|
Kind: model.KindAlbumArtwork,
|
|
ID: row.AlbumID,
|
|
Hash: row.ImageHash,
|
|
}
|
|
result[row.FolderID] = artID.String()
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func wrapFolderCursor(cursor iter.Seq2[dbFolder, error]) model.FolderCursor {
|
|
return model.FolderCursor(wrapCursor(cursor, func(f dbFolder) *model.Folder { return f.Folder }))
|
|
}
|
|
|
|
func (r folderRepository) purgeEmpty(ctx context.Context, libraryIDs ...int) error {
|
|
sq := Delete(r.tableName).Where(And{
|
|
Eq{"num_audio_files": 0},
|
|
Eq{"num_playlists": 0},
|
|
Eq{"image_files": "[]"},
|
|
ConcatExpr("id not in (select parent_id from folder)"),
|
|
ConcatExpr("id not in (select folder_id from media_file)"),
|
|
})
|
|
// If libraryIDs are specified, only purge folders from those libraries
|
|
if len(libraryIDs) > 0 {
|
|
sq = sq.Where(Eq{"library_id": libraryIDs})
|
|
}
|
|
c, err := r.executeSQL(ctx, sq)
|
|
if err != nil {
|
|
return fmt.Errorf("purging empty folders: %w", err)
|
|
}
|
|
if c > 0 {
|
|
log.Debug(ctx, "Purging empty folders", "totalDeleted", c)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var _ model.FolderRepository = (*folderRepository)(nil)
|