Add "inspect" command to CLI

This commit is contained in:
Deluan 2023-12-27 12:41:08 -05:00
parent ea7ba22699
commit 798b03eabd
6 changed files with 154 additions and 37 deletions

99
cmd/inspect.go Normal file
View File

@ -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))
}

View File

@ -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

View File

@ -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() {

View File

@ -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...)
}
}

View File

@ -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"},
}}
})

View File

@ -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