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,242 @@
|
|
|
1
|
+
"""ID3v1 raw metadata handling."""
|
|
2
|
+
|
|
3
|
+
import struct
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from mutagen._file import FileType
|
|
9
|
+
|
|
10
|
+
from ._constants import (
|
|
11
|
+
ID3V1_MIN_COMMENT_LENGTH_FOR_TRACK_NUMBER,
|
|
12
|
+
ID3V1_TAG_SIZE,
|
|
13
|
+
ID3V1_TRACK_NUMBER_POSITION,
|
|
14
|
+
ID3V1_TRACK_NUMBER_VALUE_POSITION,
|
|
15
|
+
)
|
|
16
|
+
from .id3v1_raw_metadata_key import Id3v1RawMetadataKey
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Id3v1RawMetadata(FileType):
|
|
20
|
+
"""A custom file-like object for ID3v1 tags, providing a consistent interface similar to mutagen.
|
|
21
|
+
|
|
22
|
+
This class encapsulates the ID3v1 128-byte structure and provides a clean interface for accessing and modifying tag
|
|
23
|
+
data. It supports both reading and writing using direct file manipulation.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Id3v1Tag:
|
|
28
|
+
title: str = ""
|
|
29
|
+
artists_names_str: str = ""
|
|
30
|
+
album_name: str = ""
|
|
31
|
+
year: str = ""
|
|
32
|
+
comment: str = ""
|
|
33
|
+
track_number: int | None = None
|
|
34
|
+
genre_code: int = 255 # 255 is undefined genre
|
|
35
|
+
|
|
36
|
+
def __init__(self, fileobj: Any):
|
|
37
|
+
self.fileobj = fileobj
|
|
38
|
+
object.__setattr__(self, "tags", None)
|
|
39
|
+
self._load_tags()
|
|
40
|
+
|
|
41
|
+
def _load_tags(self) -> None:
|
|
42
|
+
# Handle both file objects and file paths
|
|
43
|
+
if isinstance(self.fileobj, str | Path):
|
|
44
|
+
with Path(self.fileobj).open("rb") as f:
|
|
45
|
+
f.seek(-ID3V1_TAG_SIZE, 2) # Seek from end
|
|
46
|
+
data = f.read(ID3V1_TAG_SIZE)
|
|
47
|
+
else:
|
|
48
|
+
self.fileobj.seek(-ID3V1_TAG_SIZE, 2) # Seek from end
|
|
49
|
+
data = self.fileobj.read(ID3V1_TAG_SIZE)
|
|
50
|
+
|
|
51
|
+
if not data.startswith(b"TAG"):
|
|
52
|
+
self.tags = None
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
# Parse the fixed structure into our tag object
|
|
56
|
+
tag = self.Id3v1Tag(
|
|
57
|
+
title=data[3:33].strip(b"\0").decode("latin1", "replace"),
|
|
58
|
+
artists_names_str=data[33:63].strip(b"\0").decode("latin1", "replace"),
|
|
59
|
+
album_name=data[63:93].strip(b"\0").decode("latin1", "replace"),
|
|
60
|
+
year=data[93:97].strip(b"\0").decode("latin1", "replace"),
|
|
61
|
+
genre_code=struct.unpack("B", data[127:128])[0],
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Handle ID3v1.1 track number in comment field
|
|
65
|
+
try:
|
|
66
|
+
comment = data[97:127]
|
|
67
|
+
|
|
68
|
+
# Check for ID3v1.1 track number format (bytes 125-126)
|
|
69
|
+
if (
|
|
70
|
+
len(comment) >= ID3V1_MIN_COMMENT_LENGTH_FOR_TRACK_NUMBER
|
|
71
|
+
and comment[ID3V1_TRACK_NUMBER_POSITION] == 0
|
|
72
|
+
and comment[ID3V1_TRACK_NUMBER_VALUE_POSITION] != 0
|
|
73
|
+
):
|
|
74
|
+
# ID3v1.1 format: track number in last two bytes
|
|
75
|
+
tag.track_number = comment[ID3V1_TRACK_NUMBER_VALUE_POSITION]
|
|
76
|
+
tag.comment = comment[:ID3V1_TRACK_NUMBER_POSITION].strip(b"\0").decode("latin1", "replace")
|
|
77
|
+
else:
|
|
78
|
+
# Regular ID3v1 format: no track number
|
|
79
|
+
tag.track_number = None
|
|
80
|
+
tag.comment = comment.strip(b"\0").decode("latin1", "replace")
|
|
81
|
+
except Exception:
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
# Convert to dictionary format similar to other metadata formats
|
|
85
|
+
tags_dict: dict[Id3v1RawMetadataKey, list[str]] = {}
|
|
86
|
+
if tag.title:
|
|
87
|
+
tags_dict[Id3v1RawMetadataKey.TITLE] = [tag.title]
|
|
88
|
+
if tag.artists_names_str:
|
|
89
|
+
tags_dict[Id3v1RawMetadataKey.ARTISTS_NAMES_STR] = [tag.artists_names_str]
|
|
90
|
+
if tag.album_name:
|
|
91
|
+
tags_dict[Id3v1RawMetadataKey.ALBUM] = [tag.album_name]
|
|
92
|
+
if tag.year:
|
|
93
|
+
tags_dict[Id3v1RawMetadataKey.YEAR] = [tag.year]
|
|
94
|
+
if tag.genre_code is not None:
|
|
95
|
+
tags_dict[Id3v1RawMetadataKey.GENRE_CODE_OR_NAME] = [str(tag.genre_code)]
|
|
96
|
+
if tag.track_number and tag.track_number != 0:
|
|
97
|
+
tags_dict[Id3v1RawMetadataKey.TRACK_NUMBER] = [str(tag.track_number)]
|
|
98
|
+
if tag.comment:
|
|
99
|
+
tags_dict[Id3v1RawMetadataKey.COMMENT] = [tag.comment]
|
|
100
|
+
object.__setattr__(self, "tags", tags_dict)
|
|
101
|
+
|
|
102
|
+
def save(self) -> None:
|
|
103
|
+
"""Save ID3v1 metadata to file using direct file manipulation."""
|
|
104
|
+
if not self.tags:
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
# Read the entire file
|
|
108
|
+
if isinstance(self.fileobj, str | Path): # type: ignore[unreachable]
|
|
109
|
+
# File path
|
|
110
|
+
with Path(self.fileobj).open("rb") as f:
|
|
111
|
+
file_data = bytearray(f.read())
|
|
112
|
+
else:
|
|
113
|
+
# File object - use the same pattern as _load_tags
|
|
114
|
+
self.fileobj.seek(0)
|
|
115
|
+
file_data = bytearray(self.fileobj.read())
|
|
116
|
+
|
|
117
|
+
# Create ID3v1 tag data
|
|
118
|
+
tag_data = self._create_id3v1_tag_data()
|
|
119
|
+
|
|
120
|
+
# Remove existing ID3v1 tag if present
|
|
121
|
+
self._remove_existing_id3v1_tag(file_data)
|
|
122
|
+
|
|
123
|
+
# Append new ID3v1 tag
|
|
124
|
+
file_data.extend(tag_data)
|
|
125
|
+
|
|
126
|
+
# Write back to file
|
|
127
|
+
if isinstance(self.fileobj, str | Path):
|
|
128
|
+
# File path
|
|
129
|
+
with Path(self.fileobj).open("wb") as f:
|
|
130
|
+
f.write(file_data)
|
|
131
|
+
else:
|
|
132
|
+
# File object
|
|
133
|
+
self.fileobj.seek(0)
|
|
134
|
+
self.fileobj.write(file_data)
|
|
135
|
+
self.fileobj.truncate()
|
|
136
|
+
|
|
137
|
+
def _create_id3v1_tag_data(self) -> bytes:
|
|
138
|
+
"""Create 128-byte ID3v1 tag data from current tags."""
|
|
139
|
+
from typing import cast as type_cast
|
|
140
|
+
|
|
141
|
+
tags: dict[Id3v1RawMetadataKey, list[str]] = type_cast(dict[Id3v1RawMetadataKey, list[str]], self.tags)
|
|
142
|
+
if not tags:
|
|
143
|
+
msg = "Tags must be loaded before creating tag data"
|
|
144
|
+
raise ValueError(msg)
|
|
145
|
+
# Initialize with null bytes
|
|
146
|
+
tag_data = bytearray(ID3V1_TAG_SIZE)
|
|
147
|
+
|
|
148
|
+
# TAG identifier (bytes 0-2)
|
|
149
|
+
tag_data[0:3] = b"TAG"
|
|
150
|
+
|
|
151
|
+
# Title (bytes 3-32, 30 chars max)
|
|
152
|
+
title = tags.get(Id3v1RawMetadataKey.TITLE, [""])[0]
|
|
153
|
+
title_bytes = self._truncate_string(title, 30).encode("latin-1", errors="ignore")
|
|
154
|
+
tag_data[3 : 3 + len(title_bytes)] = title_bytes
|
|
155
|
+
|
|
156
|
+
# Artist (bytes 33-62, 30 chars max)
|
|
157
|
+
artist = tags.get(Id3v1RawMetadataKey.ARTISTS_NAMES_STR, [""])[0]
|
|
158
|
+
artist_bytes = self._truncate_string(artist, 30).encode("latin-1", errors="ignore")
|
|
159
|
+
tag_data[33 : 33 + len(artist_bytes)] = artist_bytes
|
|
160
|
+
|
|
161
|
+
# Album (bytes 63-92, 30 chars max)
|
|
162
|
+
album = tags.get(Id3v1RawMetadataKey.ALBUM, [""])[0]
|
|
163
|
+
album_bytes = self._truncate_string(album, 30).encode("latin-1", errors="ignore")
|
|
164
|
+
tag_data[63 : 63 + len(album_bytes)] = album_bytes
|
|
165
|
+
|
|
166
|
+
# Year (bytes 93-96, 4 chars max)
|
|
167
|
+
year = tags.get(Id3v1RawMetadataKey.YEAR, [""])[0]
|
|
168
|
+
year_bytes = self._truncate_string(year, 4).encode("latin-1", errors="ignore")
|
|
169
|
+
tag_data[93 : 93 + len(year_bytes)] = year_bytes
|
|
170
|
+
|
|
171
|
+
# Comment and track number (bytes 97-126, 28 chars for comment + 2 for track)
|
|
172
|
+
comment = tags.get(Id3v1RawMetadataKey.COMMENT, [""])[0]
|
|
173
|
+
comment_bytes = self._truncate_string(comment, 28).encode("latin-1", errors="ignore")
|
|
174
|
+
tag_data[97 : 97 + len(comment_bytes)] = comment_bytes
|
|
175
|
+
|
|
176
|
+
# Track number (bytes 125-126 for ID3v1.1)
|
|
177
|
+
track_number = tags.get(Id3v1RawMetadataKey.TRACK_NUMBER, ["0"])[0]
|
|
178
|
+
if track_number and track_number != "0":
|
|
179
|
+
track_num = max(0, min(255, int(track_number)))
|
|
180
|
+
if track_num > 0:
|
|
181
|
+
tag_data[125] = 0 # Null byte to indicate track number presence
|
|
182
|
+
tag_data[126] = track_num
|
|
183
|
+
|
|
184
|
+
# Genre (byte 127)
|
|
185
|
+
genre_code = tags.get(Id3v1RawMetadataKey.GENRE_CODE_OR_NAME, ["255"])[0]
|
|
186
|
+
try:
|
|
187
|
+
tag_data[127] = int(genre_code)
|
|
188
|
+
except ValueError:
|
|
189
|
+
tag_data[127] = 255 # Unknown genre
|
|
190
|
+
|
|
191
|
+
return bytes(tag_data)
|
|
192
|
+
|
|
193
|
+
def _remove_existing_id3v1_tag(self, file_data: bytearray) -> bool:
|
|
194
|
+
"""Remove existing ID3v1 tag from file data if present.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
bool: True if a tag was removed, False otherwise
|
|
198
|
+
"""
|
|
199
|
+
if len(file_data) >= ID3V1_TAG_SIZE:
|
|
200
|
+
# Check if last 128 bytes contain ID3v1 tag
|
|
201
|
+
last_128 = file_data[-ID3V1_TAG_SIZE:]
|
|
202
|
+
if last_128[:3] == b"TAG":
|
|
203
|
+
# Remove the last 128 bytes
|
|
204
|
+
del file_data[-ID3V1_TAG_SIZE:]
|
|
205
|
+
return True
|
|
206
|
+
return False
|
|
207
|
+
|
|
208
|
+
def _truncate_string(self, text: str, max_length: int) -> str:
|
|
209
|
+
"""Truncate string to maximum length, handling encoding properly."""
|
|
210
|
+
if len(text) <= max_length:
|
|
211
|
+
return text
|
|
212
|
+
return text[:max_length]
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def mime(self) -> list[str]:
|
|
216
|
+
"""Return a list of MIME types this file type could be."""
|
|
217
|
+
return ["audio/mpeg"] # ID3v1 is typically used with MP3 files
|
|
218
|
+
|
|
219
|
+
def add_tags(self) -> None:
|
|
220
|
+
"""Add a new ID3v1 tag to the file."""
|
|
221
|
+
if self.tags is None:
|
|
222
|
+
object.__setattr__(self, "tags", {})
|
|
223
|
+
|
|
224
|
+
def delete(self, filename: str) -> None:
|
|
225
|
+
"""Remove tags from a file."""
|
|
226
|
+
try:
|
|
227
|
+
# Read the entire file
|
|
228
|
+
with Path(filename).open("rb") as f:
|
|
229
|
+
file_data = bytearray(f.read())
|
|
230
|
+
|
|
231
|
+
# Remove existing ID3v1 tag if present
|
|
232
|
+
if self._remove_existing_id3v1_tag(file_data):
|
|
233
|
+
# Write back to file
|
|
234
|
+
with Path(filename).open("wb") as f:
|
|
235
|
+
f.write(file_data)
|
|
236
|
+
except Exception:
|
|
237
|
+
pass # Ignore errors during deletion
|
|
238
|
+
|
|
239
|
+
@staticmethod
|
|
240
|
+
def score(_filename: str, _fileobj: Any, _header: Any) -> int:
|
|
241
|
+
"""Return a score indicating how likely this class can handle the file."""
|
|
242
|
+
return 0 # We don't want this to be auto-detected by mutagen
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from audiometa.utils.unified_metadata_key import UnifiedMetadataKey
|
|
2
|
+
|
|
3
|
+
from ...utils.types import RawMetadataKey
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Id3v1RawMetadataKey(RawMetadataKey):
|
|
7
|
+
TITLE = UnifiedMetadataKey.TITLE
|
|
8
|
+
ARTISTS_NAMES_STR = UnifiedMetadataKey.ARTISTS
|
|
9
|
+
ALBUM = UnifiedMetadataKey.ALBUM
|
|
10
|
+
GENRE_CODE_OR_NAME = "GENRE_CODE_OR_NAME"
|
|
11
|
+
YEAR = "YEAR"
|
|
12
|
+
TRACK_NUMBER = "TRACK_NUMBER"
|
|
13
|
+
COMMENT = "COMMENT"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Test package for audiometa-python."""
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Script to create test audio files for testing."""
|
|
3
|
+
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def create_silent_audio_file(output_path: Path, duration: float = 1.0, sample_rate: int = 44100):
|
|
9
|
+
"""Create a silent audio file using ffmpeg."""
|
|
10
|
+
try:
|
|
11
|
+
# Determine codec based on file extension
|
|
12
|
+
ext = output_path.suffix.lower()
|
|
13
|
+
if ext == ".mp3":
|
|
14
|
+
codec = "libmp3lame"
|
|
15
|
+
bitrate = "128k"
|
|
16
|
+
elif ext == ".flac":
|
|
17
|
+
codec = "flac"
|
|
18
|
+
bitrate = None
|
|
19
|
+
elif ext == ".wav":
|
|
20
|
+
codec = "pcm_s16le"
|
|
21
|
+
bitrate = None
|
|
22
|
+
else:
|
|
23
|
+
codec = "libmp3lame"
|
|
24
|
+
bitrate = "128k"
|
|
25
|
+
|
|
26
|
+
cmd = [
|
|
27
|
+
"ffmpeg",
|
|
28
|
+
"-f",
|
|
29
|
+
"lavfi",
|
|
30
|
+
"-i",
|
|
31
|
+
f"anullsrc=duration={duration}:sample_rate={sample_rate}",
|
|
32
|
+
"-c:a",
|
|
33
|
+
codec,
|
|
34
|
+
"-y", # Overwrite output file
|
|
35
|
+
str(output_path),
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
if bitrate:
|
|
39
|
+
cmd.insert(-1, "-b:a")
|
|
40
|
+
cmd.insert(-1, bitrate)
|
|
41
|
+
|
|
42
|
+
subprocess.run(cmd, check=True, capture_output=True)
|
|
43
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
44
|
+
return False
|
|
45
|
+
else:
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def create_test_files():
|
|
50
|
+
"""Create test audio files in different formats."""
|
|
51
|
+
test_dir = Path(__file__).parent
|
|
52
|
+
test_dir.mkdir(exist_ok=True)
|
|
53
|
+
|
|
54
|
+
# Create different format test files
|
|
55
|
+
formats = [
|
|
56
|
+
("sample.mp3", 1.0),
|
|
57
|
+
("sample.flac", 1.0),
|
|
58
|
+
("sample.wav", 1.0),
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
for filename, duration in formats:
|
|
62
|
+
output_path = test_dir / filename
|
|
63
|
+
if not output_path.exists():
|
|
64
|
+
success = create_silent_audio_file(output_path, duration)
|
|
65
|
+
if not success:
|
|
66
|
+
pass
|
|
67
|
+
else:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
create_test_files()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Test helpers package for audiometa.
|
|
2
|
+
|
|
3
|
+
This package provides utilities for creating and managing test files with metadata.
|
|
4
|
+
Classes are organized by metadata format in subdirectories following clean architecture principles.
|
|
5
|
+
|
|
6
|
+
Main Classes:
|
|
7
|
+
- TempFileWithMetadata: Context manager for test files with comprehensive metadata operations
|
|
8
|
+
Located in: temp_file_with_metadata.py
|
|
9
|
+
|
|
10
|
+
Organized by Format:
|
|
11
|
+
|
|
12
|
+
ID3v1 Format (id3v1/):
|
|
13
|
+
- Id3v1Tool: Wrapper for id3v1 operations using id3v2 tool
|
|
14
|
+
- ID3v1MetadataDeleter: Deleting ID3v1 metadata
|
|
15
|
+
|
|
16
|
+
ID3v2 Format (id3v2/):
|
|
17
|
+
- ID3v2MetadataVerifier: Verifying ID3v2 metadata
|
|
18
|
+
- ID3v2MetadataSetter: Setting ID3v2 metadata including multiple frame values
|
|
19
|
+
|
|
20
|
+
- ManualID3v2FrameCreator: Manual binary construction of ID3v2 frames for testing edge cases
|
|
21
|
+
- ID3HeaderVerifier: Verifying ID3v1/ID3v2 headers
|
|
22
|
+
|
|
23
|
+
Vorbis Format (vorbis/):
|
|
24
|
+
- VorbisMetadataSetter: Setting Vorbis metadata and managing multiple Vorbis comment values
|
|
25
|
+
- VorbisMetadataDeleter: Deleting Vorbis metadata
|
|
26
|
+
- VorbisHeaderVerifier: Verifying Vorbis comment headers and retrieving metadata information
|
|
27
|
+
- VorbisMetadataVerifier: Verifying Vorbis comments
|
|
28
|
+
|
|
29
|
+
RIFF Format (riff/):
|
|
30
|
+
- RIFFMetadataVerifier: Verifying RIFF metadata
|
|
31
|
+
- RIFFMetadataSetter: Setting RIFF metadata, managing separator-based metadata, and managing multiple RIFF chunk values
|
|
32
|
+
- RIFFMetadataDeleter: Deleting RIFF metadata
|
|
33
|
+
- RIFFHeaderVerifier: Verifying RIFF INFO chunk headers and retrieving metadata information
|
|
34
|
+
|
|
35
|
+
Common Utilities (common/):
|
|
36
|
+
- AudioFileCreator: Utilities for creating minimal audio files
|
|
37
|
+
- ComprehensiveMetadataVerifier: Cross-format comprehensive verification and header detection
|
|
38
|
+
- run_script: Unified function for running external scripts with proper error handling
|
|
39
|
+
|
|
40
|
+
Usage:
|
|
41
|
+
from audiometa.test.helpers.temp_file_with_metadata import TempFileWithMetadata
|
|
42
|
+
from audiometa.test.helpers.id3v1 import Id3v1Tool, ID3v1MetadataDeleter
|
|
43
|
+
from audiometa.test.helpers.id3v2 import (
|
|
44
|
+
ID3v2MetadataVerifier, ID3v2MetadataSetter, ManualID3v2FrameCreator, ID3HeaderVerifier
|
|
45
|
+
)
|
|
46
|
+
from audiometa.test.helpers.vorbis import (
|
|
47
|
+
VorbisMetadataSetter, VorbisMetadataDeleter, VorbisHeaderVerifier, VorbisMetadataVerifier
|
|
48
|
+
)
|
|
49
|
+
from audiometa.test.helpers.riff import RIFFMetadataVerifier, RIFFHeaderVerifier
|
|
50
|
+
from audiometa.test.helpers.common import AudioFileCreator, ComprehensiveMetadataVerifier, run_script
|
|
51
|
+
"""
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Audio file creator utilities for testing."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AudioFileCreator:
|
|
9
|
+
"""Utilities for creating minimal audio files for testing."""
|
|
10
|
+
|
|
11
|
+
@staticmethod
|
|
12
|
+
def create_minimal_audio_file(file_path: Path, format_type: str, assets_dir: Path) -> None:
|
|
13
|
+
"""Create a minimal audio file for testing.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
file_path: Path where to create the file
|
|
17
|
+
format_type: Audio format ('mp3', 'flac', 'wav')
|
|
18
|
+
assets_dir: Directory containing template files
|
|
19
|
+
"""
|
|
20
|
+
if format_type.lower() in ["mp3", "id3v1", "id3v2.3", "id3v2.4"]:
|
|
21
|
+
template_file = assets_dir / "metadata=none.mp3"
|
|
22
|
+
elif format_type.lower() == "flac":
|
|
23
|
+
template_file = assets_dir / "metadata=none.flac"
|
|
24
|
+
elif format_type.lower() == "wav":
|
|
25
|
+
template_file = assets_dir / "metadata=none.wav"
|
|
26
|
+
else:
|
|
27
|
+
msg = f"Unsupported format type: {format_type}"
|
|
28
|
+
raise ValueError(msg)
|
|
29
|
+
|
|
30
|
+
if template_file.exists():
|
|
31
|
+
# Copy from template
|
|
32
|
+
shutil.copy2(template_file, file_path)
|
|
33
|
+
else:
|
|
34
|
+
# Fallback: create a minimal file using ffmpeg if available
|
|
35
|
+
try:
|
|
36
|
+
# For id3v1, id3v2.3, id3v2.4, use mp3 format for ffmpeg
|
|
37
|
+
actual_format = "mp3" if format_type.lower() in ["id3v1", "id3v2.3", "id3v2.4"] else format_type.lower()
|
|
38
|
+
AudioFileCreator._create_minimal_audio_with_ffmpeg(file_path, actual_format)
|
|
39
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
40
|
+
# Last resort: copy from any available sample file
|
|
41
|
+
search_format = "mp3" if format_type.lower() in ["id3v1", "id3v2.3", "id3v2.4"] else format_type.lower()
|
|
42
|
+
sample_files = list(assets_dir.glob(f"*.{search_format}"))
|
|
43
|
+
if sample_files:
|
|
44
|
+
shutil.copy2(sample_files[0], file_path)
|
|
45
|
+
else:
|
|
46
|
+
msg = f"No template file found for {format_type}"
|
|
47
|
+
raise RuntimeError(msg) from None
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def _create_minimal_audio_with_ffmpeg(file_path: Path, format_type: str) -> None:
|
|
51
|
+
"""Create a minimal audio file using ffmpeg."""
|
|
52
|
+
# Create 1 second of silence
|
|
53
|
+
cmd = [
|
|
54
|
+
"ffmpeg",
|
|
55
|
+
"-f",
|
|
56
|
+
"lavfi",
|
|
57
|
+
"-i",
|
|
58
|
+
"anullsrc=duration=1",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
# For WAV, don't specify -acodec as ffmpeg will use the default PCM codec
|
|
62
|
+
# For other formats, specify the codec
|
|
63
|
+
if format_type.lower() != "wav":
|
|
64
|
+
cmd.extend(["-acodec", format_type.lower()])
|
|
65
|
+
|
|
66
|
+
cmd.extend(["-y", str(file_path)])
|
|
67
|
+
|
|
68
|
+
subprocess.run(cmd, check=True, capture_output=True)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Unified utility for running external tools and scripts with consistent error handling."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ExternalMetadataToolError(Exception):
|
|
8
|
+
"""Exception raised when external metadata tools fail."""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run_external_tool(
|
|
12
|
+
command: list[str], tool_name: str = "external tool", check: bool = True, input_data: str | bytes | None = None
|
|
13
|
+
) -> subprocess.CompletedProcess:
|
|
14
|
+
"""Run an external tool command with proper error handling.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
command: List of command and arguments to execute
|
|
18
|
+
tool_name: Name of the tool for error messages (e.g., "metaflac", "mid3v2")
|
|
19
|
+
check: Whether to raise exception on non-zero exit code
|
|
20
|
+
input_data: Optional input to pass to stdin
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
subprocess.CompletedProcess: The result of the command execution
|
|
24
|
+
|
|
25
|
+
Raises:
|
|
26
|
+
ExternalMetadataToolError: If the command fails or tool is not found
|
|
27
|
+
"""
|
|
28
|
+
try:
|
|
29
|
+
text = not isinstance(input_data, bytes) if input_data is not None else True
|
|
30
|
+
return subprocess.run(command, capture_output=True, text=text, check=check, input=input_data)
|
|
31
|
+
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
|
32
|
+
msg = f"{tool_name} failed: {e}"
|
|
33
|
+
raise ExternalMetadataToolError(msg) from e
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def run_script(
|
|
37
|
+
script_path: str | Path, target_file: str | Path, scripts_dir: str | Path | None = None
|
|
38
|
+
) -> subprocess.CompletedProcess:
|
|
39
|
+
"""Run a shell script with proper error handling and permissions.
|
|
40
|
+
|
|
41
|
+
Convenience wrapper around run_external_tool for script execution.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
script_path: Path to the script file, or script name if scripts_dir is provided
|
|
45
|
+
target_file: File to pass as argument to the script
|
|
46
|
+
scripts_dir: Directory containing scripts (optional, if script_path is relative)
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
subprocess.CompletedProcess: The result of the script execution
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
FileNotFoundError: If the script file doesn't exist
|
|
53
|
+
ExternalMetadataToolError: If the script execution fails
|
|
54
|
+
"""
|
|
55
|
+
# Resolve script path
|
|
56
|
+
full_script_path = Path(scripts_dir) / script_path if scripts_dir is not None else Path(script_path)
|
|
57
|
+
|
|
58
|
+
# Validate script exists
|
|
59
|
+
if not full_script_path.exists():
|
|
60
|
+
msg = f"Script not found: {full_script_path}"
|
|
61
|
+
raise FileNotFoundError(msg)
|
|
62
|
+
|
|
63
|
+
if not full_script_path.is_file():
|
|
64
|
+
msg = f"Script is not a file: {full_script_path}"
|
|
65
|
+
raise FileNotFoundError(msg)
|
|
66
|
+
|
|
67
|
+
# Make script executable
|
|
68
|
+
full_script_path.chmod(0o755)
|
|
69
|
+
|
|
70
|
+
# Run script using the unified external tool runner
|
|
71
|
+
script_name = full_script_path.name
|
|
72
|
+
command = [str(full_script_path), str(target_file)]
|
|
73
|
+
|
|
74
|
+
return run_external_tool(command, f"script {script_name}")
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""ID3v1 metadata format helpers."""
|
|
2
|
+
|
|
3
|
+
from .id3v1_header_verifier import ID3v1HeaderVerifier
|
|
4
|
+
from .id3v1_metadata_deleter import ID3v1MetadataDeleter
|
|
5
|
+
from .id3v1_metadata_getter import ID3v1MetadataGetter
|
|
6
|
+
from .id3v1_metadata_setter import ID3v1MetadataSetter
|
|
7
|
+
|
|
8
|
+
__all__ = ["ID3v1MetadataDeleter", "ID3v1MetadataSetter", "ID3v1HeaderVerifier", "ID3v1MetadataGetter"]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""ID3v1 metadata header verification utilities."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ID3v1HeaderVerifier:
|
|
7
|
+
"""Utilities for verifying ID3v1 metadata headers in audio files."""
|
|
8
|
+
|
|
9
|
+
@staticmethod
|
|
10
|
+
def has_id3v1_header(file_path: Path) -> bool:
|
|
11
|
+
"""Check if file has ID3v1 header by reading the last 128 bytes."""
|
|
12
|
+
try:
|
|
13
|
+
with file_path.open("rb") as f:
|
|
14
|
+
f.seek(-128, 2) # Seek to last 128 bytes
|
|
15
|
+
header = f.read(128)
|
|
16
|
+
return header[:3] == b"TAG"
|
|
17
|
+
except OSError:
|
|
18
|
+
return False
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""ID3v1 metadata deletion operations."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from ..common.external_tool_runner import ExternalMetadataToolError, run_external_tool
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ID3v1MetadataDeleter:
|
|
9
|
+
"""Static utility class for ID3v1 metadata deletion using external tools."""
|
|
10
|
+
|
|
11
|
+
@staticmethod
|
|
12
|
+
def delete_tag(file_path: Path, tag_name: str) -> None:
|
|
13
|
+
try:
|
|
14
|
+
command = ["id3v2", "--id3v1-only", "--delete", tag_name, str(file_path)]
|
|
15
|
+
run_external_tool(command, "id3v2")
|
|
16
|
+
except ExternalMetadataToolError:
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
@staticmethod
|
|
20
|
+
def delete_comment(file_path: Path) -> None:
|
|
21
|
+
ID3v1MetadataDeleter.delete_tag(file_path, "COMM")
|
|
22
|
+
|
|
23
|
+
@staticmethod
|
|
24
|
+
def delete_title(file_path: Path) -> None:
|
|
25
|
+
ID3v1MetadataDeleter.delete_tag(file_path, "TIT2")
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def delete_artist(file_path: Path) -> None:
|
|
29
|
+
ID3v1MetadataDeleter.delete_tag(file_path, "TPE1")
|
|
30
|
+
|
|
31
|
+
@staticmethod
|
|
32
|
+
def delete_album(file_path: Path) -> None:
|
|
33
|
+
ID3v1MetadataDeleter.delete_tag(file_path, "TALB")
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def delete_genre(file_path: Path) -> None:
|
|
37
|
+
ID3v1MetadataDeleter.delete_tag(file_path, "TCON")
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""ID3v1 metadata inspection utilities for testing audio file metadata."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ID3v1MetadataGetter:
|
|
8
|
+
"""Utilities for inspecting ID3v1 metadata in audio files."""
|
|
9
|
+
|
|
10
|
+
@staticmethod
|
|
11
|
+
def get_raw_metadata(file_path: Path) -> dict[str, Any]:
|
|
12
|
+
"""Return the raw metadata for a specific ID3v1 field."""
|
|
13
|
+
with file_path.open("rb") as f:
|
|
14
|
+
f.seek(-128, 2) # Seek to last 128 bytes (ID3v1 tag location)
|
|
15
|
+
data = f.read(128)
|
|
16
|
+
|
|
17
|
+
# Check for ID3v1 tag header
|
|
18
|
+
if not data.startswith(b"TAG"):
|
|
19
|
+
return {}
|
|
20
|
+
|
|
21
|
+
# Determine if it's ID3v1.1 (has track number)
|
|
22
|
+
is_id3v1_1 = data[125] != 0
|
|
23
|
+
|
|
24
|
+
# Parse fields based on ID3v1 specification
|
|
25
|
+
field_info = {
|
|
26
|
+
"title": (3, 33, 30), # bytes 3-32, 30 chars max
|
|
27
|
+
"artist": (33, 63, 30), # bytes 33-62, 30 chars max
|
|
28
|
+
"album": (63, 93, 30), # bytes 63-92, 30 chars max
|
|
29
|
+
"year": (93, 97, 4), # bytes 93-96, 4 chars max
|
|
30
|
+
"genre": (127, 128, 1), # byte 127
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if is_id3v1_1:
|
|
34
|
+
field_info["comment"] = (97, 125, 28) # bytes 97-124, 28 chars max (ID3v1.1)
|
|
35
|
+
field_info["track"] = (125, 126, 1) # byte 125 (ID3v1.1)
|
|
36
|
+
else:
|
|
37
|
+
field_info["comment"] = (97, 127, 30) # bytes 97-126, 30 chars max (ID3v1)
|
|
38
|
+
|
|
39
|
+
metadata: dict[str, str | int | None] = {}
|
|
40
|
+
for field, (start, end, _max_chars) in field_info.items():
|
|
41
|
+
raw_bytes = data[start:end]
|
|
42
|
+
if field in ["title", "artist", "album", "year", "comment"]:
|
|
43
|
+
metadata[field] = raw_bytes.decode("latin-1").rstrip("\x00")
|
|
44
|
+
elif field == "track":
|
|
45
|
+
metadata[field] = raw_bytes[0] if raw_bytes and raw_bytes[0] != 0 else None
|
|
46
|
+
elif field == "genre":
|
|
47
|
+
metadata[field] = raw_bytes[0] if raw_bytes else 0
|
|
48
|
+
|
|
49
|
+
# Handle ID3v1 track number stored in comment field (non-standard but common)
|
|
50
|
+
if "track" not in metadata or metadata["track"] is None:
|
|
51
|
+
comment = metadata.get("comment", "")
|
|
52
|
+
if isinstance(comment, str) and len(comment) == 30 and comment[-1] != "\x00":
|
|
53
|
+
metadata["track"] = ord(comment[-1])
|
|
54
|
+
metadata["comment"] = comment[:-1].rstrip("\x00")
|
|
55
|
+
|
|
56
|
+
return metadata
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def get_title(file_path):
|
|
60
|
+
metadata = ID3v1MetadataGetter.get_raw_metadata(file_path)
|
|
61
|
+
return metadata.get("title", "")
|