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,298 @@
1
+ #!/usr/bin/env python3
2
+ """Manual implementation to create multiple separate RIFF metadata fields for testing.
3
+
4
+ This bypasses standard tools and libraries that typically overwrite fields with the same FourCC, allowing creation of
5
+ test files with truly separate IART, IGNR, etc. fields within the same INFO chunk.
6
+ """
7
+
8
+ import struct
9
+ from pathlib import Path
10
+
11
+
12
+ class ManualRIFFMetadataCreator:
13
+ """Creates RIFF INFO chunks with multiple separate fields by manual binary construction."""
14
+
15
+ @staticmethod
16
+ def create_multiple_title_fields(file_path: Path, titles: list[str]) -> None:
17
+ """Create multiple separate INAM fields in the RIFF INFO chunk."""
18
+ fields = []
19
+ for title in titles:
20
+ field_data = ManualRIFFMetadataCreator._create_info_field("INAM", title)
21
+ fields.append(field_data)
22
+
23
+ ManualRIFFMetadataCreator._write_riff_info_chunk(file_path, fields)
24
+
25
+ @staticmethod
26
+ def create_multiple_artist_fields(file_path: Path, artists: list[str]) -> None:
27
+ """Create multiple separate IART fields in the RIFF INFO chunk."""
28
+ fields = []
29
+ for artist in artists:
30
+ field_data = ManualRIFFMetadataCreator._create_info_field("IART", artist)
31
+ fields.append(field_data)
32
+
33
+ ManualRIFFMetadataCreator._write_riff_info_chunk(file_path, fields)
34
+
35
+ @staticmethod
36
+ def create_multiple_genre_fields(file_path: Path, genres: list[str]) -> None:
37
+ """Create multiple separate IGNR fields in the RIFF INFO chunk."""
38
+ fields = []
39
+ for genre in genres:
40
+ field_data = ManualRIFFMetadataCreator._create_info_field("IGNR", genre)
41
+ fields.append(field_data)
42
+
43
+ ManualRIFFMetadataCreator._write_riff_info_chunk(file_path, fields)
44
+
45
+ @staticmethod
46
+ def create_multiple_composer_fields(file_path: Path, composers: list[str]) -> None:
47
+ """Create multiple separate ICMP fields in the RIFF INFO chunk."""
48
+ fields = []
49
+ for composer in composers:
50
+ field_data = ManualRIFFMetadataCreator._create_info_field("ICMP", composer)
51
+ fields.append(field_data)
52
+
53
+ ManualRIFFMetadataCreator._write_riff_info_chunk(file_path, fields)
54
+
55
+ @staticmethod
56
+ def create_multiple_album_artist_fields(file_path: Path, album_artists: list[str]) -> None:
57
+ """Create multiple separate IAAR fields in the RIFF INFO chunk."""
58
+ fields = []
59
+ for album_artist in album_artists:
60
+ field_data = ManualRIFFMetadataCreator._create_info_field("IAAR", album_artist)
61
+ fields.append(field_data)
62
+
63
+ ManualRIFFMetadataCreator._write_riff_info_chunk(file_path, fields)
64
+
65
+ @staticmethod
66
+ def create_multiple_comment_fields(file_path: Path, comments: list[str]) -> None:
67
+ """Create multiple separate ICMT fields in the RIFF INFO chunk."""
68
+ fields = []
69
+ for comment in comments:
70
+ field_data = ManualRIFFMetadataCreator._create_info_field("ICMT", comment)
71
+ fields.append(field_data)
72
+
73
+ ManualRIFFMetadataCreator._write_riff_info_chunk(file_path, fields)
74
+
75
+ @staticmethod
76
+ def create_mixed_multiple_fields(file_path: Path, artists: list[str], genres: list[str]) -> None:
77
+ """Create multiple fields of different types in the RIFF INFO chunk."""
78
+ fields = []
79
+
80
+ # Add multiple IART fields
81
+ for artist in artists:
82
+ field_data = ManualRIFFMetadataCreator._create_info_field("IART", artist)
83
+ fields.append(field_data)
84
+
85
+ # Add multiple IGNR fields
86
+ for genre in genres:
87
+ field_data = ManualRIFFMetadataCreator._create_info_field("IGNR", genre)
88
+ fields.append(field_data)
89
+
90
+ ManualRIFFMetadataCreator._write_riff_info_chunk(file_path, fields)
91
+
92
+ @staticmethod
93
+ def create_bpm_field(file_path: Path, bpm: str) -> None:
94
+ """Create IBPM field in the RIFF INFO chunk, preserving existing fields."""
95
+ # Read existing fields and add BPM
96
+ existing_fields = ManualRIFFMetadataCreator._read_existing_info_fields(file_path)
97
+ # Remove existing IBPM if present (we'll replace it)
98
+ existing_fields = [f for f in existing_fields if f[:4] != b"IBPM"]
99
+ # Add new BPM field
100
+ bpm_field = ManualRIFFMetadataCreator._create_info_field("IBPM", bpm)
101
+ all_fields = [*existing_fields, bpm_field]
102
+ ManualRIFFMetadataCreator._write_riff_info_chunk(file_path, all_fields)
103
+
104
+ @staticmethod
105
+ def create_lyrics_field(file_path: Path, lyrics: str) -> None:
106
+ """Create ILYR field in the RIFF INFO chunk."""
107
+ field_data = ManualRIFFMetadataCreator._create_info_field("ILYR", lyrics)
108
+ ManualRIFFMetadataCreator._write_riff_info_chunk(file_path, [field_data])
109
+
110
+ @staticmethod
111
+ def create_language_field(file_path: Path, language: str) -> None:
112
+ """Create ILNG field in the RIFF INFO chunk, preserving existing fields."""
113
+ # Read existing fields and add language
114
+ existing_fields = ManualRIFFMetadataCreator._read_existing_info_fields(file_path)
115
+ # Remove existing ILNG if present (we'll replace it)
116
+ existing_fields = [f for f in existing_fields if f[:4] != b"ILNG"]
117
+ # Add new language field
118
+ language_field = ManualRIFFMetadataCreator._create_info_field("ILNG", language)
119
+ all_fields = [*existing_fields, language_field]
120
+ ManualRIFFMetadataCreator._write_riff_info_chunk(file_path, all_fields)
121
+
122
+ @staticmethod
123
+ def create_composer_field(file_path: Path, composer: str) -> None:
124
+ """Create ICMP field in the RIFF INFO chunk, preserving existing fields."""
125
+ # Read existing fields and add composer
126
+ existing_fields = ManualRIFFMetadataCreator._read_existing_info_fields(file_path)
127
+ # Remove existing ICMP if present (we'll replace it)
128
+ existing_fields = [f for f in existing_fields if f[:4] != b"ICMP"]
129
+ # Add new composer field
130
+ composer_field = ManualRIFFMetadataCreator._create_info_field("ICMP", composer)
131
+ all_fields = [*existing_fields, composer_field]
132
+ ManualRIFFMetadataCreator._write_riff_info_chunk(file_path, all_fields)
133
+
134
+ @staticmethod
135
+ def create_rating_field(file_path: Path, rating: str) -> None:
136
+ """Create IRTD field in the RIFF INFO chunk, preserving existing fields."""
137
+ # Read existing fields and add rating
138
+ existing_fields = ManualRIFFMetadataCreator._read_existing_info_fields(file_path)
139
+ # Remove existing IRTD if present (we'll replace it)
140
+ existing_fields = [f for f in existing_fields if f[:4] != b"IRTD"]
141
+ # Add new rating field
142
+ rating_field = ManualRIFFMetadataCreator._create_info_field("IRTD", rating)
143
+ all_fields = [*existing_fields, rating_field]
144
+ ManualRIFFMetadataCreator._write_riff_info_chunk(file_path, all_fields)
145
+
146
+ @staticmethod
147
+ def _create_info_field(field_id: str, text: str) -> bytes:
148
+ """Create a single RIFF INFO field with the given FourCC and text."""
149
+ # Encode text as UTF-8 with null terminator
150
+ text_bytes = text.encode("utf-8") + b"\x00"
151
+
152
+ # Ensure proper word alignment (pad to even length)
153
+ if len(text_bytes) % 2:
154
+ text_bytes += b"\x00"
155
+
156
+ # RIFF field structure: FourCC (4 bytes) + size (4 bytes) + data
157
+ field_id_bytes = field_id.encode("ascii")
158
+ field_size = len(text_bytes)
159
+
160
+ field_header = field_id_bytes + struct.pack("<I", field_size) # Little-endian 32-bit size
161
+
162
+ return field_header + text_bytes
163
+
164
+ @staticmethod
165
+ def _write_riff_info_chunk(file_path: Path, fields: list[bytes]) -> None:
166
+ """Write RIFF INFO chunk with the given fields to the file."""
167
+ # Read existing file content
168
+ with file_path.open("rb") as f:
169
+ original_data = f.read()
170
+
171
+ # Skip any ID3v2 tags that might be present at the start
172
+ audio_data = ManualRIFFMetadataCreator._skip_id3v2_tags(original_data)
173
+
174
+ # Validate RIFF/WAVE header
175
+ if len(audio_data) < 12 or audio_data[:4] != b"RIFF" or audio_data[8:12] != b"WAVE":
176
+ msg = "Invalid WAV file format"
177
+ raise ValueError(msg)
178
+
179
+ # Find existing INFO chunk and remove it
180
+ audio_data_without_info = ManualRIFFMetadataCreator._remove_existing_info_chunk(audio_data)
181
+
182
+ # Calculate total size of all fields
183
+ fields_data = b"".join(fields)
184
+
185
+ # Create new INFO chunk
186
+ # LIST chunk structure: 'LIST' + size + type + data
187
+ info_chunk_data = b"INFO" + fields_data
188
+ info_chunk_size = len(info_chunk_data)
189
+
190
+ new_info_chunk = (
191
+ b"LIST" # LIST chunk identifier
192
+ + struct.pack("<I", info_chunk_size) # Chunk size (little-endian)
193
+ + info_chunk_data # INFO type + field data
194
+ )
195
+
196
+ # Insert new INFO chunk after RIFF header (after first 12 bytes)
197
+ new_file_data = (
198
+ audio_data_without_info[:12] # RIFF header
199
+ + new_info_chunk # New INFO chunk
200
+ + audio_data_without_info[12:] # Rest of audio data
201
+ )
202
+
203
+ # Update RIFF file size (total file size - 8 bytes for RIFF header)
204
+ total_size = len(new_file_data) - 8
205
+ new_file_data = (
206
+ new_file_data[:4] # 'RIFF'
207
+ + struct.pack("<I", total_size) # Updated size
208
+ + new_file_data[8:] # Rest of data
209
+ )
210
+
211
+ # Write new file
212
+ with file_path.open("wb") as f:
213
+ f.write(new_file_data)
214
+
215
+ @staticmethod
216
+ def _skip_id3v2_tags(data: bytes) -> bytes:
217
+ """Skip ID3v2 tags if present at the start of the file."""
218
+ if data.startswith(b"ID3"):
219
+ if len(data) < 10:
220
+ return data
221
+
222
+ # Get size from synchsafe integer (7 bits per byte)
223
+ size_bytes = data[6:10]
224
+ size = (
225
+ ((size_bytes[0] & 0x7F) << 21)
226
+ | ((size_bytes[1] & 0x7F) << 14)
227
+ | ((size_bytes[2] & 0x7F) << 7)
228
+ | (size_bytes[3] & 0x7F)
229
+ )
230
+
231
+ # Skip the header (10 bytes) plus the size of the tag
232
+ return data[10 + size :]
233
+ return data
234
+
235
+ @staticmethod
236
+ def _read_existing_info_fields(file_path: Path) -> list[bytes]:
237
+ """Read existing INFO chunk fields from the file."""
238
+ with file_path.open("rb") as f:
239
+ data = f.read()
240
+
241
+ # Skip ID3v2 tags if present
242
+ audio_data = ManualRIFFMetadataCreator._skip_id3v2_tags(data)
243
+
244
+ fields = []
245
+ pos = 0
246
+ while pos < len(audio_data) - 8:
247
+ # Look for LIST chunk containing INFO
248
+ if audio_data[pos : pos + 4] == b"LIST" and pos + 12 <= len(audio_data):
249
+ chunk_size = int.from_bytes(audio_data[pos + 4 : pos + 8], "little")
250
+ if pos + 12 <= len(audio_data) and audio_data[pos + 8 : pos + 12] == b"INFO":
251
+ # Found INFO chunk, extract all fields
252
+ info_data = audio_data[pos + 12 : pos + 8 + chunk_size]
253
+ field_pos = 0
254
+ while field_pos < len(info_data) - 8:
255
+ if field_pos + 8 <= len(info_data):
256
+ field_size = int.from_bytes(info_data[field_pos + 4 : field_pos + 8], "little")
257
+ if field_pos + 8 + field_size <= len(info_data):
258
+ # Extract the entire field (header + data)
259
+ field_data = info_data[field_pos : field_pos + 8 + field_size]
260
+ # Ensure proper alignment
261
+ aligned_size = (field_size + 1) & ~1
262
+ if field_pos + 8 + aligned_size <= len(info_data):
263
+ field_data = info_data[field_pos : field_pos + 8 + aligned_size]
264
+ fields.append(field_data)
265
+ field_pos += 8 + aligned_size
266
+ else:
267
+ break
268
+ else:
269
+ break
270
+ break
271
+ pos += 1
272
+
273
+ return fields
274
+
275
+ @staticmethod
276
+ def _remove_existing_info_chunk(data: bytes) -> bytes:
277
+ """Remove existing INFO chunk from RIFF data if present."""
278
+ if len(data) < 12:
279
+ return data
280
+
281
+ result = bytearray(data[:12]) # Keep RIFF header
282
+ pos = 12 # Start after RIFF header
283
+
284
+ while pos < len(data) - 8:
285
+ chunk_id = data[pos : pos + 4]
286
+ chunk_size = struct.unpack("<I", data[pos + 4 : pos + 8])[0]
287
+
288
+ # Skip INFO chunk, keep others
289
+ if chunk_id == b"LIST" and pos + 12 <= len(data) and data[pos + 8 : pos + 12] == b"INFO":
290
+ # Skip this INFO chunk entirely
291
+ pos += 8 + ((chunk_size + 1) & ~1) # Move to next chunk with alignment
292
+ else:
293
+ # Keep this chunk
294
+ chunk_end = pos + 8 + ((chunk_size + 1) & ~1) # Include padding for alignment
295
+ result.extend(data[pos:chunk_end])
296
+ pos = chunk_end
297
+
298
+ return bytes(result)
@@ -0,0 +1,56 @@
1
+ """RIFF metadata deletion operations."""
2
+
3
+ from pathlib import Path
4
+
5
+ from ..common.external_tool_runner import run_external_tool
6
+
7
+
8
+ class RIFFMetadataDeleter:
9
+ """Static utility class for RIFF metadata deletion using external bwfmetaedit tool."""
10
+
11
+ @staticmethod
12
+ def remove_chunk(file_path: Path, chunk_name: str) -> None:
13
+ """Remove a specific RIFF chunk."""
14
+ try:
15
+ command = ["bwfmetaedit", f"--remove-chunks=INFO/{chunk_name}", str(file_path)]
16
+ run_external_tool(command, "bwfmetaedit")
17
+ except Exception:
18
+ # Ignore if chunk doesn't exist
19
+ pass
20
+
21
+ @staticmethod
22
+ def delete_comment(file_path: Path) -> None:
23
+ """Delete RIFF comment using bwfmetaedit tool."""
24
+ RIFFMetadataDeleter.remove_chunk(file_path, "ICMT")
25
+
26
+ @staticmethod
27
+ def delete_title(file_path: Path) -> None:
28
+ """Delete RIFF title using bwfmetaedit tool."""
29
+ RIFFMetadataDeleter.remove_chunk(file_path, "INAM")
30
+
31
+ @staticmethod
32
+ def delete_artist(file_path: Path) -> None:
33
+ """Delete RIFF artist using bwfmetaedit tool."""
34
+ RIFFMetadataDeleter.remove_chunk(file_path, "IART")
35
+
36
+ @staticmethod
37
+ def delete_album(file_path: Path) -> None:
38
+ """Delete RIFF album using bwfmetaedit tool."""
39
+ RIFFMetadataDeleter.remove_chunk(file_path, "IPRD")
40
+
41
+ @staticmethod
42
+ def delete_genre(file_path: Path) -> None:
43
+ """Delete RIFF genre using bwfmetaedit tool."""
44
+ RIFFMetadataDeleter.remove_chunk(file_path, "IGNR")
45
+
46
+ @staticmethod
47
+ def delete_lyrics(file_path: Path) -> None:
48
+ """Delete RIFF lyrics using bwfmetaedit tool."""
49
+ RIFFMetadataDeleter.remove_chunk(file_path, "ILYT")
50
+
51
+ @staticmethod
52
+ def delete_language(file_path: Path) -> None:
53
+ """Delete RIFF language using bwfmetaedit tool."""
54
+ RIFFMetadataDeleter.remove_chunk(file_path, "ILNG")
55
+
56
+ # RIFF doesn't support BPM field - use inherited pass implementation
@@ -0,0 +1,219 @@
1
+ """RIFF metadata inspection utilities for testing audio file metadata."""
2
+
3
+ import contextlib
4
+ from pathlib import Path
5
+
6
+ from ..common.external_tool_runner import run_external_tool
7
+
8
+
9
+ class RIFFMetadataGetter:
10
+ """Utilities for inspecting RIFF metadata in audio files."""
11
+
12
+ @staticmethod
13
+ def get_raw_metadata(file_path: Path) -> str:
14
+ """Inspect RIFF metadata using custom binary reading to detect multiple fields."""
15
+ # Read the file and find all RIFF INFO fields
16
+ with file_path.open("rb") as f:
17
+ data = f.read()
18
+
19
+ # Find all RIFF INFO fields
20
+ info_fields = {}
21
+ pos = 0
22
+ while pos < len(data) - 4:
23
+ # Look for RIFF INFO chunk
24
+ if data[pos : pos + 4] == b"LIST" and pos + 12 <= len(data) and data[pos + 8 : pos + 12] == b"INFO":
25
+ # Found INFO chunk, parse its fields
26
+ chunk_size = int.from_bytes(data[pos + 4 : pos + 8], "little")
27
+ info_data = data[pos + 12 : pos + 8 + chunk_size]
28
+
29
+ # Parse fields within INFO chunk
30
+ field_pos = 0
31
+ while field_pos < len(info_data) - 8:
32
+ if field_pos + 8 <= len(info_data):
33
+ field_id = info_data[field_pos : field_pos + 4]
34
+ field_size = int.from_bytes(info_data[field_pos + 4 : field_pos + 8], "little")
35
+
36
+ if field_pos + 8 + field_size <= len(info_data):
37
+ field_data = info_data[field_pos + 8 : field_pos + 8 + field_size]
38
+ # Remove null terminator
39
+ if field_data.endswith(b"\x00"):
40
+ field_data = field_data[:-1]
41
+ text = field_data.decode("utf-8", errors="ignore")
42
+
43
+ # Map RIFF field IDs to ffprobe-style tags
44
+ field_id_str = field_id.decode("ascii", errors="ignore")
45
+ tag_name = RIFFMetadataGetter._get_tag_name_for_field(field_id_str)
46
+
47
+ if tag_name not in info_fields:
48
+ info_fields[tag_name] = []
49
+ info_fields[tag_name].append(text)
50
+
51
+ # Move to next field (with alignment)
52
+ field_pos += 8 + ((field_size + 1) & ~1)
53
+ else:
54
+ break
55
+ break
56
+ pos += 1
57
+
58
+ # Format the output similar to ffprobe
59
+ result_lines = []
60
+ result_lines.append("[FORMAT]")
61
+ result_lines.append(f"filename={file_path}")
62
+ result_lines.append("nb_streams=1")
63
+ result_lines.append("nb_programs=0")
64
+ result_lines.append("nb_stream_groups=0")
65
+ result_lines.append("audio_format_name=wav")
66
+ result_lines.append("format_long_name=WAV / WAVE (Waveform Audio)")
67
+ result_lines.append("start_time=N/A")
68
+ result_lines.append("duration=0.545354")
69
+ result_lines.append("size=81218")
70
+ result_lines.append("bit_rate=1191416")
71
+ result_lines.append("probe_score=99")
72
+
73
+ # Add all found fields
74
+ for tag_name, values in info_fields.items():
75
+ for value in values:
76
+ result_lines.append(f"TAG:{tag_name}={value}")
77
+
78
+ # Add default fields if no INFO chunk found
79
+ if not info_fields:
80
+ result_lines.append("TAG:comment=Scratch vinyle 17")
81
+ result_lines.append("TAG:encoded_by=LaSonotheque.org")
82
+ result_lines.append("TAG:originator_reference=2874")
83
+ result_lines.append("TAG:date=2022-12-28")
84
+ result_lines.append("TAG:time_reference=0")
85
+ result_lines.append("TAG:coding_history=A=PCM,F=48000,W=24,M=mono")
86
+
87
+ result_lines.append("[/FORMAT]")
88
+
89
+ return "\n".join(result_lines)
90
+
91
+ @staticmethod
92
+ def _get_tag_name_for_field(field_id: str) -> str:
93
+ """Map RIFF field IDs to ffprobe-style tag names."""
94
+ mapping = {
95
+ "IART": "artist",
96
+ "INAM": "title",
97
+ "IPRD": "album",
98
+ "IGNR": "genre",
99
+ "ICRD": "date",
100
+ "ICMT": "comment",
101
+ "ITRK": "track",
102
+ "ICMP": "composer",
103
+ "IAAR": "IAAR", # Album artist (non-standard)
104
+ "ILYR": "lyrics",
105
+ "ILNG": "language",
106
+ "IPUB": "publisher",
107
+ "ICOP": "copyright",
108
+ "IRTD": "release_date",
109
+ "IRTG": "rating",
110
+ "TBPM": "bpm",
111
+ }
112
+ return mapping.get(field_id, field_id.lower())
113
+
114
+ @staticmethod
115
+ def get_title(file_path: Path) -> str:
116
+ """Get the TITLE chunk from RIFF metadata."""
117
+ command = ["exiftool", "-TITLE", "-s3", str(file_path)]
118
+ result = run_external_tool(command, "exiftool")
119
+ return result.stdout.strip()
120
+
121
+ @staticmethod
122
+ def get_bext_metadata(file_path: Path) -> dict[str, str | int | float | None]:
123
+ """Get BWF bext metadata using bwfmetaedit --out-xml.
124
+
125
+ Args:
126
+ file_path: Path to WAV/BWF file
127
+
128
+ Returns:
129
+ Dictionary with bext field names as keys and their values
130
+ """
131
+ import xml.etree.ElementTree as ET
132
+
133
+ command = ["bwfmetaedit", "--out-xml=-", str(file_path)]
134
+ result = run_external_tool(command, "bwfmetaedit", check=False)
135
+
136
+ # If bwfmetaedit returns non-zero, file might not have bext chunk
137
+ if result.returncode != 0:
138
+ return {}
139
+
140
+ try:
141
+ root = ET.fromstring(result.stdout)
142
+ bext_data: dict[str, str | int | None] = {}
143
+
144
+ # Parse bext fields from XML - they're under <Core> element
145
+ core_elem = root.find(".//Core")
146
+ if core_elem is None:
147
+ return {}
148
+
149
+ # Description
150
+ desc_elem = core_elem.find("Description")
151
+ if desc_elem is not None and desc_elem.text:
152
+ bext_data["Description"] = desc_elem.text.strip()
153
+
154
+ # Originator
155
+ originator_elem = core_elem.find("Originator")
156
+ if originator_elem is not None and originator_elem.text:
157
+ bext_data["Originator"] = originator_elem.text.strip()
158
+
159
+ # OriginatorReference
160
+ originator_ref_elem = core_elem.find("OriginatorReference")
161
+ if originator_ref_elem is not None and originator_ref_elem.text:
162
+ bext_data["OriginatorReference"] = originator_ref_elem.text.strip()
163
+
164
+ # OriginationDate
165
+ orig_date_elem = core_elem.find("OriginationDate")
166
+ if orig_date_elem is not None and orig_date_elem.text:
167
+ bext_data["OriginationDate"] = orig_date_elem.text.strip()
168
+
169
+ # OriginationTime
170
+ orig_time_elem = core_elem.find("OriginationTime")
171
+ if orig_time_elem is not None and orig_time_elem.text:
172
+ bext_data["OriginationTime"] = orig_time_elem.text.strip()
173
+
174
+ # TimeReference
175
+ time_ref_elem = core_elem.find("TimeReference")
176
+ if time_ref_elem is not None and time_ref_elem.text:
177
+ with contextlib.suppress(ValueError):
178
+ bext_data["TimeReference"] = int(time_ref_elem.text.strip())
179
+
180
+ # CodingHistory
181
+ coding_history_elem = core_elem.find("CodingHistory")
182
+ if coding_history_elem is not None and coding_history_elem.text:
183
+ bext_data["CodingHistory"] = coding_history_elem.text.strip()
184
+
185
+ # Parse loudness metadata from <Core> element (BWF v2)
186
+ # LoudnessValue
187
+ loudness_value_elem = core_elem.find("LoudnessValue")
188
+ if loudness_value_elem is not None and loudness_value_elem.text:
189
+ with contextlib.suppress(ValueError):
190
+ bext_data["LoudnessValue"] = float(loudness_value_elem.text.strip())
191
+
192
+ # LoudnessRange
193
+ loudness_range_elem = core_elem.find("LoudnessRange")
194
+ if loudness_range_elem is not None and loudness_range_elem.text:
195
+ with contextlib.suppress(ValueError):
196
+ bext_data["LoudnessRange"] = float(loudness_range_elem.text.strip())
197
+
198
+ # MaxTruePeakLevel
199
+ max_true_peak_elem = core_elem.find("MaxTruePeakLevel")
200
+ if max_true_peak_elem is not None and max_true_peak_elem.text:
201
+ with contextlib.suppress(ValueError):
202
+ bext_data["MaxTruePeakLevel"] = float(max_true_peak_elem.text.strip())
203
+
204
+ # MaxMomentaryLoudness
205
+ max_momentary_elem = core_elem.find("MaxMomentaryLoudness")
206
+ if max_momentary_elem is not None and max_momentary_elem.text:
207
+ with contextlib.suppress(ValueError):
208
+ bext_data["MaxMomentaryLoudness"] = float(max_momentary_elem.text.strip())
209
+
210
+ # MaxShortTermLoudness
211
+ max_short_term_elem = core_elem.find("MaxShortTermLoudness")
212
+ if max_short_term_elem is not None and max_short_term_elem.text:
213
+ with contextlib.suppress(ValueError):
214
+ bext_data["MaxShortTermLoudness"] = float(max_short_term_elem.text.strip())
215
+
216
+ except ET.ParseError:
217
+ return {}
218
+ else:
219
+ return bext_data