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.
Files changed (318) hide show
  1. audiometa/__init__.py +1240 -0
  2. audiometa/__main__.py +6 -0
  3. audiometa/_audio_file.py +602 -0
  4. audiometa/cli.py +347 -0
  5. audiometa/exceptions.py +167 -0
  6. audiometa/manager/_MetadataManager.py +665 -0
  7. audiometa/manager/__init__.py +1 -0
  8. audiometa/manager/_rating_supporting/_RatingSupportingMetadataManager.py +250 -0
  9. audiometa/manager/_rating_supporting/__init__.py +1 -0
  10. audiometa/manager/_rating_supporting/id3v2/_Id3v2Manager.py +945 -0
  11. audiometa/manager/_rating_supporting/id3v2/__init__.py +25 -0
  12. audiometa/manager/_rating_supporting/id3v2/_id3v2_constants.py +11 -0
  13. audiometa/manager/_rating_supporting/riff/_RiffManager.py +778 -0
  14. audiometa/manager/_rating_supporting/riff/__init__.py +25 -0
  15. audiometa/manager/_rating_supporting/riff/_riff_constants.py +10 -0
  16. audiometa/manager/_rating_supporting/vorbis/_VorbisManager.py +525 -0
  17. audiometa/manager/_rating_supporting/vorbis/__init__.py +17 -0
  18. audiometa/manager/_rating_supporting/vorbis/_vorbis_constants.py +6 -0
  19. audiometa/manager/id3v1/_Id3v1Manager.py +512 -0
  20. audiometa/manager/id3v1/__init__.py +1 -0
  21. audiometa/manager/id3v1/_constants.py +8 -0
  22. audiometa/manager/id3v1/id3v1_raw_metadata.py +242 -0
  23. audiometa/manager/id3v1/id3v1_raw_metadata_key.py +13 -0
  24. audiometa/test/__init__.py +1 -0
  25. audiometa/test/assets/create_test_files.py +72 -0
  26. audiometa/test/helpers/__init__.py +51 -0
  27. audiometa/test/helpers/common/__init__.py +6 -0
  28. audiometa/test/helpers/common/audio_file_creator.py +68 -0
  29. audiometa/test/helpers/common/external_tool_runner.py +74 -0
  30. audiometa/test/helpers/id3v1/__init__.py +8 -0
  31. audiometa/test/helpers/id3v1/id3v1_header_verifier.py +18 -0
  32. audiometa/test/helpers/id3v1/id3v1_metadata_deleter.py +37 -0
  33. audiometa/test/helpers/id3v1/id3v1_metadata_getter.py +61 -0
  34. audiometa/test/helpers/id3v1/id3v1_metadata_setter.py +82 -0
  35. audiometa/test/helpers/id3v2/__init__.py +28 -0
  36. audiometa/test/helpers/id3v2/id3v2_frame_manual_creator.py +305 -0
  37. audiometa/test/helpers/id3v2/id3v2_header_verifier.py +38 -0
  38. audiometa/test/helpers/id3v2/id3v2_metadata_deleter.py +56 -0
  39. audiometa/test/helpers/id3v2/id3v2_metadata_getter.py +188 -0
  40. audiometa/test/helpers/id3v2/id3v2_metadata_setter.py +428 -0
  41. audiometa/test/helpers/riff/__init__.py +8 -0
  42. audiometa/test/helpers/riff/riff_header_verifier.py +85 -0
  43. audiometa/test/helpers/riff/riff_manual_metadata_creator.py +216 -0
  44. audiometa/test/helpers/riff/riff_metadata_deleter.py +56 -0
  45. audiometa/test/helpers/riff/riff_metadata_getter.py +118 -0
  46. audiometa/test/helpers/riff/riff_metadata_setter.py +196 -0
  47. audiometa/test/helpers/scripts/__init__.py +0 -0
  48. audiometa/test/helpers/technical_info_inspector.py +115 -0
  49. audiometa/test/helpers/temp_file_with_metadata.py +82 -0
  50. audiometa/test/helpers/vorbis/__init__.py +8 -0
  51. audiometa/test/helpers/vorbis/vorbis_header_verifier.py +31 -0
  52. audiometa/test/helpers/vorbis/vorbis_metadata_deleter.py +49 -0
  53. audiometa/test/helpers/vorbis/vorbis_metadata_getter.py +67 -0
  54. audiometa/test/helpers/vorbis/vorbis_metadata_setter.py +200 -0
  55. audiometa/test/tests/__init__.py +0 -0
  56. audiometa/test/tests/conftest.py +261 -0
  57. audiometa/test/tests/e2e/__init__.py +0 -0
  58. audiometa/test/tests/e2e/cli/__init__.py +0 -0
  59. audiometa/test/tests/e2e/cli/error_handling/__init__.py +1 -0
  60. audiometa/test/tests/e2e/cli/error_handling/test_command_structure_errors.py +77 -0
  61. audiometa/test/tests/e2e/cli/error_handling/test_file_access_errors.py +130 -0
  62. audiometa/test/tests/e2e/cli/error_handling/test_format_output_errors.py +118 -0
  63. audiometa/test/tests/e2e/cli/error_handling/test_input_validation_errors.py +172 -0
  64. audiometa/test/tests/e2e/cli/error_handling/test_missing_fields_validation.py +49 -0
  65. audiometa/test/tests/e2e/cli/error_handling/test_multiple_files_errors.py +160 -0
  66. audiometa/test/tests/e2e/cli/error_handling/test_rating_validation.py +90 -0
  67. audiometa/test/tests/e2e/cli/error_handling/test_year_validation.py +51 -0
  68. audiometa/test/tests/e2e/cli/test_cli_delete.py +20 -0
  69. audiometa/test/tests/e2e/cli/test_cli_formatting.py +31 -0
  70. audiometa/test/tests/e2e/cli/test_cli_help.py +40 -0
  71. audiometa/test/tests/e2e/cli/test_cli_read.py +79 -0
  72. audiometa/test/tests/e2e/cli/test_cli_unified.py +33 -0
  73. audiometa/test/tests/e2e/cli/test_cli_write.py +116 -0
  74. audiometa/test/tests/e2e/scenarios/__init__.py +0 -0
  75. audiometa/test/tests/e2e/scenarios/test_user_scenarios.py +166 -0
  76. audiometa/test/tests/e2e/workflows/__init__.py +0 -0
  77. audiometa/test/tests/e2e/workflows/test_core_workflows.py +166 -0
  78. audiometa/test/tests/e2e/workflows/test_deletion_workflows.py +318 -0
  79. audiometa/test/tests/e2e/workflows/test_error_handling_workflows.py +165 -0
  80. audiometa/test/tests/e2e/workflows/test_format_specific_workflows.py +129 -0
  81. audiometa/test/tests/e2e/workflows/test_rating_workflows.py +124 -0
  82. audiometa/test/tests/integration/__init__.py +0 -0
  83. audiometa/test/tests/integration/audio_format/__init__.py +0 -0
  84. audiometa/test/tests/integration/audio_format/flac/__init__.py +0 -0
  85. audiometa/test/tests/integration/audio_format/flac/test_flac_delete_all.py +108 -0
  86. audiometa/test/tests/integration/audio_format/flac/test_flac_reading_all.py +61 -0
  87. audiometa/test/tests/integration/audio_format/flac/test_flac_reading_field.py +65 -0
  88. audiometa/test/tests/integration/audio_format/flac/test_flac_writing.py +69 -0
  89. audiometa/test/tests/integration/audio_format/mp3/__init__.py +0 -0
  90. audiometa/test/tests/integration/audio_format/mp3/test_mp3_delete_all.py +79 -0
  91. audiometa/test/tests/integration/audio_format/mp3/test_mp3_reading_all.py +61 -0
  92. audiometa/test/tests/integration/audio_format/mp3/test_mp3_reading_field.py +67 -0
  93. audiometa/test/tests/integration/audio_format/mp3/test_mp3_writing.py +60 -0
  94. audiometa/test/tests/integration/audio_format/wav/__init__.py +0 -0
  95. audiometa/test/tests/integration/audio_format/wav/test_wav_delete_all.py +87 -0
  96. audiometa/test/tests/integration/audio_format/wav/test_wav_reading_all.py +62 -0
  97. audiometa/test/tests/integration/audio_format/wav/test_wav_reading_field.py +57 -0
  98. audiometa/test/tests/integration/audio_format/wav/test_wav_with_id3v2_tags.py +83 -0
  99. audiometa/test/tests/integration/audio_format/wav/test_wav_writing.py +62 -0
  100. audiometa/test/tests/integration/conftest.py +29 -0
  101. audiometa/test/tests/integration/delete_all_metadata/__init__.py +1 -0
  102. audiometa/test/tests/integration/delete_all_metadata/test_audio_format_all.py +102 -0
  103. audiometa/test/tests/integration/delete_all_metadata/test_audio_format_header_deletion.py +77 -0
  104. audiometa/test/tests/integration/delete_all_metadata/test_basic_functionality.py +47 -0
  105. audiometa/test/tests/integration/delete_all_metadata/test_error_handling.py +24 -0
  106. audiometa/test/tests/integration/encoding/__init__.py +1 -0
  107. audiometa/test/tests/integration/encoding/test_encoding.py +88 -0
  108. audiometa/test/tests/integration/encoding/test_special_characters_edge_cases.py +223 -0
  109. audiometa/test/tests/integration/get_full_metadata/__init__.py +0 -0
  110. audiometa/test/tests/integration/get_full_metadata/test_binary_data_filtering.py +250 -0
  111. audiometa/test/tests/integration/get_full_metadata/test_get_full_metadata.py +297 -0
  112. audiometa/test/tests/integration/get_full_metadata/test_get_full_metadata_edge_cases.py +231 -0
  113. audiometa/test/tests/integration/get_full_metadata/test_options.py +207 -0
  114. audiometa/test/tests/integration/get_full_metadata/test_parsed_fields_keys.py +85 -0
  115. audiometa/test/tests/integration/metadata_field/__init__.py +0 -0
  116. audiometa/test/tests/integration/metadata_field/album/__init__.py +0 -0
  117. audiometa/test/tests/integration/metadata_field/album/test_deleting.py +73 -0
  118. audiometa/test/tests/integration/metadata_field/album/test_reading.py +36 -0
  119. audiometa/test/tests/integration/metadata_field/album/test_writing.py +50 -0
  120. audiometa/test/tests/integration/metadata_field/album_artists/__init__.py +0 -0
  121. audiometa/test/tests/integration/metadata_field/album_artists/test_deleting.py +83 -0
  122. audiometa/test/tests/integration/metadata_field/album_artists/test_reading.py +38 -0
  123. audiometa/test/tests/integration/metadata_field/album_artists/test_writing.py +52 -0
  124. audiometa/test/tests/integration/metadata_field/artists/__init__.py +0 -0
  125. audiometa/test/tests/integration/metadata_field/artists/test_deleting.py +68 -0
  126. audiometa/test/tests/integration/metadata_field/artists/test_reading.py +36 -0
  127. audiometa/test/tests/integration/metadata_field/artists/test_writing.py +46 -0
  128. audiometa/test/tests/integration/metadata_field/bpm/__init__.py +0 -0
  129. audiometa/test/tests/integration/metadata_field/bpm/test_deleting.py +75 -0
  130. audiometa/test/tests/integration/metadata_field/bpm/test_reading.py +32 -0
  131. audiometa/test/tests/integration/metadata_field/bpm/test_writing.py +56 -0
  132. audiometa/test/tests/integration/metadata_field/comment/__init__.py +0 -0
  133. audiometa/test/tests/integration/metadata_field/comment/test_deleting.py +68 -0
  134. audiometa/test/tests/integration/metadata_field/comment/test_reading.py +36 -0
  135. audiometa/test/tests/integration/metadata_field/comment/test_writing.py +49 -0
  136. audiometa/test/tests/integration/metadata_field/composer/__init__.py +0 -0
  137. audiometa/test/tests/integration/metadata_field/composer/test_deleting.py +75 -0
  138. audiometa/test/tests/integration/metadata_field/composer/test_reading.py +34 -0
  139. audiometa/test/tests/integration/metadata_field/composer/test_writing.py +41 -0
  140. audiometa/test/tests/integration/metadata_field/copyright/__init__.py +0 -0
  141. audiometa/test/tests/integration/metadata_field/copyright/test_deleting.py +81 -0
  142. audiometa/test/tests/integration/metadata_field/copyright/test_reading.py +35 -0
  143. audiometa/test/tests/integration/metadata_field/copyright/test_writing.py +41 -0
  144. audiometa/test/tests/integration/metadata_field/field_not_supported/__init__.py +0 -0
  145. audiometa/test/tests/integration/metadata_field/field_not_supported/test_deleting.py +56 -0
  146. audiometa/test/tests/integration/metadata_field/field_not_supported/test_reading.py +54 -0
  147. audiometa/test/tests/integration/metadata_field/field_not_supported/test_writing.py +61 -0
  148. audiometa/test/tests/integration/metadata_field/genre/__init__.py +0 -0
  149. audiometa/test/tests/integration/metadata_field/genre/reading/__init__.py +0 -0
  150. audiometa/test/tests/integration/metadata_field/genre/reading/metadata_format/__init__.py +0 -0
  151. audiometa/test/tests/integration/metadata_field/genre/reading/metadata_format/test_id3v1_reading.py +65 -0
  152. audiometa/test/tests/integration/metadata_field/genre/reading/metadata_format/test_id3v2_reading.py +25 -0
  153. audiometa/test/tests/integration/metadata_field/genre/reading/metadata_format/test_riff_reading.py +58 -0
  154. audiometa/test/tests/integration/metadata_field/genre/reading/metadata_format/test_vorbis_reading.py +61 -0
  155. audiometa/test/tests/integration/metadata_field/genre/reading/test_smart_reading.py +191 -0
  156. audiometa/test/tests/integration/metadata_field/genre/test_deleting.py +62 -0
  157. audiometa/test/tests/integration/metadata_field/genre/test_writing.py +64 -0
  158. audiometa/test/tests/integration/metadata_field/language/__init__.py +0 -0
  159. audiometa/test/tests/integration/metadata_field/language/test_deleting.py +75 -0
  160. audiometa/test/tests/integration/metadata_field/language/test_reading.py +39 -0
  161. audiometa/test/tests/integration/metadata_field/language/test_writing.py +43 -0
  162. audiometa/test/tests/integration/metadata_field/lyrics/__init__.py +0 -0
  163. audiometa/test/tests/integration/metadata_field/lyrics/test_deleting.py +129 -0
  164. audiometa/test/tests/integration/metadata_field/lyrics/test_reading.py +57 -0
  165. audiometa/test/tests/integration/metadata_field/lyrics/test_writing.py +59 -0
  166. audiometa/test/tests/integration/metadata_field/publisher/__init__.py +0 -0
  167. audiometa/test/tests/integration/metadata_field/publisher/test_deleting.py +88 -0
  168. audiometa/test/tests/integration/metadata_field/publisher/test_reading.py +32 -0
  169. audiometa/test/tests/integration/metadata_field/publisher/test_writing.py +47 -0
  170. audiometa/test/tests/integration/metadata_field/rating/__init__.py +0 -0
  171. audiometa/test/tests/integration/metadata_field/rating/reading/__init__.py +0 -0
  172. audiometa/test/tests/integration/metadata_field/rating/reading/test_base_100_proportional.py +81 -0
  173. audiometa/test/tests/integration/metadata_field/rating/reading/test_base_255_non_proportional.py +33 -0
  174. audiometa/test/tests/integration/metadata_field/rating/reading/test_base_255_proportional.py +58 -0
  175. audiometa/test/tests/integration/metadata_field/rating/test_deleting.py +117 -0
  176. audiometa/test/tests/integration/metadata_field/rating/test_error_handling.py +137 -0
  177. audiometa/test/tests/integration/metadata_field/rating/writing/__init__.py +0 -0
  178. audiometa/test/tests/integration/metadata_field/rating/writing/metadata_format/__init__.py +0 -0
  179. audiometa/test/tests/integration/metadata_field/rating/writing/metadata_format/test_id3v2.py +77 -0
  180. audiometa/test/tests/integration/metadata_field/rating/writing/metadata_format/test_riff.py +55 -0
  181. audiometa/test/tests/integration/metadata_field/rating/writing/metadata_format/test_vorbis.py +57 -0
  182. audiometa/test/tests/integration/metadata_field/rating/writing/test_comprehensive.py +192 -0
  183. audiometa/test/tests/integration/metadata_field/release_date/__init__.py +0 -0
  184. audiometa/test/tests/integration/metadata_field/release_date/test_deleting.py +74 -0
  185. audiometa/test/tests/integration/metadata_field/release_date/test_error_handling.py +82 -0
  186. audiometa/test/tests/integration/metadata_field/release_date/test_reading.py +59 -0
  187. audiometa/test/tests/integration/metadata_field/release_date/test_writing.py +49 -0
  188. audiometa/test/tests/integration/metadata_field/test_metadata_field_validation.py +134 -0
  189. audiometa/test/tests/integration/metadata_field/title/__init__.py +0 -0
  190. audiometa/test/tests/integration/metadata_field/title/test_deleting.py +73 -0
  191. audiometa/test/tests/integration/metadata_field/title/test_error_handling.py +47 -0
  192. audiometa/test/tests/integration/metadata_field/title/test_reading.py +36 -0
  193. audiometa/test/tests/integration/metadata_field/title/test_writing.py +64 -0
  194. audiometa/test/tests/integration/metadata_field/track_number/__init__.py +0 -0
  195. audiometa/test/tests/integration/metadata_field/track_number/reading/__init__.py +0 -0
  196. audiometa/test/tests/integration/metadata_field/track_number/reading/test_edge_cases.py +43 -0
  197. audiometa/test/tests/integration/metadata_field/track_number/reading/test_metadata_format.py +32 -0
  198. audiometa/test/tests/integration/metadata_field/track_number/test_deleting.py +59 -0
  199. audiometa/test/tests/integration/metadata_field/track_number/test_writing.py +73 -0
  200. audiometa/test/tests/integration/multiple_values/__init__.py +1 -0
  201. audiometa/test/tests/integration/multiple_values/reading/__init__.py +1 -0
  202. audiometa/test/tests/integration/multiple_values/reading/metadata_format/__init__.py +1 -0
  203. audiometa/test/tests/integration/multiple_values/reading/metadata_format/test_id3v1.py +23 -0
  204. audiometa/test/tests/integration/multiple_values/reading/metadata_format/test_id3v2_3.py +92 -0
  205. audiometa/test/tests/integration/multiple_values/reading/metadata_format/test_id3v2_4.py +216 -0
  206. audiometa/test/tests/integration/multiple_values/reading/metadata_format/test_riff.py +84 -0
  207. audiometa/test/tests/integration/multiple_values/reading/metadata_format/test_vorbis.py +169 -0
  208. audiometa/test/tests/integration/multiple_values/reading/test_performance_large_data.py +209 -0
  209. audiometa/test/tests/integration/multiple_values/reading/test_smart_parsing_scenarios.py +198 -0
  210. audiometa/test/tests/integration/multiple_values/reading/test_unicode_handling.py +24 -0
  211. audiometa/test/tests/integration/multiple_values/writing/__init__.py +1 -0
  212. audiometa/test/tests/integration/multiple_values/writing/metadata_format/__init__.py +1 -0
  213. audiometa/test/tests/integration/multiple_values/writing/metadata_format/test_id3v1.py +62 -0
  214. audiometa/test/tests/integration/multiple_values/writing/metadata_format/test_id3v2_3.py +36 -0
  215. audiometa/test/tests/integration/multiple_values/writing/metadata_format/test_id3v2_4.py +34 -0
  216. audiometa/test/tests/integration/multiple_values/writing/metadata_format/test_riff.py +32 -0
  217. audiometa/test/tests/integration/multiple_values/writing/metadata_format/test_vorbis.py +54 -0
  218. audiometa/test/tests/integration/multiple_values/writing/test_error_handling.py +42 -0
  219. audiometa/test/tests/integration/multiple_values/writing/test_large_values.py +98 -0
  220. audiometa/test/tests/integration/reading/__init__.py +1 -0
  221. audiometa/test/tests/integration/reading/test_read_multiple_metadata.py +80 -0
  222. audiometa/test/tests/integration/reading/test_reading_error_handling.py +36 -0
  223. audiometa/test/tests/integration/real_audio_files/__init__.py +0 -0
  224. audiometa/test/tests/integration/real_audio_files/test_reading.py +146 -0
  225. audiometa/test/tests/integration/real_audio_files/test_writing.py +198 -0
  226. audiometa/test/tests/integration/technical_info/__init__.py +0 -0
  227. audiometa/test/tests/integration/technical_info/flac_md5/__init__.py +0 -0
  228. audiometa/test/tests/integration/technical_info/flac_md5/conftest.py +103 -0
  229. audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/__init__.py +0 -0
  230. audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/test_audio_data_corruption.py +21 -0
  231. audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/test_flipped_md5.py +29 -0
  232. audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/test_non_flac_error.py +13 -0
  233. audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/test_partial_md5.py +29 -0
  234. audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/test_random_md5.py +29 -0
  235. audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/test_unset_md5.py +56 -0
  236. audiometa/test/tests/integration/technical_info/flac_md5/test_valid_md5.py +21 -0
  237. audiometa/test/tests/integration/technical_info/test_bitrate.py +38 -0
  238. audiometa/test/tests/integration/technical_info/test_channels.py +38 -0
  239. audiometa/test/tests/integration/technical_info/test_duration_in_sec.py +38 -0
  240. audiometa/test/tests/integration/technical_info/test_sample_rate.py +40 -0
  241. audiometa/test/tests/integration/test_audio_file.py +23 -0
  242. audiometa/test/tests/integration/test_audio_format_readable_after_update_all_metadata_formats.py +95 -0
  243. audiometa/test/tests/integration/writing/__init__.py +0 -0
  244. audiometa/test/tests/integration/writing/test_error_handling.py +44 -0
  245. audiometa/test/tests/integration/writing/test_forced_format.py +224 -0
  246. audiometa/test/tests/integration/writing/test_multiple_format_preservation.py +223 -0
  247. audiometa/test/tests/integration/writing/test_partial_update.py +36 -0
  248. audiometa/test/tests/integration/writing/writing_strategies/__init__.py +0 -0
  249. audiometa/test/tests/integration/writing/writing_strategies/test_cleanup_strategy.py +79 -0
  250. audiometa/test/tests/integration/writing/writing_strategies/test_preserve_strategy.py +76 -0
  251. audiometa/test/tests/integration/writing/writing_strategies/test_sync_strategy.py +215 -0
  252. audiometa/test/tests/integration/writing/writing_strategies/unsupported_fields/__init__.py +0 -0
  253. audiometa/test/tests/integration/writing/writing_strategies/unsupported_fields/test_fail_behavior.py +42 -0
  254. audiometa/test/tests/integration/writing/writing_strategies/unsupported_fields/test_no_writing_on_failure.py +93 -0
  255. audiometa/test/tests/integration/writing/writing_strategies/unsupported_fields/test_strategy_specific.py +99 -0
  256. audiometa/test/tests/unit/__init__.py +0 -0
  257. audiometa/test/tests/unit/audio_file/__init__.py +0 -0
  258. audiometa/test/tests/unit/audio_file/technical_info/__init__.py +0 -0
  259. audiometa/test/tests/unit/audio_file/technical_info/test_bitrate.py +26 -0
  260. audiometa/test/tests/unit/audio_file/technical_info/test_channels.py +31 -0
  261. audiometa/test/tests/unit/audio_file/technical_info/test_duration_in_sec.py +38 -0
  262. audiometa/test/tests/unit/audio_file/technical_info/test_error_handling.py +130 -0
  263. audiometa/test/tests/unit/audio_file/technical_info/test_file_size.py +51 -0
  264. audiometa/test/tests/unit/audio_file/technical_info/test_format_name.py +28 -0
  265. audiometa/test/tests/unit/audio_file/technical_info/test_sample_rate.py +31 -0
  266. audiometa/test/tests/unit/audio_file/test_context_manager.py +30 -0
  267. audiometa/test/tests/unit/audio_file/test_file_validation.py +40 -0
  268. audiometa/test/tests/unit/audio_file/test_operations.py +20 -0
  269. audiometa/test/tests/unit/audio_file/test_path_handling.py +23 -0
  270. audiometa/test/tests/unit/cli/__init__.py +0 -0
  271. audiometa/test/tests/unit/cli/test_expand_file_patterns.py +234 -0
  272. audiometa/test/tests/unit/metadata_managers/__init__.py +0 -0
  273. audiometa/test/tests/unit/metadata_managers/conftest.py +142 -0
  274. audiometa/test/tests/unit/metadata_managers/header_info/__init__.py +0 -0
  275. audiometa/test/tests/unit/metadata_managers/header_info/test_id3v1.py +49 -0
  276. audiometa/test/tests/unit/metadata_managers/header_info/test_id3v2.py +66 -0
  277. audiometa/test/tests/unit/metadata_managers/header_info/test_riff.py +57 -0
  278. audiometa/test/tests/unit/metadata_managers/header_info/test_vorbis.py +53 -0
  279. audiometa/test/tests/unit/metadata_managers/metadata_field/__init__.py +0 -0
  280. audiometa/test/tests/unit/metadata_managers/metadata_field/multiple_values/__init__.py +0 -0
  281. audiometa/test/tests/unit/metadata_managers/metadata_field/multiple_values/reading/__init__.py +0 -0
  282. audiometa/test/tests/unit/metadata_managers/metadata_field/multiple_values/reading/test_smart_parsing.py +186 -0
  283. audiometa/test/tests/unit/metadata_managers/metadata_field/multiple_values/writing/__init__.py +0 -0
  284. audiometa/test/tests/unit/metadata_managers/metadata_field/multiple_values/writing/test_separator_selection.py +142 -0
  285. audiometa/test/tests/unit/metadata_managers/metadata_field/multiple_values/writing/test_value_filtering.py +76 -0
  286. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/__init__.py +0 -0
  287. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/reading/__init__.py +0 -0
  288. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/reading/test_normalization.py +152 -0
  289. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/reading/test_profiles_values.py +23 -0
  290. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/test_rating_validation.py +77 -0
  291. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/writing/__init__.py +0 -0
  292. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/writing/test_validation.py +151 -0
  293. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/writing/test_writing_profiles.py +61 -0
  294. audiometa/test/tests/unit/metadata_managers/metadata_field/test_date_format_validation.py +135 -0
  295. audiometa/test/tests/unit/metadata_managers/metadata_field/test_track_number_validation.py +46 -0
  296. audiometa/test/tests/unit/metadata_managers/metadata_field/test_type_validation_exception.py +22 -0
  297. audiometa/test/tests/unit/metadata_managers/metadata_field/test_validation.py +83 -0
  298. audiometa/test/tests/unit/metadata_managers/test_metadata_format_managers_write_and_read.py +74 -0
  299. audiometa/utils/__init__.py +1 -0
  300. audiometa/utils/id3v1_genre_code_map.py +205 -0
  301. audiometa/utils/metadata_format.py +31 -0
  302. audiometa/utils/metadata_writing_strategy.py +16 -0
  303. audiometa/utils/os_dependencies_checker/__init__.py +24 -0
  304. audiometa/utils/os_dependencies_checker/base.py +62 -0
  305. audiometa/utils/os_dependencies_checker/config.py +51 -0
  306. audiometa/utils/os_dependencies_checker/macos.py +236 -0
  307. audiometa/utils/os_dependencies_checker/ubuntu.py +95 -0
  308. audiometa/utils/os_dependencies_checker/windows.py +227 -0
  309. audiometa/utils/rating_profiles.py +110 -0
  310. audiometa/utils/tool_path_resolver.py +135 -0
  311. audiometa/utils/types.py +82 -0
  312. audiometa/utils/unified_metadata_key.py +81 -0
  313. audiometa_python-0.2.2.dist-info/METADATA +1464 -0
  314. audiometa_python-0.2.2.dist-info/RECORD +318 -0
  315. audiometa_python-0.2.2.dist-info/WHEEL +5 -0
  316. audiometa_python-0.2.2.dist-info/entry_points.txt +2 -0
  317. audiometa_python-0.2.2.dist-info/licenses/LICENSE +202 -0
  318. audiometa_python-0.2.2.dist-info/top_level.txt +1 -0
