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  }