audiometa-python 0.6.0__py3-none-any.whl

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