1import os 2import re 3import dataclasses 4from functools import partial 5from argparse import ArgumentTypeError 6 7from eyed3.plugins import LoaderPlugin 8from eyed3 import core, id3, mp3 9from eyed3.utils import makeUniqueFileName, b, formatTime 10from eyed3.utils.console import ( 11 printMsg, printError, printWarning, boldText, getTtySize, 12) 13from eyed3.id3.frames import ImageFrame 14from eyed3.mimetype import guessMimetype 15 16from eyed3.utils.log import getLogger 17log = getLogger(__name__) 18 19FIELD_DELIM = ':' 20 21DEFAULT_MAX_PADDING = 64 * 1024 22 23 24class ClassicPlugin(LoaderPlugin): 25 SUMMARY = "Classic eyeD3 interface for viewing and editing tags." 26 DESCRIPTION = """ 27All PATH arguments are parsed and displayed. Directory paths are searched 28recursively. Any editing options (--artist, --title) are applied to each file 29read. 30 31All date options (-Y, --release-year excepted) follow ISO 8601 format. This is 32``yyyy-mm-ddThh:mm:ss``. The year is required, and each component thereafter is 33optional. For example, 2012-03 is valid, 2012--12 is not. 34""" 35 NAMES = ["classic"] 36 37 def __init__(self, arg_parser): 38 super(ClassicPlugin, self).__init__(arg_parser) 39 g = self.arg_group 40 41 def PositiveIntArg(i): 42 i = int(i) 43 if i < 0: 44 raise ArgumentTypeError("positive number required") 45 return i 46 47 # Common options 48 g.add_argument("-a", "--artist", dest="artist", 49 metavar="STRING", help=ARGS_HELP["--artist"]) 50 g.add_argument("-A", "--album", dest="album", 51 metavar="STRING", help=ARGS_HELP["--album"]) 52 g.add_argument("-b", "--album-artist", 53 dest="album_artist", metavar="STRING", 54 help=ARGS_HELP["--album-artist"]) 55 g.add_argument("-t", "--title", dest="title", 56 metavar="STRING", help=ARGS_HELP["--title"]) 57 g.add_argument("-n", "--track", type=PositiveIntArg, dest="track", 58 metavar="NUM", help=ARGS_HELP["--track"]) 59 g.add_argument("-N", "--track-total", type=PositiveIntArg, 60 dest="track_total", metavar="NUM", 61 help=ARGS_HELP["--track-total"]) 62 63 g.add_argument("--track-offset", type=int, dest="track_offset", 64 metavar="N", help=ARGS_HELP["--track-offset"]) 65 66 g.add_argument("--composer", dest="composer", 67 metavar="STRING", help=ARGS_HELP["--composer"]) 68 g.add_argument("--orig-artist", dest="orig_artist", 69 metavar="STRING", help=ARGS_HELP["--orig-artist"]) 70 g.add_argument("-d", "--disc-num", type=PositiveIntArg, dest="disc_num", 71 metavar="NUM", help=ARGS_HELP["--disc-num"]) 72 g.add_argument("-D", "--disc-total", type=PositiveIntArg, 73 dest="disc_total", metavar="NUM", 74 help=ARGS_HELP["--disc-total"]) 75 g.add_argument("-G", "--genre", dest="genre", 76 metavar="GENRE", help=ARGS_HELP["--genre"]) 77 g.add_argument("--non-std-genres", dest="non_std_genres", 78 action="store_true", help=ARGS_HELP["--non-std-genres"]) 79 g.add_argument("-Y", "--release-year", type=PositiveIntArg, 80 dest="release_year", metavar="YEAR", 81 help=ARGS_HELP["--release-year"]) 82 g.add_argument("-c", "--comment", dest="simple_comment", 83 metavar="STRING", 84 help=ARGS_HELP["--comment"]) 85 g.add_argument("--artist-city", metavar="STRING", 86 help="The artist's city of origin. " 87 f"Stored as a user text frame `{core.TXXX_ARTIST_ORIGIN}`") 88 g.add_argument("--artist-state", metavar="STRING", 89 help="The artist's state of origin. " 90 f"Stored as a user text frame `{core.TXXX_ARTIST_ORIGIN}`") 91 g.add_argument("--artist-country", metavar="STRING", 92 help="The artist's country of origin. " 93 f"Stored as a user text frame `{core.TXXX_ARTIST_ORIGIN}`") 94 g.add_argument("--rename", dest="rename_pattern", metavar="PATTERN", 95 help=ARGS_HELP["--rename"]) 96 97 gid3 = arg_parser.add_argument_group("ID3 options") 98 99 def _splitArgs(arg, maxsplit=None): 100 NEW_DELIM = "#DELIM#" 101 arg = re.sub(r"\\%s" % FIELD_DELIM, NEW_DELIM, arg) 102 t = tuple(re.sub(NEW_DELIM, FIELD_DELIM, s) 103 for s in arg.split(FIELD_DELIM)) 104 if maxsplit is not None and maxsplit < 2: 105 raise ValueError("Invalid maxsplit value: {}".format(maxsplit)) 106 elif maxsplit and len(t) > maxsplit: 107 t = t[:maxsplit - 1] + (FIELD_DELIM.join(t[maxsplit - 1:]),) 108 assert len(t) <= maxsplit 109 return t 110 111 def DescLangArg(arg): 112 """DESCRIPTION[:LANG]""" 113 vals = _splitArgs(arg, 2) 114 desc = vals[0] 115 lang = vals[1] if len(vals) > 1 else id3.DEFAULT_LANG 116 return desc, b(lang)[:3] or id3.DEFAULT_LANG 117 118 def DescTextArg(arg): 119 """DESCRIPTION:TEXT""" 120 vals = _splitArgs(arg, 2) 121 desc = vals[0].strip() 122 text = FIELD_DELIM.join(vals[1:] if len(vals) > 1 else []) 123 return desc or "", text or "" 124 KeyValueArg = DescTextArg 125 126 def DescUrlArg(arg): 127 desc, url = DescTextArg(arg) 128 return desc, url.encode("latin1") 129 130 def FidArg(arg): 131 fid = arg.strip().encode("ascii") 132 if not fid: 133 raise ArgumentTypeError("No frame ID") 134 return fid 135 136 def TextFrameArg(arg): 137 """FID:TEXT""" 138 vals = _splitArgs(arg, 2) 139 fid = vals[0].strip().encode("ascii") 140 if not fid: 141 raise ArgumentTypeError("No frame ID") 142 text = vals[1] if len(vals) > 1 else "" 143 return fid, text 144 145 def UrlFrameArg(arg): 146 """FID:TEXT""" 147 fid, url = TextFrameArg(arg) 148 return fid, url.encode("latin1") 149 150 def DateArg(date_str): 151 return core.Date.parse(date_str) if date_str else "" 152 153 def CommentArg(arg): 154 """ 155 COMMENT[:DESCRIPTION[:LANG] 156 """ 157 vals = _splitArgs(arg, 3) 158 text = vals[0] 159 if not text: 160 raise ArgumentTypeError("text required") 161 desc = vals[1] if len(vals) > 1 else "" 162 lang = vals[2] if len(vals) > 2 else id3.DEFAULT_LANG 163 return text, desc, b(lang)[:3] 164 165 def LyricsArg(arg): 166 text, desc, lang = CommentArg(arg) 167 try: 168 with open(text, "r") as fp: 169 data = fp.read() 170 except Exception: # noqa: B901 171 raise ArgumentTypeError("Unable to read file") 172 return data, desc, lang 173 174 def PlayCountArg(pc): 175 if not pc: 176 raise ArgumentTypeError("value required") 177 increment = False 178 if pc[0] == "+": 179 pc = int(pc[1:]) 180 increment = True 181 else: 182 pc = int(pc) 183 if pc < 0: 184 raise ArgumentTypeError("out of range") 185 return increment, pc 186 187 def BpmArg(bpm): 188 bpm = int(float(bpm) + 0.5) 189 if bpm <= 0: 190 raise ArgumentTypeError("out of range") 191 return bpm 192 193 def DirArg(d): 194 if not d or not os.path.isdir(d): 195 raise ArgumentTypeError("invalid directory: %s" % d) 196 return d 197 198 def ImageArg(s): 199 """PATH:TYPE[:DESCRIPTION] 200 Returns (path, type_id, mime_type, description) 201 """ 202 args = _splitArgs(s, 3) 203 if len(args) < 2: 204 raise ArgumentTypeError("Format is: PATH:TYPE[:DESCRIPTION]") 205 206 path, type_str = args[:2] 207 desc = args[2] if len(args) > 2 else "" 208 209 try: 210 type_id = id3.frames.ImageFrame.stringToPicType(type_str) 211 except Exception: # noqa: B901 212 raise ArgumentTypeError("invalid pic type: {}".format(type_str)) 213 214 if not path: 215 raise ArgumentTypeError("path required") 216 elif True in [path.startswith(prefix) 217 for prefix in ["http://", "https://"]]: 218 mt = ImageFrame.URL_MIME_TYPE 219 else: 220 if not os.path.isfile(path): 221 raise ArgumentTypeError("file does not exist") 222 mt = guessMimetype(path) 223 if mt is None: 224 raise ArgumentTypeError("Cannot determine mime-type") 225 226 return path, type_id, mt, desc 227 228 def ObjectArg(s): 229 """OBJ_PATH:MIME-TYPE[:DESCRIPTION[:FILENAME]], 230 Returns (path, mime_type, description, filename) 231 """ 232 args = _splitArgs(s, 4) 233 if len(args) < 2: 234 raise ArgumentTypeError("too few parts") 235 236 path = args[0] 237 if path: 238 mt = args[1] 239 desc = args[2] if len(args) > 2 else "" 240 filename = args[3] \ 241 if len(args) > 3 \ 242 else os.path.basename(path) 243 if not os.path.isfile(path): 244 raise ArgumentTypeError("file does not exist") 245 if not mt: 246 raise ArgumentTypeError("mime-type required") 247 else: 248 raise ArgumentTypeError("path required") 249 return (path, mt, desc, filename) 250 251 def UniqFileIdArg(arg): 252 owner_id, id = KeyValueArg(arg) 253 if not owner_id: 254 raise ArgumentTypeError("owner_id required") 255 id = id.encode("latin1") # don't want to pass unicode 256 if len(id) > 64: 257 raise ArgumentTypeError("id must be <= 64 bytes") 258 return (owner_id, id) 259 260 def PopularityArg(arg): 261 """EMAIL:RATING[:PLAY_COUNT] 262 Returns (email, rating, play_count) 263 """ 264 args = _splitArgs(arg, 3) 265 if len(args) < 2: 266 raise ArgumentTypeError("Incorrect number of argument components") 267 email = args[0] 268 rating = int(float(args[1])) 269 if rating < 0 or rating > 255: 270 raise ArgumentTypeError("Rating out-of-range") 271 play_count = 0 272 if len(args) > 2: 273 play_count = int(args[2]) 274 if play_count < 0: 275 raise ArgumentTypeError("Play count out-of-range") 276 return (email, rating, play_count) 277 278 # Tag versions 279 gid3.add_argument("-1", "--v1", action="store_const", const=id3.ID3_V1, 280 dest="tag_version", default=id3.ID3_ANY_VERSION, 281 help=ARGS_HELP["--v1"]) 282 gid3.add_argument("-2", "--v2", action="store_const", const=id3.ID3_V2, 283 dest="tag_version", default=id3.ID3_ANY_VERSION, 284 help=ARGS_HELP["--v2"]) 285 gid3.add_argument("--to-v1.1", action="store_const", const=id3.ID3_V1_1, 286 dest="convert_version", help=ARGS_HELP["--to-v1.1"]) 287 gid3.add_argument("--to-v2.3", action="store_const", const=id3.ID3_V2_3, 288 dest="convert_version", help=ARGS_HELP["--to-v2.3"]) 289 gid3.add_argument("--to-v2.4", action="store_const", const=id3.ID3_V2_4, 290 dest="convert_version", help=ARGS_HELP["--to-v2.4"]) 291 292 # Dates 293 gid3.add_argument("--release-date", type=DateArg, dest="release_date", 294 metavar="DATE", 295 help=ARGS_HELP["--release-date"]) 296 gid3.add_argument("--orig-release-date", type=DateArg, 297 dest="orig_release_date", metavar="DATE", 298 help=ARGS_HELP["--orig-release-date"]) 299 gid3.add_argument("--recording-date", type=DateArg, 300 dest="recording_date", metavar="DATE", 301 help=ARGS_HELP["--recording-date"]) 302 gid3.add_argument("--encoding-date", type=DateArg, dest="encoding_date", 303 metavar="DATE", help=ARGS_HELP["--encoding-date"]) 304 gid3.add_argument("--tagging-date", type=DateArg, dest="tagging_date", 305 metavar="DATE", help=ARGS_HELP["--tagging-date"]) 306 307 # Misc 308 gid3.add_argument("--publisher", action="store", 309 dest="publisher", metavar="STRING", 310 help=ARGS_HELP["--publisher"]) 311 gid3.add_argument("--play-count", type=PlayCountArg, dest="play_count", 312 metavar="<+>N", default=None, 313 help=ARGS_HELP["--play-count"]) 314 gid3.add_argument("--bpm", type=BpmArg, dest="bpm", metavar="N", 315 default=None, help=ARGS_HELP["--bpm"]) 316 gid3.add_argument("--unique-file-id", action="append", 317 type=UniqFileIdArg, dest="unique_file_ids", 318 metavar="OWNER_ID:ID", default=[], 319 help=ARGS_HELP["--unique-file-id"]) 320 321 # Comments 322 gid3.add_argument("--add-comment", action="append", dest="comments", 323 metavar="COMMENT[:DESCRIPTION[:LANG]]", default=[], 324 type=CommentArg, help=ARGS_HELP["--add-comment"]) 325 gid3.add_argument("--remove-comment", action="append", type=DescLangArg, 326 dest="remove_comment", default=[], 327 metavar="DESCRIPTION[:LANG]", 328 help=ARGS_HELP["--remove-comment"]) 329 gid3.add_argument("--remove-all-comments", action="store_true", 330 dest="remove_all_comments", 331 help=ARGS_HELP["--remove-all-comments"]) 332 333 gid3.add_argument("--add-lyrics", action="append", type=LyricsArg, 334 dest="lyrics", default=[], 335 metavar="LYRICS_FILE[:DESCRIPTION[:LANG]]", 336 help=ARGS_HELP["--add-lyrics"]) 337 gid3.add_argument("--remove-lyrics", action="append", type=DescLangArg, 338 dest="remove_lyrics", default=[], 339 metavar="DESCRIPTION[:LANG]", 340 help=ARGS_HELP["--remove-lyrics"]) 341 gid3.add_argument("--remove-all-lyrics", action="store_true", 342 dest="remove_all_lyrics", 343 help=ARGS_HELP["--remove-all-lyrics"]) 344 345 gid3.add_argument("--text-frame", action="append", type=TextFrameArg, 346 dest="text_frames", metavar="FID:TEXT", default=[], 347 help=ARGS_HELP["--text-frame"]) 348 gid3.add_argument("--user-text-frame", action="append", 349 type=DescTextArg, 350 dest="user_text_frames", metavar="DESC:TEXT", 351 default=[], help=ARGS_HELP["--user-text-frame"]) 352 353 gid3.add_argument("--url-frame", action="append", type=UrlFrameArg, 354 dest="url_frames", metavar="FID:URL", default=[], 355 help=ARGS_HELP["--url-frame"]) 356 gid3.add_argument("--user-url-frame", action="append", type=DescUrlArg, 357 dest="user_url_frames", metavar="DESCRIPTION:URL", 358 default=[], help=ARGS_HELP["--user-url-frame"]) 359 360 gid3.add_argument("--add-image", action="append", type=ImageArg, 361 dest="images", metavar="IMG_PATH:TYPE[:DESCRIPTION]", 362 default=[], help=ARGS_HELP["--add-image"]) 363 gid3.add_argument("--remove-image", action="append", 364 dest="remove_image", default=[], 365 metavar="DESCRIPTION", 366 help=ARGS_HELP["--remove-image"]) 367 gid3.add_argument("--remove-all-images", action="store_true", 368 dest="remove_all_images", 369 help=ARGS_HELP["--remove-all-images"]) 370 gid3.add_argument("--write-images", dest="write_images_dir", 371 metavar="DIR", type=DirArg, 372 help=ARGS_HELP["--write-images"]) 373 374 gid3.add_argument("--add-object", action="append", type=ObjectArg, 375 dest="objects", default=[], 376 metavar="OBJ_PATH:MIME-TYPE[:DESCRIPTION[:FILENAME]]", 377 help=ARGS_HELP["--add-object"]) 378 gid3.add_argument("--remove-object", action="append", 379 dest="remove_object", default=[], 380 metavar="DESCRIPTION", 381 help=ARGS_HELP["--remove-object"]) 382 gid3.add_argument("--write-objects", action="store", 383 dest="write_objects_dir", metavar="DIR", default=None, 384 help=ARGS_HELP["--write-objects"]) 385 gid3.add_argument("--remove-all-objects", action="store_true", 386 dest="remove_all_objects", 387 help=ARGS_HELP["--remove-all-objects"]) 388 389 gid3.add_argument("--add-popularity", action="append", 390 type=PopularityArg, dest="popularities", default=[], 391 metavar="EMAIL:RATING[:PLAY_COUNT]", 392 help=ARGS_HELP["--add-popularity"]) 393 gid3.add_argument("--remove-popularity", action="append", type=str, 394 dest="remove_popularity", default=[], 395 metavar="EMAIL", 396 help=ARGS_HELP["--remove-popularity"]) 397 398 gid3.add_argument("--remove-v1", action="store_true", dest="remove_v1", 399 default=False, help=ARGS_HELP["--remove-v1"]) 400 gid3.add_argument("--remove-v2", action="store_true", dest="remove_v2", 401 default=False, help=ARGS_HELP["--remove-v2"]) 402 gid3.add_argument("--remove-all", action="store_true", default=False, 403 dest="remove_all", help=ARGS_HELP["--remove-all"]) 404 gid3.add_argument("--remove-frame", action="append", default=[], 405 dest="remove_fids", metavar="FID", type=FidArg, 406 help=ARGS_HELP["--remove-frame"]) 407 408 # 'True' means 'apply default max_padding, but only if saving anyhow' 409 gid3.add_argument("--max-padding", type=int, dest="max_padding", 410 default=True, metavar="NUM_BYTES", 411 help=ARGS_HELP["--max-padding"]) 412 gid3.add_argument("--no-max-padding", dest="max_padding", 413 action="store_const", const=None, 414 help=ARGS_HELP["--no-max-padding"]) 415 416 _encodings = ["latin1", "utf8", "utf16", "utf16-be"] 417 gid3.add_argument("--encoding", dest="text_encoding", default=None, 418 choices=_encodings, metavar='|'.join(_encodings), 419 help=ARGS_HELP["--encoding"]) 420 421 # Misc options 422 gid4 = arg_parser.add_argument_group("Misc options") 423 gid4.add_argument("--force-update", action="store_true", default=False, 424 dest="force_update", help=ARGS_HELP["--force-update"]) 425 gid4.add_argument("-v", "--verbose", action="store_true", 426 dest="verbose", help=ARGS_HELP["--verbose"]) 427 gid4.add_argument("--preserve-file-times", action="store_true", 428 dest="preserve_file_time", 429 help=ARGS_HELP["--preserve-file-times"]) 430 431 def handleFile(self, f): 432 parse_version = self.args.tag_version 433 434 try: 435 super().handleFile(f, tag_version=parse_version) 436 except id3.TagException as tag_ex: 437 printError(str(tag_ex)) 438 return 439 440 if not self.audio_file: 441 return 442 443 self.terminal_width = getTtySize()[1] 444 self.printHeader(f) 445 446 if self.audio_file.tag and self.handleRemoves(self.audio_file.tag): 447 # Reload after removal 448 super(ClassicPlugin, self).handleFile(f, tag_version=parse_version) 449 if not self.audio_file: 450 return 451 452 new_tag = False 453 if not self.audio_file.tag: 454 self.audio_file.initTag(version=parse_version) 455 new_tag = True 456 457 try: 458 save_tag = (self.handleEdits(self.audio_file.tag) or 459 self.handlePadding(self.audio_file.tag) or 460 self.args.force_update or self.args.convert_version) 461 except ValueError as ex: 462 printError(str(ex)) 463 return 464 465 self.printAudioInfo(self.audio_file.info) 466 467 if not save_tag and new_tag: 468 printError(f"No ID3 {id3.versionToString(self.args.tag_version)} tag found!") 469 return 470 471 self.printTag(self.audio_file.tag) 472 473 if self.args.write_images_dir: 474 for img in self.audio_file.tag.images: 475 if img.mime_type not in ImageFrame.URL_MIME_TYPE_VALUES: 476 img_path = "%s%s" % (self.args.write_images_dir, 477 os.path.sep) 478 if not os.path.isdir(img_path): 479 raise IOError("Directory does not exist: %s" % img_path) 480 img_file = makeUniqueFileName( 481 os.path.join(img_path, img.makeFileName())) 482 printWarning("Writing %s..." % img_file) 483 with open(img_file, "wb") as fp: 484 fp.write(img.image_data) 485 486 if save_tag: 487 # Use current tag version unless a convert was supplied 488 version = (self.args.convert_version or 489 self.audio_file.tag.version) 490 printWarning("Writing ID3 version %s" % 491 id3.versionToString(version)) 492 493 # DEFAULT_MAX_PADDING is not set up as argument default, 494 # because we don't want to rewrite the file if the user 495 # did not trigger that explicitly: 496 max_padding = self.args.max_padding 497 if max_padding is True: 498 max_padding = DEFAULT_MAX_PADDING 499 500 self.audio_file.tag.save( 501 version=version, encoding=self.args.text_encoding, 502 backup=self.args.backup, 503 preserve_file_time=self.args.preserve_file_time, 504 max_padding=max_padding) 505 506 if self.args.rename_pattern: 507 # Handle file renaming. 508 from eyed3.id3.tag import TagTemplate 509 template = TagTemplate(self.args.rename_pattern) 510 name = template.substitute(self.audio_file.tag, zeropad=True) 511 orig = self.audio_file.path 512 try: 513 self.audio_file.rename(name) 514 printWarning(f"Renamed '{orig}' to '{self.audio_file.path}'") 515 except IOError as ex: 516 printError(str(ex)) 517 518 printMsg(self._getHardRule(self.terminal_width)) 519 520 def printHeader(self, file_path): 521 printMsg(self._getFileHeader(file_path, self.terminal_width)) 522 printMsg(self._getHardRule(self.terminal_width)) 523 524 def printAudioInfo(self, info): 525 if isinstance(info, mp3.Mp3AudioInfo): 526 printMsg(boldText("Time: ") + 527 "%s\tMPEG%d, Layer %s\t[ %s @ %s Hz - %s ]" % 528 (formatTime(info.time_secs), 529 info.mp3_header.version, 530 "I" * info.mp3_header.layer, 531 info.bit_rate_str, 532 info.mp3_header.sample_freq, info.mp3_header.mode)) 533 printMsg(self._getHardRule(self.terminal_width)) 534 535 @staticmethod 536 def _getDefaultNameForObject(obj_frame, suffix=""): 537 if obj_frame.filename: 538 name_str = obj_frame.filename 539 else: 540 name_str = obj_frame.description 541 name_str += ".%s" % obj_frame.mime_type.split("/")[1] 542 if suffix: 543 name_str += suffix 544 return name_str 545 546 def printTag(self, tag): 547 if isinstance(tag, id3.Tag): 548 if self.args.quiet: 549 printMsg(f"ID3 {id3.versionToString(tag.version)}: {len(tag.frame_set)} frames") 550 return 551 printMsg(f"ID3 {id3.versionToString(tag.version)}:") 552 553 artist = tag.artist if tag.artist else "" 554 title = tag.title if tag.title else "" 555 album = tag.album if tag.album else "" 556 printMsg("%s: %s" % (boldText("title"), title)) 557 printMsg("%s: %s" % (boldText("artist"), artist)) 558 printMsg("%s: %s" % (boldText("album"), album)) 559 if tag.album_artist: 560 printMsg("%s: %s" % (boldText("album artist"), 561 tag.album_artist)) 562 if tag.composer: 563 printMsg("%s: %s" % (boldText("composer"), tag.composer)) 564 if tag.original_artist: 565 printMsg("%s: %s" % (boldText("original artist"), tag.original_artist)) 566 567 for date, date_label in [ 568 (tag.release_date, "release date"), 569 (tag.original_release_date, "original release date"), 570 (tag.recording_date, "recording date"), 571 (tag.encoding_date, "encoding date"), 572 (tag.tagging_date, "tagging date"), 573 ]: 574 if date: 575 printMsg("%s: %s" % (boldText(date_label), str(date))) 576 577 track_str = "" 578 (track_num, track_total) = tag.track_num 579 if track_num is not None: 580 track_str = str(track_num) 581 if track_total: 582 track_str += "/%d" % track_total 583 584 genre = tag.genre if not self.args.non_std_genres else tag.non_std_genre 585 genre_str = f"{boldText('genre')}: {genre.name} (id {genre.id})" if genre else "" 586 printMsg(f"{boldText('track')}: {track_str}\t\t{genre_str}") 587 588 (num, total) = tag.disc_num 589 if num is not None: 590 disc_str = str(num) 591 if total: 592 disc_str += "/%d" % total 593 printMsg("%s: %s" % (boldText("disc"), disc_str)) 594 595 # PCNT 596 play_count = tag.play_count 597 if tag.play_count is not None: 598 printMsg("%s %d" % (boldText("Play Count:"), play_count)) 599 600 # POPM 601 for popm in tag.popularities: 602 printMsg("%s [email: %s] [rating: %d] [play count: %d]" % 603 (boldText("Popularity:"), popm.email, popm.rating, 604 popm.count)) 605 606 # TBPM 607 bpm = tag.bpm 608 if bpm is not None: 609 printMsg("%s %d" % (boldText("BPM:"), bpm)) 610 611 # TPUB 612 pub = tag.publisher 613 if pub is not None: 614 printMsg("%s %s" % (boldText("Publisher/label:"), pub)) 615 616 # UFID 617 for ufid in tag.unique_file_ids: 618 printMsg("%s [%s] : %s" % 619 (boldText("Unique File ID:"), ufid.owner_id, 620 ufid.uniq_id.decode("unicode_escape"))) 621 622 # COMM 623 for c in tag.comments: 624 printMsg("%s: [Description: %s] [Lang: %s]\n%s" % 625 (boldText("Comment"), c.description or "", 626 c.lang.decode("ascii") or "", c.text or "")) 627 628 # USLT 629 for l in tag.lyrics: 630 printMsg("%s: [Description: %s] [Lang: %s]\n%s" % 631 (boldText("Lyrics"), l.description or "", 632 l.lang.decode("ascii") or "", l.text)) 633 634 # TXXX 635 for f in tag.user_text_frames: 636 printMsg("%s: [Description: %s]\n%s" % 637 (boldText("UserTextFrame"), f.description, f.text)) 638 639 # URL frames 640 for desc, url in (("Artist URL", tag.artist_url), 641 ("Audio source URL", tag.audio_source_url), 642 ("Audio file URL", tag.audio_file_url), 643 ("Internet radio URL", tag.internet_radio_url), 644 ("Commercial URL", tag.commercial_url), 645 ("Payment URL", tag.payment_url), 646 ("Publisher URL", tag.publisher_url), 647 ("Copyright URL", tag.copyright_url), 648 ): 649 if url: 650 printMsg("%s: %s" % (boldText(desc), url)) 651 652 # user url frames 653 for u in tag.user_url_frames: 654 printMsg("%s [Description: %s]: %s" % (u.id, u.description, 655 u.url)) 656 657 # APIC 658 for img in tag.images: 659 if img.mime_type not in ImageFrame.URL_MIME_TYPE_VALUES: 660 printMsg("%s: [Size: %d bytes] [Type: %s]" % 661 (boldText(img.picTypeToString(img.picture_type) + 662 " Image"), 663 len(img.image_data), 664 img.mime_type)) 665 printMsg("Description: %s" % img.description) 666 printMsg("") 667 else: 668 printMsg("%s: [Type: %s] [URL: %s]" % 669 (boldText(img.picTypeToString(img.picture_type) + 670 " Image"), 671 img.mime_type, img.image_url)) 672 printMsg("Description: %s" % img.description) 673 printMsg("") 674 675 # GOBJ 676 for obj in tag.objects: 677 printMsg("%s: [Size: %d bytes] [Type: %s]" % 678 (boldText("GEOB"), len(obj.object_data), 679 obj.mime_type)) 680 printMsg("Description: %s" % obj.description) 681 printMsg("Filename: %s" % obj.filename) 682 printMsg("\n") 683 if self.args.write_objects_dir: 684 obj_path = "%s%s" % (self.args.write_objects_dir, os.sep) 685 if not os.path.isdir(obj_path): 686 raise IOError("Directory does not exist: %s" % obj_path) 687 obj_file = self._getDefaultNameForObject(obj) 688 count = 1 689 while os.path.exists(os.path.join(obj_path, obj_file)): 690 obj_file = self._getDefaultNameForObject(obj, 691 str(count)) 692 count += 1 693 printWarning("Writing %s..." % os.path.join(obj_path, 694 obj_file)) 695 with open(os.path.join(obj_path, obj_file), "wb") as fp: 696 fp.write(obj.object_data) 697 698 # PRIV 699 for p in tag.privates: 700 printMsg("%s: [Data: %d bytes]" % (boldText("PRIV"), 701 len(p.data))) 702 printMsg("Owner Id: %s" % p.owner_id.decode("ascii")) 703 704 # MCDI 705 if tag.cd_id: 706 printMsg("\n%s: [Data: %d bytes]" % (boldText("MCDI"), 707 len(tag.cd_id))) 708 709 # USER 710 if tag.terms_of_use: 711 printMsg("\nTerms of Use (%s): %s" % (boldText("USER"), 712 tag.terms_of_use)) 713 714 # --verbose 715 if self.args.verbose: 716 printMsg(self._getHardRule(self.terminal_width)) 717 printMsg("%d ID3 Frames:" % len(tag.frame_set)) 718 for fid in tag.frame_set: 719 frames = tag.frame_set[fid] 720 num_frames = len(frames) 721 count = " x %d" % num_frames if num_frames > 1 else "" 722 if not tag.isV1(): 723 total_bytes = sum( 724 tuple(frame.header.data_size + frame.header.size 725 for frame in frames if frame.header)) 726 else: 727 total_bytes = 30 728 if total_bytes: 729 printMsg("%s%s (%d bytes)" % (fid.decode("ascii"), 730 count, total_bytes)) 731 printMsg("%d bytes unused (padding)" % 732 (tag.file_info.tag_padding_size, )) 733 else: 734 raise TypeError("Unknown tag type: " + str(type(tag))) 735 736 def handleRemoves(self, tag): 737 remove_version = 0 738 status = False 739 rm_str = "" 740 if self.args.remove_all: 741 remove_version = id3.ID3_ANY_VERSION 742 rm_str = "v1.x and/or v2.x" 743 elif self.args.remove_v1: 744 remove_version = id3.ID3_V1 745 rm_str = "v1.x" 746 elif self.args.remove_v2: 747 remove_version = id3.ID3_V2 748 rm_str = "v2.x" 749 750 if remove_version: 751 status = id3.Tag.remove(tag.file_info.name, remove_version, 752 preserve_file_time=self.args.preserve_file_time) 753 printWarning(f"Removing ID3 {rm_str} tag: {'SUCCESS' if status else 'FAIL'}") 754 755 return status 756 757 def handlePadding(self, tag): 758 max_padding = self.args.max_padding 759 if max_padding is None or max_padding is True: 760 return False 761 padding = tag.file_info.tag_padding_size 762 needs_change = padding > max_padding 763 return needs_change 764 765 def handleEdits(self, tag): 766 retval = False 767 768 # --remove-all-*, Handling removes first means later options are still 769 # applied 770 for what, arg, fid in (("comments", self.args.remove_all_comments, 771 id3.frames.COMMENT_FID), 772 ("lyrics", self.args.remove_all_lyrics, 773 id3.frames.LYRICS_FID), 774 ("images", self.args.remove_all_images, 775 id3.frames.IMAGE_FID), 776 ("objects", self.args.remove_all_objects, 777 id3.frames.OBJECT_FID), 778 ): 779 if arg and tag.frame_set[fid]: 780 printWarning("Removing all %s..." % what) 781 del tag.frame_set[fid] 782 retval = True 783 784 # --artist, --title, etc. All common/simple text frames. 785 for (what, setFunc) in ( 786 ("artist", partial(tag._setArtist, self.args.artist)), 787 ("album", partial(tag._setAlbum, self.args.album)), 788 ("album artist", partial(tag._setAlbumArtist, 789 self.args.album_artist)), 790 ("title", partial(tag._setTitle, self.args.title)), 791 ("genre", partial(tag._setGenre, self.args.genre, 792 id3_std=not self.args.non_std_genres)), 793 ("release date", partial(tag._setReleaseDate, 794 self.args.release_date)), 795 ("original release date", partial(tag._setOrigReleaseDate, 796 self.args.orig_release_date)), 797 ("recording date", partial(tag._setRecordingDate, 798 self.args.recording_date)), 799 ("encoding date", partial(tag._setEncodingDate, 800 self.args.encoding_date)), 801 ("tagging date", partial(tag._setTaggingDate, 802 self.args.tagging_date)), 803 ("beats per minute", partial(tag._setBpm, self.args.bpm)), 804 ("publisher", partial(tag._setPublisher, self.args.publisher)), 805 ("composer", partial(tag._setComposer, self.args.composer)), 806 ("orig-artist", partial(tag._setOrigArtist, self.args.orig_artist)), 807 ): 808 if setFunc.args[0] is not None: 809 printWarning("Setting %s: %s" % (what, setFunc.args[0])) 810 setFunc() 811 retval = True 812 813 def _checkNumberedArgTuples(curr, new): 814 n = None 815 if new not in [(None, None), curr]: 816 n = [None] * 2 817 for i in (0, 1): 818 if new[i] == 0: 819 n[i] = None 820 else: 821 n[i] = new[i] or curr[i] 822 n = tuple(n) 823 # Returning None means do nothing, (None, None) would clear both vals 824 return n 825 826 # --artist-{city,state,country} 827 origin = core.ArtistOrigin(self.args.artist_city, 828 self.args.artist_state, 829 self.args.artist_country) 830 if origin or (dataclasses.astuple(origin) != (None, None, None) and tag.artist_origin): 831 printWarning(f"Setting artist origin: {origin}") 832 tag.artist_origin = origin 833 retval = True 834 835 # --track, --track-total 836 track_info = _checkNumberedArgTuples(tag.track_num, 837 (self.args.track, 838 self.args.track_total)) 839 if track_info is not None: 840 printWarning("Setting track info: %s" % str(track_info)) 841 tag.track_num = track_info 842 retval = True 843 844 # --track-offset 845 if self.args.track_offset: 846 offset = self.args.track_offset 847 tag.track_num = (tag.track_num[0] + offset, tag.track_num[1]) 848 printWarning("%s track info by %d: %d" % 849 ("Incrementing" if offset > 0 else "Decrementing", 850 offset, tag.track_num[0])) 851 retval = True 852 853 # --disc-num, --disc-total 854 disc_info = _checkNumberedArgTuples(tag.disc_num, 855 (self.args.disc_num, 856 self.args.disc_total)) 857 if disc_info is not None: 858 printWarning("Setting disc info: %s" % str(disc_info)) 859 tag.disc_num = disc_info 860 retval = True 861 862 # -Y, --release-year 863 if self.args.release_year is not None: 864 # empty string means clean, None means not given 865 year = self.args.release_year 866 printWarning(f"Setting release year: {year}") 867 tag.release_date = int(year) if year else None 868 retval = True 869 870 # -c , simple comment 871 if self.args.simple_comment: 872 # Just add it as if it came in --add-comment 873 self.args.comments.append((self.args.simple_comment, "", 874 id3.DEFAULT_LANG)) 875 876 # --remove-comment, remove-lyrics, --remove-image, --remove-object 877 for what, arg, accessor in (("comment", self.args.remove_comment, 878 tag.comments), 879 ("lyrics", self.args.remove_lyrics, 880 tag.lyrics), 881 ("image", self.args.remove_image, 882 tag.images), 883 ("object", self.args.remove_object, 884 tag.objects), 885 ): 886 for vals in arg: 887 if type(vals) is str: 888 frame = accessor.remove(vals) 889 else: 890 frame = accessor.remove(*vals) 891 if frame: 892 printWarning("Removed %s %s" % (what, str(vals))) 893 retval = True 894 else: 895 printError("Removing %s failed, %s not found" % 896 (what, str(vals))) 897 898 # --add-comment, --add-lyrics 899 for what, arg, accessor in (("comment", self.args.comments, 900 tag.comments), 901 ("lyrics", self.args.lyrics, tag.lyrics), 902 ): 903 for text, desc, lang in arg: 904 printWarning("Setting %s: %s/%s" % 905 (what, desc, str(lang, "ascii"))) 906 accessor.set(text, desc, b(lang)) 907 retval = True 908 909 # --play-count 910 playcount_arg = self.args.play_count 911 if playcount_arg: 912 increment, pc = playcount_arg 913 if increment: 914 printWarning("Increment play count by %d" % pc) 915 tag.play_count += pc 916 else: 917 printWarning("Setting play count to %d" % pc) 918 tag.play_count = pc 919 retval = True 920 921 # --add-popularity 922 for email, rating, play_count in self.args.popularities: 923 tag.popularities.set(email.encode("latin1"), rating, play_count) 924 retval = True 925 926 # --remove-popularity 927 for email in self.args.remove_popularity: 928 popm = tag.popularities.remove(email.encode("latin1")) 929 if popm: 930 retval = True 931 932 # --text-frame, --url-frame 933 for what, arg, setter in ( 934 ("text frame", self.args.text_frames, tag.setTextFrame), 935 ("url frame", self.args.url_frames, tag._setUrlFrame), 936 ): 937 for fid, text in arg: 938 if text: 939 printWarning("Setting %s %s to '%s'" % (fid, what, text)) 940 else: 941 printWarning("Removing %s %s" % (fid, what)) 942 setter(fid, text) 943 retval = True 944 945 # --user-text-frame, --user-url-frame 946 for what, arg, accessor in ( 947 ("user text frame", self.args.user_text_frames, 948 tag.user_text_frames), 949 ("user url frame", self.args.user_url_frames, 950 tag.user_url_frames), 951 ): 952 for desc, text in arg: 953 if text: 954 printWarning(f"Setting '{desc}' {what} to '{text}'") 955 accessor.set(text, desc) 956 else: 957 printWarning(f"Removing '{desc}' {what}") 958 accessor.remove(desc) 959 retval = True 960 961 # --add-image 962 for img_path, img_type, img_mt, img_desc in self.args.images: 963 assert img_path 964 printWarning("Adding image %s" % img_path) 965 if img_mt not in ImageFrame.URL_MIME_TYPE_VALUES: 966 with open(img_path, "rb") as img_fp: 967 tag.images.set(img_type, img_fp.read(), img_mt, img_desc) 968 else: 969 tag.images.set(img_type, None, None, img_desc, img_url=img_path) 970 retval = True 971 972 # --add-object 973 for obj_path, obj_mt, obj_desc, obj_fname in self.args.objects or []: 974 assert obj_path 975 printWarning("Adding object %s" % obj_path) 976 with open(obj_path, "rb") as obj_fp: 977 tag.objects.set(obj_fp.read(), obj_mt, obj_desc, obj_fname) 978 retval = True 979 980 # --unique-file-id 981 for arg in self.args.unique_file_ids: 982 owner_id, id = arg 983 if not id: 984 if tag.unique_file_ids.remove(owner_id): 985 printWarning("Removed unique file ID '%s'" % owner_id) 986 retval = True 987 else: 988 printWarning("Unique file ID '%s' not found" % owner_id) 989 else: 990 tag.unique_file_ids.set(id, owner_id.encode("latin1")) 991 printWarning("Setting unique file ID '%s' to %s" % 992 (owner_id, id)) 993 retval = True 994 995 # --remove-frame 996 for fid in self.args.remove_fids: 997 assert(isinstance(fid, bytes)) 998 if fid in tag.frame_set: 999 del tag.frame_set[fid] 1000 retval = True 1001 1002 return retval 1003 1004 1005def _getTemplateKeys(): 1006 keys = list(id3.TagTemplate("")._makeMapping(None, False).keys()) 1007 keys.sort() 1008 return ", ".join(["$%s" % v for v in keys]) 1009 1010 1011ARGS_HELP = { 1012 "--artist": "Set the artist name.", 1013 "--album": "Set the album name.", 1014 "--album-artist": "Set the album artist name. '%s', for example. " 1015 "Another example is collaborations when the " 1016 "track artist might be 'Eminem featuring Proof' " 1017 "the album artist would be 'Eminem'." % 1018 core.VARIOUS_ARTISTS, 1019 "--title": "Set the track title.", 1020 "--track": "Set the track number. Use 0 to clear.", 1021 "--track-total": "Set total number of tracks. Use 0 to clear.", 1022 "--disc-num": "Set the disc number. Use 0 to clear.", 1023 "--disc-total": "Set total number of discs in set. Use 0 to clear.", 1024 "--genre": "Set the genre. If the argument is a standard ID3 genre " 1025 "name or number both will be set. Otherwise, any string " 1026 "can be used. Run 'eyeD3 --plugin=genres' for a list of " 1027 "standard ID3 genre names/ids.", 1028 "--non-std-genres": "Disables certain ID3 genre standards, such as the " 1029 "mapping of numeric value to genre names. For example, " 1030 "genre=1 is taken literally, not mapped to 'Classic Rock'.", 1031 "--release-year": "Set the year the track was released. Use the date " 1032 "options for more precise values or dates other " 1033 "than release.", 1034 1035 "--v1": "Only read and write ID3 v1.x tags. By default, v1.x tags are " 1036 "only read or written if there is not a v2 tag in the file.", 1037 "--v2": "Only read/write ID3 v2.x tags. This is the default unless " 1038 "the file only contains a v1 tag.", 1039 1040 "--to-v1.1": "Convert the file's tag to ID3 v1.1 (Or 1.0 if there is " 1041 "no track number)", 1042 "--to-v2.3": "Convert the file's tag to ID3 v2.3", 1043 "--to-v2.4": "Convert the file's tag to ID3 v2.4", 1044 1045 "--release-date": "Set the date the track/album was released", 1046 "--orig-release-date": "Set the original date the track/album was " 1047 "released", 1048 "--recording-date": "Set the date the track/album was recorded", 1049 "--encoding-date": "Set the date the file was encoded", 1050 "--tagging-date": "Set the date the file was tagged", 1051 1052 "--comment": "Set a comment. In ID3 tags this is the comment with " 1053 "an empty description. See --add-comment to add multiple " 1054 "comment frames.", 1055 "--add-comment": 1056 "Add or replace a comment. There may be more than one comment in a " 1057 "tag, as long as the DESCRIPTION and LANG values are unique. The " 1058 "default DESCRIPTION is '' and the default language code is '%s'." % 1059 str(id3.DEFAULT_LANG, "ascii"), 1060 "--remove-comment": "Remove comment matching DESCRIPTION and LANG. " 1061 "The default language code is '%s'." % 1062 str(id3.DEFAULT_LANG, "ascii"), 1063 "--remove-all-comments": "Remove all comments from the tag.", 1064 1065 "--add-lyrics": 1066 "Add or replace a lyrics. There may be more than one set of lyrics " 1067 "in a tag, as long as the DESCRIPTION and LANG values are unique. " 1068 "The default DESCRIPTION is '' and the default language code is " 1069 "'%s'." % str(id3.DEFAULT_LANG, "ascii"), 1070 "--remove-lyrics": "Remove lyrics matching DESCRIPTION and LANG. " 1071 "The default language code is '%s'." % 1072 str(id3.DEFAULT_LANG, "ascii"), 1073 "--remove-all-lyrics": "Remove all lyrics from the tag.", 1074 1075 "--publisher": "Set the publisher/label name", 1076 "--play-count": "Set the number of times played counter. If the " 1077 "argument value begins with '+' the tag's play count " 1078 "is incremented by N, otherwise the value is set to " 1079 "exactly N.", 1080 "--bpm": "Set the beats per minute value.", 1081 1082 "--text-frame": "Set the value of a text frame. To remove the " 1083 "frame, specify an empty value. For example, " 1084 "--text-frame='TDRC:'", 1085 "--user-text-frame": "Set the value of a user text frame (i.e., TXXX). " 1086 "To remove the frame, specify an empty value. " 1087 "e.g., --user-text-frame='SomeDesc:'", 1088 "--url-frame": "Set the value of a URL frame. To remove the frame, " 1089 "specify an empty value. e.g., --url-frame='WCOM:'", 1090 "--user-url-frame": "Set the value of a user URL frame (i.e., WXXX). " 1091 "To remove the frame, specify an empty value. " 1092 "e.g., --user-url-frame='SomeDesc:'", 1093 1094 "--add-image": "Add or replace an image. There may be more than one " 1095 "image in a tag, as long as the DESCRIPTION values are " 1096 "unique. The default DESCRIPTION is ''. If PATH begins " 1097 "with 'http[s]://' then it is interpreted as a URL " 1098 "instead of a file containing image data. The TYPE must " 1099 "be one of the following: %s." 1100 % (", ".join([ImageFrame.picTypeToString(t) 1101 for t in range(ImageFrame.MIN_TYPE, 1102 ImageFrame.MAX_TYPE + 1)]), 1103 ), 1104 "--remove-image": "Remove image matching DESCRIPTION.", 1105 "--remove-all-images": "Remove all images from the tag", 1106 "--write-images": "Causes all attached images (APIC frames) to be " 1107 "written to the specified directory.", 1108 1109 "--add-object": "Add or replace an object. There may be more than one " 1110 "object in a tag, as long as the DESCRIPTION values " 1111 "are unique. The default DESCRIPTION is ''.", 1112 "--remove-object": "Remove object matching DESCRIPTION.", 1113 "--remove-all-objects": "Remove all objects from the tag", 1114 "--write-objects": "Causes all attached objects (GEOB frames) to be " 1115 "written to the specified directory.", 1116 1117 "--add-popularity": "Adds a pupularity metric. There may be multiples " 1118 "popularity values, but each must have a unique " 1119 "email address component. The rating is a number " 1120 "between 0 (worst) and 255 (best). The play count " 1121 "is optional, and defaults to 0, since there is " 1122 "already a dedicated play count frame.", 1123 "--remove-popularity": "Removes the popularity frame with the " 1124 "specified email key.", 1125 1126 "--remove-v1": "Remove ID3 v1.x tag.", 1127 "--remove-v2": "Remove ID3 v2.x tag.", 1128 "--remove-all": "Remove ID3 v1.x and v2.x tags.", 1129 1130 "--remove-frame": "Remove all frames with the given ID. This option " 1131 "may be specified multiple times.", 1132 1133 "--max-padding": "Shrink file if tag padding (unused space) exceeds " 1134 "the given number of bytes. " 1135 "(Useful e.g. after removal of large cover art.) " 1136 "Default is 64 KiB, file will be rewritten with " 1137 "default padding (1 KiB) or max padding, whichever " 1138 "is smaller.", 1139 "--no-max-padding": "Disable --max-padding altogether.", 1140 1141 "--force-update": "Rewrite the tag despite there being no edit " 1142 "options.", 1143 "--verbose": "Show all available tag data", 1144 "--unique-file-id": "Add a unique file ID frame. If the ID arg is " 1145 "empty the frame is removed. An OWNER_ID is " 1146 "required. The ID may be no more than 64 bytes.", 1147 "--encoding": "Set the encoding that is used for all text frames. " 1148 "This option is only applied if the tag is updated " 1149 "as the result of an edit option (e.g. --artist, " 1150 "--title, etc.) or --force-update is specified.", 1151 "--rename": "Rename file (the extension is not affected) " 1152 "based on data in the tag using substitution " 1153 "variables: " + _getTemplateKeys(), 1154 "--preserve-file-times": "When writing, do not update file " 1155 "modification times.", 1156 "--track-offset": "Increment/decrement the track number by [-]N. " 1157 "This option is applied after --track=N is set.", 1158 "--composer": "Set the composer's name.", 1159 "--orig-artist": "Set the orignal artist's name. For example, a cover song can include " 1160 "the orignal author of the track.", 1161} 1162