audiometa/__init__.py ADDED
@@ -0,0 +1,1240 @@
1
+ """Audio metadata handling module.
2
+
3
+ A comprehensive Python library for reading and writing audio metadata across multiple formats
4
+ including MP3, FLAC, WAV, and more. Supports ID3v1, ID3v2, Vorbis (FLAC), and RIFF (WAV) formats
5
+ with 15+ metadata fields including title, artist, album, rating, BPM, and more.
6
+
7
+ Note: OGG file support is planned but not yet implemented.
8
+
9
+ For detailed metadata support information, see the README.md file.
10
+ """
11
+
12
+ import contextlib
13
+ import warnings
14
+ from pathlib import Path
15
+ from typing import Any, Union, cast
16
+
17
+ from ._audio_file import _AudioFile
18
+ from .exceptions import (
19
+ FileCorruptedError,
20
+ FileTypeNotSupportedError,
21
+ InvalidMetadataFieldTypeError,
22
+ MetadataFieldNotSupportedByLibError,
23
+ MetadataFieldNotSupportedByMetadataFormatError,
24
+ MetadataFormatNotSupportedByAudioFormatError,
25
+ MetadataWritingConflictParametersError,
26
+ )
27
+ from .manager._MetadataManager import _MetadataManager
28
+ from .manager._rating_supporting._RatingSupportingMetadataManager import _RatingSupportingMetadataManager
29
+ from .manager._rating_supporting.id3v2._Id3v2Manager import _Id3v2Manager
30
+ from .manager._rating_supporting.riff._RiffManager import _RiffManager
31
+ from .manager._rating_supporting.vorbis._VorbisManager import _VorbisManager
32
+ from .manager.id3v1._Id3v1Manager import _Id3v1Manager
33
+ from .utils.metadata_format import MetadataFormat
34
+ from .utils.metadata_writing_strategy import MetadataWritingStrategy
35
+ from .utils.types import UnifiedMetadata, UnifiedMetadataValue
36
+ from .utils.unified_metadata_key import UnifiedMetadataKey
37
+
38
+ FILE_EXTENSION_NOT_HANDLED_MESSAGE = "The file's format is not handled by the service."
39
+
40
+ METADATA_FORMAT_MANAGER_CLASS_MAP: dict[MetadataFormat, type] = {
41
+ MetadataFormat.ID3V1: _Id3v1Manager,
42
+ MetadataFormat.ID3V2: _Id3v2Manager,
43
+ MetadataFormat.VORBIS: _VorbisManager,
44
+ MetadataFormat.RIFF: _RiffManager,
45
+ }
46
+
47
+ # Public API: only accepts standard file path types (not _AudioFile)
48
+ type PublicFileType = str | Path
49
+
50
+
51
+ def _get_metadata_manager(
52
+ audio_file: _AudioFile,
53
+ metadata_format: MetadataFormat | None = None,
54
+ normalized_rating_max_value: int | None = None,
55
+ id3v2_version: tuple[int, int, int] | None = None,
56
+ ) -> _MetadataManager:
57
+ audio_file_prioritized_tag_formats = MetadataFormat.get_priorities().get(audio_file.file_extension)
58
+ if not audio_file_prioritized_tag_formats:
59
+ raise FileTypeNotSupportedError(FILE_EXTENSION_NOT_HANDLED_MESSAGE)
60
+
61
+ if not metadata_format:
62
+ metadata_format = audio_file_prioritized_tag_formats[0]
63
+ elif metadata_format not in audio_file_prioritized_tag_formats:
64
+ msg = f"Tag format {metadata_format} not supported for file extension {audio_file.file_extension}"
65
+ raise MetadataFormatNotSupportedByAudioFormatError(msg)
66
+
67
+ manager_class: type[_MetadataManager] = cast(Any, METADATA_FORMAT_MANAGER_CLASS_MAP[metadata_format])
68
+ if issubclass(manager_class, _RatingSupportingMetadataManager):
69
+ if manager_class is _Id3v2Manager:
70
+ # Determine ID3v2 version based on provided version or use default
71
+ version = id3v2_version if id3v2_version is not None else (2, 3, 0) # Default to ID3v2.3
72
+ id3v2_manager_class = cast(type[_Id3v2Manager], manager_class)
73
+ return cast(
74
+ _MetadataManager,
75
+ id3v2_manager_class(
76
+ audio_file=audio_file,
77
+ normalized_rating_max_value=normalized_rating_max_value,
78
+ id3v2_version=version,
79
+ ),
80
+ )
81
+ return manager_class(audio_file=audio_file, normalized_rating_max_value=normalized_rating_max_value) # type: ignore[call-arg]
82
+ return manager_class(audio_file=audio_file) # type: ignore[call-arg]
83
+
84
+
85
+ def _get_metadata_managers(
86
+ audio_file: _AudioFile,
87
+ tag_formats: list[MetadataFormat] | None = None,
88
+ normalized_rating_max_value: int | None = None,
89
+ id3v2_version: tuple[int, int, int] | None = None,
90
+ ) -> dict[MetadataFormat, _MetadataManager]:
91
+ managers = {}
92
+
93
+ if not tag_formats:
94
+ tag_formats = MetadataFormat.get_priorities().get(audio_file.file_extension)
95
+ if not tag_formats:
96
+ raise FileTypeNotSupportedError(FILE_EXTENSION_NOT_HANDLED_MESSAGE)
97
+
98
+ for metadata_format in tag_formats:
99
+ managers[metadata_format] = _get_metadata_manager(
100
+ audio_file=audio_file,
101
+ metadata_format=metadata_format,
102
+ normalized_rating_max_value=normalized_rating_max_value,
103
+ id3v2_version=id3v2_version,
104
+ )
105
+ return managers
106
+
107
+
108
+ def get_unified_metadata(
109
+ file: PublicFileType,
110
+ normalized_rating_max_value: int | None = None,
111
+ id3v2_version: tuple[int, int, int] | None = None,
112
+ metadata_format: MetadataFormat | None = None,
113
+ ) -> UnifiedMetadata:
114
+ """Get metadata from a file, either unified across all formats or from a specific format only.
115
+
116
+ When metadata_format is None (default), this function reads metadata from all available
117
+ formats (ID3v1, ID3v2, Vorbis, RIFF) and returns a unified dictionary with the best
118
+ available data for each field.
119
+
120
+ When metadata_format is specified, this function reads metadata from only the specified
121
+ format, returning data from that format only.
122
+
123
+ Args:
124
+ file: Audio file path (str or Path)
125
+ normalized_rating_max_value: Maximum value for rating normalization (0-10 scale).
126
+ When provided, ratings are normalized to this scale. Defaults to None (raw values).
127
+ id3v2_version: ID3v2 version tuple for ID3v2-specific operations
128
+ metadata_format: Specific metadata format to read from. If None, reads from all available formats.
129
+
130
+ Returns:
131
+ Dictionary containing metadata fields
132
+
133
+ Raises:
134
+ FileTypeNotSupportedError: If the file format is not supported
135
+ FileNotFoundError: If the file does not exist
136
+
137
+ Examples:
138
+ # Get all metadata with raw rating values (unified)
139
+ metadata = get_unified_metadata("song.mp3")
140
+ print(metadata.get(UnifiedMetadataKey.TITLE))
141
+
142
+ # Get all metadata with normalized ratings (unified)
143
+ metadata = get_unified_metadata("song.mp3", normalized_rating_max_value=100)
144
+ print(metadata.get(UnifiedMetadataKey.RATING)) # Returns 0-100
145
+
146
+ # Get metadata from FLAC file (unified)
147
+ metadata = get_unified_metadata("song.flac")
148
+ print(metadata.get(UnifiedMetadataKey.ARTISTS))
149
+
150
+ # Get only ID3v2 metadata
151
+ metadata = get_unified_metadata("song.mp3", metadata_format=MetadataFormat.ID3V2)
152
+ print(metadata.get(UnifiedMetadataKey.TITLE))
153
+
154
+ # Get only Vorbis metadata from FLAC
155
+ metadata = get_unified_metadata("song.flac", metadata_format=MetadataFormat.VORBIS)
156
+ print(metadata.get(UnifiedMetadataKey.ARTISTS))
157
+
158
+ # Get ID3v2 metadata with normalized ratings
159
+ metadata = get_unified_metadata(
160
+ "song.mp3", metadata_format=MetadataFormat.ID3V2, normalized_rating_max_value=100
161
+ )
162
+ print(metadata.get(UnifiedMetadataKey.RATING)) # Returns 0-100
163
+ """
164
+ audio_file = _AudioFile(file)
165
+
166
+ # If specific format requested, return data from that format only
167
+ if metadata_format is not None:
168
+ manager = _get_metadata_manager(
169
+ audio_file=audio_file,
170
+ metadata_format=metadata_format,
171
+ normalized_rating_max_value=normalized_rating_max_value,
172
+ id3v2_version=id3v2_version,
173
+ )
174
+ return manager.get_unified_metadata()
175
+
176
+ # Get all available managers for this file type
177
+ all_managers = _get_metadata_managers(
178
+ audio_file=audio_file, normalized_rating_max_value=normalized_rating_max_value, id3v2_version=id3v2_version
179
+ )
180
+
181
+ # Get file-specific format priorities
182
+ available_formats = MetadataFormat.get_priorities().get(audio_file.file_extension, [])
183
+ managers_by_precedence = []
184
+
185
+ for format_type in available_formats:
186
+ if format_type in all_managers:
187
+ managers_by_precedence.append((format_type, all_managers[format_type]))
188
+
189
+ result: dict[UnifiedMetadataKey, UnifiedMetadataValue] = {}
190
+ for unified_metadata_key in UnifiedMetadataKey:
191
+ for _format_type, manager in managers_by_precedence:
192
+ try:
193
+ unified_metadata = manager.get_unified_metadata()
194
+ if unified_metadata_key in unified_metadata:
195
+ value = unified_metadata[unified_metadata_key]
196
+ if value is not None:
197
+ result[unified_metadata_key] = value
198
+ break
199
+ except Exception:
200
+ # If this manager fails, continue to the next one
201
+ continue
202
+ return result
203
+
204
+
205
+ def get_unified_metadata_field(
206
+ file: PublicFileType,
207
+ unified_metadata_key: str | UnifiedMetadataKey,
208
+ normalized_rating_max_value: int | None = None,
209
+ id3v2_version: tuple[int, int, int] | None = None,
210
+ metadata_format: MetadataFormat | None = None,
211
+ ) -> UnifiedMetadataValue:
212
+ """Get a specific unified metadata field from an audio file.
213
+
214
+ Args:
215
+ file: Audio file path (str or Path)
216
+ unified_metadata_key: The metadata field to retrieve. Can be a UnifiedMetadataKey enum instance
217
+ or a string matching an enum value (e.g., "title").
218
+ normalized_rating_max_value: Maximum value for rating normalization (0-10 scale).
219
+ Only used when unified_metadata_key is RATING. For other metadata fields,
220
+ this parameter is ignored. Defaults to None (no normalization).
221
+ id3v2_version: ID3v2 version tuple for ID3v2-specific operations
222
+ metadata_format: Specific metadata format to read from. If None, uses priority order.
223
+
224
+ Returns:
225
+ The metadata value or None if not found
226
+
227
+ Raises:
228
+ MetadataFieldNotSupportedByMetadataFormatError: When metadata_format is specified and the field
229
+ is not supported by that format
230
+ MetadataFieldNotSupportedByLibError: When the field is not supported by any format in the library
231
+ (only when metadata_format is None and all formats raise MetadataFieldNotSupportedByMetadataFormatError)
232
+
233
+ Examples:
234
+ # Get title from any format (priority order)
235
+ title = get_unified_metadata_field("song.mp3", UnifiedMetadataKey.TITLE)
236
+
237
+ # Get title specifically from ID3v2
238
+ title = get_unified_metadata_field("song.mp3", UnifiedMetadataKey.TITLE, metadata_format=MetadataFormat.ID3V2)
239
+
240
+ # Get rating without normalization
241
+ rating = get_unified_metadata_field("song.mp3", UnifiedMetadataKey.RATING)
242
+
243
+ # Get rating with 0-100 normalization
244
+ rating = get_unified_metadata_field("song.mp3", UnifiedMetadataKey.RATING, normalized_rating_max_value=100)
245
+
246
+ # Handle format-specific errors
247
+ try:
248
+ bpm = get_unified_metadata_field("song.wav", UnifiedMetadataKey.BPM, metadata_format=MetadataFormat.RIFF)
249
+ except MetadataFieldNotSupportedByMetadataFormatError:
250
+ print("BPM not supported by RIFF format")
251
+
252
+ # Handle library-wide errors
253
+ try:
254
+ value = get_unified_metadata_field("song.mp3", UnifiedMetadataKey.SOME_FIELD)
255
+ except MetadataFieldNotSupportedByLibError:
256
+ print("Field not supported by any format in the library")
257
+ """
258
+ unified_metadata_key = _ensure_unified_metadata_key(unified_metadata_key)
259
+
260
+ audio_file = _AudioFile(file)
261
+
262
+ if metadata_format is not None:
263
+ # Get metadata from specific format
264
+ manager = _get_metadata_manager(
265
+ audio_file=audio_file,
266
+ metadata_format=metadata_format,
267
+ normalized_rating_max_value=normalized_rating_max_value,
268
+ id3v2_version=id3v2_version,
269
+ )
270
+ try:
271
+ return manager.get_unified_metadata_field(unified_metadata_key=unified_metadata_key)
272
+ except MetadataFieldNotSupportedByMetadataFormatError:
273
+ # Re-raise format-specific errors to let the user know the field is not supported
274
+ raise
275
+ except Exception:
276
+ return None
277
+ else:
278
+ # Use priority order across all formats
279
+ managers_prioritized = _get_metadata_managers(
280
+ audio_file=audio_file, normalized_rating_max_value=normalized_rating_max_value, id3v2_version=id3v2_version
281
+ )
282
+
283
+ # Try each manager in priority order until we find a value
284
+ format_errors = []
285
+ for format_type, manager in managers_prioritized.items():
286
+ try:
287
+ value = manager.get_unified_metadata_field(unified_metadata_key=unified_metadata_key)
288
+ if value is not None:
289
+ return value
290
+ except MetadataFieldNotSupportedByMetadataFormatError as e:
291
+ # Track format-specific errors to determine if field is supported by library at all
292
+ format_errors.append((format_type, e))
293
+ except Exception:
294
+ # If this manager fails for other reasons, try the next one
295
+ continue
296
+
297
+ # If ALL managers raised MetadataFieldNotSupportedByMetadataFormatError,
298
+ # the field is not supported by the library at all
299
+ if len(format_errors) == len(managers_prioritized) and len(format_errors) > 0:
300
+ msg = f"{unified_metadata_key} metadata field is not supported by any format in the library"
301
+ raise MetadataFieldNotSupportedByLibError(msg)
302
+
303
+ return None
304
+
305
+
306
+ def _ensure_unified_metadata_key(key: str | UnifiedMetadataKey) -> UnifiedMetadataKey:
307
+ """Ensure a key is a UnifiedMetadataKey enum instance.
308
+
309
+ This function accepts both UnifiedMetadataKey enum instances and string values that match
310
+ enum values. Converts string keys to enum instances when they match. This provides runtime
311
+ validation since Python doesn't enforce type hints at runtime, allowing the function to catch
312
+ invalid inputs (e.g., invalid strings) that would otherwise cause confusing errors later in
313
+ the code.
314
+
315
+ Args:
316
+ key: The metadata key to ensure. Can be a UnifiedMetadataKey enum instance or a string
317
+ matching an enum value (e.g., "title", "artist").
318
+
319
+ Returns:
320
+ The normalized UnifiedMetadataKey enum instance.
321
+
322
+ Raises:
323
+ MetadataFieldNotSupportedByLibError: When the key is not a valid UnifiedMetadataKey
324
+ (neither an enum instance nor a string matching an enum value).
325
+ """
326
+ if isinstance(key, UnifiedMetadataKey):
327
+ return key
328
+ if isinstance(key, str):
329
+ for enum_member in UnifiedMetadataKey:
330
+ if enum_member.value == key:
331
+ return enum_member
332
+ msg = f"{key} metadata not supported by the library."
333
+ raise MetadataFieldNotSupportedByLibError(msg)
334
+
335
+
336
+ def _validate_unified_metadata_types(unified_metadata: UnifiedMetadata) -> None:
337
+ """Validate types of values in unified_metadata against UnifiedMetadataKey.get_optional_type().
338
+
339
+ Raises InvalidMetadataFieldTypeError when a value does not match the expected type. None values are allowed (used to
340
+ indicate removal of a field).
341
+
342
+ Note: This function only validates types, not formats. Format validation (e.g., release date, track number)
343
+ is handled separately.
344
+ """
345
+ if not unified_metadata:
346
+ return
347
+
348
+ from typing import get_args, get_origin
349
+
350
+ for raw_key, value in unified_metadata.items():
351
+ key = _ensure_unified_metadata_key(raw_key)
352
+
353
+ # Allow None to mean "remove this field"
354
+ if value is None:
355
+ continue
356
+
357
+ try:
358
+ expected_type = key.get_optional_type()
359
+ except Exception as err:
360
+ msg = f"Cannot determine expected type for key: {key.value}"
361
+ raise TypeError(msg) from err
362
+
363
+ origin = get_origin(expected_type)
364
+ if origin is list:
365
+ # Expect a list of a particular type (e.g., list[str]). Do NOT allow
366
+ # single values of the inner type; callers must provide a list.
367
+ arg_types = get_args(expected_type)
368
+ item_type = arg_types[0] if arg_types else str
369
+ # Value must be a list and all items must be of the expected inner type
370
+ if not isinstance(value, list):
371
+ raise InvalidMetadataFieldTypeError(
372
+ key.value, f"list[{getattr(item_type, '__name__', str(item_type))}]", value
373
+ )
374
+ # Allow None values in lists - they will be filtered out automatically during writing
375
+ if not all(item is None or isinstance(item, item_type) for item in value):
376
+ raise InvalidMetadataFieldTypeError(
377
+ key.value, f"list[{getattr(item_type, '__name__', str(item_type))}]", value
378
+ )
379
+ elif origin == Union or (origin is not None and hasattr(origin, "__name__") and origin.__name__ == "UnionType"):
380
+ # Handle Union types (e.g., Union[int, str] or int | float)
381
+ arg_types = get_args(expected_type)
382
+ if not isinstance(value, arg_types):
383
+ type_names = ", ".join(getattr(t, "__name__", str(t)) if t is not None else "None" for t in arg_types)
384
+ raise InvalidMetadataFieldTypeError(key.value, f"Union[{type_names}]", value)
385
+ # expected_type is a plain type like str or int
386
+ elif not isinstance(value, expected_type):
387
+ # Special case for TRACK_NUMBER: allow int for writing convenience (returns string when reading)
388
+ if key == UnifiedMetadataKey.TRACK_NUMBER and isinstance(value, int | str):
389
+ continue
390
+ raise InvalidMetadataFieldTypeError(
391
+ key.value, getattr(expected_type, "__name__", str(expected_type)), value
392
+ )
393
+
394
+
395
+ def validate_metadata_for_update(
396
+ unified_metadata: dict[UnifiedMetadataKey, Any] | UnifiedMetadata,
397
+ normalized_rating_max_value: int | None = None,
398
+ ) -> None:
399
+ """Validate unified metadata values before updating metadata in a file.
400
+
401
+ This function validates that a metadata dictionary contains at least one field and validates
402
+ the types and formats of values. None values (which indicate field removal), empty strings,
403
+ empty lists, and lists containing None values are all considered valid metadata values.
404
+
405
+ Additionally validates rating, release date, and track number values if present (and non-empty):
406
+ - Rating values are validated using the same validation logic as the rating-supporting
407
+ metadata managers
408
+ - Release date values are validated for correct format (YYYY or YYYY-MM-DD)
409
+ - Track number values are validated for correct format (simple number or number with separator)
410
+
411
+ Note: For list-type fields (e.g., ARTISTS, GENRES), lists containing None values like
412
+ [None, None] are allowed. During writing, None values are automatically filtered out,
413
+ and if all values are filtered out, the field is removed (set to None).
414
+
415
+ String keys that match UnifiedMetadataKey enum values are automatically converted to
416
+ enum instances and validated. This allows using both string keys (e.g., "title") and
417
+ enum keys (e.g., UnifiedMetadataKey.TITLE) for validation.
418
+
419
+ Args:
420
+ unified_metadata: Dictionary containing metadata to validate. Keys can be strings
421
+ matching UnifiedMetadataKey enum values or UnifiedMetadataKey enum instances.
422
+ normalized_rating_max_value: Maximum value for rating normalization (0-10 scale).
423
+ When provided, ratings are validated against this scale. Defaults to None (raw values).
424
+
425
+ Raises:
426
+ ValueError: If no metadata fields are specified (empty dict)
427
+ InvalidRatingValueError: If rating value is invalid
428
+ InvalidMetadataFieldFormatError: If release date or track number format is invalid
429
+ MetadataFieldNotSupportedByLibError: If a string key doesn't match any UnifiedMetadataKey enum value
430
+
431
+ Examples:
432
+ >>> from audiometa import validate_metadata_for_update, UnifiedMetadataKey
433
+ >>> validate_metadata_for_update({UnifiedMetadataKey.TITLE: "Song Title"})
434
+ >>> validate_metadata_for_update({"title": "Song Title"}) # Valid
435
+ >>> validate_metadata_for_update({UnifiedMetadataKey.TITLE: ""})
436
+ >>> validate_metadata_for_update({UnifiedMetadataKey.ARTISTS: []})
437
+ >>> validate_metadata_for_update({UnifiedMetadataKey.ARTISTS: [None, None]})
438
+ >>> validate_metadata_for_update({UnifiedMetadataKey.TITLE: None})
439
+ >>> validate_metadata_for_update({UnifiedMetadataKey.RATING: 50}, normalized_rating_max_value=100)
440
+ >>> validate_metadata_for_update({UnifiedMetadataKey.RATING: 1.5}, normalized_rating_max_value=10)
441
+ >>> validate_metadata_for_update({UnifiedMetadataKey.RATING: 0}, normalized_rating_max_value=100)
442
+ >>> validate_metadata_for_update({UnifiedMetadataKey.RATING: 100}, normalized_rating_max_value=100)
443
+ >>> validate_metadata_for_update({UnifiedMetadataKey.RATING: -1}) # Error
444
+ >>> validate_metadata_for_update({UnifiedMetadataKey.RATING: 101}, normalized_rating_max_value=100) # Error
445
+ >>> validate_metadata_for_update({UnifiedMetadataKey.RATING: 33}, normalized_rating_max_value=100) # Error
446
+ >>> validate_metadata_for_update({UnifiedMetadataKey.RELEASE_DATE: "2024-01-01"})
447
+ >>> validate_metadata_for_update({UnifiedMetadataKey.RELEASE_DATE: "2024/01/01"}) # Error
448
+ >>> validate_metadata_for_update({UnifiedMetadataKey.TRACK_NUMBER: "5"}) # Valid
449
+ >>> validate_metadata_for_update({UnifiedMetadataKey.TRACK_NUMBER: 5}) # Valid
450
+ >>> validate_metadata_for_update({UnifiedMetadataKey.TRACK_NUMBER: "5/12"}) # Valid
451
+ >>> validate_metadata_for_update({UnifiedMetadataKey.TRACK_NUMBER: "/12"}) # Error
452
+ """
453
+ if not unified_metadata:
454
+ msg = "no metadata fields specified"
455
+ raise ValueError(msg)
456
+
457
+ # Convert string keys to UnifiedMetadataKey enum instances
458
+ normalized_metadata: dict[UnifiedMetadataKey, Any] = {}
459
+ for key, value in unified_metadata.items():
460
+ normalized_key = _ensure_unified_metadata_key(key)
461
+ normalized_metadata[normalized_key] = value
462
+
463
+ # Validate types
464
+ _validate_unified_metadata_types(normalized_metadata)
465
+
466
+ # Validate rating if present and non-empty
467
+ if UnifiedMetadataKey.RATING in normalized_metadata:
468
+ rating_value = normalized_metadata[UnifiedMetadataKey.RATING]
469
+ if rating_value is not None:
470
+ if isinstance(rating_value, int | float):
471
+ # In raw mode (no normalization), only accept floats that can be parsed to int
472
+ # This allows the library to accept values like 196.0 as 196
473
+ if normalized_rating_max_value is None and isinstance(rating_value, float):
474
+ if rating_value.is_integer():
475
+ rating_value = int(rating_value)
476
+ normalized_metadata[UnifiedMetadataKey.RATING] = rating_value
477
+ else:
478
+ from .exceptions import InvalidRatingValueError
479
+
480
+ msg = (
481
+ f"Rating value {rating_value} is invalid. In raw mode, float values must be whole numbers "
482
+ f"(e.g., 196.0). Half-star values like {rating_value} require normalization."
483
+ )
484
+ raise InvalidRatingValueError(msg)
485
+ from .manager._rating_supporting._RatingSupportingMetadataManager import (
486
+ _RatingSupportingMetadataManager,
487
+ )
488
+
489
+ _RatingSupportingMetadataManager.validate_rating_value(rating_value, normalized_rating_max_value)
490
+ else:
491
+ from .exceptions import InvalidRatingValueError
492
+
493
+ msg = f"Rating value must be numeric, got {type(rating_value).__name__}"
494
+ raise InvalidRatingValueError(msg)
495
+
496
+ # Validate release date if present and non-empty
497
+ if UnifiedMetadataKey.RELEASE_DATE in normalized_metadata:
498
+ release_date_value = normalized_metadata[UnifiedMetadataKey.RELEASE_DATE]
499
+ if release_date_value is not None and isinstance(release_date_value, str) and release_date_value:
500
+ from .manager._MetadataManager import _MetadataManager
501
+
502
+ _MetadataManager.validate_release_date(release_date_value)
503
+
504
+ # Validate track number if present and non-empty
505
+ if UnifiedMetadataKey.TRACK_NUMBER in normalized_metadata:
506
+ track_number_value = normalized_metadata[UnifiedMetadataKey.TRACK_NUMBER]
507
+ if track_number_value is not None:
508
+ from .manager._MetadataManager import _MetadataManager
509
+
510
+ if isinstance(track_number_value, str | int):
511
+ _MetadataManager.validate_track_number(track_number_value)
512
+
513
+
514
+ def update_metadata(
515
+ file: PublicFileType,
516
+ unified_metadata: dict[UnifiedMetadataKey, Any] | UnifiedMetadata,
517
+ normalized_rating_max_value: int | None = None,
518
+ id3v2_version: tuple[int, int, int] | None = None,
519
+ metadata_strategy: MetadataWritingStrategy | None = None,
520
+ metadata_format: MetadataFormat | None = None,
521
+ fail_on_unsupported_field: bool = False,
522
+ ) -> None:
523
+ """Update metadata in an audio file.
524
+
525
+ This function writes metadata to the specified audio file using the appropriate
526
+ format manager. It supports multiple writing strategies and format selection.
527
+
528
+ Args:
529
+ file: Audio file path (str or Path)
530
+ unified_metadata: Dictionary containing metadata to write
531
+ normalized_rating_max_value: Maximum value for rating normalization (0-10 scale).
532
+ When provided, ratings are normalized to this scale. Defaults to None (raw values).
533
+ Half-star ratings (e.g., 1.5, 2.5, 3.5) are supported to be consistent with classic star rating
534
+ systems that allow half-star increments.
535
+ id3v2_version: ID3v2 version tuple for ID3v2-specific operations
536
+ metadata_strategy: Writing strategy (SYNC, PRESERVE, CLEANUP). Defaults to SYNC.
537
+ Ignored when metadata_format is specified.
538
+ metadata_format: Specific format to write to. If None, uses the file's native format.
539
+ When specified, strategy is ignored and metadata is written only to this format.
540
+ fail_on_unsupported_field: If True, fails when any metadata field is not supported by the target format.
541
+ Applies to all strategies (SYNC, PRESERVE, CLEANUP). Defaults to False (graceful handling with warnings).
542
+
543
+ Returns:
544
+ None
545
+
546
+ Raises:
547
+ FileTypeNotSupportedError: If the file format is not supported
548
+ FileNotFoundError: If the file does not exist
549
+ MetadataFieldNotSupportedByMetadataFormatError: If the metadata field is not supported by
550
+ the format (only for PRESERVE, CLEANUP strategies)
551
+ MetadataFieldNotSupportedByLibError: If any key in unified_metadata is not a valid UnifiedMetadataKey enum value
552
+ MetadataWritingConflictParametersError: If both metadata_strategy and metadata_format are specified
553
+ InvalidRatingValueError: If invalid rating values are provided
554
+ InvalidMetadataFieldFormatError: If release date or track number format is invalid
555
+
556
+ Note:
557
+ Cannot specify both metadata_strategy and metadata_format simultaneously. Choose one approach:
558
+
559
+ - Use metadata_strategy for multi-format management (SYNC, PRESERVE, CLEANUP)
560
+ - Use metadata_format for single-format writing (writes only to specified format)
561
+
562
+ When metadata_format is specified, metadata is written only to that format and unsupported
563
+ fields will raise MetadataFieldNotSupportedByMetadataFormatError.
564
+
565
+ When metadata_strategy is used, unsupported metadata fields are handled based on the
566
+ fail_on_unsupported_field parameter: True raises MetadataFieldNotSupportedByMetadataFormatError, False (default)
567
+ handles gracefully with warnings.
568
+
569
+ Data Filtering:
570
+ For list-type metadata fields (e.g., ARTISTS, GENRES), empty strings and None values
571
+ are automatically filtered out before writing. If all values in a list are filtered out,
572
+ the field is removed entirely (set to None). This ensures clean metadata without empty
573
+ or invalid entries across all supported formats.
574
+
575
+ Examples:
576
+ # Basic metadata update
577
+ metadata = {
578
+ UnifiedMetadataKey.TITLE: "New Title",
579
+ UnifiedMetadataKey.ARTISTS: ["Artist Name"]
580
+ }
581
+ update_metadata("song.mp3", metadata)
582
+
583
+ # Update with rating normalization
584
+ metadata = {
585
+ UnifiedMetadataKey.TITLE: "New Title",
586
+ UnifiedMetadataKey.RATING: 75 # Will be normalized to 0-100 scale
587
+ }
588
+ update_metadata("song.mp3", metadata, normalized_rating_max_value=100)
589
+
590
+ # Clean up other formats (remove ID3v1, keep only ID3v2)
591
+ update_metadata("song.mp3", metadata, metadata_strategy=MetadataWritingStrategy.CLEANUP)
592
+
593
+ # Write to specific format
594
+ update_metadata("song.mp3", metadata, metadata_format=MetadataFormat.ID3V2)
595
+
596
+ # Remove specific fields by setting them to None
597
+ update_metadata("song.mp3", {
598
+ UnifiedMetadataKey.TITLE: None, # Removes title field
599
+ UnifiedMetadataKey.ARTISTS: None # Removes artist field
600
+ })
601
+
602
+ # Automatic filtering of empty values
603
+ metadata = {
604
+ UnifiedMetadataKey.ARTISTS: ["", "Artist 1", " ", "Artist 2", None]
605
+ }
606
+ # Results in: ["Artist 1", "Artist 2"] - empty strings and None filtered out
607
+ update_metadata("song.mp3", metadata)
608
+ """
609
+ audio_file = _AudioFile(file)
610
+
611
+ # Validate that both parameters are not specified simultaneously
612
+ if metadata_strategy is not None and metadata_format is not None:
613
+ msg = (
614
+ "Cannot specify both metadata_strategy and metadata_format. "
615
+ "When metadata_format is specified, strategy is not applicable. "
616
+ "Choose either: use metadata_strategy for multi-format management, "
617
+ "or metadata_format for single-format writing."
618
+ )
619
+ raise MetadataWritingConflictParametersError(msg)
620
+
621
+ # Default to SYNC strategy if not specified
622
+ if metadata_strategy is None:
623
+ metadata_strategy = MetadataWritingStrategy.SYNC
624
+
625
+ # Handle strategy-specific behavior before writing
626
+ # Validate provided unified_metadata value types before attempting any writes
627
+ _validate_unified_metadata_types(unified_metadata)
628
+
629
+ # Validate release date format if present and non-empty
630
+ if UnifiedMetadataKey.RELEASE_DATE in unified_metadata:
631
+ release_date_value = unified_metadata[UnifiedMetadataKey.RELEASE_DATE]
632
+ if release_date_value is not None and isinstance(release_date_value, str) and release_date_value:
633
+ from .manager._MetadataManager import _MetadataManager
634
+
635
+ _MetadataManager.validate_release_date(release_date_value)
636
+
637
+ # Validate track number format if present and non-empty
638
+ if UnifiedMetadataKey.TRACK_NUMBER in unified_metadata:
639
+ track_number_value = unified_metadata[UnifiedMetadataKey.TRACK_NUMBER]
640
+ if track_number_value is not None:
641
+ from .manager._MetadataManager import _MetadataManager
642
+
643
+ if isinstance(track_number_value, str | int):
644
+ _MetadataManager.validate_track_number(track_number_value)
645
+
646
+ _handle_metadata_strategy(
647
+ audio_file,
648
+ unified_metadata,
649
+ metadata_strategy,
650
+ normalized_rating_max_value,
651
+ id3v2_version,
652
+ metadata_format,
653
+ fail_on_unsupported_field,
654
+ )
655
+
656
+
657
+ def _handle_metadata_strategy(
658
+ audio_file: _AudioFile,
659
+ unified_metadata: UnifiedMetadata,
660
+ strategy: MetadataWritingStrategy,
661
+ normalized_rating_max_value: int | None,
662
+ id3v2_version: tuple[int, int, int] | None,
663
+ target_format: MetadataFormat | None = None,
664
+ fail_on_unsupported_field: bool = False,
665
+ ) -> None:
666
+ """Handle metadata strategy-specific behavior for all strategies."""
667
+
668
+ # Get the target format (specified format or native format)
669
+ if target_format:
670
+ target_format_actual = target_format
671
+ else:
672
+ available_formats = MetadataFormat.get_priorities().get(audio_file.file_extension)
673
+ if not available_formats:
674
+ msg = f"File extension {audio_file.file_extension} is not supported"
675
+ raise FileTypeNotSupportedError(msg)
676
+ target_format_actual = available_formats[0]
677
+
678
+ # When a specific format is forced, ignore strategy and write only to that format
679
+ if target_format:
680
+ all_managers = _get_metadata_managers(
681
+ audio_file=audio_file,
682
+ tag_formats=[target_format_actual],
683
+ normalized_rating_max_value=normalized_rating_max_value,
684
+ id3v2_version=id3v2_version,
685
+ )
686
+ target_manager = all_managers[target_format_actual]
687
+ target_manager.update_metadata(unified_metadata)
688
+ return
689
+
690
+ # Get all available managers for this file type
691
+ all_managers = _get_metadata_managers(
692
+ audio_file=audio_file, normalized_rating_max_value=normalized_rating_max_value, id3v2_version=id3v2_version
693
+ )
694
+
695
+ # Get other formats (non-target)
696
+ other_managers = {fmt: mgr for fmt, mgr in all_managers.items() if fmt != target_format_actual}
697
+
698
+ if strategy == MetadataWritingStrategy.CLEANUP:
699
+ # First, clean up non-target formats
700
+ for _fmt, manager in other_managers.items():
701
+ with contextlib.suppress(Exception):
702
+ manager.delete_metadata()
703
+ # Some managers might not support deletion or might fail
704
+
705
+ # Check for unsupported fields by target format
706
+ target_manager = all_managers[target_format_actual]
707
+ unsupported_fields = []
708
+ for field in unified_metadata:
709
+ if (
710
+ hasattr(target_manager, "metadata_keys_direct_map_write")
711
+ and target_manager.metadata_keys_direct_map_write
712
+ ) and field not in target_manager.metadata_keys_direct_map_write:
713
+ unsupported_fields.append(field)
714
+
715
+ if unsupported_fields:
716
+ if fail_on_unsupported_field:
717
+ msg = f"Fields not supported by {target_format_actual.value} format: {unsupported_fields}"
718
+ raise MetadataFieldNotSupportedByMetadataFormatError(msg)
719
+ warnings.warn(
720
+ f"Fields not supported by {target_format_actual.value} format will be skipped: {unsupported_fields}",
721
+ stacklevel=2,
722
+ )
723
+ # Create filtered metadata without unsupported fields
724
+ filtered_metadata = {k: v for k, v in unified_metadata.items() if k not in unsupported_fields}
725
+ unified_metadata = filtered_metadata
726
+
727
+ # Then write to target format
728
+ target_manager.update_metadata(unified_metadata)
729
+
730
+ elif strategy == MetadataWritingStrategy.SYNC:
731
+ # For SYNC, we need to write to all available formats
732
+ # Check if any fields are unsupported by the target format when fail_on_unsupported_field is True
733
+ if fail_on_unsupported_field:
734
+ target_manager = all_managers[target_format_actual]
735
+ unsupported_fields = []
736
+ for field in unified_metadata:
737
+ if (
738
+ hasattr(target_manager, "metadata_keys_direct_map_write")
739
+ and target_manager.metadata_keys_direct_map_write
740
+ ) and field not in target_manager.metadata_keys_direct_map_write:
741
+ unsupported_fields.append(field)
742
+ if unsupported_fields:
743
+ unsupported_error_msg = (
744
+ f"Fields not supported by {target_format_actual.value} format: {unsupported_fields}"
745
+ )
746
+ raise MetadataFieldNotSupportedByMetadataFormatError(unsupported_error_msg)
747
+ else:
748
+ # Filter out unsupported fields when fail_on_unsupported_field is False
749
+ target_manager = all_managers[target_format_actual]
750
+ unsupported_fields = []
751
+ for field in unified_metadata:
752
+ if (
753
+ hasattr(target_manager, "metadata_keys_direct_map_write")
754
+ and target_manager.metadata_keys_direct_map_write
755
+ ) and field not in target_manager.metadata_keys_direct_map_write:
756
+ unsupported_fields.append(field)
757
+ if unsupported_fields:
758
+ unsupported_warn_msg = (
759
+ f"Fields not supported by {target_format_actual.value} format will be skipped: {unsupported_fields}"
760
+ )
761
+ warnings.warn(unsupported_warn_msg, stacklevel=2)
762
+ # Create filtered metadata without unsupported fields
763
+ filtered_metadata = {k: v for k, v in unified_metadata.items() if k not in unsupported_fields}
764
+ unified_metadata = filtered_metadata
765
+
766
+ # Write to target format first
767
+ target_manager = all_managers[target_format_actual]
768
+ try:
769
+ target_manager.update_metadata(unified_metadata)
770
+ except MetadataFieldNotSupportedByMetadataFormatError as e:
771
+ # For SYNC strategy, log warning but continue with other formats
772
+ format_warn_msg = f"Format {target_format_actual} doesn't support some metadata fields: {e}"
773
+ warnings.warn(format_warn_msg, stacklevel=2)
774
+ except Exception as e:
775
+ # Re-raise user errors (like InvalidRatingValueError) immediately
776
+ from .exceptions import ConfigurationError, InvalidRatingValueError
777
+
778
+ if isinstance(e, InvalidRatingValueError | ConfigurationError):
779
+ raise
780
+ # Some managers might not support writing or might fail for other reasons
781
+
782
+ # Then sync all other available formats
783
+ # Note: We need to be careful about the order to avoid conflicts
784
+ for fmt_name, manager in other_managers.items():
785
+ try:
786
+ manager.update_metadata(unified_metadata)
787
+ except MetadataFieldNotSupportedByMetadataFormatError as e:
788
+ # For SYNC strategy, log warning but continue with other formats
789
+ format_warn_msg = f"Format {fmt_name} doesn't support some metadata fields: {e}"
790
+ warnings.warn(format_warn_msg, stacklevel=2)
791
+ continue
792
+ except Exception:
793
+ # Some managers might not support writing or might fail for other reasons
794
+ pass
795
+
796
+ elif strategy == MetadataWritingStrategy.PRESERVE:
797
+ # For PRESERVE, we need to save existing metadata from other formats first
798
+ preserved_metadata: dict[MetadataFormat, UnifiedMetadata] = {}
799
+ for fmt, manager in other_managers.items():
800
+ try:
801
+ existing_metadata = manager.get_unified_metadata()
802
+ if existing_metadata:
803
+ preserved_metadata[fmt] = existing_metadata
804
+ except Exception:
805
+ pass
806
+
807
+ # Check for unsupported fields by target format
808
+ target_manager = all_managers[target_format_actual]
809
+ unsupported_fields = []
810
+ for field in unified_metadata:
811
+ if (
812
+ hasattr(target_manager, "metadata_keys_direct_map_write")
813
+ and target_manager.metadata_keys_direct_map_write
814
+ ) and field not in target_manager.metadata_keys_direct_map_write:
815
+ unsupported_fields.append(field)
816
+
817
+ if unsupported_fields:
818
+ if fail_on_unsupported_field:
819
+ unsupported_error_msg = (
820
+ f"Fields not supported by {target_format_actual.value} format: {unsupported_fields}"
821
+ )
822
+ raise MetadataFieldNotSupportedByMetadataFormatError(unsupported_error_msg)
823
+ unsupported_warn_msg = (
824
+ f"Fields not supported by {target_format_actual.value} format will be skipped: {unsupported_fields}"
825
+ )
826
+ warnings.warn(unsupported_warn_msg, stacklevel=2)
827
+ # Create filtered metadata without unsupported fields
828
+ filtered_metadata = {k: v for k, v in unified_metadata.items() if k not in unsupported_fields}
829
+ unified_metadata = filtered_metadata
830
+
831
+ # Write to target format
832
+ target_manager.update_metadata(unified_metadata)
833
+
834
+ # Restore preserved metadata from other formats
835
+ for fmt, metadata in preserved_metadata.items():
836
+ try:
837
+ manager = other_managers[fmt]
838
+ manager.update_metadata(metadata)
839
+ except Exception:
840
+ # Some managers might not support writing or might fail for other reasons
841
+ pass
842
+
843
+
844
+ def delete_all_metadata(
845
+ file: PublicFileType,
846
+ metadata_format: MetadataFormat | None = None,
847
+ id3v2_version: tuple[int, int, int] | None = None,
848
+ ) -> bool:
849
+ """Delete all metadata from an audio file, including metadata headers.
850
+
851
+ This function completely removes all metadata tags and their container structures
852
+ from the specified audio file. This is a destructive operation that removes
853
+ metadata headers entirely, not just the content.
854
+
855
+ Args:
856
+ file: Audio file path (str or Path)
857
+ metadata_format: Specific format to delete metadata from. If None, deletes from ALL supported formats.
858
+ id3v2_version: ID3v2 version tuple for ID3v2-specific operations
859
+
860
+ Returns:
861
+ True if metadata was successfully deleted from at least one format, False otherwise
862
+
863
+ Raises:
864
+ FileTypeNotSupportedError: If the file format is not supported
865
+ FileNotFoundError: If the file does not exist
866
+
867
+ Examples:
868
+ # Delete ALL metadata from ALL supported formats (removes headers completely)
869
+ success = delete_all_metadata("song.mp3")
870
+
871
+ # Delete only ID3v2 metadata (keep ID3v1, removes ID3v2 headers)
872
+ success = delete_all_metadata("song.mp3", metadata_format=MetadataFormat.ID3V2)
873
+
874
+ # Delete Vorbis metadata from FLAC (removes Vorbis comment blocks)
875
+ success = delete_all_metadata("song.flac", metadata_format=MetadataFormat.VORBIS)
876
+
877
+ Note:
878
+ This function removes metadata headers entirely, significantly reducing file size.
879
+ This is different from setting individual fields to None, which only removes
880
+ specific fields while preserving the metadata structure and other fields.
881
+
882
+ When no metadata_format is specified, the function attempts to delete metadata from
883
+ ALL supported formats for the file type. Some formats may not support deletion
884
+ and will be skipped silently.
885
+
886
+ Use cases:
887
+ - Complete privacy cleanup (remove all metadata)
888
+ - File size optimization (remove all metadata headers)
889
+ - Format cleanup (remove specific format metadata)
890
+
891
+ For selective field removal, use update_metadata with None values instead.
892
+ """
893
+ audio_file = _AudioFile(file)
894
+
895
+ # If specific format requested, delete only that format
896
+ if metadata_format:
897
+ manager = _get_metadata_manager(
898
+ audio_file=audio_file, metadata_format=metadata_format, id3v2_version=id3v2_version
899
+ )
900
+ result: bool = manager.delete_metadata()
901
+ return result
902
+
903
+ # Delete from all supported formats for this file type
904
+ all_managers = _get_metadata_managers(
905
+ audio_file=audio_file, normalized_rating_max_value=None, id3v2_version=id3v2_version
906
+ )
907
+ success_count = 0
908
+
909
+ for _format_type, manager in all_managers.items():
910
+ try:
911
+ if manager.delete_metadata():
912
+ success_count += 1
913
+ except Exception:
914
+ # Some formats may not support deletion (e.g., ID3v1) or may fail
915
+ # Continue with other formats
916
+ pass
917
+
918
+ # Return True if at least one format was successfully deleted
919
+ return success_count > 0
920
+
921
+
922
+ def get_bitrate(file: PublicFileType) -> int:
923
+ """Get the bitrate of an audio file.
924
+
925
+ Args:
926
+ file: Audio file path (str or Path)
927
+
928
+ Returns:
929
+ Bitrate in bits per second
930
+
931
+ Raises:
932
+ FileTypeNotSupportedError: If the file format is not supported
933
+ FileNotFoundError: If the file does not exist
934
+
935
+ Examples:
936
+ bitrate = get_bitrate("song.mp3")
937
+ print(f"Bitrate: {bitrate} bps")
938
+ """
939
+ audio_file = _AudioFile(file)
940
+ return audio_file.get_bitrate()
941
+
942
+
943
+ def get_channels(file: PublicFileType) -> int:
944
+ """Get the number of channels in an audio file.
945
+
946
+ Args:
947
+ file: Audio file path (str or Path)
948
+
949
+ Returns:
950
+ Number of audio channels (e.g., 1 for mono, 2 for stereo)
951
+
952
+ Raises:
953
+ FileTypeNotSupportedError: If the file format is not supported
954
+ FileNotFoundError: If the file does not exist
955
+
956
+ Examples:
957
+ channels = get_channels("song.mp3")
958
+ print(f"Channels: {channels}")
959
+ """
960
+ audio_file = _AudioFile(file)
961
+ return audio_file.get_channels()
962
+
963
+
964
+ def get_file_size(file: PublicFileType) -> int:
965
+ """Get the file size of an audio file in bytes.
966
+
967
+ Args:
968
+ file: Audio file path (str or Path)
969
+
970
+ Returns:
971
+ File size in bytes
972
+
973
+ Raises:
974
+ FileTypeNotSupportedError: If the file format is not supported
975
+ FileNotFoundError: If the file does not exist
976
+
977
+ Examples:
978
+ size = get_file_size("song.mp3")
979
+ print(f"File size: {size} bytes")
980
+ """
981
+ audio_file = _AudioFile(file)
982
+ return audio_file.get_file_size()
983
+
984
+
985
+ def get_sample_rate(file: PublicFileType) -> int:
986
+ """Get the sample rate of an audio file in Hz.
987
+
988
+ Args:
989
+ file: Audio file path (str or Path)
990
+
991
+ Returns:
992
+ Sample rate in Hz
993
+
994
+ Raises:
995
+ FileTypeNotSupportedError: If the file format is not supported
996
+ FileNotFoundError: If the file does not exist
997
+
998
+ Examples:
999
+ sample_rate = get_sample_rate("song.mp3")
1000
+ print(f"Sample rate: {sample_rate} Hz")
1001
+ """
1002
+ audio_file = _AudioFile(file)
1003
+ return audio_file.get_sample_rate()
1004
+
1005
+
1006
+ def get_duration_in_sec(file: PublicFileType) -> float:
1007
+ """Get the duration of an audio file in seconds.
1008
+
1009
+ Args:
1010
+ file: Audio file path (str or Path)
1011
+
1012
+ Returns:
1013
+ Duration in seconds as a float
1014
+
1015
+ Raises:
1016
+ FileTypeNotSupportedError: If the file format is not supported
1017
+ FileNotFoundError: If the file does not exist
1018
+
1019
+ Examples:
1020
+ duration = get_duration_in_sec("song.mp3")
1021
+ print(f"Duration: {duration:.2f} seconds")
1022
+
1023
+ # Convert to minutes
1024
+ minutes = duration / 60
1025
+ print(f"Duration: {minutes:.2f} minutes")
1026
+ """
1027
+ audio_file = _AudioFile(file)
1028
+ return audio_file.get_duration_in_sec()
1029
+
1030
+
1031
+ def is_flac_md5_valid(file: PublicFileType) -> bool:
1032
+ """Check if a FLAC file's MD5 signature is valid.
1033
+
1034
+ This function verifies the integrity of a FLAC file by checking its MD5 signature.
1035
+ Only works with FLAC files.
1036
+
1037
+ Args:
1038
+ file: Audio file path (str or Path; must be FLAC)
1039
+
1040
+ Returns:
1041
+ True if MD5 signature is valid, False otherwise
1042
+
1043
+ Raises:
1044
+ FileTypeNotSupportedError: If the file is not a FLAC file
1045
+ FileNotFoundError: If the file does not exist
1046
+
1047
+ Examples:
1048
+ # Check FLAC file integrity
1049
+ is_valid = is_flac_md5_valid("song.flac")
1050
+ if is_valid:
1051
+ print("FLAC file is intact")
1052
+ else:
1053
+ print("FLAC file may be corrupted")
1054
+ """
1055
+ audio_file = _AudioFile(file)
1056
+ try:
1057
+ return audio_file.is_flac_file_md5_valid()
1058
+ except FileCorruptedError:
1059
+ return False
1060
+
1061
+
1062
+ def fix_md5_checking(file: PublicFileType) -> str:
1063
+ """Return a temporary file with corrected MD5 signature.
1064
+
1065
+ Args:
1066
+ file: Audio file path (str or Path)
1067
+
1068
+ Returns:
1069
+ str: Path to a temporary file containing the corrected audio data.
1070
+
1071
+ Raises:
1072
+ FileTypeNotSupportedError: If the file is not a FLAC file
1073
+ FileCorruptedError: If the FLAC file is corrupted or cannot be corrected
1074
+ RuntimeError: If the FLAC command fails to execute
1075
+ """
1076
+ audio_file = _AudioFile(file)
1077
+ return audio_file.get_file_with_corrected_md5(delete_original=True)
1078
+
1079
+
1080
+ def get_full_metadata(
1081
+ file: PublicFileType, include_headers: bool = True, include_technical: bool = True
1082
+ ) -> dict[str, Any]:
1083
+ """Get comprehensive metadata including all available information from a file.
1084
+
1085
+ Includes headers and technical details even when no metadata is present.
1086
+
1087
+ This function provides the most complete view of an audio file by combining:
1088
+ - All metadata from all supported formats (ID3v1, ID3v2, Vorbis, RIFF)
1089
+ - Technical information (duration, bitrate, sample rate, channels, file size)
1090
+ - Format-specific headers and structure information
1091
+ - Raw metadata details from each format
1092
+
1093
+ Args:
1094
+ file: Audio file path (str or Path)
1095
+ include_headers: Whether to include format-specific header information (default: True)
1096
+ include_technical: Whether to include technical audio information (default: True)
1097
+
1098
+ Returns:
1099
+ Comprehensive dictionary containing all available metadata and technical information
1100
+
1101
+ Raises:
1102
+ FileTypeNotSupportedError: If the file format is not supported
1103
+ FileNotFoundError: If the file does not exist
1104
+ FileCorruptedError: If the file content is corrupted or not a valid audio file
1105
+
1106
+ Examples:
1107
+ # Get complete metadata including headers and technical info
1108
+ full_metadata = get_full_metadata("song.mp3")
1109
+
1110
+ # Access unified metadata (same as get_unified_metadata)
1111
+ print(f"Title: {full_metadata['unified_metadata']['title']}")
1112
+
1113
+ # Access technical information
1114
+ print(f"Duration: {full_metadata['technical_info']['duration_seconds']} seconds")
1115
+ print(f"Bitrate: {full_metadata['technical_info']['bitrate_kbps']} kbps")
1116
+
1117
+ # Access format-specific metadata
1118
+ print(f"ID3v2 Title: {full_metadata['metadata_format']['id3v2']['title']}")
1119
+
1120
+ # Access header information
1121
+ print(f"ID3v2 Version: {full_metadata['headers']['id3v2']['version']}")
1122
+ print(f"Has ID3v1 Header: {full_metadata['headers']['id3v1']['present']}")
1123
+ """
1124
+ audio_file = _AudioFile(file)
1125
+
1126
+ # Get all available managers for this file type
1127
+ all_managers = _get_metadata_managers(audio_file=audio_file, normalized_rating_max_value=None, id3v2_version=None)
1128
+
1129
+ # Get file-specific format priorities
1130
+ available_formats = MetadataFormat.get_priorities().get(audio_file.file_extension, [])
1131
+
1132
+ # Initialize result structure
1133
+ result: dict[str, Any] = {
1134
+ "unified_metadata": {},
1135
+ "technical_info": {},
1136
+ "metadata_format": {},
1137
+ "headers": {},
1138
+ "raw_metadata": {},
1139
+ "format_priorities": {
1140
+ "file_extension": audio_file.file_extension,
1141
+ "reading_order": [fmt.value for fmt in available_formats],
1142
+ "writing_format": available_formats[0].value if available_formats else None,
1143
+ },
1144
+ }
1145
+
1146
+ # Get unified metadata (same as get_unified_metadata)
1147
+ result["unified_metadata"] = get_unified_metadata(file)
1148
+
1149
+ # Get technical information
1150
+ if include_technical:
1151
+ try:
1152
+ result["technical_info"] = {
1153
+ "duration_seconds": audio_file.get_duration_in_sec(),
1154
+ "bitrate_kbps": audio_file.get_bitrate(),
1155
+ "sample_rate_hz": audio_file.get_sample_rate(),
1156
+ "channels": audio_file.get_channels(),
1157
+ "file_size_bytes": get_file_size(file),
1158
+ "file_extension": audio_file.file_extension,
1159
+ "audio_format_name": audio_file.get_audio_format_name(),
1160
+ "is_flac_md5_valid": (
1161
+ audio_file.is_flac_file_md5_valid() if audio_file.file_extension == ".flac" else None
1162
+ ),
1163
+ }
1164
+ except Exception:
1165
+ result["technical_info"] = {
1166
+ "duration_seconds": 0,
1167
+ "bitrate_kbps": 0,
1168
+ "sample_rate_hz": 0,
1169
+ "channels": 0,
1170
+ "file_size_bytes": 0,
1171
+ "file_extension": audio_file.file_extension,
1172
+ "audio_format_name": audio_file.get_audio_format_name(),
1173
+ "is_flac_md5_valid": None,
1174
+ }
1175
+
1176
+ # Get format-specific metadata and headers
1177
+ metadata_format_dict: dict[str, Any] = result["metadata_format"]
1178
+ headers_dict: dict[str, Any] = result["headers"]
1179
+ raw_metadata_dict: dict[str, Any] = result["raw_metadata"]
1180
+
1181
+ for format_type in available_formats:
1182
+ format_key = format_type.value
1183
+ manager = all_managers.get(format_type)
1184
+
1185
+ if manager:
1186
+ # Get format-specific metadata
1187
+ try:
1188
+ metadata_format = manager.get_unified_metadata()
1189
+ metadata_format_dict[format_key] = metadata_format
1190
+ except Exception:
1191
+ metadata_format_dict[format_key] = {}
1192
+
1193
+ # Get header information
1194
+ if include_headers:
1195
+ try:
1196
+ header_info = manager.get_header_info()
1197
+ headers_dict[format_key] = header_info
1198
+ except Exception:
1199
+ headers_dict[format_key] = {
1200
+ "present": False,
1201
+ "version": None,
1202
+ "size_bytes": 0,
1203
+ "position": None,
1204
+ "flags": {},
1205
+ "extended_header": {},
1206
+ }
1207
+
1208
+ # Get raw metadata information
1209
+ try:
1210
+ raw_info = manager.get_raw_metadata_info()
1211
+ raw_metadata_dict[format_key] = raw_info
1212
+ except Exception:
1213
+ raw_metadata_dict[format_key] = {
1214
+ "raw_data": None,
1215
+ "parsed_fields": {},
1216
+ "frames": {},
1217
+ "comments": {},
1218
+ "chunk_structure": {},
1219
+ }
1220
+ else:
1221
+ # Format not available for this file type
1222
+ metadata_format_dict[format_key] = {}
1223
+ if include_headers:
1224
+ headers_dict[format_key] = {
1225
+ "present": False,
1226
+ "version": None,
1227
+ "size_bytes": 0,
1228
+ "position": None,
1229
+ "flags": {},
1230
+ "extended_header": {},
1231
+ }
1232
+ raw_metadata_dict[format_key] = {
1233
+ "raw_data": None,
1234
+ "parsed_fields": {},
1235
+ "frames": {},
1236
+ "comments": {},
1237
+ "chunk_structure": {},
1238
+ }
1239
+
1240
+ return result