audiometa-python 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (352) hide show
  1. audiometa/__init__.py +1297 -0
  2. audiometa/__main__.py +6 -0
  3. audiometa/_audio_file.py +607 -0
  4. audiometa/cli.py +476 -0
  5. audiometa/exceptions.py +167 -0
  6. audiometa/manager/_MetadataManager.py +768 -0
  7. audiometa/manager/__init__.py +1 -0
  8. audiometa/manager/_rating_supporting/_RatingSupportingMetadataManager.py +250 -0
  9. audiometa/manager/_rating_supporting/__init__.py +1 -0
  10. audiometa/manager/_rating_supporting/id3v2/_Id3v2Manager.py +1032 -0
  11. audiometa/manager/_rating_supporting/id3v2/__init__.py +25 -0
  12. audiometa/manager/_rating_supporting/id3v2/_id3v2_constants.py +11 -0
  13. audiometa/manager/_rating_supporting/riff/_RiffManager.py +1002 -0
  14. audiometa/manager/_rating_supporting/riff/__init__.py +25 -0
  15. audiometa/manager/_rating_supporting/riff/_riff_constants.py +17 -0
  16. audiometa/manager/_rating_supporting/vorbis/_VorbisManager.py +542 -0
  17. audiometa/manager/_rating_supporting/vorbis/__init__.py +17 -0
  18. audiometa/manager/_rating_supporting/vorbis/_vorbis_constants.py +6 -0
  19. audiometa/manager/id3v1/_Id3v1Manager.py +512 -0
  20. audiometa/manager/id3v1/__init__.py +1 -0
  21. audiometa/manager/id3v1/_constants.py +8 -0
  22. audiometa/manager/id3v1/id3v1_raw_metadata.py +242 -0
  23. audiometa/manager/id3v1/id3v1_raw_metadata_key.py +13 -0
  24. audiometa/test/__init__.py +1 -0
  25. audiometa/test/assets/create_test_files.py +72 -0
  26. audiometa/test/helpers/__init__.py +51 -0
  27. audiometa/test/helpers/common/__init__.py +6 -0
  28. audiometa/test/helpers/common/audio_file_creator.py +68 -0
  29. audiometa/test/helpers/common/external_tool_runner.py +74 -0
  30. audiometa/test/helpers/id3v1/__init__.py +8 -0
  31. audiometa/test/helpers/id3v1/id3v1_header_verifier.py +18 -0
  32. audiometa/test/helpers/id3v1/id3v1_metadata_deleter.py +37 -0
  33. audiometa/test/helpers/id3v1/id3v1_metadata_getter.py +61 -0
  34. audiometa/test/helpers/id3v1/id3v1_metadata_setter.py +82 -0
  35. audiometa/test/helpers/id3v2/__init__.py +28 -0
  36. audiometa/test/helpers/id3v2/id3v2_frame_manual_creator.py +349 -0
  37. audiometa/test/helpers/id3v2/id3v2_header_verifier.py +38 -0
  38. audiometa/test/helpers/id3v2/id3v2_metadata_deleter.py +56 -0
  39. audiometa/test/helpers/id3v2/id3v2_metadata_getter.py +189 -0
  40. audiometa/test/helpers/id3v2/id3v2_metadata_setter.py +506 -0
  41. audiometa/test/helpers/riff/__init__.py +8 -0
  42. audiometa/test/helpers/riff/riff_header_verifier.py +85 -0
  43. audiometa/test/helpers/riff/riff_manual_metadata_creator.py +298 -0
  44. audiometa/test/helpers/riff/riff_metadata_deleter.py +56 -0
  45. audiometa/test/helpers/riff/riff_metadata_getter.py +219 -0
  46. audiometa/test/helpers/riff/riff_metadata_setter.py +374 -0
  47. audiometa/test/helpers/scripts/__init__.py +0 -0
  48. audiometa/test/helpers/technical_info_inspector.py +115 -0
  49. audiometa/test/helpers/temp_file_with_metadata.py +82 -0
  50. audiometa/test/helpers/vorbis/__init__.py +8 -0
  51. audiometa/test/helpers/vorbis/vorbis_header_verifier.py +31 -0
  52. audiometa/test/helpers/vorbis/vorbis_metadata_deleter.py +49 -0
  53. audiometa/test/helpers/vorbis/vorbis_metadata_getter.py +67 -0
  54. audiometa/test/helpers/vorbis/vorbis_metadata_setter.py +221 -0
  55. audiometa/test/tests/__init__.py +0 -0
  56. audiometa/test/tests/conftest.py +276 -0
  57. audiometa/test/tests/e2e/__init__.py +0 -0
  58. audiometa/test/tests/e2e/cli/__init__.py +0 -0
  59. audiometa/test/tests/e2e/cli/error_handling/__init__.py +1 -0
  60. audiometa/test/tests/e2e/cli/error_handling/test_command_structure_errors.py +77 -0
  61. audiometa/test/tests/e2e/cli/error_handling/test_file_access_errors.py +130 -0
  62. audiometa/test/tests/e2e/cli/error_handling/test_format_output_errors.py +118 -0
  63. audiometa/test/tests/e2e/cli/error_handling/test_input_validation_errors.py +172 -0
  64. audiometa/test/tests/e2e/cli/error_handling/test_missing_fields_validation.py +49 -0
  65. audiometa/test/tests/e2e/cli/error_handling/test_multiple_files_errors.py +160 -0
  66. audiometa/test/tests/e2e/cli/error_handling/test_rating_validation.py +90 -0
  67. audiometa/test/tests/e2e/cli/error_handling/test_year_validation.py +51 -0
  68. audiometa/test/tests/e2e/cli/read/__init__.py +0 -0
  69. audiometa/test/tests/e2e/cli/read/test_basic.py +58 -0
  70. audiometa/test/tests/e2e/cli/read/test_comprehensive.py +240 -0
  71. audiometa/test/tests/e2e/cli/read/test_formats.py +55 -0
  72. audiometa/test/tests/e2e/cli/read/test_metadata_content.py +164 -0
  73. audiometa/test/tests/e2e/cli/read/test_multiple_files.py +149 -0
  74. audiometa/test/tests/e2e/cli/read/test_options.py +88 -0
  75. audiometa/test/tests/e2e/cli/read/test_unified.py +84 -0
  76. audiometa/test/tests/e2e/cli/test_delete.py +20 -0
  77. audiometa/test/tests/e2e/cli/test_formatting.py +31 -0
  78. audiometa/test/tests/e2e/cli/test_help.py +41 -0
  79. audiometa/test/tests/e2e/cli/write/__init__.py +0 -0
  80. audiometa/test/tests/e2e/cli/write/test_basic.py +51 -0
  81. audiometa/test/tests/e2e/cli/write/test_comprehensive.py +210 -0
  82. audiometa/test/tests/e2e/cli/write/test_force_format.py +336 -0
  83. audiometa/test/tests/e2e/cli/write/test_integer_fields.py +145 -0
  84. audiometa/test/tests/e2e/cli/write/test_list_fields.py +107 -0
  85. audiometa/test/tests/e2e/cli/write/test_rating.py +74 -0
  86. audiometa/test/tests/e2e/cli/write/test_string_fields.py +54 -0
  87. audiometa/test/tests/e2e/cli/write/test_validation.py +85 -0
  88. audiometa/test/tests/e2e/scenarios/__init__.py +0 -0
  89. audiometa/test/tests/e2e/scenarios/test_user_scenarios.py +166 -0
  90. audiometa/test/tests/e2e/workflows/__init__.py +0 -0
  91. audiometa/test/tests/e2e/workflows/test_core_workflows.py +166 -0
  92. audiometa/test/tests/e2e/workflows/test_deletion_workflows.py +318 -0
  93. audiometa/test/tests/e2e/workflows/test_error_handling_workflows.py +165 -0
  94. audiometa/test/tests/e2e/workflows/test_format_specific_workflows.py +129 -0
  95. audiometa/test/tests/e2e/workflows/test_rating_workflows.py +124 -0
  96. audiometa/test/tests/integration/__init__.py +0 -0
  97. audiometa/test/tests/integration/audio_format/__init__.py +0 -0
  98. audiometa/test/tests/integration/audio_format/flac/__init__.py +0 -0
  99. audiometa/test/tests/integration/audio_format/flac/test_flac_delete_all.py +108 -0
  100. audiometa/test/tests/integration/audio_format/flac/test_flac_reading_all.py +61 -0
  101. audiometa/test/tests/integration/audio_format/flac/test_flac_reading_field.py +65 -0
  102. audiometa/test/tests/integration/audio_format/flac/test_flac_writing.py +69 -0
  103. audiometa/test/tests/integration/audio_format/mp3/__init__.py +0 -0
  104. audiometa/test/tests/integration/audio_format/mp3/test_mp3_delete_all.py +79 -0
  105. audiometa/test/tests/integration/audio_format/mp3/test_mp3_reading_all.py +61 -0
  106. audiometa/test/tests/integration/audio_format/mp3/test_mp3_reading_field.py +67 -0
  107. audiometa/test/tests/integration/audio_format/mp3/test_mp3_writing.py +60 -0
  108. audiometa/test/tests/integration/audio_format/wav/__init__.py +0 -0
  109. audiometa/test/tests/integration/audio_format/wav/test_wav_delete_all.py +87 -0
  110. audiometa/test/tests/integration/audio_format/wav/test_wav_reading_all.py +62 -0
  111. audiometa/test/tests/integration/audio_format/wav/test_wav_reading_field.py +57 -0
  112. audiometa/test/tests/integration/audio_format/wav/test_wav_with_id3v2_tags.py +83 -0
  113. audiometa/test/tests/integration/audio_format/wav/test_wav_writing.py +62 -0
  114. audiometa/test/tests/integration/conftest.py +29 -0
  115. audiometa/test/tests/integration/delete_all_metadata/__init__.py +1 -0
  116. audiometa/test/tests/integration/delete_all_metadata/test_audio_format_all.py +102 -0
  117. audiometa/test/tests/integration/delete_all_metadata/test_audio_format_header_deletion.py +77 -0
  118. audiometa/test/tests/integration/delete_all_metadata/test_basic_functionality.py +47 -0
  119. audiometa/test/tests/integration/delete_all_metadata/test_error_handling.py +24 -0
  120. audiometa/test/tests/integration/encoding/__init__.py +1 -0
  121. audiometa/test/tests/integration/encoding/test_encoding.py +88 -0
  122. audiometa/test/tests/integration/encoding/test_special_characters_edge_cases.py +223 -0
  123. audiometa/test/tests/integration/get_full_metadata/__init__.py +0 -0
  124. audiometa/test/tests/integration/get_full_metadata/test_audio_formats.py +122 -0
  125. audiometa/test/tests/integration/get_full_metadata/test_binary_data_filtering.py +250 -0
  126. audiometa/test/tests/integration/get_full_metadata/test_consistency.py +67 -0
  127. audiometa/test/tests/integration/get_full_metadata/test_edge_cases.py +123 -0
  128. audiometa/test/tests/integration/get_full_metadata/test_error_handling.py +40 -0
  129. audiometa/test/tests/integration/get_full_metadata/test_get_full_metadata.py +43 -0
  130. audiometa/test/tests/integration/get_full_metadata/test_options.py +207 -0
  131. audiometa/test/tests/integration/get_full_metadata/test_performance.py +95 -0
  132. audiometa/test/tests/integration/get_full_metadata/test_riff_bext.py +128 -0
  133. audiometa/test/tests/integration/get_full_metadata/test_structure.py +161 -0
  134. audiometa/test/tests/integration/metadata_field/__init__.py +0 -0
  135. audiometa/test/tests/integration/metadata_field/album/__init__.py +0 -0
  136. audiometa/test/tests/integration/metadata_field/album/test_deleting.py +73 -0
  137. audiometa/test/tests/integration/metadata_field/album/test_reading.py +36 -0
  138. audiometa/test/tests/integration/metadata_field/album/test_writing.py +50 -0
  139. audiometa/test/tests/integration/metadata_field/album_artists/__init__.py +0 -0
  140. audiometa/test/tests/integration/metadata_field/album_artists/test_deleting.py +83 -0
  141. audiometa/test/tests/integration/metadata_field/album_artists/test_reading.py +38 -0
  142. audiometa/test/tests/integration/metadata_field/album_artists/test_writing.py +52 -0
  143. audiometa/test/tests/integration/metadata_field/artists/__init__.py +0 -0
  144. audiometa/test/tests/integration/metadata_field/artists/test_deleting.py +68 -0
  145. audiometa/test/tests/integration/metadata_field/artists/test_reading.py +36 -0
  146. audiometa/test/tests/integration/metadata_field/artists/test_writing.py +46 -0
  147. audiometa/test/tests/integration/metadata_field/bpm/__init__.py +0 -0
  148. audiometa/test/tests/integration/metadata_field/bpm/test_deleting.py +75 -0
  149. audiometa/test/tests/integration/metadata_field/bpm/test_reading.py +32 -0
  150. audiometa/test/tests/integration/metadata_field/bpm/test_writing.py +56 -0
  151. audiometa/test/tests/integration/metadata_field/comment/__init__.py +0 -0
  152. audiometa/test/tests/integration/metadata_field/comment/test_deleting.py +68 -0
  153. audiometa/test/tests/integration/metadata_field/comment/test_reading.py +36 -0
  154. audiometa/test/tests/integration/metadata_field/comment/test_writing.py +49 -0
  155. audiometa/test/tests/integration/metadata_field/composer/__init__.py +0 -0
  156. audiometa/test/tests/integration/metadata_field/composer/test_deleting.py +75 -0
  157. audiometa/test/tests/integration/metadata_field/composer/test_reading.py +34 -0
  158. audiometa/test/tests/integration/metadata_field/composer/test_writing.py +41 -0
  159. audiometa/test/tests/integration/metadata_field/copyright/__init__.py +0 -0
  160. audiometa/test/tests/integration/metadata_field/copyright/test_deleting.py +81 -0
  161. audiometa/test/tests/integration/metadata_field/copyright/test_reading.py +35 -0
  162. audiometa/test/tests/integration/metadata_field/copyright/test_writing.py +41 -0
  163. audiometa/test/tests/integration/metadata_field/disc_number/__init__.py +0 -0
  164. audiometa/test/tests/integration/metadata_field/disc_number/test_deleting.py +97 -0
  165. audiometa/test/tests/integration/metadata_field/disc_number/test_reading.py +92 -0
  166. audiometa/test/tests/integration/metadata_field/disc_number/test_writing.py +153 -0
  167. audiometa/test/tests/integration/metadata_field/field_not_supported/__init__.py +0 -0
  168. audiometa/test/tests/integration/metadata_field/field_not_supported/test_deleting.py +56 -0
  169. audiometa/test/tests/integration/metadata_field/field_not_supported/test_reading.py +54 -0
  170. audiometa/test/tests/integration/metadata_field/field_not_supported/test_writing.py +61 -0
  171. audiometa/test/tests/integration/metadata_field/genre/__init__.py +0 -0
  172. audiometa/test/tests/integration/metadata_field/genre/reading/__init__.py +0 -0
  173. audiometa/test/tests/integration/metadata_field/genre/reading/metadata_format/__init__.py +0 -0
  174. audiometa/test/tests/integration/metadata_field/genre/reading/metadata_format/test_id3v1_reading.py +65 -0
  175. audiometa/test/tests/integration/metadata_field/genre/reading/metadata_format/test_id3v2_reading.py +25 -0
  176. audiometa/test/tests/integration/metadata_field/genre/reading/metadata_format/test_riff_reading.py +58 -0
  177. audiometa/test/tests/integration/metadata_field/genre/reading/metadata_format/test_vorbis_reading.py +61 -0
  178. audiometa/test/tests/integration/metadata_field/genre/reading/test_smart_reading.py +191 -0
  179. audiometa/test/tests/integration/metadata_field/genre/test_deleting.py +62 -0
  180. audiometa/test/tests/integration/metadata_field/genre/test_writing.py +64 -0
  181. audiometa/test/tests/integration/metadata_field/isrc/__init__.py +1 -0
  182. audiometa/test/tests/integration/metadata_field/isrc/test_deleting.py +31 -0
  183. audiometa/test/tests/integration/metadata_field/isrc/test_reading.py +35 -0
  184. audiometa/test/tests/integration/metadata_field/isrc/test_writing.py +165 -0
  185. audiometa/test/tests/integration/metadata_field/language/__init__.py +0 -0
  186. audiometa/test/tests/integration/metadata_field/language/test_deleting.py +75 -0
  187. audiometa/test/tests/integration/metadata_field/language/test_reading.py +39 -0
  188. audiometa/test/tests/integration/metadata_field/language/test_writing.py +43 -0
  189. audiometa/test/tests/integration/metadata_field/lyrics/__init__.py +0 -0
  190. audiometa/test/tests/integration/metadata_field/lyrics/test_deleting.py +129 -0
  191. audiometa/test/tests/integration/metadata_field/lyrics/test_reading.py +57 -0
  192. audiometa/test/tests/integration/metadata_field/lyrics/test_writing.py +59 -0
  193. audiometa/test/tests/integration/metadata_field/publisher/__init__.py +0 -0
  194. audiometa/test/tests/integration/metadata_field/publisher/test_deleting.py +88 -0
  195. audiometa/test/tests/integration/metadata_field/publisher/test_reading.py +32 -0
  196. audiometa/test/tests/integration/metadata_field/publisher/test_writing.py +47 -0
  197. audiometa/test/tests/integration/metadata_field/rating/__init__.py +0 -0
  198. audiometa/test/tests/integration/metadata_field/rating/reading/__init__.py +0 -0
  199. audiometa/test/tests/integration/metadata_field/rating/reading/test_base_100_proportional.py +81 -0
  200. audiometa/test/tests/integration/metadata_field/rating/reading/test_base_255_non_proportional.py +33 -0
  201. audiometa/test/tests/integration/metadata_field/rating/reading/test_base_255_proportional.py +58 -0
  202. audiometa/test/tests/integration/metadata_field/rating/test_deleting.py +117 -0
  203. audiometa/test/tests/integration/metadata_field/rating/test_error_handling.py +137 -0
  204. audiometa/test/tests/integration/metadata_field/rating/writing/__init__.py +0 -0
  205. audiometa/test/tests/integration/metadata_field/rating/writing/metadata_format/__init__.py +0 -0
  206. audiometa/test/tests/integration/metadata_field/rating/writing/metadata_format/test_id3v2.py +77 -0
  207. audiometa/test/tests/integration/metadata_field/rating/writing/metadata_format/test_riff.py +55 -0
  208. audiometa/test/tests/integration/metadata_field/rating/writing/metadata_format/test_vorbis.py +57 -0
  209. audiometa/test/tests/integration/metadata_field/rating/writing/test_comprehensive.py +192 -0
  210. audiometa/test/tests/integration/metadata_field/release_date/__init__.py +0 -0
  211. audiometa/test/tests/integration/metadata_field/release_date/test_deleting.py +74 -0
  212. audiometa/test/tests/integration/metadata_field/release_date/test_error_handling.py +82 -0
  213. audiometa/test/tests/integration/metadata_field/release_date/test_reading.py +59 -0
  214. audiometa/test/tests/integration/metadata_field/release_date/test_writing.py +49 -0
  215. audiometa/test/tests/integration/metadata_field/test_metadata_field_validation.py +135 -0
  216. audiometa/test/tests/integration/metadata_field/title/__init__.py +0 -0
  217. audiometa/test/tests/integration/metadata_field/title/test_deleting.py +73 -0
  218. audiometa/test/tests/integration/metadata_field/title/test_error_handling.py +47 -0
  219. audiometa/test/tests/integration/metadata_field/title/test_reading.py +36 -0
  220. audiometa/test/tests/integration/metadata_field/title/test_writing.py +64 -0
  221. audiometa/test/tests/integration/metadata_field/track_number/__init__.py +0 -0
  222. audiometa/test/tests/integration/metadata_field/track_number/reading/__init__.py +0 -0
  223. audiometa/test/tests/integration/metadata_field/track_number/reading/test_edge_cases.py +43 -0
  224. audiometa/test/tests/integration/metadata_field/track_number/reading/test_metadata_format.py +32 -0
  225. audiometa/test/tests/integration/metadata_field/track_number/test_deleting.py +59 -0
  226. audiometa/test/tests/integration/metadata_field/track_number/test_writing.py +73 -0
  227. audiometa/test/tests/integration/multiple_values/__init__.py +1 -0
  228. audiometa/test/tests/integration/multiple_values/reading/__init__.py +1 -0
  229. audiometa/test/tests/integration/multiple_values/reading/metadata_format/__init__.py +1 -0
  230. audiometa/test/tests/integration/multiple_values/reading/metadata_format/test_id3v1.py +23 -0
  231. audiometa/test/tests/integration/multiple_values/reading/metadata_format/test_id3v2_3.py +92 -0
  232. audiometa/test/tests/integration/multiple_values/reading/metadata_format/test_id3v2_4.py +216 -0
  233. audiometa/test/tests/integration/multiple_values/reading/metadata_format/test_riff.py +84 -0
  234. audiometa/test/tests/integration/multiple_values/reading/metadata_format/test_vorbis.py +169 -0
  235. audiometa/test/tests/integration/multiple_values/reading/test_performance_large_data.py +209 -0
  236. audiometa/test/tests/integration/multiple_values/reading/test_smart_parsing_scenarios.py +198 -0
  237. audiometa/test/tests/integration/multiple_values/reading/test_unicode_handling.py +24 -0
  238. audiometa/test/tests/integration/multiple_values/writing/__init__.py +1 -0
  239. audiometa/test/tests/integration/multiple_values/writing/metadata_format/__init__.py +1 -0
  240. audiometa/test/tests/integration/multiple_values/writing/metadata_format/test_id3v1.py +62 -0
  241. audiometa/test/tests/integration/multiple_values/writing/metadata_format/test_id3v2_3.py +36 -0
  242. audiometa/test/tests/integration/multiple_values/writing/metadata_format/test_id3v2_4.py +34 -0
  243. audiometa/test/tests/integration/multiple_values/writing/metadata_format/test_riff.py +32 -0
  244. audiometa/test/tests/integration/multiple_values/writing/metadata_format/test_vorbis.py +54 -0
  245. audiometa/test/tests/integration/multiple_values/writing/test_error_handling.py +42 -0
  246. audiometa/test/tests/integration/multiple_values/writing/test_large_values.py +98 -0
  247. audiometa/test/tests/integration/reading/__init__.py +1 -0
  248. audiometa/test/tests/integration/reading/test_read_multiple_metadata.py +80 -0
  249. audiometa/test/tests/integration/reading/test_reading_error_handling.py +36 -0
  250. audiometa/test/tests/integration/real_audio_files/__init__.py +0 -0
  251. audiometa/test/tests/integration/real_audio_files/test_reading.py +146 -0
  252. audiometa/test/tests/integration/real_audio_files/test_writing.py +198 -0
  253. audiometa/test/tests/integration/technical_info/__init__.py +0 -0
  254. audiometa/test/tests/integration/technical_info/flac_md5/__init__.py +0 -0
  255. audiometa/test/tests/integration/technical_info/flac_md5/conftest.py +103 -0
  256. audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/__init__.py +0 -0
  257. audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/test_audio_data_corruption.py +21 -0
  258. audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/test_flipped_md5.py +29 -0
  259. audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/test_non_flac_error.py +13 -0
  260. audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/test_partial_md5.py +29 -0
  261. audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/test_random_md5.py +29 -0
  262. audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/test_unset_md5.py +56 -0
  263. audiometa/test/tests/integration/technical_info/flac_md5/test_valid_md5.py +21 -0
  264. audiometa/test/tests/integration/technical_info/test_bitrate.py +79 -0
  265. audiometa/test/tests/integration/technical_info/test_channels.py +38 -0
  266. audiometa/test/tests/integration/technical_info/test_duration_in_sec.py +38 -0
  267. audiometa/test/tests/integration/technical_info/test_sample_rate.py +40 -0
  268. audiometa/test/tests/integration/test_audio_file.py +35 -0
  269. audiometa/test/tests/integration/test_audio_format_readable_after_update_all_metadata_formats.py +95 -0
  270. audiometa/test/tests/integration/writing/__init__.py +0 -0
  271. audiometa/test/tests/integration/writing/test_error_handling.py +44 -0
  272. audiometa/test/tests/integration/writing/test_forced_format.py +224 -0
  273. audiometa/test/tests/integration/writing/test_multiple_format_preservation.py +223 -0
  274. audiometa/test/tests/integration/writing/test_partial_update.py +36 -0
  275. audiometa/test/tests/integration/writing/writing_strategies/__init__.py +0 -0
  276. audiometa/test/tests/integration/writing/writing_strategies/test_cleanup_strategy.py +79 -0
  277. audiometa/test/tests/integration/writing/writing_strategies/test_preserve_strategy.py +76 -0
  278. audiometa/test/tests/integration/writing/writing_strategies/test_sync_strategy.py +215 -0
  279. audiometa/test/tests/integration/writing/writing_strategies/unsupported_fields/__init__.py +0 -0
  280. audiometa/test/tests/integration/writing/writing_strategies/unsupported_fields/test_fail_behavior.py +42 -0
  281. audiometa/test/tests/integration/writing/writing_strategies/unsupported_fields/test_no_writing_on_failure.py +93 -0
  282. audiometa/test/tests/integration/writing/writing_strategies/unsupported_fields/test_strategy_specific.py +99 -0
  283. audiometa/test/tests/unit/__init__.py +0 -0
  284. audiometa/test/tests/unit/audio_file/__init__.py +0 -0
  285. audiometa/test/tests/unit/audio_file/technical_info/__init__.py +0 -0
  286. audiometa/test/tests/unit/audio_file/technical_info/test_bitrate.py +26 -0
  287. audiometa/test/tests/unit/audio_file/technical_info/test_channels.py +31 -0
  288. audiometa/test/tests/unit/audio_file/technical_info/test_duration_in_sec.py +38 -0
  289. audiometa/test/tests/unit/audio_file/technical_info/test_error_handling.py +190 -0
  290. audiometa/test/tests/unit/audio_file/technical_info/test_file_size.py +51 -0
  291. audiometa/test/tests/unit/audio_file/technical_info/test_format_name.py +28 -0
  292. audiometa/test/tests/unit/audio_file/technical_info/test_sample_rate.py +31 -0
  293. audiometa/test/tests/unit/audio_file/test_context_manager.py +30 -0
  294. audiometa/test/tests/unit/audio_file/test_file_validation.py +40 -0
  295. audiometa/test/tests/unit/audio_file/test_is_audio_file.py +49 -0
  296. audiometa/test/tests/unit/audio_file/test_operations.py +20 -0
  297. audiometa/test/tests/unit/audio_file/test_path_handling.py +23 -0
  298. audiometa/test/tests/unit/cli/__init__.py +0 -0
  299. audiometa/test/tests/unit/cli/test_expand_file_patterns.py +234 -0
  300. audiometa/test/tests/unit/metadata_managers/__init__.py +0 -0
  301. audiometa/test/tests/unit/metadata_managers/conftest.py +142 -0
  302. audiometa/test/tests/unit/metadata_managers/header_info/__init__.py +0 -0
  303. audiometa/test/tests/unit/metadata_managers/header_info/test_id3v1.py +49 -0
  304. audiometa/test/tests/unit/metadata_managers/header_info/test_id3v2.py +66 -0
  305. audiometa/test/tests/unit/metadata_managers/header_info/test_riff.py +343 -0
  306. audiometa/test/tests/unit/metadata_managers/header_info/test_vorbis.py +53 -0
  307. audiometa/test/tests/unit/metadata_managers/metadata_field/__init__.py +0 -0
  308. audiometa/test/tests/unit/metadata_managers/metadata_field/multiple_values/__init__.py +0 -0
  309. audiometa/test/tests/unit/metadata_managers/metadata_field/multiple_values/reading/__init__.py +0 -0
  310. audiometa/test/tests/unit/metadata_managers/metadata_field/multiple_values/reading/test_smart_parsing.py +186 -0
  311. audiometa/test/tests/unit/metadata_managers/metadata_field/multiple_values/writing/__init__.py +0 -0
  312. audiometa/test/tests/unit/metadata_managers/metadata_field/multiple_values/writing/test_separator_selection.py +142 -0
  313. audiometa/test/tests/unit/metadata_managers/metadata_field/multiple_values/writing/test_value_filtering.py +76 -0
  314. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/__init__.py +0 -0
  315. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/reading/__init__.py +0 -0
  316. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/reading/test_normalization.py +152 -0
  317. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/reading/test_profiles_values.py +23 -0
  318. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/test_rating_validation.py +77 -0
  319. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/writing/__init__.py +0 -0
  320. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/writing/test_configuration_error.py +43 -0
  321. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/writing/test_validation.py +151 -0
  322. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/writing/test_writing_profiles.py +61 -0
  323. audiometa/test/tests/unit/metadata_managers/metadata_field/test_date_format_validation.py +135 -0
  324. audiometa/test/tests/unit/metadata_managers/metadata_field/test_disc_number_validation.py +75 -0
  325. audiometa/test/tests/unit/metadata_managers/metadata_field/test_isrc_format_validation.py +121 -0
  326. audiometa/test/tests/unit/metadata_managers/metadata_field/test_isrc_type_validation.py +30 -0
  327. audiometa/test/tests/unit/metadata_managers/metadata_field/test_track_number_validation.py +46 -0
  328. audiometa/test/tests/unit/metadata_managers/metadata_field/test_type_validation_exception.py +22 -0
  329. audiometa/test/tests/unit/metadata_managers/metadata_field/test_validation.py +83 -0
  330. audiometa/test/tests/unit/metadata_managers/test_metadata_format_managers_write_and_read.py +74 -0
  331. audiometa/test/tests/unit/metadata_managers/test_riff_configuration_error.py +26 -0
  332. audiometa/utils/__init__.py +1 -0
  333. audiometa/utils/id3v1_genre_code_map.py +205 -0
  334. audiometa/utils/metadata_format.py +31 -0
  335. audiometa/utils/metadata_writing_strategy.py +16 -0
  336. audiometa/utils/mutagen_exception_handler.py +24 -0
  337. audiometa/utils/os_dependencies_checker/__init__.py +24 -0
  338. audiometa/utils/os_dependencies_checker/base.py +62 -0
  339. audiometa/utils/os_dependencies_checker/config.py +77 -0
  340. audiometa/utils/os_dependencies_checker/macos.py +236 -0
  341. audiometa/utils/os_dependencies_checker/ubuntu.py +95 -0
  342. audiometa/utils/os_dependencies_checker/windows.py +227 -0
  343. audiometa/utils/rating_profiles.py +110 -0
  344. audiometa/utils/tool_path_resolver.py +135 -0
  345. audiometa/utils/types.py +82 -0
  346. audiometa/utils/unified_metadata_key.py +87 -0
  347. audiometa_python-0.6.0.dist-info/METADATA +1593 -0
  348. audiometa_python-0.6.0.dist-info/RECORD +352 -0
  349. audiometa_python-0.6.0.dist-info/WHEEL +5 -0
  350. audiometa_python-0.6.0.dist-info/entry_points.txt +2 -0
  351. audiometa_python-0.6.0.dist-info/licenses/LICENSE +202 -0
  352. audiometa_python-0.6.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,236 @@
