audiometa-python 0.2.2__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 +1240 -0
- audiometa/__main__.py +6 -0
- audiometa/_audio_file.py +602 -0
- audiometa/cli.py +347 -0
- audiometa/exceptions.py +167 -0
- audiometa/manager/_MetadataManager.py +665 -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 +945 -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 +778 -0
- audiometa/manager/_rating_supporting/riff/__init__.py +25 -0
- audiometa/manager/_rating_supporting/riff/_riff_constants.py +10 -0
- audiometa/manager/_rating_supporting/vorbis/_VorbisManager.py +525 -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 +305 -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 +188 -0
- audiometa/test/helpers/id3v2/id3v2_metadata_setter.py +428 -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 +216 -0
- audiometa/test/helpers/riff/riff_metadata_deleter.py +56 -0
- audiometa/test/helpers/riff/riff_metadata_getter.py +118 -0
- audiometa/test/helpers/riff/riff_metadata_setter.py +196 -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 +200 -0
- audiometa/test/tests/__init__.py +0 -0
- audiometa/test/tests/conftest.py +261 -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/test_cli_delete.py +20 -0
- audiometa/test/tests/e2e/cli/test_cli_formatting.py +31 -0
- audiometa/test/tests/e2e/cli/test_cli_help.py +40 -0
- audiometa/test/tests/e2e/cli/test_cli_read.py +79 -0
- audiometa/test/tests/e2e/cli/test_cli_unified.py +33 -0
- audiometa/test/tests/e2e/cli/test_cli_write.py +116 -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_binary_data_filtering.py +250 -0
- audiometa/test/tests/integration/get_full_metadata/test_get_full_metadata.py +297 -0
- audiometa/test/tests/integration/get_full_metadata/test_get_full_metadata_edge_cases.py +231 -0
- audiometa/test/tests/integration/get_full_metadata/test_options.py +207 -0
- audiometa/test/tests/integration/get_full_metadata/test_parsed_fields_keys.py +85 -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/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/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 +134 -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 +38 -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 +23 -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 +130 -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_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 +57 -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_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_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/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/os_dependencies_checker/__init__.py +24 -0
- audiometa/utils/os_dependencies_checker/base.py +62 -0
- audiometa/utils/os_dependencies_checker/config.py +51 -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 +81 -0
- audiometa_python-0.2.2.dist-info/METADATA +1464 -0
- audiometa_python-0.2.2.dist-info/RECORD +318 -0
- audiometa_python-0.2.2.dist-info/WHEEL +5 -0
- audiometa_python-0.2.2.dist-info/entry_points.txt +2 -0
- audiometa_python-0.2.2.dist-info/licenses/LICENSE +202 -0
- audiometa_python-0.2.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""RIFF manager and constants."""
|
|
2
|
+
|
|
3
|
+
from ._riff_constants import (
|
|
4
|
+
RIFF_AUDIO_FORMAT_IEEE_FLOAT,
|
|
5
|
+
RIFF_CHUNK_ID_SIZE,
|
|
6
|
+
RIFF_FORMAT_CHUNK_MIN_SIZE,
|
|
7
|
+
RIFF_HEADER_SIZE,
|
|
8
|
+
RIFF_INFO_CHUNK_MIN_SIZE,
|
|
9
|
+
RIFF_MIN_DATA_SIZE_FOR_ID3V2,
|
|
10
|
+
RIFF_MIN_VERSION_LENGTH,
|
|
11
|
+
RIFF_WAVE_FORMAT_POSITION,
|
|
12
|
+
)
|
|
13
|
+
from ._RiffManager import _RiffManager
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"_RiffManager",
|
|
17
|
+
"RIFF_AUDIO_FORMAT_IEEE_FLOAT",
|
|
18
|
+
"RIFF_CHUNK_ID_SIZE",
|
|
19
|
+
"RIFF_FORMAT_CHUNK_MIN_SIZE",
|
|
20
|
+
"RIFF_HEADER_SIZE",
|
|
21
|
+
"RIFF_INFO_CHUNK_MIN_SIZE",
|
|
22
|
+
"RIFF_MIN_DATA_SIZE_FOR_ID3V2",
|
|
23
|
+
"RIFF_MIN_VERSION_LENGTH",
|
|
24
|
+
"RIFF_WAVE_FORMAT_POSITION",
|
|
25
|
+
]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Constants for RIFF format."""
|
|
2
|
+
|
|
3
|
+
RIFF_HEADER_SIZE = 12
|
|
4
|
+
RIFF_CHUNK_ID_SIZE = 4
|
|
5
|
+
RIFF_WAVE_FORMAT_POSITION = 8
|
|
6
|
+
RIFF_MIN_DATA_SIZE_FOR_ID3V2 = 10
|
|
7
|
+
RIFF_INFO_CHUNK_MIN_SIZE = 16
|
|
8
|
+
RIFF_MIN_VERSION_LENGTH = 3
|
|
9
|
+
RIFF_AUDIO_FORMAT_IEEE_FLOAT = 3
|
|
10
|
+
RIFF_FORMAT_CHUNK_MIN_SIZE = 16
|
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import struct
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import TYPE_CHECKING, Any, TypeVar, cast
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from ...._audio_file import _AudioFile
|
|
8
|
+
from ....exceptions import FileCorruptedError, InvalidRatingValueError, MetadataFieldNotSupportedByMetadataFormatError
|
|
9
|
+
from ....utils.rating_profiles import RatingWriteProfile
|
|
10
|
+
from ....utils.tool_path_resolver import get_tool_path
|
|
11
|
+
from ....utils.types import RawMetadataDict, RawMetadataKey, UnifiedMetadata, UnifiedMetadataValue
|
|
12
|
+
from ....utils.unified_metadata_key import UnifiedMetadataKey
|
|
13
|
+
from .._RatingSupportingMetadataManager import _RatingSupportingMetadataManager
|
|
14
|
+
from ._vorbis_constants import VORBIS_BLOCK_HEADER_SIZE, VORBIS_COMMENT_BLOCK_TYPE, VORBIS_ID3V2_HEADER_SIZE
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T", str, int)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _VorbisManager(_RatingSupportingMetadataManager):
|
|
20
|
+
"""Manages Vorbis comments for audio files.
|
|
21
|
+
|
|
22
|
+
Vorbis comments are used to store metadata in audio files, primarily in FLAC format.
|
|
23
|
+
(OGG file support is planned but not yet implemented.)
|
|
24
|
+
They are more flexible and extensible compared to ID3 tags, allowing for a wide range of metadata fields.
|
|
25
|
+
|
|
26
|
+
Vorbis comments are key-value pairs, where the key is a field name and the value is the corresponding metadata.
|
|
27
|
+
Common fields are defined in the VorbisKey enum class, which includes standardized keys for metadata like
|
|
28
|
+
title, artist, album, genre, rating, and more.
|
|
29
|
+
|
|
30
|
+
Implementation Details:
|
|
31
|
+
- Reading: Custom FLAC parsing to preserve original Vorbis comment key casing
|
|
32
|
+
- Writing: External metaflac tool to maintain proper key casing per Vorbis specification
|
|
33
|
+
- The Vorbis specification recommends uppercase keys, which metaflac preserves during writing
|
|
34
|
+
- Custom parsing for reading avoids mutagen's lowercase conversion behavior
|
|
35
|
+
|
|
36
|
+
Compatible Extensions:
|
|
37
|
+
- FLAC: Fully supports Vorbis comments.
|
|
38
|
+
|
|
39
|
+
TODO: OGG file support is planned but not yet implemented.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
class VorbisKey(RawMetadataKey):
|
|
43
|
+
# Standard
|
|
44
|
+
TITLE = "TITLE"
|
|
45
|
+
ARTIST = "ARTIST"
|
|
46
|
+
ALBUM = "ALBUM"
|
|
47
|
+
ALBUM_ARTISTS = "ALBUMARTIST"
|
|
48
|
+
GENRES_NAMES = "GENRE"
|
|
49
|
+
DATE = "DATE" # Creation/Release date
|
|
50
|
+
TRACK_NUMBER = "TRACKNUMBER"
|
|
51
|
+
COMMENT = "COMMENT"
|
|
52
|
+
PERFORMER = "PERFORMER"
|
|
53
|
+
COPYRIGHT = "COPYRIGHT"
|
|
54
|
+
LICENSE = "LICENSE"
|
|
55
|
+
ORGANIZATION = "ORGANIZATION" # Label or organization
|
|
56
|
+
DESCRIPTION = "DESCRIPTION"
|
|
57
|
+
LOCATION = "LOCATION" # Recording location
|
|
58
|
+
CONTACT = "CONTACT" # Contact information
|
|
59
|
+
ISRC = "ISRC" # International Standard Recording Code
|
|
60
|
+
|
|
61
|
+
# Non-standard
|
|
62
|
+
LANGUAGE = "LANGUAGE"
|
|
63
|
+
BPM = "BPM"
|
|
64
|
+
COMPOSERS = "COMPOSER"
|
|
65
|
+
ENCODED_BY = "ENCODEDBY" # Encoder software
|
|
66
|
+
RATING = "RATING"
|
|
67
|
+
RATING_TRAKTOR = "RATING WMP" # Traktor rating
|
|
68
|
+
UNSYNCHRONIZED_LYRICS = "LYRICS" # Not standard
|
|
69
|
+
REPLAYGAIN = "REPLAYGAIN"
|
|
70
|
+
PUBLISHER = "PUBLISHER"
|
|
71
|
+
|
|
72
|
+
def __init__(self, audio_file: "_AudioFile", normalized_rating_max_value: int | None = None):
|
|
73
|
+
metadata_keys_direct_map_read = {
|
|
74
|
+
UnifiedMetadataKey.TITLE: self.VorbisKey.TITLE,
|
|
75
|
+
UnifiedMetadataKey.ARTISTS: self.VorbisKey.ARTIST,
|
|
76
|
+
UnifiedMetadataKey.ALBUM: self.VorbisKey.ALBUM,
|
|
77
|
+
UnifiedMetadataKey.ALBUM_ARTISTS: self.VorbisKey.ALBUM_ARTISTS,
|
|
78
|
+
UnifiedMetadataKey.GENRES_NAMES: self.VorbisKey.GENRES_NAMES,
|
|
79
|
+
UnifiedMetadataKey.RATING: None,
|
|
80
|
+
UnifiedMetadataKey.LANGUAGE: self.VorbisKey.LANGUAGE,
|
|
81
|
+
UnifiedMetadataKey.RELEASE_DATE: self.VorbisKey.DATE,
|
|
82
|
+
UnifiedMetadataKey.TRACK_NUMBER: self.VorbisKey.TRACK_NUMBER,
|
|
83
|
+
UnifiedMetadataKey.BPM: self.VorbisKey.BPM,
|
|
84
|
+
UnifiedMetadataKey.COMPOSERS: self.VorbisKey.COMPOSERS,
|
|
85
|
+
UnifiedMetadataKey.COPYRIGHT: self.VorbisKey.COPYRIGHT,
|
|
86
|
+
UnifiedMetadataKey.COMMENT: self.VorbisKey.COMMENT,
|
|
87
|
+
UnifiedMetadataKey.UNSYNCHRONIZED_LYRICS: self.VorbisKey.UNSYNCHRONIZED_LYRICS,
|
|
88
|
+
UnifiedMetadataKey.REPLAYGAIN: self.VorbisKey.REPLAYGAIN,
|
|
89
|
+
UnifiedMetadataKey.PUBLISHER: self.VorbisKey.PUBLISHER,
|
|
90
|
+
}
|
|
91
|
+
metadata_keys_direct_map_write = {
|
|
92
|
+
UnifiedMetadataKey.TITLE: self.VorbisKey.TITLE,
|
|
93
|
+
UnifiedMetadataKey.ARTISTS: self.VorbisKey.ARTIST,
|
|
94
|
+
UnifiedMetadataKey.ALBUM: self.VorbisKey.ALBUM,
|
|
95
|
+
UnifiedMetadataKey.ALBUM_ARTISTS: self.VorbisKey.ALBUM_ARTISTS,
|
|
96
|
+
UnifiedMetadataKey.GENRES_NAMES: self.VorbisKey.GENRES_NAMES,
|
|
97
|
+
UnifiedMetadataKey.RATING: None,
|
|
98
|
+
UnifiedMetadataKey.LANGUAGE: self.VorbisKey.LANGUAGE,
|
|
99
|
+
UnifiedMetadataKey.RELEASE_DATE: self.VorbisKey.DATE,
|
|
100
|
+
UnifiedMetadataKey.TRACK_NUMBER: self.VorbisKey.TRACK_NUMBER,
|
|
101
|
+
UnifiedMetadataKey.BPM: self.VorbisKey.BPM,
|
|
102
|
+
UnifiedMetadataKey.COMPOSERS: self.VorbisKey.COMPOSERS,
|
|
103
|
+
UnifiedMetadataKey.COPYRIGHT: self.VorbisKey.COPYRIGHT,
|
|
104
|
+
UnifiedMetadataKey.COMMENT: self.VorbisKey.COMMENT,
|
|
105
|
+
UnifiedMetadataKey.UNSYNCHRONIZED_LYRICS: self.VorbisKey.UNSYNCHRONIZED_LYRICS,
|
|
106
|
+
UnifiedMetadataKey.REPLAYGAIN: self.VorbisKey.REPLAYGAIN,
|
|
107
|
+
UnifiedMetadataKey.PUBLISHER: self.VorbisKey.PUBLISHER,
|
|
108
|
+
}
|
|
109
|
+
super().__init__(
|
|
110
|
+
audio_file=audio_file,
|
|
111
|
+
metadata_keys_direct_map_read=cast(
|
|
112
|
+
dict[UnifiedMetadataKey, RawMetadataKey | None], metadata_keys_direct_map_read
|
|
113
|
+
),
|
|
114
|
+
metadata_keys_direct_map_write=cast(
|
|
115
|
+
dict[UnifiedMetadataKey, RawMetadataKey | None], metadata_keys_direct_map_write
|
|
116
|
+
),
|
|
117
|
+
rating_write_profile=RatingWriteProfile.BASE_100_PROPORTIONAL,
|
|
118
|
+
normalized_rating_max_value=normalized_rating_max_value,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def _extract_mutagen_metadata(self) -> RawMetadataDict:
|
|
122
|
+
"""Read Vorbis comments from a FLAC file.
|
|
123
|
+
|
|
124
|
+
This is a custom implementation for extracting Vorbis comments because:
|
|
125
|
+
- Mutagen does not preserve original key case
|
|
126
|
+
Returns a dict: {key: [values]}.
|
|
127
|
+
"""
|
|
128
|
+
comments: dict[str, list[str]] = {}
|
|
129
|
+
with Path(self.audio_file.file_path).open("rb") as f:
|
|
130
|
+
# --- Step 1: Skip ID3v2 tags if present, then find FLAC header ---
|
|
131
|
+
header = f.read(4)
|
|
132
|
+
if header in (b"ID3\x03", b"ID3\x04"):
|
|
133
|
+
# ID3v2 tag present, skip it
|
|
134
|
+
f.seek(0) # Reset to beginning
|
|
135
|
+
# Read ID3v2 header to get tag size
|
|
136
|
+
id3_header = f.read(VORBIS_ID3V2_HEADER_SIZE)
|
|
137
|
+
if len(id3_header) >= VORBIS_ID3V2_HEADER_SIZE:
|
|
138
|
+
# ID3v2 tag size is stored in bytes 6-9 (syncsafe integer)
|
|
139
|
+
tag_size = (
|
|
140
|
+
((id3_header[6] & 0x7F) << 21)
|
|
141
|
+
| ((id3_header[7] & 0x7F) << 14)
|
|
142
|
+
| ((id3_header[8] & 0x7F) << 7)
|
|
143
|
+
| (id3_header[9] & 0x7F)
|
|
144
|
+
)
|
|
145
|
+
# Skip the ID3v2 tag
|
|
146
|
+
f.seek(tag_size + VORBIS_ID3V2_HEADER_SIZE)
|
|
147
|
+
# Now read the FLAC header
|
|
148
|
+
header = f.read(4)
|
|
149
|
+
|
|
150
|
+
if header != b"fLaC":
|
|
151
|
+
msg = "Not a valid FLAC file"
|
|
152
|
+
raise ValueError(msg)
|
|
153
|
+
|
|
154
|
+
# --- Step 2: Read metadata blocks ---
|
|
155
|
+
is_last = False
|
|
156
|
+
while not is_last:
|
|
157
|
+
block_header = f.read(VORBIS_BLOCK_HEADER_SIZE)
|
|
158
|
+
if len(block_header) < VORBIS_BLOCK_HEADER_SIZE:
|
|
159
|
+
break
|
|
160
|
+
is_last = bool(block_header[0] & 0x80)
|
|
161
|
+
block_type = block_header[0] & 0x7F
|
|
162
|
+
block_size = struct.unpack(">I", b"\x00" + block_header[1:])[0]
|
|
163
|
+
data = f.read(block_size)
|
|
164
|
+
|
|
165
|
+
# --- Step 3: Look for VORBIS_COMMENT block ---
|
|
166
|
+
if block_type == VORBIS_COMMENT_BLOCK_TYPE: # VORBIS_COMMENT
|
|
167
|
+
offset = 0
|
|
168
|
+
# Vendor length (32-bit LE)
|
|
169
|
+
vendor_len = struct.unpack("<I", data[offset : offset + 4])[0]
|
|
170
|
+
offset += 4 + vendor_len
|
|
171
|
+
# Number of comments
|
|
172
|
+
num_comments = struct.unpack("<I", data[offset : offset + 4])[0]
|
|
173
|
+
offset += 4
|
|
174
|
+
|
|
175
|
+
for _ in range(num_comments):
|
|
176
|
+
comment_len = struct.unpack("<I", data[offset : offset + 4])[0]
|
|
177
|
+
offset += 4
|
|
178
|
+
comment_bytes = data[offset : offset + comment_len]
|
|
179
|
+
offset += comment_len
|
|
180
|
+
comment_str = comment_bytes.decode("utf-8", errors="replace")
|
|
181
|
+
|
|
182
|
+
# Split key=value at first '='
|
|
183
|
+
if "=" not in comment_str:
|
|
184
|
+
continue
|
|
185
|
+
key, value = comment_str.split("=", 1)
|
|
186
|
+
# Preserve original case
|
|
187
|
+
comments.setdefault(key, []).append(value)
|
|
188
|
+
break
|
|
189
|
+
|
|
190
|
+
return cast(RawMetadataDict, comments)
|
|
191
|
+
|
|
192
|
+
def _convert_raw_mutagen_metadata_to_dict_with_potential_duplicate_keys(
|
|
193
|
+
self,
|
|
194
|
+
raw_mutagen_metadata: dict,
|
|
195
|
+
) -> RawMetadataDict:
|
|
196
|
+
# _extract_mutagen_metadata already returns metadata with list values
|
|
197
|
+
return raw_mutagen_metadata
|
|
198
|
+
|
|
199
|
+
def _extract_raw_clean_metadata_uppercase_keys_from_file(self) -> None:
|
|
200
|
+
if self.raw_clean_metadata is None:
|
|
201
|
+
self.raw_clean_metadata = self._extract_cleaned_raw_metadata_from_file()
|
|
202
|
+
|
|
203
|
+
# Merge case variants of keys (e.g., "ARTIST" and "artist" -> "ARTIST")
|
|
204
|
+
# Vorbis comments preserve original key case, so we need to merge them
|
|
205
|
+
# Use a temporary dict with string keys for merging, then convert to RawMetadataDict
|
|
206
|
+
# Use Any for values since we're merging different list types and will convert back
|
|
207
|
+
temp_dict: dict[str, list[Any]] = {}
|
|
208
|
+
for key, values in self.raw_clean_metadata.items():
|
|
209
|
+
if values is None:
|
|
210
|
+
continue
|
|
211
|
+
uppercase_key = str(key).upper()
|
|
212
|
+
# Merge values from all case variants
|
|
213
|
+
if isinstance(values, list):
|
|
214
|
+
# values is guaranteed to be a list here (not None)
|
|
215
|
+
if uppercase_key not in temp_dict:
|
|
216
|
+
# First occurrence: use the list as-is (preserves type)
|
|
217
|
+
temp_dict[uppercase_key] = list(values)
|
|
218
|
+
else:
|
|
219
|
+
# Subsequent occurrence: merge while avoiding duplicates
|
|
220
|
+
existing_list = temp_dict[uppercase_key]
|
|
221
|
+
for val in values:
|
|
222
|
+
if val not in existing_list:
|
|
223
|
+
existing_list.append(val)
|
|
224
|
+
|
|
225
|
+
# Convert to RawMetadataDict format
|
|
226
|
+
# Since RawMetadataKey is str, Enum, we can use string keys directly at runtime
|
|
227
|
+
# Use cast to satisfy type checker since RawMetadataKey is str, Enum
|
|
228
|
+
result_dict: dict[str | RawMetadataKey, list[str] | list[int] | list[float]] = {}
|
|
229
|
+
for key_str, values_list in temp_dict.items():
|
|
230
|
+
# Try to find matching enum member, otherwise use string as key
|
|
231
|
+
# RawMetadataKey is str, Enum so string keys work at runtime
|
|
232
|
+
final_key: RawMetadataKey | str = key_str
|
|
233
|
+
for enum_class in RawMetadataKey.__subclasses__():
|
|
234
|
+
for member in enum_class.__members__.values():
|
|
235
|
+
if str(member.value).upper() == key_str:
|
|
236
|
+
final_key = member
|
|
237
|
+
break
|
|
238
|
+
if isinstance(final_key, RawMetadataKey):
|
|
239
|
+
break
|
|
240
|
+
# values_list is guaranteed to be a list (not empty, not None)
|
|
241
|
+
result_dict[final_key] = values_list
|
|
242
|
+
|
|
243
|
+
# Cast to RawMetadataDict since RawMetadataKey is str, Enum and string keys work
|
|
244
|
+
self.raw_clean_metadata_uppercase_keys = cast(RawMetadataDict, result_dict)
|
|
245
|
+
|
|
246
|
+
def _get_raw_rating_by_traktor_or_not(self, raw_clean_metadata: RawMetadataDict) -> tuple[int | None, bool]:
|
|
247
|
+
if self.VorbisKey.RATING in raw_clean_metadata:
|
|
248
|
+
rating_list = raw_clean_metadata[self.VorbisKey.RATING]
|
|
249
|
+
if rating_list and len(rating_list) > 0 and rating_list[0] is not None:
|
|
250
|
+
return int(rating_list[0]), False
|
|
251
|
+
|
|
252
|
+
if self.VorbisKey.RATING_TRAKTOR in raw_clean_metadata:
|
|
253
|
+
rating_list = raw_clean_metadata[self.VorbisKey.RATING_TRAKTOR]
|
|
254
|
+
if rating_list and len(rating_list) > 0 and rating_list[0] is not None:
|
|
255
|
+
return int(rating_list[0]), True
|
|
256
|
+
|
|
257
|
+
return None, False
|
|
258
|
+
|
|
259
|
+
def _update_formatted_value_in_raw_mutagen_metadata(
|
|
260
|
+
self,
|
|
261
|
+
raw_mutagen_metadata: dict,
|
|
262
|
+
raw_metadata_key: RawMetadataKey,
|
|
263
|
+
app_metadata_value: UnifiedMetadataValue,
|
|
264
|
+
) -> None:
|
|
265
|
+
if app_metadata_value is not None:
|
|
266
|
+
if isinstance(app_metadata_value, list):
|
|
267
|
+
# For multi-value fields, keep as separate entries
|
|
268
|
+
raw_mutagen_metadata[raw_metadata_key] = [str(v) for v in app_metadata_value]
|
|
269
|
+
# Convert BPM to string for Vorbis comments
|
|
270
|
+
elif raw_metadata_key == self.VorbisKey.BPM:
|
|
271
|
+
raw_mutagen_metadata[raw_metadata_key] = [str(app_metadata_value)]
|
|
272
|
+
else:
|
|
273
|
+
raw_mutagen_metadata[raw_metadata_key] = [str(app_metadata_value)]
|
|
274
|
+
elif raw_metadata_key in raw_mutagen_metadata:
|
|
275
|
+
del raw_mutagen_metadata[raw_metadata_key]
|
|
276
|
+
|
|
277
|
+
def update_metadata(self, unified_metadata: UnifiedMetadata) -> None:
|
|
278
|
+
"""Update Vorbis metadata in FLAC files using external metaflac tool.
|
|
279
|
+
|
|
280
|
+
This method uses the metaflac external command-line tool instead of Python libraries
|
|
281
|
+
to ensure proper Vorbis specification compliance and prevent file corruption.
|
|
282
|
+
|
|
283
|
+
Key Features:
|
|
284
|
+
- **Uppercase Key Casing**: Preserves proper Vorbis key casing (TITLE, ARTIST, etc.)
|
|
285
|
+
unlike mutagen which converts to lowercase
|
|
286
|
+
- **Multi-Value Support**: Creates separate tag entries for list values
|
|
287
|
+
- **File Integrity**: Prevents corruption that occurs with some Python libraries
|
|
288
|
+
- **Deletion Support**: Properly removes tags when None values are passed
|
|
289
|
+
|
|
290
|
+
Multi-Value Behavior:
|
|
291
|
+
- List values create separate tag entries (Vorbis specification compliant)
|
|
292
|
+
- Example: ["Artist One", "Artist Two"] creates:
|
|
293
|
+
* ARTIST=Artist One
|
|
294
|
+
* ARTIST=Artist Two
|
|
295
|
+
- NOT: ARTIST=Artist One;Artist Two (semicolon-joined)
|
|
296
|
+
|
|
297
|
+
External Tool Requirements:
|
|
298
|
+
- Requires 'metaflac' command-line tool to be installed
|
|
299
|
+
- Falls back to FileCorruptedError if metaflac is not available
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
unified_metadata: Dictionary of metadata to write/update
|
|
303
|
+
Use None values to delete specific fields
|
|
304
|
+
|
|
305
|
+
Raises:
|
|
306
|
+
MetadataFieldNotSupportedByMetadataFormatError: If field not supported
|
|
307
|
+
FileCorruptedError: If metaflac tool fails or is not found
|
|
308
|
+
"""
|
|
309
|
+
if not self.metadata_keys_direct_map_write:
|
|
310
|
+
msg = "This format does not support metadata modification"
|
|
311
|
+
raise MetadataFieldNotSupportedByMetadataFormatError(msg)
|
|
312
|
+
|
|
313
|
+
self._validate_and_process_rating(unified_metadata)
|
|
314
|
+
|
|
315
|
+
# Get current metadata
|
|
316
|
+
current_metadata = self._extract_mutagen_metadata()
|
|
317
|
+
|
|
318
|
+
# Update metadata dict
|
|
319
|
+
for unified_metadata_key in list(unified_metadata.keys()):
|
|
320
|
+
app_metadata_value = unified_metadata[unified_metadata_key]
|
|
321
|
+
|
|
322
|
+
# Filter out empty values for list-type metadata before processing
|
|
323
|
+
if isinstance(app_metadata_value, list):
|
|
324
|
+
app_metadata_value = self._filter_valid_values(cast(list[str | None], app_metadata_value))
|
|
325
|
+
# If all values were filtered out, set to None to remove the field
|
|
326
|
+
if not app_metadata_value:
|
|
327
|
+
app_metadata_value = None
|
|
328
|
+
|
|
329
|
+
if unified_metadata_key not in self.metadata_keys_direct_map_write:
|
|
330
|
+
metadata_format_name = self._get_formatted_metadata_format_name()
|
|
331
|
+
msg = f"{unified_metadata_key} metadata not supported by {metadata_format_name} format"
|
|
332
|
+
raise MetadataFieldNotSupportedByMetadataFormatError(msg)
|
|
333
|
+
raw_metadata_key = self.metadata_keys_direct_map_write[unified_metadata_key]
|
|
334
|
+
if raw_metadata_key:
|
|
335
|
+
self._update_formatted_value_in_raw_mutagen_metadata(
|
|
336
|
+
raw_mutagen_metadata=current_metadata,
|
|
337
|
+
raw_metadata_key=raw_metadata_key,
|
|
338
|
+
app_metadata_value=app_metadata_value,
|
|
339
|
+
)
|
|
340
|
+
else:
|
|
341
|
+
self._update_undirectly_mapped_metadata(
|
|
342
|
+
raw_mutagen_metadata=current_metadata,
|
|
343
|
+
app_metadata_value=app_metadata_value,
|
|
344
|
+
unified_metadata_key=unified_metadata_key,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
# Write metadata using metaflac
|
|
348
|
+
self._write_metadata_with_metaflac(current_metadata)
|
|
349
|
+
|
|
350
|
+
# Clear cached metadata to ensure subsequent reads reflect the changes
|
|
351
|
+
self.raw_clean_metadata = None
|
|
352
|
+
self.raw_clean_metadata_uppercase_keys = None
|
|
353
|
+
|
|
354
|
+
def _write_metadata_with_metaflac(self, metadata: dict) -> None:
|
|
355
|
+
"""Write metadata to the FLAC file using metaflac external tool."""
|
|
356
|
+
try:
|
|
357
|
+
import subprocess
|
|
358
|
+
|
|
359
|
+
# Map unified metadata keys to metaflac tag names (uppercase)
|
|
360
|
+
key_mapping = {
|
|
361
|
+
"TITLE": "TITLE",
|
|
362
|
+
"ARTIST": "ARTIST",
|
|
363
|
+
"ALBUM": "ALBUM",
|
|
364
|
+
"DATE": "DATE",
|
|
365
|
+
"GENRE": "GENRE",
|
|
366
|
+
"COMMENT": "COMMENT",
|
|
367
|
+
"TRACKNUMBER": "TRACKNUMBER",
|
|
368
|
+
"BPM": "BPM",
|
|
369
|
+
"COMPOSER": "COMPOSER",
|
|
370
|
+
"COPYRIGHT": "COPYRIGHT",
|
|
371
|
+
"LYRICS": "LYRICS",
|
|
372
|
+
"LANGUAGE": "LANGUAGE",
|
|
373
|
+
"RATING": "RATING",
|
|
374
|
+
"ALBUMARTIST": "ALBUMARTIST",
|
|
375
|
+
"MOOD": "MOOD",
|
|
376
|
+
"KEY": "KEY",
|
|
377
|
+
"ENCODER": "ENCODER",
|
|
378
|
+
"URL": "URL",
|
|
379
|
+
"ISRC": "ISRC",
|
|
380
|
+
"PUBLISHER": "PUBLISHER",
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
# Get all possible tags that we might need to remove
|
|
384
|
+
# This includes both tags in the metadata dict and all possible tags
|
|
385
|
+
tags_to_remove = set()
|
|
386
|
+
|
|
387
|
+
# Add tags that are in the metadata dict (these are being updated/deleted)
|
|
388
|
+
for key in metadata:
|
|
389
|
+
if key in key_mapping:
|
|
390
|
+
tags_to_remove.add(key_mapping[key])
|
|
391
|
+
|
|
392
|
+
# Also remove all possible tags to ensure clean state
|
|
393
|
+
# This is necessary because we might be deleting tags that aren't in the metadata dict
|
|
394
|
+
for metaflac_key in key_mapping.values():
|
|
395
|
+
tags_to_remove.add(metaflac_key)
|
|
396
|
+
|
|
397
|
+
# Remove all existing tags
|
|
398
|
+
if tags_to_remove:
|
|
399
|
+
for metaflac_key in tags_to_remove:
|
|
400
|
+
with contextlib.suppress(subprocess.CalledProcessError):
|
|
401
|
+
subprocess.run(
|
|
402
|
+
[get_tool_path("metaflac"), "--remove-tag=" + metaflac_key, self.audio_file.file_path],
|
|
403
|
+
check=True,
|
|
404
|
+
capture_output=True,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
# Then, add new tags for non-None values
|
|
408
|
+
set_cmd = [get_tool_path("metaflac")]
|
|
409
|
+
for key, values in metadata.items():
|
|
410
|
+
if key in key_mapping and values is not None:
|
|
411
|
+
metaflac_key = key_mapping[key]
|
|
412
|
+
|
|
413
|
+
# Handle list values by creating separate tag entries
|
|
414
|
+
if isinstance(values, list):
|
|
415
|
+
for value in values:
|
|
416
|
+
if value: # Only add non-empty values
|
|
417
|
+
set_cmd.extend(["--set-tag", f"{metaflac_key}={value}"])
|
|
418
|
+
else:
|
|
419
|
+
value = str(values)
|
|
420
|
+
if value: # Only add non-empty values
|
|
421
|
+
set_cmd.extend(["--set-tag", f"{metaflac_key}={value}"])
|
|
422
|
+
|
|
423
|
+
# Add file path and execute
|
|
424
|
+
if len(set_cmd) > 1: # Only if we have tags to set
|
|
425
|
+
set_cmd.append(self.audio_file.file_path)
|
|
426
|
+
subprocess.run(set_cmd, check=True, capture_output=True)
|
|
427
|
+
|
|
428
|
+
except subprocess.CalledProcessError as e:
|
|
429
|
+
msg = f"Failed to write metadata with metaflac: {e}"
|
|
430
|
+
raise FileCorruptedError(msg) from e
|
|
431
|
+
except FileNotFoundError as e:
|
|
432
|
+
msg = "metaflac tool not found. Please install it to write Vorbis metadata to FLAC files."
|
|
433
|
+
raise FileCorruptedError(msg) from e
|
|
434
|
+
|
|
435
|
+
def get_header_info(self) -> dict:
|
|
436
|
+
try:
|
|
437
|
+
# Use custom parsing to get file information
|
|
438
|
+
metadata = self._extract_mutagen_metadata()
|
|
439
|
+
comment_count = sum(len(values) for values in metadata.values() if values)
|
|
440
|
+
|
|
441
|
+
info = {
|
|
442
|
+
"present": True,
|
|
443
|
+
"vendor_string": None, # Vendor string not available via custom parsing
|
|
444
|
+
"comment_count": comment_count,
|
|
445
|
+
"block_size": 4096, # Default Vorbis comment block size
|
|
446
|
+
}
|
|
447
|
+
except Exception:
|
|
448
|
+
return {"present": False, "vendor_string": None, "comment_count": 0, "block_size": 0}
|
|
449
|
+
else:
|
|
450
|
+
return info
|
|
451
|
+
|
|
452
|
+
def get_raw_metadata_info(self) -> dict:
|
|
453
|
+
try:
|
|
454
|
+
# Use custom parsing to get metadata
|
|
455
|
+
metadata = self._extract_mutagen_metadata()
|
|
456
|
+
|
|
457
|
+
return {
|
|
458
|
+
"raw_data": None, # Custom parsing handles this internally
|
|
459
|
+
"parsed_fields": {},
|
|
460
|
+
"frames": {},
|
|
461
|
+
"comments": dict(metadata), # Convert to regular dict
|
|
462
|
+
"chunk_structure": {},
|
|
463
|
+
}
|
|
464
|
+
except Exception:
|
|
465
|
+
return {"raw_data": None, "parsed_fields": {}, "frames": {}, "comments": {}, "chunk_structure": {}}
|
|
466
|
+
|
|
467
|
+
def delete_metadata(self) -> bool:
|
|
468
|
+
"""Delete all metadata from the FLAC file by removing the VORBIS_COMMENT block."""
|
|
469
|
+
import subprocess
|
|
470
|
+
|
|
471
|
+
try:
|
|
472
|
+
# Remove all VORBIS_COMMENT blocks from the FLAC file
|
|
473
|
+
subprocess.run(
|
|
474
|
+
[get_tool_path("metaflac"), "--remove", "--block-type=VORBIS_COMMENT", self.audio_file.file_path],
|
|
475
|
+
capture_output=True,
|
|
476
|
+
text=True,
|
|
477
|
+
check=True,
|
|
478
|
+
)
|
|
479
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
480
|
+
return False
|
|
481
|
+
else:
|
|
482
|
+
return True
|
|
483
|
+
|
|
484
|
+
def _get_undirectly_mapped_metadata_value_other_than_rating_from_raw_clean_metadata(
|
|
485
|
+
self, _raw_clean_metadata: RawMetadataDict, unified_metadata_key: UnifiedMetadataKey
|
|
486
|
+
) -> UnifiedMetadataValue:
|
|
487
|
+
msg = f"Metadata key not handled: {unified_metadata_key}"
|
|
488
|
+
raise MetadataFieldNotSupportedByMetadataFormatError(msg)
|
|
489
|
+
|
|
490
|
+
def _update_undirectly_mapped_metadata(
|
|
491
|
+
self,
|
|
492
|
+
raw_mutagen_metadata: dict,
|
|
493
|
+
app_metadata_value: UnifiedMetadataValue,
|
|
494
|
+
unified_metadata_key: UnifiedMetadataKey,
|
|
495
|
+
) -> None:
|
|
496
|
+
if unified_metadata_key == UnifiedMetadataKey.RATING:
|
|
497
|
+
if app_metadata_value is not None:
|
|
498
|
+
if self.normalized_rating_max_value is None:
|
|
499
|
+
# When no normalization, write value as-is (already validated by parent class)
|
|
500
|
+
if isinstance(app_metadata_value, int | float):
|
|
501
|
+
raw_mutagen_metadata[self.VorbisKey.RATING] = [str(int(app_metadata_value))]
|
|
502
|
+
else:
|
|
503
|
+
raw_mutagen_metadata[self.VorbisKey.RATING] = [str(app_metadata_value)]
|
|
504
|
+
else:
|
|
505
|
+
try:
|
|
506
|
+
# Preserve float values to support half-star ratings (consistent with classic star rating
|
|
507
|
+
# systems)
|
|
508
|
+
if isinstance(app_metadata_value, int | float):
|
|
509
|
+
normalized_rating = float(app_metadata_value)
|
|
510
|
+
else:
|
|
511
|
+
normalized_rating = float(str(app_metadata_value))
|
|
512
|
+
file_rating = self._convert_normalized_rating_to_file_rating(normalized_rating)
|
|
513
|
+
raw_mutagen_metadata[self.VorbisKey.RATING] = [str(file_rating)]
|
|
514
|
+
except (TypeError, ValueError) as e:
|
|
515
|
+
msg = f"Invalid rating value: {app_metadata_value}. Expected a numeric value."
|
|
516
|
+
raise InvalidRatingValueError(msg) from e
|
|
517
|
+
else:
|
|
518
|
+
# Remove rating
|
|
519
|
+
if self.VorbisKey.RATING in raw_mutagen_metadata:
|
|
520
|
+
del raw_mutagen_metadata[self.VorbisKey.RATING]
|
|
521
|
+
if self.VorbisKey.RATING_TRAKTOR in raw_mutagen_metadata:
|
|
522
|
+
del raw_mutagen_metadata[self.VorbisKey.RATING_TRAKTOR]
|
|
523
|
+
else:
|
|
524
|
+
msg = f"Metadata key not handled: {unified_metadata_key}"
|
|
525
|
+
raise MetadataFieldNotSupportedByMetadataFormatError(msg)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Vorbis manager and constants."""
|
|
2
|
+
|
|
3
|
+
from ._vorbis_constants import (
|
|
4
|
+
VORBIS_BLOCK_HEADER_SIZE,
|
|
5
|
+
VORBIS_CHUNK_ID_SIZE,
|
|
6
|
+
VORBIS_COMMENT_BLOCK_TYPE,
|
|
7
|
+
VORBIS_ID3V2_HEADER_SIZE,
|
|
8
|
+
)
|
|
9
|
+
from ._VorbisManager import _VorbisManager
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"_VorbisManager",
|
|
13
|
+
"VORBIS_BLOCK_HEADER_SIZE",
|
|
14
|
+
"VORBIS_CHUNK_ID_SIZE",
|
|
15
|
+
"VORBIS_COMMENT_BLOCK_TYPE",
|
|
16
|
+
"VORBIS_ID3V2_HEADER_SIZE",
|
|
17
|
+
]
|