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 }