SpotiFLAC 4.2.2__tar.gz → 4.3.0__tar.gz

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 (344) hide show
  1. {spotiflac-4.2.2/SpotiFLAC.egg-info → spotiflac-4.3.0}/PKG-INFO +1 -1
  2. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/health_check.py +7 -0
  3. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/lyrics.py +310 -15
  4. spotiflac-4.3.0/SpotiFLAC/core/provider_cooldown.py +226 -0
  5. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/signed_session_desktop.py +23 -2
  6. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/signed_session_errors.py +29 -0
  7. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/signed_session_mobile.py +30 -12
  8. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/downloader.py +8 -1
  9. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/extensions/_bridge.js +39 -8
  10. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/extensions/provider.py +1 -0
  11. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/extensions/runtime.py +13 -1
  12. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/frontend/app.js +7 -1
  13. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/frontend/index.html +5 -5
  14. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/launcher.py +4 -1
  15. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/tui/config_state.py +4 -1
  16. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/tui/config_view.py +5 -2
  17. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/webapp.py +3 -3
  18. {spotiflac-4.2.2 → spotiflac-4.3.0/SpotiFLAC.egg-info}/PKG-INFO +1 -1
  19. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC.egg-info/SOURCES.txt +4 -0
  20. {spotiflac-4.2.2 → spotiflac-4.3.0}/pyproject.toml +1 -1
  21. spotiflac-4.3.0/tests/test_lyrics_ttml_catalogues.py +246 -0
  22. spotiflac-4.3.0/tests/test_provider_cooldown.py +266 -0
  23. spotiflac-4.3.0/tests/test_rate_limit_logging.py +300 -0
  24. {spotiflac-4.2.2 → spotiflac-4.3.0}/LICENSE +0 -0
  25. {spotiflac-4.2.2 → spotiflac-4.3.0}/MANIFEST.in +0 -0
  26. {spotiflac-4.2.2 → spotiflac-4.3.0}/README.md +0 -0
  27. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/__init__.py +0 -0
  28. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/__main__.py +0 -0
  29. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/api_mixins/__init__.py +0 -0
  30. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/api_mixins/covers_lyrics.py +0 -0
  31. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/api_mixins/csv_import.py +0 -0
  32. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/api_mixins/dedup.py +0 -0
  33. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/api_mixins/discovery.py +0 -0
  34. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/api_mixins/extension_health.py +0 -0
  35. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/api_mixins/failed_tracks.py +0 -0
  36. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/api_mixins/local_tagging.py +0 -0
  37. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/api_mixins/search.py +0 -0
  38. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/api_mixins/signed_sessions.py +0 -0
  39. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/api_mixins/stats.py +0 -0
  40. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/api_mixins/subscriptions.py +0 -0
  41. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/api_mixins/trust.py +0 -0
  42. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/app.py +0 -0
  43. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/check_update.py +0 -0
  44. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/client.py +0 -0
  45. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/__init__.py +0 -0
  46. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/acoustid_lookup.py +0 -0
  47. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/apple_music_metadata.py +0 -0
  48. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/apple_ttml.py +0 -0
  49. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/atomic_io.py +0 -0
  50. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/audio_fingerprint.py +0 -0
  51. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/base.py +0 -0
  52. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/cache_admin.py +0 -0
  53. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/canvas.py +0 -0
  54. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/cli_preview.py +0 -0
  55. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/code_search.py +0 -0
  56. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/console.py +0 -0
  57. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/cross_loop_lock.py +0 -0
  58. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/csv_picker.py +0 -0
  59. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/csv_source.py +0 -0
  60. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/db.py +0 -0
  61. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/dns_doh.py +0 -0
  62. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/download_log.py +0 -0
  63. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/download_validation.py +0 -0
  64. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/errors.py +0 -0
  65. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/extension_enrichment.py +0 -0
  66. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/extension_metadata.py +0 -0
  67. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/failed_tracks.py +0 -0
  68. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/ffmpeg_check.py +0 -0
  69. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/flac_validation.py +0 -0
  70. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/hires_check.py +0 -0
  71. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/history.py +0 -0
  72. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/hooks.py +0 -0
  73. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/http.py +0 -0
  74. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/isrc_cache.py +0 -0
  75. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/isrc_finder.py +0 -0
  76. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/isrc_helper.py +0 -0
  77. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/isrc_utils.py +0 -0
  78. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/job_queue.py +0 -0
  79. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/library_dedup.py +0 -0
  80. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/library_index_cache.py +0 -0
  81. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/library_notify.py +0 -0
  82. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/library_upgrade.py +0 -0
  83. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/link_resolver.py +0 -0
  84. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/local_matcher.py +0 -0
  85. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/local_processor.py +0 -0
  86. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/local_scanner.py +0 -0
  87. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/loop_runner.py +0 -0
  88. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/metadata_enrichment.py +0 -0
  89. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/metadata_fallback.py +0 -0
  90. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/models.py +0 -0
  91. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/musicbrainz.py +0 -0
  92. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/node_check.py +0 -0
  93. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/notifiers.py +0 -0
  94. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/output_lock.py +0 -0
  95. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/output_sink.py +0 -0
  96. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/paths.py +0 -0
  97. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/playlist_sync.py +0 -0
  98. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/profiles.py +0 -0
  99. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/progress.py +0 -0
  100. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/provider_stats.py +0 -0
  101. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/quality.py +0 -0
  102. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/recording_guard.py +0 -0
  103. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/replaygain.py +0 -0
  104. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/report.py +0 -0
  105. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/response_cache.py +0 -0
  106. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/session_memory.py +0 -0
  107. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/signed_session_mono.py +0 -0
  108. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/signed_session_status.py +0 -0
  109. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/solver.py +0 -0
  110. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/soundplate.py +0 -0
  111. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/spotfetch.py +0 -0
  112. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/spotify_metadata.py +0 -0
  113. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/spotify_protobuf.py +0 -0
  114. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/spotify_totp.py +0 -0
  115. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/stats.py +0 -0
  116. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/subscription_scheduler.py +0 -0
  117. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/subscriptions.py +0 -0
  118. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/tagger.py +0 -0
  119. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/text_match.py +0 -0
  120. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/tidal_metadata.py +0 -0
  121. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/tracklist.py +0 -0
  122. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/transcode.py +0 -0
  123. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/url_utils.py +0 -0
  124. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/core/web_users.py +0 -0
  125. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/extensions/__init__.py +0 -0
  126. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/extensions/_fsguard.js +0 -0
  127. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/extensions/_netguard.js +0 -0
  128. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/extensions/catalog.py +0 -0
  129. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/extensions/directories.py +0 -0
  130. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/extensions/manager.py +0 -0
  131. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/extensions/python_provider.py +0 -0
  132. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/extensions/registry_config.py +0 -0
  133. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/extensions/runtime_features.py +0 -0
  134. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/extensions/sandbox.py +0 -0
  135. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/extensions/trust.py +0 -0
  136. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/frontend/assets/icons/am.png +0 -0
  137. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/frontend/assets/icons/amzn.png +0 -0
  138. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/frontend/assets/icons/apple-touch-icon.png +0 -0
  139. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/frontend/assets/icons/download.svg +0 -0
  140. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/frontend/assets/icons/dzr.png +0 -0
  141. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/frontend/assets/icons/flacdownloader.png +0 -0
  142. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/frontend/assets/icons/joox.svg +0 -0
  143. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/frontend/assets/icons/kuwo.png +0 -0
  144. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/frontend/assets/icons/logo-192.png +0 -0
  145. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/frontend/assets/icons/logo-512.png +0 -0
  146. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/frontend/assets/icons/logo.svg +0 -0
  147. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/frontend/assets/icons/lrclib.png +0 -0
  148. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/frontend/assets/icons/migu.jpeg +0 -0
  149. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/frontend/assets/icons/musicbrainz_d.png +0 -0
  150. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/frontend/assets/icons/musicbrainz_l.png +0 -0
  151. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/frontend/assets/icons/musixmatch.svg +0 -0
  152. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/frontend/assets/icons/netease.svg +0 -0
  153. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/frontend/assets/icons/next.svg +0 -0
  154. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/frontend/assets/icons/pandora.svg +0 -0
  155. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/frontend/assets/icons/qbz.png +0 -0
  156. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/frontend/assets/icons/songlink_d.png +0 -0
  157. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/frontend/assets/icons/songlink_l.png +0 -0
  158. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/frontend/assets/icons/songstats.png +0 -0
  159. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/frontend/assets/icons/soundcloud.svg +0 -0
  160. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/frontend/assets/icons/spotify.svg +0 -0
  161. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/frontend/assets/icons/spotubedl.svg +0 -0
  162. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/frontend/assets/icons/tidal_d.png +0 -0
  163. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/frontend/assets/icons/tidal_l.png +0 -0
  164. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/frontend/assets/icons/xbatchdl.svg +0 -0
  165. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/frontend/assets/icons/youtube.svg +0 -0
  166. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/frontend/manifest.json +0 -0
  167. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/frontend/styles.css +0 -0
  168. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/frontend/sw.js +0 -0
  169. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/frontend/toast-system.js +0 -0
  170. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/frontend/web-shim.js +0 -0
  171. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/py.typed +0 -0
  172. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/tools/__init__.py +0 -0
  173. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/tools/bump_cache_bust.py +0 -0
  174. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/tools/cloud_config_cli.py +0 -0
  175. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/tools/dedup_check_cli.py +0 -0
  176. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/tools/ext_dryrun.py +0 -0
  177. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/tools/ext_scaffold.py +0 -0
  178. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/tools/hires_check_cli.py +0 -0
  179. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/tools/jellyfin_lyrics_gap.py +0 -0
  180. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/tools/library_dedup_cli.py +0 -0
  181. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/tools/library_upgrade_cli.py +0 -0
  182. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/tools/registry_signing_cli.py +0 -0
  183. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/tui/__init__.py +0 -0
  184. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/tui/app.py +0 -0
  185. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/tui/banner.py +0 -0
  186. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/tui/branding.py +0 -0
  187. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/tui/clipboard.py +0 -0
  188. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/tui/csv_picker_screen.py +0 -0
  189. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/tui/extensions_view.py +0 -0
  190. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/tui/health_view.py +0 -0
  191. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/tui/help_screen.py +0 -0
  192. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/tui/queue_view.py +0 -0
  193. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/tui/runner.py +0 -0
  194. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/tui/search_view.py +0 -0
  195. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/tui/session_view.py +0 -0
  196. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/tui/signed_sessions_view.py +0 -0
  197. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/tui/spotiflac.tcss +0 -0
  198. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/tui/tracklist_view.py +0 -0
  199. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/tui/window_size.py +0 -0
  200. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/webapi/__init__.py +0 -0
  201. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/webapi/routes.py +0 -0
  202. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC/webapi/schemas.py +0 -0
  203. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC.egg-info/dependency_links.txt +0 -0
  204. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC.egg-info/entry_points.txt +0 -0
  205. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC.egg-info/requires.txt +0 -0
  206. {spotiflac-4.2.2 → spotiflac-4.3.0}/SpotiFLAC.egg-info/top_level.txt +0 -0
  207. {spotiflac-4.2.2 → spotiflac-4.3.0}/setup.cfg +0 -0
  208. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_acoustid_lookup.py +0 -0
  209. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_app_api_mixins.py +0 -0
  210. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_artist_credit_split.py +0 -0
  211. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_audio_fingerprint.py +0 -0
  212. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_bridge_download_segments.py +0 -0
  213. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_bridge_mobile_api.py +0 -0
  214. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_bridge_retry_utils.py +0 -0
  215. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_bridge_startup_errors.py +0 -0
  216. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_cache_admin.py +0 -0
  217. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_canvas.py +0 -0
  218. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_canvas_sidecar.py +0 -0
  219. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_cli_preview.py +0 -0
  220. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_core_logic.py +0 -0
  221. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_cover_fitting.py +0 -0
  222. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_cover_url_plumbing.py +0 -0
  223. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_credential_storage.py +0 -0
  224. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_cross_loop_locks.py +0 -0
  225. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_csv_download.py +0 -0
  226. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_csv_picker.py +0 -0
  227. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_csv_source.py +0 -0
  228. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_db_persistence.py +0 -0
  229. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_dedup_mixin.py +0 -0
  230. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_directories.py +0 -0
  231. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_disc_number_from_album.py +0 -0
  232. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_discovery_mixin.py +0 -0
  233. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_dns_doh.py +0 -0
  234. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_download_queue_reporting.py +0 -0
  235. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_download_recording_guard_wiring.py +0 -0
  236. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_download_resume.py +0 -0
  237. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_download_services.py +0 -0
  238. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_ext_scaffold_and_dryrun.py +0 -0
  239. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_extension_enrichment.py +0 -0
  240. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_extension_fsguard.py +0 -0
  241. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_extension_install_limits.py +0 -0
  242. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_extension_metadata.py +0 -0
  243. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_extension_netguard.py +0 -0
  244. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_extension_output_containment.py +0 -0
  245. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_extension_sandbox.py +0 -0
  246. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_failed_tracks_and_resume.py +0 -0
  247. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_fake_hires_redownload.py +0 -0
  248. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_ffmpeg_check.py +0 -0
  249. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_frontend_new_ui_wiring.py +0 -0
  250. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_gui_batch_downloads.py +0 -0
  251. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_gui_folder_options.py +0 -0
  252. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_gui_hires_verification.py +0 -0
  253. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_gui_prefetched_selection.py +0 -0
  254. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_hires_check.py +0 -0
  255. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_http_redirects.py +0 -0
  256. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_job_queue.py +0 -0
  257. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_json_report.py +0 -0
  258. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_launcher_tui_flag.py +0 -0
  259. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_launcher_web_flags.py +0 -0
  260. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_library_dedup.py +0 -0
  261. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_library_index_cache.py +0 -0
  262. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_library_notify.py +0 -0
  263. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_library_upgrade.py +0 -0
  264. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_link_resolver_resolve_api.py +0 -0
  265. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_link_resolver_songstats.py +0 -0
  266. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_local_matcher.py +0 -0
  267. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_loop_runner.py +0 -0
  268. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_lrc_sidecar.py +0 -0
  269. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_lyrics_apple_match.py +0 -0
  270. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_lyrics_jellyfin_gap.py +0 -0
  271. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_lyrics_provider_order.py +0 -0
  272. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_m4a_no_roundtrip.py +0 -0
  273. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_metadata_fallback.py +0 -0
  274. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_mono_browser_cleanup.py +0 -0
  275. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_mono_page_fallback.py +0 -0
  276. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_musicbrainz_fallback.py +0 -0
  277. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_musicbrainz_release_pick.py +0 -0
  278. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_musicbrainz_tag_names.py +0 -0
  279. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_node_check.py +0 -0
  280. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_node_options_quoting.py +0 -0
  281. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_notifiers.py +0 -0
  282. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_output_lock.py +0 -0
  283. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_output_sink.py +0 -0
  284. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_playcount_throttle.py +0 -0
  285. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_post_download_command_gate.py +0 -0
  286. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_post_download_hooks.py +0 -0
  287. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_profiles.py +0 -0
  288. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_progress_and_csv_counters.py +0 -0
  289. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_progress_cross_loop.py +0 -0
  290. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_progress_manager_loops.py +0 -0
  291. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_quotas.py +0 -0
  292. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_recording_guard.py +0 -0
  293. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_registry_signing_cli.py +0 -0
  294. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_release_date_hydration.py +0 -0
  295. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_replaygain.py +0 -0
  296. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_runtime_node_check.py +0 -0
  297. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_search_mixin.py +0 -0
  298. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_signed_session_backoff.py +0 -0
  299. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_signed_session_errors.py +0 -0
  300. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_signed_session_status.py +0 -0
  301. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_smoke.py +0 -0
  302. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_solver_turnstile_locate.py +0 -0
  303. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_solver_virtual_display.py +0 -0
  304. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_spotify_metadata_nulls.py +0 -0
  305. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_stats.py +0 -0
  306. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_stream_url_validation.py +0 -0
  307. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_subscription_scheduler.py +0 -0
  308. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_subscriptions.py +0 -0
  309. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_subscriptions_mixin.py +0 -0
  310. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_tag_number_parsing.py +0 -0
  311. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_title_floor.py +0 -0
  312. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_tqdm_lock_under_capture.py +0 -0
  313. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_track_identity.py +0 -0
  314. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_track_isrc_wiring.py +0 -0
  315. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_tracklist.py +0 -0
  316. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_transcode_ffmpeg_install.py +0 -0
  317. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_transcode_lossless.py +0 -0
  318. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_trust.py +0 -0
  319. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_trust_enforcement.py +0 -0
  320. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_trust_mixin.py +0 -0
  321. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_tui_app.py +0 -0
  322. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_tui_config_profile.py +0 -0
  323. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_tui_config_state.py +0 -0
  324. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_tui_csv_picker.py +0 -0
  325. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_tui_extensions.py +0 -0
  326. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_tui_fixes.py +0 -0
  327. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_tui_health.py +0 -0
  328. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_tui_look.py +0 -0
  329. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_tui_runner.py +0 -0
  330. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_tui_search.py +0 -0
  331. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_tui_session.py +0 -0
  332. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_tui_signed_sessions.py +0 -0
  333. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_tui_tracklist.py +0 -0
  334. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_tui_window_size.py +0 -0
  335. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_watch.py +0 -0
  336. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_web_shim_methods_in_sync.py +0 -0
  337. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_web_users.py +0 -0
  338. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_webapi_integration.py +0 -0
  339. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_webapi_v1.py +0 -0
  340. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_webapp_auth.py +0 -0
  341. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_webapp_isolation.py +0 -0
  342. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_webapp_multiuser.py +0 -0
  343. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_webapp_ops.py +0 -0
  344. {spotiflac-4.2.2 → spotiflac-4.3.0}/tests/test_webapp_pwa.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: SpotiFLAC
