def _audio_tit3(atuple): audio, atag, advanced, _, _ = atuple if advanced: param = ast.literal_eval(atag) audio.add(TIT3(3, param[1])) else: audio.add(TIT3(3, atag))
def test_addframe(self): f = ID3(self.filename) self.assert_("TIT3" not in f) f["TIT3"] = TIT3(encoding=0, text="A subtitle!") f.save() id3 = ID3(self.filename) self.assertEquals(id3["TIT3"], "A subtitle!")
def test_addframe(self): f = ID3(self.newsilence) self.assert_("TIT3" not in f) f["TIT3"] = TIT3(encoding=0, text="A subtitle!") f.save() id3 = eyeD3.tag.Tag(eyeD3.ID3_V2_4) id3.link(self.newsilence) self.assertEquals(id3.frames["TIT3"][0].text, "A subtitle!")
def test_write_tit3_v23(self): spec = ID3FramesSpec("name") config = ID3SaveConfig(3, "/") tags = ID3Tags() tags.add(TIT3(encoding=3, text=[u"F", u"B"])) self.assertEqual( spec.write(config, None, tags), b"TIT3" + b"\x00\x00\x00\x0B" + b"\x00\x00" + b"\x01" + b"\xff\xfeF\x00/\x00B\x00\x00\x00")
def test_validate(self): header = ID3Header() header.version = (2, 4, 0) spec = ID3FramesSpec("name") self.assertRaises(TypeError, spec.validate, None, None) self.assertTrue(isinstance(spec.validate(None, []), ID3Tags)) v = spec.validate(None, [TIT3(encoding=3, text=[u"foo"])]) self.assertEqual(v.getall("TIT3")[0].text, [u"foo"])
def copyTagsToTranscodedFileMp3(losslessFile, lossyFile): # # Copy the tags from the losslessFile (.flac) to the lossyFile. # All previous tags from the lossyFile will be deleted before the # tags from the losslessFile are copied. # from mutagen.flac import FLAC from mutagen.id3 import ID3 # Read all tags from the flac file flacFile = FLAC(losslessFile) flacFileTags = flacFile.tags # Returns a dictionary containing the flac tags # Only mp3 files with ID3 headers can be openend. # So be sure to add some tags during encoding .wav. to mp3 # Mapping from Vorbis comments field recommendations to id3v2_4_0 # For more information about vorbis field recommendations: http://reactor-core.org/ogg-tagging.html # For more information about id3v2_4_0 frames: http://www.id3.org/id3v2.4.0-frames # # Single value tags: # ALBUM -> TALB # ARTIST -> TPE1 # PUBLISHER -> TPUB # COPYRIGHT -> WCOP # DISCNUMBER -> TPOS # ISRC -> TSRC # EAN/UPN # LABEL # LABELNO # LICENSE -> TOWN # OPUS -> TIT3 # SOURCEMEDIA -> TMED # TITLE -> TIT2 # TRACKNUMBER -> TRCK # VERSION # ENCODED-BY -> TENC # ENCODING # Multiple value tags: # COMPOSER -> TCOM # ARRANGER # LYRICIST -> TEXT # AUTHOR -> TEXT # CONDUCTOR -> TPE3 # PERFORMER -> # ENSEMBLE -> TPE2 # PART -> TIT1 # PARTNUMBER -> TIT1 # GENRE -> TCON # DATE -> TDRC # LOCATION # COMMENT -> COMM # Other vorbis tags are mapped to TXXX tags mp3File = ID3(lossyFile) mp3File.delete() for key, value in flacFileTags.items(): if key == 'title': # Map to TIT2 frame from mutagen.id3 import TIT2 mp3File.add(TIT2(encoding=3, text=value)) elif key == 'album': # Map to TALB frame from mutagen.id3 import TALB mp3File.add(TALB(encoding=3, text=value)) elif key == 'artist': # Map to TPE1 frame from mutagen.id3 import TPE1 mp3File.add(TPE1(encoding=3, text=value)) elif key == 'tracknumber': # Map to TRCK frame from mutagen.id3 import TRCK mp3File.add(TRCK(encoding=3, text=value)) elif key == 'date': # Map to TDRC frame from mutagen.id3 import TDRC mp3File.add(TDRC(encoding=3, text=value)) elif key == 'genre': # Map to TCON frame from mutagen.id3 import TCON mp3File.add(TCON(encoding=3, text=value)) elif key == 'discnumber': # Map to TPOS frame from mutagen.id3 import TPOS mp3File.add(TPOS(encoding=3, text=value)) elif key == 'composer': # Map to TCOM frame from mutagen.id3 import TCOM mp3File.add(TCOM(encoding=3, text=value)) elif key == 'conductor': # Map to TPE3 frame from mutagen.id3 import TPE3 mp3File.add(TPE3(encoding=3, text=value)) elif key == 'ensemble': # Map to TPE2 frame from mutagen.id3 import TPE2 mp3File.add(TPE2(encoding=3, text=value)) elif key == 'comment': # Map to COMM frame from mutagen.id3 import COMM mp3File.add(COMM(encoding=3, text=value)) elif key == 'publisher': # Map to TPUB frame from mutagen.id3 import TPUB mp3File.add(TPUB(encoding=3, text=value)) elif key == 'opus': # Map to TIT3 frame from mutagen.id3 import TIT3 mp3File.add(TIT3(encoding=3, text=value)) elif key == 'sourcemedia': # Map to TMED frame from mutagen.id3 import TMED mp3File.add(TMED(encoding=3, text=value)) elif key == 'isrc': # Map to TSRC frame from mutagen.id3 import TSRC mp3File.add(TSRC(encoding=3, text=value)) elif key == 'license': # Map to TOWN frame from mutagen.id3 import TOWN mp3File.add(TOWN(encoding=3, text=value)) elif key == 'copyright': # Map to WCOP frame from mutagen.id3 import WCOP mp3File.add(WCOP(encoding=3, text=value)) elif key == 'encoded-by': # Map to TENC frame from mutagen.id3 import TENC mp3File.add(TENC(encoding=3, text=value)) elif (key == 'part' or key == 'partnumber'): # Map to TIT3 frame from mutagen.id3 import TIT3 mp3File.add(TIT3(encoding=3, text=value)) elif (key == 'lyricist' or key == 'textwriter'): # Map to TEXT frame from mutagen.id3 import TIT3 mp3File.add(TIT3(encoding=3, text=value)) else: #all other tags are mapped to TXXX frames # Map to TXXX frame from mutagen.id3 import TXXX mp3File.add(TXXX(encoding=3, text=value, desc=key)) mp3File.update_to_v24() mp3File.save() return
def run(): parser = argparse.ArgumentParser( prog='odmpy', description='Download/return an Overdrive loan audiobook', epilog= 'Version {version}. [Python {py_major}.{py_minor}.{py_micro}-{platform}] ' 'Source at https://github.com/ping/odmpy/'.format( version=__version__, py_major=sys.version_info.major, py_minor=sys.version_info.minor, py_micro=sys.version_info.micro, platform=sys.platform, )) parser.add_argument('-v', '--verbose', dest='verbose', action='store_true', help='Enable more verbose messages for debugging') parser.add_argument( '-t', '--timeout', dest='timeout', type=int, default=10, help='Timeout (seconds) for network requests. Default 10.') subparsers = parser.add_subparsers( title='Available commands', dest='subparser_name', help='To get more help, use the -h option with the command.') parser_info = subparsers.add_parser( 'info', description='Get information about a loan file.', help='Get information about a loan file') parser_info.add_argument('odm_file', type=str, help='ODM file path') parser_dl = subparsers.add_parser('dl', description='Download from a loan file.', help='Download from a loan file') parser_dl.add_argument('-d', '--downloaddir', dest='download_dir', default='.', help='Download folder path') parser_dl.add_argument('-c', '--chapters', dest='add_chapters', action='store_true', help='Add chapter marks (experimental)') parser_dl.add_argument( '-m', '--merge', dest='merge_output', action='store_true', help='Merge into 1 file (experimental, requires ffmpeg)') parser_dl.add_argument( '--mergeformat', dest='merge_format', choices=['mp3', 'm4b'], default='mp3', help='Merged file format (m4b is slow, experimental, requires ffmpeg)') parser_dl.add_argument( '-k', '--keepcover', dest='always_keep_cover', action='store_true', help='Always generate the cover image file (cover.jpg)') parser_dl.add_argument('-f', '--keepmp3', dest='keep_mp3', action='store_true', help='Keep downloaded mp3 files (after merging)') parser_dl.add_argument('-j', '--writejson', dest='write_json', action='store_true', help='Generate a meta json file (for debugging)') parser_dl.add_argument('odm_file', type=str, help='ODM file path') parser_ret = subparsers.add_parser('ret', description='Return a loan file.', help='Return a loan file.') parser_ret.add_argument('odm_file', type=str, help='ODM file path') args = parser.parse_args() if args.verbose: logger.setLevel(logging.DEBUG) try: # test for odm file args.odm_file except AttributeError: parser.print_help() exit(0) xml_doc = xml.etree.ElementTree.parse(args.odm_file) root = xml_doc.getroot() # Return Book if args.subparser_name == 'ret': logger.info('Returning {} ...'.format(args.odm_file)) early_return_url = root.find('EarlyReturnURL').text try: early_return_res = requests.get(early_return_url, headers={'User-Agent': UA_LONG}, timeout=10) early_return_res.raise_for_status() logger.info('Loan returned successfully: {}'.format(args.odm_file)) except HTTPError as he: if he.response.status_code == 403: logger.warning('Loan is probably already returned.') sys.exit() logger.error( 'Unexpected HTTPError while trying to return loan {}'.format( args.odm_file)) logger.error('HTTPError: {}'.format(str(he))) logger.debug(he.response.content) sys.exit(1) except ConnectionError as ce: logger.error('ConnectionError: {}'.format(str(ce))) sys.exit(1) sys.exit() metadata = None for t in root.itertext(): if not t.startswith('<Metadata>'): continue metadata = xml.etree.ElementTree.fromstring( # remove invalid & char re.sub(r'\s&\s', ' & ', t)) break debug_meta = {} title = metadata.find('Title').text cover_url = metadata.find( 'CoverUrl').text if metadata.find('CoverUrl') != None else '' authors = [ unescape_html(c.text) for c in metadata.find('Creators') if 'Author' in c.attrib.get('role', '') ] if not authors: authors = [ unescape_html(c.text) for c in metadata.find('Creators') if 'Editor' in c.attrib.get('role', '') ] if not authors: authors = [unescape_html(c.text) for c in metadata.find('Creators')] publisher = metadata.find('Publisher').text description = metadata.find('Description').text if metadata.find( 'Description') is not None else '' debug_meta['meta'] = { 'title': title, 'coverUrl': cover_url, 'authors': authors, 'publisher': publisher, 'description': description, } # View Book Info if args.subparser_name == 'info': logger.info(u'{:10} {}'.format('Title:', colored.blue(title))) logger.info(u'{:10} {}'.format( 'Creators:', colored.blue(u', '.join([ u'{} ({})'.format(c.text, c.attrib['role']) for c in metadata.find('Creators') ])))) logger.info(u'{:10} {}'.format('Publisher:', metadata.find('Publisher').text)) logger.info(u'{:10} {}'.format( 'Subjects:', u', '.join([c.text for c in metadata.find('Subjects')]))) logger.info(u'{:10} {}'.format( 'Languages:', u', '.join([c.text for c in metadata.find('Languages')]))) logger.info(u'{:10} \n{}'.format('Description:', metadata.find('Description').text)) for formats in root.findall('Formats'): for f in formats: logger.info(u'\n{:10} {}'.format('Format:', f.attrib['name'])) parts = f.find('Parts') for p in parts: logger.info('* {} - {} ({:,.0f}kB)'.format( p.attrib['name'], p.attrib['duration'], math.ceil(1.0 * int(p.attrib['filesize']) / 1024))) sys.exit() # Download Book download_baseurl = '' download_parts = [] for formats in root.findall('Formats'): for f in formats: protocols = f.find('Protocols') for p in protocols: if p.attrib.get('method', '') != 'download': continue download_baseurl = p.attrib['baseurl'] break parts = f.find('Parts') for p in parts: download_parts.append(p.attrib) debug_meta['download_parts'] = download_parts logger.info('Downloading "{}" by "{}" in {} parts...'.format( colored.blue(title, bold=True), colored.blue(', '.join(authors)), len(download_parts))) # declare book folder/file names here together so we can catch problems from too long names book_folder = os.path.join( args.download_dir, u'{} - {}'.format(title.replace(os.sep, '-'), u', '.join(authors).replace(os.sep, '-'))) # for merged mp3 book_filename = os.path.join( book_folder, u'{} - {}.mp3'.format(title.replace(os.sep, '-'), u', '.join(authors).replace(os.sep, '-'))) # for merged m4b book_m4b_filename = os.path.join( book_folder, u'{} - {}.m4b'.format(title.replace(os.sep, '-'), u', '.join(authors).replace(os.sep, '-'))) if not os.path.exists(book_folder): try: os.makedirs(book_folder) except OSError as exc: if exc.errno not in ( 36, 63): # ref http://www.ioplex.com/~miallen/errcmpp.html raise # Ref OSError: [Errno 36] File name too long https://github.com/ping/odmpy/issues/5 # create book folder, file with just the title book_folder = os.path.join( args.download_dir, u'{}'.format(title.replace(os.sep, '-'))) os.makedirs(book_folder) book_filename = os.path.join( book_folder, u'{}.mp3'.format(title.replace(os.sep, '-'))) book_m4b_filename = os.path.join( book_folder, u'{}.m4b'.format(title.replace(os.sep, '-'))) cover_filename = os.path.join(book_folder, 'cover.jpg') debug_filename = os.path.join(book_folder, 'debug.json') if not os.path.isfile(cover_filename) and cover_url: cover_res = requests.get(cover_url, headers={'User-Agent': UA}) cover_res.raise_for_status() with open(cover_filename, 'wb') as outfile: outfile.write(cover_res.content) acquisition_url = root.find('License').find('AcquisitionUrl').text media_id = root.attrib['id'] client_id = str(uuid.uuid1()).upper() raw_hash = '{client_id}|{omc}|{os}|ELOSNOC*AIDEM*EVIRDREVO'.format( client_id=client_id, omc=OMC, os=OS) m = hashlib.sha1(raw_hash.encode('utf-16-le')) license_hash = base64.b64encode(m.digest()) # Extract license # License file is downloadable only once per odm # so we keep it in case downloads fail _, odm_filename = os.path.split(args.odm_file) license_file = os.path.join(args.download_dir, odm_filename.replace('.odm', '.license')) if os.path.isfile(license_file): logger.warning( 'Already downloaded license file: {}'.format(license_file)) else: # download license file params = OrderedDict([('MediaID', media_id), ('ClientID', client_id), ('OMC', OMC), ('OS', OS), ('Hash', license_hash)]) license_res = requests.get(acquisition_url, params=params, headers={'User-Agent': UA}, timeout=args.timeout, stream=True) try: license_res.raise_for_status() with open(license_file, 'wb') as outfile: for chunk in license_res.iter_content(1024): outfile.write(chunk) logger.debug('Saved license file {}'.format(license_file)) except HTTPError as he: if he.response.status_code == 404: # odm file has expired logger.error('The loan file "{}" has expired.' 'Please download again.'.format(args.odm_file)) else: logger.error(he.response.content) sys.exit(1) except ConnectionError as ce: logger.error('ConnectionError: {}'.format(str(ce))) sys.exit(1) license_xml_doc = xml.etree.ElementTree.parse(license_file) license_root = license_xml_doc.getroot() ns = '{http://license.overdrive.com/2008/03/License.xsd}' license_client = license_root.find('{}SignedInfo'.format(ns)).find( '{}ClientID'.format(ns)) license_client_id = license_client.text lic_file_contents = '' with open(license_file, 'r') as lic_file: lic_file_contents = lic_file.read() cover_bytes = None if os.path.isfile(cover_filename): with open(cover_filename, 'rb') as f: cover_bytes = f.read() track_count = 0 file_tracks = [] keep_cover = args.always_keep_cover audio_lengths_ms = [] for p in download_parts: part_number = int(p['number']) part_filename = os.path.join( book_folder, u'{}.mp3'.format( slugify(u'{} - Part {:02d}'.format(title, part_number), allow_unicode=True))) part_tmp_filename = u'{}.part'.format(part_filename) part_file_size = int(p['filesize']) part_url_filename = p['filename'] part_download_url = '{}/{}'.format(download_baseurl, part_url_filename) part_markers = [] if os.path.isfile(part_filename): logger.warning('Already saved {}'.format( colored.magenta(part_filename))) else: try: part_download_res = requests.get(part_download_url, headers={ 'User-Agent': UA, 'ClientID': license_client_id, 'License': lic_file_contents }, timeout=args.timeout, stream=True) part_download_res.raise_for_status() chunk_size = 1024 * 1024 expected_chunk_count = math.ceil(1.0 * part_file_size / chunk_size) with open(part_tmp_filename, 'wb') as outfile: for chunk in progress.bar( part_download_res.iter_content( chunk_size=chunk_size), label='Part {}'.format(part_number), expected_size=expected_chunk_count): if chunk: outfile.write(chunk) # try to remux file to remove mp3 lame tag errors cmd = [ 'ffmpeg', '-y', '-nostdin', '-hide_banner', '-loglevel', 'info' if logger.level == logging.DEBUG else 'error', '-i', part_tmp_filename, '-c:a', 'copy', '-c:v', 'copy', part_filename ] try: exit_code = subprocess.call(cmd) if exit_code: logger.warning( 'ffmpeg exited with the code: {0!s}'.format( exit_code)) logger.warning('Command: {0!s}'.format(' '.join(cmd))) os.rename(part_tmp_filename, part_filename) else: os.remove(part_tmp_filename) except Exception as ffmpeg_ex: logger.warning('Error executing ffmpeg: {}'.format( str(ffmpeg_ex))) os.rename(part_tmp_filename, part_filename) except HTTPError as he: logger.error('HTTPError: {}'.format(str(he))) logger.debug(he.response.content) sys.exit(1) except ConnectionError as ce: logger.error('ConnectionError: {}'.format(str(ce))) sys.exit(1) try: # Fill id3 info for mp3 part mutagen_audio = MP3(part_filename, ID3=ID3) if not mutagen_audio.tags: mutagen_audio.tags = ID3() if 'TIT2' not in mutagen_audio.tags: mutagen_audio.tags.add( TIT2(encoding=3, text=u'{}'.format(title))) if 'TIT3' not in mutagen_audio.tags: mutagen_audio.tags.add( TIT3(encoding=3, text=u'{}'.format(description))) if 'TALB' not in mutagen_audio.tags: mutagen_audio.tags.add( TALB(encoding=3, text=u'{}'.format(title))) if 'TPE1' not in mutagen_audio.tags: mutagen_audio.tags.add( TPE1(encoding=3, text=u'{}'.format(authors[0]))) if 'TPE2' not in mutagen_audio.tags: mutagen_audio.tags.add( TPE2(encoding=3, text=u'{}'.format(authors[0]))) if 'TRCK' not in mutagen_audio.tags: mutagen_audio.tags.add( TRCK(encoding=3, text=u'{}'.format(part_number))) if 'TPUB' not in mutagen_audio.tags: mutagen_audio.tags.add( TPUB(encoding=3, text=u'{}'.format(publisher))) if 'COMM' not in mutagen_audio.tags: mutagen_audio.tags.add( COMM(encoding=3, desc=u'Description', text=u'{}'.format(description))) if cover_bytes: mutagen_audio.tags.add( APIC(encoding=3, mime=u'image/jpeg', type=3, desc=u'Cover', data=cover_bytes)) mutagen_audio.save() audio_lengths_ms.append( int(round(mutagen_audio.info.length * 1000))) # Extract OD chapter info from mp3s for use in merged file if 'TXXX:OverDrive MediaMarkers' in mutagen_audio.tags \ and mutagen_audio.tags['TXXX:OverDrive MediaMarkers'].text: marker_text = mutagen_audio.tags[ 'TXXX:OverDrive MediaMarkers'].text[0] try: tree = xml.etree.ElementTree.fromstring(marker_text) except UnicodeEncodeError: tree = xml.etree.ElementTree.fromstring( marker_text.encode('ascii', 'ignore').decode('ascii')) for m in tree.iter('Marker'): marker_name = m.find('Name').text.strip() marker_timestamp = m.find('Time').text timestamp = None ts_mark = 0 # 2 timestamp formats found for r in ('%M:%S.%f', '%H:%M:%S.%f'): try: timestamp = time.strptime(marker_timestamp, r) ts = datetime.timedelta(hours=timestamp.tm_hour, minutes=timestamp.tm_min, seconds=timestamp.tm_sec) ts_mark = int(1000 * ts.total_seconds()) break except ValueError: pass if not timestamp: # some invalid timestamp string, e.g. 60:15.00 mobj = re.match(MARKER_TIMESTAMP_HHMMSS, marker_timestamp) if mobj: ts_mark = int(mobj.group('hr')) * 60 * 60 * 1000 + \ int(mobj.group('min')) * 60 * 1000 + \ int(mobj.group('sec')) * 1000 + \ int(mobj.group('ms')) else: mobj = re.match(MARKER_TIMESTAMP_MMSS, marker_timestamp) if mobj: ts_mark = int(mobj.group('min')) * 60 * 1000 + \ int(mobj.group('sec')) * 1000 + \ int(mobj.group('ms')) else: raise ValueError( 'Invalid marker timestamp: {}'.format( marker_timestamp)) track_count += 1 part_markers.append((u'ch{:02d}'.format(track_count), marker_name, ts_mark)) if args.add_chapters and not args.merge_output: # set the chapter marks generated_markers = [] for j, file_marker in enumerate(part_markers): generated_markers.append({ 'id': file_marker[0], 'text': file_marker[1], 'start_time': int(file_marker[2]), 'end_time': int( round(mutagen_audio.info.length * 1000) if j == (len(part_markers) - 1) else part_markers[j + 1][2]), }) mutagen_audio.tags.add( CTOC(element_id=u'toc', flags=CTOCFlags.TOP_LEVEL | CTOCFlags.ORDERED, child_element_ids=[ m['id'].encode('ascii') for m in generated_markers ], sub_frames=[TIT2(text=[u'Table of Contents'])])) for i, m in enumerate(generated_markers): mutagen_audio.tags.add( CHAP(element_id=m['id'].encode('ascii'), start_time=m['start_time'], end_time=m['end_time'], sub_frames=[TIT2(text=[u'{}'.format(m['text'])]) ])) start_time = datetime.timedelta( milliseconds=m['start_time']) end_time = datetime.timedelta(milliseconds=m['end_time']) logger.debug( u'Added chap tag => {}: {}-{} "{}" to "{}"'.format( colored.cyan(m['id']), start_time, end_time, colored.cyan(m['text']), colored.blue(part_filename))) if len(generated_markers) == 1: # Weird player problem on voice where title is shown instead of chapter title mutagen_audio.tags.add( TIT2(encoding=3, text=u'{}'.format(title))) mutagen_audio.save() except Exception as e: logger.warning('Error saving ID3: {}'.format( colored.red(str(e), bold=True))) keep_cover = True logger.info('Saved "{}"'.format(colored.magenta(part_filename))) file_tracks.append({ 'file': part_filename, 'markers': part_markers, }) # end loop: for p in download_parts: debug_meta['audio_lengths_ms'] = audio_lengths_ms debug_meta['file_tracks'] = file_tracks if args.merge_output: if os.path.isfile(book_filename if args.merge_format == 'mp3' else book_m4b_filename): logger.warning('Already saved "{}"'.format( colored.magenta(book_filename if args.merge_format == 'mp3' else book_m4b_filename))) sys.exit(0) logger.info('Generating "{}"...'.format( colored.magenta(book_filename if args.merge_format == 'mp3' else book_m4b_filename))) # We can't directly generate a m4b here even if specified because eyed3 doesn't support m4b/mp4 temp_book_filename = '{}.part'.format(book_filename) cmd = [ 'ffmpeg', '-y', '-nostdin', '-hide_banner', '-loglevel', 'info' if logger.level == logging.DEBUG else 'error', '-stats', '-i', 'concat:{}'.format('|'.join([ft['file'] for ft in file_tracks])), '-acodec', 'copy', '-b:a', '64k', # explicitly set audio bitrate '-f', 'mp3', temp_book_filename ] exit_code = subprocess.call(cmd) if exit_code: logger.error( 'ffmpeg exited with the code: {0!s}'.format(exit_code)) logger.error('Command: {0!s}'.format(' '.join(cmd))) exit(exit_code) os.rename(temp_book_filename, book_filename) mutagen_audio = MP3(book_filename, ID3=ID3) if not mutagen_audio.tags: mutagen_audio.tags = ID3() # Overwrite title since it gets picked up from the first track by default mutagen_audio.tags.add(TIT2(encoding=3, text=u'{}'.format(title))) if 'TALB' not in mutagen_audio.tags: mutagen_audio.tags.add(TALB(encoding=3, text=u'{}'.format(title))) if 'TPE1' not in mutagen_audio.tags: mutagen_audio.tags.add( TPE1(encoding=3, text=u'{}'.format(authors[0]))) if 'TPE2' not in mutagen_audio.tags: mutagen_audio.tags.add( TPE2(encoding=3, text=u'{}'.format(authors[0]))) if 'TPUB' not in mutagen_audio.tags: mutagen_audio.tags.add( TPUB(encoding=3, text=u'{}'.format(publisher))) if 'COMM' not in mutagen_audio.tags: mutagen_audio.tags.add( COMM(encoding=3, desc=u'Description', text=u'{}'.format(description))) mutagen_audio.save() if args.add_chapters: merged_markers = [] for i, f in enumerate(file_tracks): prev_tracks_len_ms = 0 if i == 0 else reduce( lambda x, y: x + y, audio_lengths_ms[0:i]) this_track_endtime_ms = int( reduce(lambda x, y: x + y, audio_lengths_ms[0:i + 1])) file_markers = f['markers'] for j, file_marker in enumerate(file_markers): merged_markers.append({ 'id': file_marker[0], 'text': u'{}'.format(file_marker[1]), 'start_time': int(file_marker[2]) + prev_tracks_len_ms, 'end_time': int(this_track_endtime_ms if j == ( len(file_markers) - 1) else file_markers[j + 1][2] + prev_tracks_len_ms), }) debug_meta['merged_markers'] = merged_markers mutagen_audio.tags.add( CTOC(element_id=u'toc', flags=CTOCFlags.TOP_LEVEL | CTOCFlags.ORDERED, child_element_ids=[ m['id'].encode('ascii') for m in merged_markers ], sub_frames=[TIT2(text=[u"Table of Contents"])])) for i, m in enumerate(merged_markers): mutagen_audio.tags.add( CHAP(element_id=m['id'].encode('ascii'), start_time=m['start_time'], end_time=m['end_time'], sub_frames=[TIT2(text=[u'{}'.format(m['text'])])])) start_time = datetime.timedelta(milliseconds=m['start_time']) end_time = datetime.timedelta(milliseconds=m['end_time']) logger.debug( u'Added chap tag => {}: {}-{} "{}" to "{}"'.format( colored.cyan(m['id']), start_time, end_time, colored.cyan(m['text']), colored.blue(book_filename))) mutagen_audio.save() if args.merge_format == 'mp3': logger.info('Merged files into "{}"'.format( colored.magenta(book_filename if args.merge_format == 'mp3' else book_m4b_filename))) if args.merge_format == 'm4b': temp_book_m4b_filename = '{}.part'.format(book_m4b_filename) cmd = [ 'ffmpeg', '-y', '-nostdin', '-hide_banner', '-loglevel', 'info' if logger.level == logging.DEBUG else 'error', '-stats', '-i', book_filename, ] if os.path.isfile(cover_filename): cmd.extend(['-i', cover_filename]) cmd.extend([ '-map', '0:a', '-c:a', 'aac', '-b:a', '64k', # explicitly set audio bitrate ]) if os.path.isfile(cover_filename): cmd.extend([ '-map', '1:v', '-c:v', 'copy', '-disposition:v:0', 'attached_pic', ]) cmd.extend(['-f', 'mp4', temp_book_m4b_filename]) exit_code = subprocess.call(cmd) if exit_code: logger.error( 'ffmpeg exited with the code: {0!s}'.format(exit_code)) logger.error('Command: {0!s}'.format(' '.join(cmd))) exit(exit_code) os.rename(temp_book_m4b_filename, book_m4b_filename) logger.info('Merged files into "{}"'.format( colored.magenta(book_m4b_filename))) try: os.remove(book_filename) except Exception as e: logger.warning('Error deleting "{}": {}'.format( book_filename, str(e))) if not args.keep_mp3: for f in file_tracks: try: os.remove(f['file']) except Exception as e: logger.warning('Error deleting "{}": {}'.format( f['file'], str(e))) if not keep_cover: try: os.remove(cover_filename) except Exception as e: logger.warning('Error deleting "{}": {}'.format( cover_filename, str(e))) if args.write_json: with open(debug_filename, 'w') as outfile: json.dump(debug_meta, outfile, indent=2)
def subtitle(self, subtitle: str): self.file['TIT3'] = TIT3(encoding=3, text=subtitle) return self
def CopyTagsToTranscodedFileMp3(losslessFile, lossyFile): # Because the input flac file is decoded to wav, all metadata is lost. We have to extract this metadata from # the flac file and put it directly into the generated mp3 file. from mutagen.flac import FLAC from mutagen.id3 import ID3 flacFile = FLAC(losslessFile) flacFileTags = flacFile.tags mp3File = ID3(lossyFile) mp3File.delete() for key,value in flacFileTags.items(): if key == 'title': from mutagen.id3 import TIT2 mp3File.add(TIT2(encoding=3, text=value)) elif key == 'album': from mutagen.id3 import TALB mp3File.add(TALB(encoding=3, text=value)) elif key == 'artist': from mutagen.id3 import TPE1 mp3File.add(TPE1(encoding=3, text=value)) elif key == 'tracknumber': from mutagen.id3 import TRCK mp3File.add(TRCK(encoding=3, text=value)) elif key == 'date': from mutagen.id3 import TDRC mp3File.add(TDRC(encoding=3, text=value)) elif key == 'genre': from mutagen.id3 import TCON mp3File.add(TCON(encoding=3, text=value)) elif key == 'discnumber': from mutagen.id3 import TPOS mp3File.add(TPOS(encoding=3, text=value)) elif key == 'composer': from mutagen.id3 import TCOM mp3File.add(TCOM(encoding=3, text=value)) elif key == 'conductor': from mutagen.id3 import TPE3 mp3File.add(TPE3(encoding=3, text=value)) elif key == 'ensemble': from mutagen.id3 import TPE2 mp3File.add(TPE2(encoding=3, text=value)) elif key == 'comment': from mutagen.id3 import COMM mp3File.add(COMM(encoding=3, text=value)) elif key == 'publisher': from mutagen.id3 import TPUB mp3File.add(TPUB(encoding=3, text=value)) elif key == 'opus': from mutagen.id3 import TIT3 mp3File.add(TIT3(encoding=3, text=value)) elif key == 'sourcemedia': from mutagen.id3 import TMED mp3File.add(TMED(encoding=3, text=value)) elif key == 'isrc': from mutagen.id3 import TSRC mp3File.add(TSRC(encoding=3, text=value)) elif key == 'license': from mutagen.id3 import TOWN mp3File.add(TOWN(encoding=3, text=value)) elif key == 'copyright': from mutagen.id3 import WCOP mp3File.add(WCOP(encoding=3, text=value)) elif key == 'encoded-by': from mutagen.id3 import TENC mp3File.add(TENC(encoding=3, text=value)) elif (key == 'part' or key == 'partnumber'): from mutagen.id3 import TIT3 mp3File.add(TIT3(encoding=3, text=value)) elif (key == 'lyricist' or key == 'textwriter'): from mutagen.id3 import TIT3 mp3File.add(TIT3(encoding=3, text=value)) else: from mutagen.id3 import TXXX mp3File.add(TXXX(encoding=3, text=value, desc=key)) mp3File.update_to_v24() mp3File.save() return