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,1002 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
5
|
+
|
|
6
|
+
from mutagen._file import FileType as MutagenMetadata
|
|
7
|
+
from mutagen.wave import WAVE
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from ...._audio_file import _AudioFile
|
|
11
|
+
from ....exceptions import ConfigurationError, FileTypeNotSupportedError, MetadataFieldNotSupportedByMetadataFormatError
|
|
12
|
+
from ....utils.id3v1_genre_code_map import ID3V1_GENRE_CODE_MAP
|
|
13
|
+
from ....utils.rating_profiles import RatingWriteProfile
|
|
14
|
+
from ....utils.types import RawMetadataDict, RawMetadataKey, UnifiedMetadata, UnifiedMetadataValue
|
|
15
|
+
from ....utils.unified_metadata_key import UnifiedMetadataKey
|
|
16
|
+
from .._RatingSupportingMetadataManager import _RatingSupportingMetadataManager
|
|
17
|
+
from ..id3v2._id3v2_constants import ID3V2_HEADER_SIZE
|
|
18
|
+
from ._riff_constants import (
|
|
19
|
+
BEXT_LOUDNESS_METADATA_SIZE,
|
|
20
|
+
BEXT_MIN_CHUNK_SIZE,
|
|
21
|
+
BEXT_ORIGINATION_DATE_SIZE,
|
|
22
|
+
BEXT_ORIGINATION_TIME_SIZE,
|
|
23
|
+
BWF_V2_VERSION,
|
|
24
|
+
RIFF_AUDIO_FORMAT_IEEE_FLOAT,
|
|
25
|
+
RIFF_CHUNK_ID_SIZE,
|
|
26
|
+
RIFF_FORMAT_CHUNK_MIN_SIZE,
|
|
27
|
+
RIFF_HEADER_SIZE,
|
|
28
|
+
RIFF_MIN_DATA_SIZE_FOR_ID3V2,
|
|
29
|
+
RIFF_WAVE_FORMAT_POSITION,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _RiffManager(_RatingSupportingMetadataManager):
|
|
34
|
+
"""Manages RIFF metadata for WAV audio files.
|
|
35
|
+
|
|
36
|
+
Implementation Note:
|
|
37
|
+
While mutagen is used for reading WAV metadata, it does not support writing RIFF metadata. This is a known
|
|
38
|
+
limitation of the library, which only provides read-only access to WAVE files' metadata through its WAVE class.
|
|
39
|
+
Therefore, this manager implements its own RIFF metadata writing functionality by directly manipulating the file's
|
|
40
|
+
INFO chunk according to the RIFF specification.
|
|
41
|
+
|
|
42
|
+
RIFF Format:
|
|
43
|
+
RIFF (Resource Interchange File Format) is the standard metadata format for WAV files. The INFO chunk in RIFF/WAV
|
|
44
|
+
files uses standardized 4-character codes (FourCC) like INAM(Title), IART(Artist) or ICMT(Comments).
|
|
45
|
+
|
|
46
|
+
These codes are defined in RiffTagKey and are part of the standard RIFF specification. Each tag in the INFO chunk
|
|
47
|
+
follows the format:
|
|
48
|
+
- FourCC (4 chars): Identifies the metadata field (e.g., 'INAM' for title)
|
|
49
|
+
- Size (4 bytes): Length of the data in bytes
|
|
50
|
+
- Data (UTF-8): The actual metadata content
|
|
51
|
+
- Padding: If needed for word alignment (2 bytes)
|
|
52
|
+
|
|
53
|
+
Genre Support:
|
|
54
|
+
The IGNR tag in RIFF files has two modes:
|
|
55
|
+
1. Text Mode (Preferred when writing): Direct genre name as text
|
|
56
|
+
- Supports any genre name
|
|
57
|
+
- More flexible and readable
|
|
58
|
+
- Better compatibility with modern software
|
|
59
|
+
- Supports custom genres
|
|
60
|
+
2. Genre Code: Uses the standard ID3v1 genre list (0-147)
|
|
61
|
+
- Limited to predefined genres
|
|
62
|
+
- Compatible with older software
|
|
63
|
+
- No custom genres
|
|
64
|
+
- No multiple genres
|
|
65
|
+
|
|
66
|
+
Unsupported Metadata:
|
|
67
|
+
RIFF format has limited metadata support compared to other formats. The following metadata fields are NOT supported
|
|
68
|
+
and will raise MetadataFieldNotSupportedByMetadataFormatError if provided:
|
|
69
|
+
- Genre: Limited to predefined genre codes (0-147) or text mode
|
|
70
|
+
|
|
71
|
+
Rating Support:
|
|
72
|
+
RIFF format supports rating through the custom IRTD chunk, which is used by some applications
|
|
73
|
+
as an analogy to ID3 tags. While not part of the official RIFF specification, it's widely
|
|
74
|
+
recognized and supported by many audio applications.
|
|
75
|
+
|
|
76
|
+
When attempting to update unsupported metadata, the manager will raise
|
|
77
|
+
MetadataFieldNotSupportedByMetadataFormatError with a clear message indicating
|
|
78
|
+
which field is not supported by the RIFF format.
|
|
79
|
+
|
|
80
|
+
Note: This manager is the preferred way to handle WAV metadata, as it uses the format's native metadata system
|
|
81
|
+
rather than non-standard alternatives like ID3v2 tags. The custom implementation ensures proper handling of RIFF
|
|
82
|
+
chunk structures, maintaining word alignment and size fields according to the specification.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
class RiffTagKey(RawMetadataKey):
|
|
86
|
+
# Standard
|
|
87
|
+
TITLE = "INAM"
|
|
88
|
+
ARTIST = "IART"
|
|
89
|
+
ALBUM = "IPRD"
|
|
90
|
+
GENRES_NAMES_OR_CODES = "IGNR"
|
|
91
|
+
DATE = "ICRD" # Creation/Release date
|
|
92
|
+
TRACK_NUMBER = "IPRT"
|
|
93
|
+
COMPOSERS = "ICMP" # Composers
|
|
94
|
+
|
|
95
|
+
# Non-standard
|
|
96
|
+
ALBUM_ARTISTS = "IAAR"
|
|
97
|
+
LANGUAGE = "ILNG"
|
|
98
|
+
RATING = "IRTD"
|
|
99
|
+
COMMENT = "ICMT"
|
|
100
|
+
ENGINEER = "IENG" # Engineer who worked on the track
|
|
101
|
+
SOFTWARE = "ISFT" # Software used to create the file
|
|
102
|
+
COPYRIGHT = "ICOP"
|
|
103
|
+
TECHNICIAN = "ITCH" # Technician who worked on the track
|
|
104
|
+
BPM = "IBPM"
|
|
105
|
+
UNSYNCHRONIZED_LYRICS = "ILYR"
|
|
106
|
+
|
|
107
|
+
# BWF
|
|
108
|
+
ISRC = "ISRC" # International Standard Recording Code
|
|
109
|
+
|
|
110
|
+
def __init__(self, audio_file: "_AudioFile", normalized_rating_max_value: None | int = None):
|
|
111
|
+
# Validate that the file is a WAV file
|
|
112
|
+
if audio_file.file_extension != ".wav":
|
|
113
|
+
msg = f"RiffManager only supports WAV files, got {audio_file.file_extension}"
|
|
114
|
+
raise FileTypeNotSupportedError(msg)
|
|
115
|
+
|
|
116
|
+
metadata_keys_direct_map_read: dict[UnifiedMetadataKey, RawMetadataKey | None] = {
|
|
117
|
+
UnifiedMetadataKey.TITLE: self.RiffTagKey.TITLE,
|
|
118
|
+
UnifiedMetadataKey.ARTISTS: self.RiffTagKey.ARTIST,
|
|
119
|
+
UnifiedMetadataKey.ALBUM: self.RiffTagKey.ALBUM,
|
|
120
|
+
UnifiedMetadataKey.ALBUM_ARTISTS: self.RiffTagKey.ALBUM_ARTISTS,
|
|
121
|
+
UnifiedMetadataKey.GENRES_NAMES: None,
|
|
122
|
+
UnifiedMetadataKey.RATING: None,
|
|
123
|
+
UnifiedMetadataKey.LANGUAGE: self.RiffTagKey.LANGUAGE,
|
|
124
|
+
UnifiedMetadataKey.RELEASE_DATE: self.RiffTagKey.DATE,
|
|
125
|
+
UnifiedMetadataKey.COMPOSERS: self.RiffTagKey.COMPOSERS,
|
|
126
|
+
UnifiedMetadataKey.COPYRIGHT: self.RiffTagKey.COPYRIGHT,
|
|
127
|
+
UnifiedMetadataKey.COMMENT: self.RiffTagKey.COMMENT,
|
|
128
|
+
UnifiedMetadataKey.BPM: self.RiffTagKey.BPM,
|
|
129
|
+
UnifiedMetadataKey.UNSYNCHRONIZED_LYRICS: self.RiffTagKey.UNSYNCHRONIZED_LYRICS,
|
|
130
|
+
UnifiedMetadataKey.TRACK_NUMBER: self.RiffTagKey.TRACK_NUMBER,
|
|
131
|
+
UnifiedMetadataKey.ISRC: self.RiffTagKey.ISRC,
|
|
132
|
+
}
|
|
133
|
+
metadata_keys_direct_map_write: dict[UnifiedMetadataKey, RawMetadataKey | None] = {
|
|
134
|
+
UnifiedMetadataKey.TITLE: self.RiffTagKey.TITLE,
|
|
135
|
+
UnifiedMetadataKey.ARTISTS: self.RiffTagKey.ARTIST,
|
|
136
|
+
UnifiedMetadataKey.ALBUM: self.RiffTagKey.ALBUM,
|
|
137
|
+
UnifiedMetadataKey.ALBUM_ARTISTS: self.RiffTagKey.ALBUM_ARTISTS,
|
|
138
|
+
UnifiedMetadataKey.GENRES_NAMES: None,
|
|
139
|
+
UnifiedMetadataKey.RATING: None,
|
|
140
|
+
UnifiedMetadataKey.LANGUAGE: self.RiffTagKey.LANGUAGE,
|
|
141
|
+
UnifiedMetadataKey.RELEASE_DATE: self.RiffTagKey.DATE,
|
|
142
|
+
UnifiedMetadataKey.COMPOSERS: self.RiffTagKey.COMPOSERS,
|
|
143
|
+
UnifiedMetadataKey.COPYRIGHT: self.RiffTagKey.COPYRIGHT,
|
|
144
|
+
UnifiedMetadataKey.COMMENT: self.RiffTagKey.COMMENT,
|
|
145
|
+
UnifiedMetadataKey.BPM: self.RiffTagKey.BPM,
|
|
146
|
+
UnifiedMetadataKey.UNSYNCHRONIZED_LYRICS: self.RiffTagKey.UNSYNCHRONIZED_LYRICS,
|
|
147
|
+
UnifiedMetadataKey.TRACK_NUMBER: self.RiffTagKey.TRACK_NUMBER,
|
|
148
|
+
UnifiedMetadataKey.ISRC: self.RiffTagKey.ISRC,
|
|
149
|
+
}
|
|
150
|
+
super().__init__(
|
|
151
|
+
audio_file=audio_file,
|
|
152
|
+
metadata_keys_direct_map_read=metadata_keys_direct_map_read,
|
|
153
|
+
metadata_keys_direct_map_write=metadata_keys_direct_map_write,
|
|
154
|
+
rating_write_profile=RatingWriteProfile.BASE_255_NON_PROPORTIONAL,
|
|
155
|
+
normalized_rating_max_value=normalized_rating_max_value,
|
|
156
|
+
update_using_mutagen_metadata=False,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def _skip_id3v2_tags(self, data: bytes) -> bytes:
|
|
160
|
+
"""Skip ID3v2 tags if present at the start of the file.
|
|
161
|
+
|
|
162
|
+
Returns the data starting from after any ID3v2 tags.
|
|
163
|
+
"""
|
|
164
|
+
if data.startswith(b"ID3"):
|
|
165
|
+
# ID3v2 header is 10 bytes:
|
|
166
|
+
# 3 bytes: ID3
|
|
167
|
+
# 2 bytes: version
|
|
168
|
+
# 1 byte: flags
|
|
169
|
+
# 4 bytes: size (synchsafe integer)
|
|
170
|
+
if len(data) < ID3V2_HEADER_SIZE:
|
|
171
|
+
return data
|
|
172
|
+
|
|
173
|
+
# Get size from synchsafe integer (7 bits per byte)
|
|
174
|
+
size_bytes = data[6:ID3V2_HEADER_SIZE]
|
|
175
|
+
size = (
|
|
176
|
+
((size_bytes[0] & 0x7F) << 21)
|
|
177
|
+
| ((size_bytes[1] & 0x7F) << 14)
|
|
178
|
+
| ((size_bytes[2] & 0x7F) << 7)
|
|
179
|
+
| (size_bytes[3] & 0x7F)
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# Skip the header (10 bytes) plus the size of the tag
|
|
183
|
+
return data[ID3V2_HEADER_SIZE + size :]
|
|
184
|
+
return data
|
|
185
|
+
|
|
186
|
+
def _extract_riff_metadata_directly(self, file_data: bytes) -> dict[str, list[str]]:
|
|
187
|
+
"""Manually extract metadata from RIFF chunks without relying on external libraries.
|
|
188
|
+
|
|
189
|
+
This method directly parses the RIFF structure to extract metadata from the INFO chunk.
|
|
190
|
+
"""
|
|
191
|
+
info_tags: dict[str, list[str]] = {}
|
|
192
|
+
|
|
193
|
+
# Skip ID3v2 if present
|
|
194
|
+
file_data = self._skip_id3v2_tags(file_data)
|
|
195
|
+
|
|
196
|
+
# Validate RIFF header
|
|
197
|
+
if (
|
|
198
|
+
len(file_data) < RIFF_HEADER_SIZE
|
|
199
|
+
or file_data[:RIFF_CHUNK_ID_SIZE] != b"RIFF"
|
|
200
|
+
or file_data[RIFF_WAVE_FORMAT_POSITION:RIFF_HEADER_SIZE] != b"WAVE"
|
|
201
|
+
):
|
|
202
|
+
return info_tags
|
|
203
|
+
|
|
204
|
+
pos = 12 # Start after RIFF header
|
|
205
|
+
while pos < len(file_data) - 8:
|
|
206
|
+
chunk_id = file_data[pos : pos + 4]
|
|
207
|
+
chunk_size = int.from_bytes(file_data[pos + 4 : pos + 8], "little")
|
|
208
|
+
|
|
209
|
+
if chunk_id == b"LIST" and pos + 12 <= len(file_data) and file_data[pos + 8 : pos + 12] == b"INFO":
|
|
210
|
+
# Process INFO chunk
|
|
211
|
+
info_pos = pos + 12
|
|
212
|
+
info_end = pos + 8 + chunk_size
|
|
213
|
+
|
|
214
|
+
while info_pos < info_end - 8:
|
|
215
|
+
# Extract each metadata field
|
|
216
|
+
field_id = file_data[info_pos : info_pos + 4].decode("ascii", errors="ignore")
|
|
217
|
+
field_size = int.from_bytes(file_data[info_pos + 4 : info_pos + 8], "little")
|
|
218
|
+
|
|
219
|
+
if field_size > 0 and info_pos + 8 + field_size <= info_end:
|
|
220
|
+
# -1 to exclude null terminator
|
|
221
|
+
field_data = file_data[info_pos + 8 : info_pos + 8 + field_size - 1]
|
|
222
|
+
try:
|
|
223
|
+
# Decode and handle null-terminated strings
|
|
224
|
+
field_value = field_data.decode("utf-8", errors="ignore")
|
|
225
|
+
# Split on null byte and take first part if exists
|
|
226
|
+
field_value = field_value.split("\x00")[0].strip()
|
|
227
|
+
# Compare field_id with enum member values (FourCC strings)
|
|
228
|
+
if (
|
|
229
|
+
any(field_id == member.value for member in self.RiffTagKey.__members__.values())
|
|
230
|
+
and field_value
|
|
231
|
+
):
|
|
232
|
+
if field_id not in info_tags:
|
|
233
|
+
info_tags[field_id] = []
|
|
234
|
+
info_tags[field_id].append(field_value)
|
|
235
|
+
except UnicodeDecodeError:
|
|
236
|
+
pass
|
|
237
|
+
|
|
238
|
+
# Move to next field, maintaining alignment
|
|
239
|
+
info_pos += 8 + ((field_size + 1) & ~1)
|
|
240
|
+
break
|
|
241
|
+
|
|
242
|
+
# Move to next chunk, maintaining alignment
|
|
243
|
+
pos += 8 + ((chunk_size + 1) & ~1)
|
|
244
|
+
|
|
245
|
+
return info_tags
|
|
246
|
+
|
|
247
|
+
def _extract_bext_chunk(self, file_data: bytes) -> dict[str, Any] | None:
|
|
248
|
+
"""Extract and parse the bext chunk from BWF files.
|
|
249
|
+
|
|
250
|
+
BWF has multiple versions:
|
|
251
|
+
- Version 0 (1997): Original specification, no UMID field
|
|
252
|
+
- Version 1 (2001): Added UMID field (64 bytes)
|
|
253
|
+
- Version 2 (2011): Added loudness metadata fields (not yet parsed)
|
|
254
|
+
|
|
255
|
+
The bext chunk structure (v1):
|
|
256
|
+
- Description (256 bytes, ASCII, null-terminated)
|
|
257
|
+
- Originator (32 bytes, ASCII, null-terminated)
|
|
258
|
+
- OriginatorReference (32 bytes, ASCII, null-terminated)
|
|
259
|
+
- OriginationDate (10 bytes, ASCII, YYYY-MM-DD)
|
|
260
|
+
- OriginationTime (8 bytes, ASCII, HH:MM:SS)
|
|
261
|
+
- TimeReference (8 bytes, uint64, little-endian)
|
|
262
|
+
- Version (2 bytes, uint16, little-endian): 0x0000 (v0), 0x0001 (v1), 0x0002 (v2)
|
|
263
|
+
- UMID (64 bytes, binary, v1+ only)
|
|
264
|
+
- Reserved (190 bytes, zeros)
|
|
265
|
+
- CodingHistory (variable length, ASCII, null-terminated)
|
|
266
|
+
|
|
267
|
+
BWF v2 adds loudness metadata fields (10 bytes total) at the START of reserved bytes (offset 412):
|
|
268
|
+
- LoudnessValue (2 bytes, int16, little-endian, stored as 0.01 LU units by bwfmetaedit)
|
|
269
|
+
- LoudnessRange (2 bytes, int16, little-endian, stored as 0.01 LU units)
|
|
270
|
+
- MaxTruePeakLevel (2 bytes, int16, little-endian, stored as 0.01 dB units)
|
|
271
|
+
- MaxMomentaryLoudness (2 bytes, int16, little-endian, stored as 0.01 LU units)
|
|
272
|
+
- MaxShortTermLoudness (2 bytes, int16, little-endian, stored as 0.01 LU units)
|
|
273
|
+
Note: bwfmetaedit stores loudness values as 0.01 units (centi-units), so values are divided by 100.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
Dictionary with parsed bext fields or None if bext chunk not found
|
|
277
|
+
"""
|
|
278
|
+
# Skip ID3v2 if present
|
|
279
|
+
file_data = self._skip_id3v2_tags(file_data)
|
|
280
|
+
|
|
281
|
+
# Validate RIFF header
|
|
282
|
+
if (
|
|
283
|
+
len(file_data) < RIFF_HEADER_SIZE
|
|
284
|
+
or file_data[:RIFF_CHUNK_ID_SIZE] != b"RIFF"
|
|
285
|
+
or file_data[RIFF_WAVE_FORMAT_POSITION:RIFF_HEADER_SIZE] != b"WAVE"
|
|
286
|
+
):
|
|
287
|
+
return None
|
|
288
|
+
|
|
289
|
+
pos = 12 # Start after RIFF header
|
|
290
|
+
while pos < len(file_data) - 8:
|
|
291
|
+
chunk_id = file_data[pos : pos + 4]
|
|
292
|
+
chunk_size = int.from_bytes(file_data[pos + 4 : pos + 8], "little")
|
|
293
|
+
|
|
294
|
+
if chunk_id == b"bext":
|
|
295
|
+
# Found bext chunk
|
|
296
|
+
bext_data_start = pos + 8
|
|
297
|
+
bext_data_end = bext_data_start + chunk_size
|
|
298
|
+
|
|
299
|
+
if bext_data_end > len(file_data):
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
bext_data = file_data[bext_data_start:bext_data_end]
|
|
303
|
+
|
|
304
|
+
# Minimum bext chunk size is 602 bytes (256+32+32+10+8+8+2+64+190)
|
|
305
|
+
if len(bext_data) < BEXT_MIN_CHUNK_SIZE:
|
|
306
|
+
return None
|
|
307
|
+
|
|
308
|
+
bext_fields: dict[str, Any] = {}
|
|
309
|
+
|
|
310
|
+
# Parse fixed fields
|
|
311
|
+
offset = 0
|
|
312
|
+
|
|
313
|
+
# Description (256 bytes)
|
|
314
|
+
description_bytes = bext_data[offset : offset + 256]
|
|
315
|
+
description = description_bytes.split(b"\x00")[0].decode("ascii", errors="ignore").strip()
|
|
316
|
+
if description:
|
|
317
|
+
bext_fields["Description"] = description
|
|
318
|
+
offset += 256
|
|
319
|
+
|
|
320
|
+
# Originator (32 bytes)
|
|
321
|
+
originator_bytes = bext_data[offset : offset + 32]
|
|
322
|
+
originator = originator_bytes.split(b"\x00")[0].decode("ascii", errors="ignore").strip()
|
|
323
|
+
if originator:
|
|
324
|
+
bext_fields["Originator"] = originator
|
|
325
|
+
offset += 32
|
|
326
|
+
|
|
327
|
+
# OriginatorReference (32 bytes)
|
|
328
|
+
originator_ref_bytes = bext_data[offset : offset + 32]
|
|
329
|
+
originator_ref = originator_ref_bytes.split(b"\x00")[0].decode("ascii", errors="ignore").strip()
|
|
330
|
+
if originator_ref:
|
|
331
|
+
bext_fields["OriginatorReference"] = originator_ref
|
|
332
|
+
offset += 32
|
|
333
|
+
|
|
334
|
+
# OriginationDate (10 bytes, YYYY-MM-DD)
|
|
335
|
+
origination_date_bytes = bext_data[offset : offset + BEXT_ORIGINATION_DATE_SIZE]
|
|
336
|
+
origination_date = origination_date_bytes.decode("ascii", errors="ignore").strip()
|
|
337
|
+
if origination_date and len(origination_date) == BEXT_ORIGINATION_DATE_SIZE:
|
|
338
|
+
bext_fields["OriginationDate"] = origination_date
|
|
339
|
+
offset += BEXT_ORIGINATION_DATE_SIZE
|
|
340
|
+
|
|
341
|
+
# OriginationTime (8 bytes, HH:MM:SS)
|
|
342
|
+
origination_time_bytes = bext_data[offset : offset + BEXT_ORIGINATION_TIME_SIZE]
|
|
343
|
+
origination_time = origination_time_bytes.decode("ascii", errors="ignore").strip()
|
|
344
|
+
if origination_time and len(origination_time) == BEXT_ORIGINATION_TIME_SIZE:
|
|
345
|
+
bext_fields["OriginationTime"] = origination_time
|
|
346
|
+
offset += BEXT_ORIGINATION_TIME_SIZE
|
|
347
|
+
|
|
348
|
+
# TimeReference (8 bytes, uint64, little-endian)
|
|
349
|
+
if offset + 8 <= len(bext_data):
|
|
350
|
+
time_reference = int.from_bytes(bext_data[offset : offset + 8], "little")
|
|
351
|
+
bext_fields["TimeReference"] = time_reference
|
|
352
|
+
offset += 8
|
|
353
|
+
|
|
354
|
+
# Version (2 bytes, uint16, little-endian)
|
|
355
|
+
if offset + 2 <= len(bext_data):
|
|
356
|
+
version = int.from_bytes(bext_data[offset : offset + 2], "little")
|
|
357
|
+
bext_fields["Version"] = version
|
|
358
|
+
offset += 2
|
|
359
|
+
|
|
360
|
+
# UMID (64 bytes, binary)
|
|
361
|
+
if offset + 64 <= len(bext_data):
|
|
362
|
+
umid_bytes = bext_data[offset : offset + 64]
|
|
363
|
+
# Check if UMID is not all zeros
|
|
364
|
+
if any(umid_bytes):
|
|
365
|
+
# Format as hex string for readability
|
|
366
|
+
umid_hex = umid_bytes.hex().upper()
|
|
367
|
+
bext_fields["UMID"] = umid_hex
|
|
368
|
+
offset += 64
|
|
369
|
+
|
|
370
|
+
# Reserved (190 bytes) - in BWF v2, loudness metadata is stored at the START of reserved bytes
|
|
371
|
+
# Parse loudness metadata if BWF v2 (version >= 2)
|
|
372
|
+
if version >= BWF_V2_VERSION and offset + BEXT_LOUDNESS_METADATA_SIZE <= len(bext_data):
|
|
373
|
+
# Loudness metadata starts at offset 412 (start of reserved bytes area)
|
|
374
|
+
# LoudnessValue (2 bytes, int16, little-endian, stored as 0.01 LU units by bwfmetaedit)
|
|
375
|
+
loudness_value_raw = int.from_bytes(bext_data[offset : offset + 2], "little", signed=True)
|
|
376
|
+
if loudness_value_raw != 0: # 0 means not set
|
|
377
|
+
# bwfmetaedit stores as 0.01 units, convert to LU
|
|
378
|
+
bext_fields["LoudnessValue"] = round(loudness_value_raw / 100.0, 2)
|
|
379
|
+
offset += 2
|
|
380
|
+
|
|
381
|
+
# LoudnessRange (2 bytes, int16, little-endian, stored as 0.01 LU units)
|
|
382
|
+
if offset + 2 <= len(bext_data):
|
|
383
|
+
loudness_range_raw = int.from_bytes(bext_data[offset : offset + 2], "little", signed=True)
|
|
384
|
+
if loudness_range_raw != 0: # 0 means not set
|
|
385
|
+
bext_fields["LoudnessRange"] = round(loudness_range_raw / 100.0, 2)
|
|
386
|
+
offset += 2
|
|
387
|
+
|
|
388
|
+
# MaxTruePeakLevel (2 bytes, int16, little-endian, stored as 0.01 dB units)
|
|
389
|
+
if offset + 2 <= len(bext_data):
|
|
390
|
+
max_true_peak_raw = int.from_bytes(bext_data[offset : offset + 2], "little", signed=True)
|
|
391
|
+
if max_true_peak_raw != 0: # 0 means not set
|
|
392
|
+
bext_fields["MaxTruePeakLevel"] = round(max_true_peak_raw / 100.0, 2)
|
|
393
|
+
offset += 2
|
|
394
|
+
|
|
395
|
+
# MaxMomentaryLoudness (2 bytes, int16, little-endian, stored as 0.01 LU units)
|
|
396
|
+
if offset + 2 <= len(bext_data):
|
|
397
|
+
max_momentary_raw = int.from_bytes(bext_data[offset : offset + 2], "little", signed=True)
|
|
398
|
+
if max_momentary_raw != 0: # 0 means not set
|
|
399
|
+
bext_fields["MaxMomentaryLoudness"] = round(max_momentary_raw / 100.0, 2)
|
|
400
|
+
offset += 2
|
|
401
|
+
|
|
402
|
+
# MaxShortTermLoudness (2 bytes, int16, little-endian, stored as 0.01 LU units)
|
|
403
|
+
if offset + 2 <= len(bext_data):
|
|
404
|
+
max_short_term_raw = int.from_bytes(bext_data[offset : offset + 2], "little", signed=True)
|
|
405
|
+
if max_short_term_raw != 0: # 0 means not set
|
|
406
|
+
bext_fields["MaxShortTermLoudness"] = round(max_short_term_raw / 100.0, 2)
|
|
407
|
+
offset += 2
|
|
408
|
+
|
|
409
|
+
# Skip remaining reserved bytes (190 - 10 = 180 bytes)
|
|
410
|
+
offset += 180
|
|
411
|
+
else:
|
|
412
|
+
# Skip all reserved bytes if not v2
|
|
413
|
+
offset += 190
|
|
414
|
+
|
|
415
|
+
# CodingHistory (variable length, null-terminated)
|
|
416
|
+
if offset < len(bext_data):
|
|
417
|
+
coding_history_bytes = bext_data[offset:]
|
|
418
|
+
# Find null terminator or end of chunk
|
|
419
|
+
null_pos = coding_history_bytes.find(b"\x00")
|
|
420
|
+
if null_pos >= 0:
|
|
421
|
+
coding_history_bytes = coding_history_bytes[:null_pos]
|
|
422
|
+
coding_history = coding_history_bytes.decode("ascii", errors="ignore").strip()
|
|
423
|
+
if coding_history:
|
|
424
|
+
bext_fields["CodingHistory"] = coding_history
|
|
425
|
+
|
|
426
|
+
return bext_fields if bext_fields else None
|
|
427
|
+
|
|
428
|
+
# Move to next chunk, maintaining alignment
|
|
429
|
+
pos += 8 + ((chunk_size + 1) & ~1)
|
|
430
|
+
|
|
431
|
+
return None
|
|
432
|
+
|
|
433
|
+
@contextlib.contextmanager
|
|
434
|
+
def _suppress_output(self) -> Any:
|
|
435
|
+
"""Context manager to suppress all output including direct prints."""
|
|
436
|
+
with (
|
|
437
|
+
Path(os.devnull).open("w") as devnull,
|
|
438
|
+
contextlib.redirect_stdout(devnull),
|
|
439
|
+
contextlib.redirect_stderr(devnull),
|
|
440
|
+
):
|
|
441
|
+
yield
|
|
442
|
+
|
|
443
|
+
def _extract_mutagen_metadata(self) -> RawMetadataDict:
|
|
444
|
+
"""Extract RIFF metadata from WAV files using direct RIFF chunk parsing.
|
|
445
|
+
|
|
446
|
+
This method reads the WAV file's INFO chunk directly, providing the most reliable way to access RIFF metadata.
|
|
447
|
+
"""
|
|
448
|
+
self.audio_file.seek(0)
|
|
449
|
+
file_data = self.audio_file.read()
|
|
450
|
+
|
|
451
|
+
# Skip ID3v2 metadata if present and create a clean RIFF file for mutagen
|
|
452
|
+
clean_data = self._skip_id3v2_tags(file_data)
|
|
453
|
+
|
|
454
|
+
# Create a temporary file with just the RIFF data for mutagen to parse
|
|
455
|
+
import tempfile
|
|
456
|
+
|
|
457
|
+
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as temp_file:
|
|
458
|
+
temp_file.write(clean_data)
|
|
459
|
+
temp_file.flush()
|
|
460
|
+
|
|
461
|
+
try:
|
|
462
|
+
# Create WAVE object with the clean RIFF data
|
|
463
|
+
wave = WAVE(filename=temp_file.name)
|
|
464
|
+
info_tags = self._extract_riff_metadata_directly(file_data) # Use original data for our custom parsing
|
|
465
|
+
wave.info = info_tags
|
|
466
|
+
return cast(RawMetadataDict, wave)
|
|
467
|
+
finally:
|
|
468
|
+
# Clean up temporary file
|
|
469
|
+
|
|
470
|
+
with contextlib.suppress(OSError):
|
|
471
|
+
Path(temp_file.name).unlink()
|
|
472
|
+
|
|
473
|
+
def _convert_raw_mutagen_metadata_to_dict_with_potential_duplicate_keys(
|
|
474
|
+
self, raw_mutagen_metadata: MutagenMetadata
|
|
475
|
+
) -> RawMetadataDict:
|
|
476
|
+
"""Convert RIFF metadata to dictionary.
|
|
477
|
+
|
|
478
|
+
Extracts tags from our custom info_tags attribute which contains the directly parsed INFO chunk data.
|
|
479
|
+
"""
|
|
480
|
+
raw_mutagen_metadata_wav: WAVE = cast(WAVE, raw_mutagen_metadata)
|
|
481
|
+
raw_metadata_dict: dict = {}
|
|
482
|
+
|
|
483
|
+
# Get metadata from our custom info which contains the directly parsed INFO chunk
|
|
484
|
+
if hasattr(raw_mutagen_metadata_wav, "info") and raw_mutagen_metadata_wav.info is not None:
|
|
485
|
+
info_tags = raw_mutagen_metadata_wav.info
|
|
486
|
+
for key, value in info_tags.items():
|
|
487
|
+
# key is a FourCC string; check against enum member values
|
|
488
|
+
if any(key == member.value for member in self.RiffTagKey.__members__.values()):
|
|
489
|
+
# info_tags now contains lists of values, so we can pass them directly
|
|
490
|
+
raw_metadata_dict[key] = value
|
|
491
|
+
|
|
492
|
+
return raw_metadata_dict
|
|
493
|
+
|
|
494
|
+
def _get_raw_rating_by_traktor_or_not(self, raw_clean_metadata: RawMetadataDict) -> tuple[int | None, bool]:
|
|
495
|
+
# raw_clean_metadata uses FourCC string keys; compare using enum .value
|
|
496
|
+
rating_key = self.RiffTagKey.RATING
|
|
497
|
+
if rating_key not in raw_clean_metadata:
|
|
498
|
+
return None, False
|
|
499
|
+
|
|
500
|
+
raw_ratings = raw_clean_metadata[rating_key]
|
|
501
|
+
if not raw_ratings or len(raw_ratings) == 0:
|
|
502
|
+
return None, False
|
|
503
|
+
|
|
504
|
+
raw_rating = raw_ratings[0]
|
|
505
|
+
# It is a Traktor rating if it's an integer
|
|
506
|
+
if isinstance(raw_rating, str):
|
|
507
|
+
return int(raw_rating), False
|
|
508
|
+
return cast(int, raw_rating), True
|
|
509
|
+
|
|
510
|
+
def _get_undirectly_mapped_metadata_value_other_than_rating_from_raw_clean_metadata(
|
|
511
|
+
self, raw_clean_metadata: RawMetadataDict, unified_metadata_key: UnifiedMetadataKey
|
|
512
|
+
) -> UnifiedMetadataValue:
|
|
513
|
+
if unified_metadata_key == UnifiedMetadataKey.GENRES_NAMES:
|
|
514
|
+
return self._get_genres_from_raw_clean_metadata_uppercase_keys(
|
|
515
|
+
raw_clean_metadata, self.RiffTagKey.GENRES_NAMES_OR_CODES
|
|
516
|
+
)
|
|
517
|
+
msg = f"Metadata key not handled: {unified_metadata_key}"
|
|
518
|
+
raise MetadataFieldNotSupportedByMetadataFormatError(msg)
|
|
519
|
+
|
|
520
|
+
def _update_not_using_mutagen_metadata(self, unified_metadata: UnifiedMetadata) -> None:
|
|
521
|
+
"""Update metadata fields in the RIFF INFO chunk using an optimized chunk-based approach.
|
|
522
|
+
|
|
523
|
+
This implementation
|
|
524
|
+
maintains RIFF specification compliance while providing better performance and reliability for metadata updates.
|
|
525
|
+
|
|
526
|
+
Note: While TinyTag is excellent for reading metadata, it doesn't support writing.
|
|
527
|
+
Therefore, we implement our own RIFF chunk writer following the specification.
|
|
528
|
+
"""
|
|
529
|
+
if not self.metadata_keys_direct_map_write:
|
|
530
|
+
msg = "metadata_keys_direct_map_write must be set"
|
|
531
|
+
raise ConfigurationError(msg)
|
|
532
|
+
|
|
533
|
+
# Validate that all metadata fields are supported by RIFF format
|
|
534
|
+
for unified_metadata_key in unified_metadata:
|
|
535
|
+
if unified_metadata_key not in self.metadata_keys_direct_map_write:
|
|
536
|
+
msg = f"{unified_metadata_key} metadata not supported by RIFF format"
|
|
537
|
+
raise MetadataFieldNotSupportedByMetadataFormatError(msg)
|
|
538
|
+
|
|
539
|
+
# Read the entire file into a mutable bytearray
|
|
540
|
+
self.audio_file.seek(0)
|
|
541
|
+
file_data = bytearray(self.audio_file.read())
|
|
542
|
+
|
|
543
|
+
# Check if we should preserve ID3v2 tags based on the calling context
|
|
544
|
+
# In PRESERVE strategy, we should not strip ID3v2 tags as they will be restored later
|
|
545
|
+
should_preserve_id3v2 = self._should_preserve_id3v2_tags()
|
|
546
|
+
|
|
547
|
+
if should_preserve_id3v2:
|
|
548
|
+
# For PRESERVE strategy, work with the full file data including ID3v2 tags
|
|
549
|
+
# We'll write RIFF metadata without affecting the ID3v2 section
|
|
550
|
+
pass # file_data already contains the full file including ID3v2
|
|
551
|
+
else:
|
|
552
|
+
# For other strategies (CLEANUP, SYNC), strip ID3v2 tags as before
|
|
553
|
+
skipped_data = self._skip_id3v2_tags(bytes(file_data))
|
|
554
|
+
file_data = bytearray(skipped_data)
|
|
555
|
+
|
|
556
|
+
# Find RIFF header and validate
|
|
557
|
+
# If ID3v2 tags are present, we need to find the RIFF header after them
|
|
558
|
+
if should_preserve_id3v2 and file_data.startswith(b"ID3"):
|
|
559
|
+
# Find RIFF header after ID3v2 tags
|
|
560
|
+
riff_start = self._find_riff_header_after_id3v2(file_data)
|
|
561
|
+
if riff_start == -1:
|
|
562
|
+
msg = "Invalid WAV file format - RIFF header not found after ID3v2 tags"
|
|
563
|
+
raise MetadataFieldNotSupportedByMetadataFormatError(msg)
|
|
564
|
+
# Work with the RIFF portion only for metadata updates
|
|
565
|
+
riff_data = file_data[riff_start:]
|
|
566
|
+
else:
|
|
567
|
+
riff_data = file_data
|
|
568
|
+
|
|
569
|
+
if (
|
|
570
|
+
len(riff_data) < RIFF_HEADER_SIZE
|
|
571
|
+
or bytes(riff_data[:RIFF_CHUNK_ID_SIZE]) != b"RIFF"
|
|
572
|
+
or bytes(riff_data[RIFF_WAVE_FORMAT_POSITION:RIFF_HEADER_SIZE]) != b"WAVE"
|
|
573
|
+
):
|
|
574
|
+
msg = "Invalid WAV file format"
|
|
575
|
+
raise MetadataFieldNotSupportedByMetadataFormatError(msg)
|
|
576
|
+
|
|
577
|
+
# Find or create LIST INFO chunk in the RIFF data
|
|
578
|
+
info_chunk_start = self._find_info_chunk_in_file_data(riff_data)
|
|
579
|
+
if info_chunk_start == -1:
|
|
580
|
+
info_chunk_start = self._create_info_chunk_after_wave_header(riff_data)
|
|
581
|
+
|
|
582
|
+
# Process metadata updates
|
|
583
|
+
info_chunk_size = int.from_bytes(bytes(riff_data[info_chunk_start + 4 : info_chunk_start + 8]), "little")
|
|
584
|
+
|
|
585
|
+
# Read existing metadata to preserve it
|
|
586
|
+
existing_metadata = self._extract_riff_metadata_directly(bytes(riff_data))
|
|
587
|
+
|
|
588
|
+
# Convert existing metadata to unified format for merging
|
|
589
|
+
existing_unified_metadata: UnifiedMetadata = {}
|
|
590
|
+
for existing_riff_key, values in existing_metadata.items():
|
|
591
|
+
# Find the corresponding unified metadata key
|
|
592
|
+
for unified_key, mapped_riff_key in self.metadata_keys_direct_map_write.items():
|
|
593
|
+
if mapped_riff_key and mapped_riff_key.value == existing_riff_key:
|
|
594
|
+
if len(values) == 1:
|
|
595
|
+
existing_unified_metadata[unified_key] = values[0]
|
|
596
|
+
else:
|
|
597
|
+
existing_unified_metadata[unified_key] = values
|
|
598
|
+
break
|
|
599
|
+
|
|
600
|
+
# Merge existing metadata with new metadata (new metadata takes precedence)
|
|
601
|
+
merged_metadata: UnifiedMetadata = {**existing_unified_metadata, **unified_metadata}
|
|
602
|
+
|
|
603
|
+
# Build new tags data
|
|
604
|
+
new_tags_data = bytearray()
|
|
605
|
+
for app_key, value in merged_metadata.items():
|
|
606
|
+
if value is None or value == "":
|
|
607
|
+
continue
|
|
608
|
+
|
|
609
|
+
# Get corresponding RIFF tag
|
|
610
|
+
riff_key: RawMetadataKey | None = self._get_riff_key_for_metadata(app_key, value)
|
|
611
|
+
if not riff_key:
|
|
612
|
+
continue
|
|
613
|
+
|
|
614
|
+
# Handle multiple values (e.g., multiple artists)
|
|
615
|
+
if isinstance(value, list):
|
|
616
|
+
# Values are already filtered at the base level
|
|
617
|
+
if value:
|
|
618
|
+
# Use smart separator to concatenate multiple values
|
|
619
|
+
separator = self.find_safe_separator(value)
|
|
620
|
+
concatenated_value: str = separator.join(value)
|
|
621
|
+
value_bytes = self._prepare_tag_value(concatenated_value, app_key)
|
|
622
|
+
if value_bytes:
|
|
623
|
+
new_tags_data.extend(self._create_aligned_metadata_with_proper_padding(riff_key, value_bytes))
|
|
624
|
+
# Single value - ensure it's not None before processing
|
|
625
|
+
elif isinstance(value, int | float | str):
|
|
626
|
+
value_bytes = self._prepare_tag_value(value, app_key)
|
|
627
|
+
if value_bytes:
|
|
628
|
+
new_tags_data.extend(self._create_aligned_metadata_with_proper_padding(riff_key, value_bytes))
|
|
629
|
+
|
|
630
|
+
# Create new INFO chunk
|
|
631
|
+
new_info_chunk = bytearray()
|
|
632
|
+
new_info_chunk.extend(b"LIST")
|
|
633
|
+
new_info_chunk.extend((len(new_tags_data) + 4).to_bytes(4, "little")) # +4 for 'INFO'
|
|
634
|
+
new_info_chunk.extend(b"INFO")
|
|
635
|
+
new_info_chunk.extend(new_tags_data)
|
|
636
|
+
|
|
637
|
+
# Replace old INFO chunk in RIFF data
|
|
638
|
+
riff_data[info_chunk_start : info_chunk_start + info_chunk_size + 8] = new_info_chunk
|
|
639
|
+
|
|
640
|
+
# Update RIFF chunk size
|
|
641
|
+
total_size = len(riff_data) - 8 # Exclude RIFF and size fields
|
|
642
|
+
riff_data[4:8] = total_size.to_bytes(4, "little")
|
|
643
|
+
|
|
644
|
+
# If we preserved ID3v2 tags, we need to reconstruct the full file
|
|
645
|
+
if should_preserve_id3v2 and file_data.startswith(b"ID3"):
|
|
646
|
+
# Reconstruct the full file with ID3v2 tags + updated RIFF data
|
|
647
|
+
id3v2_size = self._get_id3v2_size(file_data)
|
|
648
|
+
final_file_data = bytearray(file_data[:id3v2_size]) # Keep ID3v2 tags
|
|
649
|
+
final_file_data.extend(riff_data) # Add updated RIFF data
|
|
650
|
+
else:
|
|
651
|
+
final_file_data = riff_data
|
|
652
|
+
|
|
653
|
+
# Write updated file
|
|
654
|
+
self.audio_file.seek(0)
|
|
655
|
+
self.audio_file.write(final_file_data)
|
|
656
|
+
|
|
657
|
+
def delete_metadata(self) -> bool:
|
|
658
|
+
"""Delete all RIFF metadata from the audio file.
|
|
659
|
+
|
|
660
|
+
This removes all RIFF INFO chunks from the file while preserving the audio data.
|
|
661
|
+
Uses custom RIFF chunk manipulation since mutagen doesn't support RIFF writing.
|
|
662
|
+
|
|
663
|
+
Returns:
|
|
664
|
+
bool: True if metadata was successfully deleted, False otherwise
|
|
665
|
+
"""
|
|
666
|
+
try:
|
|
667
|
+
# Read the entire file into a mutable bytearray
|
|
668
|
+
self.audio_file.seek(0)
|
|
669
|
+
file_data = bytearray(self.audio_file.read())
|
|
670
|
+
|
|
671
|
+
# Check if we should preserve ID3v2 tags
|
|
672
|
+
should_preserve_id3v2 = self._should_preserve_id3v2_tags()
|
|
673
|
+
|
|
674
|
+
if should_preserve_id3v2:
|
|
675
|
+
# For files with ID3v2 tags, work with the RIFF portion only
|
|
676
|
+
if file_data.startswith(b"ID3"):
|
|
677
|
+
# Find RIFF header after ID3v2 tags
|
|
678
|
+
riff_start = self._find_riff_header_after_id3v2(file_data)
|
|
679
|
+
if riff_start == -1:
|
|
680
|
+
return False # No RIFF header found
|
|
681
|
+
riff_data = file_data[riff_start:]
|
|
682
|
+
else:
|
|
683
|
+
riff_data = file_data
|
|
684
|
+
else:
|
|
685
|
+
# For files without ID3v2 tags, work with the entire file
|
|
686
|
+
riff_data = file_data
|
|
687
|
+
|
|
688
|
+
# Find and remove LIST INFO chunk
|
|
689
|
+
info_chunk_start = self._find_info_chunk_in_file_data(riff_data)
|
|
690
|
+
if info_chunk_start == -1:
|
|
691
|
+
return True # No INFO chunk found, consider deletion successful
|
|
692
|
+
|
|
693
|
+
# Get the size of the INFO chunk
|
|
694
|
+
info_chunk_size = int.from_bytes(bytes(riff_data[info_chunk_start + 4 : info_chunk_start + 8]), "little")
|
|
695
|
+
|
|
696
|
+
# Remove the INFO chunk
|
|
697
|
+
riff_data[info_chunk_start : info_chunk_start + info_chunk_size + 8] = b""
|
|
698
|
+
|
|
699
|
+
# Update RIFF chunk size
|
|
700
|
+
total_size = len(riff_data) - 8 # Exclude RIFF and size fields
|
|
701
|
+
riff_data[4:8] = total_size.to_bytes(4, "little")
|
|
702
|
+
|
|
703
|
+
# If we preserved ID3v2 tags, reconstruct the full file
|
|
704
|
+
if should_preserve_id3v2 and file_data.startswith(b"ID3"):
|
|
705
|
+
id3v2_size = self._get_id3v2_size(file_data)
|
|
706
|
+
final_file_data = bytearray(file_data[:id3v2_size]) # Keep ID3v2 tags
|
|
707
|
+
final_file_data.extend(riff_data) # Add updated RIFF data
|
|
708
|
+
else:
|
|
709
|
+
final_file_data = riff_data
|
|
710
|
+
|
|
711
|
+
# Write updated file
|
|
712
|
+
self.audio_file.seek(0)
|
|
713
|
+
self.audio_file.write(final_file_data)
|
|
714
|
+
except Exception:
|
|
715
|
+
return False
|
|
716
|
+
else:
|
|
717
|
+
return True
|
|
718
|
+
|
|
719
|
+
def _find_info_chunk_in_file_data(self, file_data: bytearray) -> int:
|
|
720
|
+
pos = 12 # Start after RIFF header
|
|
721
|
+
while pos < len(file_data) - 8:
|
|
722
|
+
if (
|
|
723
|
+
bytes(file_data[pos : pos + 4]) == b"LIST"
|
|
724
|
+
and pos + 8 < len(file_data)
|
|
725
|
+
and bytes(file_data[pos + 8 : pos + 12]) == b"INFO"
|
|
726
|
+
):
|
|
727
|
+
return pos
|
|
728
|
+
chunk_size = int.from_bytes(bytes(file_data[pos + 4 : pos + 8]), "little")
|
|
729
|
+
pos += 8 + ((chunk_size + 1) & ~1) # Move to next chunk, maintaining alignment
|
|
730
|
+
return -1
|
|
731
|
+
|
|
732
|
+
def _create_info_chunk_after_wave_header(self, file_data: bytearray) -> int:
|
|
733
|
+
info_chunk = bytearray(b"LIST\x04\x00\x00\x00INFO") # Minimal INFO chunk
|
|
734
|
+
insert_pos = 12 # After RIFF+size+WAVE
|
|
735
|
+
file_data[insert_pos:insert_pos] = info_chunk
|
|
736
|
+
return insert_pos
|
|
737
|
+
|
|
738
|
+
def _get_riff_key_for_metadata(
|
|
739
|
+
self, app_key: UnifiedMetadataKey, _value: UnifiedMetadataValue
|
|
740
|
+
) -> RawMetadataKey | None:
|
|
741
|
+
"""Get the appropriate RIFF tag key for the metadata."""
|
|
742
|
+
if not self.metadata_keys_direct_map_write:
|
|
743
|
+
return None
|
|
744
|
+
|
|
745
|
+
riff_key = self.metadata_keys_direct_map_write.get(app_key, None)
|
|
746
|
+
if not riff_key:
|
|
747
|
+
if app_key == UnifiedMetadataKey.GENRES_NAMES:
|
|
748
|
+
return cast(RawMetadataKey | None, self.RiffTagKey.GENRES_NAMES_OR_CODES)
|
|
749
|
+
if app_key == UnifiedMetadataKey.RATING:
|
|
750
|
+
return cast(RawMetadataKey | None, self.RiffTagKey.RATING)
|
|
751
|
+
return riff_key
|
|
752
|
+
|
|
753
|
+
def _prepare_tag_value(self, value: UnifiedMetadataValue, app_key: UnifiedMetadataKey) -> bytes | None:
|
|
754
|
+
"""Prepare the tag value for writing, handling special cases."""
|
|
755
|
+
# Handle list values (should not happen in this method anymore due to upstream processing)
|
|
756
|
+
if isinstance(value, list):
|
|
757
|
+
value = value[0] if value else ""
|
|
758
|
+
|
|
759
|
+
if app_key == UnifiedMetadataKey.GENRES_NAMES:
|
|
760
|
+
# Write genre as text instead of numeric code for better compatibility
|
|
761
|
+
value = str(value)
|
|
762
|
+
elif (
|
|
763
|
+
app_key == UnifiedMetadataKey.RATING and value is not None and self.normalized_rating_max_value is not None
|
|
764
|
+
):
|
|
765
|
+
# Convert normalized rating to file rating for RIFF format
|
|
766
|
+
try:
|
|
767
|
+
# Preserve float values to support half-star ratings (consistent with classic star rating systems)
|
|
768
|
+
normalized_rating = float(value)
|
|
769
|
+
file_rating = self._convert_normalized_rating_to_file_rating(normalized_rating=normalized_rating)
|
|
770
|
+
value = file_rating
|
|
771
|
+
except (TypeError, ValueError):
|
|
772
|
+
# If conversion fails, use the original value
|
|
773
|
+
pass
|
|
774
|
+
|
|
775
|
+
if value is None:
|
|
776
|
+
return None
|
|
777
|
+
|
|
778
|
+
return str(value).encode("utf-8")
|
|
779
|
+
|
|
780
|
+
def _create_aligned_metadata_with_proper_padding(self, metadata_id: RawMetadataKey, value_bytes: bytes) -> bytes:
|
|
781
|
+
# Add null terminator
|
|
782
|
+
value_bytes = value_bytes + b"\x00"
|
|
783
|
+
# Pad to even length if needed
|
|
784
|
+
if len(value_bytes) % 2:
|
|
785
|
+
value_bytes = value_bytes + b"\x00"
|
|
786
|
+
|
|
787
|
+
return metadata_id.encode("ascii") + len(value_bytes).to_bytes(4, "little") + value_bytes
|
|
788
|
+
|
|
789
|
+
def _get_genre_code_from_name(self, genre_name: str) -> int | None:
|
|
790
|
+
genre_name_lower = genre_name.lower()
|
|
791
|
+
for code, name in ID3V1_GENRE_CODE_MAP.items():
|
|
792
|
+
if name and name.lower() == genre_name_lower:
|
|
793
|
+
return cast(int | None, code)
|
|
794
|
+
return cast(int | None, 12) # Default to 'Other' genre if not found
|
|
795
|
+
|
|
796
|
+
def _should_preserve_id3v2_tags(self) -> bool:
|
|
797
|
+
"""Determine if ID3v2 tags should be preserved based on the calling context and file state.
|
|
798
|
+
|
|
799
|
+
This method detects if the RIFF manager is being called in a PRESERVE strategy
|
|
800
|
+
context by checking the call stack. In PRESERVE strategy, the high-level
|
|
801
|
+
_handle_metadata_strategy function will restore ID3v2 metadata after RIFF
|
|
802
|
+
writing, so we should not strip it.
|
|
803
|
+
|
|
804
|
+
We preserve ID3v2 tags when:
|
|
805
|
+
1. We're in a PRESERVE strategy context AND we're writing to RIFF format
|
|
806
|
+
2. We're in a SYNC strategy context AND we're writing to RIFF format
|
|
807
|
+
3. ID3v2 tags exist in the file (for coexistence support)
|
|
808
|
+
"""
|
|
809
|
+
import inspect
|
|
810
|
+
|
|
811
|
+
# First, check if ID3v2 tags exist in the file
|
|
812
|
+
# This allows coexistence even when not in a strategy context
|
|
813
|
+
try:
|
|
814
|
+
with Path(self.audio_file.file_path).open("rb") as f:
|
|
815
|
+
first_bytes = f.read(10)
|
|
816
|
+
if first_bytes.startswith(b"ID3"):
|
|
817
|
+
# ID3v2 tags exist, preserve them for coexistence
|
|
818
|
+
return True
|
|
819
|
+
except Exception:
|
|
820
|
+
# If we can't read the file, fall back to strategy detection
|
|
821
|
+
pass
|
|
822
|
+
|
|
823
|
+
# Get the call stack
|
|
824
|
+
frame = inspect.currentframe()
|
|
825
|
+
try:
|
|
826
|
+
# Look for _handle_metadata_strategy in the call stack
|
|
827
|
+
while frame:
|
|
828
|
+
if (
|
|
829
|
+
frame.f_code.co_name == "_handle_metadata_strategy"
|
|
830
|
+
and "strategy" in frame.f_locals
|
|
831
|
+
and "target_format_actual" in frame.f_locals
|
|
832
|
+
):
|
|
833
|
+
# Check if we're in the PRESERVE strategy branch
|
|
834
|
+
# Look at the local variables to determine the strategy and target format
|
|
835
|
+
strategy = frame.f_locals["strategy"]
|
|
836
|
+
target_format = frame.f_locals["target_format_actual"]
|
|
837
|
+
from audiometa.utils.metadata_format import MetadataFormat
|
|
838
|
+
from audiometa.utils.metadata_writing_strategy import MetadataWritingStrategy
|
|
839
|
+
|
|
840
|
+
# Preserve ID3v2 tags when:
|
|
841
|
+
# 1. PRESERVE strategy and target format is RIFF (preserve existing ID3v2 tags)
|
|
842
|
+
# 2. SYNC strategy and target format is RIFF
|
|
843
|
+
# (preserve ID3v2 tags that were written by other managers)
|
|
844
|
+
if strategy in (MetadataWritingStrategy.PRESERVE, MetadataWritingStrategy.SYNC):
|
|
845
|
+
return bool(target_format == MetadataFormat.RIFF)
|
|
846
|
+
return False
|
|
847
|
+
frame = frame.f_back
|
|
848
|
+
finally:
|
|
849
|
+
del frame
|
|
850
|
+
|
|
851
|
+
# Default to not preserving (for backward compatibility)
|
|
852
|
+
return False
|
|
853
|
+
|
|
854
|
+
def _find_riff_header_after_id3v2(self, file_data: bytearray) -> int:
|
|
855
|
+
"""Find the RIFF header after ID3v2 tags in the file data.
|
|
856
|
+
|
|
857
|
+
Returns the position of the RIFF header or -1 if not found.
|
|
858
|
+
"""
|
|
859
|
+
if not file_data.startswith(b"ID3"):
|
|
860
|
+
return -1
|
|
861
|
+
|
|
862
|
+
# Skip ID3v2 tags using existing method
|
|
863
|
+
skipped_data = self._skip_id3v2_tags(bytes(file_data))
|
|
864
|
+
if not skipped_data.startswith(b"RIFF"):
|
|
865
|
+
return -1
|
|
866
|
+
|
|
867
|
+
# Calculate the position where RIFF starts
|
|
868
|
+
return len(file_data) - len(skipped_data)
|
|
869
|
+
|
|
870
|
+
def _get_id3v2_size(self, file_data: bytearray) -> int:
|
|
871
|
+
"""Get the size of ID3v2 tags at the beginning of the file.
|
|
872
|
+
|
|
873
|
+
Returns the total size including header and data.
|
|
874
|
+
"""
|
|
875
|
+
if not file_data.startswith(b"ID3"):
|
|
876
|
+
return 0
|
|
877
|
+
|
|
878
|
+
if len(file_data) < RIFF_MIN_DATA_SIZE_FOR_ID3V2:
|
|
879
|
+
return 0
|
|
880
|
+
|
|
881
|
+
# Get size from synchsafe integer (7 bits per byte)
|
|
882
|
+
size_bytes = file_data[6:ID3V2_HEADER_SIZE]
|
|
883
|
+
size = (
|
|
884
|
+
((size_bytes[0] & 0x7F) << 21)
|
|
885
|
+
| ((size_bytes[1] & 0x7F) << 14)
|
|
886
|
+
| ((size_bytes[2] & 0x7F) << 7)
|
|
887
|
+
| (size_bytes[3] & 0x7F)
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
return 10 + size # Header (10 bytes) + data size
|
|
891
|
+
|
|
892
|
+
def get_header_info(self) -> dict:
|
|
893
|
+
try:
|
|
894
|
+
# Read file data to analyze RIFF structure
|
|
895
|
+
self.audio_file.seek(0)
|
|
896
|
+
file_data = self.audio_file.read()
|
|
897
|
+
|
|
898
|
+
if (
|
|
899
|
+
len(file_data) < RIFF_HEADER_SIZE
|
|
900
|
+
or not file_data.startswith(b"RIFF")
|
|
901
|
+
or file_data[RIFF_WAVE_FORMAT_POSITION:RIFF_HEADER_SIZE] != b"WAVE"
|
|
902
|
+
):
|
|
903
|
+
return {"present": False, "chunk_info": {}}
|
|
904
|
+
|
|
905
|
+
# Parse RIFF chunk info
|
|
906
|
+
riff_chunk_size = int.from_bytes(file_data[4:8], "little")
|
|
907
|
+
info_chunk_size = 0
|
|
908
|
+
audio_format = "Unknown"
|
|
909
|
+
subchunk_size = 0
|
|
910
|
+
|
|
911
|
+
# Find INFO chunk
|
|
912
|
+
pos = 12
|
|
913
|
+
while pos < len(file_data) - 8:
|
|
914
|
+
chunk_id = file_data[pos : pos + 4]
|
|
915
|
+
chunk_size = int.from_bytes(file_data[pos + 4 : pos + 8], "little")
|
|
916
|
+
|
|
917
|
+
if chunk_id == b"LIST" and file_data[pos + 8 : pos + 12] == b"INFO":
|
|
918
|
+
info_chunk_size = chunk_size
|
|
919
|
+
break
|
|
920
|
+
if chunk_id == b"fmt ":
|
|
921
|
+
# Parse format chunk
|
|
922
|
+
if chunk_size >= RIFF_FORMAT_CHUNK_MIN_SIZE:
|
|
923
|
+
audio_format_code = int.from_bytes(file_data[pos + 8 : pos + 10], "little")
|
|
924
|
+
if audio_format_code == 1:
|
|
925
|
+
audio_format = "PCM"
|
|
926
|
+
elif audio_format_code == RIFF_AUDIO_FORMAT_IEEE_FLOAT:
|
|
927
|
+
audio_format = "IEEE Float"
|
|
928
|
+
else:
|
|
929
|
+
audio_format = f"Code {audio_format_code}"
|
|
930
|
+
elif chunk_id == b"data":
|
|
931
|
+
subchunk_size = chunk_size
|
|
932
|
+
break
|
|
933
|
+
|
|
934
|
+
pos += 8 + chunk_size
|
|
935
|
+
if chunk_size % 2 == 1: # Word alignment
|
|
936
|
+
pos += 1
|
|
937
|
+
except Exception:
|
|
938
|
+
return {"present": False, "chunk_info": {}}
|
|
939
|
+
else:
|
|
940
|
+
return {
|
|
941
|
+
"present": True,
|
|
942
|
+
"chunk_info": {
|
|
943
|
+
"riff_chunk_size": riff_chunk_size,
|
|
944
|
+
"info_chunk_size": info_chunk_size,
|
|
945
|
+
"audio_format": audio_format,
|
|
946
|
+
"subchunk_size": subchunk_size,
|
|
947
|
+
},
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
def get_raw_metadata_info(self) -> dict[str, Any]:
|
|
951
|
+
try:
|
|
952
|
+
if self.raw_clean_metadata is None:
|
|
953
|
+
extracted_metadata: RawMetadataDict = self._extract_cleaned_raw_metadata_from_file()
|
|
954
|
+
self.raw_clean_metadata = extracted_metadata
|
|
955
|
+
|
|
956
|
+
if not self.raw_clean_metadata:
|
|
957
|
+
# Still try to extract bext chunk even if no INFO metadata
|
|
958
|
+
chunk_structure = {}
|
|
959
|
+
try:
|
|
960
|
+
self.audio_file.seek(0)
|
|
961
|
+
file_data = self.audio_file.read()
|
|
962
|
+
bext_data = self._extract_bext_chunk(file_data)
|
|
963
|
+
if bext_data:
|
|
964
|
+
chunk_structure["bext"] = bext_data
|
|
965
|
+
except Exception:
|
|
966
|
+
pass
|
|
967
|
+
|
|
968
|
+
return {
|
|
969
|
+
"raw_data": None,
|
|
970
|
+
"parsed_fields": {},
|
|
971
|
+
"frames": {},
|
|
972
|
+
"comments": {},
|
|
973
|
+
"chunk_structure": chunk_structure,
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
raw_clean_metadata: RawMetadataDict = self.raw_clean_metadata
|
|
977
|
+
|
|
978
|
+
# Get parsed fields
|
|
979
|
+
parsed_fields = {}
|
|
980
|
+
for key, value in raw_clean_metadata.items():
|
|
981
|
+
parsed_fields[key] = value[0] if value else ""
|
|
982
|
+
|
|
983
|
+
# Extract bext chunk
|
|
984
|
+
chunk_structure = {}
|
|
985
|
+
try:
|
|
986
|
+
self.audio_file.seek(0)
|
|
987
|
+
file_data = self.audio_file.read()
|
|
988
|
+
bext_data = self._extract_bext_chunk(file_data)
|
|
989
|
+
if bext_data:
|
|
990
|
+
chunk_structure["bext"] = bext_data
|
|
991
|
+
except Exception:
|
|
992
|
+
pass
|
|
993
|
+
except Exception:
|
|
994
|
+
return {"raw_data": None, "parsed_fields": {}, "frames": {}, "comments": {}, "chunk_structure": {}}
|
|
995
|
+
else:
|
|
996
|
+
return {
|
|
997
|
+
"raw_data": None, # RIFF data is complex binary structure
|
|
998
|
+
"parsed_fields": parsed_fields,
|
|
999
|
+
"frames": {},
|
|
1000
|
+
"comments": {},
|
|
1001
|
+
"chunk_structure": chunk_structure,
|
|
1002
|
+
}
|