github.com/derat/nup@v0.0.0-20230418113745-15592ba7c620/cmd/nup/metadata/command.go (about) 1 // Copyright 2023 Daniel Erat. 2 // All rights reserved. 3 4 package metadata 5 6 import ( 7 "context" 8 "encoding/json" 9 "errors" 10 "flag" 11 "fmt" 12 "os" 13 "path/filepath" 14 "sort" 15 "strings" 16 "time" 17 18 "github.com/derat/nup/cmd/nup/client" 19 "github.com/derat/nup/cmd/nup/client/files" 20 "github.com/derat/nup/server/db" 21 "github.com/google/subcommands" 22 ) 23 24 // maxSongLengthDiff is the maximum difference in length to allow between on-disk songs 25 // and MusicBrainz tracks when updating album IDs. 26 const maxSongLengthDiff = 5 * time.Second 27 28 type Command struct { 29 Cfg *client.Config 30 opts updateOptions 31 print bool // print song metadata 32 printFull bool // print song metadata with SHA1 and length 33 scan bool // scan songs for updated metadata 34 setAlbumID string // release MBID to update songs to 35 setNonAlbum bool // update songs to be non-album tracks 36 recordingIDs string // comma-separated list of recording MBIDs 37 } 38 39 func (*Command) Name() string { return "metadata" } 40 func (*Command) Synopsis() string { return "update song metadata" } 41 func (*Command) Usage() string { 42 return `metadata <flags> <path>...: 43 Fetch updated metadata from MusicBrainz and write override files. 44 -scan updates the specified songs or all songs (without positional arguments). 45 -set-album changes the album of the specified files or directories. 46 -set-non-album updates the specified song file(s) to be non-album tracks. 47 -print prints current on-disk metadata for the specified file(s). 48 -print-full additionally includes SHA1s and lengths. 49 50 ` 51 } 52 53 func (cmd *Command) SetFlags(f *flag.FlagSet) { 54 f.BoolVar(&cmd.opts.dryRun, "dry-run", false, "Don't write override files") 55 f.BoolVar(&cmd.opts.logUpdates, "log-updates", true, "Log updates to stdout") 56 f.BoolVar(&cmd.print, "print", false, "Print metadata from specified song file(s)") 57 f.BoolVar(&cmd.printFull, "print-full", false, "Like -print, but include SHA1 and length (slower)") 58 f.StringVar(&cmd.recordingIDs, "recordings", "", "Comma-separated list of recording ID overrides for -set-album") 59 f.BoolVar(&cmd.scan, "scan", false, "Scan songs for updated metadata") 60 f.StringVar(&cmd.setAlbumID, "set-album", "", "Update MusicBrainz release ID for specified files/dirs") 61 f.BoolVar(&cmd.setNonAlbum, "set-non-album", false, "Update specified file(s) to be non-album tracks") 62 } 63 64 func (cmd *Command) Execute(ctx context.Context, fs *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { 65 api := newAPI("https://musicbrainz.org") 66 cmd.Cfg.ComputeGain = false // no need to compute gains 67 68 // TODO: Fail if multiple actions are specified. 69 switch { 70 case cmd.print, cmd.printFull: 71 return cmd.doPrint(fs.Args()) 72 case cmd.scan: 73 return cmd.doScan(ctx, api, fs.Args()) 74 case cmd.setAlbumID != "": 75 return cmd.doSetAlbum(ctx, api, fs.Args()) 76 case cmd.setNonAlbum: 77 return cmd.doSetNonAlbum(ctx, api, fs.Args()) 78 default: 79 fmt.Fprintln(os.Stderr, "No action specified") 80 return subcommands.ExitUsageError 81 } 82 } 83 84 // doPrint prints metadata for the specified song files. 85 func (cmd *Command) doPrint(args []string) subcommands.ExitStatus { 86 if len(args) == 0 { 87 fmt.Fprintln(os.Stderr, "No song files specified") 88 return subcommands.ExitUsageError 89 } 90 enc := json.NewEncoder(os.Stdout) 91 enc.SetIndent("", " ") 92 for _, p := range args { 93 var flags files.ReadSongFlag 94 if !cmd.printFull { 95 flags |= files.SkipAudioData 96 } 97 s, err := files.ReadSong(cmd.Cfg, p, nil, flags, nil /* gc */) 98 if err != nil { 99 fmt.Fprintln(os.Stderr, "Failed reading song:", err) 100 return subcommands.ExitFailure 101 } 102 var date string 103 if !s.Date.IsZero() { 104 date = s.Date.Format("2006-01-02") 105 } 106 // Use a custom struct instead of db.Song so we can choose which fields get printed. 107 enc.Encode(struct { 108 SHA1 string `json:"sha1,omitempty"` 109 Filename string `json:"filename"` 110 Artist string `json:"artist"` 111 Title string `json:"title"` 112 Album string `json:"album"` 113 AlbumArtist string `json:"albumArtist"` 114 DiscSubtitle string `json:"discSubtitle"` 115 AlbumID string `json:"albumId"` 116 OrigAlbumID string `json:"origAlbumId"` 117 RecordingID string `json:"recordingId"` 118 OrigRecordingID string `json:"origRecordingId"` 119 Track int `json:"track"` 120 Disc int `json:"disc"` 121 Date string `json:"date"` 122 Length float64 `json:"length,omitempty"` 123 }{ 124 SHA1: s.SHA1, 125 Filename: s.Filename, 126 Artist: s.Artist, 127 Title: s.Title, 128 Album: s.Album, 129 AlbumArtist: s.AlbumArtist, 130 DiscSubtitle: s.DiscSubtitle, 131 AlbumID: s.AlbumID, 132 OrigAlbumID: s.OrigAlbumID, 133 RecordingID: s.RecordingID, 134 OrigRecordingID: s.OrigRecordingID, 135 Track: s.Track, 136 Disc: s.Disc, 137 Date: date, 138 Length: s.Length, 139 }) 140 } 141 return subcommands.ExitSuccess 142 } 143 144 // doScan scans for updated metadata with the supplied positional args. 145 func (cmd *Command) doScan(ctx context.Context, api *api, args []string) subcommands.ExitStatus { 146 var errMsgs []string 147 if len(args) > 0 { 148 for _, p := range args { 149 if err := scanSong(ctx, cmd.Cfg, api, p, nil /* fi */, &cmd.opts); err != nil { 150 errMsgs = append(errMsgs, fmt.Sprintf("%v: %v", p, err)) 151 } 152 } 153 } else { 154 if len(cmd.Cfg.MusicDir) == 0 { 155 fmt.Fprintln(os.Stderr, "musicDir not set in config") 156 return subcommands.ExitUsageError 157 } 158 if err := filepath.Walk(cmd.Cfg.MusicDir, func(p string, fi os.FileInfo, err error) error { 159 if fi.Mode().IsRegular() && files.IsMusicPath(p) { 160 if err := scanSong(ctx, cmd.Cfg, api, p, fi, &cmd.opts); err != nil { 161 rel := p[len(cmd.Cfg.MusicDir)+1:] 162 errMsgs = append(errMsgs, fmt.Sprintf("%v: %v", rel, err)) 163 } 164 } 165 return nil 166 }); err != nil { 167 errMsgs = append(errMsgs, fmt.Sprintf("Failed walking music dir: %v", err)) 168 } 169 } 170 171 // Print the error messages last so they're easier to find. 172 if len(errMsgs) > 0 { 173 for _, msg := range errMsgs { 174 fmt.Fprintln(os.Stderr, msg) 175 } 176 return subcommands.ExitFailure 177 } 178 return subcommands.ExitSuccess 179 } 180 181 func (cmd *Command) doSetAlbum(ctx context.Context, api *api, paths []string) subcommands.ExitStatus { 182 // Read the songs from disk first. 183 var songs []*db.Song 184 for _, p := range paths { 185 ps, err := readAlbumSongs(cmd.Cfg, p) 186 if err != nil { 187 fmt.Fprintln(os.Stderr, "Failed reading songs:", err) 188 return subcommands.ExitFailure 189 } 190 songs = append(songs, ps...) 191 } 192 if len(songs) == 0 { 193 fmt.Fprintln(os.Stderr, "No songs found") 194 return subcommands.ExitUsageError 195 } 196 197 // Fetch the new release from MusicBrainz. 198 rel, err := api.getRelease(ctx, cmd.setAlbumID) 199 if err != nil { 200 fmt.Fprintf(os.Stderr, "Failed fetching release %v: %v\n", cmd.setAlbumID, err) 201 return subcommands.ExitFailure 202 } 203 204 // Map the songs to the album. 205 var recordingIDs []string 206 if cmd.recordingIDs != "" { 207 recordingIDs = strings.Split(cmd.recordingIDs, ",") 208 } 209 updated, err := setAlbum(songs, rel, recordingIDs) 210 if err != nil { 211 fmt.Fprintln(os.Stderr, "Failed setting album:", err) 212 return subcommands.ExitFailure 213 } 214 215 // Save the updates. 216 for i, orig := range songs { 217 up := updated[i] 218 if orig.MetadataEquals(up) { 219 continue 220 } 221 if cmd.opts.logUpdates { 222 fmt.Println(orig.Filename + "\n" + db.DiffSongs(orig, up) + "\n") 223 } 224 if !cmd.opts.dryRun { 225 if err := files.UpdateMetadataOverride(cmd.Cfg, up); err != nil { 226 fmt.Fprintln(os.Stderr, "Failed writing override file:", err) 227 return subcommands.ExitFailure 228 } 229 } 230 } 231 232 return subcommands.ExitSuccess 233 } 234 235 // doSetNonAlbum updates the specified song files to be non-album tracks. 236 func (cmd *Command) doSetNonAlbum(ctx context.Context, api *api, paths []string) subcommands.ExitStatus { 237 for _, p := range paths { 238 orig, err := files.ReadSong(cmd.Cfg, p, nil, files.SkipAudioData, nil /* gc */) 239 if err != nil { 240 fmt.Fprintln(os.Stderr, "Failed reading songs:", err) 241 return subcommands.ExitFailure 242 } 243 244 updated := *orig 245 updated.Album = files.NonAlbumTracksValue 246 updated.AlbumID = "" 247 248 if orig.MetadataEquals(&updated) { 249 continue 250 } 251 if cmd.opts.logUpdates { 252 fmt.Println(orig.Filename + "\n" + db.DiffSongs(orig, &updated) + "\n") 253 } 254 if !cmd.opts.dryRun { 255 if err := files.UpdateMetadataOverride(cmd.Cfg, &updated); err != nil { 256 fmt.Fprintln(os.Stderr, "Failed writing override file:", err) 257 return subcommands.ExitFailure 258 } 259 } 260 } 261 262 return subcommands.ExitSuccess 263 } 264 265 // updateOptions configures how songs are updated. 266 type updateOptions struct { 267 dryRun bool // don't actually write override files 268 logUpdates bool // print song updates to stdout 269 } 270 271 // scanSong reads the song file at p, fetches updated metadata using api, 272 // and writes a metadata override file if needed. p and fi are passed to files.ReadSong. 273 func scanSong(ctx context.Context, cfg *client.Config, api *api, 274 p string, fi os.FileInfo, opts *updateOptions) error { 275 if opts == nil { 276 opts = &updateOptions{} 277 } 278 orig, err := files.ReadSong(cfg, p, fi, files.SkipAudioData, nil /* gc */) 279 if err != nil { 280 return err 281 } 282 updated, err := getSongUpdates(ctx, orig, api) 283 if err != nil { 284 return err 285 } 286 if orig.MetadataEquals(updated) { 287 return nil 288 } 289 290 if opts.logUpdates { 291 fmt.Println(orig.Filename + "\n" + db.DiffSongs(orig, updated) + "\n") 292 } 293 if opts.dryRun { 294 return nil 295 } 296 return files.UpdateMetadataOverride(cfg, updated) 297 } 298 299 // getSongUpdates fetches metadata for song using api and returns an updated copy. 300 func getSongUpdates(ctx context.Context, song *db.Song, api *api) (*db.Song, error) { 301 updated := *song 302 303 switch { 304 // Some old standalone recordings have their album set to "[non-album tracks]" but also have a 305 // non-empty, now-deleted album ID. I think that (pre-NGS?) MB used to have per-artist fake 306 // "[non-album tracks]" albums. 307 case song.AlbumID != "" && song.Album != files.NonAlbumTracksValue: 308 if song.RecordingID == "" { 309 return nil, errors.New("no recording ID") 310 } 311 rel, err := api.getRelease(ctx, song.AlbumID) 312 if err != nil { 313 return nil, fmt.Errorf("release %v: %v", song.AlbumID, err) 314 } 315 if updateSongFromRelease(&updated, rel) { 316 return &updated, nil 317 } 318 319 // If we didn't find the recording in the release, it might've been 320 // merged into a different recording. Look up the recording to try 321 // to get an updated ID that might be in the release. 322 rec, err := api.getRecording(ctx, song.RecordingID) 323 if err != nil { 324 return nil, fmt.Errorf("recording %v: %v", song.RecordingID, err) 325 } 326 updated.RecordingID = rec.ID 327 if !updateSongFromRelease(&updated, rel) { 328 return nil, fmt.Errorf("recording %v not in release %v", rec.ID, rel.ID) 329 } 330 return &updated, nil 331 332 case song.RecordingID != "": 333 rec, err := api.getRecording(ctx, song.RecordingID) 334 if err != nil { 335 return nil, fmt.Errorf("recording %v: %v", song.RecordingID, err) 336 } 337 updateSongFromRecording(&updated, rec) 338 return &updated, nil 339 } 340 341 return nil, errors.New("song is untagged") 342 } 343 344 // readAlbumSongs reads p, a song file or a directory containing song files from a 345 // single album. Directory entries are sorted by ascinding disc and track, and 346 // SHA1s and lengths are computed. 347 func readAlbumSongs(cfg *client.Config, p string) ([]*db.Song, error) { 348 // If the path is a file, read it directly. 349 if fi, err := os.Stat(p); err != nil { 350 return nil, err 351 } else if !fi.IsDir() { 352 if s, err := files.ReadSong(cfg, p, nil, 0, nil /* gc */); err != nil { 353 return nil, err 354 } else { 355 return []*db.Song{s}, nil 356 } 357 } 358 359 // Otherwise, process all the songs in the directory. 360 dir := p 361 f, err := os.Open(dir) 362 if err != nil { 363 return nil, err 364 } 365 defer f.Close() 366 367 fis, err := f.Readdir(-1) 368 if err != nil { 369 return nil, err 370 } 371 372 var albumID string 373 var songs []*db.Song 374 for _, fi := range fis { 375 p := filepath.Join(dir, fi.Name()) 376 if !fi.Mode().IsRegular() || !files.IsMusicPath(p) { 377 continue 378 } 379 s, err := files.ReadSong(cfg, p, nil, 0, nil /* gc */) 380 if err != nil { 381 return nil, fmt.Errorf("%v: %v", p, err) 382 } 383 if s.RecordingID == "" { 384 return nil, fmt.Errorf("%q lacks recording ID", s.Filename) 385 } else if s.AlbumID == "" { 386 return nil, fmt.Errorf("%q lacks album ID", s.Filename) 387 } else if albumID == "" { 388 albumID = s.AlbumID 389 } else if s.AlbumID != albumID { 390 return nil, fmt.Errorf("%q has album ID %v but saw %v in same dir", s.Filename, s.AlbumID, albumID) 391 } 392 songs = append(songs, s) 393 } 394 395 sort.Slice(songs, func(i, j int) bool { 396 si, sj := songs[i], songs[j] 397 if si.Disc < sj.Disc { 398 return true 399 } else if sj.Disc < si.Disc { 400 return false 401 } 402 return si.Track < sj.Track 403 }) 404 405 return songs, nil 406 } 407 408 // setAlbum returns a shallow copy of the supplied songs with their album (and other metadata) 409 // switched to rel. An error is returned if the songs can't be mapped to the new album. 410 // recordingIDs may be used to override songs' recording IDs before matching. 411 func setAlbum(songs []*db.Song, rel *release, recordingIDs []string) ([]*db.Song, error) { 412 trackCountsMatch := len(songs) == rel.numTracks() 413 updated := make([]*db.Song, len(songs)) 414 for i, s := range songs { 415 cp := *s 416 updated[i] = &cp 417 418 // Override the song's recording ID if requested. 419 if i < len(recordingIDs) && recordingIDs[i] != "" { 420 cp.RecordingID = recordingIDs[i] 421 } 422 423 // First, try to match the song by recording ID. 424 // TODO: Is this safe, or would it be better to also compare the lengths here? 425 if updateSongFromRelease(&cp, rel) { 426 continue 427 } 428 429 // Otherwise, use the track in the same position if it's around the same length. 430 if !trackCountsMatch { 431 return nil, fmt.Errorf("%q has unmatched recording %v", s.Filename, s.RecordingID) 432 } 433 tr := rel.getTrackByIndex(i) // should succeed since track counts match 434 slen := time.Duration(s.Length * float64(time.Second)) 435 tlen := time.Duration(tr.Length) * time.Millisecond 436 if absDur(slen-tlen) > maxSongLengthDiff { 437 return nil, fmt.Errorf("%q length %v is too different from track %q length %v", s.Filename, slen, tr.Title, tlen) 438 } 439 cp.RecordingID = tr.Recording.ID 440 if !updateSongFromRelease(&cp, rel) { 441 return nil, fmt.Errorf("unable to find %q (recording %v) in new release", s.Filename, s.RecordingID) 442 } 443 } 444 445 // Make sure that recordings don't get used for multiple songs. 446 recs := make(map[string]string, len(updated)) // recording ID to filename 447 for _, s := range updated { 448 if fn, ok := recs[s.RecordingID]; ok { 449 return nil, fmt.Errorf("recording %v used for both %q and %q", s.RecordingID, fn, s.Filename) 450 } 451 recs[s.RecordingID] = s.Filename 452 } 453 454 return updated, nil 455 } 456 457 // TODO: Use time.Duration.Abs once I can switch to the go119 runtime. 458 func absDur(d time.Duration) time.Duration { 459 if d < 0 { 460 return -d 461 } 462 return d 463 }