From f61214a23811078a47f55c8ce4f9c83f67ed65e1 Mon Sep 17 00:00:00 2001 From: ABCbum Date: Mon, 23 Dec 2019 23:03:49 +0700 Subject: [PATCH] Support fetching cover art images from the Cover Art Archive Add option `--cover-art` to `whipper cd rip` command which accepts three values: - `file`: save the downloaded cover image as standalone file in the rip folder (named `cover.jpg`) - `embed`: embed the download cover image into all the ripped audio tracks (no standalone file will be kept) - `complete`: save standalone cover image as standalone file and embed it into all the ripped audio tracks (`file` + `embed`) Every cover art is fetched from the Cover Art Archive as JPEG thumbnail with a maximum dimension of 500px. Other supported values for the thumbnails are 250, 500 and 1200 (currently only some images have a corresponding 1200px sized thumbnail). This feature introduces an optional dependency on the `Pillow` module which is required for the decoding of the cover file (required by the `embed` and `complete` option values). Problem: - EmbedPicTureTask shouldn't be a task. Signed-off-by: ABCbum Co-authored-by: JoeLametta Signed-off-by: JoeLametta --- whipper/command/cd.py | 30 ++++++++++++++- whipper/common/encode.py | 71 ++++++++++++++++++++++++++++++++++- whipper/common/program.py | 35 ++++++++++++++++- whipper/program/cdparanoia.py | 5 ++- 4 files changed, 135 insertions(+), 6 deletions(-) diff --git a/whipper/command/cd.py b/whipper/command/cd.py index a5ecc49..488faef 100644 --- a/whipper/command/cd.py +++ b/whipper/command/cd.py @@ -20,6 +20,7 @@ import argparse import cdio +import importlib.util import os import glob import logging @@ -290,6 +291,14 @@ Log files will log the path to tracks relative to this directory. help="whether to continue ripping if " "the disc is a CD-R", default=False) + self.parser.add_argument('-C', '--cover-art', + action="store", dest="fetch_cover_art", + help="Fetch cover art and save it as " + "standalone file, embed into FLAC files " + "or perform both actions: file, embed, " + "complete option values respectively", + choices=['file', 'embed', 'complete'], + default=None) def handle_arguments(self): self.options.output_directory = os.path.expanduser( @@ -342,6 +351,19 @@ Log files will log the path to tracks relative to this directory. logger.info("creating output directory %s", dirname) os.makedirs(dirname) + self.coverArtPath = None + if (self.options.fetch_cover_art in {"embed", "complete"} and + importlib.util.find_spec("PIL") is None): + logger.warning("the cover art option '%s' won't be honored " + "because the 'pillow' module isn't available", + self.options.fetch_cover_art) + elif self.options.fetch_cover_art in {"file", "embed", "complete"}: + self.coverArtPath = self.program.getCoverArt( + dirname, + self.program.metadata.mbid) + if self.options.fetch_cover_art == "file": + self.coverArtPath = None # NOTE: avoid image embedding (hacky) + # FIXME: turn this into a method def _ripIfNotRipped(number): logger.debug('ripIfNotRipped for track %d', number) @@ -412,7 +434,8 @@ Log files will log the path to tracks relative to this directory. what='track %d of %d%s' % ( number, len(self.itable.tracks), - extra)) + extra), + coverArtPath=self.coverArtPath) break # FIXME: catching too general exception (Exception) except Exception as e: @@ -474,6 +497,11 @@ Log files will log the path to tracks relative to this directory. continue _ripIfNotRipped(i + 1) + if (self.options.fetch_cover_art == "embed" and + self.coverArtPath is not None): + logger.debug('deleting cover art file at: %r', self.coverArtPath) + os.remove(self.coverArtPath) + logger.debug('writing cue file for %r', discName) self.program.writeCue(discName) diff --git a/whipper/common/encode.py b/whipper/common/encode.py index 237daa9..667ab51 100644 --- a/whipper/common/encode.py +++ b/whipper/common/encode.py @@ -19,7 +19,8 @@ # along with whipper. If not, see . -from mutagen.flac import FLAC +from mutagen.flac import FLAC, Picture +from mutagen.id3 import PictureType from whipper.extern.task import task @@ -89,3 +90,71 @@ class TaggingTask(task.Task): w.save() self.stop() + + +class EmbedPictureTask(task.Task): + description = 'Embed picture to FLAC' + + def __init__(self, track_path, cover_art_path): + self.track_path = track_path + self.cover_art_path = cover_art_path + + def start(self, runner): + task.Task.start(self, runner) + self.schedule(0.0, self._embed_picture) + + def _make_flac_picture(self, cover_art_filename): + """ + Given a path to a jpg/png file, return a FLAC picture for embedding. + + The embedding will be performed using the mutagen module. + + :param cover_art_filename: path to cover art image file + :type cover_art_filename: str + :returns: a valid FLAC picture for embedding + :rtype: mutagen.flac.Picture or None + """ + if not cover_art_filename: + return + + from PIL import Image + + im = Image.open(cover_art_filename) + # NOTE: the cover art thumbnails we're getting from the Cover Art + # Archive should be always in the JPEG format: this check is currently + # useless but will leave it here to better handle unexpected formats. + if im.format == 'JPEG': + mime = 'image/jpeg' + elif im.format == 'PNG': + mime = 'image/png' + else: + # we only support png and jpeg + logger.warning("no cover art will be added because the fetched " + "image format is unsupported") + return + + pic = Picture() + with open(cover_art_filename, 'rb') as f: + pic.data = f.read() + + pic.type = PictureType.COVER_FRONT + pic.mime = mime + pic.width, pic.height = im.size + if im.mode not in ('P', 'RGB', 'SRGB'): + logger.warning("no cover art will be added because the fetched " + "image mode is unsupported") + return + + return pic + + def _embed_picture(self): + """ + Get flac picture generated from mutagen.flac.Picture then embed + it to given track if the flac picture exists. + """ + flac_pic = self._make_flac_picture(self.cover_art_path) + if flac_pic: + w = FLAC(self.track_path) + w.add_picture(flac_pic) + + self.stop() diff --git a/whipper/common/program.py b/whipper/common/program.py index 1d829ae..dd1f824 100644 --- a/whipper/common/program.py +++ b/whipper/common/program.py @@ -27,6 +27,7 @@ import re import os import time +from tempfile import NamedTemporaryFile from whipper.common import accurip, cache, checksum, common, mbngs, path from whipper.program import cdrdao, cdparanoia from whipper.image import image @@ -470,6 +471,35 @@ class Program: stop = track.getIndex(1).absolute - 1 return start, stop + def getCoverArt(self, path, release_id): + """ + Get cover art image from Cover Art Archive. + + :param path: where to store the fetched image + :type path: str + :param release_id: a release id (self.program.metadata.mbid) + :type release_id: str + :returns: path to the downloaded cover art, else `None` + :rtype: str or None + """ + cover_art_path = os.path.join(path, 'cover.jpg') + + logger.debug('fetching cover art for release: %r', release_id) + try: + data = musicbrainzngs.get_image_front(release_id, 500) + except musicbrainzngs.ResponseError as e: + logger.error('error fetching cover art: %r', e) + return + + if data: + with NamedTemporaryFile(suffix='.cover.jpg', delete=False) as f: + f.write(data) + os.chmod(f.name, 0o644) + os.replace(f.name, cover_art_path) + logger.debug('cover art fetched at: %r', cover_art_path) + return cover_art_path + return + @staticmethod def verifyTrack(runner, trackResult): is_wave = not trackResult.filename.endswith('.flac') @@ -490,7 +520,7 @@ class Program: return ret def ripTrack(self, runner, trackResult, offset, device, taglist, - overread, what=None): + overread, what=None, coverArtPath=None): """ Ripping the track may change the track's filename as stored in trackResult. @@ -516,7 +546,8 @@ class Program: offset=offset, device=device, taglist=taglist, - what=what) + what=what, + coverArtPath=coverArtPath) runner.run(t) diff --git a/whipper/program/cdparanoia.py b/whipper/program/cdparanoia.py index f071674..d410d0c 100644 --- a/whipper/program/cdparanoia.py +++ b/whipper/program/cdparanoia.py @@ -427,7 +427,7 @@ class ReadVerifyTrackTask(task.MultiSeparateTask): _tmppath = None def __init__(self, path, table, start, stop, overread, offset=0, - device=None, taglist=None, what="track"): + device=None, taglist=None, what="track", coverArtPath=None): """ :param path: where to store the ripped track :type path: str @@ -493,8 +493,9 @@ class ReadVerifyTrackTask(task.MultiSeparateTask): self.tasks.append(checksum.CRC32Task(tmppath)) self.tasks.append(encode.SoxPeakTask(tmppath)) - # TODO: Move tagging outside of cdparanoia + # TODO: Move tagging and embed picture outside of cdparanoia self.tasks.append(encode.TaggingTask(tmpoutpath, taglist)) + self.tasks.append(encode.EmbedPictureTask(tmpoutpath, coverArtPath)) self.checksum = None