3
- Version: 4.2.2
3
+ Version: 4.3.0
4
4
  Summary: Fetch Spotify track metadata and retrieve matching lossless audio through configurable Tidal, Qobuz & Amazon Music provider backends.
5
5
  Author: BartolomeoRusso9
6
6
  License: MIT
@@ -21,6 +21,13 @@ _SERVERS = {
21
21
  "qq": "https://lyrics.paxsenix.org/qq/search?q=Bohemian%20Rhapsody%20Queen",
22
22
  "youtube": "https://lyrics.paxsenix.org/youtube/search?q=Bohemian%20Rhapsody%20Queen",
23
23
  "kugou": "https://lyrics.paxsenix.org/kugou/search?q=Bohemian%20Rhapsody%20Queen",
24
+ "binilyrics": "https://lyrics-api.binimum.org/?track=Bohemian%20Rhapsody&artist=Queen&duration=355",
25
+ # Unison answers 404 for anything it has no lyrics for, and it has few:
26
+ # this is a track it does have.
27
+ "unison": "https://unison.boidu.dev/lyrics?song=Someone%20Like%20You&artist=Adele&duration=285",
28
+ # "Tum Hi Ho" (id aRZbUYD7): one of the has_lyrics:true results, so this
29
+ # actually exercises the lyrics.getLyrics call, not just search.
30
+ "jiosaavn": "https://www.jiosaavn.com/api.php?__call=lyrics.getLyrics&_format=json&_marker=0&api_version=4&ctx=web6dot0&lyrics_id=aRZbUYD7",
24
31
  }
