audiometa-python 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (318) hide show
  1. audiometa/__init__.py +1240 -0
  2. audiometa/__main__.py +6 -0
  3. audiometa/_audio_file.py +602 -0
  4. audiometa/cli.py +347 -0
  5. audiometa/exceptions.py +167 -0
  6. audiometa/manager/_MetadataManager.py +665 -0
  7. audiometa/manager/__init__.py +1 -0
  8. audiometa/manager/_rating_supporting/_RatingSupportingMetadataManager.py +250 -0
  9. audiometa/manager/_rating_supporting/__init__.py +1 -0
  10. audiometa/manager/_rating_supporting/id3v2/_Id3v2Manager.py +945 -0
  11. audiometa/manager/_rating_supporting/id3v2/__init__.py +25 -0
  12. audiometa/manager/_rating_supporting/id3v2/_id3v2_constants.py +11 -0
  13. audiometa/manager/_rating_supporting/riff/_RiffManager.py +778 -0
  14. audiometa/manager/_rating_supporting/riff/__init__.py +25 -0
  15. audiometa/manager/_rating_supporting/riff/_riff_constants.py +10 -0
  16. audiometa/manager/_rating_supporting/vorbis/_VorbisManager.py +525 -0
  17. audiometa/manager/_rating_supporting/vorbis/__init__.py +17 -0
  18. audiometa/manager/_rating_supporting/vorbis/_vorbis_constants.py +6 -0
  19. audiometa/manager/id3v1/_Id3v1Manager.py +512 -0
  20. audiometa/manager/id3v1/__init__.py +1 -0
  21. audiometa/manager/id3v1/_constants.py +8 -0
  22. audiometa/manager/id3v1/id3v1_raw_metadata.py +242 -0
  23. audiometa/manager/id3v1/id3v1_raw_metadata_key.py +13 -0
  24. audiometa/test/__init__.py +1 -0
  25. audiometa/test/assets/create_test_files.py +72 -0
  26. audiometa/test/helpers/__init__.py +51 -0
  27. audiometa/test/helpers/common/__init__.py +6 -0
  28. audiometa/test/helpers/common/audio_file_creator.py +68 -0
  29. audiometa/test/helpers/common/external_tool_runner.py +74 -0
  30. audiometa/test/helpers/id3v1/__init__.py +8 -0
  31. audiometa/test/helpers/id3v1/id3v1_header_verifier.py +18 -0
  32. audiometa/test/helpers/id3v1/id3v1_metadata_deleter.py +37 -0
  33. audiometa/test/helpers/id3v1/id3v1_metadata_getter.py +61 -0
  34. audiometa/test/helpers/id3v1/id3v1_metadata_setter.py +82 -0
  35. audiometa/test/helpers/id3v2/__init__.py +28 -0
  36. audiometa/test/helpers/id3v2/id3v2_frame_manual_creator.py +305 -0
  37. audiometa/test/helpers/id3v2/id3v2_header_verifier.py +38 -0
  38. audiometa/test/helpers/id3v2/id3v2_metadata_deleter.py +56 -0
  39. audiometa/test/helpers/id3v2/id3v2_metadata_getter.py +188 -0
  40. audiometa/test/helpers/id3v2/id3v2_metadata_setter.py +428 -0
  41. audiometa/test/helpers/riff/__init__.py +8 -0
  42. audiometa/test/helpers/riff/riff_header_verifier.py +85 -0
  43. audiometa/test/helpers/riff/riff_manual_metadata_creator.py +216 -0
  44. audiometa/test/helpers/riff/riff_metadata_deleter.py +56 -0
  45. audiometa/test/helpers/riff/riff_metadata_getter.py +118 -0
  46. audiometa/test/helpers/riff/riff_metadata_setter.py +196 -0
  47. audiometa/test/helpers/scripts/__init__.py +0 -0
  48. audiometa/test/helpers/technical_info_inspector.py +115 -0
  49. audiometa/test/helpers/temp_file_with_metadata.py +82 -0
  50. audiometa/test/helpers/vorbis/__init__.py +8 -0
  51. audiometa/test/helpers/vorbis/vorbis_header_verifier.py +31 -0
  52. audiometa/test/helpers/vorbis/vorbis_metadata_deleter.py +49 -0
  53. audiometa/test/helpers/vorbis/vorbis_metadata_getter.py +67 -0
  54. audiometa/test/helpers/vorbis/vorbis_metadata_setter.py +200 -0
  55. audiometa/test/tests/__init__.py +0 -0
  56. audiometa/test/tests/conftest.py +261 -0
  57. audiometa/test/tests/e2e/__init__.py +0 -0
  58. audiometa/test/tests/e2e/cli/__init__.py +0 -0
  59. audiometa/test/tests/e2e/cli/error_handling/__init__.py +1 -0
  60. audiometa/test/tests/e2e/cli/error_handling/test_command_structure_errors.py +77 -0
  61. audiometa/test/tests/e2e/cli/error_handling/test_file_access_errors.py +130 -0
  62. audiometa/test/tests/e2e/cli/error_handling/test_format_output_errors.py +118 -0
  63. audiometa/test/tests/e2e/cli/error_handling/test_input_validation_errors.py +172 -0
  64. audiometa/test/tests/e2e/cli/error_handling/test_missing_fields_validation.py +49 -0
  65. audiometa/test/tests/e2e/cli/error_handling/test_multiple_files_errors.py +160 -0
  66. audiometa/test/tests/e2e/cli/error_handling/test_rating_validation.py +90 -0
  67. audiometa/test/tests/e2e/cli/error_handling/test_year_validation.py +51 -0
  68. audiometa/test/tests/e2e/cli/test_cli_delete.py +20 -0
  69. audiometa/test/tests/e2e/cli/test_cli_formatting.py +31 -0
  70. audiometa/test/tests/e2e/cli/test_cli_help.py +40 -0
  71. audiometa/test/tests/e2e/cli/test_cli_read.py +79 -0
  72. audiometa/test/tests/e2e/cli/test_cli_unified.py +33 -0
  73. audiometa/test/tests/e2e/cli/test_cli_write.py +116 -0
  74. audiometa/test/tests/e2e/scenarios/__init__.py +0 -0
  75. audiometa/test/tests/e2e/scenarios/test_user_scenarios.py +166 -0
  76. audiometa/test/tests/e2e/workflows/__init__.py +0 -0
  77. audiometa/test/tests/e2e/workflows/test_core_workflows.py +166 -0
  78. audiometa/test/tests/e2e/workflows/test_deletion_workflows.py +318 -0
  79. audiometa/test/tests/e2e/workflows/test_error_handling_workflows.py +165 -0
  80. audiometa/test/tests/e2e/workflows/test_format_specific_workflows.py +129 -0
  81. audiometa/test/tests/e2e/workflows/test_rating_workflows.py +124 -0
  82. audiometa/test/tests/integration/__init__.py +0 -0
  83. audiometa/test/tests/integration/audio_format/__init__.py +0 -0
  84. audiometa/test/tests/integration/audio_format/flac/__init__.py +0 -0
  85. audiometa/test/tests/integration/audio_format/flac/test_flac_delete_all.py +108 -0
  86. audiometa/test/tests/integration/audio_format/flac/test_flac_reading_all.py +61 -0
  87. audiometa/test/tests/integration/audio_format/flac/test_flac_reading_field.py +65 -0
  88. audiometa/test/tests/integration/audio_format/flac/test_flac_writing.py +69 -0
  89. audiometa/test/tests/integration/audio_format/mp3/__init__.py +0 -0
  90. audiometa/test/tests/integration/audio_format/mp3/test_mp3_delete_all.py +79 -0
  91. audiometa/test/tests/integration/audio_format/mp3/test_mp3_reading_all.py +61 -0
  92. audiometa/test/tests/integration/audio_format/mp3/test_mp3_reading_field.py +67 -0
  93. audiometa/test/tests/integration/audio_format/mp3/test_mp3_writing.py +60 -0
  94. audiometa/test/tests/integration/audio_format/wav/__init__.py +0 -0
  95. audiometa/test/tests/integration/audio_format/wav/test_wav_delete_all.py +87 -0
  96. audiometa/test/tests/integration/audio_format/wav/test_wav_reading_all.py +62 -0
  97. audiometa/test/tests/integration/audio_format/wav/test_wav_reading_field.py +57 -0
  98. audiometa/test/tests/integration/audio_format/wav/test_wav_with_id3v2_tags.py +83 -0
  99. audiometa/test/tests/integration/audio_format/wav/test_wav_writing.py +62 -0
  100. audiometa/test/tests/integration/conftest.py +29 -0
  101. audiometa/test/tests/integration/delete_all_metadata/__init__.py +1 -0
  102. audiometa/test/tests/integration/delete_all_metadata/test_audio_format_all.py +102 -0
  103. audiometa/test/tests/integration/delete_all_metadata/test_audio_format_header_deletion.py +77 -0
  104. audiometa/test/tests/integration/delete_all_metadata/test_basic_functionality.py +47 -0
  105. audiometa/test/tests/integration/delete_all_metadata/test_error_handling.py +24 -0
  106. audiometa/test/tests/integration/encoding/__init__.py +1 -0
  107. audiometa/test/tests/integration/encoding/test_encoding.py +88 -0
  108. audiometa/test/tests/integration/encoding/test_special_characters_edge_cases.py +223 -0
  109. audiometa/test/tests/integration/get_full_metadata/__init__.py +0 -0
  110. audiometa/test/tests/integration/get_full_metadata/test_binary_data_filtering.py +250 -0
  111. audiometa/test/tests/integration/get_full_metadata/test_get_full_metadata.py +297 -0
  112. audiometa/test/tests/integration/get_full_metadata/test_get_full_metadata_edge_cases.py +231 -0
  113. audiometa/test/tests/integration/get_full_metadata/test_options.py +207 -0
  114. audiometa/test/tests/integration/get_full_metadata/test_parsed_fields_keys.py +85 -0
  115. audiometa/test/tests/integration/metadata_field/__init__.py +0 -0
  116. audiometa/test/tests/integration/metadata_field/album/__init__.py +0 -0
  117. audiometa/test/tests/integration/metadata_field/album/test_deleting.py +73 -0
  118. audiometa/test/tests/integration/metadata_field/album/test_reading.py +36 -0
  119. audiometa/test/tests/integration/metadata_field/album/test_writing.py +50 -0
  120. audiometa/test/tests/integration/metadata_field/album_artists/__init__.py +0 -0
  121. audiometa/test/tests/integration/metadata_field/album_artists/test_deleting.py +83 -0
  122. audiometa/test/tests/integration/metadata_field/album_artists/test_reading.py +38 -0
  123. audiometa/test/tests/integration/metadata_field/album_artists/test_writing.py +52 -0
  124. audiometa/test/tests/integration/metadata_field/artists/__init__.py +0 -0
  125. audiometa/test/tests/integration/metadata_field/artists/test_deleting.py +68 -0
  126. audiometa/test/tests/integration/metadata_field/artists/test_reading.py +36 -0
  127. audiometa/test/tests/integration/metadata_field/artists/test_writing.py +46 -0
  128. audiometa/test/tests/integration/metadata_field/bpm/__init__.py +0 -0
  129. audiometa/test/tests/integration/metadata_field/bpm/test_deleting.py +75 -0
  130. audiometa/test/tests/integration/metadata_field/bpm/test_reading.py +32 -0
  131. audiometa/test/tests/integration/metadata_field/bpm/test_writing.py +56 -0
  132. audiometa/test/tests/integration/metadata_field/comment/__init__.py +0 -0
  133. audiometa/test/tests/integration/metadata_field/comment/test_deleting.py +68 -0
  134. audiometa/test/tests/integration/metadata_field/comment/test_reading.py +36 -0
  135. audiometa/test/tests/integration/metadata_field/comment/test_writing.py +49 -0
  136. audiometa/test/tests/integration/metadata_field/composer/__init__.py +0 -0
  137. audiometa/test/tests/integration/metadata_field/composer/test_deleting.py +75 -0
  138. audiometa/test/tests/integration/metadata_field/composer/test_reading.py +34 -0
  139. audiometa/test/tests/integration/metadata_field/composer/test_writing.py +41 -0
  140. audiometa/test/tests/integration/metadata_field/copyright/__init__.py +0 -0
  141. audiometa/test/tests/integration/metadata_field/copyright/test_deleting.py +81 -0
  142. audiometa/test/tests/integration/metadata_field/copyright/test_reading.py +35 -0
  143. audiometa/test/tests/integration/metadata_field/copyright/test_writing.py +41 -0
  144. audiometa/test/tests/integration/metadata_field/field_not_supported/__init__.py +0 -0
  145. audiometa/test/tests/integration/metadata_field/field_not_supported/test_deleting.py +56 -0
  146. audiometa/test/tests/integration/metadata_field/field_not_supported/test_reading.py +54 -0
  147. audiometa/test/tests/integration/metadata_field/field_not_supported/test_writing.py +61 -0
  148. audiometa/test/tests/integration/metadata_field/genre/__init__.py +0 -0
  149. audiometa/test/tests/integration/metadata_field/genre/reading/__init__.py +0 -0
  150. audiometa/test/tests/integration/metadata_field/genre/reading/metadata_format/__init__.py +0 -0
  151. audiometa/test/tests/integration/metadata_field/genre/reading/metadata_format/test_id3v1_reading.py +65 -0
  152. audiometa/test/tests/integration/metadata_field/genre/reading/metadata_format/test_id3v2_reading.py +25 -0
  153. audiometa/test/tests/integration/metadata_field/genre/reading/metadata_format/test_riff_reading.py +58 -0
  154. audiometa/test/tests/integration/metadata_field/genre/reading/metadata_format/test_vorbis_reading.py +61 -0
  155. audiometa/test/tests/integration/metadata_field/genre/reading/test_smart_reading.py +191 -0
  156. audiometa/test/tests/integration/metadata_field/genre/test_deleting.py +62 -0
  157. audiometa/test/tests/integration/metadata_field/genre/test_writing.py +64 -0
  158. audiometa/test/tests/integration/metadata_field/language/__init__.py +0 -0
  159. audiometa/test/tests/integration/metadata_field/language/test_deleting.py +75 -0
  160. audiometa/test/tests/integration/metadata_field/language/test_reading.py +39 -0
  161. audiometa/test/tests/integration/metadata_field/language/test_writing.py +43 -0
  162. audiometa/test/tests/integration/metadata_field/lyrics/__init__.py +0 -0
  163. audiometa/test/tests/integration/metadata_field/lyrics/test_deleting.py +129 -0
  164. audiometa/test/tests/integration/metadata_field/lyrics/test_reading.py +57 -0
  165. audiometa/test/tests/integration/metadata_field/lyrics/test_writing.py +59 -0
  166. audiometa/test/tests/integration/metadata_field/publisher/__init__.py +0 -0
  167. audiometa/test/tests/integration/metadata_field/publisher/test_deleting.py +88 -0
  168. audiometa/test/tests/integration/metadata_field/publisher/test_reading.py +32 -0
  169. audiometa/test/tests/integration/metadata_field/publisher/test_writing.py +47 -0
  170. audiometa/test/tests/integration/metadata_field/rating/__init__.py +0 -0
  171. audiometa/test/tests/integration/metadata_field/rating/reading/__init__.py +0 -0
  172. audiometa/test/tests/integration/metadata_field/rating/reading/test_base_100_proportional.py +81 -0
  173. audiometa/test/tests/integration/metadata_field/rating/reading/test_base_255_non_proportional.py +33 -0
  174. audiometa/test/tests/integration/metadata_field/rating/reading/test_base_255_proportional.py +58 -0
  175. audiometa/test/tests/integration/metadata_field/rating/test_deleting.py +117 -0
  176. audiometa/test/tests/integration/metadata_field/rating/test_error_handling.py +137 -0
  177. audiometa/test/tests/integration/metadata_field/rating/writing/__init__.py +0 -0
  178. audiometa/test/tests/integration/metadata_field/rating/writing/metadata_format/__init__.py +0 -0
  179. audiometa/test/tests/integration/metadata_field/rating/writing/metadata_format/test_id3v2.py +77 -0
  180. audiometa/test/tests/integration/metadata_field/rating/writing/metadata_format/test_riff.py +55 -0
  181. audiometa/test/tests/integration/metadata_field/rating/writing/metadata_format/test_vorbis.py +57 -0
  182. audiometa/test/tests/integration/metadata_field/rating/writing/test_comprehensive.py +192 -0
  183. audiometa/test/tests/integration/metadata_field/release_date/__init__.py +0 -0
  184. audiometa/test/tests/integration/metadata_field/release_date/test_deleting.py +74 -0
  185. audiometa/test/tests/integration/metadata_field/release_date/test_error_handling.py +82 -0
  186. audiometa/test/tests/integration/metadata_field/release_date/test_reading.py +59 -0
  187. audiometa/test/tests/integration/metadata_field/release_date/test_writing.py +49 -0
  188. audiometa/test/tests/integration/metadata_field/test_metadata_field_validation.py +134 -0
  189. audiometa/test/tests/integration/metadata_field/title/__init__.py +0 -0
  190. audiometa/test/tests/integration/metadata_field/title/test_deleting.py +73 -0
  191. audiometa/test/tests/integration/metadata_field/title/test_error_handling.py +47 -0
  192. audiometa/test/tests/integration/metadata_field/title/test_reading.py +36 -0
  193. audiometa/test/tests/integration/metadata_field/title/test_writing.py +64 -0
  194. audiometa/test/tests/integration/metadata_field/track_number/__init__.py +0 -0
  195. audiometa/test/tests/integration/metadata_field/track_number/reading/__init__.py +0 -0
  196. audiometa/test/tests/integration/metadata_field/track_number/reading/test_edge_cases.py +43 -0
  197. audiometa/test/tests/integration/metadata_field/track_number/reading/test_metadata_format.py +32 -0
  198. audiometa/test/tests/integration/metadata_field/track_number/test_deleting.py +59 -0
  199. audiometa/test/tests/integration/metadata_field/track_number/test_writing.py +73 -0
  200. audiometa/test/tests/integration/multiple_values/__init__.py +1 -0
  201. audiometa/test/tests/integration/multiple_values/reading/__init__.py +1 -0
  202. audiometa/test/tests/integration/multiple_values/reading/metadata_format/__init__.py +1 -0
  203. audiometa/test/tests/integration/multiple_values/reading/metadata_format/test_id3v1.py +23 -0
  204. audiometa/test/tests/integration/multiple_values/reading/metadata_format/test_id3v2_3.py +92 -0
  205. audiometa/test/tests/integration/multiple_values/reading/metadata_format/test_id3v2_4.py +216 -0
  206. audiometa/test/tests/integration/multiple_values/reading/metadata_format/test_riff.py +84 -0
  207. audiometa/test/tests/integration/multiple_values/reading/metadata_format/test_vorbis.py +169 -0
  208. audiometa/test/tests/integration/multiple_values/reading/test_performance_large_data.py +209 -0
  209. audiometa/test/tests/integration/multiple_values/reading/test_smart_parsing_scenarios.py +198 -0
  210. audiometa/test/tests/integration/multiple_values/reading/test_unicode_handling.py +24 -0
  211. audiometa/test/tests/integration/multiple_values/writing/__init__.py +1 -0
  212. audiometa/test/tests/integration/multiple_values/writing/metadata_format/__init__.py +1 -0
  213. audiometa/test/tests/integration/multiple_values/writing/metadata_format/test_id3v1.py +62 -0
  214. audiometa/test/tests/integration/multiple_values/writing/metadata_format/test_id3v2_3.py +36 -0
  215. audiometa/test/tests/integration/multiple_values/writing/metadata_format/test_id3v2_4.py +34 -0
  216. audiometa/test/tests/integration/multiple_values/writing/metadata_format/test_riff.py +32 -0
  217. audiometa/test/tests/integration/multiple_values/writing/metadata_format/test_vorbis.py +54 -0
  218. audiometa/test/tests/integration/multiple_values/writing/test_error_handling.py +42 -0
  219. audiometa/test/tests/integration/multiple_values/writing/test_large_values.py +98 -0
  220. audiometa/test/tests/integration/reading/__init__.py +1 -0
  221. audiometa/test/tests/integration/reading/test_read_multiple_metadata.py +80 -0
  222. audiometa/test/tests/integration/reading/test_reading_error_handling.py +36 -0
  223. audiometa/test/tests/integration/real_audio_files/__init__.py +0 -0
  224. audiometa/test/tests/integration/real_audio_files/test_reading.py +146 -0
  225. audiometa/test/tests/integration/real_audio_files/test_writing.py +198 -0
  226. audiometa/test/tests/integration/technical_info/__init__.py +0 -0
  227. audiometa/test/tests/integration/technical_info/flac_md5/__init__.py +0 -0
  228. audiometa/test/tests/integration/technical_info/flac_md5/conftest.py +103 -0
  229. audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/__init__.py +0 -0
  230. audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/test_audio_data_corruption.py +21 -0
  231. audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/test_flipped_md5.py +29 -0
  232. audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/test_non_flac_error.py +13 -0
  233. audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/test_partial_md5.py +29 -0
  234. audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/test_random_md5.py +29 -0
  235. audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/test_unset_md5.py +56 -0
  236. audiometa/test/tests/integration/technical_info/flac_md5/test_valid_md5.py +21 -0
  237. audiometa/test/tests/integration/technical_info/test_bitrate.py +38 -0
  238. audiometa/test/tests/integration/technical_info/test_channels.py +38 -0
  239. audiometa/test/tests/integration/technical_info/test_duration_in_sec.py +38 -0
  240. audiometa/test/tests/integration/technical_info/test_sample_rate.py +40 -0
  241. audiometa/test/tests/integration/test_audio_file.py +23 -0
  242. audiometa/test/tests/integration/test_audio_format_readable_after_update_all_metadata_formats.py +95 -0
  243. audiometa/test/tests/integration/writing/__init__.py +0 -0
  244. audiometa/test/tests/integration/writing/test_error_handling.py +44 -0
  245. audiometa/test/tests/integration/writing/test_forced_format.py +224 -0
  246. audiometa/test/tests/integration/writing/test_multiple_format_preservation.py +223 -0
  247. audiometa/test/tests/integration/writing/test_partial_update.py +36 -0
  248. audiometa/test/tests/integration/writing/writing_strategies/__init__.py +0 -0
  249. audiometa/test/tests/integration/writing/writing_strategies/test_cleanup_strategy.py +79 -0
  250. audiometa/test/tests/integration/writing/writing_strategies/test_preserve_strategy.py +76 -0
  251. audiometa/test/tests/integration/writing/writing_strategies/test_sync_strategy.py +215 -0
  252. audiometa/test/tests/integration/writing/writing_strategies/unsupported_fields/__init__.py +0 -0
  253. audiometa/test/tests/integration/writing/writing_strategies/unsupported_fields/test_fail_behavior.py +42 -0
  254. audiometa/test/tests/integration/writing/writing_strategies/unsupported_fields/test_no_writing_on_failure.py +93 -0
  255. audiometa/test/tests/integration/writing/writing_strategies/unsupported_fields/test_strategy_specific.py +99 -0
  256. audiometa/test/tests/unit/__init__.py +0 -0
  257. audiometa/test/tests/unit/audio_file/__init__.py +0 -0
  258. audiometa/test/tests/unit/audio_file/technical_info/__init__.py +0 -0
  259. audiometa/test/tests/unit/audio_file/technical_info/test_bitrate.py +26 -0
  260. audiometa/test/tests/unit/audio_file/technical_info/test_channels.py +31 -0
  261. audiometa/test/tests/unit/audio_file/technical_info/test_duration_in_sec.py +38 -0
  262. audiometa/test/tests/unit/audio_file/technical_info/test_error_handling.py +130 -0
  263. audiometa/test/tests/unit/audio_file/technical_info/test_file_size.py +51 -0
  264. audiometa/test/tests/unit/audio_file/technical_info/test_format_name.py +28 -0
  265. audiometa/test/tests/unit/audio_file/technical_info/test_sample_rate.py +31 -0
  266. audiometa/test/tests/unit/audio_file/test_context_manager.py +30 -0
  267. audiometa/test/tests/unit/audio_file/test_file_validation.py +40 -0
  268. audiometa/test/tests/unit/audio_file/test_operations.py +20 -0
  269. audiometa/test/tests/unit/audio_file/test_path_handling.py +23 -0
  270. audiometa/test/tests/unit/cli/__init__.py +0 -0
  271. audiometa/test/tests/unit/cli/test_expand_file_patterns.py +234 -0
  272. audiometa/test/tests/unit/metadata_managers/__init__.py +0 -0
  273. audiometa/test/tests/unit/metadata_managers/conftest.py +142 -0
  274. audiometa/test/tests/unit/metadata_managers/header_info/__init__.py +0 -0
  275. audiometa/test/tests/unit/metadata_managers/header_info/test_id3v1.py +49 -0
  276. audiometa/test/tests/unit/metadata_managers/header_info/test_id3v2.py +66 -0
  277. audiometa/test/tests/unit/metadata_managers/header_info/test_riff.py +57 -0
  278. audiometa/test/tests/unit/metadata_managers/header_info/test_vorbis.py +53 -0
  279. audiometa/test/tests/unit/metadata_managers/metadata_field/__init__.py +0 -0
  280. audiometa/test/tests/unit/metadata_managers/metadata_field/multiple_values/__init__.py +0 -0
  281. audiometa/test/tests/unit/metadata_managers/metadata_field/multiple_values/reading/__init__.py +0 -0
  282. audiometa/test/tests/unit/metadata_managers/metadata_field/multiple_values/reading/test_smart_parsing.py +186 -0
  283. audiometa/test/tests/unit/metadata_managers/metadata_field/multiple_values/writing/__init__.py +0 -0
  284. audiometa/test/tests/unit/metadata_managers/metadata_field/multiple_values/writing/test_separator_selection.py +142 -0
  285. audiometa/test/tests/unit/metadata_managers/metadata_field/multiple_values/writing/test_value_filtering.py +76 -0
  286. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/__init__.py +0 -0
  287. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/reading/__init__.py +0 -0
  288. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/reading/test_normalization.py +152 -0
  289. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/reading/test_profiles_values.py +23 -0
  290. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/test_rating_validation.py +77 -0
  291. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/writing/__init__.py +0 -0
  292. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/writing/test_validation.py +151 -0
  293. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/writing/test_writing_profiles.py +61 -0
  294. audiometa/test/tests/unit/metadata_managers/metadata_field/test_date_format_validation.py +135 -0
  295. audiometa/test/tests/unit/metadata_managers/metadata_field/test_track_number_validation.py +46 -0
  296. audiometa/test/tests/unit/metadata_managers/metadata_field/test_type_validation_exception.py +22 -0
  297. audiometa/test/tests/unit/metadata_managers/metadata_field/test_validation.py +83 -0
  298. audiometa/test/tests/unit/metadata_managers/test_metadata_format_managers_write_and_read.py +74 -0
  299. audiometa/utils/__init__.py +1 -0
  300. audiometa/utils/id3v1_genre_code_map.py +205 -0
  301. audiometa/utils/metadata_format.py +31 -0
  302. audiometa/utils/metadata_writing_strategy.py +16 -0
  303. audiometa/utils/os_dependencies_checker/__init__.py +24 -0
  304. audiometa/utils/os_dependencies_checker/base.py +62 -0
  305. audiometa/utils/os_dependencies_checker/config.py +51 -0
  306. audiometa/utils/os_dependencies_checker/macos.py +236 -0
  307. audiometa/utils/os_dependencies_checker/ubuntu.py +95 -0
  308. audiometa/utils/os_dependencies_checker/windows.py +227 -0
  309. audiometa/utils/rating_profiles.py +110 -0
  310. audiometa/utils/tool_path_resolver.py +135 -0
  311. audiometa/utils/types.py +82 -0
  312. audiometa/utils/unified_metadata_key.py +81 -0
  313. audiometa_python-0.2.2.dist-info/METADATA +1464 -0
  314. audiometa_python-0.2.2.dist-info/RECORD +318 -0
  315. audiometa_python-0.2.2.dist-info/WHEEL +5 -0
  316. audiometa_python-0.2.2.dist-info/entry_points.txt +2 -0
  317. audiometa_python-0.2.2.dist-info/licenses/LICENSE +202 -0
  318. audiometa_python-0.2.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,25 @@
