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,1032 @@
1
+ import contextlib
2
+ import shutil
3
+ import subprocess
4
+ import tempfile
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, Any, ClassVar, cast
7
+
8
+ from mutagen._file import FileType as MutagenMetadata
9
+ from mutagen.id3 import ID3
10
+ from mutagen.id3._frames import (
11
+ COMM,
12
+ POPM,
13
+ TALB,
14
+ TBPM,
15
+ TCOM,
16
+ TCON,
17
+ TCOP,
18
+ TDAT,
19
+ TDRC,
20
+ TDRL,
21
+ TENC,
22
+ TIT2,
23
+ TKEY,
24
+ TLAN,
25
+ TMOO,
26
+ TPE1,
27
+ TPE2,
28
+ TPOS,
29
+ TPUB,
30
+ TRCK,
31
+ TSRC,
32
+ TXXX,
33
+ TYER,
34
+ USLT,
35
+ WOAR,
36
+ )
37
+ from mutagen.id3._util import ID3NoHeaderError
38
+
39
+ from audiometa.utils.unified_metadata_key import UnifiedMetadataKey
40
+
41
+ from ....utils.tool_path_resolver import get_tool_path
42
+
43
+ if TYPE_CHECKING:
44
+ from ...._audio_file import _AudioFile
45
+ from ....exceptions import FileCorruptedError, MetadataFieldNotSupportedByMetadataFormatError
46
+ from ....utils.rating_profiles import RatingWriteProfile
47
+ from ....utils.types import RawMetadataDict, RawMetadataKey, UnifiedMetadata, UnifiedMetadataValue
48
+ from ..._MetadataManager import _MetadataManager as MetadataManager
49
+ from .._RatingSupportingMetadataManager import _RatingSupportingMetadataManager
50
+ from ._id3v2_constants import ID3V2_DATE_FORMAT_LENGTH, ID3V2_VERSION_3, ID3V2_VERSION_4
51
+
52
+
53
+ class _Id3v2Manager(_RatingSupportingMetadataManager):
54
+ """ID3v2 metadata manager for audio files.
55
+
56
+ ID3v2 Version Compatibility Table:
57
+ +---------------+----------+----------+----------+
58
+ | Player/Device | ID3v2.2 | ID3v2.3 | ID3v2.4 |
59
+ +---------------+----------+----------+----------+
60
+ | Windows Media Player |
61
+ | - WMP 9-12 | ✓ | ✓ | ~ |
62
+ | - WMP 7-8 | ✓ | ✓ | |
63
+ +---------------+----------+----------+----------+
64
+ | iTunes |
65
+ | - 12.x+ | ✓ | ✓ | ✓ |
66
+ | - 7.x-11.x | ✓ | ✓ | ~ |
67
+ +---------------+----------+----------+----------+
68
+ | Winamp |
69
+ | - 5.x+ | ✓ | ✓ | ✓ |
70
+ | - 2.x-4.x | ✓ | ✓ | ~ |
71
+ +---------------+----------+----------+----------+
72
+ | MusicBee |
73
+ | - 3.x+ | ✓ | ✓ | ✓ |
74
+ | - 2.x | ✓ | ✓ | ~ |
75
+ +---------------+----------+----------+----------+
76
+ | VLC |
77
+ | - 2.x+ | ✓ | ✓ | ✓ |
78
+ | - 1.x | ✓ | ✓ | ~ |
79
+ +---------------+----------+----------+----------+
80
+ | Smartphones |
81
+ | - iOS 7+ | ✓ | ✓ | ✓ |
82
+ | - Android 4+ | ✓ | ✓ | ✓ |
83
+ | - Windows | ✓ | ✓ | ✓ |
84
+ | - Blackberry | ✓ | ✓ | ~ |
85
+ +---------------+----------+----------+----------+
86
+ | Network Players |
87
+ | - Sonos | ✓ | ✓ | ✓ |
88
+ | - Roku | ✓ | ✓ | ~ |
89
+ | - Chromecast | ✓ | ✓ | ✓ |
90
+ | - Apple TV | ✓ | ✓ | ✓ |
91
+ +---------------+----------+----------+----------+
92
+ |iPods/MP3 Players |
93
+ | - iPod 5G+ | ✓ | ✓ | ✓ |
94
+ | - iPod 1-4G | ✓ | ✓ | ~ |
95
+ | - Zune | ✓ | ✓ | ~ |
96
+ | - Sony | ✓ | ✓ | ~ |
97
+ +---------------+----------+----------+----------+
98
+ | Car Systems |
99
+ | - Post-2010 | ✓ | ✓ | ~ |
100
+ | - Pre-2010 | ✓ | ~ | |
101
+ +---------------+----------+----------+----------+
102
+ | Home Audio Systems |
103
+ | - Post-2000 | ✓ | ✓ | ~ |
104
+ | - Pre-2000 | ✓ | ~ | |
105
+ +---------------+----------+----------+----------+
106
+ | DJ Software |
107
+ | - Traktor | ✓ | ✓ | ✓ |
108
+ | - Serato | ✓ | ✓ | ~ |
109
+ | - VirtualDJ | ✓ | ✓ | ~ |
110
+ | - Rekordbox | ✓ | ✓ | ~ |
111
+ | - Mixxx | ✓ | ✓ | ~ |
112
+ | - Cross DJ | ✓ | ✓ | ~ |
113
+ | - djay Pro | ✓ | ✓ | ~ |
114
+ +---------------+----------+----------+----------+
115
+ | Web Browsers |
116
+ | - Chrome | ✓ | ✓ | ✓ |
117
+ | - Firefox | ✓ | ✓ | ✓ |
118
+ | - Safari | ✓ | ✓ | ✓ |
119
+ | - Edge | ✓ | ✓ | ✓ |
120
+ +---------------+----------+----------+----------+
121
+ | Gaming Consoles |
122
+ | - PS4/PS5 | ✓ | ✓ | ✓ |
123
+ | - Xbox Series| ✓ | ✓ | ✓ |
124
+ | - PS3 | ✓ | ✓ | ~ |
125
+ | - Xbox 360 | ✓ | ✓ | ~ |
126
+ +---------------+----------+----------+----------+
127
+ | Smart TVs |
128
+ | - Samsung | ✓ | ✓ | ~ |
129
+ | - LG | ✓ | ✓ | ~ |
130
+ | - Sony | ✓ | ✓ | ~ |
131
+ | - Android TV | ✓ | ✓ | ✓ |
132
+ +---------------+----------+----------+----------+
133
+
134
+ Legend:
135
+ ✓ = Full support
136
+ ~ = Partial support/May have issues
137
+ = No support
138
+
139
+ Notes:
140
+ - ID3v2.4 introduced UTF-8 encoding and unsync changes
141
+ - Older players may have issues with ID3v2.4's changes
142
+ - For maximum compatibility, ID3v2.3 is recommended
143
+
144
+ - ID3:
145
+ - Writing Policy:
146
+ * The app writes ID3v2 tags in the specified version (default: v2.3)
147
+ * When updating an existing file:
148
+ - Tags are upgraded to the specified version if different
149
+ - v2.2, v2.3, or v2.4 tags are upgraded to the specified version
150
+ - Frame IDs are automatically converted
151
+ - All text is encoded in UTF-8
152
+ * Reading supports all versions (v2.2, v2.3, v2.4)
153
+ * Only one ID3v2 version can exist in a file at a time
154
+ * Native format for MP3 files
155
+ * Version selection allows choosing between v2.3 (maximum compatibility) and v2.4 (modern features)
156
+
157
+ - ID3v1:
158
+ * Fixed 128-byte format at end of file
159
+ * ASCII only, no Unicode
160
+ * Limited to 30 chars for text fields
161
+ * Single byte for track number (v1.1 only)
162
+ * Genre limited to predefined codes (0-147)
163
+ * Legacy format
164
+
165
+ - ID3v2:
166
+ * v2.2:
167
+ - Introduced in 1998
168
+ - Three-character frame IDs (TT2, TP1, etc.)
169
+ - ISO-8859-1 or UCS-2 text encoding
170
+ - All standard fields supported
171
+ - Simpler header structure than v2.3/v2.4
172
+ - Basic support for embedded images
173
+ - Less common but equally functional
174
+
175
+ * v2.3:
176
+ - Introduced in 1999
177
+ - TYER+TDAT frames for date (year and date separately)
178
+ - UTF-16/UTF-16BE text encoding
179
+ - Basic unsynchronization
180
+ - All metadata fields supported
181
+ - Better support for embedded images and other binary data
182
+ - Most widely used version
183
+
184
+ * v2.4:
185
+ - Introduced in 2000
186
+ - TDRC frame for full timestamps (YYYY-MM-DD)
187
+ - UTF-8 text encoding
188
+ - Extended header features
189
+ - Unsynchronization per frame
190
+ - All metadata fields supported
191
+ - New frames for more detailed metadata (e.g., TDRC for recording time, TDRL for release time)
192
+ - Preferred version for new tags
193
+
194
+ For maximum compatibility, ID3v2.3 is used as the default version for writing metadata.
195
+ Users can choose ID3v2.4 for modern features if their target players support it.
196
+ When reading/updating an existing file, the ID3 tags will be updated to the specified version format.
197
+ """
198
+
199
+ ID3_RATING_APP_EMAIL = "audiometa-python@audiometa.dev"
200
+
201
+ class Id3TextFrame(RawMetadataKey):
202
+ TITLE = "TIT2"
203
+ ARTISTS = "TPE1"
204
+ ALBUM = "TALB"
205
+ ALBUM_ARTISTS = "TPE2"
206
+ GENRES_NAMES = "TCON"
207
+
208
+ # In cleaned metadata, the rating is stored as a tuple the potential identifier (e.g. 'Traktor') and the rating
209
+ # value
210
+ RATING = "POPM"
211
+ LANGUAGE = "TLAN"
212
+ RECORDING_TIME = "TDRC" # ID3v2.4 recording time
213
+ RELEASE_TIME = "TDRL" # ID3v2.4 release time
214
+ YEAR = "TYER" # ID3v2.3 year
215
+ DATE = "TDAT" # ID3v2.3 date (DDMM)
216
+ TRACK_NUMBER = "TRCK"
217
+ DISC_NUMBER = "TPOS"
218
+ BPM = "TBPM"
219
+
220
+ # Additional metadata fields
221
+ COMPOSERS = "TCOM"
222
+ PUBLISHER = "TPUB"
223
+ COPYRIGHT = "TCOP"
224
+ UNSYNCHRONIZED_LYRICS = "USLT"
225
+ COMMENT = "COMM" # Comment frame
226
+ ENCODER = "TENC"
227
+ URL = "WOAR" # Official artist/performer webpage
228
+ ISRC = "TSRC"
229
+ MOOD = "TMOO"
230
+ KEY = "TKEY"
231
+ REPLAYGAIN = "REPLAYGAIN"
232
+
233
+ ID3_TEXT_FRAME_CLASS_MAP: ClassVar[dict[RawMetadataKey, type]] = {
234
+ Id3TextFrame.TITLE: TIT2,
235
+ Id3TextFrame.ARTISTS: TPE1,
236
+ Id3TextFrame.ALBUM: TALB,
237
+ Id3TextFrame.ALBUM_ARTISTS: TPE2,
238
+ Id3TextFrame.GENRES_NAMES: TCON,
239
+ Id3TextFrame.LANGUAGE: TLAN,
240
+ Id3TextFrame.RECORDING_TIME: TDRC,
241
+ Id3TextFrame.RELEASE_TIME: TDRL,
242
+ Id3TextFrame.YEAR: TYER,
243
+ Id3TextFrame.DATE: TDAT,
244
+ Id3TextFrame.TRACK_NUMBER: TRCK,
245
+ Id3TextFrame.DISC_NUMBER: TPOS,
246
+ Id3TextFrame.BPM: TBPM,
247
+ Id3TextFrame.RATING: POPM,
248
+ Id3TextFrame.COMPOSERS: TCOM,
249
+ Id3TextFrame.PUBLISHER: TPUB,
250
+ Id3TextFrame.COPYRIGHT: TCOP,
251
+ Id3TextFrame.UNSYNCHRONIZED_LYRICS: USLT,
252
+ Id3TextFrame.COMMENT: COMM,
253
+ Id3TextFrame.ENCODER: TENC,
254
+ Id3TextFrame.URL: WOAR,
255
+ Id3TextFrame.ISRC: TSRC,
256
+ Id3TextFrame.MOOD: TMOO,
257
+ Id3TextFrame.KEY: TKEY,
258
+ }
259
+
260
+ def __init__(
261
+ self,
262
+ audio_file: "_AudioFile",
263
+ normalized_rating_max_value: int | None = None,
264
+ id3v2_version: tuple[int, int, int] = (2, 3, 0),
265
+ ):
266
+ self.id3v2_version = id3v2_version
267
+ metadata_keys_direct_map_read = {
268
+ UnifiedMetadataKey.TITLE: self.Id3TextFrame.TITLE,
269
+ UnifiedMetadataKey.ARTISTS: self.Id3TextFrame.ARTISTS,
270
+ UnifiedMetadataKey.ALBUM: self.Id3TextFrame.ALBUM,
271
+ UnifiedMetadataKey.ALBUM_ARTISTS: self.Id3TextFrame.ALBUM_ARTISTS,
272
+ UnifiedMetadataKey.GENRES_NAMES: self.Id3TextFrame.GENRES_NAMES,
273
+ UnifiedMetadataKey.RATING: None,
274
+ UnifiedMetadataKey.LANGUAGE: self.Id3TextFrame.LANGUAGE,
275
+ UnifiedMetadataKey.RELEASE_DATE: self.Id3TextFrame.RECORDING_TIME,
276
+ UnifiedMetadataKey.TRACK_NUMBER: self.Id3TextFrame.TRACK_NUMBER,
277
+ UnifiedMetadataKey.DISC_NUMBER: None,
278
+ UnifiedMetadataKey.DISC_TOTAL: None,
279
+ UnifiedMetadataKey.BPM: self.Id3TextFrame.BPM,
280
+ UnifiedMetadataKey.COMPOSERS: self.Id3TextFrame.COMPOSERS,
281
+ UnifiedMetadataKey.PUBLISHER: self.Id3TextFrame.PUBLISHER,
282
+ UnifiedMetadataKey.COPYRIGHT: self.Id3TextFrame.COPYRIGHT,
283
+ UnifiedMetadataKey.UNSYNCHRONIZED_LYRICS: self.Id3TextFrame.UNSYNCHRONIZED_LYRICS,
284
+ UnifiedMetadataKey.COMMENT: self.Id3TextFrame.COMMENT,
285
+ UnifiedMetadataKey.REPLAYGAIN: None,
286
+ UnifiedMetadataKey.ISRC: self.Id3TextFrame.ISRC,
287
+ }
288
+ metadata_keys_direct_map_write: dict[UnifiedMetadataKey, RawMetadataKey | None] = {
289
+ UnifiedMetadataKey.TITLE: self.Id3TextFrame.TITLE,
290
+ UnifiedMetadataKey.ARTISTS: self.Id3TextFrame.ARTISTS,
291
+ UnifiedMetadataKey.ALBUM: self.Id3TextFrame.ALBUM,
292
+ UnifiedMetadataKey.ALBUM_ARTISTS: self.Id3TextFrame.ALBUM_ARTISTS,
293
+ UnifiedMetadataKey.GENRES_NAMES: self.Id3TextFrame.GENRES_NAMES,
294
+ UnifiedMetadataKey.RATING: self.Id3TextFrame.RATING,
295
+ UnifiedMetadataKey.LANGUAGE: self.Id3TextFrame.LANGUAGE,
296
+ UnifiedMetadataKey.RELEASE_DATE: self.Id3TextFrame.RECORDING_TIME,
297
+ UnifiedMetadataKey.TRACK_NUMBER: self.Id3TextFrame.TRACK_NUMBER,
298
+ UnifiedMetadataKey.DISC_NUMBER: None,
299
+ UnifiedMetadataKey.DISC_TOTAL: None,
300
+ UnifiedMetadataKey.BPM: self.Id3TextFrame.BPM,
301
+ UnifiedMetadataKey.COMPOSERS: self.Id3TextFrame.COMPOSERS,
302
+ UnifiedMetadataKey.PUBLISHER: self.Id3TextFrame.PUBLISHER,
303
+ UnifiedMetadataKey.COPYRIGHT: self.Id3TextFrame.COPYRIGHT,
304
+ UnifiedMetadataKey.UNSYNCHRONIZED_LYRICS: self.Id3TextFrame.UNSYNCHRONIZED_LYRICS,
305
+ UnifiedMetadataKey.COMMENT: self.Id3TextFrame.COMMENT,
306
+ UnifiedMetadataKey.REPLAYGAIN: None,
307
+ UnifiedMetadataKey.ISRC: self.Id3TextFrame.ISRC,
308
+ }
309
+
310
+ super().__init__(
311
+ audio_file=audio_file,
312
+ metadata_keys_direct_map_read=cast(
313
+ dict[UnifiedMetadataKey, RawMetadataKey | None], metadata_keys_direct_map_read
314
+ ),
315
+ metadata_keys_direct_map_write=metadata_keys_direct_map_write,
316
+ rating_write_profile=RatingWriteProfile.BASE_255_NON_PROPORTIONAL,
317
+ normalized_rating_max_value=normalized_rating_max_value,
318
+ )
319
+
320
+ def _extract_mutagen_metadata(self) -> RawMetadataDict:
321
+ try:
322
+ id3 = ID3(self.audio_file.file_path, load_v1=False, translate=False)
323
+
324
+ # Upgrade to specified version if different
325
+ if id3.version != self.id3v2_version:
326
+ id3.version = self.id3v2_version
327
+
328
+ return cast(RawMetadataDict, id3)
329
+ except ID3NoHeaderError:
330
+ try:
331
+ id3 = ID3(self.audio_file.file_path, load_v1=True, translate=False)
332
+ id3.clear() # Exclude ID3v1 tags
333
+ id3.version = self.id3v2_version
334
+ return cast(RawMetadataDict, id3)
335
+ except ID3NoHeaderError:
336
+ # Create empty ID3 object - will be saved during write operations
337
+ # This allows write operations to work with files that have no ID3v2 header
338
+ id3 = ID3()
339
+ id3.version = self.id3v2_version
340
+ return cast(RawMetadataDict, id3)
341
+
342
+ def _convert_raw_mutagen_metadata_to_dict_with_potential_duplicate_keys(
343
+ self, raw_mutagen_metadata: MutagenMetadata
344
+ ) -> RawMetadataDict:
345
+ raw_metadata_id3: ID3 = cast(ID3, raw_mutagen_metadata)
346
+ result: RawMetadataDict = {}
347
+
348
+ for frame_key in self.Id3TextFrame.__members__.values():
349
+ if frame_key == self.Id3TextFrame.RATING:
350
+ for raw_mutagen_frame in raw_mutagen_metadata.items():
351
+ popm_key = raw_mutagen_frame[0]
352
+ if popm_key.startswith(self.Id3TextFrame.RATING):
353
+ popm: POPM = raw_mutagen_frame[1]
354
+ popm_key_without_prefixes = popm_key.replace(f"{self.Id3TextFrame.RATING}:", "")
355
+ result[self.Id3TextFrame.RATING] = [
356
+ popm_key_without_prefixes,
357
+ getattr(popm, "rating", 0),
358
+ ]
359
+ break
360
+ elif frame_key == self.Id3TextFrame.COMMENT:
361
+ # Handle COMM frames (comment frames)
362
+ for raw_mutagen_frame in raw_mutagen_metadata.items():
363
+ if raw_mutagen_frame[0].startswith("COMM"):
364
+ comm_frame = raw_mutagen_frame[1]
365
+ result[frame_key] = comm_frame.text
366
+ break
367
+ elif frame_key == self.Id3TextFrame.UNSYNCHRONIZED_LYRICS:
368
+ # Handle USLT frames (unsynchronized lyrics frames)
369
+ for raw_mutagen_frame in raw_mutagen_metadata.items():
370
+ if raw_mutagen_frame[0].startswith("USLT"):
371
+ uslt_frame = raw_mutagen_frame[1]
372
+ result[frame_key] = [uslt_frame.text]
373
+ break
374
+ elif frame_key == self.Id3TextFrame.URL:
375
+ # Handle WOAR frames (official artist/performer webpage)
376
+ for raw_mutagen_frame in raw_mutagen_metadata.items():
377
+ if raw_mutagen_frame[0].startswith("WOAR"):
378
+ woar_frame = raw_mutagen_frame[1]
379
+ result[frame_key] = [woar_frame.url]
380
+ break
381
+ else:
382
+ frame_value = frame_key in raw_metadata_id3 and raw_metadata_id3[frame_key]
383
+ if not frame_value:
384
+ continue
385
+
386
+ if not frame_value.text:
387
+ continue
388
+
389
+ result[frame_key] = frame_value.text
390
+
391
+ # Handle TXXX frames for REPLAYGAIN
392
+ for raw_mutagen_frame in raw_mutagen_metadata.items():
393
+ if raw_mutagen_frame[0].startswith("TXXX"):
394
+ txxx_frame = raw_mutagen_frame[1]
395
+ if hasattr(txxx_frame, "desc") and txxx_frame.desc == "REPLAYGAIN":
396
+ result[self.Id3TextFrame.REPLAYGAIN] = txxx_frame.text
397
+ break
398
+
399
+ # Special handling for release date: if TDRC is not present, try to construct from TYER + TDAT
400
+ # Only do this for ID3v2 files (not ID3v1) and only when both TYER and TDAT are present
401
+ if self.Id3TextFrame.RECORDING_TIME not in result:
402
+ year_key: RawMetadataKey = self.Id3TextFrame.YEAR
403
+ date_key: RawMetadataKey = self.Id3TextFrame.DATE
404
+ tyer_value = result.get(year_key, None)
405
+ tdat_value = result.get(date_key, None)
406
+ if tyer_value and tdat_value:
407
+ # Parse TDAT (DDMM) and TYER to construct YYYY-MM-DD
408
+ try:
409
+ year = str(tyer_value[0]) if isinstance(tyer_value, list) else str(tyer_value)
410
+ date_str = str(tdat_value[0]) if isinstance(tdat_value, list) else str(tdat_value)
411
+ if len(date_str) == ID3V2_DATE_FORMAT_LENGTH: # DDMM format
412
+ day = date_str[:2]
413
+ month = date_str[2:]
414
+ # Construct YYYY-MM-DD
415
+ release_date = f"{year}-{month}-{day}"
416
+ result[self.Id3TextFrame.RECORDING_TIME] = [release_date]
417
+ except (IndexError, ValueError):
418
+ pass # If parsing fails, don't add release date
419
+
420
+ return result
421
+
422
+ def _get_raw_rating_by_traktor_or_not(self, raw_clean_metadata: RawMetadataDict) -> tuple[int | None, bool]:
423
+ for raw_metadata_key, raw_metadata_values in raw_clean_metadata.items():
424
+ if raw_metadata_values and len(raw_metadata_values) > 0 and raw_metadata_key == self.Id3TextFrame.RATING:
425
+ first_popm = cast(list, raw_metadata_values)
426
+ first_popm_identifier = first_popm[0]
427
+ first_popm_rating = first_popm[1]
428
+ if first_popm_identifier.find("Traktor") != -1:
429
+ return int(first_popm_rating), True
430
+ return int(first_popm_rating), False
431
+
432
+ return None, False
433
+
434
+ def _update_undirectly_mapped_metadata(
435
+ self,
436
+ raw_mutagen_metadata: ID3,
437
+ app_metadata_value: UnifiedMetadataValue,
438
+ unified_metadata_key: UnifiedMetadataKey,
439
+ ) -> None:
440
+ if unified_metadata_key == UnifiedMetadataKey.REPLAYGAIN:
441
+ # Remove existing TXXX:REPLAYGAIN frames
442
+ raw_mutagen_metadata.delall("TXXX:REPLAYGAIN")
443
+ if app_metadata_value is not None:
444
+ # Add new TXXX frame with desc 'REPLAYGAIN'
445
+ raw_mutagen_metadata.add(TXXX(encoding=3, desc="REPLAYGAIN", text=str(app_metadata_value)))
446
+ elif unified_metadata_key in (UnifiedMetadataKey.DISC_NUMBER, UnifiedMetadataKey.DISC_TOTAL):
447
+ tpos_key = self.Id3TextFrame.DISC_NUMBER
448
+ tpos_frame_class = TPOS
449
+ encoding = 0 if self.id3v2_version[1] == ID3V2_VERSION_3 else 3
450
+
451
+ if unified_metadata_key == UnifiedMetadataKey.DISC_NUMBER:
452
+ current_tpos = raw_mutagen_metadata.get(tpos_key)
453
+ current_total = None
454
+ if current_tpos and len(current_tpos.text) > 0:
455
+ tpos_str = str(current_tpos.text[0])
456
+ import re
457
+
458
+ match = re.match(r"^(\d+)/(\d+)$", tpos_str)
459
+ if match:
460
+ current_total = int(match.group(2))
461
+
462
+ raw_mutagen_metadata.delall(tpos_key)
463
+ if app_metadata_value is not None:
464
+ if not isinstance(app_metadata_value, int):
465
+ msg = f"DISC_NUMBER must be an integer, got {type(app_metadata_value).__name__}"
466
+ raise TypeError(msg)
467
+ disc_number = min(255, max(0, app_metadata_value))
468
+ tpos_value = f"{disc_number}/{current_total}" if current_total is not None else str(disc_number)
469
+ raw_mutagen_metadata.add(tpos_frame_class(encoding=encoding, text=tpos_value))
470
+ elif unified_metadata_key == UnifiedMetadataKey.DISC_TOTAL:
471
+ current_tpos = raw_mutagen_metadata.get(tpos_key)
472
+ current_disc_number = None
473
+ if current_tpos and len(current_tpos.text) > 0:
474
+ tpos_str = str(current_tpos.text[0])
475
+ import re
476
+
477
+ match = re.match(r"^(\d+)(?:/(\d+))?$", tpos_str)
478
+ if match:
479
+ current_disc_number = int(match.group(1))
480
+
481
+ raw_mutagen_metadata.delall(tpos_key)
482
+ if app_metadata_value is not None:
483
+ if not isinstance(app_metadata_value, int):
484
+ msg = f"DISC_TOTAL must be an integer, got {type(app_metadata_value).__name__}"
485
+ raise TypeError(msg)
486
+ disc_total = min(255, max(0, app_metadata_value))
487
+ if current_disc_number is not None:
488
+ tpos_value = f"{current_disc_number}/{disc_total}"
489
+ raw_mutagen_metadata.add(tpos_frame_class(encoding=encoding, text=tpos_value))
490
+ else:
491
+ msg = "Cannot set DISC_TOTAL without DISC_NUMBER"
492
+ raise ValueError(msg)
493
+ elif current_disc_number is not None:
494
+ tpos_value = str(current_disc_number)
495
+ raw_mutagen_metadata.add(tpos_frame_class(encoding=encoding, text=tpos_value))
496
+ else:
497
+ super()._update_undirectly_mapped_metadata( # type: ignore[safe-super]
498
+ cast(Any, raw_mutagen_metadata), app_metadata_value, unified_metadata_key
499
+ )
500
+
501
+ def _get_undirectly_mapped_metadata_value_other_than_rating_from_raw_clean_metadata(
502
+ self, raw_clean_metadata: RawMetadataDict, unified_metadata_key: UnifiedMetadataKey
503
+ ) -> UnifiedMetadataValue:
504
+ if unified_metadata_key == UnifiedMetadataKey.REPLAYGAIN:
505
+ replaygain_key = self.Id3TextFrame.REPLAYGAIN
506
+ if replaygain_key not in raw_clean_metadata:
507
+ return None
508
+ replaygain_value = raw_clean_metadata[replaygain_key]
509
+ if replaygain_value is None:
510
+ return None
511
+ if len(replaygain_value) == 0:
512
+ return None
513
+ first_value = replaygain_value[0]
514
+ return cast(UnifiedMetadataValue, first_value)
515
+ if unified_metadata_key == UnifiedMetadataKey.DISC_NUMBER:
516
+ tpos_key = self.Id3TextFrame.DISC_NUMBER
517
+ if tpos_key not in raw_clean_metadata:
518
+ return None
519
+ tpos_value = raw_clean_metadata[tpos_key]
520
+ if tpos_value is None or len(tpos_value) == 0:
521
+ return None
522
+ tpos_str = str(tpos_value[0])
523
+ import re
524
+
525
+ match = re.match(r"^(\d+)(?:/(\d+))?$", tpos_str)
526
+ if match:
527
+ return int(match.group(1))
528
+ return None
529
+ if unified_metadata_key == UnifiedMetadataKey.DISC_TOTAL:
530
+ tpos_key = self.Id3TextFrame.DISC_NUMBER
531
+ if tpos_key not in raw_clean_metadata:
532
+ return None
533
+ tpos_value = raw_clean_metadata[tpos_key]
534
+ if tpos_value is None or len(tpos_value) == 0:
535
+ return None
536
+ tpos_str = str(tpos_value[0])
537
+ import re
538
+
539
+ match = re.match(r"^(\d+)/(\d+)$", tpos_str)
540
+ if match:
541
+ return int(match.group(2))
542
+ return None
543
+ msg = f"Metadata key not handled: {unified_metadata_key}"
544
+ raise MetadataFieldNotSupportedByMetadataFormatError(msg)
545
+
546
+ def _update_formatted_value_in_raw_mutagen_metadata(
547
+ self,
548
+ raw_mutagen_metadata: ID3,
549
+ raw_metadata_key: RawMetadataKey,
550
+ app_metadata_value: UnifiedMetadataValue,
551
+ ) -> None:
552
+ raw_mutagen_metadata_id3: ID3 = raw_mutagen_metadata
553
+ raw_mutagen_metadata_id3.delall(raw_metadata_key)
554
+
555
+ # If value is None, don't add any frames (field is removed)
556
+ if app_metadata_value is None:
557
+ return
558
+
559
+ # Defensive check: if list contains None values, filter them out (should not happen after base class filtering)
560
+ if isinstance(app_metadata_value, list):
561
+ app_metadata_value = [v for v in app_metadata_value if v is not None and v != ""]
562
+ if not app_metadata_value:
563
+ return
564
+
565
+ # Handle multiple values by creating separate frames for multi-value fields
566
+ if isinstance(app_metadata_value, list) and all(isinstance(item, str) for item in app_metadata_value):
567
+ # Get the corresponding UnifiedMetadataKey
568
+ unified_metadata_key = None
569
+ if self.metadata_keys_direct_map_write is None:
570
+ return
571
+ for key, raw_key in self.metadata_keys_direct_map_write.items():
572
+ if raw_key == raw_metadata_key:
573
+ unified_metadata_key = key
574
+ break
575
+
576
+ if unified_metadata_key and unified_metadata_key.can_semantically_have_multiple_values():
577
+ # Check ID3v2 version to determine handling
578
+ # Use self.id3v2_version instead of trying to get it from the mutagen object
579
+ # as the object might not have the version set yet during writing
580
+ id3v2_version = self.id3v2_version
581
+
582
+ # ID3v2.4 supports multi-value text frames (single frame with null-separated values per spec)
583
+ if id3v2_version[1] >= ID3V2_VERSION_4:
584
+ # Create single frame with multiple text values (ID3v2.4 spec: null-separated values in one frame)
585
+ # Officially supported fields: TPE1 (artists), TPE2 (album artists), TCOM (composers), TCON (genres)
586
+ text_frame_class = self.ID3_TEXT_FRAME_CLASS_MAP[raw_metadata_key]
587
+ # Values are already filtered at the base level
588
+ if app_metadata_value:
589
+ self._add_id3_frame_v24_multi(raw_mutagen_metadata_id3, text_frame_class, app_metadata_value)
590
+ return
591
+
592
+ # For ID3v2.3, use concatenation with separators (ID3v2.3 doesn't support null-separated values)
593
+ # Find a separator that doesn't appear in any of the values and concatenate
594
+ separator = MetadataManager.find_safe_separator(app_metadata_value)
595
+ app_metadata_value = separator.join(app_metadata_value)
596
+ # Continue to handle as single value
597
+ else:
598
+ # For non-multi-value fields, concatenate with separators as fallback
599
+ # Find a separator that doesn't appear in any of the values and concatenate
600
+ separator = MetadataManager.find_safe_separator(app_metadata_value)
601
+ app_metadata_value = separator.join(app_metadata_value)
602
+
603
+ # Handle single values
604
+ text_frame_class = self.ID3_TEXT_FRAME_CLASS_MAP[raw_metadata_key]
605
+ self._add_id3_frame(raw_mutagen_metadata_id3, text_frame_class, raw_metadata_key, app_metadata_value)
606
+
607
+ def _add_id3_frame(
608
+ self,
609
+ raw_mutagen_metadata_id3: ID3,
610
+ text_frame_class: type[Any],
611
+ raw_metadata_key: RawMetadataKey,
612
+ app_metadata_value: UnifiedMetadataValue,
613
+ ) -> None:
614
+ """Add a single ID3 frame with proper encoding and format handling."""
615
+ # Determine encoding based on ID3v2 version
616
+ encoding = 0 if self.id3v2_version[1] == ID3V2_VERSION_3 else 3
617
+
618
+ if raw_metadata_key == self.Id3TextFrame.RATING:
619
+ raw_mutagen_metadata_id3.add(text_frame_class(email=self.ID3_RATING_APP_EMAIL, rating=app_metadata_value))
620
+ elif raw_metadata_key == self.Id3TextFrame.COMMENT:
621
+ # Handle COMM frames (comment frames)
622
+ raw_mutagen_metadata_id3.add(
623
+ text_frame_class(encoding=encoding, lang="eng", desc="", text=app_metadata_value)
624
+ )
625
+ elif raw_metadata_key == self.Id3TextFrame.UNSYNCHRONIZED_LYRICS:
626
+ # Handle USLT frames (unsynchronized lyrics frames)
627
+ raw_mutagen_metadata_id3.add(
628
+ text_frame_class(encoding=encoding, lang="eng", desc="", text=app_metadata_value)
629
+ )
630
+ elif raw_metadata_key == self.Id3TextFrame.URL:
631
+ # Handle WOAR frames (official artist/performer webpage)
632
+ raw_mutagen_metadata_id3.add(text_frame_class(url=app_metadata_value))
633
+ elif raw_metadata_key == self.Id3TextFrame.BPM:
634
+ # Handle TBPM frames (BPM must be a string)
635
+ raw_mutagen_metadata_id3.add(text_frame_class(encoding=encoding, text=str(app_metadata_value)))
636
+ elif raw_metadata_key == self.Id3TextFrame.TRACK_NUMBER:
637
+ # Handle TRCK frames (track number must be a string)
638
+ raw_mutagen_metadata_id3.add(text_frame_class(encoding=encoding, text=str(app_metadata_value)))
639
+ else:
640
+ raw_mutagen_metadata_id3.add(text_frame_class(encoding=encoding, text=app_metadata_value))
641
+
642
+ def _add_id3_frame_v24_multi(
643
+ self, raw_mutagen_metadata_id3: ID3, text_frame_class: type[Any], values: list[str]
644
+ ) -> None:
645
+ """ID3v2.4: add a single text frame containing multiple null-separated values.
646
+
647
+ Mutagen accepts a list for the `text` parameter and will write it as
648
+ null-separated strings in a single frame which matches the ID3v2.4 spec.
649
+ """
650
+ # Add one frame with multiple text values (mutagen handles null separation)
651
+ raw_mutagen_metadata_id3.add(text_frame_class(encoding=3, text=values))
652
+
653
+ def _preserve_id3v1_metadata(self, file_path: str) -> bytes | None:
654
+ """Read and preserve existing ID3v1 metadata from the end of the file.
655
+
656
+ Returns:
657
+ The 128-byte ID3v1 tag data if present, None otherwise
658
+ """
659
+ with Path(file_path).open("rb") as f:
660
+ f.seek(-128, 2) # Seek to last 128 bytes
661
+ data = f.read(128)
662
+ if data.startswith(b"TAG"):
663
+ return data
664
+ return None
665
+
666
+ def _save_with_id3v1_preservation(self, file_path: str, id3v1_data: bytes | None) -> None:
667
+ """Save ID3v2 metadata while preserving ID3v1 data.
668
+
669
+ Args:
670
+ file_path: Path to the audio file
671
+ id3v1_data: The 128-byte ID3v1 tag data to preserve, or None
672
+ """
673
+ if self.raw_mutagen_metadata is not None:
674
+ # Extract the major version number from the tuple (2, 3, 0) -> 3
675
+ version_major = self.id3v2_version[1]
676
+ id3_metadata: ID3 = cast(ID3, self.raw_mutagen_metadata)
677
+
678
+ if id3v1_data:
679
+ # Save to a temporary file first
680
+ with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as temp_file:
681
+ temp_path = temp_file.name
682
+
683
+ try:
684
+ # Copy the original file to temp file first
685
+ shutil.copy2(file_path, temp_path)
686
+
687
+ # Save ID3v2 to temp file (this will overwrite ID3v2 tags in the copy)
688
+ id3_metadata.save(temp_path, v2_version=version_major)
689
+
690
+ # Read the temp file and append ID3v1 data
691
+ with Path(temp_path).open("rb") as f:
692
+ temp_data = f.read()
693
+
694
+ # Append ID3v1 data to the temp file
695
+ final_data = temp_data + id3v1_data
696
+
697
+ # Write the final file
698
+ with Path(file_path).open("wb") as f:
699
+ f.write(final_data)
700
+
701
+ finally:
702
+ # Clean up temp file
703
+ with contextlib.suppress(OSError):
704
+ Path(temp_path).unlink()
705
+ else:
706
+ # No ID3v1 data to preserve, save normally
707
+ id3_metadata.save(file_path, v2_version=version_major)
708
+
709
+ def _save_with_version(self, file_path: str) -> None:
710
+ """Save ID3 tags with the specified version, preserving existing ID3v1 metadata."""
711
+ if self.raw_mutagen_metadata is not None:
712
+ # Preserve existing ID3v1 metadata before saving ID3v2
713
+ id3v1_data = self._preserve_id3v1_metadata(file_path)
714
+
715
+ # Save ID3v2 while preserving ID3v1
716
+ self._save_with_id3v1_preservation(file_path, id3v1_data)
717
+
718
+ def update_metadata(self, unified_metadata: UnifiedMetadata) -> None:
719
+ """Update ID3v2 metadata using hybrid approach: mutagen for most formats, external tools for FLAC.
720
+
721
+ This method automatically chooses the appropriate tool based on the audio file format
722
+ to ensure optimal performance and file integrity.
723
+
724
+ Format-Specific Behavior:
725
+ - **MP3 and other formats**: Uses mutagen (Python library) for fast, reliable updates
726
+ - **FLAC files**: Uses external tools (id3v2/mid3v2) to prevent file corruption
727
+
728
+ Why External Tools for FLAC?
729
+ - Mutagen's ID3 class corrupts FLAC file structure when writing ID3v2 tags
730
+ - External tools properly handle FLAC's metadata block structure
731
+ - Prevents "Not a valid FLAC file" errors and file corruption
732
+
733
+ Tool Selection Logic:
734
+ - **ID3v2.3**: Uses 'id3v2' command-line tool
735
+ - **ID3v2.4**: Uses 'mid3v2' command-line tool
736
+ - **Other formats**: Uses mutagen for optimal performance
737
+
738
+ Key Features:
739
+ - **Version Control**: Maintains specified ID3v2 version (2.3 or 2.4)
740
+ - **ID3v1 Preservation**: Preserves existing ID3v1 tags when present
741
+ - **File Integrity**: Prevents corruption across all supported formats
742
+
743
+ External Tool Requirements (FLAC only):
744
+ - Requires 'id3v2' or 'mid3v2' command-line tools
745
+ - Falls back to FileCorruptedError if tools are not available
746
+
747
+ Args:
748
+ unified_metadata: Dictionary of metadata to write/update
749
+ Use None values to delete specific fields
750
+
751
+ Raises:
752
+ MetadataFieldNotSupportedByMetadataFormatError: If field not supported
753
+ FileCorruptedError: If external tools fail or are not found (FLAC only)
754
+ ConfigurationError: If rating configuration is invalid
755
+ """
756
+ # For FLAC files, use external tools instead of mutagen to avoid file corruption
757
+ if self.audio_file.file_extension == ".flac":
758
+ self._update_metadata_for_flac(unified_metadata)
759
+ return
760
+
761
+ if not self.metadata_keys_direct_map_write:
762
+ msg = "This format does not support metadata modification"
763
+ raise MetadataFieldNotSupportedByMetadataFormatError(msg)
764
+
765
+ self._validate_and_process_rating(unified_metadata)
766
+
767
+ # Preserve ID3v1 metadata before any modifications
768
+ id3v1_data = self._preserve_id3v1_metadata(self.audio_file.file_path)
769
+
770
+ # Update the raw mutagen metadata (without saving yet)
771
+ if self.raw_mutagen_metadata is None:
772
+ self.raw_mutagen_metadata = cast(MutagenMetadata, self._extract_mutagen_metadata())
773
+
774
+ id3_metadata: ID3 = cast(ID3, self.raw_mutagen_metadata)
775
+
776
+ for unified_metadata_key in list(unified_metadata.keys()):
777
+ app_metadata_value = unified_metadata[unified_metadata_key]
778
+ if unified_metadata_key not in self.metadata_keys_direct_map_write:
779
+ metadata_format_name = self._get_formatted_metadata_format_name()
780
+ msg = f"{unified_metadata_key} metadata not supported by {metadata_format_name} format"
781
+ raise MetadataFieldNotSupportedByMetadataFormatError(msg)
782
+ raw_metadata_key = self.metadata_keys_direct_map_write[unified_metadata_key]
783
+ if raw_metadata_key:
784
+ self._update_formatted_value_in_raw_mutagen_metadata(
785
+ raw_mutagen_metadata=id3_metadata,
786
+ raw_metadata_key=raw_metadata_key,
787
+ app_metadata_value=app_metadata_value,
788
+ )
789
+ else:
790
+ self._update_undirectly_mapped_metadata(
791
+ raw_mutagen_metadata=id3_metadata,
792
+ app_metadata_value=app_metadata_value,
793
+ unified_metadata_key=unified_metadata_key,
794
+ )
795
+
796
+ # Save with ID3v1 preservation
797
+ self._save_with_id3v1_preservation(self.audio_file.file_path, id3v1_data)
798
+
799
+ def _update_metadata_for_flac(self, unified_metadata: UnifiedMetadata) -> None:
800
+ """Update ID3v2 metadata for FLAC files using external tools to avoid file corruption."""
801
+ if not self.metadata_keys_direct_map_write:
802
+ msg = "This format does not support metadata modification"
803
+ raise MetadataFieldNotSupportedByMetadataFormatError(msg)
804
+
805
+ self._validate_and_process_rating(unified_metadata)
806
+
807
+ # Use external tools to write ID3v2 metadata to FLAC files
808
+ # This avoids the file corruption that occurs with mutagen's ID3 class
809
+ # Determine the tool and version based on the configured ID3v2 version
810
+ if self.id3v2_version[1] == ID3V2_VERSION_3:
811
+ tool = "id3v2"
812
+ cmd = [get_tool_path("id3v2"), "--id3v2-only"]
813
+ else: # ID3v2.4
814
+ tool = "mid3v2"
815
+ cmd = [get_tool_path("mid3v2")]
816
+
817
+ # Map unified metadata keys to external tool arguments
818
+ key_mapping = {
819
+ UnifiedMetadataKey.TITLE: "--song",
820
+ UnifiedMetadataKey.ARTISTS: "--artist",
821
+ UnifiedMetadataKey.ALBUM: "--album",
822
+ UnifiedMetadataKey.ALBUM_ARTISTS: "--TPE2",
823
+ UnifiedMetadataKey.GENRES_NAMES: "--genre",
824
+ UnifiedMetadataKey.COMMENT: "--comment",
825
+ UnifiedMetadataKey.TRACK_NUMBER: "--track",
826
+ UnifiedMetadataKey.BPM: "--TBPM",
827
+ UnifiedMetadataKey.COMPOSERS: "--TCOM",
828
+ UnifiedMetadataKey.COPYRIGHT: "--TCOP",
829
+ UnifiedMetadataKey.UNSYNCHRONIZED_LYRICS: "--USLT",
830
+ UnifiedMetadataKey.LANGUAGE: "--TLAN",
831
+ UnifiedMetadataKey.PUBLISHER: "--TPUB",
832
+ }
833
+
834
+ # Build command with metadata
835
+ # First, remove frames for keys explicitly set to None
836
+ frames_to_remove = []
837
+ for unified_key, value in unified_metadata.items():
838
+ if unified_key in self.metadata_keys_direct_map_write:
839
+ raw_key = self.metadata_keys_direct_map_write[unified_key]
840
+ if raw_key and value is None:
841
+ frames_to_remove.append(raw_key)
842
+
843
+ try:
844
+ if frames_to_remove:
845
+ if self.id3v2_version[1] == ID3V2_VERSION_3:
846
+ # id3v2 supports removing a single frame at a time via -r
847
+ for frame in frames_to_remove:
848
+ with contextlib.suppress(subprocess.CalledProcessError):
849
+ subprocess.run(
850
+ [get_tool_path("id3v2"), "-r", frame, self.audio_file.file_path],
851
+ check=True,
852
+ capture_output=True,
853
+ )
854
+ else:
855
+ # mid3v2 supports deleting multiple frames with --delete-frames
856
+ frames_arg = ",".join(frames_to_remove)
857
+ with contextlib.suppress(subprocess.CalledProcessError):
858
+ subprocess.run(
859
+ [get_tool_path("mid3v2"), f"--delete-frames={frames_arg}", self.audio_file.file_path],
860
+ check=True,
861
+ capture_output=True,
862
+ )
863
+ except FileNotFoundError:
864
+ # If removal tool not found, proceed and hope save will remove frames
865
+ pass
866
+
867
+ # Build command with metadata (only non-None values)
868
+ for unified_key, value in unified_metadata.items():
869
+ if unified_key in key_mapping and value is not None:
870
+ tool_arg = key_mapping[unified_key]
871
+
872
+ processed_value = value
873
+ if unified_key == UnifiedMetadataKey.ARTISTS and isinstance(value, list):
874
+ # Handle multiple artists by joining with semicolon
875
+ processed_value = ";".join(value)
876
+ elif unified_key == UnifiedMetadataKey.GENRES_NAMES and isinstance(value, list):
877
+ # Handle multiple genres by joining with semicolon
878
+ processed_value = ";".join(value)
879
+ elif unified_key == UnifiedMetadataKey.COMPOSERS and isinstance(value, list):
880
+ # Handle multiple composers by joining with semicolon
881
+ processed_value = ";".join(value)
882
+ elif unified_key == UnifiedMetadataKey.ALBUM_ARTISTS and isinstance(value, list):
883
+ # Handle multiple album artists by joining with semicolon
884
+ processed_value = ";".join(value)
885
+
886
+ cmd.extend([tool_arg, str(processed_value)])
887
+
888
+ # Add file path and execute
889
+ cmd.append(self.audio_file.file_path)
890
+
891
+ try:
892
+ subprocess.run(cmd, check=True, capture_output=True)
893
+ except subprocess.CalledProcessError as e:
894
+ msg = f"Failed to write ID3v2 metadata with {tool}: {e}"
895
+ raise FileCorruptedError(msg) from e
896
+ except FileNotFoundError as e:
897
+ msg = f"External tool {tool} not found. Please install it to write ID3v2 metadata to FLAC files."
898
+ raise FileCorruptedError(msg) from e
899
+
900
+ def delete_metadata(self) -> bool:
901
+ """Delete all ID3v2 metadata from the audio file.
902
+
903
+ This removes all ID3v2 frames from the file while preserving the audio data.
904
+ Uses ID3.delete() which is more reliable than deleting individual frames,
905
+ especially for non-MP3 files like FLAC that might have ID3v2 tags.
906
+
907
+ Returns:
908
+ bool: True if metadata was successfully deleted, False otherwise
909
+ """
910
+ try:
911
+ # Create a new ID3 instance and use delete() to remove all ID3v2 tags
912
+ id3 = ID3(self.audio_file.file_path)
913
+ id3.delete()
914
+ except ID3NoHeaderError:
915
+ # No ID3 tags present, consider this a success
916
+ return True
917
+ except Exception:
918
+ return False
919
+ else:
920
+ return True
921
+
922
+ def get_header_info(self) -> dict:
923
+ try:
924
+ if self.raw_mutagen_metadata is None:
925
+ self.raw_mutagen_metadata = cast(MutagenMetadata, self._extract_mutagen_metadata())
926
+
927
+ if not self.raw_mutagen_metadata:
928
+ return {"present": False, "version": None, "header_size_bytes": 0, "flags": {}, "extended_header": {}}
929
+
930
+ id3_metadata: ID3 = cast(ID3, self.raw_mutagen_metadata)
931
+
932
+ # Get ID3v2 version
933
+ version = getattr(id3_metadata, "version", None)
934
+ version_str = f"{version[0]}.{version[1]}.{version[2]}" if version else None
935
+
936
+ # Get header size
937
+ header_size = getattr(id3_metadata, "size", 0)
938
+
939
+ # Get flags
940
+ flags = {}
941
+ if hasattr(id3_metadata, "flags"):
942
+ flags = {
943
+ "unsync": bool(id3_metadata.flags & 0x80),
944
+ "extended_header": bool(id3_metadata.flags & 0x40),
945
+ "experimental": bool(id3_metadata.flags & 0x20),
946
+ "footer": bool(id3_metadata.flags & 0x10),
947
+ }
948
+
949
+ # Get extended header info
950
+ extended_header = {}
951
+ if hasattr(id3_metadata, "extended_header"):
952
+ ext_header = id3_metadata.extended_header
953
+ if ext_header:
954
+ extended_header = {
955
+ "size": getattr(ext_header, "size", 0),
956
+ "flags": getattr(ext_header, "flags", 0),
957
+ "padding_size": getattr(ext_header, "padding_size", 0),
958
+ }
959
+ except Exception:
960
+ return {"present": False, "version": None, "header_size_bytes": 0, "flags": {}, "extended_header": {}}
961
+ else:
962
+ return {
963
+ "present": True,
964
+ "version": version_str,
965
+ "header_size_bytes": header_size,
966
+ "flags": flags,
967
+ "extended_header": extended_header,
968
+ }
969
+
970
+ def get_raw_metadata_info(self) -> dict:
971
+ try:
972
+ if self.raw_mutagen_metadata is None:
973
+ self.raw_mutagen_metadata = cast(MutagenMetadata, self._extract_mutagen_metadata())
974
+
975
+ if not self.raw_mutagen_metadata:
976
+ return {"raw_data": None, "parsed_fields": {}, "frames": {}, "comments": {}, "chunk_structure": {}}
977
+
978
+ id3_metadata: ID3 = cast(ID3, self.raw_mutagen_metadata)
979
+
980
+ # Get raw frames (exclude binary frames like APIC)
981
+ frames = {}
982
+ binary_frame_types = {
983
+ "APIC:",
984
+ "GEOB:",
985
+ "AENC:",
986
+ "RVA2:",
987
+ "RVRB:",
988
+ "EQU2:",
989
+ "PCNT:",
990
+ "POPM:",
991
+ "RBUF:",
992
+ "LINK:",
993
+ "POSS:",
994
+ "SYLT:",
995
+ "USLT:",
996
+ "SYTC:",
997
+ "ETCO:",
998
+ "MLLT:",
999
+ "OWNE:",
1000
+ "COMR:",
1001
+ "ENCR:",
1002
+ "GRID:",
1003
+ "PRIV:",
1004
+ "SIGN:",
1005
+ "SEEK:",
1006
+ "ASPI:",
1007
+ }
1008
+
1009
+ for frame_id, frame in id3_metadata.items():
1010
+ # Skip binary frames to avoid including large image/audio data
1011
+ if frame_id in binary_frame_types:
1012
+ frames[frame_id] = {
1013
+ "text": f"<Binary data: {getattr(frame, 'size', 0)} bytes>",
1014
+ "size": getattr(frame, "size", 0),
1015
+ "flags": getattr(frame, "flags", 0),
1016
+ }
1017
+ else:
1018
+ frames[frame_id] = {
1019
+ "text": str(frame) if hasattr(frame, "__str__") else repr(frame),
1020
+ "size": getattr(frame, "size", 0),
1021
+ "flags": getattr(frame, "flags", 0),
1022
+ }
1023
+ except Exception:
1024
+ return {"raw_data": None, "parsed_fields": {}, "frames": {}, "comments": {}, "chunk_structure": {}}
1025
+ else:
1026
+ return {
1027
+ "raw_data": None, # ID3v2 data is complex, not storing raw bytes
1028
+ "parsed_fields": {},
1029
+ "frames": frames,
1030
+ "comments": {},
1031
+ "chunk_structure": {},
1032
+ }