# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Allows beets to embed album art into file metadata.""" import os.path import tempfile from mimetypes import guess_extension import requests from beets import art, config, ui from beets.plugins import BeetsPlugin from beets.ui import print_ from beets.util import bytestring_path, displayable_path, normpath, syspath from beets.util.artresizer import ArtResizer def _confirm(objs, album): """Show the list of affected objects (items or albums) and confirm that the user wants to modify their artwork. `album` is a Boolean indicating whether these are albums (as opposed to items). """ noun = "album" if album else "file" prompt = "Modify artwork for {} {}{} (Y/n)?".format( len(objs), noun, "s" if len(objs) > 1 else "" ) # Show all the items or albums. for obj in objs: print_(format(obj)) # Confirm with user. return ui.input_yn(prompt) class EmbedCoverArtPlugin(BeetsPlugin): """Allows albumart to be embedded into the actual files.""" def __init__(self): super().__init__() self.config.add( { "maxwidth": 0, "auto": True, "compare_threshold": 0, "ifempty": False, "remove_art_file": False, "quality": 0, } ) if self.config["maxwidth"].get(int) and not ArtResizer.shared.local: self.config["maxwidth"] = 0 self._log.warning( "ImageMagick or PIL not found; 'maxwidth' option ignored" ) if ( self.config["compare_threshold"].get(int) and not ArtResizer.shared.can_compare ): self.config["compare_threshold"] = 0 self._log.warning( "ImageMagick 6.8.7 or higher not installed; " "'compare_threshold' option ignored" ) self.register_listener("art_set", self.process_album) def commands(self): # Embed command. embed_cmd = ui.Subcommand( "embedart", help="embed image files into file metadata" ) embed_cmd.parser.add_option( "-f", "--file", metavar="PATH", help="the image file to embed" ) embed_cmd.parser.add_option( "-y", "--yes", action="store_true", help="skip confirmation" ) embed_cmd.parser.add_option( "-u", "--url", metavar="URL", help="the URL of the image file to embed", ) maxwidth = self.config["maxwidth"].get(int) quality = self.config["quality"].get(int) compare_threshold = self.config["compare_threshold"].get(int) ifempty = self.config["ifempty"].get(bool) def embed_func(lib, opts, args): if opts.file: imagepath = normpath(opts.file) if not os.path.isfile(syspath(imagepath)): raise ui.UserError( "image file {} not found".format( displayable_path(imagepath) ) ) items = lib.items(args) # Confirm with user. if not opts.yes and not _confirm(items, not opts.file): return for item in items: art.embed_item( self._log, item, imagepath, maxwidth, None, compare_threshold, ifempty, quality=quality, ) elif opts.url: try: response = requests.get(opts.url, timeout=5) response.raise_for_status() except requests.exceptions.RequestException as e: self._log.error("{}".format(e)) return extension = guess_extension(response.headers["Content-Type"]) if extension is None: self._log.error("Invalid image file") return file = f"image{extension}" tempimg = os.path.join(tempfile.gettempdir(), file) try: with open(tempimg, "wb") as f: f.write(response.content) except Exception as e: self._log.error("Unable to save image: {}".format(e)) return items = lib.items(args) # Confirm with user. if not opts.yes and not _confirm(items, not opts.url): os.remove(tempimg) return for item in items: art.embed_item( self._log, item, tempimg, maxwidth, None, compare_threshold, ifempty, quality=quality, ) os.remove(tempimg) else: albums = lib.albums(args) # Confirm with user. if not opts.yes and not _confirm(albums, not opts.file): return for album in albums: art.embed_album( self._log, album, maxwidth, False, compare_threshold, ifempty, quality=quality, ) self.remove_artfile(album) embed_cmd.func = embed_func # Extract command. extract_cmd = ui.Subcommand( "extractart", help="extract an image from file metadata", ) extract_cmd.parser.add_option( "-o", dest="outpath", help="image output file", ) extract_cmd.parser.add_option( "-n", dest="filename", help="image filename to create for all matched albums", ) extract_cmd.parser.add_option( "-a", dest="associate", action="store_true", help="associate the extracted images with the album", ) def extract_func(lib, opts, args): if opts.outpath: art.extract_first( self._log, normpath(opts.outpath), lib.items(args) ) else: filename = bytestring_path( opts.filename or config["art_filename"].get() ) if os.path.dirname(filename) != b"": self._log.error( "Only specify a name rather than a path for -n" ) return for album in lib.albums(args): artpath = normpath(os.path.join(album.path, filename)) artpath = art.extract_first( self._log, artpath, album.items() ) if artpath and opts.associate: album.set_art(artpath) album.store() extract_cmd.func = extract_func # Clear command. clear_cmd = ui.Subcommand( "clearart", help="remove images from file metadata", ) clear_cmd.parser.add_option( "-y", "--yes", action="store_true", help="skip confirmation" ) def clear_func(lib, opts, args): items = lib.items(args) # Confirm with user. if not opts.yes and not _confirm(items, False): return art.clear(self._log, lib, args) clear_cmd.func = clear_func return [embed_cmd, extract_cmd, clear_cmd] def process_album(self, album): """Automatically embed art after art has been set""" if self.config["auto"] and ui.should_write(): max_width = self.config["maxwidth"].get(int) art.embed_album( self._log, album, max_width, True, self.config["compare_threshold"].get(int), self.config["ifempty"].get(bool), ) self.remove_artfile(album) def remove_artfile(self, album): """Possibly delete the album art file for an album (if the appropriate configuration option is enabled). """ if self.config["remove_art_file"] and album.artpath: if os.path.isfile(syspath(album.artpath)): self._log.debug("Removing album art file for {0}", album) os.remove(syspath(album.artpath)) album.artpath = None album.store()