1
+ """MacOS-specific dependency checker using Homebrew."""
2
+
3
+ import re
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ from audiometa.utils.os_dependencies_checker.base import OsDependenciesChecker
8
+
9
+
10
+ class MacOSDependenciesChecker(OsDependenciesChecker):
11
+ """MacOS-specific dependency checker using Homebrew."""
12
+
13
+ @classmethod
14
+ def get_os_type(cls) -> str:
15
+ return "macos"
16
+
17
+ def _get_brew_prefix(self) -> str | None:
18
+ """Get Homebrew prefix path."""
19
+ try:
20
+ result = subprocess.run(
21
+ ["brew", "--prefix"],
22
+ capture_output=True,
23
+ text=True,
24
+ check=True,
25
+ )
26
+ if result.stdout:
27
+ return result.stdout.strip()
28
+ except (subprocess.CalledProcessError, FileNotFoundError):
29
+ pass
30
+ return None
31
+
32
+ def _extract_version_from_output(self, output: str, tool_name: str) -> str | None:
33
+ """Extract version number from tool output."""
34
+ if tool_name in ["flac", "metaflac"]:
35
+ match = re.search(r"(\d+\.\d+\.\d+)", output)
36
+ elif tool_name == "mediainfo":
37
+ match = re.search(r"(\d+\.\d+(?:\.\d+)?)", output)
38
+ elif tool_name in ["id3v2", "bwfmetaedit"]:
39
+ match = re.search(r"(\d+\.\d+\.\d+)", output)
40
+ elif tool_name == "exiftool":
41
+ match = re.search(r"(\d+\.\d+(?:\.\d+)?)", output)
42
+ else:
43
+ match = re.search(r"(\d+\.\d+\.\d+)", output)
44
+
45
+ return match.group(1) if match else None
46
+
47
+ def check_tool_available(self, tool_name: str) -> bool:
48
+ """Check if tool is available in PATH or Homebrew locations."""
49
+ brew_prefix = self._get_brew_prefix()
50
+ if brew_prefix:
51
+ tool_paths = [
52
+ f"{brew_prefix}/opt/{tool_name}/bin/{tool_name}",
53
+ f"{brew_prefix}/bin/{tool_name}",
54
+ ]
55
+ # Special handling for ffmpeg/ffprobe (keg-only packages)
56
+ if tool_name in ["ffmpeg", "ffprobe"]:
57
+ for version in ["7", "6", "5"]:
58
+ tool_paths.insert(0, f"{brew_prefix}/opt/ffmpeg@{version}/bin/{tool_name}")
59
+
60
+ for tool_path in tool_paths:
61
+ if Path(tool_path).exists() and Path(tool_path).is_file():
62
+ try:
63
+ # exiftool uses -ver, not --version
64
+ if tool_name == "exiftool":
65
+ version_flag = "-ver"
66
+ elif tool_name == "ffprobe":
67
+ version_flag = "-version"
68
+ else:
69
+ version_flag = "--version"
70
+ result = subprocess.run(
71
+ [tool_path, version_flag],
72
+ capture_output=True,
73
+ text=True,
74
+ check=False,
75
+ )
76
+ if result.stdout or result.stderr:
77
+ return True
78
+ except Exception:
79
+ continue
80
+
81
+ # Fallback to PATH check
82
+ try:
83
+ # exiftool uses -ver, not --version
84
+ if tool_name == "exiftool":
85
+ version_flag = "-ver"
86
+ elif tool_name == "ffprobe":
87
+ version_flag = "-version"
88
+ else:
89
+ version_flag = "--version"
90
+ result = subprocess.run(
91
+ [tool_name, version_flag],
92
+ capture_output=True,
93
+ text=True,
94
+ check=False,
95
+ )
96
+ return bool(result.stdout or result.stderr)
97
+ except FileNotFoundError:
98
+ return False
99
+
100
+ def _get_ffmpeg_version(self) -> str | None:
101
+ """Get ffmpeg version (special handling for keg-only package)."""
102
+ ffprobe_paths = ["ffprobe"]
103
+ brew_prefix = self._get_brew_prefix()
104
+ if brew_prefix:
105
+ for version in ["7", "6", "5"]:
106
+ ffprobe_paths.append(f"{brew_prefix}/opt/ffmpeg@{version}/bin/ffprobe")
107
+
108
+ for ffprobe_path in ffprobe_paths:
109
+ try:
110
+ result = subprocess.run(
111
+ [ffprobe_path, "-version"],
112
+ capture_output=True,
113
+ text=True,
114
+ check=False,
115
+ )
116
+ if result.stdout or result.stderr:
117
+ output = result.stdout + result.stderr
118
+ match = re.search(r"version\s+(\d+(?:\.\d+)*)", output)
119
+ if match:
120
+ return match.group(1)
121
+ except FileNotFoundError:
122
+ continue
123
+ return None
124
+
125
+ def _get_running_version_from_executable(self, package: str, tool_name: str) -> str | None:
126
+ """Get version from tool executable."""
127
+ tool_paths = [tool_name]
128
+ brew_prefix = self._get_brew_prefix()
129
+ if brew_prefix:
130
+ tool_paths.extend(
131
+ [
132
+ f"{brew_prefix}/opt/{package}/bin/{tool_name}",
133
+ f"{brew_prefix}/bin/{tool_name}",
134
+ ]
135
+ )
136
+
137
+ for tool_path in tool_paths:
138
+ try:
139
+ # exiftool uses -ver, not --version
140
+ if tool_name == "exiftool":
141
+ version_flag = "-ver"
142
+ elif tool_name == "ffprobe":
143
+ version_flag = "-version"
144
+ else:
145
+ version_flag = "--version"
146
+ result = subprocess.run(
147
+ [tool_path, version_flag],
148
+ capture_output=True,
149
+ text=True,
150
+ check=False,
151
+ )
152
+ if result.stdout or result.stderr:
153
+ output = result.stdout + result.stderr
154
+ version = self._extract_version_from_output(output, tool_name)
155
+ if version:
156
+ return version
157
+ except FileNotFoundError:
158
+ continue
159
+ return None
160
+
161
+ def _get_installed_versions_from_brew(self, package: str) -> list[str] | None:
162
+ """Get list of installed versions from Homebrew."""
163
+ try:
164
+ result = subprocess.run(
165
+ ["brew", "list", "--versions", package],
166
+ capture_output=True,
167
+ text=True,
168
+ check=True,
169
+ )
170
+ if result.stdout:
171
+ parts = result.stdout.strip().split()
172
+ if len(parts) > 1:
173
+ return parts[1:]
174
+ except (subprocess.CalledProcessError, FileNotFoundError):
175
+ pass
176
+ return None
177
+
178
+ def _verify_pinned_version_installed(self, installed_versions: list[str], expected_version: str) -> bool:
179
+ """Verify that pinned version is in the installed versions list."""
180
+ expected_normalized = self._normalize_version(expected_version)
181
+ for version in installed_versions:
182
+ version_normalized = self._normalize_version(version)
183
+ if self._versions_match(expected_normalized, version_normalized):
184
+ return True
185
+ return False
186
+
187
+ def _find_pinned_version_in_list(self, installed_versions: list[str], expected_version: str) -> str | None:
188
+ """Find and return the pinned version from installed versions list."""
189
+ expected_normalized = self._normalize_version(expected_version)
190
+ for version in installed_versions:
191
+ version_normalized = self._normalize_version(version)
192
+ if self._versions_match(expected_normalized, version_normalized):
193
+ return version_normalized
194
+ return None
195
+
196
+ def get_installed_version(self, package: str, expected_version: str | None = None) -> str | None:
197
+ """Get installed package version on macOS."""
198
+ # Special handling for ffmpeg
199
+ if package == "ffmpeg":
200
+ return self._get_ffmpeg_version()
201
+
202
+ # Map package name to tool executable name
203
+ tool_name = package
204
+ if package == "media-info":
205
+ tool_name = "mediainfo"
206
+
207
+ # Get running version from executable
208
+ running_version = self._get_running_version_from_executable(package, tool_name)
209
+
210
+ # Verify pinned version is installed
211
+ installed_versions = self._get_installed_versions_from_brew(package)
212
+ if installed_versions is None:
213
+ return running_version
214
+
215
+ # If expected version is provided, check if running version or Homebrew version matches
216
+ if expected_version:
217
+ # Accept running version if it matches the pinned version
218
+ # This handles cases where tool is installed manually or from another source
219
+ if running_version and self._versions_match(expected_version, running_version):
220
+ return running_version
221
+
222
+ # If running version doesn't match, check if Homebrew has the pinned version
223
+ if installed_versions:
224
+ pinned_version = self._find_pinned_version_in_list(installed_versions, expected_version)
225
+ if pinned_version:
226
+ # Homebrew has the pinned version, but running version doesn't match
227
+ # Return None to indicate mismatch (running version should match pinned version)
228
+ return None
229
+
230
+ # Pinned version not found in Homebrew and running version doesn't match
231
+ return None
232
+
233
+ # If expected version not provided, return running version or first installed version
234
+ if running_version:
235
+ return running_version
236
+ return self._normalize_version(installed_versions[0])
@@ -0,0 +1,95 @@
1
+ """Ubuntu-specific dependency checker using dpkg."""
2
+
3
+ import subprocess
4
+
5
+ from audiometa.utils.os_dependencies_checker.base import OsDependenciesChecker
6
+
7
+
8
+ class UbuntuDependenciesChecker(OsDependenciesChecker):
9
+ """Ubuntu-specific dependency checker using dpkg."""
10
+
11
+ @classmethod
12
+ def get_os_type(cls) -> str:
13
+ return "ubuntu"
14
+
15
+ def check_tool_available(self, tool_name: str) -> bool:
16
+ """Check if tool is available in PATH."""
17
+ try:
18
+ result = subprocess.run(
19
+ [tool_name, "--version"],
20
+ capture_output=True,
21
+ text=True,
22
+ check=False,
23
+ )
24
+ return bool(result.stdout or result.stderr)
25
+ except FileNotFoundError:
26
+ return False
27
+
28
+ def get_installed_version(self, package: str, expected_version: str | None = None) -> str | None: # noqa: ARG002
29
+ """Get installed package version on Ubuntu."""
30
+ try:
31
+ result = subprocess.run(["dpkg", "-l"], capture_output=True, text=True, check=True)
32
+ for line in result.stdout.split("\n"):
33
+ if line.startswith("ii") and package in line:
34
+ parts = line.split()
35
+ if len(parts) >= 3: # noqa: PLR2004
36
+ return parts[2]
37
+ except (subprocess.CalledProcessError, FileNotFoundError):
38
+ pass
39
+ return None
40
+
41
+ @staticmethod
42
+ def _versions_match(version1: str, version2: str) -> bool:
43
+ """Check if two Ubuntu/Debian package version strings match.
44
+
45
+ Handles Debian package version format: upstream-version-debian-revision
46
+ Compares the upstream version part (before the first '-') for compatibility.
47
+ Supports flexible prefix matching (e.g., "24.01" matches "24.01.1" or "24.01+dfsg").
48
+
49
+ Args:
50
+ version1: First version string (e.g., "24.01", "24.01.1-1build2", or "25.04.1")
51
+ version2: Second version string (e.g., "24.01+dfsg-1build2", "24.01.1-1build2", or "25.04.1-1")
52
+
53
+ Returns:
54
+ True if upstream versions match, False otherwise
55
+ """
56
+ # Extract upstream version (part before first '-')
57
+ # Handle both formats: "24.01.1-1build2" -> "24.01.1" and "25.04.1" -> "25.04.1"
58
+ v1_upstream = version1.split("-")[0]
59
+ v2_upstream = version2.split("-")[0]
60
+
61
+ # Normalize versions: remove revision suffix (like _4) and Debian suffixes (like +dfsg)
62
+ # This allows "24.01" to match "24.01+dfsg" or "24.01.1"
63
+ v1_normalized = UbuntuDependenciesChecker._normalize_debian_version(v1_upstream)
64
+ v2_normalized = UbuntuDependenciesChecker._normalize_debian_version(v2_upstream)
65
+
66
+ # Check if versions match exactly
67
+ if v1_normalized == v2_normalized:
68
+ return True
69
+
70
+ # Check if one version is a prefix of the other
71
+ # "24.01" should match "24.01.1" (v2 starts with v1 + ".")
72
+ # "24.01.1" should match "24.01" (v1 starts with v2 + ".")
73
+ return v2_normalized.startswith(v1_normalized + ".") or v1_normalized.startswith(v2_normalized + ".")
74
+
75
+ @staticmethod
76
+ def _normalize_debian_version(version: str) -> str:
77
+ """Normalize Debian version string by removing suffixes.
78
+
79
+ Removes revision suffixes (like _4) and Debian-specific suffixes (like +dfsg, +ds).
80
+
81
+ Args:
82
+ version: Version string (e.g., "24.01+dfsg" or "24.01.1_4")
83
+
84
+ Returns:
85
+ Normalized version without suffixes
86
+ """
87
+ # Remove revision suffix (like _4)
88
+ normalized = OsDependenciesChecker._normalize_version(version)
89
+
90
+ # Remove Debian-specific suffixes (like +dfsg, +ds)
91
+ # Split on '+' and take the first part
92
+ if "+" in normalized:
93
+ normalized = normalized.split("+")[0]
94
+
95
+ return normalized
@@ -0,0 +1,227 @@
1
+ """Windows-specific dependency checker."""
2
+
3
+ import re
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ from audiometa.utils.os_dependencies_checker.base import OsDependenciesChecker
8
+
9
+
10
+ class WindowsDependenciesChecker(OsDependenciesChecker):
11
+ """Windows-specific dependency checker."""
12
+
13
+ @classmethod
14
+ def get_os_type(cls) -> str:
15
+ return "windows"
16
+
17
+ def check_tool_available(self, tool_name: str) -> bool:
18
+ """Check if tool is available in PATH or default Windows locations."""
19
+ # Check default installation paths for manually installed tools
20
+ if tool_name == "bwfmetaedit":
21
+ exe_path = r"C:\Program Files\BWFMetaEdit\bwfmetaedit.exe"
22
+ if Path(exe_path).exists():
23
+ return True
24
+ elif tool_name == "exiftool":
25
+ exe_path = r"C:\Program Files\ExifTool\exiftool.exe"
26
+ if Path(exe_path).exists():
27
+ return True
28
+ elif tool_name == "id3v2":
29
+ wrapper_path = r"C:\Program Files\id3v2-wrapper\id3v2.bat"
30
+ if Path(wrapper_path).exists():
31
+ return True
32
+ try:
33
+ subprocess.run(["wsl", "--version"], capture_output=True, check=False)
34
+ except FileNotFoundError:
35
+ pass
36
+ else:
37
+ return True
38
+
39
+ # Check PATH
40
+ try:
41
+ result = subprocess.run(
42
+ [tool_name, "--version" if tool_name != "ffprobe" else "-version"],
43
+ capture_output=True,
44
+ text=True,
45
+ check=False,
46
+ )
47
+ return bool(result.stdout or result.stderr)
48
+ except FileNotFoundError:
49
+ return False
50
+
51
+ def get_installed_version(self, package: str, expected_version: str | None = None) -> str | None: # noqa: ARG002
52
+ """Get installed package version on Windows."""
53
+ # Handle different installation methods
54
+ if package == "id3v2":
55
+ try:
56
+ result = subprocess.run(
57
+ ["wsl", "id3v2", "--version"],
58
+ capture_output=True,
59
+ text=True,
60
+ check=False,
61
+ )
62
+ if result.stdout or result.stderr:
63
+ output = result.stdout + result.stderr
64
+ match = re.search(r"(\d+\.\d+\.\d+)", output)
65
+ if match:
66
+ return match.group(1)
67
+ except FileNotFoundError:
68
+ pass
69
+ return None
70
+
71
+ if package == "bwfmetaedit":
72
+ exe_path = r"C:\Program Files\BWFMetaEdit\bwfmetaedit.exe"
73
+ try:
74
+ if not Path(exe_path).exists():
75
+ return None
76
+
77
+ result = subprocess.run(
78
+ [exe_path, "--version"],
79
+ capture_output=True,
80
+ text=True,
81
+ check=False,
82
+ timeout=10,
83
+ )
84
+ output = result.stdout + result.stderr
85
+
86
+ if not output.strip() or result.returncode != 0:
87
+ result = subprocess.run(
88
+ [exe_path],
89
+ capture_output=True,
90
+ text=True,
91
+ check=False,
92
+ timeout=10,
93
+ )
94
+ output = result.stdout + result.stderr
95
+
96
+ if output:
97
+ patterns = [
98
+ r"(\d+\.\d+\.\d+)",
99
+ r"(\d+\.\d+)",
100
+ ]
101
+ for pattern in patterns:
102
+ matches = re.findall(pattern, output)
103
+ for match in matches:
104
+ version = str(match)
105
+ if re.match(r"^\d+\.\d+", version):
106
+ return version
107
+ except (FileNotFoundError, subprocess.TimeoutExpired, Exception):
108
+ pass
109
+ return None
110
+
111
+ if package == "exiftool":
112
+ exe_path = r"C:\Program Files\ExifTool\exiftool.exe"
113
+ try:
114
+ result = subprocess.run(
115
+ [exe_path, "-ver"],
116
+ capture_output=True,
117
+ text=True,
118
+ check=False,
119
+ )
120
+ if result.stdout:
121
+ version = result.stdout.strip()
122
+ if re.match(r"^\d+\.\d+", version):
123
+ return version
124
+ except FileNotFoundError:
125
+ pass
126
+ return None
127
+
128
+ # For Chocolatey packages
129
+ choco_version = None
130
+ try:
131
+ result = subprocess.run(
132
+ ["choco", "list", "--local-only", package, "--exact"],
133
+ capture_output=True,
134
+ text=True,
135
+ check=False,
136
+ )
137
+ output = result.stdout + result.stderr
138
+
139
+ if output.strip():
140
+ pattern = rf"^{re.escape(package)}\s+(\S+)"
141
+ for raw_line in output.split("\n"):
142
+ line = raw_line.strip()
143
+ if not line or line.startswith("Chocolatey"):
144
+ continue
145
+
146
+ match = re.match(pattern, line, re.IGNORECASE)
147
+ if match:
148
+ version = match.group(1)
149
+ if version.startswith(("v", "V")):
150
+ version = version[1:]
151
+ if re.match(r"^\d+\.\d+", version):
152
+ choco_version = version
153
+ break
154
+
155
+ if "|" in line:
156
+ parts = line.split("|")
157
+ if len(parts) >= 2 and parts[0].strip().lower() == package.lower(): # noqa: PLR2004
158
+ version = parts[1].strip()
159
+ if version.startswith(("v", "V")):
160
+ version = version[1:]
161
+ if re.match(r"^\d+\.\d+", version):
162
+ choco_version = version
163
+ break
164
+ except FileNotFoundError:
165
+ pass
166
+
167
+ if choco_version:
168
+ return choco_version
169
+
170
+ # Fallback: Get version from executable directly (for Chocolatey-installed tools)
171
+ # This handles cases where Chocolatey version detection fails but tool is installed
172
+ tool_name = "ffprobe" if package == "ffmpeg" else package
173
+ try:
174
+ version_flag = "-version" if tool_name == "ffprobe" else "--version"
175
+ result = subprocess.run(
176
+ [tool_name, version_flag],
177
+ capture_output=True,
178
+ text=True,
179
+ check=False,
180
+ )
181
+ output = result.stdout + result.stderr
182
+ if output:
183
+ # Extract version from output (look for patterns like "version 7.1.0" or "7.1.0")
184
+ patterns = [
185
+ r"version\s+(\d+\.\d+\.\d+)",
186
+ r"version\s+(\d+\.\d+)",
187
+ r"(\d+\.\d+\.\d+)",
188
+ r"(\d+\.\d+)",
189
+ ]
190
+ for pattern in patterns:
191
+ matches = re.findall(pattern, output, re.IGNORECASE)
192
+ for match in matches:
193
+ version = str(match)
194
+ if re.match(r"^\d+\.\d+", version):
195
+ return version
196
+ except FileNotFoundError:
197
+ pass
198
+
199
+ return None
200
+
201
+ @staticmethod
202
+ def _versions_match(version1: str, version2: str) -> bool:
203
+ """Check if two Windows version strings match (handles different precision).
204
+
205
+ Handles cases where versions have different precision levels:
206
+ - "7.1.0" matches "7.1" (v2 is prefix of v1)
207
+ - "7.1" matches "7.1.0" (v1 is prefix of v2)
208
+ - "7.1.0" matches "7.1.0" (exact match)
209
+
210
+ Args:
211
+ version1: First version string (e.g., "7.1.0")
212
+ version2: Second version string (e.g., "7.1")
213
+
214
+ Returns:
215
+ True if versions match, False otherwise
216
+ """
217
+ v1_normalized = OsDependenciesChecker._normalize_version(version1)
218
+ v2_normalized = OsDependenciesChecker._normalize_version(version2)
219
+
220
+ # Check exact match
221
+ if v1_normalized == v2_normalized:
222
+ return True
223
+
224
+ # Check if one version is a prefix of the other
225
+ # "7.1" should match "7.1.0" (v2 starts with v1 + ".")
226
+ # "7.1.0" should match "7.1" (v1 starts with v2 + ".")
227
+ return v2_normalized.startswith(v1_normalized + ".") or v1_normalized.startswith(v2_normalized + ".")
@@ -0,0 +1,110 @@
1
+ """Rating Compatibility Table Across Different Audio Players.
2
+
3
+ ⚠️ AUTHORITATIVE SOURCE: This is the single source of truth for rating compatibility.
4
+ The README references this table for the complete details.
5
+
6
+ The following table shows how different audio players handle ratings across various audio formats.
7
+ Values represent the actual numbers written to files for each star rating (0-5 stars).
8
+
9
+ +----+----------------+------------+------------+------------+------------+---------+
10
+ | ⭐ | kid3 | Windows | MusicBee | Winamp | Traktor | iTunes |
11
+ | | /Lollypop |Media Player| | | | |
12
+ +----+---+-------+----+------------+------------+------------+------------+---------+
13
+ |ext.|mp3| wav |flac|mp3 wav flac|mp3 wav flac|mp3 wav flac|mp3 wav flac|W ops not|
14
+ +----+-----------+----+------------+------------+------------+------------+ +
15
+ |tags|id3|rif id3|vorb|id3 ✗ vorb|id3 id3 vorb|id3 ✗ vorb|id3 ✗ vorb|supported|
16
+ +----+---+-------+----+------------+------------+------------+------------+---------+
17
+ |None| ✗ ✗ ✗ ✗ | ✗ ✗ | ✗ ✗ ✗ | ✗ ✗ | 0 0 | |
18
+ | 0 | | | 0 0 0 | | | |
19
+ |0.5 | | |13 10 10 | | | |
20
+ | 1 | 1 20 1 20 | 1 20 | 1 20 20 | 1 20 | 51 51 | |
21
+ |1.5 | | |54 30 30 | | | |
22
+ | 2 |64 40 64 40 | 64 40 |64 40 40 | 64 40 |102 102 | |
23
+ |2.5 | | |118 50 50 | | | |
24
+ | 3 |128 60 128 60 | 128 60 |128 60 60 | 128 60 |153 153 | |
25
+ |3.5 | | |186 70 70 | | | |
26
+ | 4 |196 80 196 80 |196 80 80 | 196 80 | 196 80 |204 204 | |
27
+ |4.5 | | |242 90 90 | | | |
28
+ | 5 |255 100 255 100| 255 100 |255 100 100 | 255 100 |255 255 | |
29
+ +----+----------------+------------+------------+------------+------------+---------+
30
+ |Prof| A B A B | A ✗ B | A B B | A ✗ B | C ✗ C | ✗ |
31
+ +----+----------------+------------+------------+------------+------------+---------+
32
+
33
+ Legend:
34
+ id3 = id3v2
35
+ rif = RIFF
36
+ vorb = Vorbis
37
+ ✗ = No tag written
38
+ empty = Rating value not supported
39
+ ✓ = Can write ratings
40
+
41
+ - Rating Profiles:
42
+ A. 255 non-proportional: .mp3 id3v2 not Traktor, RIFF
43
+ B. 100 proportional: Vorbis not Traktor, .wav id3v2
44
+ C. 255 proportional: Traktor id3v2/Vorbis
45
+
46
+ - Key Point
47
+ Despite having different profiles, each rating value uniquely maps to one star value, enabling reliable star rating
48
+ interpretation regardless of the source profile.
49
+
50
+ - Example: All these values map to 3 stars
51
+ assert rating_to_stars(128) == 3.0 # Profile A
52
+ assert rating_to_stars(60) == 3.0 # Profile B
53
+ assert rating_to_stars(153) == 3.0 # Profile C
54
+
55
+ - Exception:
56
+ 0 can either mean no rating (Traktor) or 0 stars (MusicBee).
57
+ Luckily, Traktor ratings are written with special tags making them easy to distinguish.
58
+ """
59
+
60
+ from collections.abc import Iterator
61
+ from enum import Enum
62
+
63
+
64
+ class RatingReadProfile(Enum):
65
+ """Enumeration of rating read profiles for different audio formats."""
66
+
67
+ BASE_255_NON_PROPORTIONAL = (0, 13, 1, 54, 64, 118, 128, 186, 196, 242, 255)
68
+ BASE_255_PROPORTIONAL_TRAKTOR = (None, None, 51, None, 102, None, 153, None, 204, None, 255)
69
+ BASE_100_PROPORTIONAL = (0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100)
70
+
71
+ def __getitem__(self, index: int) -> int | None:
72
+ result = self.value[index]
73
+ return result if isinstance(result, int | type(None)) else int(result)
74
+
75
+ def __len__(self) -> int:
76
+ return len(self.value)
77
+
78
+ def __iter__(self) -> Iterator[int | None]:
79
+ return iter(self.value)
80
+
81
+ def __contains__(self, item: object) -> bool:
82
+ return item in self.value
83
+
84
+
85
+ """
86
+ Regarding the ratings that the app will write in the audio files, the app currently uses the 2 most widely supported
87
+ profiles:
88
+ - 255 non proportional (ID3v2, RIFF)
89
+ - 100 proportional (Vorbis)
90
+ """
91
+
92
+
93
+ class RatingWriteProfile(Enum):
94
+ """Enumeration of rating write profiles for different audio formats."""
95
+
96
+ BASE_255_NON_PROPORTIONAL = RatingReadProfile.BASE_255_NON_PROPORTIONAL.value
97
+ BASE_100_PROPORTIONAL = RatingReadProfile.BASE_100_PROPORTIONAL.value
98
+
99
+ def __getitem__(self, index: int) -> int | None:
100
+ result = self.value[index]
101
+ return result if isinstance(result, int | type(None)) else int(result)
102
+
103
+ def __len__(self) -> int:
104
+ return len(self.value)
105
+
106
+ def __iter__(self) -> Iterator[int | None]:
107
+ return iter(self.value)
108
+
109
+ def __contains__(self, item: object) -> bool:
110
+ return item in self.value