25
32
 
26
33
 
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import html
6
7
  import logging
7
8
  import os
8
9
  import re
@@ -42,7 +43,7 @@ class LyricsContext:
42
43
  return get_primary_artist(self.artist_name)
43
44
 
44
45
 
45
- DEFAULT_LYRICS_PROVIDERS = ["apple", "lrclib"]
46
+ DEFAULT_LYRICS_PROVIDERS = ["apple", "lrclib", "binilyrics", "unison"]
46
47
  DEFAULT_ENRICH_PROVIDERS = ["deezer", "apple", "qobuz", "tidal"]
47
48
 
48
49
 
@@ -214,7 +215,13 @@ async def _get_spotify_anon_token(timeout: int = 7) -> str:
214
215
  try:
215
216
  client = await NetworkManager.get_async_client_safe()
216
217
 
217
- totp_headers: dict[str, str] = {}
218
+ # Spotify wants the TOTP as query parameters, the way
219
+ # spotfetch.py sends it. Sent as headers the endpoint answers
220
+ # 400 "Unauthorized request" and there is no token at all.
221
+ params = {
222
+ "reason": "init",
223
+ "productType": "web-player",
224
+ }
218
225
 
219
226
  try:
220
227
  from .spotify_totp import generate_spotify_totp
@@ -222,8 +229,9 @@ async def _get_spotify_anon_token(timeout: int = 7) -> str:
222
229
  code, version = generate_spotify_totp()
