github.com/derat/nup@v0.0.0-20230418113745-15592ba7c620/cmd/nup/covers/command.go (about)

     1  // Copyright 2020 Daniel Erat.
     2  // All rights reserved.
     3  
     4  package covers
     5  
     6  import (
     7  	"context"
     8  	"encoding/json"
     9  	"flag"
    10  	"fmt"
    11  	"image"
    12  	_ "image/jpeg"
    13  	"io"
    14  	"log"
    15  	"net/http"
    16  	"os"
    17  	"os/exec"
    18  	"path/filepath"
    19  	"strconv"
    20  	"strings"
    21  	"sync"
    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  const logInterval = 100
    31  
    32  type Command struct {
    33  	Cfg *client.Config
    34  
    35  	coverDir     string // directory containing cover images
    36  	download     bool   // download image covers to coverDir
    37  	generateWebP bool   // generate WebP versions of covers in coverDir
    38  	maxSongs     int    // songs to inspect
    39  	maxRequests  int    // parallel HTTP requests
    40  	size         int    // image size to download (250, 500, 1200)
    41  }
    42  
    43  func (*Command) Name() string     { return "covers" }
    44  func (*Command) Synopsis() string { return "manage album art" }
    45  func (*Command) Usage() string {
    46  	return `covers <flags>:
    47  	Manipulate album art images in a directory.
    48  	With -download, downloads album art from coverartarchive.org.
    49  	With -generate-webp, generates WebP versions of existing JPEG images.
    50  
    51  `
    52  }
    53  
    54  func (cmd *Command) SetFlags(f *flag.FlagSet) {
    55  	f.StringVar(&cmd.coverDir, "cover-dir", "", "Directory containing cover images")
    56  	f.BoolVar(&cmd.download, "download", false,
    57  		"Download covers for dumped songs read from stdin or positional song files to -cover-dir")
    58  	f.IntVar(&cmd.size, "download-size", 1200, "Image size to download (250, 500, or 1200)")
    59  	f.BoolVar(&cmd.generateWebP, "generate-webp", false, "Generate WebP versions of covers in -cover-dir")
    60  	f.IntVar(&cmd.maxSongs, "max-downloads", -1, "Maximum number of songs to inspect for -download")
    61  	f.IntVar(&cmd.maxRequests, "max-requests", 2, "Maximum number of parallel HTTP requests for -download")
    62  }
    63  
    64  func (cmd *Command) Execute(ctx context.Context, fs *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
    65  	if cmd.coverDir == "" {
    66  		fmt.Fprintln(os.Stderr, "-cover-dir must be supplied")
    67  		return subcommands.ExitUsageError
    68  	}
    69  
    70  	switch {
    71  	case cmd.download:
    72  		if err := cmd.doDownload(fs.Args()); err != nil {
    73  			fmt.Fprintln(os.Stderr, "Failed downloading covers:", err)
    74  			return subcommands.ExitFailure
    75  		}
    76  		return subcommands.ExitSuccess
    77  	case cmd.generateWebP:
    78  		if err := cmd.doGenerateWebP(); err != nil {
    79  			fmt.Fprintln(os.Stderr, "Failed generating WebP images:", err)
    80  			return subcommands.ExitFailure
    81  		}
    82  		return subcommands.ExitSuccess
    83  	default:
    84  		fmt.Fprintln(os.Stderr, "Must supply one of -download and -generate-webp")
    85  		return subcommands.ExitUsageError
    86  	}
    87  }
    88  
    89  func (cmd *Command) doDownload(paths []string) error {
    90  	albumIDs := make([]string, 0)
    91  	if len(paths) > 0 {
    92  		ids := make(map[string]struct{})
    93  		for _, p := range paths {
    94  			// Pass a bogus config in case p isn't within cmd.Cfg.MusicDir.
    95  			cfg := client.Config{MusicDir: filepath.Dir(p)}
    96  			if s, err := files.ReadSong(&cfg, p, nil, files.SkipAudioData, nil); err != nil {
    97  				return err
    98  			} else if s.AlbumID != "" {
    99  				log.Printf("%v has album ID %v", p, s.AlbumID)
   100  				ids[s.AlbumID] = struct{}{}
   101  			}
   102  		}
   103  		for id, _ := range ids {
   104  			albumIDs = append(albumIDs, id)
   105  		}
   106  	} else {
   107  		log.Print("Reading songs from stdin")
   108  		var err error
   109  		if albumIDs, err = readDumpedSongs(os.Stdin, cmd.coverDir, cmd.maxSongs); err != nil {
   110  			return err
   111  		}
   112  	}
   113  
   114  	log.Printf("Downloading cover(s) for %v album(s)", len(albumIDs))
   115  	downloadCovers(albumIDs, cmd.coverDir, cmd.size, cmd.maxRequests)
   116  	return nil
   117  }
   118  
   119  func (cmd *Command) doGenerateWebP() error {
   120  	return filepath.Walk(cmd.coverDir, func(p string, fi os.FileInfo, err error) error {
   121  		if err != nil {
   122  			return err
   123  		}
   124  		if !fi.Mode().IsRegular() || !strings.HasSuffix(p, cover.OrigExt) {
   125  			return nil
   126  		}
   127  
   128  		var width, height int
   129  		for _, size := range cover.WebPSizes {
   130  			// Skip this size if we already have it and it's up-to-date.
   131  			gp := cover.WebPFilename(p, size)
   132  			if gfi, err := os.Stat(gp); err == nil && !fi.ModTime().After(gfi.ModTime()) {
   133  				continue
   134  			}
   135  			// Read the source image's dimensions if we haven't already.
   136  			if width == 0 && height == 0 {
   137  				if width, height, err = getDimensions(p); err != nil {
   138  					return fmt.Errorf("failed getting %q dimensions: %v", p, err)
   139  				}
   140  			}
   141  			if err := writeWebP(p, gp, width, height, size); err != nil {
   142  				return fmt.Errorf("failed converting %q to %q: %v", p, gp, err)
   143  			}
   144  		}
   145  		return nil
   146  	})
   147  }
   148  
   149  // getDimensions returns the dimensions of the JPEG image at p.
   150  func getDimensions(p string) (width, height int, err error) {
   151  	f, err := os.Open(p)
   152  	if err != nil {
   153  		return 0, 0, err
   154  	}
   155  	defer f.Close()
   156  
   157  	cfg, _, err := image.DecodeConfig(f)
   158  	return cfg.Width, cfg.Height, err
   159  }
   160  
   161  // writeWebP writes the JPEG image at srcPath of the supplied dimensions
   162  // to destPath in WebP format and with the supplied (square) size.
   163  // The image is cropped and scaled as per the Scale function in server/cover/cover.go.
   164  func writeWebP(srcPath string, destPath string, srcWidth, srcHeight int, destSize int) error {
   165  	args := []string{
   166  		"-mt", // multithreaded
   167  		"-resize", strconv.Itoa(destSize), strconv.Itoa(destSize),
   168  	}
   169  
   170  	// Crop the source image rect if it isn't square.
   171  	if srcWidth > srcHeight {
   172  		args = append(args, "-crop", strconv.Itoa((srcWidth-srcHeight)/2), "0",
   173  			strconv.Itoa(srcHeight), strconv.Itoa(srcHeight))
   174  	} else if srcHeight > srcWidth {
   175  		args = append(args, "-crop", "0", strconv.Itoa((srcHeight-srcWidth)/2),
   176  			strconv.Itoa(srcWidth), strconv.Itoa(srcWidth))
   177  	}
   178  
   179  	// TODO: cwebp dies with "Unsupported color conversion request" when given
   180  	// a JPEG with a CMYK (rather than RGB) color space:
   181  	// https://groups.google.com/a/webmproject.org/g/webp-discuss/c/MH8q_d6M1vM
   182  	// This can be fixed with e.g. "convert -colorspace RGB old.jpg new.jpg", but
   183  	// CMYK images seem to be rare enough that I haven't bothered automating this.
   184  	args = append(args, "-o", destPath, srcPath)
   185  	err := exec.Command("cwebp", args...).Run()
   186  	// TODO: It'd probably be safer to write to a temp file and then rename, since it'd
   187  	// still be possible for us to die before we can unlink the dest file. If a partial
   188  	// file is written, it probably won't be replaced in future runs due to its timestamp.
   189  	if err != nil {
   190  		os.Remove(destPath)
   191  	}
   192  	return err
   193  }
   194  
   195  func readDumpedSongs(r io.Reader, coverDir string, maxSongs int) (albumIDs []string, err error) {
   196  	missingAlbumIDs := make(map[string]struct{})
   197  	d := json.NewDecoder(r)
   198  	numSongs := 0
   199  	for {
   200  		if maxSongs >= 0 && numSongs >= maxSongs {
   201  			break
   202  		}
   203  
   204  		s := db.Song{}
   205  		if err = d.Decode(&s); err == io.EOF {
   206  			break
   207  		} else if err != nil {
   208  			return nil, err
   209  		}
   210  		numSongs++
   211  
   212  		if numSongs%logInterval == 0 {
   213  			log.Printf("Scanned %v songs", numSongs)
   214  		}
   215  
   216  		// Can't do anything if the song doesn't have an album ID.
   217  		if len(s.AlbumID) == 0 {
   218  			continue
   219  		}
   220  
   221  		// Check if we already have the cover.
   222  		if _, err := os.Stat(filepath.Join(coverDir, s.AlbumID+cover.OrigExt)); err == nil {
   223  			continue
   224  		}
   225  
   226  		missingAlbumIDs[s.AlbumID] = struct{}{}
   227  	}
   228  	if numSongs%logInterval != 0 {
   229  		log.Printf("Scanned %v songs", numSongs)
   230  	}
   231  
   232  	ret := make([]string, len(missingAlbumIDs))
   233  	i := 0
   234  	for id := range missingAlbumIDs {
   235  		ret[i] = id
   236  		i++
   237  	}
   238  	return ret, nil
   239  }
   240  
   241  // downloadCover downloads cover art for albumID into dir.
   242  // If the cover was not found, path is empty and err is nil.
   243  func downloadCover(albumID, dir string, size int) (path string, err error) {
   244  	url := fmt.Sprintf("https://coverartarchive.org/release/%s/front-%d", albumID, size)
   245  	resp, err := http.Get(url)
   246  	if err != nil {
   247  		return "", fmt.Errorf("Fetching %v failed: %v", url, err)
   248  	}
   249  	if resp.StatusCode != 200 {
   250  		resp.Body.Close()
   251  		if resp.StatusCode == 404 {
   252  			return "", nil
   253  		}
   254  		return "", fmt.Errorf("Got %v when fetching %v", resp.StatusCode, url)
   255  	}
   256  	defer resp.Body.Close()
   257  
   258  	path = filepath.Join(dir, albumID+cover.OrigExt)
   259  	f, err := os.Create(path)
   260  	if err != nil {
   261  		return "", err
   262  	}
   263  	defer f.Close()
   264  
   265  	if _, err = io.Copy(f, resp.Body); err != nil {
   266  		return "", fmt.Errorf("Failed to read from %v: %v", url, err)
   267  	}
   268  	return path, nil
   269  }
   270  
   271  func downloadCovers(albumIDs []string, dir string, size, maxRequests int) {
   272  	cache := client.NewTaskCache(maxRequests)
   273  	wg := sync.WaitGroup{}
   274  	wg.Add(len(albumIDs))
   275  
   276  	for _, id := range albumIDs {
   277  		go func(id string) {
   278  			if path, err := cache.Get(id, id, func() (map[string]interface{}, error) {
   279  				if p, err := downloadCover(id, dir, size); err != nil {
   280  					return nil, err
   281  				} else {
   282  					return map[string]interface{}{id: p}, nil
   283  				}
   284  			}); err != nil {
   285  				log.Printf("Failed to get %v: %v", id, err)
   286  			} else if len(path.(string)) == 0 {
   287  				log.Printf("Didn't find %v", id)
   288  			} else {
   289  				log.Printf("Wrote %v", path.(string))
   290  			}
   291  			wg.Done()
   292  		}(id)
   293  	}
   294  	wg.Wait()
   295  }