audiometa-python 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (318) hide show
  1. audiometa/__init__.py +1240 -0
  2. audiometa/__main__.py +6 -0
  3. audiometa/_audio_file.py +602 -0
  4. audiometa/cli.py +347 -0
  5. audiometa/exceptions.py +167 -0
  6. audiometa/manager/_MetadataManager.py +665 -0
  7. audiometa/manager/__init__.py +1 -0
  8. audiometa/manager/_rating_supporting/_RatingSupportingMetadataManager.py +250 -0
  9. audiometa/manager/_rating_supporting/__init__.py +1 -0
  10. audiometa/manager/_rating_supporting/id3v2/_Id3v2Manager.py +945 -0
  11. audiometa/manager/_rating_supporting/id3v2/__init__.py +25 -0
  12. audiometa/manager/_rating_supporting/id3v2/_id3v2_constants.py +11 -0
  13. audiometa/manager/_rating_supporting/riff/_RiffManager.py +778 -0
  14. audiometa/manager/_rating_supporting/riff/__init__.py +25 -0
  15. audiometa/manager/_rating_supporting/riff/_riff_constants.py +10 -0
  16. audiometa/manager/_rating_supporting/vorbis/_VorbisManager.py +525 -0
  17. audiometa/manager/_rating_supporting/vorbis/__init__.py +17 -0
  18. audiometa/manager/_rating_supporting/vorbis/_vorbis_constants.py +6 -0
  19. audiometa/manager/id3v1/_Id3v1Manager.py +512 -0
  20. audiometa/manager/id3v1/__init__.py +1 -0
  21. audiometa/manager/id3v1/_constants.py +8 -0
  22. audiometa/manager/id3v1/id3v1_raw_metadata.py +242 -0
  23. audiometa/manager/id3v1/id3v1_raw_metadata_key.py +13 -0
  24. audiometa/test/__init__.py +1 -0
  25. audiometa/test/assets/create_test_files.py +72 -0
  26. audiometa/test/helpers/__init__.py +51 -0
  27. audiometa/test/helpers/common/__init__.py +6 -0
  28. audiometa/test/helpers/common/audio_file_creator.py +68 -0
  29. audiometa/test/helpers/common/external_tool_runner.py +74 -0
  30. audiometa/test/helpers/id3v1/__init__.py +8 -0
  31. audiometa/test/helpers/id3v1/id3v1_header_verifier.py +18 -0
  32. audiometa/test/helpers/id3v1/id3v1_metadata_deleter.py +37 -0
  33. audiometa/test/helpers/id3v1/id3v1_metadata_getter.py +61 -0
  34. audiometa/test/helpers/id3v1/id3v1_metadata_setter.py +82 -0
  35. audiometa/test/helpers/id3v2/__init__.py +28 -0
  36. audiometa/test/helpers/id3v2/id3v2_frame_manual_creator.py +305 -0
  37. audiometa/test/helpers/id3v2/id3v2_header_verifier.py +38 -0
  38. audiometa/test/helpers/id3v2/id3v2_metadata_deleter.py +56 -0
  39. audiometa/test/helpers/id3v2/id3v2_metadata_getter.py +188 -0
  40. audiometa/test/helpers/id3v2/id3v2_metadata_setter.py +428 -0
  41. audiometa/test/helpers/riff/__init__.py +8 -0
  42. audiometa/test/helpers/riff/riff_header_verifier.py +85 -0
  43. audiometa/test/helpers/riff/riff_manual_metadata_creator.py +216 -0
  44. audiometa/test/helpers/riff/riff_metadata_deleter.py +56 -0
  45. audiometa/test/helpers/riff/riff_metadata_getter.py +118 -0
  46. audiometa/test/helpers/riff/riff_metadata_setter.py +196 -0
  47. audiometa/test/helpers/scripts/__init__.py +0 -0
  48. audiometa/test/helpers/technical_info_inspector.py +115 -0
  49. audiometa/test/helpers/temp_file_with_metadata.py +82 -0
  50. audiometa/test/helpers/vorbis/__init__.py +8 -0
  51. audiometa/test/helpers/vorbis/vorbis_header_verifier.py +31 -0
  52. audiometa/test/helpers/vorbis/vorbis_metadata_deleter.py +49 -0
  53. audiometa/test/helpers/vorbis/vorbis_metadata_getter.py +67 -0
  54. audiometa/test/helpers/vorbis/vorbis_metadata_setter.py +200 -0
  55. audiometa/test/tests/__init__.py +0 -0
  56. audiometa/test/tests/conftest.py +261 -0
  57. audiometa/test/tests/e2e/__init__.py +0 -0
  58. audiometa/test/tests/e2e/cli/__init__.py +0 -0
  59. audiometa/test/tests/e2e/cli/error_handling/__init__.py +1 -0
  60. audiometa/test/tests/e2e/cli/error_handling/test_command_structure_errors.py +77 -0
  61. audiometa/test/tests/e2e/cli/error_handling/test_file_access_errors.py +130 -0
  62. audiometa/test/tests/e2e/cli/error_handling/test_format_output_errors.py +118 -0
  63. audiometa/test/tests/e2e/cli/error_handling/test_input_validation_errors.py +172 -0
  64. audiometa/test/tests/e2e/cli/error_handling/test_missing_fields_validation.py +49 -0
  65. audiometa/test/tests/e2e/cli/error_handling/test_multiple_files_errors.py +160 -0
  66. audiometa/test/tests/e2e/cli/error_handling/test_rating_validation.py +90 -0
  67. audiometa/test/tests/e2e/cli/error_handling/test_year_validation.py +51 -0
  68. audiometa/test/tests/e2e/cli/test_cli_delete.py +20 -0
  69. audiometa/test/tests/e2e/cli/test_cli_formatting.py +31 -0
  70. audiometa/test/tests/e2e/cli/test_cli_help.py +40 -0
  71. audiometa/test/tests/e2e/cli/test_cli_read.py +79 -0
  72. audiometa/test/tests/e2e/cli/test_cli_unified.py +33 -0
  73. audiometa/test/tests/e2e/cli/test_cli_write.py +116 -0
  74. audiometa/test/tests/e2e/scenarios/__init__.py +0 -0
  75. audiometa/test/tests/e2e/scenarios/test_user_scenarios.py +166 -0
  76. audiometa/test/tests/e2e/workflows/__init__.py +0 -0
  77. audiometa/test/tests/e2e/workflows/test_core_workflows.py +166 -0
  78. audiometa/test/tests/e2e/workflows/test_deletion_workflows.py +318 -0
  79. audiometa/test/tests/e2e/workflows/test_error_handling_workflows.py +165 -0
  80. audiometa/test/tests/e2e/workflows/test_format_specific_workflows.py +129 -0
  81. audiometa/test/tests/e2e/workflows/test_rating_workflows.py +124 -0
  82. audiometa/test/tests/integration/__init__.py +0 -0
  83. audiometa/test/tests/integration/audio_format/__init__.py +0 -0
  84. audiometa/test/tests/integration/audio_format/flac/__init__.py +0 -0
  85. audiometa/test/tests/integration/audio_format/flac/test_flac_delete_all.py +108 -0
  86. audiometa/test/tests/integration/audio_format/flac/test_flac_reading_all.py +61 -0
  87. audiometa/test/tests/integration/audio_format/flac/test_flac_reading_field.py +65 -0
  88. audiometa/test/tests/integration/audio_format/flac/test_flac_writing.py +69 -0
  89. audiometa/test/tests/integration/audio_format/mp3/__init__.py +0 -0
  90. audiometa/test/tests/integration/audio_format/mp3/test_mp3_delete_all.py +79 -0
  91. audiometa/test/tests/integration/audio_format/mp3/test_mp3_reading_all.py +61 -0
  92. audiometa/test/tests/integration/audio_format/mp3/test_mp3_reading_field.py +67 -0
  93. audiometa/test/tests/integration/audio_format/mp3/test_mp3_writing.py +60 -0
  94. audiometa/test/tests/integration/audio_format/wav/__init__.py +0 -0
  95. audiometa/test/tests/integration/audio_format/wav/test_wav_delete_all.py +87 -0
  96. audiometa/test/tests/integration/audio_format/wav/test_wav_reading_all.py +62 -0
  97. audiometa/test/tests/integration/audio_format/wav/test_wav_reading_field.py +57 -0
  98. audiometa/test/tests/integration/audio_format/wav/test_wav_with_id3v2_tags.py +83 -0
  99. audiometa/test/tests/integration/audio_format/wav/test_wav_writing.py +62 -0
  100. audiometa/test/tests/integration/conftest.py +29 -0
  101. audiometa/test/tests/integration/delete_all_metadata/__init__.py +1 -0
  102. audiometa/test/tests/integration/delete_all_metadata/test_audio_format_all.py +102 -0
  103. audiometa/test/tests/integration/delete_all_metadata/test_audio_format_header_deletion.py +77 -0
  104. audiometa/test/tests/integration/delete_all_metadata/test_basic_functionality.py +47 -0
  105. audiometa/test/tests/integration/delete_all_metadata/test_error_handling.py +24 -0
  106. audiometa/test/tests/integration/encoding/__init__.py +1 -0
  107. audiometa/test/tests/integration/encoding/test_encoding.py +88 -0
  108. audiometa/test/tests/integration/encoding/test_special_characters_edge_cases.py +223 -0
  109. audiometa/test/tests/integration/get_full_metadata/__init__.py +0 -0
  110. audiometa/test/tests/integration/get_full_metadata/test_binary_data_filtering.py +250 -0
  111. audiometa/test/tests/integration/get_full_metadata/test_get_full_metadata.py +297 -0
  112. audiometa/test/tests/integration/get_full_metadata/test_get_full_metadata_edge_cases.py +231 -0
  113. audiometa/test/tests/integration/get_full_metadata/test_options.py +207 -0
  114. audiometa/test/tests/integration/get_full_metadata/test_parsed_fields_keys.py +85 -0
  115. audiometa/test/tests/integration/metadata_field/__init__.py +0 -0
  116. audiometa/test/tests/integration/metadata_field/album/__init__.py +0 -0
  117. audiometa/test/tests/integration/metadata_field/album/test_deleting.py +73 -0
  118. audiometa/test/tests/integration/metadata_field/album/test_reading.py +36 -0
  119. audiometa/test/tests/integration/metadata_field/album/test_writing.py +50 -0
  120. audiometa/test/tests/integration/metadata_field/album_artists/__init__.py +0 -0
  121. audiometa/test/tests/integration/metadata_field/album_artists/test_deleting.py +83 -0
  122. audiometa/test/tests/integration/metadata_field/album_artists/test_reading.py +38 -0
  123. audiometa/test/tests/integration/metadata_field/album_artists/test_writing.py +52 -0
  124. audiometa/test/tests/integration/metadata_field/artists/__init__.py +0 -0
  125. audiometa/test/tests/integration/metadata_field/artists/test_deleting.py +68 -0
  126. audiometa/test/tests/integration/metadata_field/artists/test_reading.py +36 -0
  127. audiometa/test/tests/integration/metadata_field/artists/test_writing.py +46 -0
  128. audiometa/test/tests/integration/metadata_field/bpm/__init__.py +0 -0
  129. audiometa/test/tests/integration/metadata_field/bpm/test_deleting.py +75 -0
  130. audiometa/test/tests/integration/metadata_field/bpm/test_reading.py +32 -0
  131. audiometa/test/tests/integration/metadata_field/bpm/test_writing.py +56 -0
  132. audiometa/test/tests/integration/metadata_field/comment/__init__.py +0 -0
  133. audiometa/test/tests/integration/metadata_field/comment/test_deleting.py +68 -0
  134. audiometa/test/tests/integration/metadata_field/comment/test_reading.py +36 -0
  135. audiometa/test/tests/integration/metadata_field/comment/test_writing.py +49 -0
  136. audiometa/test/tests/integration/metadata_field/composer/__init__.py +0 -0
  137. audiometa/test/tests/integration/metadata_field/composer/test_deleting.py +75 -0
  138. audiometa/test/tests/integration/metadata_field/composer/test_reading.py +34 -0
  139. audiometa/test/tests/integration/metadata_field/composer/test_writing.py +41 -0
  140. audiometa/test/tests/integration/metadata_field/copyright/__init__.py +0 -0
  141. audiometa/test/tests/integration/metadata_field/copyright/test_deleting.py +81 -0
  142. audiometa/test/tests/integration/metadata_field/copyright/test_reading.py +35 -0
  143. audiometa/test/tests/integration/metadata_field/copyright/test_writing.py +41 -0
  144. audiometa/test/tests/integration/metadata_field/field_not_supported/__init__.py +0 -0
  145. audiometa/test/tests/integration/metadata_field/field_not_supported/test_deleting.py +56 -0
  146. audiometa/test/tests/integration/metadata_field/field_not_supported/test_reading.py +54 -0
  147. audiometa/test/tests/integration/metadata_field/field_not_supported/test_writing.py +61 -0
  148. audiometa/test/tests/integration/metadata_field/genre/__init__.py +0 -0
  149. audiometa/test/tests/integration/metadata_field/genre/reading/__init__.py +0 -0
  150. audiometa/test/tests/integration/metadata_field/genre/reading/metadata_format/__init__.py +0 -0
  151. audiometa/test/tests/integration/metadata_field/genre/reading/metadata_format/test_id3v1_reading.py +65 -0
  152. audiometa/test/tests/integration/metadata_field/genre/reading/metadata_format/test_id3v2_reading.py +25 -0
  153. audiometa/test/tests/integration/metadata_field/genre/reading/metadata_format/test_riff_reading.py +58 -0
  154. audiometa/test/tests/integration/metadata_field/genre/reading/metadata_format/test_vorbis_reading.py +61 -0
  155. audiometa/test/tests/integration/metadata_field/genre/reading/test_smart_reading.py +191 -0
  156. audiometa/test/tests/integration/metadata_field/genre/test_deleting.py +62 -0
  157. audiometa/test/tests/integration/metadata_field/genre/test_writing.py +64 -0
  158. audiometa/test/tests/integration/metadata_field/language/__init__.py +0 -0
  159. audiometa/test/tests/integration/metadata_field/language/test_deleting.py +75 -0
  160. audiometa/test/tests/integration/metadata_field/language/test_reading.py +39 -0
  161. audiometa/test/tests/integration/metadata_field/language/test_writing.py +43 -0
  162. audiometa/test/tests/integration/metadata_field/lyrics/__init__.py +0 -0
  163. audiometa/test/tests/integration/metadata_field/lyrics/test_deleting.py +129 -0
  164. audiometa/test/tests/integration/metadata_field/lyrics/test_reading.py +57 -0
  165. audiometa/test/tests/integration/metadata_field/lyrics/test_writing.py +59 -0
  166. audiometa/test/tests/integration/metadata_field/publisher/__init__.py +0 -0
  167. audiometa/test/tests/integration/metadata_field/publisher/test_deleting.py +88 -0
  168. audiometa/test/tests/integration/metadata_field/publisher/test_reading.py +32 -0
  169. audiometa/test/tests/integration/metadata_field/publisher/test_writing.py +47 -0
  170. audiometa/test/tests/integration/metadata_field/rating/__init__.py +0 -0
  171. audiometa/test/tests/integration/metadata_field/rating/reading/__init__.py +0 -0
  172. audiometa/test/tests/integration/metadata_field/rating/reading/test_base_100_proportional.py +81 -0
  173. audiometa/test/tests/integration/metadata_field/rating/reading/test_base_255_non_proportional.py +33 -0
  174. audiometa/test/tests/integration/metadata_field/rating/reading/test_base_255_proportional.py +58 -0
  175. audiometa/test/tests/integration/metadata_field/rating/test_deleting.py +117 -0
  176. audiometa/test/tests/integration/metadata_field/rating/test_error_handling.py +137 -0
  177. audiometa/test/tests/integration/metadata_field/rating/writing/__init__.py +0 -0
  178. audiometa/test/tests/integration/metadata_field/rating/writing/metadata_format/__init__.py +0 -0
  179. audiometa/test/tests/integration/metadata_field/rating/writing/metadata_format/test_id3v2.py +77 -0
  180. audiometa/test/tests/integration/metadata_field/rating/writing/metadata_format/test_riff.py +55 -0
  181. audiometa/test/tests/integration/metadata_field/rating/writing/metadata_format/test_vorbis.py +57 -0
  182. audiometa/test/tests/integration/metadata_field/rating/writing/test_comprehensive.py +192 -0
  183. audiometa/test/tests/integration/metadata_field/release_date/__init__.py +0 -0
  184. audiometa/test/tests/integration/metadata_field/release_date/test_deleting.py +74 -0
  185. audiometa/test/tests/integration/metadata_field/release_date/test_error_handling.py +82 -0
  186. audiometa/test/tests/integration/metadata_field/release_date/test_reading.py +59 -0
  187. audiometa/test/tests/integration/metadata_field/release_date/test_writing.py +49 -0
  188. audiometa/test/tests/integration/metadata_field/test_metadata_field_validation.py +134 -0
  189. audiometa/test/tests/integration/metadata_field/title/__init__.py +0 -0
  190. audiometa/test/tests/integration/metadata_field/title/test_deleting.py +73 -0
  191. audiometa/test/tests/integration/metadata_field/title/test_error_handling.py +47 -0
  192. audiometa/test/tests/integration/metadata_field/title/test_reading.py +36 -0
  193. audiometa/test/tests/integration/metadata_field/title/test_writing.py +64 -0
  194. audiometa/test/tests/integration/metadata_field/track_number/__init__.py +0 -0
  195. audiometa/test/tests/integration/metadata_field/track_number/reading/__init__.py +0 -0
  196. audiometa/test/tests/integration/metadata_field/track_number/reading/test_edge_cases.py +43 -0
  197. audiometa/test/tests/integration/metadata_field/track_number/reading/test_metadata_format.py +32 -0
  198. audiometa/test/tests/integration/metadata_field/track_number/test_deleting.py +59 -0
  199. audiometa/test/tests/integration/metadata_field/track_number/test_writing.py +73 -0
  200. audiometa/test/tests/integration/multiple_values/__init__.py +1 -0
  201. audiometa/test/tests/integration/multiple_values/reading/__init__.py +1 -0
  202. audiometa/test/tests/integration/multiple_values/reading/metadata_format/__init__.py +1 -0
  203. audiometa/test/tests/integration/multiple_values/reading/metadata_format/test_id3v1.py +23 -0
  204. audiometa/test/tests/integration/multiple_values/reading/metadata_format/test_id3v2_3.py +92 -0
  205. audiometa/test/tests/integration/multiple_values/reading/metadata_format/test_id3v2_4.py +216 -0
  206. audiometa/test/tests/integration/multiple_values/reading/metadata_format/test_riff.py +84 -0
  207. audiometa/test/tests/integration/multiple_values/reading/metadata_format/test_vorbis.py +169 -0
  208. audiometa/test/tests/integration/multiple_values/reading/test_performance_large_data.py +209 -0
  209. audiometa/test/tests/integration/multiple_values/reading/test_smart_parsing_scenarios.py +198 -0
  210. audiometa/test/tests/integration/multiple_values/reading/test_unicode_handling.py +24 -0
  211. audiometa/test/tests/integration/multiple_values/writing/__init__.py +1 -0
  212. audiometa/test/tests/integration/multiple_values/writing/metadata_format/__init__.py +1 -0
  213. audiometa/test/tests/integration/multiple_values/writing/metadata_format/test_id3v1.py +62 -0
  214. audiometa/test/tests/integration/multiple_values/writing/metadata_format/test_id3v2_3.py +36 -0
  215. audiometa/test/tests/integration/multiple_values/writing/metadata_format/test_id3v2_4.py +34 -0
  216. audiometa/test/tests/integration/multiple_values/writing/metadata_format/test_riff.py +32 -0
  217. audiometa/test/tests/integration/multiple_values/writing/metadata_format/test_vorbis.py +54 -0
  218. audiometa/test/tests/integration/multiple_values/writing/test_error_handling.py +42 -0
  219. audiometa/test/tests/integration/multiple_values/writing/test_large_values.py +98 -0
  220. audiometa/test/tests/integration/reading/__init__.py +1 -0
  221. audiometa/test/tests/integration/reading/test_read_multiple_metadata.py +80 -0
  222. audiometa/test/tests/integration/reading/test_reading_error_handling.py +36 -0
  223. audiometa/test/tests/integration/real_audio_files/__init__.py +0 -0
  224. audiometa/test/tests/integration/real_audio_files/test_reading.py +146 -0
  225. audiometa/test/tests/integration/real_audio_files/test_writing.py +198 -0
  226. audiometa/test/tests/integration/technical_info/__init__.py +0 -0
  227. audiometa/test/tests/integration/technical_info/flac_md5/__init__.py +0 -0
  228. audiometa/test/tests/integration/technical_info/flac_md5/conftest.py +103 -0
  229. audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/__init__.py +0 -0
  230. audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/test_audio_data_corruption.py +21 -0
  231. audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/test_flipped_md5.py +29 -0
  232. audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/test_non_flac_error.py +13 -0
  233. audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/test_partial_md5.py +29 -0
  234. audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/test_random_md5.py +29 -0
  235. audiometa/test/tests/integration/technical_info/flac_md5/test_invalid_md5/test_unset_md5.py +56 -0
  236. audiometa/test/tests/integration/technical_info/flac_md5/test_valid_md5.py +21 -0
  237. audiometa/test/tests/integration/technical_info/test_bitrate.py +38 -0
  238. audiometa/test/tests/integration/technical_info/test_channels.py +38 -0
  239. audiometa/test/tests/integration/technical_info/test_duration_in_sec.py +38 -0
  240. audiometa/test/tests/integration/technical_info/test_sample_rate.py +40 -0
  241. audiometa/test/tests/integration/test_audio_file.py +23 -0
  242. audiometa/test/tests/integration/test_audio_format_readable_after_update_all_metadata_formats.py +95 -0
  243. audiometa/test/tests/integration/writing/__init__.py +0 -0
  244. audiometa/test/tests/integration/writing/test_error_handling.py +44 -0
  245. audiometa/test/tests/integration/writing/test_forced_format.py +224 -0
  246. audiometa/test/tests/integration/writing/test_multiple_format_preservation.py +223 -0
  247. audiometa/test/tests/integration/writing/test_partial_update.py +36 -0
  248. audiometa/test/tests/integration/writing/writing_strategies/__init__.py +0 -0
  249. audiometa/test/tests/integration/writing/writing_strategies/test_cleanup_strategy.py +79 -0
  250. audiometa/test/tests/integration/writing/writing_strategies/test_preserve_strategy.py +76 -0
  251. audiometa/test/tests/integration/writing/writing_strategies/test_sync_strategy.py +215 -0
  252. audiometa/test/tests/integration/writing/writing_strategies/unsupported_fields/__init__.py +0 -0
  253. audiometa/test/tests/integration/writing/writing_strategies/unsupported_fields/test_fail_behavior.py +42 -0
  254. audiometa/test/tests/integration/writing/writing_strategies/unsupported_fields/test_no_writing_on_failure.py +93 -0
  255. audiometa/test/tests/integration/writing/writing_strategies/unsupported_fields/test_strategy_specific.py +99 -0
  256. audiometa/test/tests/unit/__init__.py +0 -0
  257. audiometa/test/tests/unit/audio_file/__init__.py +0 -0
  258. audiometa/test/tests/unit/audio_file/technical_info/__init__.py +0 -0
  259. audiometa/test/tests/unit/audio_file/technical_info/test_bitrate.py +26 -0
  260. audiometa/test/tests/unit/audio_file/technical_info/test_channels.py +31 -0
  261. audiometa/test/tests/unit/audio_file/technical_info/test_duration_in_sec.py +38 -0
  262. audiometa/test/tests/unit/audio_file/technical_info/test_error_handling.py +130 -0
  263. audiometa/test/tests/unit/audio_file/technical_info/test_file_size.py +51 -0
  264. audiometa/test/tests/unit/audio_file/technical_info/test_format_name.py +28 -0
  265. audiometa/test/tests/unit/audio_file/technical_info/test_sample_rate.py +31 -0
  266. audiometa/test/tests/unit/audio_file/test_context_manager.py +30 -0
  267. audiometa/test/tests/unit/audio_file/test_file_validation.py +40 -0
  268. audiometa/test/tests/unit/audio_file/test_operations.py +20 -0
  269. audiometa/test/tests/unit/audio_file/test_path_handling.py +23 -0
  270. audiometa/test/tests/unit/cli/__init__.py +0 -0
  271. audiometa/test/tests/unit/cli/test_expand_file_patterns.py +234 -0
  272. audiometa/test/tests/unit/metadata_managers/__init__.py +0 -0
  273. audiometa/test/tests/unit/metadata_managers/conftest.py +142 -0
  274. audiometa/test/tests/unit/metadata_managers/header_info/__init__.py +0 -0
  275. audiometa/test/tests/unit/metadata_managers/header_info/test_id3v1.py +49 -0
  276. audiometa/test/tests/unit/metadata_managers/header_info/test_id3v2.py +66 -0
  277. audiometa/test/tests/unit/metadata_managers/header_info/test_riff.py +57 -0
  278. audiometa/test/tests/unit/metadata_managers/header_info/test_vorbis.py +53 -0
  279. audiometa/test/tests/unit/metadata_managers/metadata_field/__init__.py +0 -0
  280. audiometa/test/tests/unit/metadata_managers/metadata_field/multiple_values/__init__.py +0 -0
  281. audiometa/test/tests/unit/metadata_managers/metadata_field/multiple_values/reading/__init__.py +0 -0
  282. audiometa/test/tests/unit/metadata_managers/metadata_field/multiple_values/reading/test_smart_parsing.py +186 -0
  283. audiometa/test/tests/unit/metadata_managers/metadata_field/multiple_values/writing/__init__.py +0 -0
  284. audiometa/test/tests/unit/metadata_managers/metadata_field/multiple_values/writing/test_separator_selection.py +142 -0
  285. audiometa/test/tests/unit/metadata_managers/metadata_field/multiple_values/writing/test_value_filtering.py +76 -0
  286. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/__init__.py +0 -0
  287. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/reading/__init__.py +0 -0
  288. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/reading/test_normalization.py +152 -0
  289. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/reading/test_profiles_values.py +23 -0
  290. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/test_rating_validation.py +77 -0
  291. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/writing/__init__.py +0 -0
  292. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/writing/test_validation.py +151 -0
  293. audiometa/test/tests/unit/metadata_managers/metadata_field/rating/writing/test_writing_profiles.py +61 -0
  294. audiometa/test/tests/unit/metadata_managers/metadata_field/test_date_format_validation.py +135 -0
  295. audiometa/test/tests/unit/metadata_managers/metadata_field/test_track_number_validation.py +46 -0
  296. audiometa/test/tests/unit/metadata_managers/metadata_field/test_type_validation_exception.py +22 -0
  297. audiometa/test/tests/unit/metadata_managers/metadata_field/test_validation.py +83 -0
  298. audiometa/test/tests/unit/metadata_managers/test_metadata_format_managers_write_and_read.py +74 -0
  299. audiometa/utils/__init__.py +1 -0
  300. audiometa/utils/id3v1_genre_code_map.py +205 -0
  301. audiometa/utils/metadata_format.py +31 -0
  302. audiometa/utils/metadata_writing_strategy.py +16 -0
  303. audiometa/utils/os_dependencies_checker/__init__.py +24 -0
  304. audiometa/utils/os_dependencies_checker/base.py +62 -0
  305. audiometa/utils/os_dependencies_checker/config.py +51 -0
  306. audiometa/utils/os_dependencies_checker/macos.py +236 -0
  307. audiometa/utils/os_dependencies_checker/ubuntu.py +95 -0
  308. audiometa/utils/os_dependencies_checker/windows.py +227 -0
  309. audiometa/utils/rating_profiles.py +110 -0
  310. audiometa/utils/tool_path_resolver.py +135 -0
  311. audiometa/utils/types.py +82 -0
  312. audiometa/utils/unified_metadata_key.py +81 -0
  313. audiometa_python-0.2.2.dist-info/METADATA +1464 -0
  314. audiometa_python-0.2.2.dist-info/RECORD +318 -0
  315. audiometa_python-0.2.2.dist-info/WHEEL +5 -0
  316. audiometa_python-0.2.2.dist-info/entry_points.txt +2 -0
  317. audiometa_python-0.2.2.dist-info/licenses/LICENSE +202 -0
  318. audiometa_python-0.2.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,205 @@