1
+ """RIFF manager and constants."""
2
+
3
+ from ._riff_constants import (
4
+ RIFF_AUDIO_FORMAT_IEEE_FLOAT,
5
+ RIFF_CHUNK_ID_SIZE,
6
+ RIFF_FORMAT_CHUNK_MIN_SIZE,
7
+ RIFF_HEADER_SIZE,
8
+ RIFF_INFO_CHUNK_MIN_SIZE,
9
+ RIFF_MIN_DATA_SIZE_FOR_ID3V2,
10
+ RIFF_MIN_VERSION_LENGTH,
11
+ RIFF_WAVE_FORMAT_POSITION,
12
+ )
13
+ from ._RiffManager import _RiffManager
14
+
15
+ __all__ = [
16
+ "_RiffManager",
17
+ "RIFF_AUDIO_FORMAT_IEEE_FLOAT",
18
+ "RIFF_CHUNK_ID_SIZE",
19
+ "RIFF_FORMAT_CHUNK_MIN_SIZE",
20
+ "RIFF_HEADER_SIZE",
21
+ "RIFF_INFO_CHUNK_MIN_SIZE",
22
+ "RIFF_MIN_DATA_SIZE_FOR_ID3V2",
23
+ "RIFF_MIN_VERSION_LENGTH",
24
+ "RIFF_WAVE_FORMAT_POSITION",
25
+ ]
@@ -0,0 +1,10 @@
1
+ """Constants for RIFF format."""
2
+
3
+ RIFF_HEADER_SIZE = 12
4
+ RIFF_CHUNK_ID_SIZE = 4
5
+ RIFF_WAVE_FORMAT_POSITION = 8
6
+ RIFF_MIN_DATA_SIZE_FOR_ID3V2 = 10
7
+ RIFF_INFO_CHUNK_MIN_SIZE = 16
8
+ RIFF_MIN_VERSION_LENGTH = 3
9
+ RIFF_AUDIO_FORMAT_IEEE_FLOAT = 3
10
+ RIFF_FORMAT_CHUNK_MIN_SIZE = 16
@@ -0,0 +1,525 @@
1
+ import contextlib
2
+ import struct
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING, Any, TypeVar, cast
5
+
6
+ if TYPE_CHECKING:
7
+ from ...._audio_file import _AudioFile
8
+ from ....exceptions import FileCorruptedError, InvalidRatingValueError, MetadataFieldNotSupportedByMetadataFormatError
9
+ from ....utils.rating_profiles import RatingWriteProfile
10
+ from ....utils.tool_path_resolver import get_tool_path
11
+ from ....utils.types import RawMetadataDict, RawMetadataKey, UnifiedMetadata, UnifiedMetadataValue
12
+ from ....utils.unified_metadata_key import UnifiedMetadataKey
13
+ from .._RatingSupportingMetadataManager import _RatingSupportingMetadataManager
14
+ from ._vorbis_constants import VORBIS_BLOCK_HEADER_SIZE, VORBIS_COMMENT_BLOCK_TYPE, VORBIS_ID3V2_HEADER_SIZE
15
+
16
+ T = TypeVar("T", str, int)
17
+
18
+
19
+ class _VorbisManager(_RatingSupportingMetadataManager):
20
+ """Manages Vorbis comments for audio files.
21
+
22
+ Vorbis comments are used to store metadata in audio files, primarily in FLAC format.
23
+ (OGG file support is planned but not yet implemented.)
24
+ They are more flexible and extensible compared to ID3 tags, allowing for a wide range of metadata fields.
25
+
26
+ Vorbis comments are key-value pairs, where the key is a field name and the value is the corresponding metadata.
27
+ Common fields are defined in the VorbisKey enum class, which includes standardized keys for metadata like
28
+ title, artist, album, genre, rating, and more.
29
+
30
+ Implementation Details:
31
+ - Reading: Custom FLAC parsing to preserve original Vorbis comment key casing
32
+ - Writing: External metaflac tool to maintain proper key casing per Vorbis specification
33
+ - The Vorbis specification recommends uppercase keys, which metaflac preserves during writing
34
+ - Custom parsing for reading avoids mutagen's lowercase conversion behavior
35
+
36
+ Compatible Extensions:
37
+ - FLAC: Fully supports Vorbis comments.
38
+
39
+ TODO: OGG file support is planned but not yet implemented.
40
+ """
41
+
42
+ class VorbisKey(RawMetadataKey):
43
+ # Standard
44
+ TITLE = "TITLE"
45
+ ARTIST = "ARTIST"
46
+ ALBUM = "ALBUM"
47
+ ALBUM_ARTISTS = "ALBUMARTIST"
48
+ GENRES_NAMES = "GENRE"
49
+ DATE = "DATE" # Creation/Release date
50
+ TRACK_NUMBER = "TRACKNUMBER"
51
+ COMMENT = "COMMENT"
52
+ PERFORMER = "PERFORMER"
53
+ COPYRIGHT = "COPYRIGHT"
54
+ LICENSE = "LICENSE"
55
+ ORGANIZATION = "ORGANIZATION" # Label or organization
56
+ DESCRIPTION = "DESCRIPTION"
57
+ LOCATION = "LOCATION" # Recording location
58
+ CONTACT = "CONTACT" # Contact information
59
+ ISRC = "ISRC" # International Standard Recording Code
60
+
61
+ # Non-standard
62
+ LANGUAGE = "LANGUAGE"
63
+ BPM = "BPM"
64
+ COMPOSERS = "COMPOSER"
65
+ ENCODED_BY = "ENCODEDBY" # Encoder software
66
+ RATING = "RATING"
67
+ RATING_TRAKTOR = "RATING WMP" # Traktor rating
68
+ UNSYNCHRONIZED_LYRICS = "LYRICS" # Not standard
69
+ REPLAYGAIN = "REPLAYGAIN"
70
+ PUBLISHER = "PUBLISHER"
71
+
72
+ def __init__(self, audio_file: "_AudioFile", normalized_rating_max_value: int | None = None):
73
+ metadata_keys_direct_map_read = {
74
+ UnifiedMetadataKey.TITLE: self.VorbisKey.TITLE,
75
+ UnifiedMetadataKey.ARTISTS: self.VorbisKey.ARTIST,
76
+ UnifiedMetadataKey.ALBUM: self.VorbisKey.ALBUM,
77
+ UnifiedMetadataKey.ALBUM_ARTISTS: self.VorbisKey.ALBUM_ARTISTS,
78
+ UnifiedMetadataKey.GENRES_NAMES: self.VorbisKey.GENRES_NAMES,
79
+ UnifiedMetadataKey.RATING: None,
80
+ UnifiedMetadataKey.LANGUAGE: self.VorbisKey.LANGUAGE,
81
+ UnifiedMetadataKey.RELEASE_DATE: self.VorbisKey.DATE,
82
+ UnifiedMetadataKey.TRACK_NUMBER: self.VorbisKey.TRACK_NUMBER,
83
+ UnifiedMetadataKey.BPM: self.VorbisKey.BPM,
84
+ UnifiedMetadataKey.COMPOSERS: self.VorbisKey.COMPOSERS,
85
+ UnifiedMetadataKey.COPYRIGHT: self.VorbisKey.COPYRIGHT,
86
+ UnifiedMetadataKey.COMMENT: self.VorbisKey.COMMENT,
87
+ UnifiedMetadataKey.UNSYNCHRONIZED_LYRICS: self.VorbisKey.UNSYNCHRONIZED_LYRICS,
88
+ UnifiedMetadataKey.REPLAYGAIN: self.VorbisKey.REPLAYGAIN,
89
+ UnifiedMetadataKey.PUBLISHER: self.VorbisKey.PUBLISHER,
90
+ }
91
+ metadata_keys_direct_map_write = {
92
+ UnifiedMetadataKey.TITLE: self.VorbisKey.TITLE,
93
+ UnifiedMetadataKey.ARTISTS: self.VorbisKey.ARTIST,
94
+ UnifiedMetadataKey.ALBUM: self.VorbisKey.ALBUM,
95
+ UnifiedMetadataKey.ALBUM_ARTISTS: self.VorbisKey.ALBUM_ARTISTS,
96
+ UnifiedMetadataKey.GENRES_NAMES: self.VorbisKey.GENRES_NAMES,
97
+ UnifiedMetadataKey.RATING: None,
98
+ UnifiedMetadataKey.LANGUAGE: self.VorbisKey.LANGUAGE,
99
+ UnifiedMetadataKey.RELEASE_DATE: self.VorbisKey.DATE,
100
+ UnifiedMetadataKey.TRACK_NUMBER: self.VorbisKey.TRACK_NUMBER,
101
+ UnifiedMetadataKey.BPM: self.VorbisKey.BPM,
102
+ UnifiedMetadataKey.COMPOSERS: self.VorbisKey.COMPOSERS,
103
+ UnifiedMetadataKey.COPYRIGHT: self.VorbisKey.COPYRIGHT,
104
+ UnifiedMetadataKey.COMMENT: self.VorbisKey.COMMENT,
105
+ UnifiedMetadataKey.UNSYNCHRONIZED_LYRICS: self.VorbisKey.UNSYNCHRONIZED_LYRICS,
106
+ UnifiedMetadataKey.REPLAYGAIN: self.VorbisKey.REPLAYGAIN,
107
+ UnifiedMetadataKey.PUBLISHER: self.VorbisKey.PUBLISHER,
108
+ }
109
+ super().__init__(
110
+ audio_file=audio_file,
111
+ metadata_keys_direct_map_read=cast(
112
+ dict[UnifiedMetadataKey, RawMetadataKey | None], metadata_keys_direct_map_read
113
+ ),
114
+ metadata_keys_direct_map_write=cast(
115
+ dict[UnifiedMetadataKey, RawMetadataKey | None], metadata_keys_direct_map_write
116
+ ),
117
+ rating_write_profile=RatingWriteProfile.BASE_100_PROPORTIONAL,
118
+ normalized_rating_max_value=normalized_rating_max_value,
119
+ )
120
+
121
+ def _extract_mutagen_metadata(self) -> RawMetadataDict:
122
+ """Read Vorbis comments from a FLAC file.
123
+
124
+ This is a custom implementation for extracting Vorbis comments because:
125
+ - Mutagen does not preserve original key case
126
+ Returns a dict: {key: [values]}.
127
+ """
128
+ comments: dict[str, list[str]] = {}
129
+ with Path(self.audio_file.file_path).open("rb") as f:
130
+ # --- Step 1: Skip ID3v2 tags if present, then find FLAC header ---
131
+ header = f.read(4)
132
+ if header in (b"ID3\x03", b"ID3\x04"):
133
+ # ID3v2 tag present, skip it
134
+ f.seek(0) # Reset to beginning
135
+ # Read ID3v2 header to get tag size
136
+ id3_header = f.read(VORBIS_ID3V2_HEADER_SIZE)
137
+ if len(id3_header) >= VORBIS_ID3V2_HEADER_SIZE:
138
+ # ID3v2 tag size is stored in bytes 6-9 (syncsafe integer)
139
+ tag_size = (
140
+ ((id3_header[6] & 0x7F) << 21)
141
+ | ((id3_header[7] & 0x7F) << 14)
142
+ | ((id3_header[8] & 0x7F) << 7)
143
+ | (id3_header[9] & 0x7F)
144
+ )
145
+ # Skip the ID3v2 tag
146
+ f.seek(tag_size + VORBIS_ID3V2_HEADER_SIZE)
147
+ # Now read the FLAC header
148
+ header = f.read(4)
149
+
150
+ if header != b"fLaC":
151
+ msg = "Not a valid FLAC file"
152
+ raise ValueError(msg)
153
+
154
+ # --- Step 2: Read metadata blocks ---
155
+ is_last = False
156
+ while not is_last:
157
+ block_header = f.read(VORBIS_BLOCK_HEADER_SIZE)
158
+ if len(block_header) < VORBIS_BLOCK_HEADER_SIZE:
159
+ break
160
+ is_last = bool(block_header[0] & 0x80)
161
+ block_type = block_header[0] & 0x7F
162
+ block_size = struct.unpack(">I", b"\x00" + block_header[1:])[0]
163
+ data = f.read(block_size)
164
+
165
+ # --- Step 3: Look for VORBIS_COMMENT block ---
166
+ if block_type == VORBIS_COMMENT_BLOCK_TYPE: # VORBIS_COMMENT
167
+ offset = 0
168
+ # Vendor length (32-bit LE)
169
+ vendor_len = struct.unpack("<I", data[offset : offset + 4])[0]
170
+ offset += 4 + vendor_len
171
+ # Number of comments
172
+ num_comments = struct.unpack("<I", data[offset : offset + 4])[0]
173
+ offset += 4
174
+
175
+ for _ in range(num_comments):
176
+ comment_len = struct.unpack("<I", data[offset : offset + 4])[0]
177
+ offset += 4
178
+ comment_bytes = data[offset : offset + comment_len]
179
+ offset += comment_len
180
+ comment_str = comment_bytes.decode("utf-8", errors="replace")
181
+
182
+ # Split key=value at first '='
183
+ if "=" not in comment_str:
184
+ continue
185
+ key, value = comment_str.split("=", 1)
186
+ # Preserve original case
187
+ comments.setdefault(key, []).append(value)
188
+ break
189
+
190
+ return cast(RawMetadataDict, comments)
191
+
192
+ def _convert_raw_mutagen_metadata_to_dict_with_potential_duplicate_keys(
193
+ self,
194
+ raw_mutagen_metadata: dict,
195
+ ) -> RawMetadataDict:
196
+ # _extract_mutagen_metadata already returns metadata with list values
197
+ return raw_mutagen_metadata
198
+
199
+ def _extract_raw_clean_metadata_uppercase_keys_from_file(self) -> None:
200
+ if self.raw_clean_metadata is None:
201
+ self.raw_clean_metadata = self._extract_cleaned_raw_metadata_from_file()
202
+
203
+ # Merge case variants of keys (e.g., "ARTIST" and "artist" -> "ARTIST")
204
+ # Vorbis comments preserve original key case, so we need to merge them
205
+ # Use a temporary dict with string keys for merging, then convert to RawMetadataDict
206
+ # Use Any for values since we're merging different list types and will convert back
207
+ temp_dict: dict[str, list[Any]] = {}
208
+ for key, values in self.raw_clean_metadata.items():
209
+ if values is None:
210
+ continue
211
+ uppercase_key = str(key).upper()
212
+ # Merge values from all case variants
213
+ if isinstance(values, list):
214
+ # values is guaranteed to be a list here (not None)
215
+ if uppercase_key not in temp_dict:
216
+ # First occurrence: use the list as-is (preserves type)
217
+ temp_dict[uppercase_key] = list(values)
218
+ else:
219
+ # Subsequent occurrence: merge while avoiding duplicates
220
+ existing_list = temp_dict[uppercase_key]
221
+ for val in values:
222
+ if val not in existing_list:
223
+ existing_list.append(val)
224
+
225
+ # Convert to RawMetadataDict format
226
+ # Since RawMetadataKey is str, Enum, we can use string keys directly at runtime
227
+ # Use cast to satisfy type checker since RawMetadataKey is str, Enum
228
+ result_dict: dict[str | RawMetadataKey, list[str] | list[int] | list[float]] = {}
229
+ for key_str, values_list in temp_dict.items():
230
+ # Try to find matching enum member, otherwise use string as key
231
+ # RawMetadataKey is str, Enum so string keys work at runtime
232
+ final_key: RawMetadataKey | str = key_str
233
+ for enum_class in RawMetadataKey.__subclasses__():
234
+ for member in enum_class.__members__.values():
235
+ if str(member.value).upper() == key_str:
236
+ final_key = member
237
+ break
238
+ if isinstance(final_key, RawMetadataKey):
239
+ break
240
+ # values_list is guaranteed to be a list (not empty, not None)
241
+ result_dict[final_key] = values_list
242
+
243
+ # Cast to RawMetadataDict since RawMetadataKey is str, Enum and string keys work
244
+ self.raw_clean_metadata_uppercase_keys = cast(RawMetadataDict, result_dict)
245
+
246
+ def _get_raw_rating_by_traktor_or_not(self, raw_clean_metadata: RawMetadataDict) -> tuple[int | None, bool]:
247
+ if self.VorbisKey.RATING in raw_clean_metadata:
248
+ rating_list = raw_clean_metadata[self.VorbisKey.RATING]
249
+ if rating_list and len(rating_list) > 0 and rating_list[0] is not None:
250
+ return int(rating_list[0]), False
251
+
252
+ if self.VorbisKey.RATING_TRAKTOR in raw_clean_metadata:
253
+ rating_list = raw_clean_metadata[self.VorbisKey.RATING_TRAKTOR]
254
+ if rating_list and len(rating_list) > 0 and rating_list[0] is not None:
255
+ return int(rating_list[0]), True
256
+
257
+ return None, False
258
+
259
+ def _update_formatted_value_in_raw_mutagen_metadata(
260
+ self,
261
+ raw_mutagen_metadata: dict,
262
+ raw_metadata_key: RawMetadataKey,
263
+ app_metadata_value: UnifiedMetadataValue,
264
+ ) -> None:
265
+ if app_metadata_value is not None:
266
+ if isinstance(app_metadata_value, list):
267
+ # For multi-value fields, keep as separate entries
268
+ raw_mutagen_metadata[raw_metadata_key] = [str(v) for v in app_metadata_value]
269
+ # Convert BPM to string for Vorbis comments
270
+ elif raw_metadata_key == self.VorbisKey.BPM:
271
+ raw_mutagen_metadata[raw_metadata_key] = [str(app_metadata_value)]
272
+ else:
273
+ raw_mutagen_metadata[raw_metadata_key] = [str(app_metadata_value)]
274
+ elif raw_metadata_key in raw_mutagen_metadata:
275
+ del raw_mutagen_metadata[raw_metadata_key]
276
+
277
+ def update_metadata(self, unified_metadata: UnifiedMetadata) -> None:
278
+ """Update Vorbis metadata in FLAC files using external metaflac tool.
279
+
280
+ This method uses the metaflac external command-line tool instead of Python libraries
281
+ to ensure proper Vorbis specification compliance and prevent file corruption.
282
+
283
+ Key Features:
284
+ - **Uppercase Key Casing**: Preserves proper Vorbis key casing (TITLE, ARTIST, etc.)
285
+ unlike mutagen which converts to lowercase
286
+ - **Multi-Value Support**: Creates separate tag entries for list values
287
+ - **File Integrity**: Prevents corruption that occurs with some Python libraries
288
+ - **Deletion Support**: Properly removes tags when None values are passed
289
+
290
+ Multi-Value Behavior:
291
+ - List values create separate tag entries (Vorbis specification compliant)
292
+ - Example: ["Artist One", "Artist Two"] creates:
293
+ * ARTIST=Artist One
294
+ * ARTIST=Artist Two
295
+ - NOT: ARTIST=Artist One;Artist Two (semicolon-joined)
296
+
297
+ External Tool Requirements:
298
+ - Requires 'metaflac' command-line tool to be installed
299
+ - Falls back to FileCorruptedError if metaflac is not available
300
+
301
+ Args:
302
+ unified_metadata: Dictionary of metadata to write/update
303
+ Use None values to delete specific fields
304
+
305
+ Raises:
306
+ MetadataFieldNotSupportedByMetadataFormatError: If field not supported
307
+ FileCorruptedError: If metaflac tool fails or is not found
308
+ """
309
+ if not self.metadata_keys_direct_map_write:
310
+ msg = "This format does not support metadata modification"
311
+ raise MetadataFieldNotSupportedByMetadataFormatError(msg)
312
+
313
+ self._validate_and_process_rating(unified_metadata)
314
+
315
+ # Get current metadata
316
+ current_metadata = self._extract_mutagen_metadata()
317
+
318
+ # Update metadata dict
319
+ for unified_metadata_key in list(unified_metadata.keys()):
320
+ app_metadata_value = unified_metadata[unified_metadata_key]
321
+
322
+ # Filter out empty values for list-type metadata before processing
323
+ if isinstance(app_metadata_value, list):
324
+ app_metadata_value = self._filter_valid_values(cast(list[str | None], app_metadata_value))
325
+ # If all values were filtered out, set to None to remove the field
326
+ if not app_metadata_value:
327
+ app_metadata_value = None
328
+
329
+ if unified_metadata_key not in self.metadata_keys_direct_map_write:
330
+ metadata_format_name = self._get_formatted_metadata_format_name()
331
+ msg = f"{unified_metadata_key} metadata not supported by {metadata_format_name} format"
332
+ raise MetadataFieldNotSupportedByMetadataFormatError(msg)
333
+ raw_metadata_key = self.metadata_keys_direct_map_write[unified_metadata_key]
334
+ if raw_metadata_key:
335
+ self._update_formatted_value_in_raw_mutagen_metadata(
336
+ raw_mutagen_metadata=current_metadata,
337
+ raw_metadata_key=raw_metadata_key,
338
+ app_metadata_value=app_metadata_value,
339
+ )
340
+ else:
341
+ self._update_undirectly_mapped_metadata(
342
+ raw_mutagen_metadata=current_metadata,
343
+ app_metadata_value=app_metadata_value,
344
+ unified_metadata_key=unified_metadata_key,
345
+ )
346
+
347
+ # Write metadata using metaflac
348
+ self._write_metadata_with_metaflac(current_metadata)
349
+
350
+ # Clear cached metadata to ensure subsequent reads reflect the changes
351
+ self.raw_clean_metadata = None
352
+ self.raw_clean_metadata_uppercase_keys = None
353
+
354
+ def _write_metadata_with_metaflac(self, metadata: dict) -> None:
355
+ """Write metadata to the FLAC file using metaflac external tool."""
356
+ try:
357
+ import subprocess
358
+
359
+ # Map unified metadata keys to metaflac tag names (uppercase)
360
+ key_mapping = {
361
+ "TITLE": "TITLE",
362
+ "ARTIST": "ARTIST",
363
+ "ALBUM": "ALBUM",
364
+ "DATE": "DATE",
365
+ "GENRE": "GENRE",
366
+ "COMMENT": "COMMENT",
367
+ "TRACKNUMBER": "TRACKNUMBER",
368
+ "BPM": "BPM",
369
+ "COMPOSER": "COMPOSER",
370
+ "COPYRIGHT": "COPYRIGHT",
371
+ "LYRICS": "LYRICS",
372
+ "LANGUAGE": "LANGUAGE",
373
+ "RATING": "RATING",
374
+ "ALBUMARTIST": "ALBUMARTIST",
375
+ "MOOD": "MOOD",
376
+ "KEY": "KEY",
377
+ "ENCODER": "ENCODER",
378
+ "URL": "URL",
379
+ "ISRC": "ISRC",
380
+ "PUBLISHER": "PUBLISHER",
381
+ }
382
+
383
+ # Get all possible tags that we might need to remove
384
+ # This includes both tags in the metadata dict and all possible tags
385
+ tags_to_remove = set()
386
+
387
+ # Add tags that are in the metadata dict (these are being updated/deleted)
388
+ for key in metadata:
389
+ if key in key_mapping:
390
+ tags_to_remove.add(key_mapping[key])
391
+
392
+ # Also remove all possible tags to ensure clean state
393
+ # This is necessary because we might be deleting tags that aren't in the metadata dict
394
+ for metaflac_key in key_mapping.values():
395
+ tags_to_remove.add(metaflac_key)
396
+
397
+ # Remove all existing tags
398
+ if tags_to_remove:
399
+ for metaflac_key in tags_to_remove:
400
+ with contextlib.suppress(subprocess.CalledProcessError):
401
+ subprocess.run(
402
+ [get_tool_path("metaflac"), "--remove-tag=" + metaflac_key, self.audio_file.file_path],
403
+ check=True,
404
+ capture_output=True,
405
+ )
406
+
407
+ # Then, add new tags for non-None values
408
+ set_cmd = [get_tool_path("metaflac")]
409
+ for key, values in metadata.items():
410
+ if key in key_mapping and values is not None:
411
+ metaflac_key = key_mapping[key]
412
+
413
+ # Handle list values by creating separate tag entries
414
+ if isinstance(values, list):
415
+ for value in values:
416
+ if value: # Only add non-empty values
417
+ set_cmd.extend(["--set-tag", f"{metaflac_key}={value}"])
418
+ else:
419
+ value = str(values)
420
+ if value: # Only add non-empty values
421
+ set_cmd.extend(["--set-tag", f"{metaflac_key}={value}"])
422
+
423
+ # Add file path and execute
424
+ if len(set_cmd) > 1: # Only if we have tags to set
425
+ set_cmd.append(self.audio_file.file_path)
426
+ subprocess.run(set_cmd, check=True, capture_output=True)
427
+
428
+ except subprocess.CalledProcessError as e:
429
+ msg = f"Failed to write metadata with metaflac: {e}"
430
+ raise FileCorruptedError(msg) from e
431
+ except FileNotFoundError as e:
432
+ msg = "metaflac tool not found. Please install it to write Vorbis metadata to FLAC files."
433
+ raise FileCorruptedError(msg) from e
434
+
435
+ def get_header_info(self) -> dict:
436
+ try:
437
+ # Use custom parsing to get file information
438
+ metadata = self._extract_mutagen_metadata()
439
+ comment_count = sum(len(values) for values in metadata.values() if values)
440
+
441
+ info = {
442
+ "present": True,
443
+ "vendor_string": None, # Vendor string not available via custom parsing
444
+ "comment_count": comment_count,
445
+ "block_size": 4096, # Default Vorbis comment block size
446
+ }
447
+ except Exception:
448
+ return {"present": False, "vendor_string": None, "comment_count": 0, "block_size": 0}
449
+ else:
450
+ return info
451
+
452
+ def get_raw_metadata_info(self) -> dict:
453
+ try:
454
+ # Use custom parsing to get metadata
455
+ metadata = self._extract_mutagen_metadata()
456
+
457
+ return {
458
+ "raw_data": None, # Custom parsing handles this internally
459
+ "parsed_fields": {},
460
+ "frames": {},
461
+ "comments": dict(metadata), # Convert to regular dict
462
+ "chunk_structure": {},
463
+ }
464
+ except Exception:
465
+ return {"raw_data": None, "parsed_fields": {}, "frames": {}, "comments": {}, "chunk_structure": {}}
466
+
467
+ def delete_metadata(self) -> bool:
468
+ """Delete all metadata from the FLAC file by removing the VORBIS_COMMENT block."""
469
+ import subprocess
470
+
471
+ try:
472
+ # Remove all VORBIS_COMMENT blocks from the FLAC file
473
+ subprocess.run(
474
+ [get_tool_path("metaflac"), "--remove", "--block-type=VORBIS_COMMENT", self.audio_file.file_path],
475
+ capture_output=True,
476
+ text=True,
477
+ check=True,
478
+ )
479
+ except (subprocess.CalledProcessError, FileNotFoundError):
480
+ return False
481
+ else:
482
+ return True
483
+
484
+ def _get_undirectly_mapped_metadata_value_other_than_rating_from_raw_clean_metadata(
485
+ self, _raw_clean_metadata: RawMetadataDict, unified_metadata_key: UnifiedMetadataKey
486
+ ) -> UnifiedMetadataValue:
487
+ msg = f"Metadata key not handled: {unified_metadata_key}"
488
+ raise MetadataFieldNotSupportedByMetadataFormatError(msg)
489
+
490
+ def _update_undirectly_mapped_metadata(
491
+ self,
492
+ raw_mutagen_metadata: dict,
493
+ app_metadata_value: UnifiedMetadataValue,
494
+ unified_metadata_key: UnifiedMetadataKey,
495
+ ) -> None:
496
+ if unified_metadata_key == UnifiedMetadataKey.RATING:
497
+ if app_metadata_value is not None:
498
+ if self.normalized_rating_max_value is None:
499
+ # When no normalization, write value as-is (already validated by parent class)
500
+ if isinstance(app_metadata_value, int | float):
501
+ raw_mutagen_metadata[self.VorbisKey.RATING] = [str(int(app_metadata_value))]
502
+ else:
503
+ raw_mutagen_metadata[self.VorbisKey.RATING] = [str(app_metadata_value)]
504
+ else:
505
+ try:
506
+ # Preserve float values to support half-star ratings (consistent with classic star rating
507
+ # systems)
508
+ if isinstance(app_metadata_value, int | float):
509
+ normalized_rating = float(app_metadata_value)
510
+ else:
511
+ normalized_rating = float(str(app_metadata_value))
512
+ file_rating = self._convert_normalized_rating_to_file_rating(normalized_rating)
513
+ raw_mutagen_metadata[self.VorbisKey.RATING] = [str(file_rating)]
514
+ except (TypeError, ValueError) as e:
515
+ msg = f"Invalid rating value: {app_metadata_value}. Expected a numeric value."
516
+ raise InvalidRatingValueError(msg) from e
517
+ else:
518
+ # Remove rating
519
+ if self.VorbisKey.RATING in raw_mutagen_metadata:
520
+ del raw_mutagen_metadata[self.VorbisKey.RATING]
521
+ if self.VorbisKey.RATING_TRAKTOR in raw_mutagen_metadata:
522
+ del raw_mutagen_metadata[self.VorbisKey.RATING_TRAKTOR]
523
+ else:
524
+ msg = f"Metadata key not handled: {unified_metadata_key}"
525
+ raise MetadataFieldNotSupportedByMetadataFormatError(msg)
@@ -0,0 +1,17 @@
1
+ """Vorbis manager and constants."""
2
+
3
+ from ._vorbis_constants import (
4
+ VORBIS_BLOCK_HEADER_SIZE,
5
+ VORBIS_CHUNK_ID_SIZE,
6
+ VORBIS_COMMENT_BLOCK_TYPE,
7
+ VORBIS_ID3V2_HEADER_SIZE,
8
+ )
9
+ from ._VorbisManager import _VorbisManager
10
+
11
+ __all__ = [
12
+ "_VorbisManager",
13
+ "VORBIS_BLOCK_HEADER_SIZE",
14
+ "VORBIS_CHUNK_ID_SIZE",
15
+ "VORBIS_COMMENT_BLOCK_TYPE",
16
+ "VORBIS_ID3V2_HEADER_SIZE",
17
+ ]
@@ -0,0 +1,6 @@
1
+ """Constants for Vorbis format."""
2
+
3
+ VORBIS_ID3V2_HEADER_SIZE = 10
4
+ VORBIS_BLOCK_HEADER_SIZE = 4
5
+ VORBIS_COMMENT_BLOCK_TYPE = 4
6
+ VORBIS_CHUNK_ID_SIZE = 4