Files
navidrome/persistence/folder_repository.go
Deluan Quintão 2c90685bc2 fix(scanner): import playlists skipped when no admin existed yet (#5609)
* fix(scanner): import playlists skipped when no admin existed yet (#5499)

On a fresh install the first scan runs before any admin user exists, so the
scanner's playlist phase skips all playlists (playlists are owned by the
first admin). Nothing re-imported them afterwards because folder selection
is gated on updated_at > last_scan_at, which nothing bumps.

The playlist phase now:
- resolves the admin at phase time (FindFirstAdmin) instead of trusting the
  context snapshot taken at scan start, so a long admin-less scan still
  imports playlists in its own phase if an admin was created meanwhile;
- records a persisted PlaylistsImportPending flag when no admin exists yet;
- when that flag is set, imports ALL playlist folders via a new
  GetAllWithPlaylists (bypassing the timestamp gate) and clears the flag.

Playlists are recovered by the next scan that runs with an admin, with no
dependency on scan duration and no changes to the auth/server layers.

* fix(scanner): surface datastore errors in playlist import deferral (#5499)

Address review feedback:
- distinguish model.ErrNotFound (no admin yet -> defer) from real datastore
  errors when resolving the admin, so DB failures are propagated, not swallowed;
- propagate the error if the pending-import flag can't be persisted, so a scan
  doesn't complete as successful without recording the recovery;
- surface read errors when checking the pending flag.

Also name the no-admin condition for readability.

* fix(scanner): simplify admin existence check in playlist import

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(scanner): streamline folder access in playlist import logic

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-06-14 13:39:16 -04:00

302 lines
8.8 KiB
Go

package persistence
import (
"context"
"encoding/json"
"fmt"
"iter"
"maps"
"os"
"path"
"path/filepath"
"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:"-"`
}
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(ctx context.Context, db dbx.Builder) model.FolderRepository {
r := &folderRepository{}
r.ctx = ctx
r.db = db
r.tableName = "folder"
return r
}
func (r folderRepository) selectFolder(options ...model.QueryOptions) SelectBuilder {
sql := r.newSelect(options...).Columns("folder.*", "library.path as library_path").
Join("library on library.id = folder.library_id")
return r.applyLibraryFilter(sql)
}
func (r folderRepository) Get(id string) (*model.Folder, error) {
sq := r.selectFolder().Where(Eq{"folder.id": id})
var res dbFolder
err := r.queryOne(sq, &res)
return res.Folder, err
}
func (r folderRepository) GetByPath(lib model.Library, path string) (*model.Folder, error) {
id := model.NewFolder(lib, path).ID
return r.Get(id)
}
func (r folderRepository) GetAll(opt ...model.QueryOptions) ([]model.Folder, error) {
sq := r.selectFolder(opt...)
var res dbFolders
err := r.queryAll(sq, &res)
return res.toModels(), err
}
func (r folderRepository) CountAll(opt ...model.QueryOptions) (int64, error) {
query := r.newSelect(opt...).Columns("count(*)")
query = r.applyLibraryFilter(query)
return r.count(query)
}
func (r folderRepository) GetFolderUpdateInfo(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(lib)
}
// Check if any path is root (return all folders)
for _, targetPath := range targetPaths {
if targetPath == "" || targetPath == "." {
return r.getFolderUpdateInfoAll(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(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(lib model.Library) (map[string]model.FolderUpdateInfo, error) {
where := And{
Eq{"library_id": lib.ID},
Eq{"missing": false},
}
return r.queryFolderUpdateInfo(where)
}
// getFolderUpdateInfoBatch returns update info for a batch of target paths and their descendants
func (r folderRepository) getFolderUpdateInfoBatch(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 {
// Clean the path to normalize it. Paths stored in the folder table do not have leading/trailing slashes.
cleanPath := strings.TrimPrefix(targetPath, string(os.PathSeparator))
cleanPath = filepath.Clean(cleanPath)
// 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(where)
}
// queryFolderUpdateInfo executes the query and returns the result map
func (r folderRepository) queryFolderUpdateInfo(where And) (map[string]model.FolderUpdateInfo, error) {
sq := r.newSelect().Columns("id", "updated_at", "hash").Where(where)
var res []struct {
ID string
UpdatedAt time.Time
Hash string
}
err := r.queryAll(sq, &res)
if err != nil {
return nil, err
}
m := make(map[string]model.FolderUpdateInfo, len(res))
for _, f := range res {
m[f.ID] = model.FolderUpdateInfo{UpdatedAt: f.UpdatedAt, Hash: f.Hash}
}
return m, nil
}
// 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(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(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(f *model.Folder) error {
dbf := dbFolder{Folder: f}
_, err := r.put(dbf.ID, &dbf)
return err
}
func (r folderRepository) MarkMissing(missing bool, ids ...string) error {
log.Debug(r.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(sq)
if err != nil {
return err
}
}
return nil
}
func (r folderRepository) GetTouchedWithPlaylists() (model.FolderCursor, error) {
query := r.selectFolder().Where(And{
Eq{"missing": false},
Gt{"num_playlists": 0},
ConcatExpr("folder.updated_at > library.last_scan_at"),
})
cursor, err := queryWithStableResults[dbFolder](r.sqlRepository, query)
if err != nil {
return nil, err
}
return wrapFolderCursor(cursor), nil
}
func (r folderRepository) GetAllWithPlaylists() (model.FolderCursor, error) {
query := r.selectFolder().Where(And{
Eq{"missing": false},
Gt{"num_playlists": 0},
})
cursor, err := queryWithStableResults[dbFolder](r.sqlRepository, query)
if err != nil {
return nil, err
}
return wrapFolderCursor(cursor), nil
}
func wrapFolderCursor(cursor iter.Seq2[dbFolder, error]) model.FolderCursor {
return func(yield func(model.Folder, error) bool) {
for f, err := range cursor {
if f.Folder == nil {
yield(model.Folder{}, fmt.Errorf("unexpected nil folder (%v): %w", f, err))
return
}
if !yield(*f.Folder, err) || err != nil {
return
}
}
}
}
func (r folderRepository) purgeEmpty(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(sq)
if err != nil {
return fmt.Errorf("purging empty folders: %w", err)
}
if c > 0 {
log.Debug(r.ctx, "Purging empty folders", "totalDeleted", c)
}
return nil
}
var _ model.FolderRepository = (*folderRepository)(nil)