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  }