github.com/derat/nup@v0.0.0-20230418113745-15592ba7c620/cmd/nup/update/command.go (about) 1 // Copyright 2020 Daniel Erat. 2 // All rights reserved. 3 4 package update 5 6 import ( 7 "context" 8 "encoding/json" 9 "flag" 10 "fmt" 11 "io" 12 "log" 13 "os" 14 "path/filepath" 15 "time" 16 17 "github.com/derat/nup/cmd/nup/client" 18 "github.com/derat/nup/cmd/nup/client/files" 19 "github.com/derat/nup/cmd/nup/mp3gain" 20 "github.com/derat/nup/server/cover" 21 "github.com/derat/nup/server/db" 22 "github.com/google/subcommands" 23 ) 24 25 type Command struct { 26 Cfg *client.Config 27 28 compareDumpFile string // path of file with song dumps to compare against 29 deleteAfterMerge bool // delete source song if mergeSongIDs is true 30 deleteSongID int64 // ID of song to delete 31 dryRun bool // print actions instead of doing anything 32 dumpedGainsFile string // path to dump file with pre-computed gains 33 forceGlob string // files to force updating 34 importJSONFile string // path to JSON file with Song objects to import 35 importUserData bool // replace user data when using importJSONFile 36 limit int // maximum number of songs to update 37 mergeSongIDs string // IDs of songs to merge, as "from:to" 38 printCoverID string // path to song file whose cover ID should be printed 39 reindexSongs bool // ask the server to reindex all songs 40 requireCovers bool // die if cover images are missing 41 songPathsFile string // path to list of songs to force updating 42 testGainInfo string // hardcoded gain info as "track:album:amp" for testing 43 useFilenames bool // use filenames instead of SHA1s to identify songs 44 } 45 46 func (*Command) Name() string { return "update" } 47 func (*Command) Synopsis() string { return "send song updates to the server" } 48 func (*Command) Usage() string { 49 return `update <flags>: 50 Send song updates to the server. 51 52 ` 53 } 54 55 func (cmd *Command) SetFlags(f *flag.FlagSet) { 56 f.StringVar(&cmd.compareDumpFile, "compare-dump-file", "", "Path to JSON file with songs to compare updates against") 57 f.BoolVar(&cmd.deleteAfterMerge, "delete-after-merge", false, "Delete source song if -merge-songs is true") 58 f.Int64Var(&cmd.deleteSongID, "delete-song", 0, "Delete song with given ID") 59 f.BoolVar(&cmd.dryRun, "dry-run", false, "Only print what would be updated") 60 f.StringVar(&cmd.dumpedGainsFile, "dumped-gains-file", "", 61 "Path to dump file from which songs' gains will be read (instead of being computed)") 62 f.StringVar(&cmd.forceGlob, "force-glob", "", 63 "Glob pattern relative to music dir for files to scan and update even if they haven't changed") 64 f.StringVar(&cmd.importJSONFile, "import-json-file", "", "Path to JSON file with songs to import") 65 f.BoolVar(&cmd.importUserData, "import-user-data", true, 66 "When importing from JSON, replace user data (ratings, tags, plays, etc.)") 67 f.IntVar(&cmd.limit, "limit", 0, "Limit the number of songs to update (for testing)") 68 f.StringVar(&cmd.mergeSongIDs, "merge-songs", "", 69 `Merge one song's user data into another song, with IDs as "src:dst"`) 70 f.StringVar(&cmd.printCoverID, "print-cover-id", "", `Print cover ID for specified song file`) 71 f.BoolVar(&cmd.reindexSongs, "reindex-songs", false, 72 "Ask server to reindex all songs' search-related fields (not typically needed)") 73 f.BoolVar(&cmd.requireCovers, "require-covers", false, 74 "Die if cover images aren't found for any songs that have album IDs") 75 f.StringVar(&cmd.songPathsFile, "song-paths-file", "", 76 "Path to file with one relative path per line for songs to force updating") 77 f.StringVar(&cmd.testGainInfo, "test-gain-info", "", 78 "Hardcoded gain info as \"track:album:amp\" (for testing)") 79 f.BoolVar(&cmd.useFilenames, "use-filenames", false, 80 "Identify songs by filename rather than audio data hash (useful when modifying files)") 81 } 82 83 func (cmd *Command) Execute(ctx context.Context, _ *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { 84 if countBools(cmd.deleteSongID > 0, cmd.importJSONFile != "", cmd.mergeSongIDs != "", 85 cmd.printCoverID != "", cmd.reindexSongs, cmd.songPathsFile != "") > 1 { 86 fmt.Fprintln(os.Stderr, "-delete-song, -import-json-file, -merge-songs, -print-cover-id, "+ 87 "-reindex-songs, and -song-paths-file are mutually exclusive") 88 return subcommands.ExitUsageError 89 } 90 91 // Handle flags that don't use the normal update process. 92 switch { 93 case cmd.deleteSongID > 0: 94 return cmd.doDeleteSong() 95 case cmd.mergeSongIDs != "": 96 return cmd.doMergeSongs() 97 case cmd.printCoverID != "": 98 return cmd.doPrintCoverID() 99 case cmd.reindexSongs: 100 return cmd.doReindexSongs() 101 } 102 103 var err error 104 var numSongs int 105 var scannedDirs []string 106 var replaceUserData, didFullScan bool 107 var oldSongs map[string]*db.Song 108 readChan := make(chan songOrErr) 109 startTime := time.Now() 110 111 if cmd.testGainInfo != "" { 112 var info mp3gain.Info 113 if _, err := fmt.Sscanf(cmd.testGainInfo, "%f:%f:%f", 114 &info.TrackGain, &info.AlbumGain, &info.PeakAmp); err != nil { 115 fmt.Fprintln(os.Stderr, "Bad -test-gain-info (want \"track:album:amp\"):", err) 116 return subcommands.ExitUsageError 117 } 118 mp3gain.SetInfoForTest(&info) 119 } 120 121 if cmd.compareDumpFile != "" { 122 if oldSongs, err = readDumpedSongs(cmd.compareDumpFile, cmd.useFilenames); err != nil { 123 fmt.Fprintln(os.Stderr, "Failed reading songs from -compare-dump-file:", err) 124 return subcommands.ExitFailure 125 } 126 } 127 128 if len(cmd.importJSONFile) > 0 { 129 if numSongs, err = readSongsFromJSONFile(cmd.importJSONFile, readChan); err != nil { 130 fmt.Fprintln(os.Stderr, "Failed reading songs:", err) 131 return subcommands.ExitFailure 132 } 133 replaceUserData = cmd.importUserData 134 } else { 135 if len(cmd.Cfg.MusicDir) == 0 { 136 fmt.Fprintln(os.Stderr, "musicDir not set in config") 137 return subcommands.ExitUsageError 138 } 139 140 // Not all these options will necessarily be used (e.g. readSongList doesn't need forceGlob 141 // or logProgress), but it doesn't hurt to pass them. 142 opts := scanOptions{ 143 forceGlob: cmd.forceGlob, 144 logProgress: true, 145 dumpedGainsPath: cmd.dumpedGainsFile, 146 } 147 148 if len(cmd.songPathsFile) > 0 { 149 numSongs, err = readSongList(cmd.Cfg, cmd.songPathsFile, readChan, &opts) 150 if err != nil { 151 fmt.Fprintln(os.Stderr, "Failed reading song list:", err) 152 return subcommands.ExitFailure 153 } 154 } else { 155 if len(cmd.Cfg.LastUpdateInfoFile) == 0 { 156 fmt.Fprintln(os.Stderr, "lastUpdateInfoFile not set in config") 157 return subcommands.ExitUsageError 158 } 159 info, err := readLastUpdateInfo(cmd.Cfg.LastUpdateInfoFile) 160 if err != nil { 161 fmt.Fprintln(os.Stderr, "Unable to get last update info:", err) 162 return subcommands.ExitFailure 163 } 164 log.Printf("Scanning for songs in %v updated since %v", cmd.Cfg.MusicDir, info.Time.Local()) 165 numSongs, scannedDirs, err = scanForUpdatedSongs(cmd.Cfg, info.Time, info.Dirs, readChan, &opts) 166 if err != nil { 167 fmt.Fprintln(os.Stderr, "Scanning failed:", err) 168 return subcommands.ExitFailure 169 } 170 didFullScan = true 171 } 172 } 173 174 if cmd.limit > 0 && numSongs > cmd.limit { 175 numSongs = cmd.limit 176 } 177 178 log.Printf("Processing %v song(s)", numSongs) 179 180 // Look up covers and feed songs to the updater. 181 updateChan := make(chan db.Song) 182 errChan := make(chan error, 1) 183 go func() { 184 for i := 0; i < numSongs; i++ { 185 soe := <-readChan 186 if soe.err != nil { 187 fn := "[unknown]" 188 if soe.song != nil { 189 fn = soe.song.Filename 190 } 191 errChan <- fmt.Errorf("%v: %v", fn, soe.err) 192 break 193 } 194 s := *soe.song 195 s.CoverFilename = getCoverFilename(cmd.Cfg.CoverDir, &s) 196 if cmd.requireCovers && len(s.CoverFilename) == 0 && (len(s.AlbumID) > 0 || len(s.CoverID) > 0) { 197 errChan <- fmt.Errorf("missing cover for %v (album=%v, cover=%v)", s.Filename, s.AlbumID, s.CoverID) 198 break 199 } 200 s.RecordingID = "" 201 202 // Check that the metadata actually changed to avoid unnecessary datastore writes. 203 key := s.SHA1 204 if cmd.useFilenames { 205 key = s.Filename 206 } 207 if old, ok := oldSongs[key]; ok && s.MetadataEquals(old) { 208 log.Print("Skipping unchanged ", s.Filename) 209 continue 210 } 211 212 // Don't send user data to the server if it would just throw it away. 213 if !replaceUserData { 214 s.Rating = 0 215 s.Tags = nil 216 s.Plays = nil 217 } 218 219 log.Print("Sending ", s.Filename) 220 updateChan <- s 221 } 222 close(updateChan) 223 close(errChan) 224 }() 225 226 if cmd.dryRun { 227 enc := json.NewEncoder(os.Stdout) 228 for s := range updateChan { 229 if err := enc.Encode(s); err != nil { 230 fmt.Fprintln(os.Stderr, "Failed encoding song:", err) 231 return subcommands.ExitFailure 232 } 233 } 234 } else { 235 var flags importSongsFlag 236 if replaceUserData { 237 flags |= importReplaceUserData 238 } 239 if cmd.useFilenames { 240 flags |= importUseFilenames 241 } 242 if err := importSongs(cmd.Cfg, updateChan, flags); err != nil { 243 fmt.Fprintln(os.Stderr, "Failed updating songs:", err) 244 return subcommands.ExitFailure 245 } 246 } 247 248 if err := <-errChan; err != nil { 249 fmt.Fprintln(os.Stderr, "Failed scanning song files:", err) 250 return subcommands.ExitFailure 251 } 252 253 if !cmd.dryRun && didFullScan { 254 if err := writeLastUpdateInfo(cmd.Cfg.LastUpdateInfoFile, lastUpdateInfo{ 255 Time: startTime, 256 Dirs: scannedDirs, 257 }); err != nil { 258 fmt.Fprintln(os.Stderr, "Failed saving update info:", err) 259 return subcommands.ExitFailure 260 } 261 } 262 return subcommands.ExitSuccess 263 } 264 265 func (cmd *Command) doDeleteSong() subcommands.ExitStatus { 266 if cmd.dryRun { 267 fmt.Fprintln(os.Stderr, "-dry-run is incompatible with -delete-song") 268 return subcommands.ExitUsageError 269 } 270 if err := deleteSong(cmd.Cfg, cmd.deleteSongID); err != nil { 271 fmt.Fprintf(os.Stderr, "Failed deleting song %v: %v\n", cmd.deleteSongID, err) 272 return subcommands.ExitFailure 273 } 274 return subcommands.ExitSuccess 275 } 276 277 func (cmd *Command) doMergeSongs() subcommands.ExitStatus { 278 var srcID, dstID int64 279 if _, err := fmt.Sscanf(cmd.mergeSongIDs, "%d:%d", &srcID, &dstID); err != nil { 280 fmt.Fprintln(os.Stderr, `-merge-songs needs IDs to merge as "src:dst"`) 281 return subcommands.ExitUsageError 282 } 283 if srcID == dstID { 284 fmt.Fprintf(os.Stderr, "Can't merge song %d into itself\n", srcID) 285 return subcommands.ExitUsageError 286 } 287 288 var err error 289 var src, dst db.Song 290 if src, err = dumpSong(cmd.Cfg, srcID); err != nil { 291 fmt.Fprintf(os.Stderr, "Failed dumping song %v: %v\n", srcID, err) 292 return subcommands.ExitFailure 293 } 294 if dst, err = dumpSong(cmd.Cfg, dstID); err != nil { 295 fmt.Fprintf(os.Stderr, "Failed dumping song %v: %v\n", dstID, err) 296 return subcommands.ExitFailure 297 } 298 if src.Rating > dst.Rating { 299 dst.Rating = src.Rating 300 } 301 dst.Tags = append(dst.Tags, src.Tags...) 302 dst.Plays = append(dst.Plays, src.Plays...) 303 dst.Clean() // sort and dedupe Tags and Plays 304 305 if cmd.dryRun { 306 if err := json.NewEncoder(os.Stdout).Encode(dst); err != nil { 307 fmt.Fprintln(os.Stderr, "Failed encoding song:", err) 308 return subcommands.ExitFailure 309 } 310 } else { 311 ch := make(chan db.Song, 1) 312 ch <- dst 313 close(ch) 314 if err := importSongs(cmd.Cfg, ch, importReplaceUserData); err != nil { 315 fmt.Fprintf(os.Stderr, "Failed updating song %v: %v\n", dstID, err) 316 return subcommands.ExitFailure 317 } 318 if cmd.deleteAfterMerge { 319 if err := deleteSong(cmd.Cfg, srcID); err != nil { 320 fmt.Fprintf(os.Stderr, "Failed deleting song %v: %v\n", srcID, err) 321 return subcommands.ExitFailure 322 } 323 } 324 } 325 return subcommands.ExitSuccess 326 } 327 328 func (cmd *Command) doPrintCoverID() subcommands.ExitStatus { 329 // Just set the file's directory as the music dir so that ReadSong won't 330 // fail when computing a relative path (which we don't use anyway). 331 if abs, err := filepath.Abs(cmd.printCoverID); err != nil { 332 fmt.Fprintln(os.Stderr, "Couldn't get absolute path:", err) 333 return subcommands.ExitFailure 334 } else { 335 cmd.Cfg.MusicDir = filepath.Dir(abs) 336 } 337 s, err := files.ReadSong(cmd.Cfg, cmd.printCoverID, nil, files.SkipAudioData, nil) 338 if err != nil { 339 fmt.Fprintln(os.Stderr, "Failed reading song:", err) 340 return subcommands.ExitFailure 341 } 342 ids := getCoverIDs(s) 343 if len(ids) == 0 { 344 fmt.Fprintln(os.Stderr, "Couldn't find cover ID in metadata") 345 return subcommands.ExitFailure 346 } 347 fmt.Println(ids[0]) 348 return subcommands.ExitSuccess 349 } 350 351 func (cmd *Command) doReindexSongs() subcommands.ExitStatus { 352 if cmd.dryRun { 353 fmt.Fprintln(os.Stderr, "-dry-run is incompatible with -reindex-songs") 354 return subcommands.ExitUsageError 355 } 356 if err := reindexSongs(cmd.Cfg); err != nil { 357 fmt.Fprintln(os.Stderr, "Failed reindexing songs:", err) 358 return subcommands.ExitFailure 359 } 360 return subcommands.ExitSuccess 361 } 362 363 type songOrErr struct { 364 song *db.Song 365 err error 366 } 367 368 func countBools(vals ...bool) int { 369 var cnt int 370 for _, v := range vals { 371 if v { 372 cnt++ 373 } 374 } 375 return cnt 376 } 377 378 // lastUpdateInfo contains information about the last full update that was performed. 379 // It is used to identify new music files. 380 type lastUpdateInfo struct { 381 // Time is the time at which the last update was started. 382 Time time.Time `json:"time"` 383 // Dirs contains all song-containing directories that were seen (relative to config.MusicDir). 384 Dirs []string `json:"dirs"` 385 } 386 387 // readLastUpdateInfo JSON-unmarshals a lastUpdateInfo struct from the file at p. 388 func readLastUpdateInfo(p string) (info lastUpdateInfo, err error) { 389 f, err := os.Open(p) 390 if err != nil { 391 if os.IsNotExist(err) { 392 return info, nil 393 } 394 return info, err 395 } 396 defer f.Close() 397 398 err = json.NewDecoder(f).Decode(&info) 399 return info, err 400 } 401 402 // writeLastUpdateInfo JSON-marshals info to a file at p. 403 func writeLastUpdateInfo(p string, info lastUpdateInfo) error { 404 f, err := os.Create(p) 405 if err != nil { 406 return err 407 } 408 enc := json.NewEncoder(f) 409 enc.SetIndent("", " ") 410 if err := enc.Encode(info); err != nil { 411 f.Close() 412 return err 413 } 414 return f.Close() 415 } 416 417 // getCoverIDs returns IDs for song's cover in their preferred order. 418 func getCoverIDs(song *db.Song) []string { 419 var ids []string 420 for _, id := range []string{ 421 song.CoverID, 422 song.AlbumID, 423 song.RecordingID, 424 // If the song's metadata was updated by an override file, fall back to the original IDs. 425 // TODO: I'm not sure if it'll matter in practice, but there are cases that this doesn't 426 // cover. A song could have an original album ID A that's then overridden to B, with a B.jpg 427 // cover image. If the album ID is overridden a second time to C, then only A.jpg and C.jpg 428 // will be checked. 429 song.OrigAlbumID, 430 song.OrigRecordingID, 431 } { 432 if len(id) > 0 { 433 ids = append(ids, id) 434 } 435 } 436 return ids 437 } 438 439 // getCoverFilename returns the relative path under dir for song's cover image. 440 func getCoverFilename(dir string, song *db.Song) string { 441 for _, id := range getCoverIDs(song) { 442 fn := id + cover.OrigExt 443 if _, err := os.Stat(filepath.Join(dir, fn)); err == nil { 444 return fn 445 } 446 } 447 return "" 448 } 449 450 // readDumpedSongs JSON-unmarshals db.Song objects from p and returns them in a map. 451 // If useFilenames is true, the map is keyed by each song's Filename field; otherwise 452 // it is keyed by the SHA1 field. 453 func readDumpedSongs(p string, useFilenames bool) (map[string]*db.Song, error) { 454 f, err := os.Open(p) 455 if err != nil { 456 return nil, err 457 } 458 defer f.Close() 459 460 songs := make(map[string]*db.Song) 461 d := json.NewDecoder(f) 462 for { 463 var s db.Song 464 if err := d.Decode(&s); err == io.EOF { 465 break 466 } else if err != nil { 467 return nil, err 468 } 469 470 if useFilenames { 471 songs[s.Filename] = &s 472 } else { 473 songs[s.SHA1] = &s 474 } 475 } 476 477 return songs, nil 478 }