223
230
 
224
231
  if code:
225
- totp_headers["Spotify-TOTP"] = code
226
- totp_headers["Spotify-TOTP-V2"] = f"{code}:{version}"
232
+ params["totp"] = code
233
+ params["totpVer"] = str(version)
234
+ params["totpServer"] = code
227
235
 
228
236
  except Exception:
229
237
  pass
@@ -236,14 +244,8 @@ async def _get_spotify_anon_token(timeout: int = 7) -> str:
236
244
 
237
245
  r = await client.get(
238
246
  "https://open.spotify.com/api/token",
239
- params={
240
- "reason": "init",
241
- "productType": "web-player",
242
- },
243
- headers={
244
- "User-Agent": _UA,
245
- **totp_headers,
246
- },
247
+ params=params,
248
+ headers={"User-Agent": _UA},
247
249
  timeout=timeout,
248
250
  )
249
251
 
@@ -932,6 +934,275 @@ async def _fetch_lrclib_async(
932
934
  return ""
933
935
 
934
936
 
937
+ # ---------------------------------------------------------------------------
938
+ # Apple-TTML catalogues: BiniLyrics, Unison
939
+ # ---------------------------------------------------------------------------
940
+
941
+ _BINI_API = "https://lyrics-api.binimum.org/"
942
+ _UNISON_API = "https://unison.boidu.dev/lyrics"
943
+
944
+ #: A recording is only taken when its length is this close to the track's, so
945
+ #: the words fall on the same beat. Both catalogues search by name, and a live
946
+ #: or sped-up cut of the same song is a different take.
947
+ _TTML_LENGTH_SLACK_S = 4
948
+
949
+ #: Providers whose text depends on `apple_word_by_word`, and so cannot share a
950
+ #: cache entry between the two renderings.
951
+ _TTML_PROVIDERS = frozenset({"binilyrics", "unison"})
952
+
953
+
954
+ def _bini_lyrics_url(value: object) -> str:
955
+ """`value` if it is an https link on Bini's own storage, else "".
956
+
957
+ The search answers with the address of the TTML to fetch next. That
958
+ address comes from a third party's response, so it is only followed
959
+ when it stays on the host that serves the lyrics.
960
+ """
961
+ if not isinstance(value, str):
962
+ return ""
963
+ parsed = urllib.parse.urlparse(value)
964
+ host = parsed.hostname or ""
965
+ if parsed.scheme != "https" or not (
966
+ host == "binimum.org" or host.endswith(".binimum.org")
967
+ ):
968
+ return ""
969
+ return value
970
+
971
+
972
+ def _best_bini_result(
973
+ results: object,
974
+ duration_s: int,
975
+ isrc: str,
976
+ ) -> dict | None:
977
+ """The best of what Bini's search found for the track.
978
+
979
+ A result is taken when its ISRC is the track's own, or its length is
980
+ within `_TTML_LENGTH_SLACK_S` of it. Among those, word timing beats line
981
+ timing, then an ISRC match, then the closest length.
982
+ """
983
+ best: dict | None = None
984
+ best_rank: tuple[bool, bool, int] | None = None
985
+ for item in results if isinstance(results, list) else []:
986
+ if not isinstance(item, dict) or not _bini_lyrics_url(item.get("lyricsUrl")):
987
+ continue
988
+ same_isrc = bool(isrc) and str(item.get("isrc") or "").upper() == isrc.upper()
989
+ length = item.get("duration")
990
+ has_length = duration_s > 0 and isinstance(length, (int, float)) and length > 0
991
+ off = abs(int(length) - duration_s) if has_length else 0
992
+ if not same_isrc and not (has_length and off <= _TTML_LENGTH_SLACK_S):
993
+ continue
994
+ rank = (item.get("timing_type") != "word", not same_isrc, off)
995
+ if best_rank is None or rank < best_rank:
996
+ best, best_rank = item, rank
997
+ return best
998
+
999
+
1000
+ async def _fetch_bini_async(
1001
+ track_name: str,
1002
+ artist_name: str,
1003
+ album_name: str = "",
1004
+ duration_s: int = 0,
1005
+ isrc: str = "",
1006
+ word_by_word: bool = True,
1007
+ timeout: int = 7,
1008
+ ) -> str:
1009
+ """BiniLyrics: Apple Music's own TTML for over a million recordings.
1010
+
1011
+ No key. The search answers with metadata and a link; the TTML itself is a
1012
+ second request, to a plain file host.
1013
+ """
1014
+ if not track_name or not artist_name:
1015
+ return ""
1016
+ params = {"track": track_name, "artist": artist_name}
1017
+ if duration_s > 0:
1018
+ params["duration"] = str(duration_s)
1019
+ if album_name:
1020
+ params["album"] = album_name
1021
+ try:
1022
+ from .apple_ttml import ttml_to_lrc
1023
+
1024
+ client = await NetworkManager.get_async_client_safe()
1025
+ r = await client.get(_BINI_API, params=params, timeout=timeout)
1026
+ if r.status_code != 200:
1027
+ return ""
1028
+ payload = r.json()
1029
+ found = _best_bini_result(
1030
+ payload.get("results") if isinstance(payload, dict) else None,
1031
+ duration_s,
1032
+ isrc,
1033
+ )
1034
+ if not found:
1035
+ return ""
1036
+ ttml = await client.get(_bini_lyrics_url(found["lyricsUrl"]), timeout=timeout)
1037
+ if ttml.status_code != 200 or not ttml.text:
1038
+ return ""
1039
+ return ttml_to_lrc(ttml.text, word_by_word=word_by_word)
1040
+ except Exception as exc:
1041
+ logger.debug("[lyrics/binilyrics] async: %s", exc)
1042
+ return ""
1043
+
1044
+
1045
+ async def _fetch_unison_async(
1046
+ track_name: str,
1047
+ artist_name: str,
1048
+ album_name: str = "",
1049
+ duration_s: int = 0,
1050
+ isrc: str = "",
1051
+ word_by_word: bool = True,
1052
+ timeout: int = 7,
1053
+ ) -> str:
1054
+ """Unison: lyrics people write by hand for Better Lyrics.
1055
+
1056
+ A small corpus next to the rest, but what is in it is Apple Music's TTML
1057
+ at its best. Reads need no key; the signed write half of its API (submit,
1058
+ vote, report) is not touched. Only TTML and LRC are taken: its plain text
1059
+ carries nothing the other providers do not.
1060
+ """
1061
+ if not track_name or not artist_name:
1062
+ return ""
1063
+ params = {"song": track_name, "artist": artist_name}
1064
+ if duration_s > 0:
1065
+ params["duration"] = str(duration_s)
1066
+ if album_name:
1067
+ params["album"] = album_name
1068
+ try:
1069
+ from .apple_ttml import ttml_to_lrc
1070
+
1071
+ client = await NetworkManager.get_async_client_safe()
1072
+ r = await client.get(_UNISON_API, params=params, timeout=timeout)
1073
+ if r.status_code != 200:
1074
+ return ""
1075
+ payload = r.json()
1076
+ data = payload.get("data") if isinstance(payload, dict) else None
1077
+ if not isinstance(data, dict) or not isinstance(data.get("lyrics"), str):
1078
+ return ""
1079
+
1080
+ same_isrc = bool(isrc) and str(data.get("isrc") or "").upper() == isrc.upper()
1081
+ length = data.get("duration")
1082
+ length_ok = (
1083
+ duration_s > 0
1084
+ and isinstance(length, (int, float))
1085
+ and length > 0
1086
+ and abs(length - duration_s) <= _TTML_LENGTH_SLACK_S
1087
+ )
1088
+ if not same_isrc and not length_ok:
1089
+ return ""
1090
+
1091
+ if data.get("format") == "ttml":
1092
+ return ttml_to_lrc(data["lyrics"], word_by_word=word_by_word)
1093
+ if data.get("format") == "lrc":
1094
+ return data["lyrics"]
1095
+ return ""
1096
+ except Exception as exc:
1097
+ logger.debug("[lyrics/unison] async: %s", exc)
1098
+ return ""
1099
+
1100
+
1101
+ # ---------------------------------------------------------------------------
1102
+ # JioSaavn: Hindi and regional-Indian catalogue (Bollywood and beyond)
1103
+ # ---------------------------------------------------------------------------
1104
+
1105
+ #: JioSaavn's own web player calls this same endpoint. The various
1106
+ #: "unofficial JioSaavn API" projects on GitHub are just thin proxies in
1107
+ #: front of it, meant to be self-hosted rather than shared publicly, so
1108
+ #: going straight to the source avoids depending on someone else's uptime
1109
+ #: for data JioSaavn already serves without a key.
1110
+ _JIOSAAVN_API = "https://www.jiosaavn.com/api.php"
1111
+ _JIOSAAVN_PARAMS = {
1112
+ "_format": "json",
1113
+ "_marker": "0",
1114
+ "api_version": "4",
1115
+ "ctx": "web6dot0",
1116
+ }
1117
+ _JIOSAAVN_LENGTH_SLACK_S = 4
1118
+
1119
+
1120
+ def _best_jiosaavn_result(results: object, duration_s: int) -> dict | None:
1121
+ """The closest-length search hit that JioSaavn actually has lyrics for.
1122
+
1123
+ Most of its catalogue has `has_lyrics: false` — a licensing flag, not a
1124
+ missing-data one — so that is filtered before length is even compared.
1125
+ """
1126
+ best: dict | None = None
1127
+ best_off = 0
1128
+ for item in results if isinstance(results, list) else []:
1129
+ if not isinstance(item, dict) or not item.get("id"):
1130
+ continue
1131
+ more = item.get("more_info")
1132
+ if not isinstance(more, dict) or more.get("has_lyrics") != "true":
1133
+ continue
1134
+ try:
1135
+ length = int(more.get("duration") or 0)
1136
+ except (TypeError, ValueError):
1137
+ length = 0
1138
+ off = abs(length - duration_s) if duration_s > 0 and length > 0 else 0
1139
+ if duration_s > 0 and length > 0 and off > _JIOSAAVN_LENGTH_SLACK_S:
1140
+ continue
1141
+ if best is None or off < best_off:
1142
+ best, best_off = item, off
1143
+ return best
1144
+
1145
+
1146
+ async def _fetch_jiosaavn_async(
1147
+ track_name: str,
1148
+ artist_name: str,
1149
+ duration_s: int = 0,
1150
+ timeout: int = 7,
1151
+ ) -> str:
1152
+ """JioSaavn: plain (unsynced) lyrics, strongest for Hindi and regional
1153
+ Indian languages — a catalogue the other providers barely cover.
1154
+ """
1155
+ if not track_name or not artist_name:
1156
+ return ""
1157
+ try:
1158
+ client = await NetworkManager.get_async_client_safe()
1159
+ search = await client.get(
1160
+ _JIOSAAVN_API,
1161
+ params={
1162
+ **_JIOSAAVN_PARAMS,
1163
+ "__call": "search.getResults",
1164
+ "q": f"{track_name} {artist_name}",
1165
+ "p": "1",
1166
+ "n": "10",
1167
+ },
1168
+ headers={"User-Agent": _UA},
1169
+ timeout=timeout,
1170
+ )
1171
+ if not search.is_success:
1172
+ return ""
1173
+ payload = search.json()
1174
+ found = _best_jiosaavn_result(
1175
+ payload.get("results") if isinstance(payload, dict) else None,
1176
+ duration_s,
1177
+ )
1178
+ if not found:
1179
+ return ""
1180
+ lyrics = await client.get(
1181
+ _JIOSAAVN_API,
1182
+ params={
1183
+ **_JIOSAAVN_PARAMS,
1184
+ "__call": "lyrics.getLyrics",
1185
+ "lyrics_id": str(found["id"]),
1186
+ },
1187
+ headers={"User-Agent": _UA},
1188
+ timeout=timeout,
1189
+ )
1190
+ if not lyrics.is_success:
1191
+ return ""
1192
+ data = lyrics.json()
1193
+ text = data.get("lyrics") if isinstance(data, dict) else None
1194
+ if not isinstance(text, str) or not text.strip():
1195
+ return ""
1196
+ # Some JioSaavn responses carry line breaks as literal `<br>` tags
1197
+ # rather than `\n`; the other providers' output never does, so this
1198
+ # is normalized before it reaches the rest of the pipeline.
1199
+ text = re.sub(r"<br\s*/?>", "\n", text, flags=re.IGNORECASE)
1200
+ return html.unescape(text).strip()
1201
+ except Exception as exc:
1202
+ logger.debug("[lyrics/jiosaavn] async: %s", exc)
1203
+ return ""
1204
+
1205
+
935
1206
  # ---------------------------------------------------------------------------
936
1207
  # Async fetch_lyrics — Phase 2 (parallel, as_completed)
937
1208
  # ---------------------------------------------------------------------------
@@ -993,6 +1264,27 @@ _PROVIDER_MAP = {
993
1264
  ctx.album_name,
994
1265
  ctx.duration_s,
995
1266
  ),
1267
+ "binilyrics": lambda ctx: _fetch_bini_async(
1268
+ ctx.clean_track,
1269
+ ctx.clean_artist,
1270
+ ctx.album_name,
1271
+ ctx.duration_s,
1272
+ ctx.isrc,
1273
+ word_by_word=ctx.apple_word_by_word,
1274
+ ),
1275
+ "unison": lambda ctx: _fetch_unison_async(
1276
+ ctx.clean_track,
1277
+ ctx.clean_artist,
1278
+ ctx.album_name,
1279
+ ctx.duration_s,
1280
+ ctx.isrc,
1281
+ word_by_word=ctx.apple_word_by_word,
1282
+ ),
1283
+ "jiosaavn": lambda ctx: _fetch_jiosaavn_async(
1284
+ ctx.clean_track,
1285
+ ctx.clean_artist,
1286
+ ctx.duration_s,
1287
+ ),
996
1288
  }
