mirror of
https://github.com/navidrome/navidrome.git
synced 2025-04-03 04:27:37 +03:00
Add "inspect" command to CLI
This commit is contained in:
parent
ea7ba22699
commit
798b03eabd
6 changed files with 154 additions and 37 deletions
99
cmd/inspect.go
Normal file
99
cmd/inspect.go
Normal 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))
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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...)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"},
|
||||
}}
|
||||
})
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue