github.com/derat/nup@v0.0.0-20230418113745-15592ba7c620/cmd/nup/check/command.go (about)

     1  // Copyright 2020 Daniel Erat.
     2  // All rights reserved.
     3  
     4  package check
     5  
     6  import (
     7  	"context"
     8  	"encoding/json"
     9  	"errors"
    10  	"flag"
    11  	"fmt"
    12  	"image"
    13  	_ "image/jpeg"
    14  	"io"
    15  	"log"
    16  	"math"
    17  	"os"
    18  	"path/filepath"
    19  	"sort"
    20  	"strconv"
    21  	"strings"
    22  
    23  	"github.com/derat/nup/cmd/nup/client"
    24  	"github.com/derat/nup/cmd/nup/client/files"
    25  	"github.com/derat/nup/server/cover"
    26  	"github.com/derat/nup/server/db"
    27  	"github.com/google/subcommands"
    28  )
    29  
    30  // checkSettings is a bitfield describing which checks to perform.
    31  type checkSettings uint32
    32  
    33  const (
    34  	checkAlbumID checkSettings = 1 << iota
    35  	checkCoverSize400
    36  	checkCoverSize800
    37  	checkImported
    38  	checkMetadata
    39  	checkSongCover
    40  	checkUnusedCover
    41  )
    42  
    43  var checkInfos = map[string]struct { // keys are values for -check flag
    44  	setting checkSettings
    45  	desc    string // description for check flag
    46  	def     bool   // on by default?
    47  }{
    48  	"album-id":       {checkAlbumID, "Songs have MusicBrainz album IDs", true},
    49  	"cover-size-400": {checkCoverSize400, "Cover images are at least 400x400", false},
    50  	"cover-size-800": {checkCoverSize800, "Cover images are at least 800x800", false},
    51  	"imported":       {checkImported, "Local songs have been imported", true},
    52  	"metadata":       {checkMetadata, "Song metadata is the same in dumped and local songs", false},
    53  	"song-cover":     {checkSongCover, "Songs with album IDs have cover files", true},
    54  	"unused-cover":   {checkUnusedCover, "Cover image files are referenced by songs", true},
    55  }
    56  
    57  type Command struct {
    58  	Cfg        *client.Config
    59  	checksList string // comma-separated list of checks to perform
    60  	checks     checkSettings
    61  }
    62  
    63  func (*Command) Name() string     { return "check" }
    64  func (*Command) Synopsis() string { return "check for issues in songs and cover images" }
    65  func (*Command) Usage() string {
    66  	return `check <flags>:
    67  	Check for issues in dumped songs read from stdin.
    68  
    69  `
    70  }
    71  
    72  func (cmd *Command) SetFlags(f *flag.FlagSet) {
    73  	var defaultChecks []string
    74  	var checkDescs []string
    75  	var max int // maximum check name length
    76  	for s := range checkInfos {
    77  		max = int(math.Max(float64(max), float64(len(s))))
    78  	}
    79  	for s, info := range checkInfos {
    80  		if info.def {
    81  			defaultChecks = append(defaultChecks, s)
    82  		}
    83  		checkDescs = append(checkDescs, fmt.Sprintf("  %-"+strconv.Itoa(max)+"s  %s\n", s, info.desc))
    84  	}
    85  	sort.Strings(defaultChecks)
    86  	sort.Strings(checkDescs)
    87  	f.StringVar(&cmd.checksList, "checks", strings.Join(defaultChecks, ","),
    88  		"Comma-separated list of checks to perform:\n"+strings.Join(checkDescs, ""))
    89  }
    90  
    91  func (cmd *Command) Execute(ctx context.Context, _ *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
    92  	if cmd.Cfg.MusicDir == "" || cmd.Cfg.CoverDir == "" {
    93  		fmt.Fprintln(os.Stderr, "musicDir and coverDir must be set in config")
    94  		return subcommands.ExitUsageError
    95  	}
    96  
    97  	for _, s := range strings.Split(cmd.checksList, ",") {
    98  		info, ok := checkInfos[s]
    99  		if !ok {
   100  			fmt.Fprintf(os.Stderr, "Invalid -check value %q\n", s)
   101  			return subcommands.ExitUsageError
   102  		}
   103  		cmd.checks |= info.setting
   104  	}
   105  
   106  	d := json.NewDecoder(os.Stdin)
   107  	songs := make([]*db.Song, 0)
   108  	for {
   109  		var s db.Song
   110  		if err := d.Decode(&s); err == io.EOF {
   111  			break
   112  		} else if err != nil {
   113  			fmt.Fprintln(os.Stderr, "Failed reading song:", err)
   114  			return subcommands.ExitFailure
   115  		}
   116  		songs = append(songs, &s)
   117  	}
   118  	log.Printf("Read %d songs", len(songs))
   119  	sort.Slice(songs, func(i, j int) bool { return songs[i].Filename < songs[j].Filename })
   120  
   121  	if err := cmd.checkSongs(songs); err != nil {
   122  		fmt.Fprintln(os.Stderr, "Failed checking songs:", err)
   123  		return subcommands.ExitFailure
   124  	}
   125  	if err := cmd.checkCovers(songs); err != nil {
   126  		fmt.Fprintln(os.Stderr, "Failed checking covers:", err)
   127  		return subcommands.ExitFailure
   128  	}
   129  	return subcommands.ExitSuccess
   130  }
   131  
   132  func (cmd *Command) checkSongs(songs []*db.Song) error {
   133  	seenFilenames := make(map[string]string, len(songs))
   134  	fs := [](func(s *db.Song) error){
   135  		func(s *db.Song) error {
   136  			if len(s.Filename) == 0 {
   137  				return errors.New("no song filename")
   138  			} else if _, err := os.Stat(filepath.Join(cmd.Cfg.MusicDir, s.Filename)); err != nil {
   139  				return errors.New("missing song file")
   140  			}
   141  			if id, ok := seenFilenames[s.Filename]; ok {
   142  				return fmt.Errorf("song %s uses same file", id)
   143  			}
   144  			seenFilenames[s.Filename] = s.SongID
   145  			return nil
   146  		},
   147  	}
   148  
   149  	if cmd.checks&checkAlbumID != 0 {
   150  		fs = append(fs, func(s *db.Song) error {
   151  			if len(s.AlbumID) == 0 && s.Album != files.NonAlbumTracksValue {
   152  				return errors.New("missing MusicBrainz album")
   153  			}
   154  			return nil
   155  		})
   156  	}
   157  
   158  	if cmd.checks&checkSongCover != 0 {
   159  		fs = append(fs, func(s *db.Song) error {
   160  			// Returns true if fn exists within the cover dir.
   161  			fileExists := func(fn string) bool {
   162  				_, err := os.Stat(filepath.Join(cmd.Cfg.CoverDir, fn))
   163  				return err == nil
   164  			}
   165  			if len(s.CoverFilename) == 0 {
   166  				if len(s.AlbumID) == 0 {
   167  					return nil // ignore missing covers for non-album tracks
   168  				}
   169  				fn := s.AlbumID + cover.OrigExt
   170  				if fileExists(fn) {
   171  					return fmt.Errorf("no cover file set but %v exists", fn)
   172  				}
   173  				return fmt.Errorf("no cover file set; album %s", s.AlbumID)
   174  			}
   175  			if !fileExists(s.CoverFilename) {
   176  				return fmt.Errorf("missing cover file %s", s.CoverFilename)
   177  			}
   178  			return nil
   179  		})
   180  	}
   181  
   182  	if cmd.checks&checkMetadata != 0 {
   183  		fs = append(fs, func(s *db.Song) error {
   184  			abs := filepath.Join(cmd.Cfg.MusicDir, s.Filename)
   185  			local, err := files.ReadSong(cmd.Cfg, abs, nil, files.SkipAudioData, nil /* gc */)
   186  			if err != nil {
   187  				return err
   188  			}
   189  			dump := *s
   190  
   191  			// Clear fields that aren't set when reading only tags or when dumping.
   192  			local.CoverID = ""
   193  			local.RecordingID = ""
   194  			local.OrigAlbumID = ""
   195  			local.OrigRecordingID = ""
   196  
   197  			dump.SHA1 = ""
   198  			dump.SongID = ""
   199  			dump.CoverFilename = ""
   200  			dump.Length = 0
   201  			dump.TrackGain = 0
   202  			dump.AlbumGain = 0
   203  			dump.PeakAmp = 0
   204  			dump.Rating = 0
   205  			dump.Tags = nil
   206  			dump.Plays = nil
   207  
   208  			if diff := db.DiffSongs(&dump, local); diff != "" {
   209  				return errors.New("dumped and local metadata differ:\n" + diff)
   210  			}
   211  			return nil
   212  		})
   213  	}
   214  
   215  	for _, f := range fs {
   216  		for _, s := range songs {
   217  			if err := f(s); err != nil {
   218  				fmt.Printf("%s (%s): %v\n", s.SongID, s.Filename, err)
   219  			}
   220  		}
   221  	}
   222  
   223  	if cmd.checks&checkImported != 0 {
   224  		known := make(map[string]struct{}, len(songs))
   225  		for _, s := range songs {
   226  			known[s.Filename] = struct{}{}
   227  		}
   228  		if err := filepath.Walk(cmd.Cfg.MusicDir, func(path string, fi os.FileInfo, err error) error {
   229  			if err != nil {
   230  				return err
   231  			}
   232  			if !fi.Mode().IsRegular() || !files.IsMusicPath(path) {
   233  				return nil
   234  			}
   235  			pre := cmd.Cfg.MusicDir + "/"
   236  			if !strings.HasPrefix(path, pre) {
   237  				return fmt.Errorf("%v doesn't have expected prefix %v", path, pre)
   238  			}
   239  			path = path[len(pre):]
   240  			if _, ok := known[path]; !ok {
   241  				fmt.Printf("%v not imported\n", path)
   242  			}
   243  			return nil
   244  		}); err != nil {
   245  			return fmt.Errorf("failed walking %v: %v", cmd.Cfg.MusicDir, err)
   246  		}
   247  	}
   248  
   249  	return nil
   250  }
   251  
   252  func (cmd *Command) checkCovers(songs []*db.Song) error {
   253  	dir, err := os.Open(cmd.Cfg.CoverDir)
   254  	if err != nil {
   255  		return err
   256  	}
   257  	defer dir.Close()
   258  
   259  	fns, err := dir.Readdirnames(0)
   260  	if err != nil {
   261  		return err
   262  	}
   263  
   264  	songFns := make(map[string]string) // values are "[artist] - [album]"
   265  	for _, s := range songs {
   266  		if len(s.CoverFilename) > 0 {
   267  			songFns[s.CoverFilename] = s.Artist + " - " + s.Album
   268  		}
   269  	}
   270  
   271  	var fs [](func(fn string) error)
   272  
   273  	if cmd.checks&checkUnusedCover != 0 {
   274  		fs = append(fs, func(fn string) error {
   275  			// Check for the original cover if this is a generated WebP image.
   276  			fn = cover.OrigFilename(fn)
   277  			if _, ok := songFns[fn]; !ok {
   278  				return errors.New("unused cover")
   279  			}
   280  			return nil
   281  		})
   282  	}
   283  
   284  	if cmd.checks&(checkCoverSize400|checkCoverSize800) != 0 {
   285  		min := 400
   286  		if cmd.checks&checkCoverSize800 != 0 {
   287  			min = 800
   288  		}
   289  		fs = append(fs, func(fn string) error {
   290  			p := filepath.Join(cmd.Cfg.CoverDir, fn)
   291  			f, err := os.Open(p)
   292  			if err != nil {
   293  				return err
   294  			}
   295  			defer f.Close()
   296  
   297  			img, _, err := image.Decode(f)
   298  			if err != nil {
   299  				return fmt.Errorf("failed to decode %v: %v", p, err)
   300  			}
   301  			b := img.Bounds()
   302  			if b.Dx() < min || b.Dy() < min {
   303  				return fmt.Errorf("cover is only %vx%v", b.Dx(), b.Dy())
   304  			}
   305  			return nil
   306  		})
   307  	}
   308  
   309  	for _, f := range fs {
   310  		for _, fn := range fns {
   311  			if err := f(fn); err != nil {
   312  				key := fn
   313  				if s := songFns[fn]; s != "" {
   314  					key += " (" + s + ")"
   315  				}
   316  				fmt.Printf("%s: %v\n", key, err)
   317  			}
   318  		}
   319  	}
   320  	return nil
   321  }