997
1289
 
998
1290
 
@@ -1025,12 +1317,15 @@ async def fetch_lyrics_async(
1025
1317
  )
1026
1318
 
1027
1319
  # Apple's word-by-word and line-synced renderings are different text from
1028
- # the same fetch, so they cannot share a cache entry — but only Apple's
1029
- # key needs splitting; every other provider ignores the setting.
1320
+ # the same fetch, so they cannot share a cache entry — but only the
1321
+ # providers that read the setting need their key splitting (Apple, and the
1322
+ # TTML catalogues); every other provider ignores it.
1030
1323
  def _provider_track_key(provider_name: str) -> str:
1324
+ mode = "wbw" if apple_word_by_word else "line"
1031
1325
  if provider_name == "apple":
1032
- mode = "wbw" if apple_word_by_word else "line"
1033
1326
  return f"{track_key}|{mode}|{_APPLE_CACHE_GENERATION}"
1327
+ if provider_name in _TTML_PROVIDERS:
1328
+ return f"{track_key}|{mode}"
1034
1329
  return track_key
1035
1330
 
1036
1331
  def _cached_for(provider_name: str) -> str | None:
@@ -0,0 +1,226 @@
1
+ """core/provider_cooldown.py — a provider that asked for a break gets one.
2
+
3
+ tidal-py tries the Community (signed-desktop) gateway first on every track.
4
+ That gateway has enforced pauses — the SpotiFLAC desktop logic it comes from
5
+ blocks downloads for a while — and during one it answers 503 with how long
6
+ the pause lasts: "Please try again in about 51 minute(s)". The extension logs
7
+ that, falls back to public mirrors that time out, and fails; on the next
8
+ track it does all of it again. Roughly 25 seconds per track went to a
9
+ provider that had said, in words, when it would work again.
10
+
11
+ This reads that answer out of the extension's log and keeps the provider out
12
+ of the rotation until then. The provider is not removed: once the pause is
13
+ over it is tried first again, in the order the user configured. The pause is
14
+ kept in the cache directory, so a restart does not forget it and start the
15
+ 25-second detour over.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import logging
22
+ import re
23
+ import threading
24
+ import time
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ from .atomic_io import write_json_atomic
29
+ from .paths import cache_path
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ _CACHE_FILE_NAME = "provider_cooldowns.json"
34
+
35
+ #: Module (and so logger) prefix of the Python `.sflx` extensions — see
36
+ #: extensions/python_provider._module_name.
37
+ _PLUGIN_PREFIX = "SpotiFLAC.extensions_plugins."
38
+
39
+ #: Logger prefix of the JS `.sflx` extensions. They have no module of their
40
+ #: own — the bridge relays their log lines from Node — so JSRuntime sends
41
+ #: them out under a child logger named for the extension, which is what
42
+ #: makes one extension's 503 tellable from another's.
43
+ _JS_LOG_PREFIX = "SpotiFLAC.extensions.runtime."
44
+
45
+ #: How a JS extension provider is named in PROVIDER_REGISTRY — see
46
+ #: extensions/provider.JSExtensionProvider.
47
+ _EXT_NAME_PREFIX = "ext:"
48
+
49
+ _UNAVAILABLE = re.compile(r"\b503\b")
50
+ _WAIT = re.compile(r"try again in (?:about )?(\d+)\s*(minute|min|hour|h)", re.I)
51
+
52
+ #: A pause longer than a day is read as a misparse, not as a promise.
53
+ _MAX_PAUSE_S = 24 * 3600
54
+
55
+ _lock = threading.Lock()
56
+
57
+
58
+ def _cache_file() -> Path:
59
+ return cache_path(_CACHE_FILE_NAME)
60
+
61
+
62
+ def _load() -> dict[str, dict]:
63
+ try:
64
+ data = json.loads(_cache_file().read_text(encoding="utf-8"))
65
+ except (OSError, ValueError):
66
+ return {}
67
+ return data if isinstance(data, dict) else {}
68
+
69
+
70
+ def _save(data: dict[str, dict]) -> None:
71
+ try:
72
+ write_json_atomic(_cache_file(), data)
73
+ except OSError:
74
+ logger.debug("[cooldown] could not write %s", _cache_file(), exc_info=True)
75
+
76
+
77
+ def pause_seconds(message: str) -> int:
78
+ """Seconds a 503 asks to wait before trying again, or 0 if it names none."""
79
+ if not _UNAVAILABLE.search(message):
80
+ return 0
81
+ match = _WAIT.search(message)
82
+ if not match:
83
+ return 0
84
+ amount = int(match.group(1))
85
+ unit = match.group(2).lower()
86
+ seconds = amount * 3600 if unit.startswith("h") else amount * 60
87
+ return min(seconds, _MAX_PAUSE_S)
88
+
89
+
90
+ def extension_key(logger_name: str) -> str:
91
+ """The pause key of the extension that logger belongs to; "" for the host.
92
+
93
+ Both kinds of extension are keyed by the name they are installed under:
94
+
95
+ "SpotiFLAC.extensions_plugins.tidal_py" → "tidal-py" (Python)
96
+ "SpotiFLAC.extensions.runtime.tidal-web" → "tidal-web" (JS)
97
+
98
+ A Python extension is a module, so its underscores are the ones a module
99
+ name is obliged to have; a JS extension's logger is named for the
100
+ extension directly and is left alone.
101
+ """
102
+ if logger_name.startswith(_PLUGIN_PREFIX):
103
+ return logger_name[len(_PLUGIN_PREFIX) :].split(".", 1)[0].replace("_", "-")
104
+ if logger_name.startswith(_JS_LOG_PREFIX):
105
+ return logger_name[len(_JS_LOG_PREFIX) :].split(".", 1)[0]
106
+ return ""
107
+
108
+
109
+ def provider_key(provider: Any) -> str:
110
+ """The pause key of a provider object, or "" if it is not an extension.
111
+
112
+ Two ways to the same key, because the two kinds of extension carry their
113
+ identity in different places. PythonExtensionProvider returns the
114
+ extension's own provider instance, so the class's module is the
115
+ extension module whose logger reported the 503. A JS extension is a
116
+ JSExtensionProvider whatever the extension is, so its module says
117
+ nothing about which one — its registry name does: "ext:tidal-web" is the
118
+ extension "tidal-web", the key its log lines already arrive under.
119
+
120
+ Keying both from the installed name is what lets pause() and
121
+ usable_providers() agree: a pause started from a JS extension's log has
122
+ to name the provider object that gets skipped for it.
123
+ """
124
+ key = extension_key(type(provider).__module__)
125
+ if key:
126
+ return key
127
+ name = str(getattr(provider, "name", "") or "")
128
+ if name.startswith(_EXT_NAME_PREFIX):
129
+ return name[len(_EXT_NAME_PREFIX) :]
130
+ return ""
131
+
132
+
133
+ def pause(key: str, seconds: float, reason: str = "", now: float | None = None) -> None:
134
+ """Keeps `key` out of the rotation for `seconds`. A shorter pause never
135
+ cuts a longer one short."""
136
+ if not key or seconds <= 0:
137
+ return
138
+ now = time.time() if now is None else now
139
+ until = now + seconds
140
+ with _lock:
141
+ data = _load()
142
+ try:
143
+ current = float((data.get(key) or {}).get("until", 0))
144
+ except (TypeError, ValueError):
145
+ current = 0.0
146
+ if current >= until:
147
+ return
148
+ data[key] = {"until": until, "reason": reason[:300]}
149
+ _save(data)
150
+ logger.warning(
151
+ "[cooldown] %s paused for %d min (until %s): the service asked to try again later",
152
+ key,
153
+ max(1, round(seconds / 60)),
154
+ time.strftime("%H:%M", time.localtime(until)),
155
+ )
156
+
157
+
158
+ def remaining(key: str, now: float | None = None) -> float:
159
+ """Seconds left on `key`'s pause; 0 when it has none."""
160
+ if not key:
161
+ return 0.0
162
+ now = time.time() if now is None else now
163
+ try:
164
+ until = float((_load().get(key) or {}).get("until", 0))
165
+ except (TypeError, ValueError):
166
+ return 0.0
167
+ return max(0.0, until - now)
168
+
169
+
170
+ def usable_providers(providers: list) -> list:
171
+ """`providers` without the ones on a pause, in the same order.
172
+
173
+ All of them when every one is paused: a track with nothing left to try
174
+ would fail without an attempt, and a pause is an estimate, not a ban.
175
+ """
176
+ active: list = []
177
+ paused: list[tuple[Any, float]] = []
178
+ for provider in providers:
179
+ left = remaining(provider_key(provider))
180
+ if left > 0:
181
+ paused.append((provider, left))
182
+ else:
183
+ active.append(provider)
184
+ if not paused or not active:
185
+ return list(providers)
186
+ for provider, left in paused:
187
+ logger.info(
188
+ "[cooldown] Skipping %s: paused for another %d min",
189
+ provider_key(provider),
190
+ max(1, round(left / 60)),
191
+ )
192
+ return active
193
+
194
+
195
+ class _PauseWatcher(logging.Handler):
196
+ """Turns an extension's "503 … try again in about N minute(s)" into a pause."""
197
+
198
+ def __init__(self) -> None:
199
+ super().__init__(level=logging.WARNING)
200
+
201
+ def emit(self, record: logging.LogRecord) -> None:
202
+ key = extension_key(record.name)
203
+ if not key:
204
+ return
205
+ try:
206
+ message = record.getMessage()
207
+ except Exception:
208
+ return
209
+ seconds = pause_seconds(message)
210
+ if seconds:
211
+ pause(key, seconds, message)
212
+
213
+
214
+ _watcher = _PauseWatcher()
215
+
216
+
217
+ def watch_extension_logs() -> None:
218
+ """Attaches the watcher to the root logger, again if it was removed.
219
+
220
+ Cheap enough to call once per track, which it is: the GUI and --web
221
+ reconfigure logging with basicConfig(force=True) when the log level
222
+ changes, and that drops every handler on the root logger.
223
+ """
224
+ root = logging.getLogger()
225
+ if _watcher not in root.handlers:
226
+ root.addHandler(_watcher)
@@ -18,9 +18,30 @@ from http.server import BaseHTTPRequestHandler, HTTPServer
18
18
  import requests
