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
audiometa/__main__.py
ADDED
audiometa/_audio_file.py
ADDED
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
"""Audio file handling module."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import json
|
|
5
|
+
import subprocess
|
|
6
|
+
import tempfile
|
|
7
|
+
import types
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import cast
|
|
10
|
+
|
|
11
|
+
from mutagen.flac import FLAC, StreamInfo
|
|
12
|
+
from mutagen.mp3 import MP3
|
|
13
|
+
from mutagen.wave import WAVE
|
|
14
|
+
|
|
15
|
+
from .exceptions import (
|
|
16
|
+
AudioFileMetadataParseError,
|
|
17
|
+
DurationNotFoundError,
|
|
18
|
+
FileByteMismatchError,
|
|
19
|
+
FileCorruptedError,
|
|
20
|
+
FileTypeNotSupportedError,
|
|
21
|
+
FlacMd5CheckFailedError,
|
|
22
|
+
InvalidChunkDecodeError,
|
|
23
|
+
)
|
|
24
|
+
from .manager._rating_supporting.id3v2._id3v2_constants import ID3V2_HEADER_SIZE
|
|
25
|
+
from .manager._rating_supporting.riff._riff_constants import RIFF_HEADER_SIZE
|
|
26
|
+
from .utils.metadata_format import MetadataFormat
|
|
27
|
+
from .utils.mutagen_exception_handler import handle_mutagen_exception
|
|
28
|
+
from .utils.tool_path_resolver import get_tool_path
|
|
29
|
+
|
|
30
|
+
# Type alias for files that can be handled (must be disk-based)
|
|
31
|
+
type DiskBasedFile = str | Path | bytes | object
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _AudioFile:
|
|
35
|
+
file: DiskBasedFile
|
|
36
|
+
file_path: str
|
|
37
|
+
|
|
38
|
+
def __init__(self, file: DiskBasedFile):
|
|
39
|
+
if isinstance(file, str):
|
|
40
|
+
self.file = file
|
|
41
|
+
self.file_path = file
|
|
42
|
+
elif isinstance(file, Path):
|
|
43
|
+
# Handle pathlib.Path objects
|
|
44
|
+
self.file = file
|
|
45
|
+
self.file_path = str(file)
|
|
46
|
+
elif hasattr(file, "path"):
|
|
47
|
+
# Handle objects with a path attribute (like TempFileWithMetadata)
|
|
48
|
+
self.file = file
|
|
49
|
+
self.file_path = str(file.path)
|
|
50
|
+
elif hasattr(file, "name"):
|
|
51
|
+
# Handle file-like objects with a name attribute
|
|
52
|
+
self.file = file
|
|
53
|
+
self.file_path = file.name
|
|
54
|
+
elif hasattr(file, "temporary_file_path"):
|
|
55
|
+
# Handle temporary uploaded files
|
|
56
|
+
self.file = file
|
|
57
|
+
self.file_path = file.temporary_file_path()
|
|
58
|
+
else:
|
|
59
|
+
msg = f"Unsupported file type: {type(file)}"
|
|
60
|
+
raise FileTypeNotSupportedError(msg)
|
|
61
|
+
|
|
62
|
+
if not Path(self.file_path).exists():
|
|
63
|
+
msg = f"File {self.file_path} does not exist"
|
|
64
|
+
raise FileNotFoundError(msg)
|
|
65
|
+
|
|
66
|
+
file_extension = Path(self.file_path).suffix.lower()
|
|
67
|
+
self.file_extension = file_extension
|
|
68
|
+
|
|
69
|
+
# Validate that the file type is supported
|
|
70
|
+
supported_extensions = MetadataFormat.get_priorities().keys()
|
|
71
|
+
if file_extension not in supported_extensions:
|
|
72
|
+
msg = f"File type {file_extension} is not supported. Supported types: {', '.join(supported_extensions)}"
|
|
73
|
+
raise FileTypeNotSupportedError(msg)
|
|
74
|
+
|
|
75
|
+
# Validate that the file content is valid for the format
|
|
76
|
+
try:
|
|
77
|
+
if file_extension == ".mp3":
|
|
78
|
+
MP3(self.file_path)
|
|
79
|
+
elif file_extension == ".flac":
|
|
80
|
+
FLAC(self.file_path)
|
|
81
|
+
elif file_extension == ".wav":
|
|
82
|
+
# Use custom WAV validation that handles ID3v2 tags
|
|
83
|
+
self._validate_wav_file(self.file_path)
|
|
84
|
+
except Exception as e:
|
|
85
|
+
msg = f"The file content is corrupted or not a valid {file_extension.upper()} file: {e!s}"
|
|
86
|
+
raise FileCorruptedError(msg) from e
|
|
87
|
+
|
|
88
|
+
def get_duration_in_sec(self) -> float:
|
|
89
|
+
path = self.file_path
|
|
90
|
+
|
|
91
|
+
if self.file_extension == ".mp3":
|
|
92
|
+
try:
|
|
93
|
+
audio = MP3(path)
|
|
94
|
+
return float(audio.info.length)
|
|
95
|
+
except Exception as exc:
|
|
96
|
+
# If MP3 fails, try other formats as fallback
|
|
97
|
+
try:
|
|
98
|
+
wave_audio = WAVE(path)
|
|
99
|
+
return float(wave_audio.info.length) # type: ignore[attr-defined,unused-ignore]
|
|
100
|
+
except Exception:
|
|
101
|
+
try:
|
|
102
|
+
flac_audio = FLAC(path)
|
|
103
|
+
return float(flac_audio.info.length) # type: ignore[attr-defined,unused-ignore]
|
|
104
|
+
except Exception:
|
|
105
|
+
msg = f"Could not determine duration for {path}"
|
|
106
|
+
raise DurationNotFoundError(msg) from exc
|
|
107
|
+
|
|
108
|
+
elif self.file_extension == ".wav":
|
|
109
|
+
try:
|
|
110
|
+
# Use ffprobe to get duration, more tolerant of file format issues
|
|
111
|
+
result = subprocess.run(
|
|
112
|
+
[
|
|
113
|
+
get_tool_path("ffprobe"),
|
|
114
|
+
"-v",
|
|
115
|
+
"quiet",
|
|
116
|
+
"-print_format",
|
|
117
|
+
"json",
|
|
118
|
+
"-show_format",
|
|
119
|
+
"-show_streams",
|
|
120
|
+
path,
|
|
121
|
+
],
|
|
122
|
+
capture_output=True,
|
|
123
|
+
text=True,
|
|
124
|
+
check=False,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if result.returncode != 0:
|
|
128
|
+
msg = "Failed to probe audio file"
|
|
129
|
+
raise RuntimeError(msg)
|
|
130
|
+
|
|
131
|
+
data = json.loads(result.stdout)
|
|
132
|
+
# Try format duration first, then stream duration if available
|
|
133
|
+
duration = float(
|
|
134
|
+
data.get("format", {}).get("duration")
|
|
135
|
+
or next((s.get("duration") for s in data.get("streams", []) if s.get("duration")), 0)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if duration <= 0:
|
|
139
|
+
msg = "Could not determine audio duration"
|
|
140
|
+
raise DurationNotFoundError(msg) from None
|
|
141
|
+
except json.JSONDecodeError as e:
|
|
142
|
+
msg = "Failed to parse audio file metadata from ffprobe output"
|
|
143
|
+
raise AudioFileMetadataParseError(msg) from e
|
|
144
|
+
except DurationNotFoundError:
|
|
145
|
+
raise
|
|
146
|
+
except Exception as exc:
|
|
147
|
+
if str(exc) == "Failed to probe audio file":
|
|
148
|
+
msg = "ffprobe could not parse the audio file."
|
|
149
|
+
raise FileCorruptedError(msg) from exc
|
|
150
|
+
msg = f"Failed to read WAV file duration: {exc!s}"
|
|
151
|
+
raise RuntimeError(msg) from exc
|
|
152
|
+
else:
|
|
153
|
+
return duration
|
|
154
|
+
|
|
155
|
+
elif self.file_extension == ".flac":
|
|
156
|
+
try:
|
|
157
|
+
return float(FLAC(path).info.length)
|
|
158
|
+
except Exception as exc:
|
|
159
|
+
error_str = str(exc)
|
|
160
|
+
if "file said" in error_str and "bytes, read" in error_str:
|
|
161
|
+
raise FileByteMismatchError(error_str.capitalize()) from exc
|
|
162
|
+
if "FLAC" in error_str or "chunk" in error_str.lower():
|
|
163
|
+
msg = f"Failed to decode FLAC chunks: {error_str}"
|
|
164
|
+
raise InvalidChunkDecodeError(msg) from exc
|
|
165
|
+
handle_mutagen_exception("read duration from FLAC file", path, exc)
|
|
166
|
+
return 0.0 # Never reached, but satisfies type checker
|
|
167
|
+
else:
|
|
168
|
+
msg = f"Reading is not supported for file type: {self.file_extension}"
|
|
169
|
+
raise FileTypeNotSupportedError(msg)
|
|
170
|
+
|
|
171
|
+
def get_bitrate(self) -> int:
|
|
172
|
+
path = self.file_path
|
|
173
|
+
if self.file_extension == ".mp3":
|
|
174
|
+
audio = MP3(path)
|
|
175
|
+
# Get MP3 bitrate directly from audio stream
|
|
176
|
+
if audio.info.bitrate:
|
|
177
|
+
return int(audio.info.bitrate)
|
|
178
|
+
return 0
|
|
179
|
+
if self.file_extension == ".wav":
|
|
180
|
+
try:
|
|
181
|
+
# Use ffprobe to get audio stream information
|
|
182
|
+
result = subprocess.run(
|
|
183
|
+
[
|
|
184
|
+
"ffprobe",
|
|
185
|
+
"-v",
|
|
186
|
+
"quiet",
|
|
187
|
+
"-print_format",
|
|
188
|
+
"json",
|
|
189
|
+
"-show_streams",
|
|
190
|
+
"-select_streams",
|
|
191
|
+
"a:0", # Select first audio stream
|
|
192
|
+
path,
|
|
193
|
+
],
|
|
194
|
+
capture_output=True,
|
|
195
|
+
text=True,
|
|
196
|
+
check=False,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
if result.returncode != 0:
|
|
200
|
+
msg = "Failed to probe audio file"
|
|
201
|
+
raise RuntimeError(msg) from None
|
|
202
|
+
|
|
203
|
+
data = json.loads(result.stdout)
|
|
204
|
+
if not data.get("streams"):
|
|
205
|
+
msg = "No audio streams found"
|
|
206
|
+
raise RuntimeError(msg) from None
|
|
207
|
+
|
|
208
|
+
stream = data["streams"][0]
|
|
209
|
+
# Get bitrate directly if available
|
|
210
|
+
if "bit_rate" in stream:
|
|
211
|
+
return int(stream["bit_rate"])
|
|
212
|
+
|
|
213
|
+
# Calculate from sample_rate * channels * bits_per_sample if no direct bitrate
|
|
214
|
+
sample_rate = int(stream.get("sample_rate", 0))
|
|
215
|
+
channels = int(stream.get("channels", 0))
|
|
216
|
+
bits_per_sample = int(stream.get("bits_per_raw_sample", 0) or stream.get("bits_per_sample", 0))
|
|
217
|
+
|
|
218
|
+
if not all([sample_rate, channels, bits_per_sample]):
|
|
219
|
+
msg = "Missing audio stream information"
|
|
220
|
+
raise RuntimeError(msg) from None
|
|
221
|
+
|
|
222
|
+
return sample_rate * channels * bits_per_sample
|
|
223
|
+
except json.JSONDecodeError as e:
|
|
224
|
+
msg = "Failed to parse audio file metadata from ffprobe output"
|
|
225
|
+
raise AudioFileMetadataParseError(msg) from e
|
|
226
|
+
except Exception as exc:
|
|
227
|
+
msg = f"Failed to read WAV file bitrate: {exc!s}"
|
|
228
|
+
raise RuntimeError(msg) from exc
|
|
229
|
+
elif self.file_extension == ".flac":
|
|
230
|
+
audio_info = cast(StreamInfo, FLAC(path).info)
|
|
231
|
+
return int(audio_info.bitrate)
|
|
232
|
+
else:
|
|
233
|
+
msg = f"Reading is not supported for file type: {self.file_extension}"
|
|
234
|
+
raise FileTypeNotSupportedError(msg)
|
|
235
|
+
|
|
236
|
+
def read(self, size: int = -1) -> bytes:
|
|
237
|
+
with Path(self.file_path).open("rb") as f:
|
|
238
|
+
return f.read(size)
|
|
239
|
+
|
|
240
|
+
def write(self, data: bytes) -> int:
|
|
241
|
+
with Path(self.file_path).open("wb") as f:
|
|
242
|
+
return f.write(data)
|
|
243
|
+
|
|
244
|
+
def seek(self, offset: int, whence: int = 0) -> int:
|
|
245
|
+
with Path(self.file_path).open("rb") as f:
|
|
246
|
+
return f.seek(offset, whence)
|
|
247
|
+
|
|
248
|
+
def close(self) -> None:
|
|
249
|
+
if hasattr(self.file, "close"):
|
|
250
|
+
self.file.close()
|
|
251
|
+
|
|
252
|
+
def __enter__(self) -> "_AudioFile":
|
|
253
|
+
return self
|
|
254
|
+
|
|
255
|
+
def __exit__(
|
|
256
|
+
self,
|
|
257
|
+
exc_type: type[BaseException] | None,
|
|
258
|
+
exc_val: BaseException | None,
|
|
259
|
+
exc_tb: types.TracebackType | None,
|
|
260
|
+
) -> None:
|
|
261
|
+
self.close()
|
|
262
|
+
|
|
263
|
+
def get_file_path_or_object(self) -> str:
|
|
264
|
+
"""Get the path to the file on the filesystem."""
|
|
265
|
+
return self.file_path
|
|
266
|
+
|
|
267
|
+
def _is_md5_unset(self) -> bool:
|
|
268
|
+
"""Check if FLAC file has unset MD5 checksum (all zeros)."""
|
|
269
|
+
try:
|
|
270
|
+
with Path(self.file_path).open("rb") as f:
|
|
271
|
+
data = f.read()
|
|
272
|
+
flac_marker_pos = data.find(b"fLaC")
|
|
273
|
+
if flac_marker_pos == -1:
|
|
274
|
+
return False
|
|
275
|
+
md5_start = flac_marker_pos + 4 + 1 + 18
|
|
276
|
+
if md5_start + 16 > len(data):
|
|
277
|
+
return False
|
|
278
|
+
md5_bytes = data[md5_start : md5_start + 16]
|
|
279
|
+
return md5_bytes == b"\x00" * 16
|
|
280
|
+
except Exception:
|
|
281
|
+
return False
|
|
282
|
+
|
|
283
|
+
def is_flac_file_md5_valid(self) -> bool:
|
|
284
|
+
if self.file_extension != ".flac":
|
|
285
|
+
msg = "The file is not a FLAC file"
|
|
286
|
+
raise FileTypeNotSupportedError(msg)
|
|
287
|
+
|
|
288
|
+
if self._is_md5_unset():
|
|
289
|
+
return False
|
|
290
|
+
|
|
291
|
+
result = subprocess.run([get_tool_path("flac"), "-t", self.file_path], capture_output=True, check=False)
|
|
292
|
+
|
|
293
|
+
# Combine stdout and stderr as flac may output to either
|
|
294
|
+
stdout_output = result.stdout.decode()
|
|
295
|
+
stderr_output = result.stderr.decode()
|
|
296
|
+
combined_output = stdout_output + stderr_output
|
|
297
|
+
|
|
298
|
+
# flac -t returns 0 on success, non-zero on error
|
|
299
|
+
# If return code is non-zero, the file is invalid
|
|
300
|
+
if result.returncode != 0:
|
|
301
|
+
return False
|
|
302
|
+
|
|
303
|
+
# Check for explicit error messages (shouldn't happen with return code 0, but defensive)
|
|
304
|
+
if "MD5 signature mismatch" in combined_output:
|
|
305
|
+
return False
|
|
306
|
+
if "FLAC__STREAM_DECODER_ERROR_STATUS_LOST_SYNC" in combined_output:
|
|
307
|
+
return False
|
|
308
|
+
|
|
309
|
+
# Check for explicit success message
|
|
310
|
+
if "ok" in combined_output.lower():
|
|
311
|
+
return True
|
|
312
|
+
|
|
313
|
+
# If return code was 0 but no "ok" found, something unexpected happened
|
|
314
|
+
msg = "The Flac file md5 check failed"
|
|
315
|
+
raise FlacMd5CheckFailedError(msg)
|
|
316
|
+
|
|
317
|
+
def get_file_with_corrected_md5(self, delete_original: bool = False) -> str:
|
|
318
|
+
"""Get a new temporary file with corrected MD5 signature.
|
|
319
|
+
|
|
320
|
+
Returns the path to the corrected file.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
delete_original: If True, deletes the original file after creating the corrected version.
|
|
324
|
+
Defaults to False to maintain backward compatibility.
|
|
325
|
+
|
|
326
|
+
Raises:
|
|
327
|
+
FileCorruptedError: If the FLAC file is corrupted or cannot be corrected
|
|
328
|
+
RuntimeError: If the FLAC command fails to execute
|
|
329
|
+
OSError: If deletion of the original file fails when delete_original is True
|
|
330
|
+
"""
|
|
331
|
+
if self.file_extension != ".flac":
|
|
332
|
+
msg = "The file is not a FLAC file"
|
|
333
|
+
raise FileTypeNotSupportedError(msg)
|
|
334
|
+
|
|
335
|
+
# Create a temporary file to store the corrected FLAC content
|
|
336
|
+
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".flac")
|
|
337
|
+
temp_path = temp_file.name
|
|
338
|
+
temp_file.close() # Close but don't delete yet
|
|
339
|
+
|
|
340
|
+
success = False
|
|
341
|
+
try:
|
|
342
|
+
# Read the input file and run FLAC command
|
|
343
|
+
with Path(self.file_path).open("rb") as f:
|
|
344
|
+
result = subprocess.run(
|
|
345
|
+
[get_tool_path("flac"), "-f", "--best", "-o", temp_path, "-"],
|
|
346
|
+
stdin=f,
|
|
347
|
+
capture_output=True,
|
|
348
|
+
check=False,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
if result.returncode != 0:
|
|
352
|
+
stderr = result.stderr.decode()
|
|
353
|
+
if "wrote" not in stderr:
|
|
354
|
+
# Clean up any empty file created by failed flac command
|
|
355
|
+
temp_path_obj = Path(temp_path)
|
|
356
|
+
if temp_path_obj.exists() and temp_path_obj.stat().st_size == 0:
|
|
357
|
+
temp_path_obj.unlink(missing_ok=True)
|
|
358
|
+
|
|
359
|
+
# Try reencoding with ffmpeg as a fallback
|
|
360
|
+
# Use -y to overwrite any existing file
|
|
361
|
+
ffmpeg_cmd = [get_tool_path("ffmpeg"), "-i", self.file_path, "-c:a", "flac", "-y", temp_path]
|
|
362
|
+
|
|
363
|
+
ffmpeg_result = subprocess.run(ffmpeg_cmd, capture_output=True, check=False)
|
|
364
|
+
|
|
365
|
+
if ffmpeg_result.returncode != 0:
|
|
366
|
+
msg = (
|
|
367
|
+
"The FLAC file MD5 check failed and reencoding attempts were unsuccessful. "
|
|
368
|
+
"The file is probably corrupted."
|
|
369
|
+
)
|
|
370
|
+
raise FileCorruptedError(msg)
|
|
371
|
+
|
|
372
|
+
# Verify the output file exists and is valid
|
|
373
|
+
temp_path_obj = Path(temp_path)
|
|
374
|
+
if not temp_path_obj.exists() or temp_path_obj.stat().st_size == 0:
|
|
375
|
+
msg = "Failed to create corrected FLAC file"
|
|
376
|
+
raise FileCorruptedError(msg)
|
|
377
|
+
|
|
378
|
+
success = True
|
|
379
|
+
|
|
380
|
+
# If requested, try to delete the original file
|
|
381
|
+
if delete_original and success:
|
|
382
|
+
try:
|
|
383
|
+
Path(self.file_path).unlink()
|
|
384
|
+
except OSError as e:
|
|
385
|
+
msg = f"Failed to delete original file: {e!s}"
|
|
386
|
+
raise OSError(msg) from e
|
|
387
|
+
|
|
388
|
+
except (subprocess.SubprocessError, OSError) as e:
|
|
389
|
+
msg = f"Failed to execute FLAC command: {e!s}"
|
|
390
|
+
raise RuntimeError(msg) from e
|
|
391
|
+
except Exception as e:
|
|
392
|
+
handle_mutagen_exception("fix FLAC MD5 checksum", self.file_path, e)
|
|
393
|
+
return "" # Never reached, but satisfies type checker
|
|
394
|
+
else:
|
|
395
|
+
return temp_path
|
|
396
|
+
finally:
|
|
397
|
+
# Clean up the temp file only if we failed
|
|
398
|
+
if not success and Path(temp_path).exists():
|
|
399
|
+
with contextlib.suppress(OSError):
|
|
400
|
+
Path(temp_path).unlink()
|
|
401
|
+
|
|
402
|
+
def get_sample_rate(self) -> int:
|
|
403
|
+
"""Get the sample rate of an audio file.
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
Sample rate in Hz
|
|
407
|
+
|
|
408
|
+
Raises:
|
|
409
|
+
FileTypeNotSupportedError: If the file format is not supported
|
|
410
|
+
FileNotFoundError: If the file does not exist
|
|
411
|
+
"""
|
|
412
|
+
if self.file_extension == ".mp3":
|
|
413
|
+
try:
|
|
414
|
+
audio = MP3(self.file_path)
|
|
415
|
+
if audio.info.sample_rate is not None:
|
|
416
|
+
return int(float(audio.info.sample_rate))
|
|
417
|
+
except Exception:
|
|
418
|
+
pass
|
|
419
|
+
return 0
|
|
420
|
+
if self.file_extension == ".wav":
|
|
421
|
+
try:
|
|
422
|
+
result = subprocess.run(
|
|
423
|
+
[
|
|
424
|
+
"ffprobe",
|
|
425
|
+
"-v",
|
|
426
|
+
"quiet",
|
|
427
|
+
"-print_format",
|
|
428
|
+
"json",
|
|
429
|
+
"-show_streams",
|
|
430
|
+
"-select_streams",
|
|
431
|
+
"a:0",
|
|
432
|
+
self.file_path,
|
|
433
|
+
],
|
|
434
|
+
capture_output=True,
|
|
435
|
+
text=True,
|
|
436
|
+
check=False,
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
if result.returncode != 0:
|
|
440
|
+
return 0
|
|
441
|
+
|
|
442
|
+
data = json.loads(result.stdout)
|
|
443
|
+
if not data.get("streams"):
|
|
444
|
+
return 0
|
|
445
|
+
|
|
446
|
+
stream = data["streams"][0]
|
|
447
|
+
return int(stream.get("sample_rate", 0))
|
|
448
|
+
except Exception:
|
|
449
|
+
return 0
|
|
450
|
+
elif self.file_extension == ".flac":
|
|
451
|
+
try:
|
|
452
|
+
audio_info = cast(StreamInfo, FLAC(self.file_path).info)
|
|
453
|
+
return int(float(audio_info.sample_rate))
|
|
454
|
+
except Exception:
|
|
455
|
+
return 0
|
|
456
|
+
else:
|
|
457
|
+
msg = f"Reading is not supported for file type: {self.file_extension}"
|
|
458
|
+
raise FileTypeNotSupportedError(msg)
|
|
459
|
+
|
|
460
|
+
def get_channels(self) -> int:
|
|
461
|
+
"""Get the number of channels in an audio file.
|
|
462
|
+
|
|
463
|
+
Returns:
|
|
464
|
+
Number of channels
|
|
465
|
+
|
|
466
|
+
Raises:
|
|
467
|
+
FileTypeNotSupportedError: If the file format is not supported
|
|
468
|
+
FileNotFoundError: If the file does not exist
|
|
469
|
+
"""
|
|
470
|
+
if self.file_extension == ".mp3":
|
|
471
|
+
try:
|
|
472
|
+
audio = MP3(self.file_path)
|
|
473
|
+
if audio.info.channels is not None:
|
|
474
|
+
return int(float(audio.info.channels))
|
|
475
|
+
except Exception:
|
|
476
|
+
pass
|
|
477
|
+
return 0
|
|
478
|
+
if self.file_extension == ".wav":
|
|
479
|
+
try:
|
|
480
|
+
result = subprocess.run(
|
|
481
|
+
[
|
|
482
|
+
"ffprobe",
|
|
483
|
+
"-v",
|
|
484
|
+
"quiet",
|
|
485
|
+
"-print_format",
|
|
486
|
+
"json",
|
|
487
|
+
"-show_streams",
|
|
488
|
+
"-select_streams",
|
|
489
|
+
"a:0",
|
|
490
|
+
self.file_path,
|
|
491
|
+
],
|
|
492
|
+
capture_output=True,
|
|
493
|
+
text=True,
|
|
494
|
+
check=False,
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
if result.returncode != 0:
|
|
498
|
+
return 0
|
|
499
|
+
|
|
500
|
+
data = json.loads(result.stdout)
|
|
501
|
+
if not data.get("streams"):
|
|
502
|
+
return 0
|
|
503
|
+
|
|
504
|
+
stream = data["streams"][0]
|
|
505
|
+
return int(stream.get("channels", 0))
|
|
506
|
+
except Exception:
|
|
507
|
+
return 0
|
|
508
|
+
elif self.file_extension == ".flac":
|
|
509
|
+
try:
|
|
510
|
+
audio_info = cast(StreamInfo, FLAC(self.file_path).info)
|
|
511
|
+
return int(float(audio_info.channels))
|
|
512
|
+
except Exception:
|
|
513
|
+
return 0
|
|
514
|
+
else:
|
|
515
|
+
msg = f"Reading is not supported for file type: {self.file_extension}"
|
|
516
|
+
raise FileTypeNotSupportedError(msg)
|
|
517
|
+
|
|
518
|
+
def get_file_size(self) -> int:
|
|
519
|
+
"""Get the file size in bytes.
|
|
520
|
+
|
|
521
|
+
Returns:
|
|
522
|
+
File size in bytes
|
|
523
|
+
"""
|
|
524
|
+
try:
|
|
525
|
+
return Path(self.file_path).stat().st_size
|
|
526
|
+
except OSError:
|
|
527
|
+
return 0
|
|
528
|
+
|
|
529
|
+
def get_audio_format_name(self) -> str:
|
|
530
|
+
"""Get the human-readable format name.
|
|
531
|
+
|
|
532
|
+
Returns:
|
|
533
|
+
Audio format name (e.g., 'MP3', 'FLAC', 'WAV')
|
|
534
|
+
"""
|
|
535
|
+
audio_format_names = {".mp3": "MP3", ".flac": "FLAC", ".wav": "WAV"}
|
|
536
|
+
return audio_format_names.get(self.file_extension, "Unknown")
|
|
537
|
+
|
|
538
|
+
def _skip_id3v2_tags(self, data: bytes) -> bytes:
|
|
539
|
+
"""Skip ID3v2 tags if present at the start of the file.
|
|
540
|
+
|
|
541
|
+
Returns the data starting from after any ID3v2 tags.
|
|
542
|
+
"""
|
|
543
|
+
if data.startswith(b"ID3"):
|
|
544
|
+
# ID3v2 header is 10 bytes:
|
|
545
|
+
# 3 bytes: ID3
|
|
546
|
+
# 2 bytes: version
|
|
547
|
+
# 1 byte: flags
|
|
548
|
+
# 4 bytes: size (synchsafe integer)
|
|
549
|
+
if len(data) < ID3V2_HEADER_SIZE:
|
|
550
|
+
return data
|
|
551
|
+
|
|
552
|
+
# Get size from synchsafe integer (7 bits per byte)
|
|
553
|
+
size_bytes = data[6:ID3V2_HEADER_SIZE]
|
|
554
|
+
size = (
|
|
555
|
+
((size_bytes[0] & 0x7F) << 21)
|
|
556
|
+
| ((size_bytes[1] & 0x7F) << 14)
|
|
557
|
+
| ((size_bytes[2] & 0x7F) << 7)
|
|
558
|
+
| (size_bytes[3] & 0x7F)
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
# Skip the header (10 bytes) plus the size of the tag
|
|
562
|
+
return data[ID3V2_HEADER_SIZE + size :]
|
|
563
|
+
return data
|
|
564
|
+
|
|
565
|
+
def _validate_wav_file(self, file_path: str) -> None:
|
|
566
|
+
"""Validate WAV file structure, handling ID3v2 tags at the beginning.
|
|
567
|
+
|
|
568
|
+
This method performs lightweight validation of the RIFF/WAV structure without relying on mutagen for files that
|
|
569
|
+
have ID3v2 tags.
|
|
570
|
+
"""
|
|
571
|
+
with Path(file_path).open("rb") as f:
|
|
572
|
+
# Read enough data to cover potential ID3v2 tags (up to ~1MB for very large tags)
|
|
573
|
+
header_data = f.read(RIFF_HEADER_SIZE)
|
|
574
|
+
|
|
575
|
+
# Skip ID3v2 tags if present
|
|
576
|
+
if header_data.startswith(b"ID3"):
|
|
577
|
+
# Read the full file to properly handle ID3v2 tags
|
|
578
|
+
f.seek(0)
|
|
579
|
+
full_data = f.read()
|
|
580
|
+
|
|
581
|
+
# Skip the ID3v2 tag
|
|
582
|
+
clean_data = self._skip_id3v2_tags(full_data)
|
|
583
|
+
|
|
584
|
+
# Check if we have enough data for RIFF header after skipping ID3v2
|
|
585
|
+
if len(clean_data) < RIFF_HEADER_SIZE:
|
|
586
|
+
msg = "File too small after skipping ID3v2 tags"
|
|
587
|
+
raise FileCorruptedError(msg)
|
|
588
|
+
|
|
589
|
+
riff_header = clean_data[:RIFF_HEADER_SIZE]
|
|
590
|
+
else:
|
|
591
|
+
riff_header = header_data
|
|
592
|
+
|
|
593
|
+
# Validate RIFF header
|
|
594
|
+
if len(riff_header) < RIFF_HEADER_SIZE:
|
|
595
|
+
msg = "File too small to contain RIFF header"
|
|
596
|
+
raise FileCorruptedError(msg)
|
|
597
|
+
|
|
598
|
+
if not riff_header.startswith(b"RIFF"):
|
|
599
|
+
msg = "Invalid RIFF header"
|
|
600
|
+
raise FileCorruptedError(msg)
|
|
601
|
+
|
|
602
|
+
if riff_header[8:12] != b"WAVE":
|
|
603
|
+
msg = "Not a WAVE file"
|
|
604
|
+
raise FileCorruptedError(msg)
|
|
605
|
+
|
|
606
|
+
# Basic structure validation passed
|
|
607
|
+
return
|