audiometa-python 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (352) hide show
  1. audiometa/__init__.py +1297 -0
  2. audiometa/__main__.py +6 -0
  3. audiometa/_audio_file.py +607 -0
  4. audiometa/cli.py +476 -0
  5. audiometa/exceptions.py +167 -0
  6. audiometa/manager/_MetadataManager.py +768 -0
  7. audiometa/manager/__init__.py +1 -0
  8. audiometa/manager/_rating_supporting/_RatingSupportingMetadataManager.py +250 -0
  9. audiometa/manager/_rating_supporting/__init__.py +1 -0
  10. audiometa/manager/_rating_supporting/id3v2/_Id3v2Manager.py +1032 -0
  11. audiometa/manager/_rating_supporting/id3v2/__init__.py +25 -0
  12. audiometa/manager/_rating_supporting/id3v2/_id3v2_constants.py +11 -0
  13. audiometa/manager/_rating_supporting/riff/_RiffManager.py +1002 -0
  14. audiometa/manager/_rating_supporting/riff/__init__.py +25 -0
  15. audiometa/manager/_rating_supporting/riff/_riff_constants.py +17 -0
  16. audiometa/manager/_rating_supporting/vorbis/_VorbisManager.py +542 -0
  17. audiometa/manager/_rating_supporting/vorbis/__init__.py +17 -0
  18. audiometa/manager/_rating_supporting/vorbis/_vorbis_constants.py +6 -0
  19. audiometa/manager/id3v1/_Id3v1Manager.py +512 -0
  20. audiometa/manager/id3v1/__init__.py +1 -0
  21. audiometa/manager/id3v1/_constants.py +8 -0
  22. audiometa/manager/id3v1/id3v1_raw_metadata.py +242 -0
  23. audiometa/manager/id3v1/id3v1_raw_metadata_key.py +13 -0
  24. audiometa/test/__init__.py +1 -0
  25. audiometa/test/assets/create_test_files.py +72 -0
  26. audiometa/test/helpers/__init__.py +51 -0
  27. audiometa/test/helpers/common/__init__.py +6 -0
  28. audiometa/test/helpers/common/audio_file_creator.py +68 -0
  29. audiometa/test/helpers/common/external_tool_runner.py +74 -0
  30. audiometa/test/helpers/id3v1/__init__.py +8 -0
  31. audiometa/test/helpers/id3v1/id3v1_header_verifier.py +18 -0
  32. audiometa/test/helpers/id3v1/id3v1_metadata_deleter.py +37 -0
  33. audiometa/test/helpers/id3v1/id3v1_metadata_getter.py +61 -0
  34. audiometa/test/helpers/id3v1/id3v1_metadata_setter.py +82 -0
  35. audiometa/test/helpers/id3v2/__init__.py +28 -0
  36. audiometa/test/helpers/id3v2/id3v2_frame_manual_creator.py +349 -0
  37. audiometa/test/helpers/id3v2/id3v2_header_verifier.py +38 -0
  38. audiometa/test/helpers/id3v2/id3v2_metadata_deleter.py +56 -0
  39. audiometa/test/helpers/id3v2/id3v2_metadata_getter.py +189 -0
  40. audiometa/test/helpers/id3v2/id3v2_metadata_setter.py +506 -0
  41. audiometa/test/helpers/riff/__init__.py +8 -0
  42. audiometa/test/helpers/riff/riff_header_verifier.py +85 -0
  43. audiometa/test/helpers/riff/riff_manual_metadata_creator.py +298 -0
  44. audiometa/test/helpers/riff/riff_metadata_deleter.py +56 -0
  45. audiometa/test/helpers/riff/riff_metadata_getter.py +219 -0
  46. audiometa/test/helpers/riff/riff_metadata_setter.py +374 -0
  47. audiometa/test/helpers/scripts/__init__.py +0 -0
  48. audiometa/test/helpers/technical_info_inspector.py +115 -0
  49. audiometa/test/helpers/temp_file_with_metadata.py +82 -0
  50. audiometa/test/helpers/vorbis/__init__.py +8 -0
  51. audiometa/test/helpers/vorbis/vorbis_header_verifier.py +31 -0
  52. audiometa/test/helpers/vorbis/vorbis_metadata_deleter.py +49 -0
  53. audiometa/test/helpers/vorbis/vorbis_metadata_getter.py +67 -0
  54. audiometa/test/helpers/vorbis/vorbis_metadata_setter.py +221 -0
  55. audiometa/test/tests/__init__.py +0 -0
  56. audiometa/test/tests/conftest.py +276 -0
  57. audiometa/test/tests/e2e/__init__.py +0 -0
  58. audiometa/test/tests/e2e/cli/__init__.py +0 -0
  59. audiometa/test/tests/e2e/cli/error_handling/__init__.py +1 -0
  60. audiometa/test/tests/e2e/cli/error_handling/test_command_structure_errors.py +77 -0
  61. audiometa/test/tests/e2e/cli/error_handling/test_file_access_errors.py +130 -0
  62. audiometa/test/tests/e2e/cli/error_handling/test_format_output_errors.py +118 -0
  63. audiometa/test/tests/e2e/cli/error_handling/test_input_validation_errors.py +172 -0
  64. audiometa/test/tests/e2e/cli/error_handling/test_missing_fields_validation.py +49 -0
  65. audiometa/test/tests/e2e/cli/error_handling/test_multiple_files_errors.py +160 -0
  66. audiometa/test/tests/e2e/cli/error_handling/test_rating_validation.py +90 -0
  67. audiometa/test/tests/e2e/cli/error_handling/test_year_validation.py +51 -0
  68. audiometa/test/tests/e2e/cli/read/__init__.py +0 -0
  69. audiometa/test/tests/e2e/cli/read/test_basic.py +58 -0
  70. audiometa/test/tests/e2e/cli/read/test_comprehensive.py +240 -0
  71. audiometa/test/tests/e2e/cli/read/test_formats.py +55 -0
  72. audiometa/test/tests/e2e/cli/read/test_metadata_content.py +164 -0
  73. audiometa/test/tests/e2e/cli/read/test_multiple_files.py +149 -0
  74. audiometa/test/tests/e2e/cli/read/test_options.py +88 -0
  75. audiometa/test/tests/e2e/cli/read/test_unified.py +84 -0
  76. audiometa/test/tests/e2e/cli/test_delete.py +20 -0
  77. audiometa/test/tests/e2e/cli/test_formatting.py +31 -0
  78. audiometa/test/tests/e2e/cli/test_help.py +41 -0
  79. audiometa/test/tests/e2e/cli/write/__init__.py +0 -0
  80. audiometa/test/tests/e2e/cli/write/test_basic.py +51 -0
  81. audiometa/test/tests/e2e/cli/write/test_comprehensive.py +210 -0
  82. audiometa/test/tests/e2e/cli/write/test_force_format.py +336 -0
  83. audiometa/test/tests/e2e/cli/write/test_integer_fields.py +145 -0
  84. audiometa/test/tests/e2e/cli/write/test_list_fields.py +107 -0
  85. audiometa/test/tests/e2e/cli/write/test_rating.py +74 -0
  86. audiometa/test/tests/e2e/cli/write/test_string_fields.py +54 -0
  87. audiometa/test/tests/e2e/cli/write/test_validation.py +85 -0
  88. audiometa/test/tests/e2e/scenarios/__init__.py +0 -0
  89. audiometa/test/tests/e2e/scenarios/test_user_scenarios.py +166 -0
  90. audiometa/test/tests/e2e/workflows/__init__.py +0 -0
  91. audiometa/test/tests/e2e/workflows/test_core_workflows.py +166 -0
  92. audiometa/test/tests/e2e/workflows/test_deletion_workflows.py +318 -0
  93. audiometa/test/tests/e2e/workflows/test_error_handling_workflows.py +165 -0
  94. audiometa/test/tests/e2e/workflows/test_format_specific_workflows.py +129 -0
  95. audiometa/test/tests/e2e/workflows/test_rating_workflows.py +124 -0
  96. audiometa/test/tests/integration/__init__.py +0 -0
  97. audiometa/test/tests/integration/audio_format/__init__.py +0 -0
  98. audiometa/test/tests/integration/audio_format/flac/__init__.py +0 -0
  99. audiometa/test/tests/integration/audio_format/flac/test_flac_delete_all.py +108 -0
  100. audiometa/test/tests/integration/audio_format/flac/test_flac_reading_all.py +61 -0
  101. audiometa/test/tests/integration/audio_format/flac/test_flac_reading_field.py +65 -0
  102. audiometa/test/tests/integration/audio_format/flac/test_flac_writing.py +69 -0
  103. audiometa/test/tests/integration/audio_format/mp3/__init__.py +0 -0
  104. audiometa/test/tests/integration/audio_format/mp3/test_mp3_delete_all.py +79 -0
  105. audiometa/test/tests/integration/audio_format/mp3/test_mp3_reading_all.py +61 -0
  106. audiometa/test/tests/integration/audio_format/mp3/test_mp3_reading_field.py +67 -0
  107. audiometa/test/tests/integration/audio_format/mp3/test_mp3_writing.py +60 -0
  108. audiometa/test/tests/integration/audio_format/wav/__init__.py +0 -0
  109. audiometa/test/tests/integration/audio_format/wav/test_wav_delete_all.py +87 -0
  110. audiometa/test/tests/integration/audio_format/wav/test_wav_reading_all.py +62 -0
  111. audiometa/test/tests/integration/audio_format/wav/test_wav_reading_field.py +57 -0
  112. audiometa/test/tests/integration/audio_format/wav/test_wav_with_id3v2_tags.py +83 -0
  113. audiometa/test/tests/integration/audio_format/wav/test_wav_writing.py +62 -0
  114. audiometa/test/tests/integration/conftest.py +29 -0
  115. audiometa/test/tests/integration/delete_all_metadata/__init__.py +1 -0
  116. audiometa/test/tests/integration/delete_all_metadata/test_audio_format_all.py +102 -0
  117. audiometa/test/tests/integration/delete_all_metadata/test_audio_format_header_deletion.py +77 -0
  118. audiometa/test/tests/integration/delete_all_metadata/test_basic_functionality.py +47 -0
  119. audiometa/test/tests/integration/delete_all_metadata/test_error_handling.py +24 -0
  120. audiometa/test/tests/integration/encoding/__init__.py +1 -0
  121. audiometa/test/tests/integration/encoding/test_encoding.py +88 -0
  122. audiometa/test/tests/integration/encoding/test_special_characters_edge_cases.py +223 -0
  123. audiometa/test/tests/integration/get_full_metadata/__init__.py +0 -0
  124. audiometa/test/tests/integration/get_full_metadata/test_audio_formats.py +122 -0
  125. audiometa/test/tests/integration/get_full_metadata/test_binary_data_filtering.py +250 -0
  126. audiometa/test/tests/integration/get_full_metadata/test_consistency.py +67 -0
  127. audiometa/test/tests/integration/get_full_metadata/test_edge_cases.py +123 -0
  128. audiometa/test/tests/integration/get_full_metadata/test_error_handling.py +40 -0
  129. audiometa/test/tests/integration/get_full_metadata/test_get_full_metadata.py +43 -0
  130. audiometa/test/tests/integration/get_full_metadata/test_options.py +207 -0
  131. audiometa/test/tests/integration/get_full_metadata/test_performance.py +95 -0
  132. audiometa/test/tests/integration/get_full_metadata/test_riff_bext.py +128 -0
  133. audiometa/test/tests/integration/get_full_metadata/test_structure.py +161 -0
  134. audiometa/test/tests/integration/metadata_field/__init__.py +0 -0
  135. audiometa/test/tests/integration/metadata_field/album/__init__.py +0 -0
  136. audiometa/test/tests/integration/metadata_field/album/test_deleting.py +73 -0
  137. audiometa/test/tests/integration/metadata_field/album/test_reading.py +36 -0
  138. audiometa/test/tests/integration/metadata_field/album/test_writing.py +50 -0
  139. audiometa/test/tests/integration/metadata_field/album_artists/__init__.py +0 -0
  140. audiometa/test/tests/integration/metadata_field/album_artists/test_deleting.py +83 -0
  141. audiometa/test/tests/integration/metadata_field/album_artists/test_reading.py +38 -0
  142. audiometa/test/tests/integration/metadata_field/album_artists/test_writing.py +52 -0
  143. audiometa/test/tests/integration/metadata_field/artists/__init__.py +0 -0
  144. audiometa/test/tests/integration/metadata_field/artists/test_deleting.py +68 -0
  145. audiometa/test/tests/integration/metadata_field/artists/test_reading.py +36 -0
  146. audiometa/test/tests/integration/metadata_field/artists/test_writing.py +46 -0
  147. audiometa/test/tests/integration/metadata_field/bpm/__init__.py +0 -0
  148. audiometa/test/tests/integration/metadata_field/bpm/test_deleting.py +75 -0
  149. audiometa/test/tests/integration/metadata_field/bpm/test_reading.py +32 -0
  150. audiometa/test/tests/integration/metadata_field/bpm/test_writing.py +56 -0
  151. audiometa/test/tests/integration/metadata_field/comment/__init__.py +0 -0
  152. audiometa/test/tests/integration/metadata_field/comment/test_deleting.py +68 -0
  153. audiometa/test/tests/integration/metadata_field/comment/test_reading.py +36 -0
  154. audiometa/test/tests/integration/metadata_field/comment/test_writing.py +49 -0
  155. audiometa/test/tests/integration/metadata_field/composer/__init__.py +0 -0
  156. audiometa/test/tests/integration/metadata_field/composer/test_deleting.py +75 -0
  157. audiometa/test/tests/integration/metadata_field/composer/test_reading.py +34 -0
  158. audiometa/test/tests/integration/metadata_field/composer/test_writing.py +41 -0
  159. audiometa/test/tests/integration/metadata_field/copyright/__init__.py +0 -0
  160. audiometa/test/tests/integration/metadata_field/copyright/test_deleting.py +81 -0
  161. audiometa/test/tests/integration/metadata_field/copyright/test_reading.py +35 -0
  162. audiometa/test/tests/integration/metadata_field/copyright/test_writing.py +41 -0
  163. audiometa/test/tests/integration/metadata_field/disc_number/__init__.py +0 -0
  164. audiometa/test/tests/integration/metadata_field/disc_number/test_deleting.py +97 -0
  165. audiometa/test/tests/integration/metadata_field/disc_number/test_reading.py +92 -0
  166. audiometa/test/tests/integration/metadata_field/disc_number/test_writing.py +153 -0
  167. audiometa/test/tests/integration/metadata_field/field_not_supported/__init__.py +0 -0
  168. audiometa/test/tests/integration/metadata_field/field_not_supported/test_deleting.py +56 -0
  169. audiometa/test/tests/integration/metadata_field/field_not_supported/test_reading.py +54 -0
  170. audiometa/test/tests/integration/metadata_field/field_not_supported/test_writing.py +61 -0
  171. audiometa/test/tests/integration/metadata_field/genre/__init__.py +0 -0
  172. audiometa/test/tests/integration/metadata_field/genre/reading/__init__.py +0 -0
  173. audiometa/test/tests/integration/metadata_field/genre/reading/metadata_format/__init__.py +0 -0
  174. audiometa/test/tests/integration/metadata_field/genre/reading/metadata_format/test_id3v1_reading.py +65 -0
  175. audiometa/test/tests/integration/metadata_field/genre/reading/metadata_format/test_id3v2_reading.py +25 -0
  176. audiometa/test/tests/integration/metadata_field/genre/reading/metadata_format/test_riff_reading.py +58 -0
  177. audiometa/test/tests/integration/metadata_field/genre/reading/metadata_format/test_vorbis_reading.py +61 -0
  178. audiometa/test/tests/integration/metadata_field/genre/reading/test_smart_reading.py +191 -0
  179. audiometa/test/tests/integration/metadata_field/genre/test_deleting.py +62 -0
  180. audiometa/test/tests/integration/metadata_field/genre/test_writing.py +64 -0
  181. audiometa/test/tests/integration/metadata_field/isrc/__init__.py +1 -0
  182. audiometa/test/tests/integration/metadata_field/isrc/test_deleting.py +31 -0
  183. audiometa/test/tests/integration/metadata_field/isrc/test_reading.py +35 -0
  184. audiometa/test/tests/integration/metadata_field/isrc/test_writing.py +165 -0
  185. audiometa/test/tests/integration/metadata_field/language/__init__.py +0 -0
  186. audiometa/test/tests/integration/metadata_field/language/test_deleting.py +75 -0
  187. audiometa/test/tests/integration/metadata_field/language/test_reading.py +39 -0
  188. audiometa/test/tests/integration/metadata_field/language/test_writing.py +43 -0
  189. audiometa/test/tests/integration/metadata_field/lyrics/__init__.py +0 -0
  190. audiometa/test/tests/integration/metadata_field/lyrics/test_deleting.py +129 -0
  191. audiometa/test/tests/integration/metadata_field/lyrics/test_reading.py +57 -0
  192. audiometa/test/tests/integration/metadata_field/lyrics/test_writing.py +59 -0
  193. audiometa/test/tests/integration/metadata_field/publisher/__init__.py +0 -0
  194. audiometa/test/tests/integration/metadata_field/publisher/test_deleting.py +88 -0
  195. audiometa/test/tests/integration/metadata_field/publisher/test_reading.py +32 -0
  196. audiometa/test/tests/integration/metadata_field/publisher/test_writing.py +47 -0
  197. audiometa/test/tests/integration/metadata_field/rating/__init__.py +0 -0
  198. audiometa/test/tests/integration/metadata_field/rating/reading/__init__.py +0 -0
  199. audiometa/test/tests/integration/metadata_field/rating/reading/test_base_100_proportional.py +81 -0
  200. audiometa/test/tests/integration/metadata_field/rating/reading/test_base_255_non_proportional.py +33 -0
  201. audiometa/test/tests/integration/metadata_field/rating/reading/test_base_255_proportional.py +58 -0
  202. audiometa/test/tests/integration/metadata_field/rating/test_deleting.py +117 -0
  203. audiometa/test/tests/integration/metadata_field/rating/test_error_handling.py +137 -0
  204. audiometa/test/tests/integration/metadata_field/rating/writing/__init__.py +0 -0
  205. audiometa/test/tests/integration/metadata_field/rating/writing/metadata_format/__init__.py +0 -0
  206. audiometa/test/tests/integration/metadata_field/rating/writing/metadata_format/test_id3v2.py +77 -0
  207. audiometa/test/tests/integration/metadata_field/rating/writing/metadata_format/test_riff.py +55 -0
  208. audiometa/test/tests/integration/metadata_field/rating/writing/metadata_format/test_vorbis.py +57 -0
  209. audiometa/test/tests/integration/metadata_field/rating/writing/test_comprehensive.py +192 -0
  210. audiometa/test/tests/integration/metadata_field/release_date/__init__.py +0 -0
  211. audiometa/test/tests/integration/metadata_field/release_date/test_deleting.py +74 -0
  212. audiometa/test/tests/integration/metadata_field/release_date/test_error_handling.py +82 -0
  213. audiometa/test/tests/integration/metadata_field/release_date/test_reading.py +59 -0
  214. audiometa/test/tests/integration/metadata_field/release_date/test_writing.py +49 -0
  215. audiometa/test/tests/integration/metadata_field/test_metadata_field_validation.py +135 -0
  216. audiometa/test/tests/integration/metadata_field/title/__init__.py +0 -0
  217. audiometa/test/tests/integration/metadata_field/title/test_deleting.py +73 -0
  218. audiometa/test/tests/integration/metadata_field/title/test_error_handling.py +47 -0
  219. audiometa/test/tests/integration/metadata_field/title/test_reading.py +36 -0
  220. audiometa/test/tests/integration/metadata_field/title/test_writing.py +64 -0
  221. audiometa/test/tests/integration/metadata_field/track_number/__init__.py +0 -0
  222. audiometa/test/tests/integration/metadata_field/track_number/reading/__init__.py +0 -0
  223. audiometa/test/tests/integration/metadata_field/track_number/reading/test_edge_cases.py +43 -0
  224. audiometa/test/tests/integration/metadata_field/track_number/reading/test_metadata_format.py +32 -0
  225. audiometa/test/tests/integration/metadata_field/track_number/test_deleting.py +59 -0
  226. audiometa/test/tests/integration/metadata_field/track_number/test_writing.py +73 -0
  227. audiometa/test/tests/integration/multiple_values/__init__.py +1 -0
  228. audiometa/test/tests/integration/multiple_values/reading/__init__.py +1 -0
  229. audiometa/test/tests/integration/multiple_values/reading/metadata_format/__init__.py +1 -0
  230. audiometa/test/tests/integration/multiple_values/reading/metadata_format/test_id3v1.py +23 -0
  231. audiometa/test/tests/integration/multiple_values/reading/metadata_format/test_id3v2_3.py +92 -0
  232. audiometa/test/tests/integration/multiple_values/reading/metadata_format/test_id3v2_4.py +216 -0
  233. audiometa/test/tests/integration/multiple_values/reading/metadata_format/test_riff.py +84 -0
  234. audiometa/test/tests/integration/multiple_values/reading/metadata_format/test_vorbis.py +169 -0
  235. audiometa/test/tests/integration/multiple_values/reading/test_performance_large_data.py +209 -0
  236. audiometa/test/tests/integration/multiple_values/reading/test_smart_parsing_scenarios.py +198 -0
  237. audiometa/test/tests/integration/multiple_values/reading/test_unicode_handling.py +24 -0
  238. audiometa/test/tests/integration/multiple_values/writing/__init__.py +1 -0
  239. audiometa/test/tests/integration/multiple_values/writing/metadata_format/__init__.py +1 -0
  240. audiometa/test/tests/integration/multiple_values/writing/metadata_format/test_id3v1.py +62 -0
  241. audiometa/test/tests/integration/multiple_values/writing/metadata_format/test_id3v2_3.py +36 -0
  242. audiometa/test/tests/integration/multiple_values/writing/metadata_format/test_id3v2_4.py +34 -0
  243. audiometa/test/tests/integration/multiple_values/writing/metadata_format/test_riff.py +32 -0
  244. audiometa/test/tests/integration/multiple_values/writing/metadata_format/test_vorbis.py +54 -0
  245. audiometa/test/tests/integration/multiple_values/writing/test_error_handling.py +42 -0
  246. audiometa/test/tests/integration/multiple_values/writing/test_large_values.py +98 -0
  247. audiometa/test/tests/integration/reading/__init__.py +1 -0
  248. audiometa/test/tests/integration/reading/test_read_multiple_metadata.py +80 -0
  249. audiometa/test/tests/integration/reading/test_reading_error_handling.py +36 -0
  250. audiometa/test/tests/integration/real_audio_files/__init__.py +0 -0
  251. audiometa/test/tests/integration/real_audio_files/test_reading.py +146 -0
  252. audiometa/test/tests/integration/real_audio_files/test_writing.py +198 -0
  253. audiometa/test/tests/integration/technical_info/__init__.py +0 -0
  254. audiometa/test/tests/integration/technical_info/flac_md5/__init__.py +0 -0
  255. audiometa/test/tests/integration/technical_info/flac_md5/conftest.py +103 -0
  256. audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/__init__.py +0 -0
  257. audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/test_audio_data_corruption.py +21 -0
  258. audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/test_flipped_md5.py +29 -0
  259. audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/test_non_flac_error.py +13 -0
  260. audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/test_partial_md5.py +29 -0
  261. audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/test_random_md5.py +29 -0
  262. audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/test_unset_md5.py +56 -0
  263. audiometa/test/tests/integration/technical_info/flac_md5/test_valid_md5.py +21 -0
  264. audiometa/test/tests/integration/technical_info/test_bitrate.py +79 -0
  265. audiometa/test/tests/integration/technical_info/test_channels.py +38 -0
  266. audiometa/test/tests/integration/technical_info/test_duration_in_sec.py +38 -0
  267. audiometa/test/tests/integration/technical_info/test_sample_rate.py +40 -0
  268. audiometa/test/tests/integration/test_audio_file.py +35 -0
  269. audiometa/test/tests/integration/test_audio_format_readable_after_update_all_metadata_formats.py +95 -0
  270. audiometa/test/tests/integration/writing/__init__.py +0 -0
  271. audiometa/test/tests/integration/writing/test_error_handling.py +44 -0
  272. audiometa/test/tests/integration/writing/test_forced_format.py +224 -0
  273. audiometa/test/tests/integration/writing/test_multiple_format_preservation.py +223 -0
  274. audiometa/test/tests/integration/writing/test_partial_update.py +36 -0
  275. audiometa/test/tests/integration/writing/writing_strategies/__init__.py +0 -0
  276. audiometa/test/tests/integration/writing/writing_strategies/test_cleanup_strategy.py +79 -0
  277. audiometa/test/tests/integration/writing/writing_strategies/test_preserve_strategy.py +76 -0
  278. audiometa/test/tests/integration/writing/writing_strategies/test_sync_strategy.py +215 -0
  279. audiometa/test/tests/integration/writing/writing_strategies/unsupported_fields/__init__.py +0 -0
  280. audiometa/test/tests/integration/writing/writing_strategies/unsupported_fields/test_fail_behavior.py +42 -0
  281. audiometa/test/tests/integration/writing/writing_strategies/unsupported_fields/test_no_writing_on_failure.py +93 -0
  282. audiometa/test/tests/integration/writing/writing_strategies/unsupported_fields/test_strategy_specific.py +99 -0
  283. audiometa/test/tests/unit/__init__.py +0 -0
  284. audiometa/test/tests/unit/audio_file/__init__.py +0 -0
  285. audiometa/test/tests/unit/audio_file/technical_info/__init__.py +0 -0
  286. audiometa/test/tests/unit/audio_file/technical_info/test_bitrate.py +26 -0
  287. audiometa/test/tests/unit/audio_file/technical_info/test_channels.py +31 -0
  288. audiometa/test/tests/unit/audio_file/technical_info/test_duration_in_sec.py +38 -0
  289. audiometa/test/tests/unit/audio_file/technical_info/test_error_handling.py +190 -0
  290. audiometa/test/tests/unit/audio_file/technical_info/test_file_size.py +51 -0
  291. audiometa/test/tests/unit/audio_file/technical_info/test_format_name.py +28 -0
  292. audiometa/test/tests/unit/audio_file/technical_info/test_sample_rate.py +31 -0
  293. audiometa/test/tests/unit/audio_file/test_context_manager.py +30 -0
  294. audiometa/test/tests/unit/audio_file/test_file_validation.py +40 -0
  295. audiometa/test/tests/unit/audio_file/test_is_audio_file.py +49 -0
  296. audiometa/test/tests/unit/audio_file/test_operations.py +20 -0
  297. audiometa/test/tests/unit/audio_file/test_path_handling.py +23 -0
  298. audiometa/test/tests/unit/cli/__init__.py +0 -0
  299. audiometa/test/tests/unit/cli/test_expand_file_patterns.py +234 -0
  300. audiometa/test/tests/unit/metadata_managers/__init__.py +0 -0
  301. audiometa/test/tests/unit/metadata_managers/conftest.py +142 -0
  302. audiometa/test/tests/unit/metadata_managers/header_info/__init__.py +0 -0
  303. audiometa/test/tests/unit/metadata_managers/header_info/test_id3v1.py +49 -0
  304. audiometa/test/tests/unit/metadata_managers/header_info/test_id3v2.py +66 -0
  305. audiometa/test/tests/unit/metadata_managers/header_info/test_riff.py +343 -0
  306. audiometa/test/tests/unit/metadata_managers/header_info/test_vorbis.py +53 -0
  307. audiometa/test/tests/unit/metadata_managers/metadata_field/__init__.py +0 -0
  308. audiometa/test/tests/unit/metadata_managers/metadata_field/multiple_values/__init__.py +0 -0
  309. audiometa/test/tests/unit/metadata_managers/metadata_field/multiple_values/reading/__init__.py +0 -0
  310. audiometa/test/tests/unit/metadata_managers/metadata_field/multiple_values/reading/test_smart_parsing.py +186 -0
  311. audiometa/test/tests/unit/metadata_managers/metadata_field/multiple_values/writing/__init__.py +0 -0
  312. audiometa/test/tests/unit/metadata_managers/metadata_field/multiple_values/writing/test_separator_selection.py +142 -0
  313. audiometa/test/tests/unit/metadata_managers/metadata_field/multiple_values/writing/test_value_filtering.py +76 -0
  314. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/__init__.py +0 -0
  315. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/reading/__init__.py +0 -0
  316. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/reading/test_normalization.py +152 -0
  317. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/reading/test_profiles_values.py +23 -0
  318. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/test_rating_validation.py +77 -0
  319. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/writing/__init__.py +0 -0
  320. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/writing/test_configuration_error.py +43 -0
  321. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/writing/test_validation.py +151 -0
  322. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/writing/test_writing_profiles.py +61 -0
  323. audiometa/test/tests/unit/metadata_managers/metadata_field/test_date_format_validation.py +135 -0
  324. audiometa/test/tests/unit/metadata_managers/metadata_field/test_disc_number_validation.py +75 -0
  325. audiometa/test/tests/unit/metadata_managers/metadata_field/test_isrc_format_validation.py +121 -0
  326. audiometa/test/tests/unit/metadata_managers/metadata_field/test_isrc_type_validation.py +30 -0
  327. audiometa/test/tests/unit/metadata_managers/metadata_field/test_track_number_validation.py +46 -0
  328. audiometa/test/tests/unit/metadata_managers/metadata_field/test_type_validation_exception.py +22 -0
  329. audiometa/test/tests/unit/metadata_managers/metadata_field/test_validation.py +83 -0
  330. audiometa/test/tests/unit/metadata_managers/test_metadata_format_managers_write_and_read.py +74 -0
  331. audiometa/test/tests/unit/metadata_managers/test_riff_configuration_error.py +26 -0
  332. audiometa/utils/__init__.py +1 -0
  333. audiometa/utils/id3v1_genre_code_map.py +205 -0
  334. audiometa/utils/metadata_format.py +31 -0
  335. audiometa/utils/metadata_writing_strategy.py +16 -0
  336. audiometa/utils/mutagen_exception_handler.py +24 -0
  337. audiometa/utils/os_dependencies_checker/__init__.py +24 -0
  338. audiometa/utils/os_dependencies_checker/base.py +62 -0
  339. audiometa/utils/os_dependencies_checker/config.py +77 -0
  340. audiometa/utils/os_dependencies_checker/macos.py +236 -0
  341. audiometa/utils/os_dependencies_checker/ubuntu.py +95 -0
  342. audiometa/utils/os_dependencies_checker/windows.py +227 -0
  343. audiometa/utils/rating_profiles.py +110 -0
  344. audiometa/utils/tool_path_resolver.py +135 -0
  345. audiometa/utils/types.py +82 -0
  346. audiometa/utils/unified_metadata_key.py +87 -0
  347. audiometa_python-0.6.0.dist-info/METADATA +1593 -0
  348. audiometa_python-0.6.0.dist-info/RECORD +352 -0
  349. audiometa_python-0.6.0.dist-info/WHEEL +5 -0
  350. audiometa_python-0.6.0.dist-info/entry_points.txt +2 -0
  351. audiometa_python-0.6.0.dist-info/licenses/LICENSE +202 -0
  352. audiometa_python-0.6.0.dist-info/top_level.txt +1 -0
