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
@@ -0,0 +1,1002 @@
1
+ import contextlib
2
+ import os
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING, Any, cast
5
+
6
+ from mutagen._file import FileType as MutagenMetadata
7
+ from mutagen.wave import WAVE
8
+
9
+ if TYPE_CHECKING:
10
+ from ...._audio_file import _AudioFile
11
+ from ....exceptions import ConfigurationError, FileTypeNotSupportedError, MetadataFieldNotSupportedByMetadataFormatError
12
+ from ....utils.id3v1_genre_code_map import ID3V1_GENRE_CODE_MAP
13
+ from ....utils.rating_profiles import RatingWriteProfile
14
+ from ....utils.types import RawMetadataDict, RawMetadataKey, UnifiedMetadata, UnifiedMetadataValue
15
+ from ....utils.unified_metadata_key import UnifiedMetadataKey
16
+ from .._RatingSupportingMetadataManager import _RatingSupportingMetadataManager
17
+ from ..id3v2._id3v2_constants import ID3V2_HEADER_SIZE
18
+ from ._riff_constants import (
19
+ BEXT_LOUDNESS_METADATA_SIZE,
20
+ BEXT_MIN_CHUNK_SIZE,
21
+ BEXT_ORIGINATION_DATE_SIZE,
22
+ BEXT_ORIGINATION_TIME_SIZE,
23
+ BWF_V2_VERSION,
24
+ RIFF_AUDIO_FORMAT_IEEE_FLOAT,
25
+ RIFF_CHUNK_ID_SIZE,
26
+ RIFF_FORMAT_CHUNK_MIN_SIZE,
27
+ RIFF_HEADER_SIZE,
28
+ RIFF_MIN_DATA_SIZE_FOR_ID3V2,
29
+ RIFF_WAVE_FORMAT_POSITION,
30
+ )
31
+
32
+
33
+ class _RiffManager(_RatingSupportingMetadataManager):
34
+ """Manages RIFF metadata for WAV audio files.
35
+
36
+ Implementation Note:
37
+ While mutagen is used for reading WAV metadata, it does not support writing RIFF metadata. This is a known
38
+ limitation of the library, which only provides read-only access to WAVE files' metadata through its WAVE class.
39
+ Therefore, this manager implements its own RIFF metadata writing functionality by directly manipulating the file's
40
+ INFO chunk according to the RIFF specification.
41
+
42
+ RIFF Format:
43
+ RIFF (Resource Interchange File Format) is the standard metadata format for WAV files. The INFO chunk in RIFF/WAV
44
+ files uses standardized 4-character codes (FourCC) like INAM(Title), IART(Artist) or ICMT(Comments).
45
+
46
+ These codes are defined in RiffTagKey and are part of the standard RIFF specification. Each tag in the INFO chunk
47
+ follows the format:
48
+ - FourCC (4 chars): Identifies the metadata field (e.g., 'INAM' for title)
49
+ - Size (4 bytes): Length of the data in bytes
50
+ - Data (UTF-8): The actual metadata content
51
+ - Padding: If needed for word alignment (2 bytes)
52
+
53
+ Genre Support:
54
+ The IGNR tag in RIFF files has two modes:
55
+ 1. Text Mode (Preferred when writing): Direct genre name as text
56
+ - Supports any genre name
57
+ - More flexible and readable
58
+ - Better compatibility with modern software
59
+ - Supports custom genres
60
+ 2. Genre Code: Uses the standard ID3v1 genre list (0-147)
61
+ - Limited to predefined genres
62
+ - Compatible with older software
63
+ - No custom genres
64
+ - No multiple genres
65
+
66
+ Unsupported Metadata:
67
+ RIFF format has limited metadata support compared to other formats. The following metadata fields are NOT supported
68
+ and will raise MetadataFieldNotSupportedByMetadataFormatError if provided:
69
+ - Genre: Limited to predefined genre codes (0-147) or text mode
70
+
71
+ Rating Support:
72
+ RIFF format supports rating through the custom IRTD chunk, which is used by some applications
73
+ as an analogy to ID3 tags. While not part of the official RIFF specification, it's widely
74
+ recognized and supported by many audio applications.
75
+
76
+ When attempting to update unsupported metadata, the manager will raise
77
+ MetadataFieldNotSupportedByMetadataFormatError with a clear message indicating
78
+ which field is not supported by the RIFF format.
79
+
80
+ Note: This manager is the preferred way to handle WAV metadata, as it uses the format's native metadata system
81
+ rather than non-standard alternatives like ID3v2 tags. The custom implementation ensures proper handling of RIFF
82
+ chunk structures, maintaining word alignment and size fields according to the specification.
83
+ """
84
+
85
+ class RiffTagKey(RawMetadataKey):
86
+ # Standard
87
+ TITLE = "INAM"
88
+ ARTIST = "IART"
89
+ ALBUM = "IPRD"
90
+ GENRES_NAMES_OR_CODES = "IGNR"
91
+ DATE = "ICRD" # Creation/Release date
92
+ TRACK_NUMBER = "IPRT"
93
+ COMPOSERS = "ICMP" # Composers
94
+
95
+ # Non-standard
96
+ ALBUM_ARTISTS = "IAAR"
97
+ LANGUAGE = "ILNG"
98
+ RATING = "IRTD"
99
+ COMMENT = "ICMT"
100
+ ENGINEER = "IENG" # Engineer who worked on the track
101
+ SOFTWARE = "ISFT" # Software used to create the file
102
+ COPYRIGHT = "ICOP"
103
+ TECHNICIAN = "ITCH" # Technician who worked on the track
104
+ BPM = "IBPM"
105
+ UNSYNCHRONIZED_LYRICS = "ILYR"
106
+
107
+ # BWF
108
+ ISRC = "ISRC" # International Standard Recording Code
109
+
110
+ def __init__(self, audio_file: "_AudioFile", normalized_rating_max_value: None | int = None):
111
+ # Validate that the file is a WAV file
112
+ if audio_file.file_extension != ".wav":
113
+ msg = f"RiffManager only supports WAV files, got {audio_file.file_extension}"
114
+ raise FileTypeNotSupportedError(msg)
115
+
116
+ metadata_keys_direct_map_read: dict[UnifiedMetadataKey, RawMetadataKey | None] = {
117
+ UnifiedMetadataKey.TITLE: self.RiffTagKey.TITLE,
118
+ UnifiedMetadataKey.ARTISTS: self.RiffTagKey.ARTIST,
119
+ UnifiedMetadataKey.ALBUM: self.RiffTagKey.ALBUM,
120
+ UnifiedMetadataKey.ALBUM_ARTISTS: self.RiffTagKey.ALBUM_ARTISTS,
121
+ UnifiedMetadataKey.GENRES_NAMES: None,
122
+ UnifiedMetadataKey.RATING: None,
123
+ UnifiedMetadataKey.LANGUAGE: self.RiffTagKey.LANGUAGE,
124
+ UnifiedMetadataKey.RELEASE_DATE: self.RiffTagKey.DATE,
125
+ UnifiedMetadataKey.COMPOSERS: self.RiffTagKey.COMPOSERS,
126
+ UnifiedMetadataKey.COPYRIGHT: self.RiffTagKey.COPYRIGHT,
127
+ UnifiedMetadataKey.COMMENT: self.RiffTagKey.COMMENT,
128
+ UnifiedMetadataKey.BPM: self.RiffTagKey.BPM,
129
+ UnifiedMetadataKey.UNSYNCHRONIZED_LYRICS: self.RiffTagKey.UNSYNCHRONIZED_LYRICS,
130
+ UnifiedMetadataKey.TRACK_NUMBER: self.RiffTagKey.TRACK_NUMBER,
131
+ UnifiedMetadataKey.ISRC: self.RiffTagKey.ISRC,
132
+ }
133
+ metadata_keys_direct_map_write: dict[UnifiedMetadataKey, RawMetadataKey | None] = {
134
+ UnifiedMetadataKey.TITLE: self.RiffTagKey.TITLE,
135
+ UnifiedMetadataKey.ARTISTS: self.RiffTagKey.ARTIST,
136
+ UnifiedMetadataKey.ALBUM: self.RiffTagKey.ALBUM,
137
+ UnifiedMetadataKey.ALBUM_ARTISTS: self.RiffTagKey.ALBUM_ARTISTS,
138
+ UnifiedMetadataKey.GENRES_NAMES: None,
139
+ UnifiedMetadataKey.RATING: None,
140
+ UnifiedMetadataKey.LANGUAGE: self.RiffTagKey.LANGUAGE,
141
+ UnifiedMetadataKey.RELEASE_DATE: self.RiffTagKey.DATE,
142
+ UnifiedMetadataKey.COMPOSERS: self.RiffTagKey.COMPOSERS,
143
+ UnifiedMetadataKey.COPYRIGHT: self.RiffTagKey.COPYRIGHT,
144
+ UnifiedMetadataKey.COMMENT: self.RiffTagKey.COMMENT,
145
+ UnifiedMetadataKey.BPM: self.RiffTagKey.BPM,
146
+ UnifiedMetadataKey.UNSYNCHRONIZED_LYRICS: self.RiffTagKey.UNSYNCHRONIZED_LYRICS,
147
+ UnifiedMetadataKey.TRACK_NUMBER: self.RiffTagKey.TRACK_NUMBER,
148
+ UnifiedMetadataKey.ISRC: self.RiffTagKey.ISRC,
149
+ }
150
+ super().__init__(
151
+ audio_file=audio_file,
152
+ metadata_keys_direct_map_read=metadata_keys_direct_map_read,
153
+ metadata_keys_direct_map_write=metadata_keys_direct_map_write,
154
+ rating_write_profile=RatingWriteProfile.BASE_255_NON_PROPORTIONAL,
155
+ normalized_rating_max_value=normalized_rating_max_value,
156
+ update_using_mutagen_metadata=False,
157
+ )
158
+
159
+ def _skip_id3v2_tags(self, data: bytes) -> bytes:
160
+ """Skip ID3v2 tags if present at the start of the file.
161
+
162
+ Returns the data starting from after any ID3v2 tags.
163
+ """
164
+ if data.startswith(b"ID3"):
165
+ # ID3v2 header is 10 bytes:
166
+ # 3 bytes: ID3
167
+ # 2 bytes: version
168
+ # 1 byte: flags
169
+ # 4 bytes: size (synchsafe integer)
170
+ if len(data) < ID3V2_HEADER_SIZE:
171
+ return data
172
+
173
+ # Get size from synchsafe integer (7 bits per byte)
174
+ size_bytes = data[6:ID3V2_HEADER_SIZE]
175
+ size = (
176
+ ((size_bytes[0] & 0x7F) << 21)
177
+ | ((size_bytes[1] & 0x7F) << 14)
178
+ | ((size_bytes[2] & 0x7F) << 7)
179
+ | (size_bytes[3] & 0x7F)
180
+ )
181
+
182
+ # Skip the header (10 bytes) plus the size of the tag
183
+ return data[ID3V2_HEADER_SIZE + size :]
184
+ return data
185
+
186
+ def _extract_riff_metadata_directly(self, file_data: bytes) -> dict[str, list[str]]:
187
+ """Manually extract metadata from RIFF chunks without relying on external libraries.
188
+
189
+ This method directly parses the RIFF structure to extract metadata from the INFO chunk.
190
+ """
191
+ info_tags: dict[str, list[str]] = {}
192
+
193
+ # Skip ID3v2 if present
194
+ file_data = self._skip_id3v2_tags(file_data)
195
+
196
+ # Validate RIFF header
197
+ if (
198
+ len(file_data) < RIFF_HEADER_SIZE
199
+ or file_data[:RIFF_CHUNK_ID_SIZE] != b"RIFF"
200
+ or file_data[RIFF_WAVE_FORMAT_POSITION:RIFF_HEADER_SIZE] != b"WAVE"
201
+ ):
202
+ return info_tags
203
+
204
+ pos = 12 # Start after RIFF header
205
+ while pos < len(file_data) - 8:
206
+ chunk_id = file_data[pos : pos + 4]
207
+ chunk_size = int.from_bytes(file_data[pos + 4 : pos + 8], "little")
208
+
209
+ if chunk_id == b"LIST" and pos + 12 <= len(file_data) and file_data[pos + 8 : pos + 12] == b"INFO":
210
+ # Process INFO chunk
211
+ info_pos = pos + 12
212
+ info_end = pos + 8 + chunk_size
213
+
214
+ while info_pos < info_end - 8:
215
+ # Extract each metadata field
216
+ field_id = file_data[info_pos : info_pos + 4].decode("ascii", errors="ignore")
217
+ field_size = int.from_bytes(file_data[info_pos + 4 : info_pos + 8], "little")
218
+
219
+ if field_size > 0 and info_pos + 8 + field_size <= info_end:
220
+ # -1 to exclude null terminator
221
+ field_data = file_data[info_pos + 8 : info_pos + 8 + field_size - 1]
222
+ try:
223
+ # Decode and handle null-terminated strings
224
+ field_value = field_data.decode("utf-8", errors="ignore")
225
+ # Split on null byte and take first part if exists
226
+ field_value = field_value.split("\x00")[0].strip()
227
+ # Compare field_id with enum member values (FourCC strings)
228
+ if (
229
+ any(field_id == member.value for member in self.RiffTagKey.__members__.values())
230
+ and field_value
231
+ ):
232
+ if field_id not in info_tags:
233
+ info_tags[field_id] = []
234
+ info_tags[field_id].append(field_value)
235
+ except UnicodeDecodeError:
236
+ pass
237
+
238
+ # Move to next field, maintaining alignment
239
+ info_pos += 8 + ((field_size + 1) & ~1)
240
+ break
241
+
242
+ # Move to next chunk, maintaining alignment
243
+ pos += 8 + ((chunk_size + 1) & ~1)
244
+
245
+ return info_tags
246
+
247
+ def _extract_bext_chunk(self, file_data: bytes) -> dict[str, Any] | None:
248
+ """Extract and parse the bext chunk from BWF files.
249
+
250
+ BWF has multiple versions:
251
+ - Version 0 (1997): Original specification, no UMID field
252
+ - Version 1 (2001): Added UMID field (64 bytes)
253
+ - Version 2 (2011): Added loudness metadata fields (not yet parsed)
254
+
255
+ The bext chunk structure (v1):
256
+ - Description (256 bytes, ASCII, null-terminated)
257
+ - Originator (32 bytes, ASCII, null-terminated)
258
+ - OriginatorReference (32 bytes, ASCII, null-terminated)
259
+ - OriginationDate (10 bytes, ASCII, YYYY-MM-DD)
260
+ - OriginationTime (8 bytes, ASCII, HH:MM:SS)
261
+ - TimeReference (8 bytes, uint64, little-endian)
262
+ - Version (2 bytes, uint16, little-endian): 0x0000 (v0), 0x0001 (v1), 0x0002 (v2)
263
+ - UMID (64 bytes, binary, v1+ only)
264
+ - Reserved (190 bytes, zeros)
265
+ - CodingHistory (variable length, ASCII, null-terminated)
266
+
267
+ BWF v2 adds loudness metadata fields (10 bytes total) at the START of reserved bytes (offset 412):
268
+ - LoudnessValue (2 bytes, int16, little-endian, stored as 0.01 LU units by bwfmetaedit)
269
+ - LoudnessRange (2 bytes, int16, little-endian, stored as 0.01 LU units)
270
+ - MaxTruePeakLevel (2 bytes, int16, little-endian, stored as 0.01 dB units)
271
+ - MaxMomentaryLoudness (2 bytes, int16, little-endian, stored as 0.01 LU units)
272
+ - MaxShortTermLoudness (2 bytes, int16, little-endian, stored as 0.01 LU units)
273
+ Note: bwfmetaedit stores loudness values as 0.01 units (centi-units), so values are divided by 100.
274
+
275
+ Returns:
276
+ Dictionary with parsed bext fields or None if bext chunk not found
277
+ """
278
+ # Skip ID3v2 if present
279
+ file_data = self._skip_id3v2_tags(file_data)
280
+
281
+ # Validate RIFF header
282
+ if (
283
+ len(file_data) < RIFF_HEADER_SIZE
284
+ or file_data[:RIFF_CHUNK_ID_SIZE] != b"RIFF"
285
+ or file_data[RIFF_WAVE_FORMAT_POSITION:RIFF_HEADER_SIZE] != b"WAVE"
286
+ ):
287
+ return None
288
+
289
+ pos = 12 # Start after RIFF header
290
+ while pos < len(file_data) - 8:
291
+ chunk_id = file_data[pos : pos + 4]
292
+ chunk_size = int.from_bytes(file_data[pos + 4 : pos + 8], "little")
293
+
294
+ if chunk_id == b"bext":
295
+ # Found bext chunk
296
+ bext_data_start = pos + 8
297
+ bext_data_end = bext_data_start + chunk_size
298
+
299
+ if bext_data_end > len(file_data):
300
+ return None
301
+
302
+ bext_data = file_data[bext_data_start:bext_data_end]
303
+
304
+ # Minimum bext chunk size is 602 bytes (256+32+32+10+8+8+2+64+190)
305
+ if len(bext_data) < BEXT_MIN_CHUNK_SIZE:
306
+ return None
307
+
308
+ bext_fields: dict[str, Any] = {}
309
+
310
+ # Parse fixed fields
311
+ offset = 0
312
+
313
+ # Description (256 bytes)
314
+ description_bytes = bext_data[offset : offset + 256]
315
+ description = description_bytes.split(b"\x00")[0].decode("ascii", errors="ignore").strip()
316
+ if description:
317
+ bext_fields["Description"] = description
318
+ offset += 256
319
+
320
+ # Originator (32 bytes)
321
+ originator_bytes = bext_data[offset : offset + 32]
322
+ originator = originator_bytes.split(b"\x00")[0].decode("ascii", errors="ignore").strip()
323
+ if originator:
324
+ bext_fields["Originator"] = originator
325
+ offset += 32
326
+
327
+ # OriginatorReference (32 bytes)
328
+ originator_ref_bytes = bext_data[offset : offset + 32]
329
+ originator_ref = originator_ref_bytes.split(b"\x00")[0].decode("ascii", errors="ignore").strip()
330
+ if originator_ref:
331
+ bext_fields["OriginatorReference"] = originator_ref
332
+ offset += 32
333
+
334
+ # OriginationDate (10 bytes, YYYY-MM-DD)
335
+ origination_date_bytes = bext_data[offset : offset + BEXT_ORIGINATION_DATE_SIZE]
336
+ origination_date = origination_date_bytes.decode("ascii", errors="ignore").strip()
337
+ if origination_date and len(origination_date) == BEXT_ORIGINATION_DATE_SIZE:
338
+ bext_fields["OriginationDate"] = origination_date
339
+ offset += BEXT_ORIGINATION_DATE_SIZE
340
+
341
+ # OriginationTime (8 bytes, HH:MM:SS)
342
+ origination_time_bytes = bext_data[offset : offset + BEXT_ORIGINATION_TIME_SIZE]
343
+ origination_time = origination_time_bytes.decode("ascii", errors="ignore").strip()
344
+ if origination_time and len(origination_time) == BEXT_ORIGINATION_TIME_SIZE:
345
+ bext_fields["OriginationTime"] = origination_time
346
+ offset += BEXT_ORIGINATION_TIME_SIZE
347
+
348
+ # TimeReference (8 bytes, uint64, little-endian)
349
+ if offset + 8 <= len(bext_data):
350
+ time_reference = int.from_bytes(bext_data[offset : offset + 8], "little")
351
+ bext_fields["TimeReference"] = time_reference
352
+ offset += 8
353
+
354
+ # Version (2 bytes, uint16, little-endian)
355
+ if offset + 2 <= len(bext_data):
356
+ version = int.from_bytes(bext_data[offset : offset + 2], "little")
357
+ bext_fields["Version"] = version
358
+ offset += 2
359
+
360
+ # UMID (64 bytes, binary)
361
+ if offset + 64 <= len(bext_data):
362
+ umid_bytes = bext_data[offset : offset + 64]
363
+ # Check if UMID is not all zeros
364
+ if any(umid_bytes):
365
+ # Format as hex string for readability
366
+ umid_hex = umid_bytes.hex().upper()
367
+ bext_fields["UMID"] = umid_hex
368
+ offset += 64
369
+
370
+ # Reserved (190 bytes) - in BWF v2, loudness metadata is stored at the START of reserved bytes
371
+ # Parse loudness metadata if BWF v2 (version >= 2)
372
+ if version >= BWF_V2_VERSION and offset + BEXT_LOUDNESS_METADATA_SIZE <= len(bext_data):
373
+ # Loudness metadata starts at offset 412 (start of reserved bytes area)
374
+ # LoudnessValue (2 bytes, int16, little-endian, stored as 0.01 LU units by bwfmetaedit)
375
+ loudness_value_raw = int.from_bytes(bext_data[offset : offset + 2], "little", signed=True)
376
+ if loudness_value_raw != 0: # 0 means not set
377
+ # bwfmetaedit stores as 0.01 units, convert to LU
378
+ bext_fields["LoudnessValue"] = round(loudness_value_raw / 100.0, 2)
379
+ offset += 2
380
+
381
+ # LoudnessRange (2 bytes, int16, little-endian, stored as 0.01 LU units)
382
+ if offset + 2 <= len(bext_data):
383
+ loudness_range_raw = int.from_bytes(bext_data[offset : offset + 2], "little", signed=True)
384
+ if loudness_range_raw != 0: # 0 means not set
385
+ bext_fields["LoudnessRange"] = round(loudness_range_raw / 100.0, 2)
386
+ offset += 2
387
+
388
+ # MaxTruePeakLevel (2 bytes, int16, little-endian, stored as 0.01 dB units)
389
+ if offset + 2 <= len(bext_data):
390
+ max_true_peak_raw = int.from_bytes(bext_data[offset : offset + 2], "little", signed=True)
391
+ if max_true_peak_raw != 0: # 0 means not set
392
+ bext_fields["MaxTruePeakLevel"] = round(max_true_peak_raw / 100.0, 2)
393
+ offset += 2
394
+
395
+ # MaxMomentaryLoudness (2 bytes, int16, little-endian, stored as 0.01 LU units)
396
+ if offset + 2 <= len(bext_data):
397
+ max_momentary_raw = int.from_bytes(bext_data[offset : offset + 2], "little", signed=True)
398
+ if max_momentary_raw != 0: # 0 means not set
399
+ bext_fields["MaxMomentaryLoudness"] = round(max_momentary_raw / 100.0, 2)
400
+ offset += 2
401
+
402
+ # MaxShortTermLoudness (2 bytes, int16, little-endian, stored as 0.01 LU units)
403
+ if offset + 2 <= len(bext_data):
404
+ max_short_term_raw = int.from_bytes(bext_data[offset : offset + 2], "little", signed=True)
405
+ if max_short_term_raw != 0: # 0 means not set
406
+ bext_fields["MaxShortTermLoudness"] = round(max_short_term_raw / 100.0, 2)
407
+ offset += 2
408
+
409
+ # Skip remaining reserved bytes (190 - 10 = 180 bytes)
410
+ offset += 180
411
+ else:
412
+ # Skip all reserved bytes if not v2
413
+ offset += 190
414
+
415
+ # CodingHistory (variable length, null-terminated)
416
+ if offset < len(bext_data):
417
+ coding_history_bytes = bext_data[offset:]
418
+ # Find null terminator or end of chunk
419
+ null_pos = coding_history_bytes.find(b"\x00")
420
+ if null_pos >= 0:
421
+ coding_history_bytes = coding_history_bytes[:null_pos]
422
+ coding_history = coding_history_bytes.decode("ascii", errors="ignore").strip()
423
+ if coding_history:
424
+ bext_fields["CodingHistory"] = coding_history
425
+
426
+ return bext_fields if bext_fields else None
427
+
428
+ # Move to next chunk, maintaining alignment
429
+ pos += 8 + ((chunk_size + 1) & ~1)
430
+
431
+ return None
432
+
433
+ @contextlib.contextmanager
434
+ def _suppress_output(self) -> Any:
435
+ """Context manager to suppress all output including direct prints."""
436
+ with (
437
+ Path(os.devnull).open("w") as devnull,
438
+ contextlib.redirect_stdout(devnull),
439
+ contextlib.redirect_stderr(devnull),
440
+ ):
441
+ yield
442
+
443
+ def _extract_mutagen_metadata(self) -> RawMetadataDict:
444
+ """Extract RIFF metadata from WAV files using direct RIFF chunk parsing.
445
+
446
+ This method reads the WAV file's INFO chunk directly, providing the most reliable way to access RIFF metadata.
447
+ """
448
+ self.audio_file.seek(0)
449
+ file_data = self.audio_file.read()
450
+
451
+ # Skip ID3v2 metadata if present and create a clean RIFF file for mutagen
452
+ clean_data = self._skip_id3v2_tags(file_data)
453
+
454
+ # Create a temporary file with just the RIFF data for mutagen to parse
455
+ import tempfile
456
+
457
+ with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as temp_file:
458
+ temp_file.write(clean_data)
459
+ temp_file.flush()
460
+
461
+ try:
462
+ # Create WAVE object with the clean RIFF data
463
+ wave = WAVE(filename=temp_file.name)
464
+ info_tags = self._extract_riff_metadata_directly(file_data) # Use original data for our custom parsing
465
+ wave.info = info_tags
466
+ return cast(RawMetadataDict, wave)
467
+ finally:
468
+ # Clean up temporary file
469
+
470
+ with contextlib.suppress(OSError):
471
+ Path(temp_file.name).unlink()
472
+
473
+ def _convert_raw_mutagen_metadata_to_dict_with_potential_duplicate_keys(
474
+ self, raw_mutagen_metadata: MutagenMetadata
475
+ ) -> RawMetadataDict:
476
+ """Convert RIFF metadata to dictionary.
477
+
478
+ Extracts tags from our custom info_tags attribute which contains the directly parsed INFO chunk data.
479
+ """
480
+ raw_mutagen_metadata_wav: WAVE = cast(WAVE, raw_mutagen_metadata)
481
+ raw_metadata_dict: dict = {}
482
+
483
+ # Get metadata from our custom info which contains the directly parsed INFO chunk
484
+ if hasattr(raw_mutagen_metadata_wav, "info") and raw_mutagen_metadata_wav.info is not None:
485
+ info_tags = raw_mutagen_metadata_wav.info
486
+ for key, value in info_tags.items():
487
+ # key is a FourCC string; check against enum member values
488
+ if any(key == member.value for member in self.RiffTagKey.__members__.values()):
489
+ # info_tags now contains lists of values, so we can pass them directly
490
+ raw_metadata_dict[key] = value
491
+
492
+ return raw_metadata_dict
493
+
494
+ def _get_raw_rating_by_traktor_or_not(self, raw_clean_metadata: RawMetadataDict) -> tuple[int | None, bool]:
495
+ # raw_clean_metadata uses FourCC string keys; compare using enum .value
496
+ rating_key = self.RiffTagKey.RATING
497
+ if rating_key not in raw_clean_metadata:
498
+ return None, False
499
+
500
+ raw_ratings = raw_clean_metadata[rating_key]
501
+ if not raw_ratings or len(raw_ratings) == 0:
502
+ return None, False
503
+
504
+ raw_rating = raw_ratings[0]
505
+ # It is a Traktor rating if it's an integer
506
+ if isinstance(raw_rating, str):
507
+ return int(raw_rating), False
508
+ return cast(int, raw_rating), True
509
+
510
+ def _get_undirectly_mapped_metadata_value_other_than_rating_from_raw_clean_metadata(
511
+ self, raw_clean_metadata: RawMetadataDict, unified_metadata_key: UnifiedMetadataKey
512
+ ) -> UnifiedMetadataValue:
513
+ if unified_metadata_key == UnifiedMetadataKey.GENRES_NAMES:
514
+ return self._get_genres_from_raw_clean_metadata_uppercase_keys(
515
+ raw_clean_metadata, self.RiffTagKey.GENRES_NAMES_OR_CODES
516
+ )
517
+ msg = f"Metadata key not handled: {unified_metadata_key}"
518
+ raise MetadataFieldNotSupportedByMetadataFormatError(msg)
519
+
520
+ def _update_not_using_mutagen_metadata(self, unified_metadata: UnifiedMetadata) -> None:
521
+ """Update metadata fields in the RIFF INFO chunk using an optimized chunk-based approach.
522
+
523
+ This implementation
524
+ maintains RIFF specification compliance while providing better performance and reliability for metadata updates.
525
+
526
+ Note: While TinyTag is excellent for reading metadata, it doesn't support writing.
527
+ Therefore, we implement our own RIFF chunk writer following the specification.
528
+ """
529
+ if not self.metadata_keys_direct_map_write:
530
+ msg = "metadata_keys_direct_map_write must be set"
531
+ raise ConfigurationError(msg)
532
+
533
+ # Validate that all metadata fields are supported by RIFF format
534
+ for unified_metadata_key in unified_metadata:
535
+ if unified_metadata_key not in self.metadata_keys_direct_map_write:
536
+ msg = f"{unified_metadata_key} metadata not supported by RIFF format"
537
+ raise MetadataFieldNotSupportedByMetadataFormatError(msg)
538
+
539
+ # Read the entire file into a mutable bytearray
540
+ self.audio_file.seek(0)
541
+ file_data = bytearray(self.audio_file.read())
542
+
543
+ # Check if we should preserve ID3v2 tags based on the calling context
544
+ # In PRESERVE strategy, we should not strip ID3v2 tags as they will be restored later
545
+ should_preserve_id3v2 = self._should_preserve_id3v2_tags()
546
+
547
+ if should_preserve_id3v2:
548
+ # For PRESERVE strategy, work with the full file data including ID3v2 tags
549
+ # We'll write RIFF metadata without affecting the ID3v2 section
550
+ pass # file_data already contains the full file including ID3v2
551
+ else:
552
+ # For other strategies (CLEANUP, SYNC), strip ID3v2 tags as before
553
+ skipped_data = self._skip_id3v2_tags(bytes(file_data))
554
+ file_data = bytearray(skipped_data)
555
+
556
+ # Find RIFF header and validate
557
+ # If ID3v2 tags are present, we need to find the RIFF header after them
558
+ if should_preserve_id3v2 and file_data.startswith(b"ID3"):
559
+ # Find RIFF header after ID3v2 tags
560
+ riff_start = self._find_riff_header_after_id3v2(file_data)
561
+ if riff_start == -1:
562
+ msg = "Invalid WAV file format - RIFF header not found after ID3v2 tags"
563
+ raise MetadataFieldNotSupportedByMetadataFormatError(msg)
564
+ # Work with the RIFF portion only for metadata updates
565
+ riff_data = file_data[riff_start:]
566
+ else:
567
+ riff_data = file_data
568
+
569
+ if (
570
+ len(riff_data) < RIFF_HEADER_SIZE
571
+ or bytes(riff_data[:RIFF_CHUNK_ID_SIZE]) != b"RIFF"
572
+ or bytes(riff_data[RIFF_WAVE_FORMAT_POSITION:RIFF_HEADER_SIZE]) != b"WAVE"
573
+ ):
574
+ msg = "Invalid WAV file format"
575
+ raise MetadataFieldNotSupportedByMetadataFormatError(msg)
576
+
577
+ # Find or create LIST INFO chunk in the RIFF data
578
+ info_chunk_start = self._find_info_chunk_in_file_data(riff_data)
579
+ if info_chunk_start == -1:
580
+ info_chunk_start = self._create_info_chunk_after_wave_header(riff_data)
581
+
582
+ # Process metadata updates
583
+ info_chunk_size = int.from_bytes(bytes(riff_data[info_chunk_start + 4 : info_chunk_start + 8]), "little")
584
+
585
+ # Read existing metadata to preserve it
586
+ existing_metadata = self._extract_riff_metadata_directly(bytes(riff_data))
587
+
588
+ # Convert existing metadata to unified format for merging
589
+ existing_unified_metadata: UnifiedMetadata = {}
590
+ for existing_riff_key, values in existing_metadata.items():
591
+ # Find the corresponding unified metadata key
592
+ for unified_key, mapped_riff_key in self.metadata_keys_direct_map_write.items():
593
+ if mapped_riff_key and mapped_riff_key.value == existing_riff_key:
594
+ if len(values) == 1:
595
+ existing_unified_metadata[unified_key] = values[0]
596
+ else:
597
+ existing_unified_metadata[unified_key] = values
598
+ break
599
+
600
+ # Merge existing metadata with new metadata (new metadata takes precedence)
601
+ merged_metadata: UnifiedMetadata = {**existing_unified_metadata, **unified_metadata}
602
+
603
+ # Build new tags data
604
+ new_tags_data = bytearray()
605
+ for app_key, value in merged_metadata.items():
606
+ if value is None or value == "":
607
+ continue
608
+
609
+ # Get corresponding RIFF tag
610
+ riff_key: RawMetadataKey | None = self._get_riff_key_for_metadata(app_key, value)
611
+ if not riff_key:
612
+ continue
613
+
614
+ # Handle multiple values (e.g., multiple artists)
615
+ if isinstance(value, list):
616
+ # Values are already filtered at the base level
617
+ if value:
618
+ # Use smart separator to concatenate multiple values
619
+ separator = self.find_safe_separator(value)
620
+ concatenated_value: str = separator.join(value)
621
+ value_bytes = self._prepare_tag_value(concatenated_value, app_key)
622
+ if value_bytes:
623
+ new_tags_data.extend(self._create_aligned_metadata_with_proper_padding(riff_key, value_bytes))
624
+ # Single value - ensure it's not None before processing
625
+ elif isinstance(value, int | float | str):
626
+ value_bytes = self._prepare_tag_value(value, app_key)
627
+ if value_bytes:
628
+ new_tags_data.extend(self._create_aligned_metadata_with_proper_padding(riff_key, value_bytes))
629
+
630
+ # Create new INFO chunk
631
+ new_info_chunk = bytearray()
632
+ new_info_chunk.extend(b"LIST")
633
+ new_info_chunk.extend((len(new_tags_data) + 4).to_bytes(4, "little")) # +4 for 'INFO'
634
+ new_info_chunk.extend(b"INFO")
635
+ new_info_chunk.extend(new_tags_data)
636
+
637
+ # Replace old INFO chunk in RIFF data
638
+ riff_data[info_chunk_start : info_chunk_start + info_chunk_size + 8] = new_info_chunk
639
+
640
+ # Update RIFF chunk size
641
+ total_size = len(riff_data) - 8 # Exclude RIFF and size fields
642
+ riff_data[4:8] = total_size.to_bytes(4, "little")
643
+
644
+ # If we preserved ID3v2 tags, we need to reconstruct the full file
645
+ if should_preserve_id3v2 and file_data.startswith(b"ID3"):
646
+ # Reconstruct the full file with ID3v2 tags + updated RIFF data
647
+ id3v2_size = self._get_id3v2_size(file_data)
648
+ final_file_data = bytearray(file_data[:id3v2_size]) # Keep ID3v2 tags
649
+ final_file_data.extend(riff_data) # Add updated RIFF data
650
+ else:
651
+ final_file_data = riff_data
652
+
653
+ # Write updated file
654
+ self.audio_file.seek(0)
655
+ self.audio_file.write(final_file_data)
656
+
657
+ def delete_metadata(self) -> bool:
658
+ """Delete all RIFF metadata from the audio file.
659
+
660
+ This removes all RIFF INFO chunks from the file while preserving the audio data.
661
+ Uses custom RIFF chunk manipulation since mutagen doesn't support RIFF writing.
662
+
663
+ Returns:
664
+ bool: True if metadata was successfully deleted, False otherwise
665
+ """
666
+ try:
667
+ # Read the entire file into a mutable bytearray
668
+ self.audio_file.seek(0)
669
+ file_data = bytearray(self.audio_file.read())
670
+
671
+ # Check if we should preserve ID3v2 tags
672
+ should_preserve_id3v2 = self._should_preserve_id3v2_tags()
673
+
674
+ if should_preserve_id3v2:
675
+ # For files with ID3v2 tags, work with the RIFF portion only
676
+ if file_data.startswith(b"ID3"):
677
+ # Find RIFF header after ID3v2 tags
678
+ riff_start = self._find_riff_header_after_id3v2(file_data)
679
+ if riff_start == -1:
680
+ return False # No RIFF header found
681
+ riff_data = file_data[riff_start:]
682
+ else:
683
+ riff_data = file_data
684
+ else:
685
+ # For files without ID3v2 tags, work with the entire file
686
+ riff_data = file_data
687
+
688
+ # Find and remove LIST INFO chunk
689
+ info_chunk_start = self._find_info_chunk_in_file_data(riff_data)
690
+ if info_chunk_start == -1:
691
+ return True # No INFO chunk found, consider deletion successful
692
+
693
+ # Get the size of the INFO chunk
694
+ info_chunk_size = int.from_bytes(bytes(riff_data[info_chunk_start + 4 : info_chunk_start + 8]), "little")
695
+
696
+ # Remove the INFO chunk
697
+ riff_data[info_chunk_start : info_chunk_start + info_chunk_size + 8] = b""
698
+
699
+ # Update RIFF chunk size
700
+ total_size = len(riff_data) - 8 # Exclude RIFF and size fields
701
+ riff_data[4:8] = total_size.to_bytes(4, "little")
702
+
703
+ # If we preserved ID3v2 tags, reconstruct the full file
704
+ if should_preserve_id3v2 and file_data.startswith(b"ID3"):
705
+ id3v2_size = self._get_id3v2_size(file_data)
706
+ final_file_data = bytearray(file_data[:id3v2_size]) # Keep ID3v2 tags
707
+ final_file_data.extend(riff_data) # Add updated RIFF data
708
+ else:
709
+ final_file_data = riff_data
710
+
711
+ # Write updated file
712
+ self.audio_file.seek(0)
713
+ self.audio_file.write(final_file_data)
714
+ except Exception:
715
+ return False
716
+ else:
717
+ return True
718
+
719
+ def _find_info_chunk_in_file_data(self, file_data: bytearray) -> int:
720
+ pos = 12 # Start after RIFF header
721
+ while pos < len(file_data) - 8:
722
+ if (
723
+ bytes(file_data[pos : pos + 4]) == b"LIST"
724
+ and pos + 8 < len(file_data)
725
+ and bytes(file_data[pos + 8 : pos + 12]) == b"INFO"
726
+ ):
727
+ return pos
728
+ chunk_size = int.from_bytes(bytes(file_data[pos + 4 : pos + 8]), "little")
729
+ pos += 8 + ((chunk_size + 1) & ~1) # Move to next chunk, maintaining alignment
730
+ return -1
731
+
732
+ def _create_info_chunk_after_wave_header(self, file_data: bytearray) -> int:
733
+ info_chunk = bytearray(b"LIST\x04\x00\x00\x00INFO") # Minimal INFO chunk
734
+ insert_pos = 12 # After RIFF+size+WAVE
735
+ file_data[insert_pos:insert_pos] = info_chunk
736
+ return insert_pos
737
+
738
+ def _get_riff_key_for_metadata(
739
+ self, app_key: UnifiedMetadataKey, _value: UnifiedMetadataValue
740
+ ) -> RawMetadataKey | None:
741
+ """Get the appropriate RIFF tag key for the metadata."""
742
+ if not self.metadata_keys_direct_map_write:
743
+ return None
744
+
745
+ riff_key = self.metadata_keys_direct_map_write.get(app_key, None)
746
+ if not riff_key:
747
+ if app_key == UnifiedMetadataKey.GENRES_NAMES:
748
+ return cast(RawMetadataKey | None, self.RiffTagKey.GENRES_NAMES_OR_CODES)
749
+ if app_key == UnifiedMetadataKey.RATING:
750
+ return cast(RawMetadataKey | None, self.RiffTagKey.RATING)
751
+ return riff_key
752
+
753
+ def _prepare_tag_value(self, value: UnifiedMetadataValue, app_key: UnifiedMetadataKey) -> bytes | None:
754
+ """Prepare the tag value for writing, handling special cases."""
755
+ # Handle list values (should not happen in this method anymore due to upstream processing)
756
+ if isinstance(value, list):
757
+ value = value[0] if value else ""
758
+
759
+ if app_key == UnifiedMetadataKey.GENRES_NAMES:
760
+ # Write genre as text instead of numeric code for better compatibility
761
+ value = str(value)
762
+ elif (
763
+ app_key == UnifiedMetadataKey.RATING and value is not None and self.normalized_rating_max_value is not None
764
+ ):
765
+ # Convert normalized rating to file rating for RIFF format
766
+ try:
767
+ # Preserve float values to support half-star ratings (consistent with classic star rating systems)
768
+ normalized_rating = float(value)
769
+ file_rating = self._convert_normalized_rating_to_file_rating(normalized_rating=normalized_rating)
770
+ value = file_rating
771
+ except (TypeError, ValueError):
772
+ # If conversion fails, use the original value
773
+ pass
774
+
775
+ if value is None:
776
+ return None
777
+
778
+ return str(value).encode("utf-8")
779
+
780
+ def _create_aligned_metadata_with_proper_padding(self, metadata_id: RawMetadataKey, value_bytes: bytes) -> bytes:
781
+ # Add null terminator
782
+ value_bytes = value_bytes + b"\x00"
783
+ # Pad to even length if needed
784
+ if len(value_bytes) % 2:
785
+ value_bytes = value_bytes + b"\x00"
786
+
787
+ return metadata_id.encode("ascii") + len(value_bytes).to_bytes(4, "little") + value_bytes
788
+
789
+ def _get_genre_code_from_name(self, genre_name: str) -> int | None:
790
+ genre_name_lower = genre_name.lower()
791
+ for code, name in ID3V1_GENRE_CODE_MAP.items():
792
+ if name and name.lower() == genre_name_lower:
793
+ return cast(int | None, code)
794
+ return cast(int | None, 12) # Default to 'Other' genre if not found
795
+
796
+ def _should_preserve_id3v2_tags(self) -> bool:
797
+ """Determine if ID3v2 tags should be preserved based on the calling context and file state.
798
+
799
+ This method detects if the RIFF manager is being called in a PRESERVE strategy
800
+ context by checking the call stack. In PRESERVE strategy, the high-level
801
+ _handle_metadata_strategy function will restore ID3v2 metadata after RIFF
802
+ writing, so we should not strip it.
803
+
804
+ We preserve ID3v2 tags when:
805
+ 1. We're in a PRESERVE strategy context AND we're writing to RIFF format
806
+ 2. We're in a SYNC strategy context AND we're writing to RIFF format
807
+ 3. ID3v2 tags exist in the file (for coexistence support)
808
+ """
809
+ import inspect
810
+
811
+ # First, check if ID3v2 tags exist in the file
812
+ # This allows coexistence even when not in a strategy context
813
+ try:
814
+ with Path(self.audio_file.file_path).open("rb") as f:
815
+ first_bytes = f.read(10)
816
+ if first_bytes.startswith(b"ID3"):
817
+ # ID3v2 tags exist, preserve them for coexistence
818
+ return True
819
+ except Exception:
820
+ # If we can't read the file, fall back to strategy detection
821
+ pass
822
+
823
+ # Get the call stack
824
+ frame = inspect.currentframe()
825
+ try:
826
+ # Look for _handle_metadata_strategy in the call stack
827
+ while frame:
828
+ if (
829
+ frame.f_code.co_name == "_handle_metadata_strategy"
830
+ and "strategy" in frame.f_locals
831
+ and "target_format_actual" in frame.f_locals
832
+ ):
833
+ # Check if we're in the PRESERVE strategy branch
834
+ # Look at the local variables to determine the strategy and target format
835
+ strategy = frame.f_locals["strategy"]
836
+ target_format = frame.f_locals["target_format_actual"]
837
+ from audiometa.utils.metadata_format import MetadataFormat
838
+ from audiometa.utils.metadata_writing_strategy import MetadataWritingStrategy
839
+
840
+ # Preserve ID3v2 tags when:
841
+ # 1. PRESERVE strategy and target format is RIFF (preserve existing ID3v2 tags)
842
+ # 2. SYNC strategy and target format is RIFF
843
+ # (preserve ID3v2 tags that were written by other managers)
844
+ if strategy in (MetadataWritingStrategy.PRESERVE, MetadataWritingStrategy.SYNC):
845
+ return bool(target_format == MetadataFormat.RIFF)
846
+ return False
847
+ frame = frame.f_back
848
+ finally:
849
+ del frame
850
+
851
+ # Default to not preserving (for backward compatibility)
852
+ return False
853
+
854
+ def _find_riff_header_after_id3v2(self, file_data: bytearray) -> int:
855
+ """Find the RIFF header after ID3v2 tags in the file data.
856
+
857
+ Returns the position of the RIFF header or -1 if not found.
858
+ """
859
+ if not file_data.startswith(b"ID3"):
860
+ return -1
861
+
862
+ # Skip ID3v2 tags using existing method
863
+ skipped_data = self._skip_id3v2_tags(bytes(file_data))
864
+ if not skipped_data.startswith(b"RIFF"):
865
+ return -1
866
+
867
+ # Calculate the position where RIFF starts
868
+ return len(file_data) - len(skipped_data)
869
+
870
+ def _get_id3v2_size(self, file_data: bytearray) -> int:
871
+ """Get the size of ID3v2 tags at the beginning of the file.
872
+
873
+ Returns the total size including header and data.
874
+ """
875
+ if not file_data.startswith(b"ID3"):
876
+ return 0
877
+
878
+ if len(file_data) < RIFF_MIN_DATA_SIZE_FOR_ID3V2:
879
+ return 0
880
+
881
+ # Get size from synchsafe integer (7 bits per byte)
882
+ size_bytes = file_data[6:ID3V2_HEADER_SIZE]
883
+ size = (
884
+ ((size_bytes[0] & 0x7F) << 21)
885
+ | ((size_bytes[1] & 0x7F) << 14)
886
+ | ((size_bytes[2] & 0x7F) << 7)
887
+ | (size_bytes[3] & 0x7F)
888
+ )
889
+
890
+ return 10 + size # Header (10 bytes) + data size
891
+
892
+ def get_header_info(self) -> dict:
893
+ try:
894
+ # Read file data to analyze RIFF structure
895
+ self.audio_file.seek(0)
896
+ file_data = self.audio_file.read()
897
+
898
+ if (
899
+ len(file_data) < RIFF_HEADER_SIZE
900
+ or not file_data.startswith(b"RIFF")
901
+ or file_data[RIFF_WAVE_FORMAT_POSITION:RIFF_HEADER_SIZE] != b"WAVE"
902
+ ):
903
+ return {"present": False, "chunk_info": {}}
904
+
905
+ # Parse RIFF chunk info
906
+ riff_chunk_size = int.from_bytes(file_data[4:8], "little")
907
+ info_chunk_size = 0
908
+ audio_format = "Unknown"
909
+ subchunk_size = 0
910
+
911
+ # Find INFO chunk
912
+ pos = 12
913
+ while pos < len(file_data) - 8:
914
+ chunk_id = file_data[pos : pos + 4]
915
+ chunk_size = int.from_bytes(file_data[pos + 4 : pos + 8], "little")
916
+
917
+ if chunk_id == b"LIST" and file_data[pos + 8 : pos + 12] == b"INFO":
918
+ info_chunk_size = chunk_size
919
+ break
920
+ if chunk_id == b"fmt ":
921
+ # Parse format chunk
922
+ if chunk_size >= RIFF_FORMAT_CHUNK_MIN_SIZE:
923
+ audio_format_code = int.from_bytes(file_data[pos + 8 : pos + 10], "little")
924
+ if audio_format_code == 1:
925
+ audio_format = "PCM"
926
+ elif audio_format_code == RIFF_AUDIO_FORMAT_IEEE_FLOAT:
927
+ audio_format = "IEEE Float"
928
+ else:
929
+ audio_format = f"Code {audio_format_code}"
930
+ elif chunk_id == b"data":
931
+ subchunk_size = chunk_size
932
+ break
933
+
934
+ pos += 8 + chunk_size
935
+ if chunk_size % 2 == 1: # Word alignment
936
+ pos += 1
937
+ except Exception:
938
+ return {"present": False, "chunk_info": {}}
939
+ else:
940
+ return {
941
+ "present": True,
942
+ "chunk_info": {
943
+ "riff_chunk_size": riff_chunk_size,
944
+ "info_chunk_size": info_chunk_size,
945
+ "audio_format": audio_format,
946
+ "subchunk_size": subchunk_size,
947
+ },
948
+ }
949
+
950
+ def get_raw_metadata_info(self) -> dict[str, Any]:
951
+ try:
952
+ if self.raw_clean_metadata is None:
953
+ extracted_metadata: RawMetadataDict = self._extract_cleaned_raw_metadata_from_file()
954
+ self.raw_clean_metadata = extracted_metadata
955
+
956
+ if not self.raw_clean_metadata:
957
+ # Still try to extract bext chunk even if no INFO metadata
958
+ chunk_structure = {}
959
+ try:
960
+ self.audio_file.seek(0)
961
+ file_data = self.audio_file.read()
962
+ bext_data = self._extract_bext_chunk(file_data)
963
+ if bext_data:
964
+ chunk_structure["bext"] = bext_data
965
+ except Exception:
966
+ pass
967
+
968
+ return {
969
+ "raw_data": None,
970
+ "parsed_fields": {},
971
+ "frames": {},
972
+ "comments": {},
973
+ "chunk_structure": chunk_structure,
974
+ }
975
+
976
+ raw_clean_metadata: RawMetadataDict = self.raw_clean_metadata
977
+
978
+ # Get parsed fields
979
+ parsed_fields = {}
980
+ for key, value in raw_clean_metadata.items():
981
+ parsed_fields[key] = value[0] if value else ""
982
+
983
+ # Extract bext chunk
984
+ chunk_structure = {}
985
+ try:
986
+ self.audio_file.seek(0)
987
+ file_data = self.audio_file.read()
988
+ bext_data = self._extract_bext_chunk(file_data)
989
+ if bext_data:
990
+ chunk_structure["bext"] = bext_data
991
+ except Exception:
992
+ pass
993
+ except Exception:
994
+ return {"raw_data": None, "parsed_fields": {}, "frames": {}, "comments": {}, "chunk_structure": {}}
995
+ else:
996
+ return {
997
+ "raw_data": None, # RIFF data is complex binary structure
998
+ "parsed_fields": parsed_fields,
999
+ "frames": {},
1000
+ "comments": {},
1001
+ "chunk_structure": chunk_structure,
1002
+ }