Source code for eyed3.plugins.classic

import os
import re
import dataclasses
from functools import partial
from argparse import ArgumentTypeError

from eyed3.plugins import LoaderPlugin
from eyed3 import core, id3, mp3
from eyed3.utils import makeUniqueFileName, b, formatTime
from eyed3.utils.console import (
    printMsg, printError, printWarning, boldText, getTtySize,
)
from eyed3.id3.frames import ImageFrame
from eyed3.mimetype import guessMimetype

from eyed3.utils.log import getLogger
log = getLogger(__name__)

FIELD_DELIM = ':'

DEFAULT_MAX_PADDING = 64 * 1024


[docs]class ClassicPlugin(LoaderPlugin): SUMMARY = "Classic eyeD3 interface for viewing and editing tags." DESCRIPTION = """ All PATH arguments are parsed and displayed. Directory paths are searched recursively. Any editing options (--artist, --title) are applied to each file read. All date options (-Y, --release-year excepted) follow ISO 8601 format. This is ``yyyy-mm-ddThh:mm:ss``. The year is required, and each component thereafter is optional. For example, 2012-03 is valid, 2012--12 is not. """ NAMES = ["classic"] def __init__(self, arg_parser): super(ClassicPlugin, self).__init__(arg_parser) g = self.arg_group def PositiveIntArg(i): i = int(i) if i < 0: raise ArgumentTypeError("positive number required") return i # Common options g.add_argument("-a", "--artist", dest="artist", metavar="STRING", help=ARGS_HELP["--artist"]) g.add_argument("-A", "--album", dest="album", metavar="STRING", help=ARGS_HELP["--album"]) g.add_argument("-b", "--album-artist", dest="album_artist", metavar="STRING", help=ARGS_HELP["--album-artist"]) g.add_argument("-t", "--title", dest="title", metavar="STRING", help=ARGS_HELP["--title"]) g.add_argument("-n", "--track", type=PositiveIntArg, dest="track", metavar="NUM", help=ARGS_HELP["--track"]) g.add_argument("-N", "--track-total", type=PositiveIntArg, dest="track_total", metavar="NUM", help=ARGS_HELP["--track-total"]) g.add_argument("--track-offset", type=int, dest="track_offset", metavar="N", help=ARGS_HELP["--track-offset"]) g.add_argument("--composer", dest="composer", metavar="STRING", help=ARGS_HELP["--composer"]) g.add_argument("--orig-artist", dest="orig_artist", metavar="STRING", help=ARGS_HELP["--orig-artist"]) g.add_argument("-d", "--disc-num", type=PositiveIntArg, dest="disc_num", metavar="NUM", help=ARGS_HELP["--disc-num"]) g.add_argument("-D", "--disc-total", type=PositiveIntArg, dest="disc_total", metavar="NUM", help=ARGS_HELP["--disc-total"]) g.add_argument("-G", "--genre", dest="genre", metavar="GENRE", help=ARGS_HELP["--genre"]) g.add_argument("--non-std-genres", dest="non_std_genres", action="store_true", help=ARGS_HELP["--non-std-genres"]) g.add_argument("-Y", "--release-year", type=PositiveIntArg, dest="release_year", metavar="YEAR", help=ARGS_HELP["--release-year"]) g.add_argument("-c", "--comment", dest="simple_comment", metavar="STRING", help=ARGS_HELP["--comment"]) g.add_argument("--artist-city", metavar="STRING", help="The artist's city of origin. " f"Stored as a user text frame `{core.TXXX_ARTIST_ORIGIN}`") g.add_argument("--artist-state", metavar="STRING", help="The artist's state of origin. " f"Stored as a user text frame `{core.TXXX_ARTIST_ORIGIN}`") g.add_argument("--artist-country", metavar="STRING", help="The artist's country of origin. " f"Stored as a user text frame `{core.TXXX_ARTIST_ORIGIN}`") g.add_argument("--rename", dest="rename_pattern", metavar="PATTERN", help=ARGS_HELP["--rename"]) gid3 = arg_parser.add_argument_group("ID3 options") def _splitArgs(arg, maxsplit=None): NEW_DELIM = "#DELIM#" arg = re.sub(r"\\%s" % FIELD_DELIM, NEW_DELIM, arg) t = tuple(re.sub(NEW_DELIM, FIELD_DELIM, s) for s in arg.split(FIELD_DELIM)) if maxsplit is not None and maxsplit < 2: raise ValueError("Invalid maxsplit value: {}".format(maxsplit)) elif maxsplit and len(t) > maxsplit: t = t[:maxsplit - 1] + (FIELD_DELIM.join(t[maxsplit - 1:]),) assert len(t) <= maxsplit return t def DescLangArg(arg): """DESCRIPTION[:LANG]""" vals = _splitArgs(arg, 2) desc = vals[0] lang = vals[1] if len(vals) > 1 else id3.DEFAULT_LANG return desc, b(lang)[:3] or id3.DEFAULT_LANG def DescTextArg(arg): """DESCRIPTION:TEXT""" vals = _splitArgs(arg, 2) desc = vals[0].strip() text = FIELD_DELIM.join(vals[1:] if len(vals) > 1 else []) return desc or "", text or "" KeyValueArg = DescTextArg def DescUrlArg(arg): desc, url = DescTextArg(arg) return desc, url.encode("latin1") def FidArg(arg): fid = arg.strip().encode("ascii") if not fid: raise ArgumentTypeError("No frame ID") return fid def TextFrameArg(arg): """FID:TEXT""" vals = _splitArgs(arg, 2) fid = vals[0].strip().encode("ascii") if not fid: raise ArgumentTypeError("No frame ID") text = vals[1] if len(vals) > 1 else "" return fid, text def UrlFrameArg(arg): """FID:TEXT""" fid, url = TextFrameArg(arg) return fid, url.encode("latin1") def DateArg(date_str): return core.Date.parse(date_str) if date_str else "" def CommentArg(arg): """ COMMENT[:DESCRIPTION[:LANG] """ vals = _splitArgs(arg, 3) text = vals[0] if not text: raise ArgumentTypeError("text required") desc = vals[1] if len(vals) > 1 else "" lang = vals[2] if len(vals) > 2 else id3.DEFAULT_LANG return text, desc, b(lang)[:3] def LyricsArg(arg): text, desc, lang = CommentArg(arg) try: with open(text, "r") as fp: data = fp.read() except Exception: # noqa: B901 raise ArgumentTypeError("Unable to read file") return data, desc, lang def PlayCountArg(pc): if not pc: raise ArgumentTypeError("value required") increment = False if pc[0] == "+": pc = int(pc[1:]) increment = True else: pc = int(pc) if pc < 0: raise ArgumentTypeError("out of range") return increment, pc def BpmArg(bpm): bpm = int(float(bpm) + 0.5) if bpm <= 0: raise ArgumentTypeError("out of range") return bpm def DirArg(d): if not d or not os.path.isdir(d): raise ArgumentTypeError("invalid directory: %s" % d) return d def ImageArg(s): """PATH:TYPE[:DESCRIPTION] Returns (path, type_id, mime_type, description) """ args = _splitArgs(s, 3) if len(args) < 2: raise ArgumentTypeError("Format is: PATH:TYPE[:DESCRIPTION]") path, type_str = args[:2] desc = args[2] if len(args) > 2 else "" try: type_id = id3.frames.ImageFrame.stringToPicType(type_str) except: # noqa: B901 raise ArgumentTypeError("invalid pic type: {}".format(type_str)) if not path: raise ArgumentTypeError("path required") elif True in [path.startswith(prefix) for prefix in ["http://", "https://"]]: mt = ImageFrame.URL_MIME_TYPE else: if not os.path.isfile(path): raise ArgumentTypeError("file does not exist") mt = guessMimetype(path) if mt is None: raise ArgumentTypeError("Cannot determine mime-type") return path, type_id, mt, desc def ObjectArg(s): """OBJ_PATH:MIME-TYPE[:DESCRIPTION[:FILENAME]], Returns (path, mime_type, description, filename) """ args = _splitArgs(s, 4) if len(args) < 2: raise ArgumentTypeError("too few parts") path = args[0] if path: mt = args[1] desc = args[2] if len(args) > 2 else "" filename = args[3] \ if len(args) > 3 \ else os.path.basename(path) if not os.path.isfile(path): raise ArgumentTypeError("file does not exist") if not mt: raise ArgumentTypeError("mime-type required") else: raise ArgumentTypeError("path required") return (path, mt, desc, filename) def UniqFileIdArg(arg): owner_id, id = KeyValueArg(arg) if not owner_id: raise ArgumentTypeError("owner_id required") id = id.encode("latin1") # don't want to pass unicode if len(id) > 64: raise ArgumentTypeError("id must be <= 64 bytes") return (owner_id, id) def PopularityArg(arg): """EMAIL:RATING[:PLAY_COUNT] Returns (email, rating, play_count) """ args = _splitArgs(arg, 3) if len(args) < 2: raise ArgumentTypeError("Incorrect number of argument components") email = args[0] rating = int(float(args[1])) if rating < 0 or rating > 255: raise ArgumentTypeError("Rating out-of-range") play_count = 0 if len(args) > 2: play_count = int(args[2]) if play_count < 0: raise ArgumentTypeError("Play count out-of-range") return (email, rating, play_count) # Tag versions gid3.add_argument("-1", "--v1", action="store_const", const=id3.ID3_V1, dest="tag_version", default=id3.ID3_ANY_VERSION, help=ARGS_HELP["--v1"]) gid3.add_argument("-2", "--v2", action="store_const", const=id3.ID3_V2, dest="tag_version", default=id3.ID3_ANY_VERSION, help=ARGS_HELP["--v2"]) gid3.add_argument("--to-v1.1", action="store_const", const=id3.ID3_V1_1, dest="convert_version", help=ARGS_HELP["--to-v1.1"]) gid3.add_argument("--to-v2.3", action="store_const", const=id3.ID3_V2_3, dest="convert_version", help=ARGS_HELP["--to-v2.3"]) gid3.add_argument("--to-v2.4", action="store_const", const=id3.ID3_V2_4, dest="convert_version", help=ARGS_HELP["--to-v2.4"]) # Dates gid3.add_argument("--release-date", type=DateArg, dest="release_date", metavar="DATE", help=ARGS_HELP["--release-date"]) gid3.add_argument("--orig-release-date", type=DateArg, dest="orig_release_date", metavar="DATE", help=ARGS_HELP["--orig-release-date"]) gid3.add_argument("--recording-date", type=DateArg, dest="recording_date", metavar="DATE", help=ARGS_HELP["--recording-date"]) gid3.add_argument("--encoding-date", type=DateArg, dest="encoding_date", metavar="DATE", help=ARGS_HELP["--encoding-date"]) gid3.add_argument("--tagging-date", type=DateArg, dest="tagging_date", metavar="DATE", help=ARGS_HELP["--tagging-date"]) # Misc gid3.add_argument("--publisher", action="store", dest="publisher", metavar="STRING", help=ARGS_HELP["--publisher"]) gid3.add_argument("--play-count", type=PlayCountArg, dest="play_count", metavar="<+>N", default=None, help=ARGS_HELP["--play-count"]) gid3.add_argument("--bpm", type=BpmArg, dest="bpm", metavar="N", default=None, help=ARGS_HELP["--bpm"]) gid3.add_argument("--unique-file-id", action="append", type=UniqFileIdArg, dest="unique_file_ids", metavar="OWNER_ID:ID", default=[], help=ARGS_HELP["--unique-file-id"]) # Comments gid3.add_argument("--add-comment", action="append", dest="comments", metavar="COMMENT[:DESCRIPTION[:LANG]]", default=[], type=CommentArg, help=ARGS_HELP["--add-comment"]) gid3.add_argument("--remove-comment", action="append", type=DescLangArg, dest="remove_comment", default=[], metavar="DESCRIPTION[:LANG]", help=ARGS_HELP["--remove-comment"]) gid3.add_argument("--remove-all-comments", action="store_true", dest="remove_all_comments", help=ARGS_HELP["--remove-all-comments"]) gid3.add_argument("--add-lyrics", action="append", type=LyricsArg, dest="lyrics", default=[], metavar="LYRICS_FILE[:DESCRIPTION[:LANG]]", help=ARGS_HELP["--add-lyrics"]) gid3.add_argument("--remove-lyrics", action="append", type=DescLangArg, dest="remove_lyrics", default=[], metavar="DESCRIPTION[:LANG]", help=ARGS_HELP["--remove-lyrics"]) gid3.add_argument("--remove-all-lyrics", action="store_true", dest="remove_all_lyrics", help=ARGS_HELP["--remove-all-lyrics"]) gid3.add_argument("--text-frame", action="append", type=TextFrameArg, dest="text_frames", metavar="FID:TEXT", default=[], help=ARGS_HELP["--text-frame"]) gid3.add_argument("--user-text-frame", action="append", type=DescTextArg, dest="user_text_frames", metavar="DESC:TEXT", default=[], help=ARGS_HELP["--user-text-frame"]) gid3.add_argument("--url-frame", action="append", type=UrlFrameArg, dest="url_frames", metavar="FID:URL", default=[], help=ARGS_HELP["--url-frame"]) gid3.add_argument("--user-url-frame", action="append", type=DescUrlArg, dest="user_url_frames", metavar="DESCRIPTION:URL", default=[], help=ARGS_HELP["--user-url-frame"]) gid3.add_argument("--add-image", action="append", type=ImageArg, dest="images", metavar="IMG_PATH:TYPE[:DESCRIPTION]", default=[], help=ARGS_HELP["--add-image"]) gid3.add_argument("--remove-image", action="append", dest="remove_image", default=[], metavar="DESCRIPTION", help=ARGS_HELP["--remove-image"]) gid3.add_argument("--remove-all-images", action="store_true", dest="remove_all_images", help=ARGS_HELP["--remove-all-images"]) gid3.add_argument("--write-images", dest="write_images_dir", metavar="DIR", type=DirArg, help=ARGS_HELP["--write-images"]) gid3.add_argument("--add-object", action="append", type=ObjectArg, dest="objects", default=[], metavar="OBJ_PATH:MIME-TYPE[:DESCRIPTION[:FILENAME]]", help=ARGS_HELP["--add-object"]) gid3.add_argument("--remove-object", action="append", dest="remove_object", default=[], metavar="DESCRIPTION", help=ARGS_HELP["--remove-object"]) gid3.add_argument("--write-objects", action="store", dest="write_objects_dir", metavar="DIR", default=None, help=ARGS_HELP["--write-objects"]) gid3.add_argument("--remove-all-objects", action="store_true", dest="remove_all_objects", help=ARGS_HELP["--remove-all-objects"]) gid3.add_argument("--add-popularity", action="append", type=PopularityArg, dest="popularities", default=[], metavar="EMAIL:RATING[:PLAY_COUNT]", help=ARGS_HELP["--add-popularity"]) gid3.add_argument("--remove-popularity", action="append", type=str, dest="remove_popularity", default=[], metavar="EMAIL", help=ARGS_HELP["--remove-popularity"]) gid3.add_argument("--remove-v1", action="store_true", dest="remove_v1", default=False, help=ARGS_HELP["--remove-v1"]) gid3.add_argument("--remove-v2", action="store_true", dest="remove_v2", default=False, help=ARGS_HELP["--remove-v2"]) gid3.add_argument("--remove-all", action="store_true", default=False, dest="remove_all", help=ARGS_HELP["--remove-all"]) gid3.add_argument("--remove-frame", action="append", default=[], dest="remove_fids", metavar="FID", type=FidArg, help=ARGS_HELP["--remove-frame"]) # 'True' means 'apply default max_padding, but only if saving anyhow' gid3.add_argument("--max-padding", type=int, dest="max_padding", default=True, metavar="NUM_BYTES", help=ARGS_HELP["--max-padding"]) gid3.add_argument("--no-max-padding", dest="max_padding", action="store_const", const=None, help=ARGS_HELP["--no-max-padding"]) _encodings = ["latin1", "utf8", "utf16", "utf16-be"] gid3.add_argument("--encoding", dest="text_encoding", default=None, choices=_encodings, metavar='|'.join(_encodings), help=ARGS_HELP["--encoding"]) # Misc options gid4 = arg_parser.add_argument_group("Misc options") gid4.add_argument("--force-update", action="store_true", default=False, dest="force_update", help=ARGS_HELP["--force-update"]) gid4.add_argument("-v", "--verbose", action="store_true", dest="verbose", help=ARGS_HELP["--verbose"]) gid4.add_argument("--preserve-file-times", action="store_true", dest="preserve_file_time", help=ARGS_HELP["--preserve-file-times"])
[docs] def handleFile(self, f): parse_version = self.args.tag_version try: super().handleFile(f, tag_version=parse_version) except id3.TagException as tag_ex: printError(str(tag_ex)) return if not self.audio_file: return self.terminal_width = getTtySize()[1] self.printHeader(f) if self.audio_file.tag and self.handleRemoves(self.audio_file.tag): # Reload after removal super(ClassicPlugin, self).handleFile(f, tag_version=parse_version) if not self.audio_file: return new_tag = False if not self.audio_file.tag: self.audio_file.initTag(version=parse_version) new_tag = True try: save_tag = (self.handleEdits(self.audio_file.tag) or self.handlePadding(self.audio_file.tag) or self.args.force_update or self.args.convert_version) except ValueError as ex: printError(str(ex)) return self.printAudioInfo(self.audio_file.info) if not save_tag and new_tag: printError(f"No ID3 {id3.versionToString(self.args.tag_version)} tag found!") return self.printTag(self.audio_file.tag) if self.args.write_images_dir: for img in self.audio_file.tag.images: if img.mime_type not in ImageFrame.URL_MIME_TYPE_VALUES: img_path = "%s%s" % (self.args.write_images_dir, os.path.sep) if not os.path.isdir(img_path): raise IOError("Directory does not exist: %s" % img_path) img_file = makeUniqueFileName( os.path.join(img_path, img.makeFileName())) printWarning("Writing %s..." % img_file) with open(img_file, "wb") as fp: fp.write(img.image_data) if save_tag: # Use current tag version unless a convert was supplied version = (self.args.convert_version or self.audio_file.tag.version) printWarning("Writing ID3 version %s" % id3.versionToString(version)) # DEFAULT_MAX_PADDING is not set up as argument default, # because we don't want to rewrite the file if the user # did not trigger that explicitly: max_padding = self.args.max_padding if max_padding is True: max_padding = DEFAULT_MAX_PADDING self.audio_file.tag.save( version=version, encoding=self.args.text_encoding, backup=self.args.backup, preserve_file_time=self.args.preserve_file_time, max_padding=max_padding) if self.args.rename_pattern: # Handle file renaming. from eyed3.id3.tag import TagTemplate template = TagTemplate(self.args.rename_pattern) name = template.substitute(self.audio_file.tag, zeropad=True) orig = self.audio_file.path try: self.audio_file.rename(name) printWarning(f"Renamed '{orig}' to '{self.audio_file.path}'") except IOError as ex: printError(str(ex)) printMsg(self._getHardRule(self.terminal_width))
[docs] def printHeader(self, file_path): printMsg(self._getFileHeader(file_path, self.terminal_width)) printMsg(self._getHardRule(self.terminal_width))
[docs] def printAudioInfo(self, info): if isinstance(info, mp3.Mp3AudioInfo): printMsg(boldText("Time: ") + "%s\tMPEG%d, Layer %s\t[ %s @ %s Hz - %s ]" % (formatTime(info.time_secs), info.mp3_header.version, "I" * info.mp3_header.layer, info.bit_rate_str, info.mp3_header.sample_freq, info.mp3_header.mode)) printMsg(self._getHardRule(self.terminal_width))
@staticmethod def _getDefaultNameForObject(obj_frame, suffix=""): if obj_frame.filename: name_str = obj_frame.filename else: name_str = obj_frame.description name_str += ".%s" % obj_frame.mime_type.split("/")[1] if suffix: name_str += suffix return name_str
[docs] def printTag(self, tag): if isinstance(tag, id3.Tag): if self.args.quiet: printMsg(f"ID3 {id3.versionToString(tag.version)}: {len(tag.frame_set)} frames") return printMsg(f"ID3 {id3.versionToString(tag.version)}:") artist = tag.artist if tag.artist else "" title = tag.title if tag.title else "" album = tag.album if tag.album else "" printMsg("%s: %s" % (boldText("title"), title)) printMsg("%s: %s" % (boldText("artist"), artist)) printMsg("%s: %s" % (boldText("album"), album)) if tag.album_artist: printMsg("%s: %s" % (boldText("album artist"), tag.album_artist)) if tag.composer: printMsg("%s: %s" % (boldText("composer"), tag.composer)) if tag.original_artist: printMsg("%s: %s" % (boldText("original artist"), tag.original_artist)) for date, date_label in [ (tag.release_date, "release date"), (tag.original_release_date, "original release date"), (tag.recording_date, "recording date"), (tag.encoding_date, "encoding date"), (tag.tagging_date, "tagging date"), ]: if date: printMsg("%s: %s" % (boldText(date_label), str(date))) track_str = "" (track_num, track_total) = tag.track_num if track_num is not None: track_str = str(track_num) if track_total: track_str += "/%d" % track_total genre = tag.genre if not self.args.non_std_genres else tag.non_std_genre genre_str = f"{boldText('genre')}: {genre.name} (id {genre.id})" if genre else "" printMsg(f"{boldText('track')}: {track_str}\t\t{genre_str}") (num, total) = tag.disc_num if num is not None: disc_str = str(num) if total: disc_str += "/%d" % total printMsg("%s: %s" % (boldText("disc"), disc_str)) # PCNT play_count = tag.play_count if tag.play_count is not None: printMsg("%s %d" % (boldText("Play Count:"), play_count)) # POPM for popm in tag.popularities: printMsg("%s [email: %s] [rating: %d] [play count: %d]" % (boldText("Popularity:"), popm.email, popm.rating, popm.count)) # TBPM bpm = tag.bpm if bpm is not None: printMsg("%s %d" % (boldText("BPM:"), bpm)) # TPUB pub = tag.publisher if pub is not None: printMsg("%s %s" % (boldText("Publisher/label:"), pub)) # UFID for ufid in tag.unique_file_ids: printMsg("%s [%s] : %s" % (boldText("Unique File ID:"), ufid.owner_id, ufid.uniq_id.decode("unicode_escape"))) # COMM for c in tag.comments: printMsg("%s: [Description: %s] [Lang: %s]\n%s" % (boldText("Comment"), c.description or "", c.lang.decode("ascii") or "", c.text or "")) # USLT for l in tag.lyrics: printMsg("%s: [Description: %s] [Lang: %s]\n%s" % (boldText("Lyrics"), l.description or "", l.lang.decode("ascii") or "", l.text)) # TXXX for f in tag.user_text_frames: printMsg("%s: [Description: %s]\n%s" % (boldText("UserTextFrame"), f.description, f.text)) # URL frames for desc, url in (("Artist URL", tag.artist_url), ("Audio source URL", tag.audio_source_url), ("Audio file URL", tag.audio_file_url), ("Internet radio URL", tag.internet_radio_url), ("Commercial URL", tag.commercial_url), ("Payment URL", tag.payment_url), ("Publisher URL", tag.publisher_url), ("Copyright URL", tag.copyright_url), ): if url: printMsg("%s: %s" % (boldText(desc), url)) # user url frames for u in tag.user_url_frames: printMsg("%s [Description: %s]: %s" % (u.id, u.description, u.url)) # APIC for img in tag.images: if img.mime_type not in ImageFrame.URL_MIME_TYPE_VALUES: printMsg("%s: [Size: %d bytes] [Type: %s]" % (boldText(img.picTypeToString(img.picture_type) + " Image"), len(img.image_data), img.mime_type)) printMsg("Description: %s" % img.description) printMsg("") else: printMsg("%s: [Type: %s] [URL: %s]" % (boldText(img.picTypeToString(img.picture_type) + " Image"), img.mime_type, img.image_url)) printMsg("Description: %s" % img.description) printMsg("") # GOBJ for obj in tag.objects: printMsg("%s: [Size: %d bytes] [Type: %s]" % (boldText("GEOB"), len(obj.object_data), obj.mime_type)) printMsg("Description: %s" % obj.description) printMsg("Filename: %s" % obj.filename) printMsg("\n") if self.args.write_objects_dir: obj_path = "%s%s" % (self.args.write_objects_dir, os.sep) if not os.path.isdir(obj_path): raise IOError("Directory does not exist: %s" % obj_path) obj_file = self._getDefaultNameForObject(obj) count = 1 while os.path.exists(os.path.join(obj_path, obj_file)): obj_file = self._getDefaultNameForObject(obj, str(count)) count += 1 printWarning("Writing %s..." % os.path.join(obj_path, obj_file)) with open(os.path.join(obj_path, obj_file), "wb") as fp: fp.write(obj.object_data) # PRIV for p in tag.privates: printMsg("%s: [Data: %d bytes]" % (boldText("PRIV"), len(p.data))) printMsg("Owner Id: %s" % p.owner_id.decode("ascii")) # MCDI if tag.cd_id: printMsg("\n%s: [Data: %d bytes]" % (boldText("MCDI"), len(tag.cd_id))) # USER if tag.terms_of_use: printMsg("\nTerms of Use (%s): %s" % (boldText("USER"), tag.terms_of_use)) # --verbose if self.args.verbose: printMsg(self._getHardRule(self.terminal_width)) printMsg("%d ID3 Frames:" % len(tag.frame_set)) for fid in tag.frame_set: frames = tag.frame_set[fid] num_frames = len(frames) count = " x %d" % num_frames if num_frames > 1 else "" if not tag.isV1(): total_bytes = sum( tuple(frame.header.data_size + frame.header.size for frame in frames if frame.header)) else: total_bytes = 30 if total_bytes: printMsg("%s%s (%d bytes)" % (fid.decode("ascii"), count, total_bytes)) printMsg("%d bytes unused (padding)" % (tag.file_info.tag_padding_size, )) else: raise TypeError("Unknown tag type: " + str(type(tag)))
[docs] def handleRemoves(self, tag): remove_version = 0 status = False rm_str = "" if self.args.remove_all: remove_version = id3.ID3_ANY_VERSION rm_str = "v1.x and/or v2.x" elif self.args.remove_v1: remove_version = id3.ID3_V1 rm_str = "v1.x" elif self.args.remove_v2: remove_version = id3.ID3_V2 rm_str = "v2.x" if remove_version: status = id3.Tag.remove( tag.file_info.name, remove_version, preserve_file_time=self.args.preserve_file_time) printWarning("Removing ID3 %s tag: %s" % (rm_str, "SUCCESS" if status else "FAIL")) return status
[docs] def handlePadding(self, tag): max_padding = self.args.max_padding if max_padding is None or max_padding is True: return False padding = tag.file_info.tag_padding_size needs_change = padding > max_padding return needs_change
[docs] def handleEdits(self, tag): retval = False # --remove-all-*, Handling removes first means later options are still # applied for what, arg, fid in (("comments", self.args.remove_all_comments, id3.frames.COMMENT_FID), ("lyrics", self.args.remove_all_lyrics, id3.frames.LYRICS_FID), ("images", self.args.remove_all_images, id3.frames.IMAGE_FID), ("objects", self.args.remove_all_objects, id3.frames.OBJECT_FID), ): if arg and tag.frame_set[fid]: printWarning("Removing all %s..." % what) del tag.frame_set[fid] retval = True # --artist, --title, etc. All common/simple text frames. for (what, setFunc) in ( ("artist", partial(tag._setArtist, self.args.artist)), ("album", partial(tag._setAlbum, self.args.album)), ("album artist", partial(tag._setAlbumArtist, self.args.album_artist)), ("title", partial(tag._setTitle, self.args.title)), ("genre", partial(tag._setGenre, self.args.genre, id3_std=not self.args.non_std_genres)), ("release date", partial(tag._setReleaseDate, self.args.release_date)), ("original release date", partial(tag._setOrigReleaseDate, self.args.orig_release_date)), ("recording date", partial(tag._setRecordingDate, self.args.recording_date)), ("encoding date", partial(tag._setEncodingDate, self.args.encoding_date)), ("tagging date", partial(tag._setTaggingDate, self.args.tagging_date)), ("beats per minute", partial(tag._setBpm, self.args.bpm)), ("publisher", partial(tag._setPublisher, self.args.publisher)), ("composer", partial(tag._setComposer, self.args.composer)), ("orig-artist", partial(tag._setOrigArtist, self.args.orig_artist)), ): if setFunc.args[0] is not None: printWarning("Setting %s: %s" % (what, setFunc.args[0])) setFunc() retval = True def _checkNumberedArgTuples(curr, new): n = None if new not in [(None, None), curr]: n = [None] * 2 for i in (0, 1): if new[i] == 0: n[i] = None else: n[i] = new[i] or curr[i] n = tuple(n) # Returning None means do nothing, (None, None) would clear both vals return n # --artist-{city,state,country} origin = core.ArtistOrigin(self.args.artist_city, self.args.artist_state, self.args.artist_country) if origin or (dataclasses.astuple(origin) != (None, None, None) and tag.artist_origin): printWarning(f"Setting artist origin: {origin}") tag.artist_origin = origin retval = True # --track, --track-total track_info = _checkNumberedArgTuples(tag.track_num, (self.args.track, self.args.track_total)) if track_info is not None: printWarning("Setting track info: %s" % str(track_info)) tag.track_num = track_info retval = True # --track-offset if self.args.track_offset: offset = self.args.track_offset tag.track_num = (tag.track_num[0] + offset, tag.track_num[1]) printWarning("%s track info by %d: %d" % ("Incrementing" if offset > 0 else "Decrementing", offset, tag.track_num[0])) retval = True # --disc-num, --disc-total disc_info = _checkNumberedArgTuples(tag.disc_num, (self.args.disc_num, self.args.disc_total)) if disc_info is not None: printWarning("Setting disc info: %s" % str(disc_info)) tag.disc_num = disc_info retval = True # -Y, --release-year if self.args.release_year is not None: # empty string means clean, None means not given year = self.args.release_year printWarning(f"Setting release year: {year}") tag.release_date = int(year) if year else None retval = True # -c , simple comment if self.args.simple_comment: # Just add it as if it came in --add-comment self.args.comments.append((self.args.simple_comment, "", id3.DEFAULT_LANG)) # --remove-comment, remove-lyrics, --remove-image, --remove-object for what, arg, accessor in (("comment", self.args.remove_comment, tag.comments), ("lyrics", self.args.remove_lyrics, tag.lyrics), ("image", self.args.remove_image, tag.images), ("object", self.args.remove_object, tag.objects), ): for vals in arg: if type(vals) is str: frame = accessor.remove(vals) else: frame = accessor.remove(*vals) if frame: printWarning("Removed %s %s" % (what, str(vals))) retval = True else: printError("Removing %s failed, %s not found" % (what, str(vals))) # --add-comment, --add-lyrics for what, arg, accessor in (("comment", self.args.comments, tag.comments), ("lyrics", self.args.lyrics, tag.lyrics), ): for text, desc, lang in arg: printWarning("Setting %s: %s/%s" % (what, desc, str(lang, "ascii"))) accessor.set(text, desc, b(lang)) retval = True # --play-count playcount_arg = self.args.play_count if playcount_arg: increment, pc = playcount_arg if increment: printWarning("Increment play count by %d" % pc) tag.play_count += pc else: printWarning("Setting play count to %d" % pc) tag.play_count = pc retval = True # --add-popularity for email, rating, play_count in self.args.popularities: tag.popularities.set(email.encode("latin1"), rating, play_count) retval = True # --remove-popularity for email in self.args.remove_popularity: popm = tag.popularities.remove(email.encode("latin1")) if popm: retval = True # --text-frame, --url-frame for what, arg, setter in ( ("text frame", self.args.text_frames, tag.setTextFrame), ("url frame", self.args.url_frames, tag._setUrlFrame), ): for fid, text in arg: if text: printWarning("Setting %s %s to '%s'" % (fid, what, text)) else: printWarning("Removing %s %s" % (fid, what)) setter(fid, text) retval = True # --user-text-frame, --user-url-frame for what, arg, accessor in ( ("user text frame", self.args.user_text_frames, tag.user_text_frames), ("user url frame", self.args.user_url_frames, tag.user_url_frames), ): for desc, text in arg: if text: printWarning(f"Setting '{desc}' {what} to '{text}'") accessor.set(text, desc) else: printWarning(f"Removing '{desc}' {what}") accessor.remove(desc) retval = True # --add-image for img_path, img_type, img_mt, img_desc in self.args.images: assert img_path printWarning("Adding image %s" % img_path) if img_mt not in ImageFrame.URL_MIME_TYPE_VALUES: with open(img_path, "rb") as img_fp: tag.images.set(img_type, img_fp.read(), img_mt, img_desc) else: tag.images.set(img_type, None, None, img_desc, img_url=img_path) retval = True # --add-object for obj_path, obj_mt, obj_desc, obj_fname in self.args.objects or []: assert obj_path printWarning("Adding object %s" % obj_path) with open(obj_path, "rb") as obj_fp: tag.objects.set(obj_fp.read(), obj_mt, obj_desc, obj_fname) retval = True # --unique-file-id for arg in self.args.unique_file_ids: owner_id, id = arg if not id: if tag.unique_file_ids.remove(owner_id): printWarning("Removed unique file ID '%s'" % owner_id) retval = True else: printWarning("Unique file ID '%s' not found" % owner_id) else: tag.unique_file_ids.set(id, owner_id.encode("latin1")) printWarning("Setting unique file ID '%s' to %s" % (owner_id, id)) retval = True # --remove-frame for fid in self.args.remove_fids: assert(isinstance(fid, bytes)) if fid in tag.frame_set: del tag.frame_set[fid] retval = True return retval
def _getTemplateKeys(): keys = list(id3.TagTemplate("")._makeMapping(None, False).keys()) keys.sort() return ", ".join(["$%s" % v for v in keys]) ARGS_HELP = { "--artist": "Set the artist name.", "--album": "Set the album name.", "--album-artist": "Set the album artist name. '%s', for example. " "Another example is collaborations when the " "track artist might be 'Eminem featuring Proof' " "the album artist would be 'Eminem'." % core.VARIOUS_ARTISTS, "--title": "Set the track title.", "--track": "Set the track number. Use 0 to clear.", "--track-total": "Set total number of tracks. Use 0 to clear.", "--disc-num": "Set the disc number. Use 0 to clear.", "--disc-total": "Set total number of discs in set. Use 0 to clear.", "--genre": "Set the genre. If the argument is a standard ID3 genre " "name or number both will be set. Otherwise, any string " "can be used. Run 'eyeD3 --plugin=genres' for a list of " "standard ID3 genre names/ids.", "--non-std-genres": "Disables certain ID3 genre standards, such as the " "mapping of numeric value to genre names. For example, " "genre=1 is taken literally, not mapped to 'Classic Rock'.", "--release-year": "Set the year the track was released. Use the date " "options for more precise values or dates other " "than release.", "--v1": "Only read and write ID3 v1.x tags. By default, v1.x tags are " "only read or written if there is not a v2 tag in the file.", "--v2": "Only read/write ID3 v2.x tags. This is the default unless " "the file only contains a v1 tag.", "--to-v1.1": "Convert the file's tag to ID3 v1.1 (Or 1.0 if there is " "no track number)", "--to-v2.3": "Convert the file's tag to ID3 v2.3", "--to-v2.4": "Convert the file's tag to ID3 v2.4", "--release-date": "Set the date the track/album was released", "--orig-release-date": "Set the original date the track/album was " "released", "--recording-date": "Set the date the track/album was recorded", "--encoding-date": "Set the date the file was encoded", "--tagging-date": "Set the date the file was tagged", "--comment": "Set a comment. In ID3 tags this is the comment with " "an empty description. See --add-comment to add multiple " "comment frames.", "--add-comment": "Add or replace a comment. There may be more than one comment in a " "tag, as long as the DESCRIPTION and LANG values are unique. The " "default DESCRIPTION is '' and the default language code is '%s'." % str(id3.DEFAULT_LANG, "ascii"), "--remove-comment": "Remove comment matching DESCRIPTION and LANG. " "The default language code is '%s'." % str(id3.DEFAULT_LANG, "ascii"), "--remove-all-comments": "Remove all comments from the tag.", "--add-lyrics": "Add or replace a lyrics. There may be more than one set of lyrics " "in a tag, as long as the DESCRIPTION and LANG values are unique. " "The default DESCRIPTION is '' and the default language code is " "'%s'." % str(id3.DEFAULT_LANG, "ascii"), "--remove-lyrics": "Remove lyrics matching DESCRIPTION and LANG. " "The default language code is '%s'." % str(id3.DEFAULT_LANG, "ascii"), "--remove-all-lyrics": "Remove all lyrics from the tag.", "--publisher": "Set the publisher/label name", "--play-count": "Set the number of times played counter. If the " "argument value begins with '+' the tag's play count " "is incremented by N, otherwise the value is set to " "exactly N.", "--bpm": "Set the beats per minute value.", "--text-frame": "Set the value of a text frame. To remove the " "frame, specify an empty value. For example, " "--text-frame='TDRC:'", "--user-text-frame": "Set the value of a user text frame (i.e., TXXX). " "To remove the frame, specify an empty value. " "e.g., --user-text-frame='SomeDesc:'", "--url-frame": "Set the value of a URL frame. To remove the frame, " "specify an empty value. e.g., --url-frame='WCOM:'", "--user-url-frame": "Set the value of a user URL frame (i.e., WXXX). " "To remove the frame, specify an empty value. " "e.g., --user-url-frame='SomeDesc:'", "--add-image": "Add or replace an image. There may be more than one " "image in a tag, as long as the DESCRIPTION values are " "unique. The default DESCRIPTION is ''. If PATH begins " "with 'http[s]://' then it is interpreted as a URL " "instead of a file containing image data. The TYPE must " "be one of the following: %s." % (", ".join([ImageFrame.picTypeToString(t) for t in range(ImageFrame.MIN_TYPE, ImageFrame.MAX_TYPE + 1)]), ), "--remove-image": "Remove image matching DESCRIPTION.", "--remove-all-images": "Remove all images from the tag", "--write-images": "Causes all attached images (APIC frames) to be " "written to the specified directory.", "--add-object": "Add or replace an object. There may be more than one " "object in a tag, as long as the DESCRIPTION values " "are unique. The default DESCRIPTION is ''.", "--remove-object": "Remove object matching DESCRIPTION.", "--remove-all-objects": "Remove all objects from the tag", "--write-objects": "Causes all attached objects (GEOB frames) to be " "written to the specified directory.", "--add-popularity": "Adds a pupularity metric. There may be multiples " "popularity values, but each must have a unique " "email address component. The rating is a number " "between 0 (worst) and 255 (best). The play count " "is optional, and defaults to 0, since there is " "already a dedicated play count frame.", "--remove-popularity": "Removes the popularity frame with the " "specified email key.", "--remove-v1": "Remove ID3 v1.x tag.", "--remove-v2": "Remove ID3 v2.x tag.", "--remove-all": "Remove ID3 v1.x and v2.x tags.", "--remove-frame": "Remove all frames with the given ID. This option " "may be specified multiple times.", "--max-padding": "Shrink file if tag padding (unused space) exceeds " "the given number of bytes. " "(Useful e.g. after removal of large cover art.) " "Default is 64 KiB, file will be rewritten with " "default padding (1 KiB) or max padding, whichever " "is smaller.", "--no-max-padding": "Disable --max-padding altogether.", "--force-update": "Rewrite the tag despite there being no edit " "options.", "--verbose": "Show all available tag data", "--unique-file-id": "Add a unique file ID frame. If the ID arg is " "empty the frame is removed. An OWNER_ID is " "required. The ID may be no more than 64 bytes.", "--encoding": "Set the encoding that is used for all text frames. " "This option is only applied if the tag is updated " "as the result of an edit option (e.g. --artist, " "--title, etc.) or --force-update is specified.", "--rename": "Rename file (the extension is not affected) " "based on data in the tag using substitution " "variables: " + _getTemplateKeys(), "--preserve-file-times": "When writing, do not update file " "modification times.", "--track-offset": "Increment/decrement the track number by [-]N. " "This option is applied after --track=N is set.", "--composer": "Set the composer's name.", "--orig-artist": "Set the orignal artist's name. For example, a cover song can include " "the orignal author of the track.", }