audiometa/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """AudioMeta CLI entry point for python -m audiometa."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,607 @@
1
+ """Audio file handling module."""
2
+
3
+ import contextlib
4
+ import json
5
+ import subprocess
6
+ import tempfile
7
+ import types
8
+ from pathlib import Path
9
+ from typing import cast
10
+
11
+ from mutagen.flac import FLAC, StreamInfo
12
+ from mutagen.mp3 import MP3
13
+ from mutagen.wave import WAVE
14
+
15
+ from .exceptions import (
16
+ AudioFileMetadataParseError,
17
+ DurationNotFoundError,
18
+ FileByteMismatchError,
19
+ FileCorruptedError,
20
+ FileTypeNotSupportedError,
21
+ FlacMd5CheckFailedError,
22
+ InvalidChunkDecodeError,
23
+ )
24
+ from .manager._rating_supporting.id3v2._id3v2_constants import ID3V2_HEADER_SIZE
25
+ from .manager._rating_supporting.riff._riff_constants import RIFF_HEADER_SIZE
26
+ from .utils.metadata_format import MetadataFormat
27
+ from .utils.mutagen_exception_handler import handle_mutagen_exception
28
+ from .utils.tool_path_resolver import get_tool_path
29
+
30
+ # Type alias for files that can be handled (must be disk-based)
31
+ type DiskBasedFile = str | Path | bytes | object
32
+
33
+
34
+ class _AudioFile:
35
+ file: DiskBasedFile
36
+ file_path: str
37
+
38
+ def __init__(self, file: DiskBasedFile):
39
+ if isinstance(file, str):
40
+ self.file = file
41
+ self.file_path = file
42
+ elif isinstance(file, Path):
43
+ # Handle pathlib.Path objects
44
+ self.file = file
45
+ self.file_path = str(file)
46
+ elif hasattr(file, "path"):
47
+ # Handle objects with a path attribute (like TempFileWithMetadata)
48
+ self.file = file
49
+ self.file_path = str(file.path)
50
+ elif hasattr(file, "name"):
51
+ # Handle file-like objects with a name attribute
52
+ self.file = file
53
+ self.file_path = file.name
54
+ elif hasattr(file, "temporary_file_path"):
55
+ # Handle temporary uploaded files
56
+ self.file = file
57
+ self.file_path = file.temporary_file_path()
58
+ else:
59
+ msg = f"Unsupported file type: {type(file)}"
60
+ raise FileTypeNotSupportedError(msg)
61
+
62
+ if not Path(self.file_path).exists():
63
+ msg = f"File {self.file_path} does not exist"
64
+ raise FileNotFoundError(msg)
65
+
66
+ file_extension = Path(self.file_path).suffix.lower()
67
+ self.file_extension = file_extension
68
+
69
+ # Validate that the file type is supported
70
+ supported_extensions = MetadataFormat.get_priorities().keys()
71
+ if file_extension not in supported_extensions:
72
+ msg = f"File type {file_extension} is not supported. Supported types: {', '.join(supported_extensions)}"
73
+ raise FileTypeNotSupportedError(msg)
74
+
75
+ # Validate that the file content is valid for the format
76
+ try:
77
+ if file_extension == ".mp3":
78
+ MP3(self.file_path)
79
+ elif file_extension == ".flac":
80
+ FLAC(self.file_path)
81
+ elif file_extension == ".wav":
82
+ # Use custom WAV validation that handles ID3v2 tags
83
+ self._validate_wav_file(self.file_path)
84
+ except Exception as e:
85
+ msg = f"The file content is corrupted or not a valid {file_extension.upper()} file: {e!s}"
86
+ raise FileCorruptedError(msg) from e
87
+
88
+ def get_duration_in_sec(self) -> float:
89
+ path = self.file_path
90
+
91
+ if self.file_extension == ".mp3":
92
+ try:
93
+ audio = MP3(path)
94
+ return float(audio.info.length)
95
+ except Exception as exc:
96
+ # If MP3 fails, try other formats as fallback
97
+ try:
98
+ wave_audio = WAVE(path)
99
+ return float(wave_audio.info.length) # type: ignore[attr-defined,unused-ignore]
100
+ except Exception:
101
+ try:
102
+ flac_audio = FLAC(path)
103
+ return float(flac_audio.info.length) # type: ignore[attr-defined,unused-ignore]
104
+ except Exception:
105
+ msg = f"Could not determine duration for {path}"
106
+ raise DurationNotFoundError(msg) from exc
107
+
108
+ elif self.file_extension == ".wav":
109
+ try:
110
+ # Use ffprobe to get duration, more tolerant of file format issues
111
+ result = subprocess.run(
112
+ [
113
+ get_tool_path("ffprobe"),
114
+ "-v",
115
+ "quiet",
116
+ "-print_format",
117
+ "json",
118
+ "-show_format",
119
+ "-show_streams",
120
+ path,
121
+ ],
122
+ capture_output=True,
123
+ text=True,
124
+ check=False,
125
+ )
126
+
127
+ if result.returncode != 0:
128
+ msg = "Failed to probe audio file"
129
+ raise RuntimeError(msg)
130
+
131
+ data = json.loads(result.stdout)
132
+ # Try format duration first, then stream duration if available
133
+ duration = float(
134
+ data.get("format", {}).get("duration")
135
+ or next((s.get("duration") for s in data.get("streams", []) if s.get("duration")), 0)
136
+ )
137
+
138
+ if duration <= 0:
139
+ msg = "Could not determine audio duration"
140
+ raise DurationNotFoundError(msg) from None
141
+ except json.JSONDecodeError as e:
142
+ msg = "Failed to parse audio file metadata from ffprobe output"
143
+ raise AudioFileMetadataParseError(msg) from e
144
+ except DurationNotFoundError:
145
+ raise
146
+ except Exception as exc:
147
+ if str(exc) == "Failed to probe audio file":
148
+ msg = "ffprobe could not parse the audio file."
149
+ raise FileCorruptedError(msg) from exc
150
+ msg = f"Failed to read WAV file duration: {exc!s}"
151
+ raise RuntimeError(msg) from exc
152
+ else:
153
+ return duration
154
+
155
+ elif self.file_extension == ".flac":
156
+ try:
157
+ return float(FLAC(path).info.length)
158
+ except Exception as exc:
159
+ error_str = str(exc)
160
+ if "file said" in error_str and "bytes, read" in error_str:
161
+ raise FileByteMismatchError(error_str.capitalize()) from exc
162
+ if "FLAC" in error_str or "chunk" in error_str.lower():
163
+ msg = f"Failed to decode FLAC chunks: {error_str}"
164
+ raise InvalidChunkDecodeError(msg) from exc
165
+ handle_mutagen_exception("read duration from FLAC file", path, exc)
166
+ return 0.0 # Never reached, but satisfies type checker
167
+ else:
168
+ msg = f"Reading is not supported for file type: {self.file_extension}"
169
+ raise FileTypeNotSupportedError(msg)
170
+
171
+ def get_bitrate(self) -> int:
172
+ path = self.file_path
173
+ if self.file_extension == ".mp3":
174
+ audio = MP3(path)
175
+ # Get MP3 bitrate directly from audio stream
176
+ if audio.info.bitrate:
177
+ return int(audio.info.bitrate)
178
+ return 0
179
+ if self.file_extension == ".wav":
180
+ try:
181
+ # Use ffprobe to get audio stream information
182
+ result = subprocess.run(
183
+ [
184
+ "ffprobe",
185
+ "-v",
186
+ "quiet",
187
+ "-print_format",
188
+ "json",
189
+ "-show_streams",
190
+ "-select_streams",
191
+ "a:0", # Select first audio stream
192
+ path,
193
+ ],
194
+ capture_output=True,
195
+ text=True,
196
+ check=False,
197
+ )
198
+
199
+ if result.returncode != 0:
200
+ msg = "Failed to probe audio file"
201
+ raise RuntimeError(msg) from None
202
+
203
+ data = json.loads(result.stdout)
204
+ if not data.get("streams"):
205
+ msg = "No audio streams found"
206
+ raise RuntimeError(msg) from None
207
+
208
+ stream = data["streams"][0]
209
+ # Get bitrate directly if available
210
+ if "bit_rate" in stream:
211
+ return int(stream["bit_rate"])
212
+
213
+ # Calculate from sample_rate * channels * bits_per_sample if no direct bitrate
214
+ sample_rate = int(stream.get("sample_rate", 0))
215
+ channels = int(stream.get("channels", 0))
216
+ bits_per_sample = int(stream.get("bits_per_raw_sample", 0) or stream.get("bits_per_sample", 0))
217
+
218
+ if not all([sample_rate, channels, bits_per_sample]):
219
+ msg = "Missing audio stream information"
220
+ raise RuntimeError(msg) from None
221
+
222
+ return sample_rate * channels * bits_per_sample
223
+ except json.JSONDecodeError as e:
224
+ msg = "Failed to parse audio file metadata from ffprobe output"
225
+ raise AudioFileMetadataParseError(msg) from e
226
+ except Exception as exc:
227
+ msg = f"Failed to read WAV file bitrate: {exc!s}"
228
+ raise RuntimeError(msg) from exc
229
+ elif self.file_extension == ".flac":
230
+ audio_info = cast(StreamInfo, FLAC(path).info)
231
+ return int(audio_info.bitrate)
232
+ else:
233
+ msg = f"Reading is not supported for file type: {self.file_extension}"
234
+ raise FileTypeNotSupportedError(msg)
235
+
236
+ def read(self, size: int = -1) -> bytes:
237
+ with Path(self.file_path).open("rb") as f:
238
+ return f.read(size)
239
+
240
+ def write(self, data: bytes) -> int:
241
+ with Path(self.file_path).open("wb") as f:
242
+ return f.write(data)
243
+
244
+ def seek(self, offset: int, whence: int = 0) -> int:
245
+ with Path(self.file_path).open("rb") as f:
246
+ return f.seek(offset, whence)
247
+
248
+ def close(self) -> None:
249
+ if hasattr(self.file, "close"):
250
+ self.file.close()
251
+
252
+ def __enter__(self) -> "_AudioFile":
253
+ return self
254
+
255
+ def __exit__(
256
+ self,
257
+ exc_type: type[BaseException] | None,
258
+ exc_val: BaseException | None,
259
+ exc_tb: types.TracebackType | None,
260
+ ) -> None:
261
+ self.close()
262
+
263
+ def get_file_path_or_object(self) -> str:
264
+ """Get the path to the file on the filesystem."""
265
+ return self.file_path
266
+
267
+ def _is_md5_unset(self) -> bool:
268
+ """Check if FLAC file has unset MD5 checksum (all zeros)."""
269
+ try:
270
+ with Path(self.file_path).open("rb") as f:
271
+ data = f.read()
272
+ flac_marker_pos = data.find(b"fLaC")
273
+ if flac_marker_pos == -1:
274
+ return False
275
+ md5_start = flac_marker_pos + 4 + 1 + 18
276
+ if md5_start + 16 > len(data):
277
+ return False
278
+ md5_bytes = data[md5_start : md5_start + 16]
279
+ return md5_bytes == b"\x00" * 16
280
+ except Exception:
281
+ return False
282
+
283
+ def is_flac_file_md5_valid(self) -> bool:
284
+ if self.file_extension != ".flac":
285
+ msg = "The file is not a FLAC file"
286
+ raise FileTypeNotSupportedError(msg)
287
+
288
+ if self._is_md5_unset():
289
+ return False
290
+
291
+ result = subprocess.run([get_tool_path("flac"), "-t", self.file_path], capture_output=True, check=False)
292
+
293
+ # Combine stdout and stderr as flac may output to either
294
+ stdout_output = result.stdout.decode()
295
+ stderr_output = result.stderr.decode()
296
+ combined_output = stdout_output + stderr_output
297
+
298
+ # flac -t returns 0 on success, non-zero on error
299
+ # If return code is non-zero, the file is invalid
300
+ if result.returncode != 0:
301
+ return False
302
+
303
+ # Check for explicit error messages (shouldn't happen with return code 0, but defensive)
304
+ if "MD5 signature mismatch" in combined_output:
305
+ return False
306
+ if "FLAC__STREAM_DECODER_ERROR_STATUS_LOST_SYNC" in combined_output:
307
+ return False
308
+
309
+ # Check for explicit success message
310
+ if "ok" in combined_output.lower():
311
+ return True
312
+
313
+ # If return code was 0 but no "ok" found, something unexpected happened
314
+ msg = "The Flac file md5 check failed"
315
+ raise FlacMd5CheckFailedError(msg)
316
+
317
+ def get_file_with_corrected_md5(self, delete_original: bool = False) -> str:
318
+ """Get a new temporary file with corrected MD5 signature.
319
+
320
+ Returns the path to the corrected file.
321
+
322
+ Args:
323
+ delete_original: If True, deletes the original file after creating the corrected version.
324
+ Defaults to False to maintain backward compatibility.
325
+
326
+ Raises:
327
+ FileCorruptedError: If the FLAC file is corrupted or cannot be corrected
328
+ RuntimeError: If the FLAC command fails to execute
329
+ OSError: If deletion of the original file fails when delete_original is True
330
+ """
331
+ if self.file_extension != ".flac":
332
+ msg = "The file is not a FLAC file"
333
+ raise FileTypeNotSupportedError(msg)
334
+
335
+ # Create a temporary file to store the corrected FLAC content
336
+ temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".flac")
337
+ temp_path = temp_file.name
338
+ temp_file.close() # Close but don't delete yet
339
+
340
+ success = False
341
+ try:
342
+ # Read the input file and run FLAC command
343
+ with Path(self.file_path).open("rb") as f:
344
+ result = subprocess.run(
345
+ [get_tool_path("flac"), "-f", "--best", "-o", temp_path, "-"],
346
+ stdin=f,
347
+ capture_output=True,
348
+ check=False,
349
+ )
350
+
351
+ if result.returncode != 0:
352
+ stderr = result.stderr.decode()
353
+ if "wrote" not in stderr:
354
+ # Clean up any empty file created by failed flac command
355
+ temp_path_obj = Path(temp_path)
356
+ if temp_path_obj.exists() and temp_path_obj.stat().st_size == 0:
357
+ temp_path_obj.unlink(missing_ok=True)
358
+
359
+ # Try reencoding with ffmpeg as a fallback
360
+ # Use -y to overwrite any existing file
361
+ ffmpeg_cmd = [get_tool_path("ffmpeg"), "-i", self.file_path, "-c:a", "flac", "-y", temp_path]
362
+
363
+ ffmpeg_result = subprocess.run(ffmpeg_cmd, capture_output=True, check=False)
364
+
365
+ if ffmpeg_result.returncode != 0:
366
+ msg = (
367
+ "The FLAC file MD5 check failed and reencoding attempts were unsuccessful. "
368
+ "The file is probably corrupted."
369
+ )
370
+ raise FileCorruptedError(msg)
371
+
372
+ # Verify the output file exists and is valid
373
+ temp_path_obj = Path(temp_path)
374
+ if not temp_path_obj.exists() or temp_path_obj.stat().st_size == 0:
375
+ msg = "Failed to create corrected FLAC file"
376
+ raise FileCorruptedError(msg)
377
+
378
+ success = True
379
+
380
+ # If requested, try to delete the original file
381
+ if delete_original and success:
382
+ try:
383
+ Path(self.file_path).unlink()
384
+ except OSError as e:
385
+ msg = f"Failed to delete original file: {e!s}"
386
+ raise OSError(msg) from e
387
+
388
+ except (subprocess.SubprocessError, OSError) as e:
389
+ msg = f"Failed to execute FLAC command: {e!s}"
390
+ raise RuntimeError(msg) from e
391
+ except Exception as e:
392
+ handle_mutagen_exception("fix FLAC MD5 checksum", self.file_path, e)
393
+ return "" # Never reached, but satisfies type checker
394
+ else:
395
+ return temp_path
396
+ finally:
397
+ # Clean up the temp file only if we failed
398
+ if not success and Path(temp_path).exists():
399
+ with contextlib.suppress(OSError):
400
+ Path(temp_path).unlink()
401
+
402
+ def get_sample_rate(self) -> int:
403
+ """Get the sample rate of an audio file.
404
+
405
+ Returns:
406
+ Sample rate in Hz
407
+
408
+ Raises:
409
+ FileTypeNotSupportedError: If the file format is not supported
410
+ FileNotFoundError: If the file does not exist
411
+ """
412
+ if self.file_extension == ".mp3":
413
+ try:
414
+ audio = MP3(self.file_path)
415
+ if audio.info.sample_rate is not None:
416
+ return int(float(audio.info.sample_rate))
417
+ except Exception:
418
+ pass
419
+ return 0
420
+ if self.file_extension == ".wav":
421
+ try:
422
+ result = subprocess.run(
423
+ [
424
+ "ffprobe",
425
+ "-v",
426
+ "quiet",
427
+ "-print_format",
428
+ "json",
429
+ "-show_streams",
430
+ "-select_streams",
431
+ "a:0",
432
+ self.file_path,
433
+ ],
434
+ capture_output=True,
435
+ text=True,
436
+ check=False,
437
+ )
438
+
439
+ if result.returncode != 0:
440
+ return 0
441
+
442
+ data = json.loads(result.stdout)
443
+ if not data.get("streams"):
444
+ return 0
445
+
446
+ stream = data["streams"][0]
447
+ return int(stream.get("sample_rate", 0))
448
+ except Exception:
449
+ return 0
450
+ elif self.file_extension == ".flac":
451
+ try:
452
+ audio_info = cast(StreamInfo, FLAC(self.file_path).info)
453
+ return int(float(audio_info.sample_rate))
454
+ except Exception:
455
+ return 0
456
+ else:
457
+ msg = f"Reading is not supported for file type: {self.file_extension}"
458
+ raise FileTypeNotSupportedError(msg)
459
+
460
+ def get_channels(self) -> int:
461
+ """Get the number of channels in an audio file.
462
+
463
+ Returns:
464
+ Number of channels
465
+
466
+ Raises:
467
+ FileTypeNotSupportedError: If the file format is not supported
468
+ FileNotFoundError: If the file does not exist
469
+ """
470
+ if self.file_extension == ".mp3":
471
+ try:
472
+ audio = MP3(self.file_path)
473
+ if audio.info.channels is not None:
474
+ return int(float(audio.info.channels))
475
+ except Exception:
476
+ pass
477
+ return 0
478
+ if self.file_extension == ".wav":
479
+ try:
480
+ result = subprocess.run(
481
+ [
482
+ "ffprobe",
483
+ "-v",
484
+ "quiet",
485
+ "-print_format",
486
+ "json",
487
+ "-show_streams",
488
+ "-select_streams",
489
+ "a:0",
490
+ self.file_path,
491
+ ],
492
+ capture_output=True,
493
+ text=True,
494
+ check=False,
495
+ )
496
+
497
+ if result.returncode != 0:
498
+ return 0
499
+
500
+ data = json.loads(result.stdout)
501
+ if not data.get("streams"):
502
+ return 0
503
+
504
+ stream = data["streams"][0]
505
+ return int(stream.get("channels", 0))
506
+ except Exception:
507
+ return 0
508
+ elif self.file_extension == ".flac":
509
+ try:
510
+ audio_info = cast(StreamInfo, FLAC(self.file_path).info)
511
+ return int(float(audio_info.channels))
512
+ except Exception:
513
+ return 0
514
+ else:
515
+ msg = f"Reading is not supported for file type: {self.file_extension}"
516
+ raise FileTypeNotSupportedError(msg)
517
+
518
+ def get_file_size(self) -> int:
519
+ """Get the file size in bytes.
520
+
521
+ Returns:
522
+ File size in bytes
523
+ """
524
+ try:
525
+ return Path(self.file_path).stat().st_size
526
+ except OSError:
527
+ return 0
528
+
529
+ def get_audio_format_name(self) -> str:
530
+ """Get the human-readable format name.
531
+
532
+ Returns:
533
+ Audio format name (e.g., 'MP3', 'FLAC', 'WAV')
534
+ """
535
+ audio_format_names = {".mp3": "MP3", ".flac": "FLAC", ".wav": "WAV"}
536
+ return audio_format_names.get(self.file_extension, "Unknown")
537
+
538
+ def _skip_id3v2_tags(self, data: bytes) -> bytes:
539
+ """Skip ID3v2 tags if present at the start of the file.
540
+
541
+ Returns the data starting from after any ID3v2 tags.
542
+ """
543
+ if data.startswith(b"ID3"):
544
+ # ID3v2 header is 10 bytes:
545
+ # 3 bytes: ID3
546
+ # 2 bytes: version
547
+ # 1 byte: flags
548
+ # 4 bytes: size (synchsafe integer)
549
+ if len(data) < ID3V2_HEADER_SIZE:
550
+ return data
551
+
552
+ # Get size from synchsafe integer (7 bits per byte)
553
+ size_bytes = data[6:ID3V2_HEADER_SIZE]
554
+ size = (
555
+ ((size_bytes[0] & 0x7F) << 21)
556
+ | ((size_bytes[1] & 0x7F) << 14)
557
+ | ((size_bytes[2] & 0x7F) << 7)
558
+ | (size_bytes[3] & 0x7F)
559
+ )
560
+
561
+ # Skip the header (10 bytes) plus the size of the tag
562
+ return data[ID3V2_HEADER_SIZE + size :]
563
+ return data
564
+
565
+ def _validate_wav_file(self, file_path: str) -> None:
566
+ """Validate WAV file structure, handling ID3v2 tags at the beginning.
567
+
568
+ This method performs lightweight validation of the RIFF/WAV structure without relying on mutagen for files that
569
+ have ID3v2 tags.
570
+ """
571
+ with Path(file_path).open("rb") as f:
572
+ # Read enough data to cover potential ID3v2 tags (up to ~1MB for very large tags)
573
+ header_data = f.read(RIFF_HEADER_SIZE)
574
+
575
+ # Skip ID3v2 tags if present
576
+ if header_data.startswith(b"ID3"):
577
+ # Read the full file to properly handle ID3v2 tags
578
+ f.seek(0)
579
+ full_data = f.read()
580
+
581
+ # Skip the ID3v2 tag
582
+ clean_data = self._skip_id3v2_tags(full_data)
583
+
584
+ # Check if we have enough data for RIFF header after skipping ID3v2
585
+ if len(clean_data) < RIFF_HEADER_SIZE:
586
+ msg = "File too small after skipping ID3v2 tags"
587
+ raise FileCorruptedError(msg)
588
+
589
+ riff_header = clean_data[:RIFF_HEADER_SIZE]
590
+ else:
591
+ riff_header = header_data
592
+
593
+ # Validate RIFF header
594
+ if len(riff_header) < RIFF_HEADER_SIZE:
595
+ msg = "File too small to contain RIFF header"
596
+ raise FileCorruptedError(msg)
597
+
598
+ if not riff_header.startswith(b"RIFF"):
599
+ msg = "Invalid RIFF header"
600
+ raise FileCorruptedError(msg)
601
+
602
+ if riff_header[8:12] != b"WAVE":
603
+ msg = "Not a WAVE file"
604
+ raise FileCorruptedError(msg)
605
+
606
+ # Basic structure validation passed
607
+ return