From 798b03eabd00d2153f44844d6715960afdb8d4ad Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 27 Dec 2023 12:41:08 -0500 Subject: [PATCH] Add "inspect" command to CLI --- cmd/inspect.go | 99 ++++++++++++++++++++++ scanner/mapping.go | 28 +++--- scanner/mapping_internal_test.go | 10 +-- scanner/metadata/metadata.go | 22 +++-- scanner/metadata/metadata_internal_test.go | 26 ++++-- scanner/tag_scanner.go | 6 +- 6 files changed, 154 insertions(+), 37 deletions(-) create mode 100644 cmd/inspect.go diff --git a/cmd/inspect.go b/cmd/inspect.go new file mode 100644 index 000000000..f53145e79 --- /dev/null +++ b/cmd/inspect.go @@ -0,0 +1,99 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/scanner" + "github.com/navidrome/navidrome/scanner/metadata" + "github.com/navidrome/navidrome/tests" + "github.com/pelletier/go-toml/v2" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +var ( + extractor string + format string +) + +func init() { + inspectCmd.Flags().StringVarP(&extractor, "extractor", "x", "", "extractor to use (ffmpeg or taglib, default: auto)") + inspectCmd.Flags().StringVarP(&format, "format", "f", "pretty", "output format (pretty, toml, yaml, json, jsonindent)") + rootCmd.AddCommand(inspectCmd) +} + +var inspectCmd = &cobra.Command{ + Use: "inspect [files to inspect]", + Short: "Inspect tags", + Long: "Show file tags as seen by Navidrome", + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + runInspector(args) + }, +} + +var marshalers = map[string]func(interface{}) ([]byte, error){ + "pretty": prettyMarshal, + "toml": toml.Marshal, + "yaml": yaml.Marshal, + "json": json.Marshal, + "jsonindent": func(v interface{}) ([]byte, error) { + return json.MarshalIndent(v, "", " ") + }, +} + +func prettyMarshal(v interface{}) ([]byte, error) { + out := v.([]inspectorOutput) + var res strings.Builder + for i := range out { + res.WriteString(fmt.Sprintf("====================\nFile: %s\n\n", out[i].File)) + t, _ := toml.Marshal(out[i].RawTags) + res.WriteString(fmt.Sprintf("Raw tags:\n%s\n\n", t)) + t, _ = toml.Marshal(out[i].MappedTags) + res.WriteString(fmt.Sprintf("Mapped tags:\n%s\n\n", t)) + } + return []byte(res.String()), nil +} + +type inspectorOutput struct { + File string + RawTags metadata.ParsedTags + MappedTags model.MediaFile +} + +func runInspector(args []string) { + if extractor != "" { + conf.Server.Scanner.Extractor = extractor + } + log.Info("Using extractor", "extractor", conf.Server.Scanner.Extractor) + md, err := metadata.Extract(args...) + if err != nil { + log.Fatal("Error extracting tags", err) + } + mapper := scanner.NewMediaFileMapper(conf.Server.MusicFolder, &tests.MockedGenreRepo{}) + marshal := marshalers[format] + if marshal == nil { + log.Fatal("Invalid format", "format", format) + } + var out []inspectorOutput + for k, v := range md { + if !model.IsAudioFile(k) { + continue + } + if len(v.Tags) == 0 { + continue + } + out = append(out, inspectorOutput{ + File: k, + RawTags: v.Tags, + MappedTags: mapper.ToMediaFile(v), + }) + } + data, _ := marshal(out) + fmt.Println(string(data)) +} diff --git a/scanner/mapping.go b/scanner/mapping.go index d05cc5a8a..c9e255cbb 100644 --- a/scanner/mapping.go +++ b/scanner/mapping.go @@ -15,20 +15,20 @@ import ( "github.com/navidrome/navidrome/utils" ) -type mediaFileMapper struct { +type MediaFileMapper struct { rootFolder string genres model.GenreRepository } -func newMediaFileMapper(rootFolder string, genres model.GenreRepository) *mediaFileMapper { - return &mediaFileMapper{ +func NewMediaFileMapper(rootFolder string, genres model.GenreRepository) *MediaFileMapper { + return &MediaFileMapper{ rootFolder: rootFolder, genres: genres, } } // TODO Move most of these mapping functions to setters in the model.MediaFile -func (s mediaFileMapper) toMediaFile(md metadata.Tags) model.MediaFile { +func (s MediaFileMapper) ToMediaFile(md metadata.Tags) model.MediaFile { mf := &model.MediaFile{} mf.ID = s.trackID(md) mf.Year, mf.Date, mf.OriginalYear, mf.OriginalDate, mf.ReleaseYear, mf.ReleaseDate = s.mapDates(md) @@ -86,7 +86,7 @@ func sanitizeFieldForSorting(originalValue string) string { return utils.NoArticle(v) } -func (s mediaFileMapper) mapTrackTitle(md metadata.Tags) string { +func (s MediaFileMapper) mapTrackTitle(md metadata.Tags) string { if md.Title() == "" { s := strings.TrimPrefix(md.FilePath(), s.rootFolder+string(os.PathSeparator)) e := filepath.Ext(s) @@ -95,7 +95,7 @@ func (s mediaFileMapper) mapTrackTitle(md metadata.Tags) string { return md.Title() } -func (s mediaFileMapper) mapAlbumArtistName(md metadata.Tags) string { +func (s MediaFileMapper) mapAlbumArtistName(md metadata.Tags) string { switch { case md.AlbumArtist() != "": return md.AlbumArtist() @@ -108,14 +108,14 @@ func (s mediaFileMapper) mapAlbumArtistName(md metadata.Tags) string { } } -func (s mediaFileMapper) mapArtistName(md metadata.Tags) string { +func (s MediaFileMapper) mapArtistName(md metadata.Tags) string { if md.Artist() != "" { return md.Artist() } return consts.UnknownArtist } -func (s mediaFileMapper) mapAlbumName(md metadata.Tags) string { +func (s MediaFileMapper) mapAlbumName(md metadata.Tags) string { name := md.Album() if name == "" { return consts.UnknownAlbum @@ -123,11 +123,11 @@ func (s mediaFileMapper) mapAlbumName(md metadata.Tags) string { return name } -func (s mediaFileMapper) trackID(md metadata.Tags) string { +func (s MediaFileMapper) trackID(md metadata.Tags) string { return fmt.Sprintf("%x", md5.Sum([]byte(md.FilePath()))) } -func (s mediaFileMapper) albumID(md metadata.Tags, releaseDate string) string { +func (s MediaFileMapper) albumID(md metadata.Tags, releaseDate string) string { albumPath := strings.ToLower(fmt.Sprintf("%s\\%s", s.mapAlbumArtistName(md), s.mapAlbumName(md))) if !conf.Server.Scanner.GroupAlbumReleases { if len(releaseDate) != 0 { @@ -137,15 +137,15 @@ func (s mediaFileMapper) albumID(md metadata.Tags, releaseDate string) string { return fmt.Sprintf("%x", md5.Sum([]byte(albumPath))) } -func (s mediaFileMapper) artistID(md metadata.Tags) string { +func (s MediaFileMapper) artistID(md metadata.Tags) string { return fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(s.mapArtistName(md))))) } -func (s mediaFileMapper) albumArtistID(md metadata.Tags) string { +func (s MediaFileMapper) albumArtistID(md metadata.Tags) string { return fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(s.mapAlbumArtistName(md))))) } -func (s mediaFileMapper) mapGenres(genres []string) (string, model.Genres) { +func (s MediaFileMapper) mapGenres(genres []string) (string, model.Genres) { var result model.Genres unique := map[string]struct{}{} var all []string @@ -174,7 +174,7 @@ func (s mediaFileMapper) mapGenres(genres []string) (string, model.Genres) { return result[0].Name, result } -func (s mediaFileMapper) mapDates(md metadata.Tags) (year int, date string, +func (s MediaFileMapper) mapDates(md metadata.Tags) (year int, date string, originalYear int, originalDate string, releaseYear int, releaseDate string) { // Start with defaults diff --git a/scanner/mapping_internal_test.go b/scanner/mapping_internal_test.go index 4687efeab..b44f2bcfb 100644 --- a/scanner/mapping_internal_test.go +++ b/scanner/mapping_internal_test.go @@ -12,11 +12,11 @@ import ( ) var _ = Describe("mapping", func() { - Describe("mediaFileMapper", func() { - var mapper *mediaFileMapper + Describe("MediaFileMapper", func() { + var mapper *MediaFileMapper Describe("mapTrackTitle", func() { BeforeEach(func() { - mapper = newMediaFileMapper("/music", nil) + mapper = NewMediaFileMapper("/music", nil) }) It("returns the Title when it is available", func() { md := metadata.NewTag("/music/artist/album01/Song.mp3", nil, metadata.ParsedTags{"title": []string{"This is not a love song"}}) @@ -37,7 +37,7 @@ var _ = Describe("mapping", func() { ds := &tests.MockDataStore{} gr = ds.Genre(ctx) gr = newCachedGenreRepository(ctx, gr) - mapper = newMediaFileMapper("/", gr) + mapper = NewMediaFileMapper("/", gr) }) It("returns empty if no genres are available", func() { @@ -79,7 +79,7 @@ var _ = Describe("mapping", func() { Describe("mapDates", func() { var md metadata.Tags BeforeEach(func() { - mapper = newMediaFileMapper("/", nil) + mapper = NewMediaFileMapper("/", nil) }) Context("when all date fields are provided", func() { BeforeEach(func() { diff --git a/scanner/metadata/metadata.go b/scanner/metadata/metadata.go index d12e875a2..cb3d18ffb 100644 --- a/scanner/metadata/metadata.go +++ b/scanner/metadata/metadata.go @@ -58,25 +58,35 @@ func Extract(files ...string) (map[string]Tags, error) { func NewTag(filePath string, fileInfo os.FileInfo, tags ParsedTags) Tags { for t, values := range tags { - tags[t] = removeDuplicates(values) + values = removeDuplicatesAndEmpty(values) + if len(values) == 0 { + delete(tags, t) + continue + } + tags[t] = values } return Tags{ filePath: filePath, fileInfo: fileInfo, - tags: tags, + Tags: tags, } } -func removeDuplicates(values []string) []string { +func removeDuplicatesAndEmpty(values []string) []string { encountered := map[string]struct{}{} + empty := true var result []string for _, v := range values { if _, ok := encountered[v]; ok { continue } encountered[v] = struct{}{} + empty = empty && v == "" result = append(result, v) } + if empty { + return nil + } return result } @@ -100,7 +110,7 @@ func (p ParsedTags) Map(customMappings ParsedTags) ParsedTags { type Tags struct { filePath string fileInfo os.FileInfo - tags ParsedTags + Tags ParsedTags } // Common tags @@ -207,7 +217,7 @@ func (t Tags) getPeakValue(tagName string) float64 { func (t Tags) getTags(tagNames ...string) []string { for _, tag := range tagNames { - if v, ok := t.tags[tag]; ok { + if v, ok := t.Tags[tag]; ok { return v } } @@ -225,7 +235,7 @@ func (t Tags) getFirstTagValue(tagNames ...string) string { func (t Tags) getAllTagValues(tagNames ...string) []string { var values []string for _, tag := range tagNames { - if v, ok := t.tags[tag]; ok { + if v, ok := t.Tags[tag]; ok { values = append(values, v...) } } diff --git a/scanner/metadata/metadata_internal_test.go b/scanner/metadata/metadata_internal_test.go index 8831550f8..44967f9fe 100644 --- a/scanner/metadata/metadata_internal_test.go +++ b/scanner/metadata/metadata_internal_test.go @@ -9,7 +9,7 @@ var _ = Describe("Tags", func() { DescribeTable("getDate", func(tag string, expectedYear int, expectedDate string) { md := &Tags{} - md.tags = map[string][]string{"date": {tag}} + md.Tags = map[string][]string{"date": {tag}} testYear, testDate := md.Date() Expect(testYear).To(Equal(expectedYear)) Expect(testDate).To(Equal(expectedDate)) @@ -29,7 +29,7 @@ var _ = Describe("Tags", func() { Describe("getMbzID", func() { It("return a valid MBID", func() { md := &Tags{} - md.tags = map[string][]string{ + md.Tags = map[string][]string{ "musicbrainz_trackid": {"8f84da07-09a0-477b-b216-cc982dabcde1"}, "musicbrainz_releasetrackid": {"6caf16d3-0b20-3fe6-8020-52e31831bc11"}, "musicbrainz_albumid": {"f68c985d-f18b-4f4a-b7f0-87837cf3fbf9"}, @@ -44,7 +44,7 @@ var _ = Describe("Tags", func() { }) It("return empty string for invalid MBID", func() { md := &Tags{} - md.tags = map[string][]string{ + md.Tags = map[string][]string{ "musicbrainz_trackid": {"11406732-6"}, "musicbrainz_albumid": {"11406732"}, "musicbrainz_artistid": {"200455"}, @@ -60,7 +60,7 @@ var _ = Describe("Tags", func() { Describe("getAllTagValues", func() { It("returns values from all tag names", func() { md := &Tags{} - md.tags = map[string][]string{ + md.Tags = map[string][]string{ "genre": {"Rock", "Pop", "New Wave"}, } @@ -68,23 +68,31 @@ var _ = Describe("Tags", func() { }) }) - Describe("removeDuplicates", func() { + Describe("removeDuplicatesAndEmpty", func() { It("removes duplicates", func() { md := NewTag("/music/artist/album01/Song.mp3", nil, ParsedTags{ "genre": []string{"pop", "rock", "pop"}, "date": []string{"2023-03-01", "2023-03-01"}, "mood": []string{"happy", "sad"}, }) - Expect(md.tags).To(HaveKeyWithValue("genre", []string{"pop", "rock"})) - Expect(md.tags).To(HaveKeyWithValue("date", []string{"2023-03-01"})) - Expect(md.tags).To(HaveKeyWithValue("mood", []string{"happy", "sad"})) + Expect(md.Tags).To(HaveKeyWithValue("genre", []string{"pop", "rock"})) + Expect(md.Tags).To(HaveKeyWithValue("date", []string{"2023-03-01"})) + Expect(md.Tags).To(HaveKeyWithValue("mood", []string{"happy", "sad"})) + }) + It("removes empty tags", func() { + md := NewTag("/music/artist/album01/Song.mp3", nil, ParsedTags{ + "genre": []string{"pop", "rock", "pop"}, + "mood": []string{"", ""}, + }) + Expect(md.Tags).To(HaveKeyWithValue("genre", []string{"pop", "rock"})) + Expect(md.Tags).ToNot(HaveKey("mood")) }) }) Describe("Bpm", func() { var t *Tags BeforeEach(func() { - t = &Tags{tags: map[string][]string{ + t = &Tags{Tags: map[string][]string{ "fbpm": []string{"141.7"}, }} }) diff --git a/scanner/tag_scanner.go b/scanner/tag_scanner.go index 5a719acd4..9195544fd 100644 --- a/scanner/tag_scanner.go +++ b/scanner/tag_scanner.go @@ -27,7 +27,7 @@ type TagScanner struct { ds model.DataStore plsSync *playlistImporter cnt *counters - mapper *mediaFileMapper + mapper *MediaFileMapper cacheWarmer artwork.CacheWarmer } @@ -100,7 +100,7 @@ func (s *TagScanner) Scan(ctx context.Context, lastModifiedSince time.Time, prog var changedDirs []string s.cnt = &counters{} genres := newCachedGenreRepository(ctx, s.ds.Genre(ctx)) - s.mapper = newMediaFileMapper(s.rootFolder, genres) + s.mapper = NewMediaFileMapper(s.rootFolder, genres) refresher := newRefresher(s.ds, s.cacheWarmer, allFSDirs) log.Trace(ctx, "Loading directory tree from music folder", "folder", s.rootFolder) @@ -386,7 +386,7 @@ func (s *TagScanner) loadTracks(filePaths []string) (model.MediaFiles, error) { var mfs model.MediaFiles for _, md := range mds { - mf := s.mapper.toMediaFile(md) + mf := s.mapper.ToMediaFile(md) mfs = append(mfs, mf) } return mfs, nil