1
+ """Standard ID3v1 genre codes mapping.
2
+
3
+ This is the complete standard genre map used by both ID3v1 and RIFF formats. Genres 0-79 are from the original ID3v1
4
+ spec. Genres 80-125 were added by Winamp. Genres 126-147 were added by other players. Genres 148-191 were added in
5
+ Winamp 5.6 (November 2010).
6
+ """
7
+
8
+ ID3V1_GENRE_CODE_MAP = {
9
+ 0: "Blues",
10
+ 1: "Classic Rock",
11
+ 2: "Country",
12
+ 3: "Dance",
13
+ 4: "Disco",
14
+ 5: "Funk",
15
+ 6: "Grunge",
16
+ 7: "Hip-Hop",
17
+ 8: "Jazz",
18
+ 9: "Metal",
19
+ 10: "New Age",
20
+ 11: "Oldies",
21
+ 12: "Other",
22
+ 13: "Pop",
23
+ 14: "R&B",
24
+ 15: "Rap",
25
+ 16: "Reggae",
26
+ 17: "Rock",
27
+ 18: "Techno",
28
+ 19: "Industrial",
29
+ 20: "Alternative",
30
+ 21: "Ska",
31
+ 22: "Death Metal",
32
+ 23: "Pranks",
33
+ 24: "Soundtrack",
34
+ 25: "Euro-Techno",
35
+ 26: "Ambient",
36
+ 27: "Trip-Hop",
37
+ 28: "Vocal",
38
+ 29: "Jazz+Funk",
39
+ 30: "Fusion",
40
+ 31: "Trance",
41
+ 32: "Classical",
42
+ 33: "Instrumental",
43
+ 34: "Acid",
44
+ 35: "House",
45
+ 36: "Game",
46
+ 37: "Sound Clip",
47
+ 38: "Gospel",
48
+ 39: "Noise",
49
+ 40: "Alternative Rock",
50
+ 41: "Bass",
51
+ 42: "Soul",
52
+ 43: "Punk",
53
+ 44: "Space",
54
+ 45: "Meditative",
55
+ 46: "Instrumental Pop",
56
+ 47: "Instrumental Rock",
57
+ 48: "Ethnic",
58
+ 49: "Gothic",
59
+ 50: "Darkwave",
60
+ 51: "Techno-Industrial",
61
+ 52: "Electronic",
62
+ 53: "Pop-Folk",
63
+ 54: "Eurodance",
64
+ 55: "Dream",
65
+ 56: "Southern Rock",
66
+ 57: "Comedy",
67
+ 58: "Cult",
68
+ 59: "Gangsta",
69
+ 60: "Top 40",
70
+ 61: "Christian Rap",
71
+ 62: "Pop/Funk",
72
+ 63: "Jungle",
73
+ 64: "Native US",
74
+ 65: "Cabaret",
75
+ 66: "New Wave",
76
+ 67: "Psychedelic",
77
+ 68: "Rave",
78
+ 69: "Showtunes",
79
+ 70: "Trailer",
80
+ 71: "Lo-Fi",
81
+ 72: "Tribal",
82
+ 73: "Acid Punk",
83
+ 74: "Acid Jazz",
84
+ 75: "Polka",
85
+ 76: "Retro",
86
+ 77: "Musical",
87
+ 78: "Rock & Roll",
88
+ 79: "Hard Rock",
89
+ # Winamp extensions
90
+ 80: "Folk",
91
+ 81: "Folk-Rock",
92
+ 82: "National Folk",
93
+ 83: "Swing",
94
+ 84: "Fast Fusion",
95
+ 85: "Bebop",
96
+ 86: "Latin",
97
+ 87: "Revival",
98
+ 88: "Celtic",
99
+ 89: "Bluegrass",
100
+ 90: "Avantgarde",
101
+ 91: "Gothic Rock",
102
+ 92: "Progressive Rock",
103
+ 93: "Psychedelic Rock",
104
+ 94: "Symphonic Rock",
105
+ 95: "Slow Rock",
106
+ 96: "Big Band",
107
+ 97: "Chorus",
108
+ 98: "Easy Listening",
109
+ 99: "Acoustic",
110
+ 100: "Humour",
111
+ 101: "Speech",
112
+ 102: "Chanson",
113
+ 103: "Opera",
114
+ 104: "Chamber Music",
115
+ 105: "Sonata",
116
+ 106: "Symphony",
117
+ 107: "Booty Bass",
118
+ 108: "Primus",
119
+ 109: "Porn Groove",
120
+ 110: "Satire",
121
+ 111: "Slow Jam",
122
+ 112: "Club",
123
+ 113: "Tango",
124
+ 114: "Samba",
125
+ 115: "Folklore",
126
+ 116: "Ballad",
127
+ 117: "Power Ballad",
128
+ 118: "Rhythmic Soul",
129
+ 119: "Freestyle",
130
+ 120: "Duet",
131
+ 121: "Punk Rock",
132
+ 122: "Drum Solo",
133
+ 123: "A Cappella",
134
+ 124: "Euro-House",
135
+ 125: "Dance Hall",
136
+ # Other extensions
137
+ 126: "Goa",
138
+ 127: "Drum & Bass",
139
+ 128: "Club-House",
140
+ 129: "Hardcore",
141
+ 130: "Terror",
142
+ 131: "Indie",
143
+ 132: "BritPop",
144
+ 133: "Negerpunk",
145
+ 134: "Polsk Punk",
146
+ 135: "Beat",
147
+ 136: "Christian Gangsta Rap",
148
+ 137: "Heavy Metal",
149
+ 138: "Black Metal",
150
+ 139: "Crossover",
151
+ 140: "Contemporary Christian",
152
+ 141: "Christian Rock",
153
+ 142: "Merengue",
154
+ 143: "Salsa",
155
+ 144: "Thrash Metal",
156
+ 145: "Anime",
157
+ 146: "JPop",
158
+ 147: "Synthpop",
159
+ # Winamp 5.6 extensions (November 2010)
160
+ 148: "Christmas",
161
+ 149: "Art Rock",
162
+ 150: "Baroque",
163
+ 151: "Bhangra",
164
+ 152: "Big Beat",
165
+ 153: "Breakbeat",
166
+ 154: "Chillout",
167
+ 155: "Downtempo",
168
+ 156: "Dub",
169
+ 157: "EBM",
170
+ 158: "Eclectic",
171
+ 159: "Electro",
172
+ 160: "Electroclash",
173
+ 161: "Emo",
174
+ 162: "Experimental",
175
+ 163: "Garage",
176
+ 164: "Global",
177
+ 165: "IDM",
178
+ 166: "Illbient",
179
+ 167: "Industro-Goth",
180
+ 168: "Jam Band",
181
+ 169: "Krautrock",
182
+ 170: "Leftfield",
183
+ 171: "Lounge",
184
+ 172: "Math Rock",
185
+ 173: "New Romantic",
186
+ 174: "Nu-Breakz",
187
+ 175: "Post-Punk",
188
+ 176: "Post-Rock",
189
+ 177: "Psytrance",
190
+ 178: "Shoegaze",
191
+ 179: "Space Rock",
192
+ 180: "Trop Rock",
193
+ 181: "World Music",
194
+ 182: "Neoclassical",
195
+ 183: "Audiobook",
196
+ 184: "Audio Theatre",
197
+ 185: "Neue Deutsche Welle",
198
+ 186: "Podcast",
199
+ 187: "Indie Rock",
200
+ 188: "G-Funk",
201
+ 189: "Dubstep",
202
+ 190: "Garage Rock",
203
+ 191: "Psybient",
204
+ 255: None,
205
+ }
@@ -0,0 +1,31 @@
1
+ """Tag type constants for audio metadata handling.
2
+
3
+ This module defines the supported metadata formats and their file extension priorities for reading and writing audio
4
+ metadata across different file types.
5
+ """
6
+
7
+ from enum import Enum
8
+
9
+
10
+ class MetadataFormat(str, Enum):
11
+ """Enumeration of supported audio metadata formats."""
12
+
13
+ ID3V2 = "id3v2"
14
+ ID3V1 = "id3v1"
15
+ VORBIS = "vorbis"
16
+ RIFF = "riff"
17
+
18
+ @classmethod
19
+ def get_priorities(cls) -> dict[str, list["MetadataFormat"]]:
20
+ """Get tag format priorities for different file formats.
21
+
22
+ First tag format in each list has highest priority.
23
+
24
+ Returns:
25
+ dictionary mapping file extensions to ordered list of tag types
26
+ """
27
+ return {
28
+ ".flac": [cls.VORBIS, cls.ID3V2, cls.ID3V1],
29
+ ".mp3": [cls.ID3V2, cls.ID3V1],
30
+ ".wav": [cls.RIFF, cls.ID3V2, cls.ID3V1],
31
+ }
@@ -0,0 +1,16 @@
1
+ """Metadata writing strategy constants for audio metadata handling."""
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class MetadataWritingStrategy(str, Enum):
7
+ """Strategy for handling metadata when writing to files with existing metadata in other formats."""
8
+
9
+ SYNC = "sync"
10
+ """Write to native format and synchronize other metadata formats that are already present (default)"""
11
+
12
+ PRESERVE = "preserve"
13
+ """Write to native format only, preserve existing metadata in other formats."""
14
+
15
+ CLEANUP = "cleanup"
16
+ """Write to native format and remove all non-native metadata formats."""
@@ -0,0 +1,24 @@
1
+ """OS-specific dependency checkers for verifying system dependencies."""
2
+
3
+ import platform
4
+
5
+ from audiometa.utils.os_dependencies_checker.base import OsDependenciesChecker
6
+ from audiometa.utils.os_dependencies_checker.macos import MacOSDependenciesChecker
7
+ from audiometa.utils.os_dependencies_checker.ubuntu import UbuntuDependenciesChecker
8
+ from audiometa.utils.os_dependencies_checker.windows import WindowsDependenciesChecker
9
+
10
+
11
+ def get_dependencies_checker() -> OsDependenciesChecker | None:
12
+ """Get the appropriate OS-specific dependencies checker.
13
+
14
+ Returns:
15
+ OS-specific checker instance, or None if OS not supported
16
+ """
17
+ system = platform.system().lower()
18
+ if system == "darwin":
19
+ return MacOSDependenciesChecker()
20
+ if system == "linux":
21
+ return UbuntuDependenciesChecker()
22
+ if system == "windows":
23
+ return WindowsDependenciesChecker()
24
+ return None
@@ -0,0 +1,62 @@
1
+ """Base class for OS-specific dependency checkers."""
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+
6
+ class OsDependenciesChecker(ABC):
7
+ """Base class for OS-specific dependency checkers."""
8
+
9
+ @classmethod
10
+ @abstractmethod
11
+ def get_os_type(cls) -> str:
12
+ """Get OS type identifier."""
13
+
14
+ @abstractmethod
15
+ def check_tool_available(self, tool_name: str) -> bool:
16
+ """Check if a tool is available (in PATH or default locations).
17
+
18
+ Args:
19
+ tool_name: Name of the tool to check
20
+
21
+ Returns:
22
+ True if tool is available, False otherwise
23
+ """
24
+
25
+ @abstractmethod
26
+ def get_installed_version(self, package: str, expected_version: str | None = None) -> str | None:
27
+ """Get installed version of a package.
28
+
29
+ Args:
30
+ package: Package name
31
+ expected_version: Optional expected/pinned version
32
+
33
+ Returns:
34
+ Installed version string, or None if not found
35
+ """
36
+
37
+ @staticmethod
38
+ def _normalize_version(version: str) -> str:
39
+ """Normalize version string by removing revision suffix.
40
+
41
+ Args:
42
+ version: Version string (e.g., "7.1_4" or "1.5.0")
43
+
44
+ Returns:
45
+ Normalized version without revision suffix
46
+ """
47
+ return version.split("_")[0]
48
+
49
+ @staticmethod
50
+ def _versions_match(version1: str, version2: str) -> bool:
51
+ """Check if two version strings match (handles different precision).
52
+
53
+ Args:
54
+ version1: First version string
55
+ version2: Second version string
56
+
57
+ Returns:
58
+ True if versions match, False otherwise
59
+ """
60
+ v1_normalized = OsDependenciesChecker._normalize_version(version1)
61
+ v2_normalized = OsDependenciesChecker._normalize_version(version2)
62
+ return v1_normalized == v2_normalized or v2_normalized.startswith(v1_normalized + ".")
@@ -0,0 +1,51 @@
1
+ """Configuration loading for OS-specific dependency checkers."""
2
+
3
+ import tomllib
4
+ from pathlib import Path
5
+
6
+
7
+ def load_dependencies_pinned_versions() -> dict[str, dict[str, str]] | None:
8
+ """Load pinned versions from system-dependencies.toml.
9
+
10
+ Returns:
11
+ Dictionary mapping tool names to OS-specific versions, or None if config not found
12
+ """
13
+ # Try to find system-dependencies.toml relative to this file
14
+ # This file is in audiometa/utils/os_dependencies_checker/, so go up to project root
15
+ config_path = Path(__file__).parent.parent.parent.parent / "system-dependencies.toml"
16
+
17
+ if not config_path.exists():
18
+ return None
19
+
20
+ try:
21
+ with config_path.open("rb") as f:
22
+ config = tomllib.load(f)
23
+
24
+ pinned_versions: dict[str, dict[str, str]] = {}
25
+
26
+ # Extract versions for each OS
27
+ for os_type in ["ubuntu", "macos", "windows"]:
28
+ if os_type not in config:
29
+ continue
30
+
31
+ os_config = config[os_type]
32
+ for tool in ["ffmpeg", "flac", "mediainfo", "id3v2", "bwfmetaedit", "exiftool"]:
33
+ if tool not in os_config:
34
+ continue
35
+
36
+ version_value = os_config[tool]
37
+ # Handle both string values and dict values (for bwfmetaedit, exiftool on Windows)
38
+ if isinstance(version_value, str):
39
+ version = version_value
40
+ elif isinstance(version_value, dict) and "pinned_version" in version_value:
41
+ version = version_value["pinned_version"]
42
+ else:
43
+ continue
44
+
45
+ if tool not in pinned_versions:
46
+ pinned_versions[tool] = {}
47
+ pinned_versions[tool][os_type] = version
48
+ except Exception:
49
+ return None
50
+ else:
51
+ return pinned_versions
@@ -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])