navidrome/scanner/phase_1_folders.go
Deluan Quintão 758e64c999
feat(scanner): per-library PID configuration (#6252)
* feat(model): add per-library PID config columns

* refactor(metadata): pass PID config to ToMediaFile and add spec validation

* feat(scanner): rescan only libraries whose PID config changed

* feat(server): validate library PID config and rescan on change

* feat(ui): edit per-library PID config

* fix(ui): label the PID mode selects

* fix: tighten per-library PID rescan edge cases

An interrupted PID rescan no longer upgrades every library to a full scan, a save that loses the race for the scanner logs at debug, the confirm dialog only shows when the effective PID spec changes, and it now gets translation keys.

* refactor(metadata): pass the library to ToMediaFile

ToMediaFile and core.Inspect took the library ID and its PID config as
separate arguments, so a caller could mix values from two libraries. They
now take the model.Library and resolve the effective PID config from it.

* chore: tidy per-library PID comments, PropTypes and migration

Trim comments that restated the code, add PropTypes to the new UI
components, and recreate the migration with make migration-sql.

* fix(ui): show the PID spec help under its input

* feat(cmd): make inspect use the file's library PID config

inspect always used the global PID config, so it showed different IDs than
the scanner for files in a library with an override. It now finds the
file's library in the DB and uses its effective config, falling back to
the global config when there is no DB or the file is outside every
library. It never creates a DB. The library path matcher moves from
core/playlists to model so both can use it.

* refactor: simplify per-library PID code

Share the DB-file check between CLI commands, move ErrAlreadyScanning to
model so core no longer imports scanner, read the libraries once for
insights, and let ValidatePIDSpec accept an empty spec and look tags up
directly. In the scanner, use FullScanInProgress instead of a second
flag, and skip recomputing album IDs when the album spec did not change.
In the UI, share the PID inputs between Create and Edit, and use docsUrl.

* feat(ui): add section titles to Library Create and pre-fill Custom PID specs

Custom now starts from the global spec, so admins edit a working spec
instead of typing one from scratch.

* fix(inspect): map files with the library-relative path the scanner uses

Inspect gave metadata the file's directory as typed, so folder-based PIDs
never matched the DB. It now uses the path relative to the library root,
through the scanner's helper, which moves to model.

* fix(scanner): say when a PID rescan only covers target folders

* fix: reject tag aliases in album PID specs and match root libraries

Tags are stored under canonical names, so an alias in a spec always reads
as empty. In an album spec that gives every album the same ID, so album
specs now require the tag name. Track specs keep accepting aliases, since
the default one uses them. LibraryMatcher now matches paths under a
library at the filesystem root.

* refactor(model): move the tag alias lookup to tag_mappings.go

* test: run the library matcher and inspect tests on Windows

Build test paths with filepath instead of Unix literals, so they use the
OS separator like filepath.Abs output, and drop the Windows skips.

* feat(ui): add pt-BR translations for per-library PID settings
2026-10-02 05:05:32 -04:00

549 lines
21 KiB
Go

package scanner
import (
"cmp"
"context"
"errors"
"fmt"
"maps"
"path"
"slices"
"sync"
"sync/atomic"
"time"
"github.com/Masterminds/squirrel"
ppl "github.com/google/go-pipeline/pkg/pipeline"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/storage"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/metadata"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/pl"
"github.com/navidrome/navidrome/utils/slice"
)
func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStore) *phaseFolders {
var jobs []*scanJob
// Create scan jobs for all libraries
for _, lib := range state.libraries {
// Get target folders for this library if selective scan
var targetFolders []string
if state.isSelectiveScan() {
targetFolders = state.targets[lib.ID]
}
job, err := newScanJob(ctx, ds, lib, state.fullScan, targetFolders)
if err != nil {
log.Error(ctx, "Scanner: Error creating scan context", "lib", lib.Name, err)
state.sendError(err)
state.markFailed(lib.ID)
continue
}
jobs = append(jobs, job)
}
walkCtx, stopWalk := context.WithCancelCause(ctx)
return &phaseFolders{jobs: jobs, ctx: ctx, walkCtx: walkCtx, stopWalk: stopWalk, ds: ds, state: state,
imageChanges: &imageChangeCollector{ds: ds}}
}
type scanJob struct {
lib model.Library
fs storage.MusicFS
lastUpdates map[string]model.FolderUpdateInfo // Holds last update info for all (DB) folders in this library
targetFolders []string // Specific folders to scan (including all descendants)
prevAlbumPIDConf string // Album PID spec of the last finished scan, only when it differs from the current one
lock sync.Mutex
numFolders atomic.Int64
}
func newScanJob(ctx context.Context, ds model.DataStore, lib model.Library, fullScan bool, targetFolders []string) (*scanJob, error) {
// Get folder updates, optionally filtered to specific target folders
lastUpdates, err := ds.Folder().GetFolderUpdateInfo(ctx, lib, targetFolders...)
if err != nil {
return nil, fmt.Errorf("getting last updates: %w", err)
}
fileStore, err := storage.For(lib.Path)
if err != nil {
log.Error(ctx, "Error getting storage for library", "library", lib.Name, "path", lib.Path, err)
return nil, fmt.Errorf("getting storage for library: %w", err)
}
fsys, err := fileStore.FS()
if err != nil {
log.Error(ctx, "Error getting fs for library", "library", lib.Name, "path", lib.Path, err)
return nil, fmt.Errorf("getting fs for library: %w", err)
}
pid := lib.EffectivePID()
if lib.NeedsPIDRescan() {
msg := "Scanner: PID config changed, rescanning library in full"
if len(targetFolders) > 0 {
msg = "Scanner: PID config changed, rescanning target folders in full"
}
log.Info(ctx, msg, "lib", lib.Name, "targetFolders", targetFolders,
"album", pid.Album, "track", pid.Track, "scannedAlbum", lib.ScannedPIDAlbum, "scannedTrack", lib.ScannedPIDTrack)
fullScan = true
}
var prevAlbumPIDConf string
if lib.ScannedPIDAlbum != pid.Album {
prevAlbumPIDConf = lib.ScannedPIDAlbum
}
// Ensure FullScanInProgress reflects the current scan request.
// This is important when resuming an interrupted quick scan as a full scan:
// the DB may have FullScanInProgress=false, but we need it true for isOutdated() to work correctly.
lib.FullScanInProgress = lib.FullScanInProgress || fullScan
return &scanJob{
lib: lib,
fs: fsys,
lastUpdates: lastUpdates,
targetFolders: targetFolders,
prevAlbumPIDConf: prevAlbumPIDConf,
}, nil
}
// popLastUpdate retrieves and removes the last update info for the given folder ID
// This is used to track which folders have been found during the walk_dir_tree
func (j *scanJob) popLastUpdate(folderID string) model.FolderUpdateInfo {
j.lock.Lock()
defer j.lock.Unlock()
lastUpdate := j.lastUpdates[folderID]
delete(j.lastUpdates, folderID)
return lastUpdate
}
// createFolderEntry creates a new folderEntry for the given path, using the last update info from the job
// to populate the previous update time and hash. It also removes the folder from the job's lastUpdates map.
// This is used to track which folders have been found during the walk_dir_tree.
func (j *scanJob) createFolderEntry(path string) *folderEntry {
id := model.FolderID(j.lib, path)
info := j.popLastUpdate(id)
return newFolderEntry(j, id, path, info)
}
// phaseFolders represents the first phase of the scanning process, which is responsible
// for scanning all libraries and importing new or updated files. This phase involves
// traversing the directory tree of each library, identifying new or modified media files,
// and updating the database with the relevant information.
//
// The phaseFolders struct holds the context, data store, and jobs required for the scanning
// process. Each job represents a library being scanned, and contains information about the
// library, file system, and the last updates of the folders.
//
// The phaseFolders struct implements the phase interface, providing methods to produce
// folder entries, process folders, persist changes to the database, and log the results.
type phaseFolders struct {
jobs []*scanJob
ds model.DataStore
ctx context.Context //nolint:containedctx // phase runs under a single scan ctx
walkCtx context.Context //nolint:containedctx // cancelled when a folder fails to persist, so the walk stops early
stopWalk context.CancelCauseFunc
state *scanState
imageChanges *imageChangeCollector
}
func (p *phaseFolders) description() string {
return "Scan all libraries and import new/updated files"
}
func (p *phaseFolders) producer() ppl.Producer[*folderEntry] {
return ppl.NewProducer(func(put func(entry *folderEntry)) error {
// TODO Parallelize multiple job when we have multiple libraries
var total int64
var totalChanged int64
for _, job := range p.jobs {
if utils.IsCtxDone(p.walkCtx) {
break
}
outputChan, err := walkDirTree(p.walkCtx, job, job.targetFolders...)
if err != nil {
log.Warn(p.ctx, "Scanner: Error scanning library", "lib", job.lib.Name, err)
}
for folder := range pl.ReadOrDone(p.walkCtx, outputChan) {
job.numFolders.Add(1)
p.state.sendProgress(&ProgressInfo{
LibID: job.lib.ID,
FileCount: uint32(len(folder.audioFiles)),
Path: folder.path,
Phase: "1",
})
// Log folder info
log.Trace(p.ctx, "Scanner: Checking folder state", " folder", folder.path, "_updTime", folder.updTime,
"_modTime", folder.modTime, "_lastScanStartedAt", folder.job.lib.LastScanStartedAt,
"numAudioFiles", len(folder.audioFiles), "numImageFiles", len(folder.imageFiles),
"numPlaylists", len(folder.playlistFiles), "numSubfolders", folder.numSubFolders)
// Check if folder is outdated
if folder.isOutdated() {
if !folder.job.lib.FullScanInProgress {
// Ancestor folders need a row even with no files of their own: artwork
// resolution climbs them, and an image added later needs a state to diff.
if folder.isEmpty() && folder.isNew() {
log.Trace(p.ctx, "Scanner: Skipping new empty folder", "folder", folder.path, "lib", job.lib.Name)
continue
}
log.Debug(p.ctx, "Scanner: Detected changes in folder", "folder", folder.path, "lastUpdate", folder.modTime, "lib", job.lib.Name)
}
totalChanged++
folder.elapsed.Stop()
put(folder)
} else {
log.Trace(p.ctx, "Scanner: Skipping up-to-date folder", "folder", folder.path, "lastUpdate", folder.modTime, "lib", job.lib.Name)
}
}
total += job.numFolders.Load()
}
log.Debug(p.ctx, "Scanner: Finished loading all folders", "numFolders", total, "numChanged", totalChanged)
return nil
}, ppl.Name("traverse filesystem"))
}
func (p *phaseFolders) measure(entry *folderEntry) func() time.Duration {
entry.elapsed.Start()
return func() time.Duration { return entry.elapsed.Stop() }
}
func (p *phaseFolders) stages() []ppl.Stage[*folderEntry] {
return []ppl.Stage[*folderEntry]{
ppl.NewStage(p.processFolder, ppl.Name("process folder"), ppl.Concurrency(conf.Server.DevScannerThreads)),
// persistChanges is not reentrant, so it always has to run with concurrency=1
ppl.NewStage(p.persistChanges, ppl.Name("persist changes"), ppl.Concurrency(1)),
ppl.NewStage(p.logFolder, ppl.Name("log results")),
}
}
func (p *phaseFolders) processFolder(entry *folderEntry) (*folderEntry, error) {
defer p.measure(entry)()
if err := context.Cause(p.walkCtx); err != nil {
return entry, err
}
// Load children mediafiles from DB
cursor, err := p.ds.MediaFile().GetCursor(p.ctx, model.QueryOptions{
Filters: squirrel.And{squirrel.Eq{"folder_id": entry.id}},
})
if err != nil {
log.Error(p.ctx, "Scanner: Error loading mediafiles from DB", "folder", entry.path, err)
return entry, err
}
dbTracks := make(map[string]*model.MediaFile)
for mf, err := range cursor {
if err != nil {
log.Error(p.ctx, "Scanner: Error loading mediafiles from DB", "folder", entry.path, err)
return entry, err
}
dbTracks[mf.Path] = &mf
}
// Get list of files to import, based on modtime (or all if fullScan),
// leave in dbTracks only tracks that are missing (not found in the FS)
filesToImport := make(map[string]*model.MediaFile, len(entry.audioFiles))
for afPath, af := range entry.audioFiles {
fullPath := path.Join(entry.path, afPath)
dbTrack, foundInDB := dbTracks[fullPath]
if !foundInDB || entry.job.lib.FullScanInProgress {
filesToImport[fullPath] = dbTrack
} else {
info, err := af.Info()
if err != nil {
log.Warn(p.ctx, "Scanner: Error getting file info", "folder", entry.path, "file", af.Name(), err)
p.state.sendWarning(fmt.Sprintf("Error getting file info for %s/%s: %v", entry.path, af.Name(), err))
return entry, nil
}
if info.ModTime().After(dbTrack.UpdatedAt) || dbTrack.Missing {
filesToImport[fullPath] = dbTrack
}
}
delete(dbTracks, fullPath)
}
// Remaining dbTracks are tracks that were not found in the FS, so they should be marked as missing
entry.missingTracks = slices.Collect(maps.Values(dbTracks))
// Load metadata from files that need to be imported
if len(filesToImport) > 0 {
err = p.loadTagsFromFiles(entry, filesToImport)
if err != nil {
log.Warn(p.ctx, "Scanner: Error loading tags from files. Skipping", "folder", entry.path, err)
p.state.sendWarning(fmt.Sprintf("Error loading tags from files in %s: %v", entry.path, err))
return entry, nil
}
p.createAlbumsFromMediaFiles(entry)
p.createArtistsFromMediaFiles(entry)
}
return entry, nil
}
const filesBatchSize = 200
// loadTagsFromFiles reads metadata from the files in the given list and populates
// the entry's tracks and tags with the results.
func (p *phaseFolders) loadTagsFromFiles(entry *folderEntry, toImport map[string]*model.MediaFile) error {
tracks := make([]model.MediaFile, 0, len(toImport))
uniqueTags := make(map[string]model.Tag, len(toImport))
for chunk := range slice.CollectChunks(maps.Keys(toImport), filesBatchSize) {
allInfo, err := entry.job.fs.ReadTags(chunk...)
if err != nil {
log.Warn(p.ctx, "Scanner: Error extracting metadata from files. Skipping", "folder", entry.path, err)
return err
}
for filePath, info := range allInfo {
md := metadata.New(filePath, info)
track := md.ToMediaFile(entry.job.lib, entry.id)
tracks = append(tracks, track)
for _, t := range track.Tags.FlattenAll() {
uniqueTags[t.ID] = t
}
// Keep track of any album ID changes, to reassign annotations later
prevAlbumID := track.AlbumID
if prev := toImport[filePath]; prev != nil {
prevAlbumID = prev.AlbumID
} else if entry.job.prevAlbumPIDConf != "" {
prevAlbumID = md.AlbumID(track, entry.job.prevAlbumPIDConf)
}
_, ok := entry.albumIDMap[track.AlbumID]
if prevAlbumID != track.AlbumID && !ok {
entry.albumIDMap[track.AlbumID] = prevAlbumID
}
}
}
entry.tracks = tracks
entry.tags = slices.Collect(maps.Values(uniqueTags))
return nil
}
// createAlbumsFromMediaFiles groups the entry's tracks by album ID and creates albums
func (p *phaseFolders) createAlbumsFromMediaFiles(entry *folderEntry) {
grouped := slice.Group(entry.tracks, func(mf model.MediaFile) string { return mf.AlbumID })
albums := make(model.Albums, 0, len(grouped))
for _, group := range grouped {
songs := model.MediaFiles(group)
album := songs.ToAlbum()
albums = append(albums, album)
}
entry.albums = albums
}
// createArtistsFromMediaFiles creates artists from the entry's tracks
func (p *phaseFolders) createArtistsFromMediaFiles(entry *folderEntry) {
participants := make(model.Participants, len(entry.tracks)*3) // preallocate ~3 artists per track
for _, track := range entry.tracks {
participants.Merge(track.Participants)
}
entry.artists = participants.AllArtists()
}
func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) {
defer p.measure(entry)()
p.state.changesDetected.Store(true)
ctx := log.NewContext(p.ctx, "folder", entry.path)
err := p.ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error {
return p.persistFolder(ctx, tx, entry)
}, "scanner: persist changes")
if err != nil {
log.Error(ctx, "Scanner: Error persisting changes to DB", err)
p.stopWalk(err)
return entry, err
}
// A new folder's albums/artists were enqueued with it; only pre-existing folders need the diff.
if !entry.isNew() {
if changed, artistImage := entry.imagesChanged(); changed {
p.imageChanges.record(entry.job.lib, imageChangedFolder{
id: entry.id, path: entry.path, artistImage: artistImage,
})
}
}
return entry, nil
}
// persistFolder writes the folder in tx. WithTxRetry may rerun it after a rollback.
func (p *phaseFolders) persistFolder(ctx context.Context, tx model.DataStore, entry *folderEntry) error {
// Collect artwork queue items for changed albums/artists, enqueued in the same transaction
var queueItems []model.ArtworkQueueItem
// persistAlbum consumes the map, so a rerun needs the original
albumIDMap := maps.Clone(entry.albumIDMap)
// Instantiate all repositories just once per folder
folderRepo := tx.Folder()
tagRepo := tx.Tag()
artistRepo := tx.Artist()
libraryRepo := tx.Library()
albumRepo := tx.Album()
mfRepo := tx.MediaFile()
// Save folder to DB
folder := entry.toFolder()
err := folderRepo.Put(ctx, folder)
if err != nil {
return fmt.Errorf("persisting folder: %w", err)
}
// Save all tags to DB
err = tagRepo.Add(ctx, entry.job.lib.ID, entry.tags...)
if err != nil {
return fmt.Errorf("persisting tags: %w", err)
}
// Save all new/modified artists to DB. Their information will be incomplete, but they will be refreshed later
for i := range entry.artists {
err = artistRepo.Put(ctx, &entry.artists[i], "name",
"mbz_artist_id", "sort_artist_name", "order_artist_name", "full_text", "search_normalized", "updated_at")
if err != nil {
return fmt.Errorf("persisting artist %q: %w", entry.artists[i].Name, err)
}
err = libraryRepo.AddArtist(ctx, entry.job.lib.ID, entry.artists[i].ID)
if err != nil {
return fmt.Errorf("adding artist %q to library: %w", entry.artists[i].Name, err)
}
if entry.artists[i].Name != consts.UnknownArtist && entry.artists[i].Name != consts.VariousArtists {
queueItems = append(queueItems, scanArtworkItem(model.KindArtistArtwork, entry.artists[i].ID))
}
}
// Save all new/modified albums to DB. Their information will be incomplete, but they will be refreshed later
for i := range entry.albums {
err = p.persistAlbum(albumRepo, &entry.albums[i], albumIDMap)
if err != nil {
return err
}
if entry.albums[i].Name != consts.UnknownAlbum {
queueItems = append(queueItems, scanArtworkItem(model.KindAlbumArtwork, entry.albums[i].ID))
}
}
// Save all tracks to DB
for i := range entry.tracks {
err = mfRepo.Put(ctx, &entry.tracks[i])
if err != nil {
return fmt.Errorf("persisting track %q: %w", entry.tracks[i].Path, err)
}
}
// A re-imported track returns to unresolved so new embedded art is picked up lazily.
if len(entry.tracks) > 0 {
trackIDs := slice.Map(entry.tracks, func(t model.MediaFile) string { return t.ID })
if err := tx.Artwork().DeleteForItems(ctx, model.KindMediaFileArtwork, trackIDs); err != nil {
log.Warn(ctx, "Scanner: could not invalidate media_file artwork", err)
}
}
// Mark all missing tracks as not available
if len(entry.missingTracks) > 0 {
err = mfRepo.MarkMissing(ctx, true, entry.missingTracks...)
if err != nil {
return fmt.Errorf("marking missing tracks: %w", err)
}
// Touch all albums that have missing tracks, so they get refreshed in later phases
groupedMissingTracks := slice.ToMap(entry.missingTracks, func(mf *model.MediaFile) (string, struct{}) {
return mf.AlbumID, struct{}{}
})
albumsToUpdate := slices.Collect(maps.Keys(groupedMissingTracks))
err = albumRepo.Touch(ctx, albumsToUpdate...)
if err != nil {
return fmt.Errorf("touching albums %v: %w", albumsToUpdate, err)
}
}
// Enqueue artwork resolution for changed albums/artists. Never fails the scan.
// A full scan re-imports every track, so a re-import is no evidence the art changed.
if len(queueItems) > 0 {
queue := tx.ArtworkQueue()
enqueue := queue.Enqueue
if entry.job.lib.FullScanInProgress {
enqueue = queue.EnqueueIfMissing
}
if err := enqueue(ctx, queueItems...); err != nil {
log.Warn(ctx, "Scanner: could not enqueue artwork resolution", err)
}
}
return nil
}
// persistAlbum persists the given album to the database, and reassigns annotations from the previous album ID
func (p *phaseFolders) persistAlbum(repo model.AlbumRepository, a *model.Album, idMap map[string]string) error {
prevID := idMap[a.ID]
log.Trace(p.ctx, "Persisting album", "album", a.Name, "albumArtist", a.AlbumArtist, "id", a.ID, "prevID", cmp.Or(prevID, "nil"))
if err := repo.Put(p.ctx, a); err != nil {
return fmt.Errorf("persisting album %s: %w", a.ID, err)
}
if prevID == "" {
return nil
}
// Reassign annotation from previous album to new album
log.Trace(p.ctx, "Reassigning album annotations", "from", prevID, "to", a.ID, "album", a.Name)
if err := repo.ReassignAnnotation(p.ctx, prevID, a.ID); err != nil {
log.Warn(p.ctx, "Scanner: Could not reassign annotations", "from", prevID, "to", a.ID, "album", a.Name, err)
p.state.sendWarning(fmt.Sprintf("Could not reassign annotations from %s to %s ('%s'): %v", prevID, a.ID, a.Name, err))
}
// Keep created_at field from previous instance of the album
if err := repo.CopyAttributes(p.ctx, prevID, a.ID, "created_at"); err != nil {
// Silently ignore when the previous album is not found
if !errors.Is(err, model.ErrNotFound) {
log.Warn(p.ctx, "Scanner: Could not copy fields", "from", prevID, "to", a.ID, "album", a.Name, err)
p.state.sendWarning(fmt.Sprintf("Could not copy fields from %s to %s ('%s'): %v", prevID, a.ID, a.Name, err))
}
}
// Don't keep track of this mapping anymore
delete(idMap, a.ID)
return nil
}
func (p *phaseFolders) logFolder(entry *folderEntry) (*folderEntry, error) {
logCall := log.Info
if entry.isEmpty() {
logCall = log.Trace
}
logCall(p.ctx, "Scanner: Completed processing folder",
"audioCount", len(entry.audioFiles), "imageCount", len(entry.imageFiles), "plsCount", len(entry.playlistFiles),
"elapsed", entry.elapsed.Elapsed(), "tracksMissing", len(entry.missingTracks),
"tracksImported", len(entry.tracks), "library", entry.job.lib.Name, consts.Zwsp+"folder", entry.path)
return entry, nil
}
func (p *phaseFolders) finalize(err error) error {
p.stopWalk(nil)
defer p.imageChanges.enqueue(p.ctx)
// A failed phase may not have walked every folder, and unvisited ones must not be marked missing
if err != nil {
return err
}
return p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error {
for _, job := range p.jobs {
// Mark all folders that were not updated as missing
if len(job.lastUpdates) == 0 {
continue
}
folderIDs := slices.Collect(maps.Keys(job.lastUpdates))
if err := tx.Folder().MarkMissing(ctx, true, folderIDs...); err != nil {
return fmt.Errorf("marking missing folders in %s: %w", job.lib.Name, err)
}
if err := tx.MediaFile().MarkMissingByFolder(ctx, true, folderIDs...); err != nil {
return fmt.Errorf("marking tracks in missing folders in %s: %w", job.lib.Name, err)
}
// Touch all albums that have missing folders, so they get refreshed in later phases
if _, err := tx.Album().TouchByMissingFolder(ctx); err != nil {
return fmt.Errorf("touching albums with missing folders in %s: %w", job.lib.Name, err)
}
}
return nil
}, "scanner: finalize phaseFolders")
}
var _ phase[*folderEntry] = (*phaseFolders)(nil)