Merge branch 'master' into msi-insights-detection

This commit is contained in:
Rob Emery 2025-05-17 13:00:05 -01:00 committed by GitHub
commit 62acfd491b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 299 additions and 167 deletions

View File

@ -36,8 +36,9 @@ watch: ##@Development Start Go tests in watch mode (re-run when code changes)
go tool ginkgo watch -tags=netgo -notify ./... go tool ginkgo watch -tags=netgo -notify ./...
.PHONY: watch .PHONY: watch
PKG ?= ./...
test: ##@Development Run Go tests test: ##@Development Run Go tests
go test -tags netgo ./... go test -tags netgo $(PKG)
.PHONY: test .PHONY: test
testrace: ##@Development Run Go tests with race detector testrace: ##@Development Run Go tests with race detector

View File

@ -132,6 +132,7 @@ type scannerOptions struct {
ArtistJoiner string ArtistJoiner string
GenreSeparators string // Deprecated: Use Tags.genre.Split instead GenreSeparators string // Deprecated: Use Tags.genre.Split instead
GroupAlbumReleases bool // Deprecated: Use PID.Album instead GroupAlbumReleases bool // Deprecated: Use PID.Album instead
FollowSymlinks bool // Whether to follow symlinks when scanning directories
} }
type subsonicOptions struct { type subsonicOptions struct {
@ -499,6 +500,7 @@ func init() {
viper.SetDefault("scanner.artistjoiner", consts.ArtistJoiner) viper.SetDefault("scanner.artistjoiner", consts.ArtistJoiner)
viper.SetDefault("scanner.genreseparators", "") viper.SetDefault("scanner.genreseparators", "")
viper.SetDefault("scanner.groupalbumreleases", false) viper.SetDefault("scanner.groupalbumreleases", false)
viper.SetDefault("scanner.followsymlinks", true)
viper.SetDefault("subsonic.appendsubtitle", true) viper.SetDefault("subsonic.appendsubtitle", true)
viper.SetDefault("subsonic.artistparticipations", false) viper.SetDefault("subsonic.artistparticipations", false)

View File

@ -315,7 +315,7 @@ func (r *albumRepository) GetTouchedAlbums(libID int) (model.AlbumCursor, error)
// RefreshPlayCounts updates the play count and last play date annotations for all albums, based // RefreshPlayCounts updates the play count and last play date annotations for all albums, based
// on the media files associated with them. // on the media files associated with them.
func (r *albumRepository) RefreshPlayCounts() (int64, error) { func (r *albumRepository) RefreshPlayCounts() (int64, error) {
query := rawSQL(` query := Expr(`
with play_counts as ( with play_counts as (
select user_id, album_id, sum(play_count) as total_play_count, max(play_date) as last_play_date select user_id, album_id, sum(play_count) as total_play_count, max(play_date) as last_play_date
from media_file from media_file

View File

@ -239,7 +239,7 @@ func (r *artistRepository) purgeEmpty() error {
// RefreshPlayCounts updates the play count and last play date annotations for all artists, based // RefreshPlayCounts updates the play count and last play date annotations for all artists, based
// on the media files associated with them. // on the media files associated with them.
func (r *artistRepository) RefreshPlayCounts() (int64, error) { func (r *artistRepository) RefreshPlayCounts() (int64, error) {
query := rawSQL(` query := Expr(`
with play_counts as ( with play_counts as (
select user_id, atom as artist_id, sum(play_count) as total_play_count, max(play_date) as last_play_date select user_id, atom as artist_id, sum(play_count) as total_play_count, max(play_date) as last_play_date
from media_file from media_file
@ -259,76 +259,123 @@ on conflict (user_id, item_id, item_type) do update
return r.executeSQL(query) return r.executeSQL(query)
} }
// RefreshStats updates the stats field for all artists, based on the media files associated with them. // RefreshStats updates the stats field for artists whose associated media files were updated after the oldest recorded library scan time.
// BFR Maybe filter by "touched" artists? // It processes artists in batches to handle potentially large updates.
func (r *artistRepository) RefreshStats() (int64, error) { func (r *artistRepository) RefreshStats() (int64, error) {
// First get all counters, one query groups by artist/role, and another with totals per artist. touchedArtistsQuerySQL := `
// Union both queries and group by artist to get a single row of counters per artist/role. SELECT DISTINCT mfa.artist_id
// Then format the counters in a JSON object, one key for each role. FROM media_file_artists mfa
// Finally update the artist table with the new counters JOIN media_file mf ON mfa.media_file_id = mf.id
// In all queries, atom is the artist ID and path is the role (or "total" for the totals) WHERE mf.updated_at > (SELECT last_scan_at FROM library ORDER BY last_scan_at ASC LIMIT 1)
query := rawSQL(` `
-- CTE to get counters for each artist, grouped by role
with artist_role_counters as (
-- Get counters for each artist, grouped by role
-- (remove the index from the role: composer[0] => composer
select atom as artist_id,
substr(
replace(jt.path, '$.', ''),
1,
case when instr(replace(jt.path, '$.', ''), '[') > 0
then instr(replace(jt.path, '$.', ''), '[') - 1
else length(replace(jt.path, '$.', ''))
end
) as role,
count(distinct album_id) as album_count,
count(mf.id) as count,
sum(size) as size
from media_file mf
left join json_tree(participants) jt
where atom is not null and key = 'id'
group by atom, role
),
-- CTE to get the totals for each artist var allTouchedArtistIDs []string
artist_total_counters as ( if err := r.db.NewQuery(touchedArtistsQuerySQL).Column(&allTouchedArtistIDs); err != nil {
select mfa.artist_id, return 0, fmt.Errorf("fetching touched artist IDs: %w", err)
'total' as role, }
count(distinct mf.album_id) as album_count,
count(distinct mf.id) as count,
sum(mf.size) as size
from (select artist_id, media_file_id
from main.media_file_artists) as mfa
join main.media_file mf on mfa.media_file_id = mf.id
group by mfa.artist_id
),
-- CTE to combine role and total counters if len(allTouchedArtistIDs) == 0 {
combined_counters as ( log.Debug(r.ctx, "RefreshStats: No artists to update.")
select artist_id, role, album_count, count, size return 0, nil
from artist_role_counters }
union log.Debug(r.ctx, "RefreshStats: Found artists to update.", "count", len(allTouchedArtistIDs))
select artist_id, role, album_count, count, size
from artist_total_counters
),
-- CTE to format the counters in a JSON object // Template for the batch update with placeholder markers that we'll replace
artist_counters as ( batchUpdateStatsSQL := `
select artist_id as id, WITH artist_role_counters AS (
json_group_object( SELECT jt.atom AS artist_id,
replace(role, '"', ''), substr(
json_object('a', album_count, 'm', count, 's', size) replace(jt.path, '$.', ''),
) as counters 1,
from combined_counters CASE WHEN instr(replace(jt.path, '$.', ''), '[') > 0
group by artist_id THEN instr(replace(jt.path, '$.', ''), '[') - 1
) ELSE length(replace(jt.path, '$.', ''))
END
) AS role,
count(DISTINCT mf.album_id) AS album_count,
count(mf.id) AS count,
sum(mf.size) AS size
FROM media_file mf
JOIN json_tree(mf.participants) jt ON jt.key = 'id' AND jt.atom IS NOT NULL
WHERE jt.atom IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders
GROUP BY jt.atom, role
),
artist_total_counters AS (
SELECT mfa.artist_id,
'total' AS role,
count(DISTINCT mf.album_id) AS album_count,
count(DISTINCT mf.id) AS count,
sum(mf.size) AS size
FROM media_file_artists mfa
JOIN media_file mf ON mfa.media_file_id = mf.id
WHERE mfa.artist_id IN (TOTAL_IDS_PLACEHOLDER) -- Will replace with actual placeholders
GROUP BY mfa.artist_id
),
combined_counters AS (
SELECT artist_id, role, album_count, count, size FROM artist_role_counters
UNION
SELECT artist_id, role, album_count, count, size FROM artist_total_counters
),
artist_counters AS (
SELECT artist_id AS id,
json_group_object(
replace(role, '"', ''),
json_object('a', album_count, 'm', count, 's', size)
) AS counters
FROM combined_counters
GROUP BY artist_id
)
UPDATE artist
SET stats = coalesce((SELECT counters FROM artist_counters ac WHERE ac.id = artist.id), '{}'),
updated_at = datetime(current_timestamp, 'localtime')
WHERE artist.id IN (UPDATE_IDS_PLACEHOLDER) AND artist.id <> '';` // Will replace with actual placeholders
-- Update the artist table with the new counters var totalRowsAffected int64 = 0
update artist const batchSize = 1000
set stats = coalesce((select counters from artist_counters where artist_counters.id = artist.id), '{}'),
updated_at = datetime(current_timestamp, 'localtime') batchCounter := 0
where id <> ''; -- always true, to avoid warnings`) for artistIDBatch := range slice.CollectChunks(slices.Values(allTouchedArtistIDs), batchSize) {
return r.executeSQL(query) batchCounter++
log.Trace(r.ctx, "RefreshStats: Processing batch", "batchNum", batchCounter, "batchSize", len(artistIDBatch))
// Create placeholders for each ID in the IN clauses
placeholders := make([]string, len(artistIDBatch))
for i := range artistIDBatch {
placeholders[i] = "?"
}
// Don't add extra parentheses, the IN clause already expects them in SQL syntax
inClause := strings.Join(placeholders, ",")
// Replace the placeholder markers with actual SQL placeholders
batchSQL := strings.Replace(batchUpdateStatsSQL, "ROLE_IDS_PLACEHOLDER", inClause, 1)
batchSQL = strings.Replace(batchSQL, "TOTAL_IDS_PLACEHOLDER", inClause, 1)
batchSQL = strings.Replace(batchSQL, "UPDATE_IDS_PLACEHOLDER", inClause, 1)
// Create a single parameter array with all IDs (repeated 3 times for each IN clause)
// We need to repeat each ID 3 times (once for each IN clause)
var args []interface{}
for _, id := range artistIDBatch {
args = append(args, id) // For ROLE_IDS_PLACEHOLDER
}
for _, id := range artistIDBatch {
args = append(args, id) // For TOTAL_IDS_PLACEHOLDER
}
for _, id := range artistIDBatch {
args = append(args, id) // For UPDATE_IDS_PLACEHOLDER
}
// Now use Expr with the expanded SQL and all parameters
sqlizer := Expr(batchSQL, args...)
rowsAffected, err := r.executeSQL(sqlizer)
if err != nil {
return totalRowsAffected, fmt.Errorf("executing batch update for artist stats (batch %d): %w", batchCounter, err)
}
totalRowsAffected += rowsAffected
}
log.Debug(r.ctx, "RefreshStats: Successfully updated stats.", "totalArtistsProcessed", len(allTouchedArtistIDs), "totalDBRowsAffected", totalRowsAffected)
return totalRowsAffected, nil
} }
func (r *artistRepository) Search(q string, offset int, size int, includeMissing bool) (model.Artists, error) { func (r *artistRepository) Search(q string, offset int, size int, includeMissing bool) (model.Artists, error) {

View File

@ -57,14 +57,6 @@ func toCamelCase(str string) string {
}) })
} }
// rawSQL is a string that will be used as is in the SQL query executor
// It does not support arguments
type rawSQL string
func (r rawSQL) ToSql() (string, []interface{}, error) {
return string(r), nil, nil
}
func Exists(subTable string, cond squirrel.Sqlizer) existsCond { func Exists(subTable string, cond squirrel.Sqlizer) existsCond {
return existsCond{subTable: subTable, cond: cond, not: false} return existsCond{subTable: subTable, cond: cond, not: false}
} }

View File

@ -136,7 +136,7 @@ func (r *libraryRepository) ScanEnd(id int) error {
return err return err
} }
// https://www.sqlite.org/pragma.html#pragma_optimize // https://www.sqlite.org/pragma.html#pragma_optimize
_, err = r.executeSQL(rawSQL("PRAGMA optimize=0x10012;")) _, err = r.executeSQL(Expr("PRAGMA optimize=0x10012;"))
return err return err
} }

View File

@ -60,7 +60,7 @@ where tag.id = updated_values.id;
` `
for _, table := range []string{"album", "media_file"} { for _, table := range []string{"album", "media_file"} {
start := time.Now() start := time.Now()
query := rawSQL(fmt.Sprintf(template, table)) query := Expr(fmt.Sprintf(template, table))
c, err := r.executeSQL(query) c, err := r.executeSQL(query)
log.Debug(r.ctx, "Updated tag counts", "table", table, "elapsed", time.Since(start), "updated", c) log.Debug(r.ctx, "Updated tag counts", "table", table, "elapsed", time.Since(start), "updated", c)
if err != nil { if err != nil {

View File

@ -11,6 +11,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
@ -266,6 +267,10 @@ func isDirOrSymlinkToDir(fsys fs.FS, baseDir string, dirEnt fs.DirEntry) (bool,
if dirEnt.Type()&fs.ModeSymlink == 0 { if dirEnt.Type()&fs.ModeSymlink == 0 {
return false, nil return false, nil
} }
// If symlinks are disabled, return false for symlinks
if !conf.Server.Scanner.FollowSymlinks {
return false, nil
}
// Does this symlink point to a directory? // Does this symlink point to a directory?
fileInfo, err := fs.Stat(fsys, path.Join(baseDir, dirEnt.Name())) fileInfo, err := fs.Stat(fsys, path.Join(baseDir, dirEnt.Name()))
if err != nil { if err != nil {

View File

@ -8,6 +8,8 @@ import (
"path/filepath" "path/filepath"
"testing/fstest" "testing/fstest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/core/storage"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
@ -17,8 +19,15 @@ import (
var _ = Describe("walk_dir_tree", func() { var _ = Describe("walk_dir_tree", func() {
Describe("walkDirTree", func() { Describe("walkDirTree", func() {
var fsys storage.MusicFS var (
fsys storage.MusicFS
job *scanJob
ctx context.Context
)
BeforeEach(func() { BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ctx = GinkgoT().Context()
fsys = &mockMusicFS{ fsys = &mockMusicFS{
FS: fstest.MapFS{ FS: fstest.MapFS{
"root/a/.ndignore": {Data: []byte("ignored/*")}, "root/a/.ndignore": {Data: []byte("ignored/*")},
@ -32,21 +41,22 @@ var _ = Describe("walk_dir_tree", func() {
"root/d/f1.mp3": {}, "root/d/f1.mp3": {},
"root/d/f2.mp3": {}, "root/d/f2.mp3": {},
"root/d/f3.mp3": {}, "root/d/f3.mp3": {},
"root/e/original/f1.mp3": {},
"root/e/symlink": {Mode: fs.ModeSymlink, Data: []byte("root/e/original")},
}, },
} }
}) job = &scanJob{
It("walks all directories", func() {
job := &scanJob{
fs: fsys, fs: fsys,
lib: model.Library{Path: "/music"}, lib: model.Library{Path: "/music"},
} }
ctx := context.Background() })
// Helper function to call walkDirTree and collect folders from the results channel
getFolders := func() map[string]*folderEntry {
results, err := walkDirTree(ctx, job) results, err := walkDirTree(ctx, job)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
folders := map[string]*folderEntry{} folders := map[string]*folderEntry{}
g := errgroup.Group{} g := errgroup.Group{}
g.Go(func() error { g.Go(func() error {
for folder := range results { for folder := range results {
@ -55,24 +65,42 @@ var _ = Describe("walk_dir_tree", func() {
return nil return nil
}) })
_ = g.Wait() _ = g.Wait()
return folders
}
Expect(folders).To(HaveLen(6)) DescribeTable("symlink handling",
Expect(folders["root/a/ignored"].audioFiles).To(BeEmpty()) func(followSymlinks bool, expectedFolderCount int) {
Expect(folders["root/a"].audioFiles).To(SatisfyAll( conf.Server.Scanner.FollowSymlinks = followSymlinks
HaveLen(2), folders := getFolders()
HaveKey("f1.mp3"),
HaveKey("f2.mp3"), Expect(folders).To(HaveLen(expectedFolderCount + 2)) // +2 for `.` and `root`
))
Expect(folders["root/a"].imageFiles).To(BeEmpty()) // Basic folder structure checks
Expect(folders["root/b"].audioFiles).To(BeEmpty()) Expect(folders["root/a"].audioFiles).To(SatisfyAll(
Expect(folders["root/b"].imageFiles).To(SatisfyAll( HaveLen(2),
HaveLen(1), HaveKey("f1.mp3"),
HaveKey("cover.jpg"), HaveKey("f2.mp3"),
)) ))
Expect(folders["root/c"].audioFiles).To(BeEmpty()) Expect(folders["root/a"].imageFiles).To(BeEmpty())
Expect(folders["root/c"].imageFiles).To(BeEmpty()) Expect(folders["root/b"].audioFiles).To(BeEmpty())
Expect(folders).ToNot(HaveKey("root/d")) Expect(folders["root/b"].imageFiles).To(SatisfyAll(
}) HaveLen(1),
HaveKey("cover.jpg"),
))
Expect(folders["root/c"].audioFiles).To(BeEmpty())
Expect(folders["root/c"].imageFiles).To(BeEmpty())
Expect(folders).ToNot(HaveKey("root/d"))
// Symlink specific checks
if followSymlinks {
Expect(folders["root/e/symlink"].audioFiles).To(HaveLen(1))
} else {
Expect(folders).ToNot(HaveKey("root/e/symlink"))
}
},
Entry("with symlinks enabled", true, 7),
Entry("with symlinks disabled", false, 6),
)
}) })
Describe("helper functions", func() { Describe("helper functions", func() {
@ -81,74 +109,88 @@ var _ = Describe("walk_dir_tree", func() {
baseDir := filepath.Join("tests", "fixtures") baseDir := filepath.Join("tests", "fixtures")
Describe("isDirOrSymlinkToDir", func() { Describe("isDirOrSymlinkToDir", func() {
It("returns true for normal dirs", func() { BeforeEach(func() {
dirEntry := getDirEntry("tests", "fixtures") DeferCleanup(configtest.SetupConfig())
Expect(isDirOrSymlinkToDir(fsys, baseDir, dirEntry)).To(BeTrue())
}) })
It("returns true for symlinks to dirs", func() {
dirEntry := getDirEntry(baseDir, "symlink2dir")
Expect(isDirOrSymlinkToDir(fsys, baseDir, dirEntry)).To(BeTrue())
})
It("returns false for files", func() {
dirEntry := getDirEntry(baseDir, "test.mp3")
Expect(isDirOrSymlinkToDir(fsys, baseDir, dirEntry)).To(BeFalse())
})
It("returns false for symlinks to files", func() {
dirEntry := getDirEntry(baseDir, "symlink")
Expect(isDirOrSymlinkToDir(fsys, baseDir, dirEntry)).To(BeFalse())
})
})
Describe("isDirIgnored", func() {
It("returns false for normal dirs", func() {
Expect(isDirIgnored("empty_folder")).To(BeFalse())
})
It("returns true when folder name starts with a `.`", func() {
Expect(isDirIgnored(".hidden_folder")).To(BeTrue())
})
It("returns false when folder name starts with ellipses", func() {
Expect(isDirIgnored("...unhidden_folder")).To(BeFalse())
})
It("returns true when folder name is $Recycle.Bin", func() {
Expect(isDirIgnored("$Recycle.Bin")).To(BeTrue())
})
It("returns true when folder name is #snapshot", func() {
Expect(isDirIgnored("#snapshot")).To(BeTrue())
})
})
})
Describe("fullReadDir", func() { Context("with symlinks enabled", func() {
var fsys fakeFS BeforeEach(func() {
var ctx context.Context conf.Server.Scanner.FollowSymlinks = true
BeforeEach(func() { })
ctx = context.Background()
fsys = fakeFS{MapFS: fstest.MapFS{ DescribeTable("returns expected result",
"root/a/f1": {}, func(dirName string, expected bool) {
"root/b/f2": {}, dirEntry := getDirEntry("tests/fixtures", dirName)
"root/c/f3": {}, Expect(isDirOrSymlinkToDir(fsys, baseDir, dirEntry)).To(Equal(expected))
}} },
Entry("normal dir", "empty_folder", true),
Entry("symlink to dir", "symlink2dir", true),
Entry("regular file", "test.mp3", false),
Entry("symlink to file", "symlink", false),
)
})
Context("with symlinks disabled", func() {
BeforeEach(func() {
conf.Server.Scanner.FollowSymlinks = false
})
DescribeTable("returns expected result",
func(dirName string, expected bool) {
dirEntry := getDirEntry("tests/fixtures", dirName)
Expect(isDirOrSymlinkToDir(fsys, baseDir, dirEntry)).To(Equal(expected))
},
Entry("normal dir", "empty_folder", true),
Entry("symlink to dir", "symlink2dir", false),
Entry("regular file", "test.mp3", false),
Entry("symlink to file", "symlink", false),
)
})
}) })
It("reads all entries", func() {
dir, _ := fsys.Open("root") Describe("isDirIgnored", func() {
entries := fullReadDir(ctx, dir.(fs.ReadDirFile)) DescribeTable("returns expected result",
Expect(entries).To(HaveLen(3)) func(dirName string, expected bool) {
Expect(entries[0].Name()).To(Equal("a")) Expect(isDirIgnored(dirName)).To(Equal(expected))
Expect(entries[1].Name()).To(Equal("b")) },
Expect(entries[2].Name()).To(Equal("c")) Entry("normal dir", "empty_folder", false),
Entry("hidden dir", ".hidden_folder", true),
Entry("dir starting with ellipsis", "...unhidden_folder", false),
Entry("recycle bin", "$Recycle.Bin", true),
Entry("snapshot dir", "#snapshot", true),
)
}) })
It("skips entries with permission error", func() {
fsys.failOn = "b" Describe("fullReadDir", func() {
dir, _ := fsys.Open("root") var (
entries := fullReadDir(ctx, dir.(fs.ReadDirFile)) fsys fakeFS
Expect(entries).To(HaveLen(2)) ctx context.Context
Expect(entries[0].Name()).To(Equal("a")) )
Expect(entries[1].Name()).To(Equal("c"))
}) BeforeEach(func() {
It("aborts if it keeps getting 'readdirent: no such file or directory'", func() { ctx = GinkgoT().Context()
fsys.err = fs.ErrNotExist fsys = fakeFS{MapFS: fstest.MapFS{
dir, _ := fsys.Open("root") "root/a/f1": {},
entries := fullReadDir(ctx, dir.(fs.ReadDirFile)) "root/b/f2": {},
Expect(entries).To(BeEmpty()) "root/c/f3": {},
}}
})
DescribeTable("reading directory entries",
func(failOn string, expectedErr error, expectedNames []string) {
fsys.failOn = failOn
fsys.err = expectedErr
dir, _ := fsys.Open("root")
entries := fullReadDir(ctx, dir.(fs.ReadDirFile))
Expect(entries).To(HaveLen(len(expectedNames)))
for i, name := range expectedNames {
Expect(entries[i].Name()).To(Equal(name))
}
},
Entry("reads all entries", "", nil, []string{"a", "b", "c"}),
Entry("skips entries with permission error", "b", nil, []string{"a", "c"}),
Entry("aborts on fs.ErrNotExist", "", fs.ErrNotExist, []string{}),
)
}) })
}) })
}) })
@ -205,11 +247,54 @@ func getDirEntry(baseDir, name string) os.DirEntry {
panic(fmt.Sprintf("Could not find %s in %s", name, baseDir)) panic(fmt.Sprintf("Could not find %s in %s", name, baseDir))
} }
// mockMusicFS is a mock implementation of the MusicFS interface that supports symlinks
type mockMusicFS struct { type mockMusicFS struct {
storage.MusicFS storage.MusicFS
fs.FS fs.FS
} }
// Open resolves symlinks
func (m *mockMusicFS) Open(name string) (fs.File, error) { func (m *mockMusicFS) Open(name string) (fs.File, error) {
return m.FS.Open(name) f, err := m.FS.Open(name)
if err != nil {
return nil, err
}
info, err := f.Stat()
if err != nil {
f.Close()
return nil, err
}
if info.Mode()&fs.ModeSymlink != 0 {
// For symlinks, read the target path from the Data field
target := string(m.FS.(fstest.MapFS)[name].Data)
f.Close()
return m.FS.Open(target)
}
return f, nil
}
// Stat uses Open to resolve symlinks
func (m *mockMusicFS) Stat(name string) (fs.FileInfo, error) {
f, err := m.Open(name)
if err != nil {
return nil, err
}
defer f.Close()
return f.Stat()
}
// ReadDir uses Open to resolve symlinks
func (m *mockMusicFS) ReadDir(name string) ([]fs.DirEntry, error) {
f, err := m.Open(name)
if err != nil {
return nil, err
}
defer f.Close()
if dirFile, ok := f.(fs.ReadDirFile); ok {
return dirFile.ReadDir(-1)
}
return nil, fmt.Errorf("not a directory")
} }