19
19
 
20
20
  from SpotiFLAC.core import get_community_url
21
+ from SpotiFLAC.core.signed_session_errors import (
22
+ parse_session_error,
23
+ retry_after_header_seconds,
24
+ )
21
25
 
22
26
  logger = logging.getLogger(__name__)
23
27
 
28
+
29
+ def _refusal_message(what: str, resp) -> str:
30
+ """ "<what> returned HTTP <code>", with the wait the service asked for.
31
+
32
+ The exception raised from it is all a caller ever sees of the response,
33
+ and a bare "returned HTTP 429" dropped the one thing that says how long
34
+ the address is limited for. Read from the Retry-After header, else from
35
+ the gateway's error envelope (see signed_session_errors).
36
+ """
37
+ msg = f"{what} returned HTTP {resp.status_code}"
38
+ wait = retry_after_header_seconds(resp.headers.get("Retry-After"))
39
+ if not wait:
40
+ with contextlib.suppress(Exception):
41
+ wait = parse_session_error(resp.content).retry_after_seconds
42
+ return f"{msg}, retry after {wait}s" if wait else msg
43
+
44
+
24
45
  # Costanti
25
46
  COMMUNITY_SESSION_SKEW = timedelta(minutes=5)
26
47
  # The callback grant is consumed by ``run_community_verification()`` from a
@@ -309,7 +330,7 @@ def run_community_verification(record: CommunitySessionRecord) -> str:
309
330
 
310
331
  resp = requests.get(bootstrap_url, params=params, timeout=15)
311
332
  if resp.status_code != 200:
312
- msg = f"verification bootstrap returned HTTP {resp.status_code}"
333
+ msg = _refusal_message("verification bootstrap", resp)
313
334
  raise Exception(msg)
314
335
 
315
336
  result = resp.json()
@@ -515,7 +536,7 @@ def exchange_community_grant(
515
536
  resp = requests.post(url, json=payload, timeout=15)
516
537
 
517
538
  if resp.status_code != 200:
518
- msg = f"session exchange returned HTTP {resp.status_code}"
539
+ msg = _refusal_message("session exchange", resp)
519
540
  raise Exception(msg)
520
541
 
521
542
  data = resp.json()