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