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 }