mirror of
https://github.com/navidrome/navidrome.git
synced 2026-06-19 07:37:15 +00:00
2c90685bc2
* 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>
302 lines
8.8 KiB
Go
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)
|