audiometa-python 0.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- audiometa/__init__.py +1297 -0
- audiometa/__main__.py +6 -0
- audiometa/_audio_file.py +607 -0
- audiometa/cli.py +476 -0
- audiometa/exceptions.py +167 -0
- audiometa/manager/_MetadataManager.py +768 -0
- audiometa/manager/__init__.py +1 -0
- audiometa/manager/_rating_supporting/_RatingSupportingMetadataManager.py +250 -0
- audiometa/manager/_rating_supporting/__init__.py +1 -0
- audiometa/manager/_rating_supporting/id3v2/_Id3v2Manager.py +1032 -0
- audiometa/manager/_rating_supporting/id3v2/__init__.py +25 -0
- audiometa/manager/_rating_supporting/id3v2/_id3v2_constants.py +11 -0
- audiometa/manager/_rating_supporting/riff/_RiffManager.py +1002 -0
- audiometa/manager/_rating_supporting/riff/__init__.py +25 -0
- audiometa/manager/_rating_supporting/riff/_riff_constants.py +17 -0
- audiometa/manager/_rating_supporting/vorbis/_VorbisManager.py +542 -0
- audiometa/manager/_rating_supporting/vorbis/__init__.py +17 -0
- audiometa/manager/_rating_supporting/vorbis/_vorbis_constants.py +6 -0
- audiometa/manager/id3v1/_Id3v1Manager.py +512 -0
- audiometa/manager/id3v1/__init__.py +1 -0
- audiometa/manager/id3v1/_constants.py +8 -0
- audiometa/manager/id3v1/id3v1_raw_metadata.py +242 -0
- audiometa/manager/id3v1/id3v1_raw_metadata_key.py +13 -0
- audiometa/test/__init__.py +1 -0
- audiometa/test/assets/create_test_files.py +72 -0
- audiometa/test/helpers/__init__.py +51 -0
- audiometa/test/helpers/common/__init__.py +6 -0
- audiometa/test/helpers/common/audio_file_creator.py +68 -0
- audiometa/test/helpers/common/external_tool_runner.py +74 -0
- audiometa/test/helpers/id3v1/__init__.py +8 -0
- audiometa/test/helpers/id3v1/id3v1_header_verifier.py +18 -0
- audiometa/test/helpers/id3v1/id3v1_metadata_deleter.py +37 -0
- audiometa/test/helpers/id3v1/id3v1_metadata_getter.py +61 -0
- audiometa/test/helpers/id3v1/id3v1_metadata_setter.py +82 -0
- audiometa/test/helpers/id3v2/__init__.py +28 -0
- audiometa/test/helpers/id3v2/id3v2_frame_manual_creator.py +349 -0
- audiometa/test/helpers/id3v2/id3v2_header_verifier.py +38 -0
- audiometa/test/helpers/id3v2/id3v2_metadata_deleter.py +56 -0
- audiometa/test/helpers/id3v2/id3v2_metadata_getter.py +189 -0
- audiometa/test/helpers/id3v2/id3v2_metadata_setter.py +506 -0
- audiometa/test/helpers/riff/__init__.py +8 -0
- audiometa/test/helpers/riff/riff_header_verifier.py +85 -0
- audiometa/test/helpers/riff/riff_manual_metadata_creator.py +298 -0
- audiometa/test/helpers/riff/riff_metadata_deleter.py +56 -0
- audiometa/test/helpers/riff/riff_metadata_getter.py +219 -0
- audiometa/test/helpers/riff/riff_metadata_setter.py +374 -0
- audiometa/test/helpers/scripts/__init__.py +0 -0
- audiometa/test/helpers/technical_info_inspector.py +115 -0
- audiometa/test/helpers/temp_file_with_metadata.py +82 -0
- audiometa/test/helpers/vorbis/__init__.py +8 -0
- audiometa/test/helpers/vorbis/vorbis_header_verifier.py +31 -0
- audiometa/test/helpers/vorbis/vorbis_metadata_deleter.py +49 -0
- audiometa/test/helpers/vorbis/vorbis_metadata_getter.py +67 -0
- audiometa/test/helpers/vorbis/vorbis_metadata_setter.py +221 -0
- audiometa/test/tests/__init__.py +0 -0
- audiometa/test/tests/conftest.py +276 -0
- audiometa/test/tests/e2e/__init__.py +0 -0
- audiometa/test/tests/e2e/cli/__init__.py +0 -0
- audiometa/test/tests/e2e/cli/error_handling/__init__.py +1 -0
- audiometa/test/tests/e2e/cli/error_handling/test_command_structure_errors.py +77 -0
- audiometa/test/tests/e2e/cli/error_handling/test_file_access_errors.py +130 -0
- audiometa/test/tests/e2e/cli/error_handling/test_format_output_errors.py +118 -0
- audiometa/test/tests/e2e/cli/error_handling/test_input_validation_errors.py +172 -0
- audiometa/test/tests/e2e/cli/error_handling/test_missing_fields_validation.py +49 -0
- audiometa/test/tests/e2e/cli/error_handling/test_multiple_files_errors.py +160 -0
- audiometa/test/tests/e2e/cli/error_handling/test_rating_validation.py +90 -0
- audiometa/test/tests/e2e/cli/error_handling/test_year_validation.py +51 -0
- audiometa/test/tests/e2e/cli/read/__init__.py +0 -0
- audiometa/test/tests/e2e/cli/read/test_basic.py +58 -0
- audiometa/test/tests/e2e/cli/read/test_comprehensive.py +240 -0
- audiometa/test/tests/e2e/cli/read/test_formats.py +55 -0
- audiometa/test/tests/e2e/cli/read/test_metadata_content.py +164 -0
- audiometa/test/tests/e2e/cli/read/test_multiple_files.py +149 -0
- audiometa/test/tests/e2e/cli/read/test_options.py +88 -0
- audiometa/test/tests/e2e/cli/read/test_unified.py +84 -0
- audiometa/test/tests/e2e/cli/test_delete.py +20 -0
- audiometa/test/tests/e2e/cli/test_formatting.py +31 -0
- audiometa/test/tests/e2e/cli/test_help.py +41 -0
- audiometa/test/tests/e2e/cli/write/__init__.py +0 -0
- audiometa/test/tests/e2e/cli/write/test_basic.py +51 -0
- audiometa/test/tests/e2e/cli/write/test_comprehensive.py +210 -0
- audiometa/test/tests/e2e/cli/write/test_force_format.py +336 -0
- audiometa/test/tests/e2e/cli/write/test_integer_fields.py +145 -0
- audiometa/test/tests/e2e/cli/write/test_list_fields.py +107 -0
- audiometa/test/tests/e2e/cli/write/test_rating.py +74 -0
- audiometa/test/tests/e2e/cli/write/test_string_fields.py +54 -0
- audiometa/test/tests/e2e/cli/write/test_validation.py +85 -0
- audiometa/test/tests/e2e/scenarios/__init__.py +0 -0
- audiometa/test/tests/e2e/scenarios/test_user_scenarios.py +166 -0
- audiometa/test/tests/e2e/workflows/__init__.py +0 -0
- audiometa/test/tests/e2e/workflows/test_core_workflows.py +166 -0
- audiometa/test/tests/e2e/workflows/test_deletion_workflows.py +318 -0
- audiometa/test/tests/e2e/workflows/test_error_handling_workflows.py +165 -0
- audiometa/test/tests/e2e/workflows/test_format_specific_workflows.py +129 -0
- audiometa/test/tests/e2e/workflows/test_rating_workflows.py +124 -0
- audiometa/test/tests/integration/__init__.py +0 -0
- audiometa/test/tests/integration/audio_format/__init__.py +0 -0
- audiometa/test/tests/integration/audio_format/flac/__init__.py +0 -0
- audiometa/test/tests/integration/audio_format/flac/test_flac_delete_all.py +108 -0
- audiometa/test/tests/integration/audio_format/flac/test_flac_reading_all.py +61 -0
- audiometa/test/tests/integration/audio_format/flac/test_flac_reading_field.py +65 -0
- audiometa/test/tests/integration/audio_format/flac/test_flac_writing.py +69 -0
- audiometa/test/tests/integration/audio_format/mp3/__init__.py +0 -0
- audiometa/test/tests/integration/audio_format/mp3/test_mp3_delete_all.py +79 -0
- audiometa/test/tests/integration/audio_format/mp3/test_mp3_reading_all.py +61 -0
- audiometa/test/tests/integration/audio_format/mp3/test_mp3_reading_field.py +67 -0
- audiometa/test/tests/integration/audio_format/mp3/test_mp3_writing.py +60 -0
- audiometa/test/tests/integration/audio_format/wav/__init__.py +0 -0
- audiometa/test/tests/integration/audio_format/wav/test_wav_delete_all.py +87 -0
- audiometa/test/tests/integration/audio_format/wav/test_wav_reading_all.py +62 -0
- audiometa/test/tests/integration/audio_format/wav/test_wav_reading_field.py +57 -0
- audiometa/test/tests/integration/audio_format/wav/test_wav_with_id3v2_tags.py +83 -0
- audiometa/test/tests/integration/audio_format/wav/test_wav_writing.py +62 -0
- audiometa/test/tests/integration/conftest.py +29 -0
- audiometa/test/tests/integration/delete_all_metadata/__init__.py +1 -0
- audiometa/test/tests/integration/delete_all_metadata/test_audio_format_all.py +102 -0
- audiometa/test/tests/integration/delete_all_metadata/test_audio_format_header_deletion.py +77 -0
- audiometa/test/tests/integration/delete_all_metadata/test_basic_functionality.py +47 -0
- audiometa/test/tests/integration/delete_all_metadata/test_error_handling.py +24 -0
- audiometa/test/tests/integration/encoding/__init__.py +1 -0
- audiometa/test/tests/integration/encoding/test_encoding.py +88 -0
- audiometa/test/tests/integration/encoding/test_special_characters_edge_cases.py +223 -0
- audiometa/test/tests/integration/get_full_metadata/__init__.py +0 -0
- audiometa/test/tests/integration/get_full_metadata/test_audio_formats.py +122 -0
- audiometa/test/tests/integration/get_full_metadata/test_binary_data_filtering.py +250 -0
- audiometa/test/tests/integration/get_full_metadata/test_consistency.py +67 -0
- audiometa/test/tests/integration/get_full_metadata/test_edge_cases.py +123 -0
- audiometa/test/tests/integration/get_full_metadata/test_error_handling.py +40 -0
- audiometa/test/tests/integration/get_full_metadata/test_get_full_metadata.py +43 -0
- audiometa/test/tests/integration/get_full_metadata/test_options.py +207 -0
- audiometa/test/tests/integration/get_full_metadata/test_performance.py +95 -0
- audiometa/test/tests/integration/get_full_metadata/test_riff_bext.py +128 -0
- audiometa/test/tests/integration/get_full_metadata/test_structure.py +161 -0
- audiometa/test/tests/integration/metadata_field/__init__.py +0 -0
- audiometa/test/tests/integration/metadata_field/album/__init__.py +0 -0
- audiometa/test/tests/integration/metadata_field/album/test_deleting.py +73 -0
- audiometa/test/tests/integration/metadata_field/album/test_reading.py +36 -0
- audiometa/test/tests/integration/metadata_field/album/test_writing.py +50 -0
- audiometa/test/tests/integration/metadata_field/album_artists/__init__.py +0 -0
- audiometa/test/tests/integration/metadata_field/album_artists/test_deleting.py +83 -0
- audiometa/test/tests/integration/metadata_field/album_artists/test_reading.py +38 -0
- audiometa/test/tests/integration/metadata_field/album_artists/test_writing.py +52 -0
- audiometa/test/tests/integration/metadata_field/artists/__init__.py +0 -0
- audiometa/test/tests/integration/metadata_field/artists/test_deleting.py +68 -0
- audiometa/test/tests/integration/metadata_field/artists/test_reading.py +36 -0
- audiometa/test/tests/integration/metadata_field/artists/test_writing.py +46 -0
- audiometa/test/tests/integration/metadata_field/bpm/__init__.py +0 -0
- audiometa/test/tests/integration/metadata_field/bpm/test_deleting.py +75 -0
- audiometa/test/tests/integration/metadata_field/bpm/test_reading.py +32 -0
- audiometa/test/tests/integration/metadata_field/bpm/test_writing.py +56 -0
- audiometa/test/tests/integration/metadata_field/comment/__init__.py +0 -0
- audiometa/test/tests/integration/metadata_field/comment/test_deleting.py +68 -0
- audiometa/test/tests/integration/metadata_field/comment/test_reading.py +36 -0
- audiometa/test/tests/integration/metadata_field/comment/test_writing.py +49 -0
- audiometa/test/tests/integration/metadata_field/composer/__init__.py +0 -0
- audiometa/test/tests/integration/metadata_field/composer/test_deleting.py +75 -0
- audiometa/test/tests/integration/metadata_field/composer/test_reading.py +34 -0
- audiometa/test/tests/integration/metadata_field/composer/test_writing.py +41 -0
- audiometa/test/tests/integration/metadata_field/copyright/__init__.py +0 -0
- audiometa/test/tests/integration/metadata_field/copyright/test_deleting.py +81 -0
- audiometa/test/tests/integration/metadata_field/copyright/test_reading.py +35 -0
- audiometa/test/tests/integration/metadata_field/copyright/test_writing.py +41 -0
- audiometa/test/tests/integration/metadata_field/disc_number/__init__.py +0 -0
- audiometa/test/tests/integration/metadata_field/disc_number/test_deleting.py +97 -0
- audiometa/test/tests/integration/metadata_field/disc_number/test_reading.py +92 -0
- audiometa/test/tests/integration/metadata_field/disc_number/test_writing.py +153 -0
- audiometa/test/tests/integration/metadata_field/field_not_supported/__init__.py +0 -0
- audiometa/test/tests/integration/metadata_field/field_not_supported/test_deleting.py +56 -0
- audiometa/test/tests/integration/metadata_field/field_not_supported/test_reading.py +54 -0
- audiometa/test/tests/integration/metadata_field/field_not_supported/test_writing.py +61 -0
- audiometa/test/tests/integration/metadata_field/genre/__init__.py +0 -0
- audiometa/test/tests/integration/metadata_field/genre/reading/__init__.py +0 -0
- audiometa/test/tests/integration/metadata_field/genre/reading/metadata_format/__init__.py +0 -0
- audiometa/test/tests/integration/metadata_field/genre/reading/metadata_format/test_id3v1_reading.py +65 -0
- audiometa/test/tests/integration/metadata_field/genre/reading/metadata_format/test_id3v2_reading.py +25 -0
- audiometa/test/tests/integration/metadata_field/genre/reading/metadata_format/test_riff_reading.py +58 -0
- audiometa/test/tests/integration/metadata_field/genre/reading/metadata_format/test_vorbis_reading.py +61 -0
- audiometa/test/tests/integration/metadata_field/genre/reading/test_smart_reading.py +191 -0
- audiometa/test/tests/integration/metadata_field/genre/test_deleting.py +62 -0
- audiometa/test/tests/integration/metadata_field/genre/test_writing.py +64 -0
- audiometa/test/tests/integration/metadata_field/isrc/__init__.py +1 -0
- audiometa/test/tests/integration/metadata_field/isrc/test_deleting.py +31 -0
- audiometa/test/tests/integration/metadata_field/isrc/test_reading.py +35 -0
- audiometa/test/tests/integration/metadata_field/isrc/test_writing.py +165 -0
- audiometa/test/tests/integration/metadata_field/language/__init__.py +0 -0
- audiometa/test/tests/integration/metadata_field/language/test_deleting.py +75 -0
- audiometa/test/tests/integration/metadata_field/language/test_reading.py +39 -0
- audiometa/test/tests/integration/metadata_field/language/test_writing.py +43 -0
- audiometa/test/tests/integration/metadata_field/lyrics/__init__.py +0 -0
- audiometa/test/tests/integration/metadata_field/lyrics/test_deleting.py +129 -0
- audiometa/test/tests/integration/metadata_field/lyrics/test_reading.py +57 -0
- audiometa/test/tests/integration/metadata_field/lyrics/test_writing.py +59 -0
- audiometa/test/tests/integration/metadata_field/publisher/__init__.py +0 -0
- audiometa/test/tests/integration/metadata_field/publisher/test_deleting.py +88 -0
- audiometa/test/tests/integration/metadata_field/publisher/test_reading.py +32 -0
- audiometa/test/tests/integration/metadata_field/publisher/test_writing.py +47 -0
- audiometa/test/tests/integration/metadata_field/rating/__init__.py +0 -0
- audiometa/test/tests/integration/metadata_field/rating/reading/__init__.py +0 -0
- audiometa/test/tests/integration/metadata_field/rating/reading/test_base_100_proportional.py +81 -0
- audiometa/test/tests/integration/metadata_field/rating/reading/test_base_255_non_proportional.py +33 -0
- audiometa/test/tests/integration/metadata_field/rating/reading/test_base_255_proportional.py +58 -0
- audiometa/test/tests/integration/metadata_field/rating/test_deleting.py +117 -0
- audiometa/test/tests/integration/metadata_field/rating/test_error_handling.py +137 -0
- audiometa/test/tests/integration/metadata_field/rating/writing/__init__.py +0 -0
- audiometa/test/tests/integration/metadata_field/rating/writing/metadata_format/__init__.py +0 -0
- audiometa/test/tests/integration/metadata_field/rating/writing/metadata_format/test_id3v2.py +77 -0
- audiometa/test/tests/integration/metadata_field/rating/writing/metadata_format/test_riff.py +55 -0
- audiometa/test/tests/integration/metadata_field/rating/writing/metadata_format/test_vorbis.py +57 -0
- audiometa/test/tests/integration/metadata_field/rating/writing/test_comprehensive.py +192 -0
- audiometa/test/tests/integration/metadata_field/release_date/__init__.py +0 -0
- audiometa/test/tests/integration/metadata_field/release_date/test_deleting.py +74 -0
- audiometa/test/tests/integration/metadata_field/release_date/test_error_handling.py +82 -0
- audiometa/test/tests/integration/metadata_field/release_date/test_reading.py +59 -0
- audiometa/test/tests/integration/metadata_field/release_date/test_writing.py +49 -0
- audiometa/test/tests/integration/metadata_field/test_metadata_field_validation.py +135 -0
- audiometa/test/tests/integration/metadata_field/title/__init__.py +0 -0
- audiometa/test/tests/integration/metadata_field/title/test_deleting.py +73 -0
- audiometa/test/tests/integration/metadata_field/title/test_error_handling.py +47 -0
- audiometa/test/tests/integration/metadata_field/title/test_reading.py +36 -0
- audiometa/test/tests/integration/metadata_field/title/test_writing.py +64 -0
- audiometa/test/tests/integration/metadata_field/track_number/__init__.py +0 -0
- audiometa/test/tests/integration/metadata_field/track_number/reading/__init__.py +0 -0
- audiometa/test/tests/integration/metadata_field/track_number/reading/test_edge_cases.py +43 -0
- audiometa/test/tests/integration/metadata_field/track_number/reading/test_metadata_format.py +32 -0
- audiometa/test/tests/integration/metadata_field/track_number/test_deleting.py +59 -0
- audiometa/test/tests/integration/metadata_field/track_number/test_writing.py +73 -0
- audiometa/test/tests/integration/multiple_values/__init__.py +1 -0
- audiometa/test/tests/integration/multiple_values/reading/__init__.py +1 -0
- audiometa/test/tests/integration/multiple_values/reading/metadata_format/__init__.py +1 -0
- audiometa/test/tests/integration/multiple_values/reading/metadata_format/test_id3v1.py +23 -0
- audiometa/test/tests/integration/multiple_values/reading/metadata_format/test_id3v2_3.py +92 -0
- audiometa/test/tests/integration/multiple_values/reading/metadata_format/test_id3v2_4.py +216 -0
- audiometa/test/tests/integration/multiple_values/reading/metadata_format/test_riff.py +84 -0
- audiometa/test/tests/integration/multiple_values/reading/metadata_format/test_vorbis.py +169 -0
- audiometa/test/tests/integration/multiple_values/reading/test_performance_large_data.py +209 -0
- audiometa/test/tests/integration/multiple_values/reading/test_smart_parsing_scenarios.py +198 -0
- audiometa/test/tests/integration/multiple_values/reading/test_unicode_handling.py +24 -0
- audiometa/test/tests/integration/multiple_values/writing/__init__.py +1 -0
- audiometa/test/tests/integration/multiple_values/writing/metadata_format/__init__.py +1 -0
- audiometa/test/tests/integration/multiple_values/writing/metadata_format/test_id3v1.py +62 -0
- audiometa/test/tests/integration/multiple_values/writing/metadata_format/test_id3v2_3.py +36 -0
- audiometa/test/tests/integration/multiple_values/writing/metadata_format/test_id3v2_4.py +34 -0
- audiometa/test/tests/integration/multiple_values/writing/metadata_format/test_riff.py +32 -0
- audiometa/test/tests/integration/multiple_values/writing/metadata_format/test_vorbis.py +54 -0
- audiometa/test/tests/integration/multiple_values/writing/test_error_handling.py +42 -0
- audiometa/test/tests/integration/multiple_values/writing/test_large_values.py +98 -0
- audiometa/test/tests/integration/reading/__init__.py +1 -0
- audiometa/test/tests/integration/reading/test_read_multiple_metadata.py +80 -0
- audiometa/test/tests/integration/reading/test_reading_error_handling.py +36 -0
- audiometa/test/tests/integration/real_audio_files/__init__.py +0 -0
- audiometa/test/tests/integration/real_audio_files/test_reading.py +146 -0
- audiometa/test/tests/integration/real_audio_files/test_writing.py +198 -0
- audiometa/test/tests/integration/technical_info/__init__.py +0 -0
- audiometa/test/tests/integration/technical_info/flac_md5/__init__.py +0 -0
- audiometa/test/tests/integration/technical_info/flac_md5/conftest.py +103 -0
- audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/__init__.py +0 -0
- audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/test_audio_data_corruption.py +21 -0
- audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/test_flipped_md5.py +29 -0
- audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/test_non_flac_error.py +13 -0
- audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/test_partial_md5.py +29 -0
- audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/test_random_md5.py +29 -0
- audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/test_unset_md5.py +56 -0
- audiometa/test/tests/integration/technical_info/flac_md5/test_valid_md5.py +21 -0
- audiometa/test/tests/integration/technical_info/test_bitrate.py +79 -0
- audiometa/test/tests/integration/technical_info/test_channels.py +38 -0
- audiometa/test/tests/integration/technical_info/test_duration_in_sec.py +38 -0
- audiometa/test/tests/integration/technical_info/test_sample_rate.py +40 -0
- audiometa/test/tests/integration/test_audio_file.py +35 -0
- audiometa/test/tests/integration/test_audio_format_readable_after_update_all_metadata_formats.py +95 -0
- audiometa/test/tests/integration/writing/__init__.py +0 -0
- audiometa/test/tests/integration/writing/test_error_handling.py +44 -0
- audiometa/test/tests/integration/writing/test_forced_format.py +224 -0
- audiometa/test/tests/integration/writing/test_multiple_format_preservation.py +223 -0
- audiometa/test/tests/integration/writing/test_partial_update.py +36 -0
- audiometa/test/tests/integration/writing/writing_strategies/__init__.py +0 -0
- audiometa/test/tests/integration/writing/writing_strategies/test_cleanup_strategy.py +79 -0
- audiometa/test/tests/integration/writing/writing_strategies/test_preserve_strategy.py +76 -0
- audiometa/test/tests/integration/writing/writing_strategies/test_sync_strategy.py +215 -0
- audiometa/test/tests/integration/writing/writing_strategies/unsupported_fields/__init__.py +0 -0
- audiometa/test/tests/integration/writing/writing_strategies/unsupported_fields/test_fail_behavior.py +42 -0
- audiometa/test/tests/integration/writing/writing_strategies/unsupported_fields/test_no_writing_on_failure.py +93 -0
- audiometa/test/tests/integration/writing/writing_strategies/unsupported_fields/test_strategy_specific.py +99 -0
- audiometa/test/tests/unit/__init__.py +0 -0
- audiometa/test/tests/unit/audio_file/__init__.py +0 -0
- audiometa/test/tests/unit/audio_file/technical_info/__init__.py +0 -0
- audiometa/test/tests/unit/audio_file/technical_info/test_bitrate.py +26 -0
- audiometa/test/tests/unit/audio_file/technical_info/test_channels.py +31 -0
- audiometa/test/tests/unit/audio_file/technical_info/test_duration_in_sec.py +38 -0
- audiometa/test/tests/unit/audio_file/technical_info/test_error_handling.py +190 -0
- audiometa/test/tests/unit/audio_file/technical_info/test_file_size.py +51 -0
- audiometa/test/tests/unit/audio_file/technical_info/test_format_name.py +28 -0
- audiometa/test/tests/unit/audio_file/technical_info/test_sample_rate.py +31 -0
- audiometa/test/tests/unit/audio_file/test_context_manager.py +30 -0
- audiometa/test/tests/unit/audio_file/test_file_validation.py +40 -0
- audiometa/test/tests/unit/audio_file/test_is_audio_file.py +49 -0
- audiometa/test/tests/unit/audio_file/test_operations.py +20 -0
- audiometa/test/tests/unit/audio_file/test_path_handling.py +23 -0
- audiometa/test/tests/unit/cli/__init__.py +0 -0
- audiometa/test/tests/unit/cli/test_expand_file_patterns.py +234 -0
- audiometa/test/tests/unit/metadata_managers/__init__.py +0 -0
- audiometa/test/tests/unit/metadata_managers/conftest.py +142 -0
- audiometa/test/tests/unit/metadata_managers/header_info/__init__.py +0 -0
- audiometa/test/tests/unit/metadata_managers/header_info/test_id3v1.py +49 -0
- audiometa/test/tests/unit/metadata_managers/header_info/test_id3v2.py +66 -0
- audiometa/test/tests/unit/metadata_managers/header_info/test_riff.py +343 -0
- audiometa/test/tests/unit/metadata_managers/header_info/test_vorbis.py +53 -0
- audiometa/test/tests/unit/metadata_managers/metadata_field/__init__.py +0 -0
- audiometa/test/tests/unit/metadata_managers/metadata_field/multiple_values/__init__.py +0 -0
- audiometa/test/tests/unit/metadata_managers/metadata_field/multiple_values/reading/__init__.py +0 -0
- audiometa/test/tests/unit/metadata_managers/metadata_field/multiple_values/reading/test_smart_parsing.py +186 -0
- audiometa/test/tests/unit/metadata_managers/metadata_field/multiple_values/writing/__init__.py +0 -0
- audiometa/test/tests/unit/metadata_managers/metadata_field/multiple_values/writing/test_separator_selection.py +142 -0
- audiometa/test/tests/unit/metadata_managers/metadata_field/multiple_values/writing/test_value_filtering.py +76 -0
- audiometa/test/tests/unit/metadata_managers/metadata_field/rating/__init__.py +0 -0
- audiometa/test/tests/unit/metadata_managers/metadata_field/rating/reading/__init__.py +0 -0
- audiometa/test/tests/unit/metadata_managers/metadata_field/rating/reading/test_normalization.py +152 -0
- audiometa/test/tests/unit/metadata_managers/metadata_field/rating/reading/test_profiles_values.py +23 -0
- audiometa/test/tests/unit/metadata_managers/metadata_field/rating/test_rating_validation.py +77 -0
- audiometa/test/tests/unit/metadata_managers/metadata_field/rating/writing/__init__.py +0 -0
- audiometa/test/tests/unit/metadata_managers/metadata_field/rating/writing/test_configuration_error.py +43 -0
- audiometa/test/tests/unit/metadata_managers/metadata_field/rating/writing/test_validation.py +151 -0
- audiometa/test/tests/unit/metadata_managers/metadata_field/rating/writing/test_writing_profiles.py +61 -0
- audiometa/test/tests/unit/metadata_managers/metadata_field/test_date_format_validation.py +135 -0
- audiometa/test/tests/unit/metadata_managers/metadata_field/test_disc_number_validation.py +75 -0
- audiometa/test/tests/unit/metadata_managers/metadata_field/test_isrc_format_validation.py +121 -0
- audiometa/test/tests/unit/metadata_managers/metadata_field/test_isrc_type_validation.py +30 -0
- audiometa/test/tests/unit/metadata_managers/metadata_field/test_track_number_validation.py +46 -0
- audiometa/test/tests/unit/metadata_managers/metadata_field/test_type_validation_exception.py +22 -0
- audiometa/test/tests/unit/metadata_managers/metadata_field/test_validation.py +83 -0
- audiometa/test/tests/unit/metadata_managers/test_metadata_format_managers_write_and_read.py +74 -0
- audiometa/test/tests/unit/metadata_managers/test_riff_configuration_error.py +26 -0
- audiometa/utils/__init__.py +1 -0
- audiometa/utils/id3v1_genre_code_map.py +205 -0
- audiometa/utils/metadata_format.py +31 -0
- audiometa/utils/metadata_writing_strategy.py +16 -0
- audiometa/utils/mutagen_exception_handler.py +24 -0
- audiometa/utils/os_dependencies_checker/__init__.py +24 -0
- audiometa/utils/os_dependencies_checker/base.py +62 -0
- audiometa/utils/os_dependencies_checker/config.py +77 -0
- audiometa/utils/os_dependencies_checker/macos.py +236 -0
- audiometa/utils/os_dependencies_checker/ubuntu.py +95 -0
- audiometa/utils/os_dependencies_checker/windows.py +227 -0
- audiometa/utils/rating_profiles.py +110 -0
- audiometa/utils/tool_path_resolver.py +135 -0
- audiometa/utils/types.py +82 -0
- audiometa/utils/unified_metadata_key.py +87 -0
- audiometa_python-0.6.0.dist-info/METADATA +1593 -0
- audiometa_python-0.6.0.dist-info/RECORD +352 -0
- audiometa_python-0.6.0.dist-info/WHEEL +5 -0
- audiometa_python-0.6.0.dist-info/entry_points.txt +2 -0
- audiometa_python-0.6.0.dist-info/licenses/LICENSE +202 -0
- audiometa_python-0.6.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1032 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import shutil
|
|
3
|
+
import subprocess
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING, Any, ClassVar, cast
|
|
7
|
+
|
|
8
|
+
from mutagen._file import FileType as MutagenMetadata
|
|
9
|
+
from mutagen.id3 import ID3
|
|
10
|
+
from mutagen.id3._frames import (
|
|
11
|
+
COMM,
|
|
12
|
+
POPM,
|
|
13
|
+
TALB,
|
|
14
|
+
TBPM,
|
|
15
|
+
TCOM,
|
|
16
|
+
TCON,
|
|
17
|
+
TCOP,
|
|
18
|
+
TDAT,
|
|
19
|
+
TDRC,
|
|
20
|
+
TDRL,
|
|
21
|
+
TENC,
|
|
22
|
+
TIT2,
|
|
23
|
+
TKEY,
|
|
24
|
+
TLAN,
|
|
25
|
+
TMOO,
|
|
26
|
+
TPE1,
|
|
27
|
+
TPE2,
|
|
28
|
+
TPOS,
|
|
29
|
+
TPUB,
|
|
30
|
+
TRCK,
|
|
31
|
+
TSRC,
|
|
32
|
+
TXXX,
|
|
33
|
+
TYER,
|
|
34
|
+
USLT,
|
|
35
|
+
WOAR,
|
|
36
|
+
)
|
|
37
|
+
from mutagen.id3._util import ID3NoHeaderError
|
|
38
|
+
|
|
39
|
+
from audiometa.utils.unified_metadata_key import UnifiedMetadataKey
|
|
40
|
+
|
|
41
|
+
from ....utils.tool_path_resolver import get_tool_path
|
|
42
|
+
|
|
43
|
+
if TYPE_CHECKING:
|
|
44
|
+
from ...._audio_file import _AudioFile
|
|
45
|
+
from ....exceptions import FileCorruptedError, MetadataFieldNotSupportedByMetadataFormatError
|
|
46
|
+
from ....utils.rating_profiles import RatingWriteProfile
|
|
47
|
+
from ....utils.types import RawMetadataDict, RawMetadataKey, UnifiedMetadata, UnifiedMetadataValue
|
|
48
|
+
from ..._MetadataManager import _MetadataManager as MetadataManager
|
|
49
|
+
from .._RatingSupportingMetadataManager import _RatingSupportingMetadataManager
|
|
50
|
+
from ._id3v2_constants import ID3V2_DATE_FORMAT_LENGTH, ID3V2_VERSION_3, ID3V2_VERSION_4
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _Id3v2Manager(_RatingSupportingMetadataManager):
|
|
54
|
+
"""ID3v2 metadata manager for audio files.
|
|
55
|
+
|
|
56
|
+
ID3v2 Version Compatibility Table:
|
|
57
|
+
+---------------+----------+----------+----------+
|
|
58
|
+
| Player/Device | ID3v2.2 | ID3v2.3 | ID3v2.4 |
|
|
59
|
+
+---------------+----------+----------+----------+
|
|
60
|
+
| Windows Media Player |
|
|
61
|
+
| - WMP 9-12 | ✓ | ✓ | ~ |
|
|
62
|
+
| - WMP 7-8 | ✓ | ✓ | |
|
|
63
|
+
+---------------+----------+----------+----------+
|
|
64
|
+
| iTunes |
|
|
65
|
+
| - 12.x+ | ✓ | ✓ | ✓ |
|
|
66
|
+
| - 7.x-11.x | ✓ | ✓ | ~ |
|
|
67
|
+
+---------------+----------+----------+----------+
|
|
68
|
+
| Winamp |
|
|
69
|
+
| - 5.x+ | ✓ | ✓ | ✓ |
|
|
70
|
+
| - 2.x-4.x | ✓ | ✓ | ~ |
|
|
71
|
+
+---------------+----------+----------+----------+
|
|
72
|
+
| MusicBee |
|
|
73
|
+
| - 3.x+ | ✓ | ✓ | ✓ |
|
|
74
|
+
| - 2.x | ✓ | ✓ | ~ |
|
|
75
|
+
+---------------+----------+----------+----------+
|
|
76
|
+
| VLC |
|
|
77
|
+
| - 2.x+ | ✓ | ✓ | ✓ |
|
|
78
|
+
| - 1.x | ✓ | ✓ | ~ |
|
|
79
|
+
+---------------+----------+----------+----------+
|
|
80
|
+
| Smartphones |
|
|
81
|
+
| - iOS 7+ | ✓ | ✓ | ✓ |
|
|
82
|
+
| - Android 4+ | ✓ | ✓ | ✓ |
|
|
83
|
+
| - Windows | ✓ | ✓ | ✓ |
|
|
84
|
+
| - Blackberry | ✓ | ✓ | ~ |
|
|
85
|
+
+---------------+----------+----------+----------+
|
|
86
|
+
| Network Players |
|
|
87
|
+
| - Sonos | ✓ | ✓ | ✓ |
|
|
88
|
+
| - Roku | ✓ | ✓ | ~ |
|
|
89
|
+
| - Chromecast | ✓ | ✓ | ✓ |
|
|
90
|
+
| - Apple TV | ✓ | ✓ | ✓ |
|
|
91
|
+
+---------------+----------+----------+----------+
|
|
92
|
+
|iPods/MP3 Players |
|
|
93
|
+
| - iPod 5G+ | ✓ | ✓ | ✓ |
|
|
94
|
+
| - iPod 1-4G | ✓ | ✓ | ~ |
|
|
95
|
+
| - Zune | ✓ | ✓ | ~ |
|
|
96
|
+
| - Sony | ✓ | ✓ | ~ |
|
|
97
|
+
+---------------+----------+----------+----------+
|
|
98
|
+
| Car Systems |
|
|
99
|
+
| - Post-2010 | ✓ | ✓ | ~ |
|
|
100
|
+
| - Pre-2010 | ✓ | ~ | |
|
|
101
|
+
+---------------+----------+----------+----------+
|
|
102
|
+
| Home Audio Systems |
|
|
103
|
+
| - Post-2000 | ✓ | ✓ | ~ |
|
|
104
|
+
| - Pre-2000 | ✓ | ~ | |
|
|
105
|
+
+---------------+----------+----------+----------+
|
|
106
|
+
| DJ Software |
|
|
107
|
+
| - Traktor | ✓ | ✓ | ✓ |
|
|
108
|
+
| - Serato | ✓ | ✓ | ~ |
|
|
109
|
+
| - VirtualDJ | ✓ | ✓ | ~ |
|
|
110
|
+
| - Rekordbox | ✓ | ✓ | ~ |
|
|
111
|
+
| - Mixxx | ✓ | ✓ | ~ |
|
|
112
|
+
| - Cross DJ | ✓ | ✓ | ~ |
|
|
113
|
+
| - djay Pro | ✓ | ✓ | ~ |
|
|
114
|
+
+---------------+----------+----------+----------+
|
|
115
|
+
| Web Browsers |
|
|
116
|
+
| - Chrome | ✓ | ✓ | ✓ |
|
|
117
|
+
| - Firefox | ✓ | ✓ | ✓ |
|
|
118
|
+
| - Safari | ✓ | ✓ | ✓ |
|
|
119
|
+
| - Edge | ✓ | ✓ | ✓ |
|
|
120
|
+
+---------------+----------+----------+----------+
|
|
121
|
+
| Gaming Consoles |
|
|
122
|
+
| - PS4/PS5 | ✓ | ✓ | ✓ |
|
|
123
|
+
| - Xbox Series| ✓ | ✓ | ✓ |
|
|
124
|
+
| - PS3 | ✓ | ✓ | ~ |
|
|
125
|
+
| - Xbox 360 | ✓ | ✓ | ~ |
|
|
126
|
+
+---------------+----------+----------+----------+
|
|
127
|
+
| Smart TVs |
|
|
128
|
+
| - Samsung | ✓ | ✓ | ~ |
|
|
129
|
+
| - LG | ✓ | ✓ | ~ |
|
|
130
|
+
| - Sony | ✓ | ✓ | ~ |
|
|
131
|
+
| - Android TV | ✓ | ✓ | ✓ |
|
|
132
|
+
+---------------+----------+----------+----------+
|
|
133
|
+
|
|
134
|
+
Legend:
|
|
135
|
+
✓ = Full support
|
|
136
|
+
~ = Partial support/May have issues
|
|
137
|
+
= No support
|
|
138
|
+
|
|
139
|
+
Notes:
|
|
140
|
+
- ID3v2.4 introduced UTF-8 encoding and unsync changes
|
|
141
|
+
- Older players may have issues with ID3v2.4's changes
|
|
142
|
+
- For maximum compatibility, ID3v2.3 is recommended
|
|
143
|
+
|
|
144
|
+
- ID3:
|
|
145
|
+
- Writing Policy:
|
|
146
|
+
* The app writes ID3v2 tags in the specified version (default: v2.3)
|
|
147
|
+
* When updating an existing file:
|
|
148
|
+
- Tags are upgraded to the specified version if different
|
|
149
|
+
- v2.2, v2.3, or v2.4 tags are upgraded to the specified version
|
|
150
|
+
- Frame IDs are automatically converted
|
|
151
|
+
- All text is encoded in UTF-8
|
|
152
|
+
* Reading supports all versions (v2.2, v2.3, v2.4)
|
|
153
|
+
* Only one ID3v2 version can exist in a file at a time
|
|
154
|
+
* Native format for MP3 files
|
|
155
|
+
* Version selection allows choosing between v2.3 (maximum compatibility) and v2.4 (modern features)
|
|
156
|
+
|
|
157
|
+
- ID3v1:
|
|
158
|
+
* Fixed 128-byte format at end of file
|
|
159
|
+
* ASCII only, no Unicode
|
|
160
|
+
* Limited to 30 chars for text fields
|
|
161
|
+
* Single byte for track number (v1.1 only)
|
|
162
|
+
* Genre limited to predefined codes (0-147)
|
|
163
|
+
* Legacy format
|
|
164
|
+
|
|
165
|
+
- ID3v2:
|
|
166
|
+
* v2.2:
|
|
167
|
+
- Introduced in 1998
|
|
168
|
+
- Three-character frame IDs (TT2, TP1, etc.)
|
|
169
|
+
- ISO-8859-1 or UCS-2 text encoding
|
|
170
|
+
- All standard fields supported
|
|
171
|
+
- Simpler header structure than v2.3/v2.4
|
|
172
|
+
- Basic support for embedded images
|
|
173
|
+
- Less common but equally functional
|
|
174
|
+
|
|
175
|
+
* v2.3:
|
|
176
|
+
- Introduced in 1999
|
|
177
|
+
- TYER+TDAT frames for date (year and date separately)
|
|
178
|
+
- UTF-16/UTF-16BE text encoding
|
|
179
|
+
- Basic unsynchronization
|
|
180
|
+
- All metadata fields supported
|
|
181
|
+
- Better support for embedded images and other binary data
|
|
182
|
+
- Most widely used version
|
|
183
|
+
|
|
184
|
+
* v2.4:
|
|
185
|
+
- Introduced in 2000
|
|
186
|
+
- TDRC frame for full timestamps (YYYY-MM-DD)
|
|
187
|
+
- UTF-8 text encoding
|
|
188
|
+
- Extended header features
|
|
189
|
+
- Unsynchronization per frame
|
|
190
|
+
- All metadata fields supported
|
|
191
|
+
- New frames for more detailed metadata (e.g., TDRC for recording time, TDRL for release time)
|
|
192
|
+
- Preferred version for new tags
|
|
193
|
+
|
|
194
|
+
For maximum compatibility, ID3v2.3 is used as the default version for writing metadata.
|
|
195
|
+
Users can choose ID3v2.4 for modern features if their target players support it.
|
|
196
|
+
When reading/updating an existing file, the ID3 tags will be updated to the specified version format.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
ID3_RATING_APP_EMAIL = "audiometa-python@audiometa.dev"
|
|
200
|
+
|
|
201
|
+
class Id3TextFrame(RawMetadataKey):
|
|
202
|
+
TITLE = "TIT2"
|
|
203
|
+
ARTISTS = "TPE1"
|
|
204
|
+
ALBUM = "TALB"
|
|
205
|
+
ALBUM_ARTISTS = "TPE2"
|
|
206
|
+
GENRES_NAMES = "TCON"
|
|
207
|
+
|
|
208
|
+
# In cleaned metadata, the rating is stored as a tuple the potential identifier (e.g. 'Traktor') and the rating
|
|
209
|
+
# value
|
|
210
|
+
RATING = "POPM"
|
|
211
|
+
LANGUAGE = "TLAN"
|
|
212
|
+
RECORDING_TIME = "TDRC" # ID3v2.4 recording time
|
|
213
|
+
RELEASE_TIME = "TDRL" # ID3v2.4 release time
|
|
214
|
+
YEAR = "TYER" # ID3v2.3 year
|
|
215
|
+
DATE = "TDAT" # ID3v2.3 date (DDMM)
|
|
216
|
+
TRACK_NUMBER = "TRCK"
|
|
217
|
+
DISC_NUMBER = "TPOS"
|
|
218
|
+
BPM = "TBPM"
|
|
219
|
+
|
|
220
|
+
# Additional metadata fields
|
|
221
|
+
COMPOSERS = "TCOM"
|
|
222
|
+
PUBLISHER = "TPUB"
|
|
223
|
+
COPYRIGHT = "TCOP"
|
|
224
|
+
UNSYNCHRONIZED_LYRICS = "USLT"
|
|
225
|
+
COMMENT = "COMM" # Comment frame
|
|
226
|
+
ENCODER = "TENC"
|
|
227
|
+
URL = "WOAR" # Official artist/performer webpage
|
|
228
|
+
ISRC = "TSRC"
|
|
229
|
+
MOOD = "TMOO"
|
|
230
|
+
KEY = "TKEY"
|
|
231
|
+
REPLAYGAIN = "REPLAYGAIN"
|
|
232
|
+
|
|
233
|
+
ID3_TEXT_FRAME_CLASS_MAP: ClassVar[dict[RawMetadataKey, type]] = {
|
|
234
|
+
Id3TextFrame.TITLE: TIT2,
|
|
235
|
+
Id3TextFrame.ARTISTS: TPE1,
|
|
236
|
+
Id3TextFrame.ALBUM: TALB,
|
|
237
|
+
Id3TextFrame.ALBUM_ARTISTS: TPE2,
|
|
238
|
+
Id3TextFrame.GENRES_NAMES: TCON,
|
|
239
|
+
Id3TextFrame.LANGUAGE: TLAN,
|
|
240
|
+
Id3TextFrame.RECORDING_TIME: TDRC,
|
|
241
|
+
Id3TextFrame.RELEASE_TIME: TDRL,
|
|
242
|
+
Id3TextFrame.YEAR: TYER,
|
|
243
|
+
Id3TextFrame.DATE: TDAT,
|
|
244
|
+
Id3TextFrame.TRACK_NUMBER: TRCK,
|
|
245
|
+
Id3TextFrame.DISC_NUMBER: TPOS,
|
|
246
|
+
Id3TextFrame.BPM: TBPM,
|
|
247
|
+
Id3TextFrame.RATING: POPM,
|
|
248
|
+
Id3TextFrame.COMPOSERS: TCOM,
|
|
249
|
+
Id3TextFrame.PUBLISHER: TPUB,
|
|
250
|
+
Id3TextFrame.COPYRIGHT: TCOP,
|
|
251
|
+
Id3TextFrame.UNSYNCHRONIZED_LYRICS: USLT,
|
|
252
|
+
Id3TextFrame.COMMENT: COMM,
|
|
253
|
+
Id3TextFrame.ENCODER: TENC,
|
|
254
|
+
Id3TextFrame.URL: WOAR,
|
|
255
|
+
Id3TextFrame.ISRC: TSRC,
|
|
256
|
+
Id3TextFrame.MOOD: TMOO,
|
|
257
|
+
Id3TextFrame.KEY: TKEY,
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
def __init__(
|
|
261
|
+
self,
|
|
262
|
+
audio_file: "_AudioFile",
|
|
263
|
+
normalized_rating_max_value: int | None = None,
|
|
264
|
+
id3v2_version: tuple[int, int, int] = (2, 3, 0),
|
|
265
|
+
):
|
|
266
|
+
self.id3v2_version = id3v2_version
|
|
267
|
+
metadata_keys_direct_map_read = {
|
|
268
|
+
UnifiedMetadataKey.TITLE: self.Id3TextFrame.TITLE,
|
|
269
|
+
UnifiedMetadataKey.ARTISTS: self.Id3TextFrame.ARTISTS,
|
|
270
|
+
UnifiedMetadataKey.ALBUM: self.Id3TextFrame.ALBUM,
|
|
271
|
+
UnifiedMetadataKey.ALBUM_ARTISTS: self.Id3TextFrame.ALBUM_ARTISTS,
|
|
272
|
+
UnifiedMetadataKey.GENRES_NAMES: self.Id3TextFrame.GENRES_NAMES,
|
|
273
|
+
UnifiedMetadataKey.RATING: None,
|
|
274
|
+
UnifiedMetadataKey.LANGUAGE: self.Id3TextFrame.LANGUAGE,
|
|
275
|
+
UnifiedMetadataKey.RELEASE_DATE: self.Id3TextFrame.RECORDING_TIME,
|
|
276
|
+
UnifiedMetadataKey.TRACK_NUMBER: self.Id3TextFrame.TRACK_NUMBER,
|
|
277
|
+
UnifiedMetadataKey.DISC_NUMBER: None,
|
|
278
|
+
UnifiedMetadataKey.DISC_TOTAL: None,
|
|
279
|
+
UnifiedMetadataKey.BPM: self.Id3TextFrame.BPM,
|
|
280
|
+
UnifiedMetadataKey.COMPOSERS: self.Id3TextFrame.COMPOSERS,
|
|
281
|
+
UnifiedMetadataKey.PUBLISHER: self.Id3TextFrame.PUBLISHER,
|
|
282
|
+
UnifiedMetadataKey.COPYRIGHT: self.Id3TextFrame.COPYRIGHT,
|
|
283
|
+
UnifiedMetadataKey.UNSYNCHRONIZED_LYRICS: self.Id3TextFrame.UNSYNCHRONIZED_LYRICS,
|
|
284
|
+
UnifiedMetadataKey.COMMENT: self.Id3TextFrame.COMMENT,
|
|
285
|
+
UnifiedMetadataKey.REPLAYGAIN: None,
|
|
286
|
+
UnifiedMetadataKey.ISRC: self.Id3TextFrame.ISRC,
|
|
287
|
+
}
|
|
288
|
+
metadata_keys_direct_map_write: dict[UnifiedMetadataKey, RawMetadataKey | None] = {
|
|
289
|
+
UnifiedMetadataKey.TITLE: self.Id3TextFrame.TITLE,
|
|
290
|
+
UnifiedMetadataKey.ARTISTS: self.Id3TextFrame.ARTISTS,
|
|
291
|
+
UnifiedMetadataKey.ALBUM: self.Id3TextFrame.ALBUM,
|
|
292
|
+
UnifiedMetadataKey.ALBUM_ARTISTS: self.Id3TextFrame.ALBUM_ARTISTS,
|
|
293
|
+
UnifiedMetadataKey.GENRES_NAMES: self.Id3TextFrame.GENRES_NAMES,
|
|
294
|
+
UnifiedMetadataKey.RATING: self.Id3TextFrame.RATING,
|
|
295
|
+
UnifiedMetadataKey.LANGUAGE: self.Id3TextFrame.LANGUAGE,
|
|
296
|
+
UnifiedMetadataKey.RELEASE_DATE: self.Id3TextFrame.RECORDING_TIME,
|
|
297
|
+
UnifiedMetadataKey.TRACK_NUMBER: self.Id3TextFrame.TRACK_NUMBER,
|
|
298
|
+
UnifiedMetadataKey.DISC_NUMBER: None,
|
|
299
|
+
UnifiedMetadataKey.DISC_TOTAL: None,
|
|
300
|
+
UnifiedMetadataKey.BPM: self.Id3TextFrame.BPM,
|
|
301
|
+
UnifiedMetadataKey.COMPOSERS: self.Id3TextFrame.COMPOSERS,
|
|
302
|
+
UnifiedMetadataKey.PUBLISHER: self.Id3TextFrame.PUBLISHER,
|
|
303
|
+
UnifiedMetadataKey.COPYRIGHT: self.Id3TextFrame.COPYRIGHT,
|
|
304
|
+
UnifiedMetadataKey.UNSYNCHRONIZED_LYRICS: self.Id3TextFrame.UNSYNCHRONIZED_LYRICS,
|
|
305
|
+
UnifiedMetadataKey.COMMENT: self.Id3TextFrame.COMMENT,
|
|
306
|
+
UnifiedMetadataKey.REPLAYGAIN: None,
|
|
307
|
+
UnifiedMetadataKey.ISRC: self.Id3TextFrame.ISRC,
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
super().__init__(
|
|
311
|
+
audio_file=audio_file,
|
|
312
|
+
metadata_keys_direct_map_read=cast(
|
|
313
|
+
dict[UnifiedMetadataKey, RawMetadataKey | None], metadata_keys_direct_map_read
|
|
314
|
+
),
|
|
315
|
+
metadata_keys_direct_map_write=metadata_keys_direct_map_write,
|
|
316
|
+
rating_write_profile=RatingWriteProfile.BASE_255_NON_PROPORTIONAL,
|
|
317
|
+
normalized_rating_max_value=normalized_rating_max_value,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
def _extract_mutagen_metadata(self) -> RawMetadataDict:
|
|
321
|
+
try:
|
|
322
|
+
id3 = ID3(self.audio_file.file_path, load_v1=False, translate=False)
|
|
323
|
+
|
|
324
|
+
# Upgrade to specified version if different
|
|
325
|
+
if id3.version != self.id3v2_version:
|
|
326
|
+
id3.version = self.id3v2_version
|
|
327
|
+
|
|
328
|
+
return cast(RawMetadataDict, id3)
|
|
329
|
+
except ID3NoHeaderError:
|
|
330
|
+
try:
|
|
331
|
+
id3 = ID3(self.audio_file.file_path, load_v1=True, translate=False)
|
|
332
|
+
id3.clear() # Exclude ID3v1 tags
|
|
333
|
+
id3.version = self.id3v2_version
|
|
334
|
+
return cast(RawMetadataDict, id3)
|
|
335
|
+
except ID3NoHeaderError:
|
|
336
|
+
# Create empty ID3 object - will be saved during write operations
|
|
337
|
+
# This allows write operations to work with files that have no ID3v2 header
|
|
338
|
+
id3 = ID3()
|
|
339
|
+
id3.version = self.id3v2_version
|
|
340
|
+
return cast(RawMetadataDict, id3)
|
|
341
|
+
|
|
342
|
+
def _convert_raw_mutagen_metadata_to_dict_with_potential_duplicate_keys(
|
|
343
|
+
self, raw_mutagen_metadata: MutagenMetadata
|
|
344
|
+
) -> RawMetadataDict:
|
|
345
|
+
raw_metadata_id3: ID3 = cast(ID3, raw_mutagen_metadata)
|
|
346
|
+
result: RawMetadataDict = {}
|
|
347
|
+
|
|
348
|
+
for frame_key in self.Id3TextFrame.__members__.values():
|
|
349
|
+
if frame_key == self.Id3TextFrame.RATING:
|
|
350
|
+
for raw_mutagen_frame in raw_mutagen_metadata.items():
|
|
351
|
+
popm_key = raw_mutagen_frame[0]
|
|
352
|
+
if popm_key.startswith(self.Id3TextFrame.RATING):
|
|
353
|
+
popm: POPM = raw_mutagen_frame[1]
|
|
354
|
+
popm_key_without_prefixes = popm_key.replace(f"{self.Id3TextFrame.RATING}:", "")
|
|
355
|
+
result[self.Id3TextFrame.RATING] = [
|
|
356
|
+
popm_key_without_prefixes,
|
|
357
|
+
getattr(popm, "rating", 0),
|
|
358
|
+
]
|
|
359
|
+
break
|
|
360
|
+
elif frame_key == self.Id3TextFrame.COMMENT:
|
|
361
|
+
# Handle COMM frames (comment frames)
|
|
362
|
+
for raw_mutagen_frame in raw_mutagen_metadata.items():
|
|
363
|
+
if raw_mutagen_frame[0].startswith("COMM"):
|
|
364
|
+
comm_frame = raw_mutagen_frame[1]
|
|
365
|
+
result[frame_key] = comm_frame.text
|
|
366
|
+
break
|
|
367
|
+
elif frame_key == self.Id3TextFrame.UNSYNCHRONIZED_LYRICS:
|
|
368
|
+
# Handle USLT frames (unsynchronized lyrics frames)
|
|
369
|
+
for raw_mutagen_frame in raw_mutagen_metadata.items():
|
|
370
|
+
if raw_mutagen_frame[0].startswith("USLT"):
|
|
371
|
+
uslt_frame = raw_mutagen_frame[1]
|
|
372
|
+
result[frame_key] = [uslt_frame.text]
|
|
373
|
+
break
|
|
374
|
+
elif frame_key == self.Id3TextFrame.URL:
|
|
375
|
+
# Handle WOAR frames (official artist/performer webpage)
|
|
376
|
+
for raw_mutagen_frame in raw_mutagen_metadata.items():
|
|
377
|
+
if raw_mutagen_frame[0].startswith("WOAR"):
|
|
378
|
+
woar_frame = raw_mutagen_frame[1]
|
|
379
|
+
result[frame_key] = [woar_frame.url]
|
|
380
|
+
break
|
|
381
|
+
else:
|
|
382
|
+
frame_value = frame_key in raw_metadata_id3 and raw_metadata_id3[frame_key]
|
|
383
|
+
if not frame_value:
|
|
384
|
+
continue
|
|
385
|
+
|
|
386
|
+
if not frame_value.text:
|
|
387
|
+
continue
|
|
388
|
+
|
|
389
|
+
result[frame_key] = frame_value.text
|
|
390
|
+
|
|
391
|
+
# Handle TXXX frames for REPLAYGAIN
|
|
392
|
+
for raw_mutagen_frame in raw_mutagen_metadata.items():
|
|
393
|
+
if raw_mutagen_frame[0].startswith("TXXX"):
|
|
394
|
+
txxx_frame = raw_mutagen_frame[1]
|
|
395
|
+
if hasattr(txxx_frame, "desc") and txxx_frame.desc == "REPLAYGAIN":
|
|
396
|
+
result[self.Id3TextFrame.REPLAYGAIN] = txxx_frame.text
|
|
397
|
+
break
|
|
398
|
+
|
|
399
|
+
# Special handling for release date: if TDRC is not present, try to construct from TYER + TDAT
|
|
400
|
+
# Only do this for ID3v2 files (not ID3v1) and only when both TYER and TDAT are present
|
|
401
|
+
if self.Id3TextFrame.RECORDING_TIME not in result:
|
|
402
|
+
year_key: RawMetadataKey = self.Id3TextFrame.YEAR
|
|
403
|
+
date_key: RawMetadataKey = self.Id3TextFrame.DATE
|
|
404
|
+
tyer_value = result.get(year_key, None)
|
|
405
|
+
tdat_value = result.get(date_key, None)
|
|
406
|
+
if tyer_value and tdat_value:
|
|
407
|
+
# Parse TDAT (DDMM) and TYER to construct YYYY-MM-DD
|
|
408
|
+
try:
|
|
409
|
+
year = str(tyer_value[0]) if isinstance(tyer_value, list) else str(tyer_value)
|
|
410
|
+
date_str = str(tdat_value[0]) if isinstance(tdat_value, list) else str(tdat_value)
|
|
411
|
+
if len(date_str) == ID3V2_DATE_FORMAT_LENGTH: # DDMM format
|
|
412
|
+
day = date_str[:2]
|
|
413
|
+
month = date_str[2:]
|
|
414
|
+
# Construct YYYY-MM-DD
|
|
415
|
+
release_date = f"{year}-{month}-{day}"
|
|
416
|
+
result[self.Id3TextFrame.RECORDING_TIME] = [release_date]
|
|
417
|
+
except (IndexError, ValueError):
|
|
418
|
+
pass # If parsing fails, don't add release date
|
|
419
|
+
|
|
420
|
+
return result
|
|
421
|
+
|
|
422
|
+
def _get_raw_rating_by_traktor_or_not(self, raw_clean_metadata: RawMetadataDict) -> tuple[int | None, bool]:
|
|
423
|
+
for raw_metadata_key, raw_metadata_values in raw_clean_metadata.items():
|
|
424
|
+
if raw_metadata_values and len(raw_metadata_values) > 0 and raw_metadata_key == self.Id3TextFrame.RATING:
|
|
425
|
+
first_popm = cast(list, raw_metadata_values)
|
|
426
|
+
first_popm_identifier = first_popm[0]
|
|
427
|
+
first_popm_rating = first_popm[1]
|
|
428
|
+
if first_popm_identifier.find("Traktor") != -1:
|
|
429
|
+
return int(first_popm_rating), True
|
|
430
|
+
return int(first_popm_rating), False
|
|
431
|
+
|
|
432
|
+
return None, False
|
|
433
|
+
|
|
434
|
+
def _update_undirectly_mapped_metadata(
|
|
435
|
+
self,
|
|
436
|
+
raw_mutagen_metadata: ID3,
|
|
437
|
+
app_metadata_value: UnifiedMetadataValue,
|
|
438
|
+
unified_metadata_key: UnifiedMetadataKey,
|
|
439
|
+
) -> None:
|
|
440
|
+
if unified_metadata_key == UnifiedMetadataKey.REPLAYGAIN:
|
|
441
|
+
# Remove existing TXXX:REPLAYGAIN frames
|
|
442
|
+
raw_mutagen_metadata.delall("TXXX:REPLAYGAIN")
|
|
443
|
+
if app_metadata_value is not None:
|
|
444
|
+
# Add new TXXX frame with desc 'REPLAYGAIN'
|
|
445
|
+
raw_mutagen_metadata.add(TXXX(encoding=3, desc="REPLAYGAIN", text=str(app_metadata_value)))
|
|
446
|
+
elif unified_metadata_key in (UnifiedMetadataKey.DISC_NUMBER, UnifiedMetadataKey.DISC_TOTAL):
|
|
447
|
+
tpos_key = self.Id3TextFrame.DISC_NUMBER
|
|
448
|
+
tpos_frame_class = TPOS
|
|
449
|
+
encoding = 0 if self.id3v2_version[1] == ID3V2_VERSION_3 else 3
|
|
450
|
+
|
|
451
|
+
if unified_metadata_key == UnifiedMetadataKey.DISC_NUMBER:
|
|
452
|
+
current_tpos = raw_mutagen_metadata.get(tpos_key)
|
|
453
|
+
current_total = None
|
|
454
|
+
if current_tpos and len(current_tpos.text) > 0:
|
|
455
|
+
tpos_str = str(current_tpos.text[0])
|
|
456
|
+
import re
|
|
457
|
+
|
|
458
|
+
match = re.match(r"^(\d+)/(\d+)$", tpos_str)
|
|
459
|
+
if match:
|
|
460
|
+
current_total = int(match.group(2))
|
|
461
|
+
|
|
462
|
+
raw_mutagen_metadata.delall(tpos_key)
|
|
463
|
+
if app_metadata_value is not None:
|
|
464
|
+
if not isinstance(app_metadata_value, int):
|
|
465
|
+
msg = f"DISC_NUMBER must be an integer, got {type(app_metadata_value).__name__}"
|
|
466
|
+
raise TypeError(msg)
|
|
467
|
+
disc_number = min(255, max(0, app_metadata_value))
|
|
468
|
+
tpos_value = f"{disc_number}/{current_total}" if current_total is not None else str(disc_number)
|
|
469
|
+
raw_mutagen_metadata.add(tpos_frame_class(encoding=encoding, text=tpos_value))
|
|
470
|
+
elif unified_metadata_key == UnifiedMetadataKey.DISC_TOTAL:
|
|
471
|
+
current_tpos = raw_mutagen_metadata.get(tpos_key)
|
|
472
|
+
current_disc_number = None
|
|
473
|
+
if current_tpos and len(current_tpos.text) > 0:
|
|
474
|
+
tpos_str = str(current_tpos.text[0])
|
|
475
|
+
import re
|
|
476
|
+
|
|
477
|
+
match = re.match(r"^(\d+)(?:/(\d+))?$", tpos_str)
|
|
478
|
+
if match:
|
|
479
|
+
current_disc_number = int(match.group(1))
|
|
480
|
+
|
|
481
|
+
raw_mutagen_metadata.delall(tpos_key)
|
|
482
|
+
if app_metadata_value is not None:
|
|
483
|
+
if not isinstance(app_metadata_value, int):
|
|
484
|
+
msg = f"DISC_TOTAL must be an integer, got {type(app_metadata_value).__name__}"
|
|
485
|
+
raise TypeError(msg)
|
|
486
|
+
disc_total = min(255, max(0, app_metadata_value))
|
|
487
|
+
if current_disc_number is not None:
|
|
488
|
+
tpos_value = f"{current_disc_number}/{disc_total}"
|
|
489
|
+
raw_mutagen_metadata.add(tpos_frame_class(encoding=encoding, text=tpos_value))
|
|
490
|
+
else:
|
|
491
|
+
msg = "Cannot set DISC_TOTAL without DISC_NUMBER"
|
|
492
|
+
raise ValueError(msg)
|
|
493
|
+
elif current_disc_number is not None:
|
|
494
|
+
tpos_value = str(current_disc_number)
|
|
495
|
+
raw_mutagen_metadata.add(tpos_frame_class(encoding=encoding, text=tpos_value))
|
|
496
|
+
else:
|
|
497
|
+
super()._update_undirectly_mapped_metadata( # type: ignore[safe-super]
|
|
498
|
+
cast(Any, raw_mutagen_metadata), app_metadata_value, unified_metadata_key
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
def _get_undirectly_mapped_metadata_value_other_than_rating_from_raw_clean_metadata(
|
|
502
|
+
self, raw_clean_metadata: RawMetadataDict, unified_metadata_key: UnifiedMetadataKey
|
|
503
|
+
) -> UnifiedMetadataValue:
|
|
504
|
+
if unified_metadata_key == UnifiedMetadataKey.REPLAYGAIN:
|
|
505
|
+
replaygain_key = self.Id3TextFrame.REPLAYGAIN
|
|
506
|
+
if replaygain_key not in raw_clean_metadata:
|
|
507
|
+
return None
|
|
508
|
+
replaygain_value = raw_clean_metadata[replaygain_key]
|
|
509
|
+
if replaygain_value is None:
|
|
510
|
+
return None
|
|
511
|
+
if len(replaygain_value) == 0:
|
|
512
|
+
return None
|
|
513
|
+
first_value = replaygain_value[0]
|
|
514
|
+
return cast(UnifiedMetadataValue, first_value)
|
|
515
|
+
if unified_metadata_key == UnifiedMetadataKey.DISC_NUMBER:
|
|
516
|
+
tpos_key = self.Id3TextFrame.DISC_NUMBER
|
|
517
|
+
if tpos_key not in raw_clean_metadata:
|
|
518
|
+
return None
|
|
519
|
+
tpos_value = raw_clean_metadata[tpos_key]
|
|
520
|
+
if tpos_value is None or len(tpos_value) == 0:
|
|
521
|
+
return None
|
|
522
|
+
tpos_str = str(tpos_value[0])
|
|
523
|
+
import re
|
|
524
|
+
|
|
525
|
+
match = re.match(r"^(\d+)(?:/(\d+))?$", tpos_str)
|
|
526
|
+
if match:
|
|
527
|
+
return int(match.group(1))
|
|
528
|
+
return None
|
|
529
|
+
if unified_metadata_key == UnifiedMetadataKey.DISC_TOTAL:
|
|
530
|
+
tpos_key = self.Id3TextFrame.DISC_NUMBER
|
|
531
|
+
if tpos_key not in raw_clean_metadata:
|
|
532
|
+
return None
|
|
533
|
+
tpos_value = raw_clean_metadata[tpos_key]
|
|
534
|
+
if tpos_value is None or len(tpos_value) == 0:
|
|
535
|
+
return None
|
|
536
|
+
tpos_str = str(tpos_value[0])
|
|
537
|
+
import re
|
|
538
|
+
|
|
539
|
+
match = re.match(r"^(\d+)/(\d+)$", tpos_str)
|
|
540
|
+
if match:
|
|
541
|
+
return int(match.group(2))
|
|
542
|
+
return None
|
|
543
|
+
msg = f"Metadata key not handled: {unified_metadata_key}"
|
|
544
|
+
raise MetadataFieldNotSupportedByMetadataFormatError(msg)
|
|
545
|
+
|
|
546
|
+
def _update_formatted_value_in_raw_mutagen_metadata(
|
|
547
|
+
self,
|
|
548
|
+
raw_mutagen_metadata: ID3,
|
|
549
|
+
raw_metadata_key: RawMetadataKey,
|
|
550
|
+
app_metadata_value: UnifiedMetadataValue,
|
|
551
|
+
) -> None:
|
|
552
|
+
raw_mutagen_metadata_id3: ID3 = raw_mutagen_metadata
|
|
553
|
+
raw_mutagen_metadata_id3.delall(raw_metadata_key)
|
|
554
|
+
|
|
555
|
+
# If value is None, don't add any frames (field is removed)
|
|
556
|
+
if app_metadata_value is None:
|
|
557
|
+
return
|
|
558
|
+
|
|
559
|
+
# Defensive check: if list contains None values, filter them out (should not happen after base class filtering)
|
|
560
|
+
if isinstance(app_metadata_value, list):
|
|
561
|
+
app_metadata_value = [v for v in app_metadata_value if v is not None and v != ""]
|
|
562
|
+
if not app_metadata_value:
|
|
563
|
+
return
|
|
564
|
+
|
|
565
|
+
# Handle multiple values by creating separate frames for multi-value fields
|
|
566
|
+
if isinstance(app_metadata_value, list) and all(isinstance(item, str) for item in app_metadata_value):
|
|
567
|
+
# Get the corresponding UnifiedMetadataKey
|
|
568
|
+
unified_metadata_key = None
|
|
569
|
+
if self.metadata_keys_direct_map_write is None:
|
|
570
|
+
return
|
|
571
|
+
for key, raw_key in self.metadata_keys_direct_map_write.items():
|
|
572
|
+
if raw_key == raw_metadata_key:
|
|
573
|
+
unified_metadata_key = key
|
|
574
|
+
break
|
|
575
|
+
|
|
576
|
+
if unified_metadata_key and unified_metadata_key.can_semantically_have_multiple_values():
|
|
577
|
+
# Check ID3v2 version to determine handling
|
|
578
|
+
# Use self.id3v2_version instead of trying to get it from the mutagen object
|
|
579
|
+
# as the object might not have the version set yet during writing
|
|
580
|
+
id3v2_version = self.id3v2_version
|
|
581
|
+
|
|
582
|
+
# ID3v2.4 supports multi-value text frames (single frame with null-separated values per spec)
|
|
583
|
+
if id3v2_version[1] >= ID3V2_VERSION_4:
|
|
584
|
+
# Create single frame with multiple text values (ID3v2.4 spec: null-separated values in one frame)
|
|
585
|
+
# Officially supported fields: TPE1 (artists), TPE2 (album artists), TCOM (composers), TCON (genres)
|
|
586
|
+
text_frame_class = self.ID3_TEXT_FRAME_CLASS_MAP[raw_metadata_key]
|
|
587
|
+
# Values are already filtered at the base level
|
|
588
|
+
if app_metadata_value:
|
|
589
|
+
self._add_id3_frame_v24_multi(raw_mutagen_metadata_id3, text_frame_class, app_metadata_value)
|
|
590
|
+
return
|
|
591
|
+
|
|
592
|
+
# For ID3v2.3, use concatenation with separators (ID3v2.3 doesn't support null-separated values)
|
|
593
|
+
# Find a separator that doesn't appear in any of the values and concatenate
|
|
594
|
+
separator = MetadataManager.find_safe_separator(app_metadata_value)
|
|
595
|
+
app_metadata_value = separator.join(app_metadata_value)
|
|
596
|
+
# Continue to handle as single value
|
|
597
|
+
else:
|
|
598
|
+
# For non-multi-value fields, concatenate with separators as fallback
|
|
599
|
+
# Find a separator that doesn't appear in any of the values and concatenate
|
|
600
|
+
separator = MetadataManager.find_safe_separator(app_metadata_value)
|
|
601
|
+
app_metadata_value = separator.join(app_metadata_value)
|
|
602
|
+
|
|
603
|
+
# Handle single values
|
|
604
|
+
text_frame_class = self.ID3_TEXT_FRAME_CLASS_MAP[raw_metadata_key]
|
|
605
|
+
self._add_id3_frame(raw_mutagen_metadata_id3, text_frame_class, raw_metadata_key, app_metadata_value)
|
|
606
|
+
|
|
607
|
+
def _add_id3_frame(
|
|
608
|
+
self,
|
|
609
|
+
raw_mutagen_metadata_id3: ID3,
|
|
610
|
+
text_frame_class: type[Any],
|
|
611
|
+
raw_metadata_key: RawMetadataKey,
|
|
612
|
+
app_metadata_value: UnifiedMetadataValue,
|
|
613
|
+
) -> None:
|
|
614
|
+
"""Add a single ID3 frame with proper encoding and format handling."""
|
|
615
|
+
# Determine encoding based on ID3v2 version
|
|
616
|
+
encoding = 0 if self.id3v2_version[1] == ID3V2_VERSION_3 else 3
|
|
617
|
+
|
|
618
|
+
if raw_metadata_key == self.Id3TextFrame.RATING:
|
|
619
|
+
raw_mutagen_metadata_id3.add(text_frame_class(email=self.ID3_RATING_APP_EMAIL, rating=app_metadata_value))
|
|
620
|
+
elif raw_metadata_key == self.Id3TextFrame.COMMENT:
|
|
621
|
+
# Handle COMM frames (comment frames)
|
|
622
|
+
raw_mutagen_metadata_id3.add(
|
|
623
|
+
text_frame_class(encoding=encoding, lang="eng", desc="", text=app_metadata_value)
|
|
624
|
+
)
|
|
625
|
+
elif raw_metadata_key == self.Id3TextFrame.UNSYNCHRONIZED_LYRICS:
|
|
626
|
+
# Handle USLT frames (unsynchronized lyrics frames)
|
|
627
|
+
raw_mutagen_metadata_id3.add(
|
|
628
|
+
text_frame_class(encoding=encoding, lang="eng", desc="", text=app_metadata_value)
|
|
629
|
+
)
|
|
630
|
+
elif raw_metadata_key == self.Id3TextFrame.URL:
|
|
631
|
+
# Handle WOAR frames (official artist/performer webpage)
|
|
632
|
+
raw_mutagen_metadata_id3.add(text_frame_class(url=app_metadata_value))
|
|
633
|
+
elif raw_metadata_key == self.Id3TextFrame.BPM:
|
|
634
|
+
# Handle TBPM frames (BPM must be a string)
|
|
635
|
+
raw_mutagen_metadata_id3.add(text_frame_class(encoding=encoding, text=str(app_metadata_value)))
|
|
636
|
+
elif raw_metadata_key == self.Id3TextFrame.TRACK_NUMBER:
|
|
637
|
+
# Handle TRCK frames (track number must be a string)
|
|
638
|
+
raw_mutagen_metadata_id3.add(text_frame_class(encoding=encoding, text=str(app_metadata_value)))
|
|
639
|
+
else:
|
|
640
|
+
raw_mutagen_metadata_id3.add(text_frame_class(encoding=encoding, text=app_metadata_value))
|
|
641
|
+
|
|
642
|
+
def _add_id3_frame_v24_multi(
|
|
643
|
+
self, raw_mutagen_metadata_id3: ID3, text_frame_class: type[Any], values: list[str]
|
|
644
|
+
) -> None:
|
|
645
|
+
"""ID3v2.4: add a single text frame containing multiple null-separated values.
|
|
646
|
+
|
|
647
|
+
Mutagen accepts a list for the `text` parameter and will write it as
|
|
648
|
+
null-separated strings in a single frame which matches the ID3v2.4 spec.
|
|
649
|
+
"""
|
|
650
|
+
# Add one frame with multiple text values (mutagen handles null separation)
|
|
651
|
+
raw_mutagen_metadata_id3.add(text_frame_class(encoding=3, text=values))
|
|
652
|
+
|
|
653
|
+
def _preserve_id3v1_metadata(self, file_path: str) -> bytes | None:
|
|
654
|
+
"""Read and preserve existing ID3v1 metadata from the end of the file.
|
|
655
|
+
|
|
656
|
+
Returns:
|
|
657
|
+
The 128-byte ID3v1 tag data if present, None otherwise
|
|
658
|
+
"""
|
|
659
|
+
with Path(file_path).open("rb") as f:
|
|
660
|
+
f.seek(-128, 2) # Seek to last 128 bytes
|
|
661
|
+
data = f.read(128)
|
|
662
|
+
if data.startswith(b"TAG"):
|
|
663
|
+
return data
|
|
664
|
+
return None
|
|
665
|
+
|
|
666
|
+
def _save_with_id3v1_preservation(self, file_path: str, id3v1_data: bytes | None) -> None:
|
|
667
|
+
"""Save ID3v2 metadata while preserving ID3v1 data.
|
|
668
|
+
|
|
669
|
+
Args:
|
|
670
|
+
file_path: Path to the audio file
|
|
671
|
+
id3v1_data: The 128-byte ID3v1 tag data to preserve, or None
|
|
672
|
+
"""
|
|
673
|
+
if self.raw_mutagen_metadata is not None:
|
|
674
|
+
# Extract the major version number from the tuple (2, 3, 0) -> 3
|
|
675
|
+
version_major = self.id3v2_version[1]
|
|
676
|
+
id3_metadata: ID3 = cast(ID3, self.raw_mutagen_metadata)
|
|
677
|
+
|
|
678
|
+
if id3v1_data:
|
|
679
|
+
# Save to a temporary file first
|
|
680
|
+
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as temp_file:
|
|
681
|
+
temp_path = temp_file.name
|
|
682
|
+
|
|
683
|
+
try:
|
|
684
|
+
# Copy the original file to temp file first
|
|
685
|
+
shutil.copy2(file_path, temp_path)
|
|
686
|
+
|
|
687
|
+
# Save ID3v2 to temp file (this will overwrite ID3v2 tags in the copy)
|
|
688
|
+
id3_metadata.save(temp_path, v2_version=version_major)
|
|
689
|
+
|
|
690
|
+
# Read the temp file and append ID3v1 data
|
|
691
|
+
with Path(temp_path).open("rb") as f:
|
|
692
|
+
temp_data = f.read()
|
|
693
|
+
|
|
694
|
+
# Append ID3v1 data to the temp file
|
|
695
|
+
final_data = temp_data + id3v1_data
|
|
696
|
+
|
|
697
|
+
# Write the final file
|
|
698
|
+
with Path(file_path).open("wb") as f:
|
|
699
|
+
f.write(final_data)
|
|
700
|
+
|
|
701
|
+
finally:
|
|
702
|
+
# Clean up temp file
|
|
703
|
+
with contextlib.suppress(OSError):
|
|
704
|
+
Path(temp_path).unlink()
|
|
705
|
+
else:
|
|
706
|
+
# No ID3v1 data to preserve, save normally
|
|
707
|
+
id3_metadata.save(file_path, v2_version=version_major)
|
|
708
|
+
|
|
709
|
+
def _save_with_version(self, file_path: str) -> None:
|
|
710
|
+
"""Save ID3 tags with the specified version, preserving existing ID3v1 metadata."""
|
|
711
|
+
if self.raw_mutagen_metadata is not None:
|
|
712
|
+
# Preserve existing ID3v1 metadata before saving ID3v2
|
|
713
|
+
id3v1_data = self._preserve_id3v1_metadata(file_path)
|
|
714
|
+
|
|
715
|
+
# Save ID3v2 while preserving ID3v1
|
|
716
|
+
self._save_with_id3v1_preservation(file_path, id3v1_data)
|
|
717
|
+
|
|
718
|
+
def update_metadata(self, unified_metadata: UnifiedMetadata) -> None:
|
|
719
|
+
"""Update ID3v2 metadata using hybrid approach: mutagen for most formats, external tools for FLAC.
|
|
720
|
+
|
|
721
|
+
This method automatically chooses the appropriate tool based on the audio file format
|
|
722
|
+
to ensure optimal performance and file integrity.
|
|
723
|
+
|
|
724
|
+
Format-Specific Behavior:
|
|
725
|
+
- **MP3 and other formats**: Uses mutagen (Python library) for fast, reliable updates
|
|
726
|
+
- **FLAC files**: Uses external tools (id3v2/mid3v2) to prevent file corruption
|
|
727
|
+
|
|
728
|
+
Why External Tools for FLAC?
|
|
729
|
+
- Mutagen's ID3 class corrupts FLAC file structure when writing ID3v2 tags
|
|
730
|
+
- External tools properly handle FLAC's metadata block structure
|
|
731
|
+
- Prevents "Not a valid FLAC file" errors and file corruption
|
|
732
|
+
|
|
733
|
+
Tool Selection Logic:
|
|
734
|
+
- **ID3v2.3**: Uses 'id3v2' command-line tool
|
|
735
|
+
- **ID3v2.4**: Uses 'mid3v2' command-line tool
|
|
736
|
+
- **Other formats**: Uses mutagen for optimal performance
|
|
737
|
+
|
|
738
|
+
Key Features:
|
|
739
|
+
- **Version Control**: Maintains specified ID3v2 version (2.3 or 2.4)
|
|
740
|
+
- **ID3v1 Preservation**: Preserves existing ID3v1 tags when present
|
|
741
|
+
- **File Integrity**: Prevents corruption across all supported formats
|
|
742
|
+
|
|
743
|
+
External Tool Requirements (FLAC only):
|
|
744
|
+
- Requires 'id3v2' or 'mid3v2' command-line tools
|
|
745
|
+
- Falls back to FileCorruptedError if tools are not available
|
|
746
|
+
|
|
747
|
+
Args:
|
|
748
|
+
unified_metadata: Dictionary of metadata to write/update
|
|
749
|
+
Use None values to delete specific fields
|
|
750
|
+
|
|
751
|
+
Raises:
|
|
752
|
+
MetadataFieldNotSupportedByMetadataFormatError: If field not supported
|
|
753
|
+
FileCorruptedError: If external tools fail or are not found (FLAC only)
|
|
754
|
+
ConfigurationError: If rating configuration is invalid
|
|
755
|
+
"""
|
|
756
|
+
# For FLAC files, use external tools instead of mutagen to avoid file corruption
|
|
757
|
+
if self.audio_file.file_extension == ".flac":
|
|
758
|
+
self._update_metadata_for_flac(unified_metadata)
|
|
759
|
+
return
|
|
760
|
+
|
|
761
|
+
if not self.metadata_keys_direct_map_write:
|
|
762
|
+
msg = "This format does not support metadata modification"
|
|
763
|
+
raise MetadataFieldNotSupportedByMetadataFormatError(msg)
|
|
764
|
+
|
|
765
|
+
self._validate_and_process_rating(unified_metadata)
|
|
766
|
+
|
|
767
|
+
# Preserve ID3v1 metadata before any modifications
|
|
768
|
+
id3v1_data = self._preserve_id3v1_metadata(self.audio_file.file_path)
|
|
769
|
+
|
|
770
|
+
# Update the raw mutagen metadata (without saving yet)
|
|
771
|
+
if self.raw_mutagen_metadata is None:
|
|
772
|
+
self.raw_mutagen_metadata = cast(MutagenMetadata, self._extract_mutagen_metadata())
|
|
773
|
+
|
|
774
|
+
id3_metadata: ID3 = cast(ID3, self.raw_mutagen_metadata)
|
|
775
|
+
|
|
776
|
+
for unified_metadata_key in list(unified_metadata.keys()):
|
|
777
|
+
app_metadata_value = unified_metadata[unified_metadata_key]
|
|
778
|
+
if unified_metadata_key not in self.metadata_keys_direct_map_write:
|
|
779
|
+
metadata_format_name = self._get_formatted_metadata_format_name()
|
|
780
|
+
msg = f"{unified_metadata_key} metadata not supported by {metadata_format_name} format"
|
|
781
|
+
raise MetadataFieldNotSupportedByMetadataFormatError(msg)
|
|
782
|
+
raw_metadata_key = self.metadata_keys_direct_map_write[unified_metadata_key]
|
|
783
|
+
if raw_metadata_key:
|
|
784
|
+
self._update_formatted_value_in_raw_mutagen_metadata(
|
|
785
|
+
raw_mutagen_metadata=id3_metadata,
|
|
786
|
+
raw_metadata_key=raw_metadata_key,
|
|
787
|
+
app_metadata_value=app_metadata_value,
|
|
788
|
+
)
|
|
789
|
+
else:
|
|
790
|
+
self._update_undirectly_mapped_metadata(
|
|
791
|
+
raw_mutagen_metadata=id3_metadata,
|
|
792
|
+
app_metadata_value=app_metadata_value,
|
|
793
|
+
unified_metadata_key=unified_metadata_key,
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
# Save with ID3v1 preservation
|
|
797
|
+
self._save_with_id3v1_preservation(self.audio_file.file_path, id3v1_data)
|
|
798
|
+
|
|
799
|
+
def _update_metadata_for_flac(self, unified_metadata: UnifiedMetadata) -> None:
|
|
800
|
+
"""Update ID3v2 metadata for FLAC files using external tools to avoid file corruption."""
|
|
801
|
+
if not self.metadata_keys_direct_map_write:
|
|
802
|
+
msg = "This format does not support metadata modification"
|
|
803
|
+
raise MetadataFieldNotSupportedByMetadataFormatError(msg)
|
|
804
|
+
|
|
805
|
+
self._validate_and_process_rating(unified_metadata)
|
|
806
|
+
|
|
807
|
+
# Use external tools to write ID3v2 metadata to FLAC files
|
|
808
|
+
# This avoids the file corruption that occurs with mutagen's ID3 class
|
|
809
|
+
# Determine the tool and version based on the configured ID3v2 version
|
|
810
|
+
if self.id3v2_version[1] == ID3V2_VERSION_3:
|
|
811
|
+
tool = "id3v2"
|
|
812
|
+
cmd = [get_tool_path("id3v2"), "--id3v2-only"]
|
|
813
|
+
else: # ID3v2.4
|
|
814
|
+
tool = "mid3v2"
|
|
815
|
+
cmd = [get_tool_path("mid3v2")]
|
|
816
|
+
|
|
817
|
+
# Map unified metadata keys to external tool arguments
|
|
818
|
+
key_mapping = {
|
|
819
|
+
UnifiedMetadataKey.TITLE: "--song",
|
|
820
|
+
UnifiedMetadataKey.ARTISTS: "--artist",
|
|
821
|
+
UnifiedMetadataKey.ALBUM: "--album",
|
|
822
|
+
UnifiedMetadataKey.ALBUM_ARTISTS: "--TPE2",
|
|
823
|
+
UnifiedMetadataKey.GENRES_NAMES: "--genre",
|
|
824
|
+
UnifiedMetadataKey.COMMENT: "--comment",
|
|
825
|
+
UnifiedMetadataKey.TRACK_NUMBER: "--track",
|
|
826
|
+
UnifiedMetadataKey.BPM: "--TBPM",
|
|
827
|
+
UnifiedMetadataKey.COMPOSERS: "--TCOM",
|
|
828
|
+
UnifiedMetadataKey.COPYRIGHT: "--TCOP",
|
|
829
|
+
UnifiedMetadataKey.UNSYNCHRONIZED_LYRICS: "--USLT",
|
|
830
|
+
UnifiedMetadataKey.LANGUAGE: "--TLAN",
|
|
831
|
+
UnifiedMetadataKey.PUBLISHER: "--TPUB",
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
# Build command with metadata
|
|
835
|
+
# First, remove frames for keys explicitly set to None
|
|
836
|
+
frames_to_remove = []
|
|
837
|
+
for unified_key, value in unified_metadata.items():
|
|
838
|
+
if unified_key in self.metadata_keys_direct_map_write:
|
|
839
|
+
raw_key = self.metadata_keys_direct_map_write[unified_key]
|
|
840
|
+
if raw_key and value is None:
|
|
841
|
+
frames_to_remove.append(raw_key)
|
|
842
|
+
|
|
843
|
+
try:
|
|
844
|
+
if frames_to_remove:
|
|
845
|
+
if self.id3v2_version[1] == ID3V2_VERSION_3:
|
|
846
|
+
# id3v2 supports removing a single frame at a time via -r
|
|
847
|
+
for frame in frames_to_remove:
|
|
848
|
+
with contextlib.suppress(subprocess.CalledProcessError):
|
|
849
|
+
subprocess.run(
|
|
850
|
+
[get_tool_path("id3v2"), "-r", frame, self.audio_file.file_path],
|
|
851
|
+
check=True,
|
|
852
|
+
capture_output=True,
|
|
853
|
+
)
|
|
854
|
+
else:
|
|
855
|
+
# mid3v2 supports deleting multiple frames with --delete-frames
|
|
856
|
+
frames_arg = ",".join(frames_to_remove)
|
|
857
|
+
with contextlib.suppress(subprocess.CalledProcessError):
|
|
858
|
+
subprocess.run(
|
|
859
|
+
[get_tool_path("mid3v2"), f"--delete-frames={frames_arg}", self.audio_file.file_path],
|
|
860
|
+
check=True,
|
|
861
|
+
capture_output=True,
|
|
862
|
+
)
|
|
863
|
+
except FileNotFoundError:
|
|
864
|
+
# If removal tool not found, proceed and hope save will remove frames
|
|
865
|
+
pass
|
|
866
|
+
|
|
867
|
+
# Build command with metadata (only non-None values)
|
|
868
|
+
for unified_key, value in unified_metadata.items():
|
|
869
|
+
if unified_key in key_mapping and value is not None:
|
|
870
|
+
tool_arg = key_mapping[unified_key]
|
|
871
|
+
|
|
872
|
+
processed_value = value
|
|
873
|
+
if unified_key == UnifiedMetadataKey.ARTISTS and isinstance(value, list):
|
|
874
|
+
# Handle multiple artists by joining with semicolon
|
|
875
|
+
processed_value = ";".join(value)
|
|
876
|
+
elif unified_key == UnifiedMetadataKey.GENRES_NAMES and isinstance(value, list):
|
|
877
|
+
# Handle multiple genres by joining with semicolon
|
|
878
|
+
processed_value = ";".join(value)
|
|
879
|
+
elif unified_key == UnifiedMetadataKey.COMPOSERS and isinstance(value, list):
|
|
880
|
+
# Handle multiple composers by joining with semicolon
|
|
881
|
+
processed_value = ";".join(value)
|
|
882
|
+
elif unified_key == UnifiedMetadataKey.ALBUM_ARTISTS and isinstance(value, list):
|
|
883
|
+
# Handle multiple album artists by joining with semicolon
|
|
884
|
+
processed_value = ";".join(value)
|
|
885
|
+
|
|
886
|
+
cmd.extend([tool_arg, str(processed_value)])
|
|
887
|
+
|
|
888
|
+
# Add file path and execute
|
|
889
|
+
cmd.append(self.audio_file.file_path)
|
|
890
|
+
|
|
891
|
+
try:
|
|
892
|
+
subprocess.run(cmd, check=True, capture_output=True)
|
|
893
|
+
except subprocess.CalledProcessError as e:
|
|
894
|
+
msg = f"Failed to write ID3v2 metadata with {tool}: {e}"
|
|
895
|
+
raise FileCorruptedError(msg) from e
|
|
896
|
+
except FileNotFoundError as e:
|
|
897
|
+
msg = f"External tool {tool} not found. Please install it to write ID3v2 metadata to FLAC files."
|
|
898
|
+
raise FileCorruptedError(msg) from e
|
|
899
|
+
|
|
900
|
+
def delete_metadata(self) -> bool:
|
|
901
|
+
"""Delete all ID3v2 metadata from the audio file.
|
|
902
|
+
|
|
903
|
+
This removes all ID3v2 frames from the file while preserving the audio data.
|
|
904
|
+
Uses ID3.delete() which is more reliable than deleting individual frames,
|
|
905
|
+
especially for non-MP3 files like FLAC that might have ID3v2 tags.
|
|
906
|
+
|
|
907
|
+
Returns:
|
|
908
|
+
bool: True if metadata was successfully deleted, False otherwise
|
|
909
|
+
"""
|
|
910
|
+
try:
|
|
911
|
+
# Create a new ID3 instance and use delete() to remove all ID3v2 tags
|
|
912
|
+
id3 = ID3(self.audio_file.file_path)
|
|
913
|
+
id3.delete()
|
|
914
|
+
except ID3NoHeaderError:
|
|
915
|
+
# No ID3 tags present, consider this a success
|
|
916
|
+
return True
|
|
917
|
+
except Exception:
|
|
918
|
+
return False
|
|
919
|
+
else:
|
|
920
|
+
return True
|
|
921
|
+
|
|
922
|
+
def get_header_info(self) -> dict:
|
|
923
|
+
try:
|
|
924
|
+
if self.raw_mutagen_metadata is None:
|
|
925
|
+
self.raw_mutagen_metadata = cast(MutagenMetadata, self._extract_mutagen_metadata())
|
|
926
|
+
|
|
927
|
+
if not self.raw_mutagen_metadata:
|
|
928
|
+
return {"present": False, "version": None, "header_size_bytes": 0, "flags": {}, "extended_header": {}}
|
|
929
|
+
|
|
930
|
+
id3_metadata: ID3 = cast(ID3, self.raw_mutagen_metadata)
|
|
931
|
+
|
|
932
|
+
# Get ID3v2 version
|
|
933
|
+
version = getattr(id3_metadata, "version", None)
|
|
934
|
+
version_str = f"{version[0]}.{version[1]}.{version[2]}" if version else None
|
|
935
|
+
|
|
936
|
+
# Get header size
|
|
937
|
+
header_size = getattr(id3_metadata, "size", 0)
|
|
938
|
+
|
|
939
|
+
# Get flags
|
|
940
|
+
flags = {}
|
|
941
|
+
if hasattr(id3_metadata, "flags"):
|
|
942
|
+
flags = {
|
|
943
|
+
"unsync": bool(id3_metadata.flags & 0x80),
|
|
944
|
+
"extended_header": bool(id3_metadata.flags & 0x40),
|
|
945
|
+
"experimental": bool(id3_metadata.flags & 0x20),
|
|
946
|
+
"footer": bool(id3_metadata.flags & 0x10),
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
# Get extended header info
|
|
950
|
+
extended_header = {}
|
|
951
|
+
if hasattr(id3_metadata, "extended_header"):
|
|
952
|
+
ext_header = id3_metadata.extended_header
|
|
953
|
+
if ext_header:
|
|
954
|
+
extended_header = {
|
|
955
|
+
"size": getattr(ext_header, "size", 0),
|
|
956
|
+
"flags": getattr(ext_header, "flags", 0),
|
|
957
|
+
"padding_size": getattr(ext_header, "padding_size", 0),
|
|
958
|
+
}
|
|
959
|
+
except Exception:
|
|
960
|
+
return {"present": False, "version": None, "header_size_bytes": 0, "flags": {}, "extended_header": {}}
|
|
961
|
+
else:
|
|
962
|
+
return {
|
|
963
|
+
"present": True,
|
|
964
|
+
"version": version_str,
|
|
965
|
+
"header_size_bytes": header_size,
|
|
966
|
+
"flags": flags,
|
|
967
|
+
"extended_header": extended_header,
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
def get_raw_metadata_info(self) -> dict:
|
|
971
|
+
try:
|
|
972
|
+
if self.raw_mutagen_metadata is None:
|
|
973
|
+
self.raw_mutagen_metadata = cast(MutagenMetadata, self._extract_mutagen_metadata())
|
|
974
|
+
|
|
975
|
+
if not self.raw_mutagen_metadata:
|
|
976
|
+
return {"raw_data": None, "parsed_fields": {}, "frames": {}, "comments": {}, "chunk_structure": {}}
|
|
977
|
+
|
|
978
|
+
id3_metadata: ID3 = cast(ID3, self.raw_mutagen_metadata)
|
|
979
|
+
|
|
980
|
+
# Get raw frames (exclude binary frames like APIC)
|
|
981
|
+
frames = {}
|
|
982
|
+
binary_frame_types = {
|
|
983
|
+
"APIC:",
|
|
984
|
+
"GEOB:",
|
|
985
|
+
"AENC:",
|
|
986
|
+
"RVA2:",
|
|
987
|
+
"RVRB:",
|
|
988
|
+
"EQU2:",
|
|
989
|
+
"PCNT:",
|
|
990
|
+
"POPM:",
|
|
991
|
+
"RBUF:",
|
|
992
|
+
"LINK:",
|
|
993
|
+
"POSS:",
|
|
994
|
+
"SYLT:",
|
|
995
|
+
"USLT:",
|
|
996
|
+
"SYTC:",
|
|
997
|
+
"ETCO:",
|
|
998
|
+
"MLLT:",
|
|
999
|
+
"OWNE:",
|
|
1000
|
+
"COMR:",
|
|
1001
|
+
"ENCR:",
|
|
1002
|
+
"GRID:",
|
|
1003
|
+
"PRIV:",
|
|
1004
|
+
"SIGN:",
|
|
1005
|
+
"SEEK:",
|
|
1006
|
+
"ASPI:",
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
for frame_id, frame in id3_metadata.items():
|
|
1010
|
+
# Skip binary frames to avoid including large image/audio data
|
|
1011
|
+
if frame_id in binary_frame_types:
|
|
1012
|
+
frames[frame_id] = {
|
|
1013
|
+
"text": f"<Binary data: {getattr(frame, 'size', 0)} bytes>",
|
|
1014
|
+
"size": getattr(frame, "size", 0),
|
|
1015
|
+
"flags": getattr(frame, "flags", 0),
|
|
1016
|
+
}
|
|
1017
|
+
else:
|
|
1018
|
+
frames[frame_id] = {
|
|
1019
|
+
"text": str(frame) if hasattr(frame, "__str__") else repr(frame),
|
|
1020
|
+
"size": getattr(frame, "size", 0),
|
|
1021
|
+
"flags": getattr(frame, "flags", 0),
|
|
1022
|
+
}
|
|
1023
|
+
except Exception:
|
|
1024
|
+
return {"raw_data": None, "parsed_fields": {}, "frames": {}, "comments": {}, "chunk_structure": {}}
|
|
1025
|
+
else:
|
|
1026
|
+
return {
|
|
1027
|
+
"raw_data": None, # ID3v2 data is complex, not storing raw bytes
|
|
1028
|
+
"parsed_fields": {},
|
|
1029
|
+
"frames": frames,
|
|
1030
|
+
"comments": {},
|
|
1031
|
+
"chunk_structure": {},
|
|
1032
|
+
}
|