SpotiFLAC 4.2.2__tar.gz → 4.2.3__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 (343) hide show
  1. {spotiflac-4.2.2/SpotiFLAC.egg-info → spotiflac-4.2.3}/PKG-INFO +1 -1
  2. spotiflac-4.2.3/SpotiFLAC/core/provider_cooldown.py +226 -0
  3. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/signed_session_desktop.py +23 -2
  4. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/signed_session_errors.py +29 -0
  5. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/signed_session_mobile.py +30 -12
  6. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/downloader.py +8 -1
  7. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/extensions/_bridge.js +39 -8
  8. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/extensions/provider.py +1 -0
  9. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/extensions/runtime.py +13 -1
  10. {spotiflac-4.2.2 → spotiflac-4.2.3/SpotiFLAC.egg-info}/PKG-INFO +1 -1
  11. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC.egg-info/SOURCES.txt +3 -0
  12. {spotiflac-4.2.2 → spotiflac-4.2.3}/pyproject.toml +1 -1
  13. spotiflac-4.2.3/tests/test_provider_cooldown.py +266 -0
  14. spotiflac-4.2.3/tests/test_rate_limit_logging.py +300 -0
  15. {spotiflac-4.2.2 → spotiflac-4.2.3}/LICENSE +0 -0
  16. {spotiflac-4.2.2 → spotiflac-4.2.3}/MANIFEST.in +0 -0
  17. {spotiflac-4.2.2 → spotiflac-4.2.3}/README.md +0 -0
  18. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/__init__.py +0 -0
  19. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/__main__.py +0 -0
  20. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/api_mixins/__init__.py +0 -0
  21. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/api_mixins/covers_lyrics.py +0 -0
  22. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/api_mixins/csv_import.py +0 -0
  23. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/api_mixins/dedup.py +0 -0
  24. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/api_mixins/discovery.py +0 -0
  25. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/api_mixins/extension_health.py +0 -0
  26. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/api_mixins/failed_tracks.py +0 -0
  27. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/api_mixins/local_tagging.py +0 -0
  28. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/api_mixins/search.py +0 -0
  29. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/api_mixins/signed_sessions.py +0 -0
  30. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/api_mixins/stats.py +0 -0
  31. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/api_mixins/subscriptions.py +0 -0
  32. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/api_mixins/trust.py +0 -0
  33. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/app.py +0 -0
  34. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/check_update.py +0 -0
  35. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/client.py +0 -0
  36. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/__init__.py +0 -0
  37. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/acoustid_lookup.py +0 -0
  38. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/apple_music_metadata.py +0 -0
  39. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/apple_ttml.py +0 -0
  40. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/atomic_io.py +0 -0
  41. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/audio_fingerprint.py +0 -0
  42. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/base.py +0 -0
  43. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/cache_admin.py +0 -0
  44. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/canvas.py +0 -0
  45. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/cli_preview.py +0 -0
  46. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/code_search.py +0 -0
  47. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/console.py +0 -0
  48. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/cross_loop_lock.py +0 -0
  49. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/csv_picker.py +0 -0
  50. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/csv_source.py +0 -0
  51. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/db.py +0 -0
  52. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/dns_doh.py +0 -0
  53. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/download_log.py +0 -0
  54. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/download_validation.py +0 -0
  55. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/errors.py +0 -0
  56. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/extension_enrichment.py +0 -0
  57. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/extension_metadata.py +0 -0
  58. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/failed_tracks.py +0 -0
  59. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/ffmpeg_check.py +0 -0
  60. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/flac_validation.py +0 -0
  61. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/health_check.py +0 -0
  62. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/hires_check.py +0 -0
  63. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/history.py +0 -0
  64. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/hooks.py +0 -0
  65. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/http.py +0 -0
  66. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/isrc_cache.py +0 -0
  67. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/isrc_finder.py +0 -0
  68. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/isrc_helper.py +0 -0
  69. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/isrc_utils.py +0 -0
  70. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/job_queue.py +0 -0
  71. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/library_dedup.py +0 -0
  72. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/library_index_cache.py +0 -0
  73. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/library_notify.py +0 -0
  74. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/library_upgrade.py +0 -0
  75. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/link_resolver.py +0 -0
  76. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/local_matcher.py +0 -0
  77. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/local_processor.py +0 -0
  78. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/local_scanner.py +0 -0
  79. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/loop_runner.py +0 -0
  80. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/lyrics.py +0 -0
  81. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/metadata_enrichment.py +0 -0
  82. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/metadata_fallback.py +0 -0
  83. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/models.py +0 -0
  84. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/musicbrainz.py +0 -0
  85. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/node_check.py +0 -0
  86. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/notifiers.py +0 -0
  87. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/output_lock.py +0 -0
  88. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/output_sink.py +0 -0
  89. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/paths.py +0 -0
  90. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/playlist_sync.py +0 -0
  91. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/profiles.py +0 -0
  92. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/progress.py +0 -0
  93. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/provider_stats.py +0 -0
  94. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/quality.py +0 -0
  95. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/recording_guard.py +0 -0
  96. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/replaygain.py +0 -0
  97. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/report.py +0 -0
  98. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/response_cache.py +0 -0
  99. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/session_memory.py +0 -0
  100. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/signed_session_mono.py +0 -0
  101. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/signed_session_status.py +0 -0
  102. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/solver.py +0 -0
  103. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/soundplate.py +0 -0
  104. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/spotfetch.py +0 -0
  105. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/spotify_metadata.py +0 -0
  106. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/spotify_protobuf.py +0 -0
  107. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/spotify_totp.py +0 -0
  108. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/stats.py +0 -0
  109. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/subscription_scheduler.py +0 -0
  110. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/subscriptions.py +0 -0
  111. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/tagger.py +0 -0
  112. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/text_match.py +0 -0
  113. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/tidal_metadata.py +0 -0
  114. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/tracklist.py +0 -0
  115. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/transcode.py +0 -0
  116. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/url_utils.py +0 -0
  117. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/web_users.py +0 -0
  118. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/extensions/__init__.py +0 -0
  119. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/extensions/_fsguard.js +0 -0
  120. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/extensions/_netguard.js +0 -0
  121. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/extensions/catalog.py +0 -0
  122. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/extensions/directories.py +0 -0
  123. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/extensions/manager.py +0 -0
  124. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/extensions/python_provider.py +0 -0
  125. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/extensions/registry_config.py +0 -0
  126. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/extensions/runtime_features.py +0 -0
  127. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/extensions/sandbox.py +0 -0
  128. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/extensions/trust.py +0 -0
  129. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/app.js +0 -0
  130. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/am.png +0 -0
  131. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/amzn.png +0 -0
  132. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/apple-touch-icon.png +0 -0
  133. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/download.svg +0 -0
  134. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/dzr.png +0 -0
  135. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/flacdownloader.png +0 -0
  136. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/joox.svg +0 -0
  137. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/kuwo.png +0 -0
  138. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/logo-192.png +0 -0
  139. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/logo-512.png +0 -0
  140. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/logo.svg +0 -0
  141. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/lrclib.png +0 -0
  142. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/migu.jpeg +0 -0
  143. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/musicbrainz_d.png +0 -0
  144. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/musicbrainz_l.png +0 -0
  145. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/musixmatch.svg +0 -0
  146. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/netease.svg +0 -0
  147. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/next.svg +0 -0
  148. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/pandora.svg +0 -0
  149. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/qbz.png +0 -0
  150. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/songlink_d.png +0 -0
  151. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/songlink_l.png +0 -0
  152. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/songstats.png +0 -0
  153. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/soundcloud.svg +0 -0
  154. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/spotify.svg +0 -0
  155. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/spotubedl.svg +0 -0
  156. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/tidal_d.png +0 -0
  157. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/tidal_l.png +0 -0
  158. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/xbatchdl.svg +0 -0
  159. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/youtube.svg +0 -0
  160. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/index.html +0 -0
  161. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/manifest.json +0 -0
  162. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/styles.css +0 -0
  163. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/sw.js +0 -0
  164. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/toast-system.js +0 -0
  165. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/web-shim.js +0 -0
  166. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/launcher.py +0 -0
  167. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/py.typed +0 -0
  168. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tools/__init__.py +0 -0
  169. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tools/bump_cache_bust.py +0 -0
  170. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tools/cloud_config_cli.py +0 -0
  171. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tools/dedup_check_cli.py +0 -0
  172. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tools/ext_dryrun.py +0 -0
  173. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tools/ext_scaffold.py +0 -0
  174. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tools/hires_check_cli.py +0 -0
  175. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tools/jellyfin_lyrics_gap.py +0 -0
  176. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tools/library_dedup_cli.py +0 -0
  177. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tools/library_upgrade_cli.py +0 -0
  178. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tools/registry_signing_cli.py +0 -0
  179. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tui/__init__.py +0 -0
  180. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tui/app.py +0 -0
  181. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tui/banner.py +0 -0
  182. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tui/branding.py +0 -0
  183. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tui/clipboard.py +0 -0
  184. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tui/config_state.py +0 -0
  185. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tui/config_view.py +0 -0
  186. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tui/csv_picker_screen.py +0 -0
  187. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tui/extensions_view.py +0 -0
  188. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tui/health_view.py +0 -0
  189. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tui/help_screen.py +0 -0
  190. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tui/queue_view.py +0 -0
  191. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tui/runner.py +0 -0
  192. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tui/search_view.py +0 -0
  193. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tui/session_view.py +0 -0
  194. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tui/signed_sessions_view.py +0 -0
  195. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tui/spotiflac.tcss +0 -0
  196. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tui/tracklist_view.py +0 -0
  197. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tui/window_size.py +0 -0
  198. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/webapi/__init__.py +0 -0
  199. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/webapi/routes.py +0 -0
  200. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/webapi/schemas.py +0 -0
  201. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/webapp.py +0 -0
  202. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC.egg-info/dependency_links.txt +0 -0
  203. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC.egg-info/entry_points.txt +0 -0
  204. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC.egg-info/requires.txt +0 -0
  205. {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC.egg-info/top_level.txt +0 -0
  206. {spotiflac-4.2.2 → spotiflac-4.2.3}/setup.cfg +0 -0
  207. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_acoustid_lookup.py +0 -0
  208. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_app_api_mixins.py +0 -0
  209. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_artist_credit_split.py +0 -0
  210. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_audio_fingerprint.py +0 -0
  211. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_bridge_download_segments.py +0 -0
  212. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_bridge_mobile_api.py +0 -0
  213. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_bridge_retry_utils.py +0 -0
  214. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_bridge_startup_errors.py +0 -0
  215. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_cache_admin.py +0 -0
  216. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_canvas.py +0 -0
  217. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_canvas_sidecar.py +0 -0
  218. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_cli_preview.py +0 -0
  219. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_core_logic.py +0 -0
  220. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_cover_fitting.py +0 -0
  221. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_cover_url_plumbing.py +0 -0
  222. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_credential_storage.py +0 -0
  223. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_cross_loop_locks.py +0 -0
  224. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_csv_download.py +0 -0
  225. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_csv_picker.py +0 -0
  226. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_csv_source.py +0 -0
  227. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_db_persistence.py +0 -0
  228. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_dedup_mixin.py +0 -0
  229. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_directories.py +0 -0
  230. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_disc_number_from_album.py +0 -0
  231. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_discovery_mixin.py +0 -0
  232. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_dns_doh.py +0 -0
  233. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_download_queue_reporting.py +0 -0
  234. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_download_recording_guard_wiring.py +0 -0
  235. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_download_resume.py +0 -0
  236. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_download_services.py +0 -0
  237. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_ext_scaffold_and_dryrun.py +0 -0
  238. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_extension_enrichment.py +0 -0
  239. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_extension_fsguard.py +0 -0
  240. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_extension_install_limits.py +0 -0
  241. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_extension_metadata.py +0 -0
  242. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_extension_netguard.py +0 -0
  243. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_extension_output_containment.py +0 -0
  244. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_extension_sandbox.py +0 -0
  245. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_failed_tracks_and_resume.py +0 -0
  246. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_fake_hires_redownload.py +0 -0
  247. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_ffmpeg_check.py +0 -0
  248. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_frontend_new_ui_wiring.py +0 -0
  249. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_gui_batch_downloads.py +0 -0
  250. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_gui_folder_options.py +0 -0
  251. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_gui_hires_verification.py +0 -0
  252. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_gui_prefetched_selection.py +0 -0
  253. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_hires_check.py +0 -0
  254. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_http_redirects.py +0 -0
  255. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_job_queue.py +0 -0
  256. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_json_report.py +0 -0
  257. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_launcher_tui_flag.py +0 -0
  258. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_launcher_web_flags.py +0 -0
  259. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_library_dedup.py +0 -0
  260. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_library_index_cache.py +0 -0
  261. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_library_notify.py +0 -0
  262. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_library_upgrade.py +0 -0
  263. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_link_resolver_resolve_api.py +0 -0
  264. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_link_resolver_songstats.py +0 -0
  265. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_local_matcher.py +0 -0
  266. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_loop_runner.py +0 -0
  267. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_lrc_sidecar.py +0 -0
  268. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_lyrics_apple_match.py +0 -0
  269. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_lyrics_jellyfin_gap.py +0 -0
  270. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_lyrics_provider_order.py +0 -0
  271. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_m4a_no_roundtrip.py +0 -0
  272. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_metadata_fallback.py +0 -0
  273. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_mono_browser_cleanup.py +0 -0
  274. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_mono_page_fallback.py +0 -0
  275. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_musicbrainz_fallback.py +0 -0
  276. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_musicbrainz_release_pick.py +0 -0
  277. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_musicbrainz_tag_names.py +0 -0
  278. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_node_check.py +0 -0
  279. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_node_options_quoting.py +0 -0
  280. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_notifiers.py +0 -0
  281. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_output_lock.py +0 -0
  282. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_output_sink.py +0 -0
  283. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_playcount_throttle.py +0 -0
  284. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_post_download_command_gate.py +0 -0
  285. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_post_download_hooks.py +0 -0
  286. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_profiles.py +0 -0
  287. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_progress_and_csv_counters.py +0 -0
  288. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_progress_cross_loop.py +0 -0
  289. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_progress_manager_loops.py +0 -0
  290. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_quotas.py +0 -0
  291. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_recording_guard.py +0 -0
  292. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_registry_signing_cli.py +0 -0
  293. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_release_date_hydration.py +0 -0
  294. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_replaygain.py +0 -0
  295. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_runtime_node_check.py +0 -0
  296. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_search_mixin.py +0 -0
  297. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_signed_session_backoff.py +0 -0
  298. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_signed_session_errors.py +0 -0
  299. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_signed_session_status.py +0 -0
  300. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_smoke.py +0 -0
  301. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_solver_turnstile_locate.py +0 -0
  302. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_solver_virtual_display.py +0 -0
  303. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_spotify_metadata_nulls.py +0 -0
  304. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_stats.py +0 -0
  305. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_stream_url_validation.py +0 -0
  306. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_subscription_scheduler.py +0 -0
  307. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_subscriptions.py +0 -0
  308. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_subscriptions_mixin.py +0 -0
  309. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_tag_number_parsing.py +0 -0
  310. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_title_floor.py +0 -0
  311. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_tqdm_lock_under_capture.py +0 -0
  312. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_track_identity.py +0 -0
  313. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_track_isrc_wiring.py +0 -0
  314. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_tracklist.py +0 -0
  315. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_transcode_ffmpeg_install.py +0 -0
  316. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_transcode_lossless.py +0 -0
  317. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_trust.py +0 -0
  318. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_trust_enforcement.py +0 -0
  319. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_trust_mixin.py +0 -0
  320. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_tui_app.py +0 -0
  321. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_tui_config_profile.py +0 -0
  322. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_tui_config_state.py +0 -0
  323. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_tui_csv_picker.py +0 -0
  324. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_tui_extensions.py +0 -0
  325. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_tui_fixes.py +0 -0
  326. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_tui_health.py +0 -0
  327. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_tui_look.py +0 -0
  328. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_tui_runner.py +0 -0
  329. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_tui_search.py +0 -0
  330. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_tui_session.py +0 -0
  331. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_tui_signed_sessions.py +0 -0
  332. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_tui_tracklist.py +0 -0
  333. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_tui_window_size.py +0 -0
  334. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_watch.py +0 -0
  335. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_web_shim_methods_in_sync.py +0 -0
  336. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_web_users.py +0 -0
  337. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_webapi_integration.py +0 -0
  338. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_webapi_v1.py +0 -0
  339. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_webapp_auth.py +0 -0
  340. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_webapp_isolation.py +0 -0
  341. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_webapp_multiuser.py +0 -0
  342. {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_webapp_ops.py +0 -0
  343. {spotiflac-4.2.2 → spotiflac-4.2.3}/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.2.3
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
@@ -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()
@@ -23,7 +23,10 @@ whose contract is what the gateway actually emits.
23
23
  from __future__ import annotations
24
24
 
25
25
  import json
26
+ import math
26
27
  from dataclasses import dataclass
28
+ from datetime import datetime, timezone
29
+ from email.utils import parsedate_to_datetime
27
30
  from typing import Any
28
31
 
29
32
  #: The session is gone and can be re-established without the user.
@@ -59,6 +62,32 @@ class SessionError:
59
62
  return self.origin == "provider"
60
63
 
61
64
 
65
+ def retry_after_header_seconds(raw: Any) -> int:
66
+ """Seconds a Retry-After header asks for, or 0. Never raises.
67
+
68
+ RFC 9110 allows two forms, a count of seconds and an HTTP date, and the
69
+ bridge's retryAfterSeconds() (extensions/_bridge.js) has always read
70
+ both. The Python readers accepted only digits, so a gateway answering
71
+ with the date form read here as "no Retry-After" and the wait fell
72
+ through to the error envelope — or to nothing. A date already in the
73
+ past is 0: the wait is over.
74
+ """
75
+ text = str(raw or "").strip()
76
+ if text.isdigit():
77
+ return int(text)
78
+ try:
79
+ when = parsedate_to_datetime(text)
80
+ except (TypeError, ValueError):
81
+ return 0
82
+ if when is None:
83
+ return 0
84
+ # An HTTP date is UTC by definition, but parsedate_to_datetime returns a
85
+ # naive datetime for the obsolete formats that carry no offset.
86
+ if when.tzinfo is None:
87
+ when = when.replace(tzinfo=timezone.utc)
88
+ return max(0, math.ceil((when - datetime.now(timezone.utc)).total_seconds()))
89
+
90
+
62
91
  def parse_session_error(body: Any) -> SessionError:
63
92
  """Reads the envelope out of a response body. Never raises.
64
93
 
@@ -23,7 +23,11 @@ from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
23
23
 
24
24
  import httpx
25
25
 
26
- from .signed_session_errors import parse_session_error, should_clear_session
26
+ from .signed_session_errors import (
27
+ parse_session_error,
28
+ retry_after_header_seconds,
29
+ should_clear_session,
30
+ )
27
31
 
28
32
  logger = logging.getLogger(__name__)
29
33
 
@@ -1187,9 +1191,9 @@ def _retry_after_from(exc: BaseException) -> int:
1187
1191
  response = getattr(exc, "response", None)
1188
1192
  if response is None:
1189
1193
  return 0
1190
- raw = (response.headers.get("Retry-After") or "").strip()
1191
- if raw.isdigit():
1192
- return int(raw)
1194
+ wait = retry_after_header_seconds(response.headers.get("Retry-After"))
1195
+ if wait:
1196
+ return wait
1193
1197
  with contextlib.suppress(Exception):
1194
1198
  return parse_session_error(response.content).retry_after_seconds
1195
1199
  return 0
@@ -1329,6 +1333,18 @@ async def perform_signed_fetch(
1329
1333
  resp = await client.request(method, path, json_body=body, extra_headers=headers)
1330
1334
  elapsed = time.monotonic() - started
1331
1335
 
1336
+ # How long a refusal says to wait: the Retry-After header first, else
1337
+ # the gateway's error envelope. Worked out before the log lines below,
1338
+ # because a 429 is logged with it: the extensions retry a rate limit
1339
+ # on their own and say nothing, so the log held no record of how long
1340
+ # an address had been limited for.
1341
+ ok = 200 <= resp.status_code < 300
1342
+ retry_after = retry_after_header_seconds(resp.headers.get("Retry-After"))
1343
+ # Parsed only for a refusal: a success body is the provider's payload,
1344
+ # sometimes a large manifest, and never an envelope.
1345
+ err = None if ok else parse_session_error(getattr(resp, "content", b""))
1346
+ wait = retry_after or (err.retry_after_seconds if err else 0)
1347
+
1332
1348
  if is_ticket:
1333
1349
  granted = False
1334
1350
  if 200 <= resp.status_code < 300:
@@ -1364,6 +1380,15 @@ async def perform_signed_fetch(
1364
1380
  elapsed,
1365
1381
  )
1366
1382
 
1383
+ if resp.status_code == 429:
1384
+ logger.warning(
1385
+ "[signed_session:%s] HTTP 429 for %s %s — %s",
1386
+ client.namespace,
1387
+ method,
1388
+ path,
1389
+ f"retry after {wait}s" if wait else "no Retry-After",
1390
+ )
1391
+
1367
1392
  # Same distinction as in request(): re-bootstrapping and demanding
1368
1393
  # verification are session decisions, and a provider's refusal is
1369
1394
  # not grounds for either.
@@ -1374,12 +1399,6 @@ async def perform_signed_fetch(
1374
1399
  if isinstance(retry_auth_url, str) and retry_auth_url:
1375
1400
  return {"needsVerification": True, "auth_url": retry_auth_url}
1376
1401
 
1377
- retry_after = 0
1378
- raw_retry_after = resp.headers.get("Retry-After", "").strip()
1379
- if raw_retry_after.isdigit():
1380
- retry_after = max(0, int(raw_retry_after))
1381
-
1382
- ok = 200 <= resp.status_code < 300
1383
1402
  result = {
1384
1403
  "statusCode": resp.status_code,
1385
1404
  "status": resp.status_code,
@@ -1405,8 +1424,7 @@ async def perform_signed_fetch(
1405
1424
  # hand the gateway a switch to end a download early, and whether it
1406
1425
  # sets them that precisely on transient failures is not something
1407
1426
  # this side can verify.
1408
- if not ok:
1409
- err = parse_session_error(getattr(resp, "content", b""))
1427
+ if err is not None:
1410
1428
  if err.retry_mode:
1411
1429
  result["retryMode"] = err.retry_mode
1412
1430
  if not retry_after and err.retry_after_seconds:
@@ -1043,10 +1043,15 @@ async def _download_one_pass_async(
1043
1043
  download_one_async(), when a Hi-Res file turns out to be upsampled and
1044
1044
  is fetched again at LOSSLESS.
1045
1045
  """
1046
+ from .core import provider_cooldown
1047
+
1046
1048
  stop_event = asyncio.Event()
1047
1049
  DownloadManager()
1048
1050
  errors: dict[str, str] = {}
1049
1051
  started_at = time.monotonic()
1052
+ # A provider that answered "503 … try again in about N minutes" is kept
1053
+ # out of the rotation until then (see core/provider_cooldown.py).
1054
+ provider_cooldown.watch_extension_logs()
1050
1055
 
1051
1056
  # What was asked for, before any provider gets to write its own findings
1052
1057
  # onto the shared TrackMetadata. Taken once for the whole track, not once
@@ -1095,7 +1100,9 @@ async def _download_one_pass_async(
1095
1100
  await asyncio.sleep(wait)
1096
1101
  errors.clear()
1097
1102
 
1098
- for idx, provider in enumerate(providers):
1103
+ # Per attempt, not per track: a pause that starts on the first
1104
+ # attempt already applies to the retries.
1105
+ for idx, provider in enumerate(provider_cooldown.usable_providers(providers)):
1099
1106
  if idx > 0:
1100
1107
  is_ext = provider.name.startswith("ext:")
1101
1108
  target_type = "extension" if is_ext else "provider"
@@ -634,6 +634,32 @@ async function handleBridgeRequest() {
634
634
  Atomics.notify(STATE, 0, 1);
635
635
  }
636
636
 
637
+ /** Seconds a Retry-After header asks for — a number of seconds or an HTTP
638
+ * date — or 0 when there is none. */
639
+ function retryAfterSeconds(headers) {
640
+ const raw = String((headers && headers['retry-after']) || '').trim();
641
+ if (/^\d+$/.test(raw)) return Number(raw);
642
+ const when = Date.parse(raw);
643
+ return Number.isNaN(when) ? 0 : Math.max(0, Math.ceil((when - Date.now()) / 1000));
644
+ }
645
+
646
+ /** Logs a rate limit at warning level, with the wait it asked for.
647
+ * The extensions retry a 429 on their own and seldom say so, which left the
648
+ * host with no record of how long an address had been limited. Sent to
649
+ * Python as a log message on stdout rather than written to stderr, which
650
+ * Python only reads at debug level. */
651
+ function logRateLimited(what, rawUrl, headers) {
652
+ let host = String(rawUrl);
653
+ try { host = new URL(rawUrl).host; } catch (_) { /* keep the raw string */ }
654
+ const seconds = retryAfterSeconds(headers);
655
+ const wait = seconds > 0 ? `retry after ${seconds}s` : 'no Retry-After';
656
+ process.stdout.write(JSON.stringify({
657
+ type: 'log',
658
+ level: 'warning',
659
+ msg: `[http] HTTP 429 from ${host} (${what}) — ${wait}`,
660
+ }) + '\n');
661
+ }
662
+
637
663
  /** The User-Agent a request gets when the extension names none. The mobile
638
664
  * app's Go client always sends one of its own; Node sends nothing, and
639
665
  * some sites refuse a request without it outright. */
@@ -699,13 +725,16 @@ function nodeHttpRequest(method, rawUrl, body, headers, _depth = 0) {
699
725
  }
700
726
  res.setEncoding('utf8');
701
727
  res.on('data', (d) => { data += d; });
702
- res.on('end', () => resolve({
703
- statusCode: res.statusCode,
704
- body: data,
705
- headers: res.headers,
706
- url: rawUrl,
707
- error: null,
708
- }));
728
+ res.on('end', () => {
729
+ if (res.statusCode === 429) logRateLimited(method, rawUrl, res.headers);
730
+ resolve({
731
+ statusCode: res.statusCode,
732
+ body: data,
733
+ headers: res.headers,
734
+ url: rawUrl,
735
+ error: null,
736
+ });
737
+ });
709
738
  });
710
739
  req.on('error', (e) => resolve({ statusCode: 0, body: '', error: e.message, url: rawUrl }));
711
740
  req.setTimeout(30_000, () => { req.destroy(); resolve({ statusCode: 0, body: '', error: 'timeout' }); });
@@ -785,6 +814,7 @@ function nodeFileDownload(rawUrl, outputPath, opts, callId) {
785
814
  const stream = fs.createWriteStream(outputPath);
786
815
  const req = lib.request(reqOpts, (res) => {
787
816
  if (res.statusCode >= 400) {
817
+ if (res.statusCode === 429) logRateLimited('file download', rawUrl, res.headers);
788
818
  res.resume();
789
819
  stream.close();
790
820
  fs.unlink(outputPath, () => {});
@@ -929,6 +959,7 @@ function nodeFileDownloadSegments(urls, outputPath, opts, callId) {
929
959
  return;
930
960
  }
931
961
  if (status >= 400) {
962
+ if (status === 429) logRateLimited('segment', rawUrl, res.headers);
932
963
  res.resume();
933
964
  // 403/410 is how every signed CDN says "this URL has aged out".
934
965
  const expired = status === 403 || status === 410;
@@ -936,7 +967,7 @@ function nodeFileDownloadSegments(urls, outputPath, opts, callId) {
936
967
  ok: false,
937
968
  error: `HTTP ${status}`,
938
969
  error_type: expired ? 'expired_stream' : 'download_error',
939
- retry_after_seconds: Number(res.headers['retry-after'] || 0) || 0,
970
+ retry_after_seconds: retryAfterSeconds(res.headers),
940
971
  });
941
972
  return;
942
973
  }
@@ -216,6 +216,7 @@ class JSExtensionProvider(BaseProvider):
216
216
  else None
217
217
  ),
218
218
  cancelled_probe=self._stop_requested,
219
+ ext_name=self._ext.name,
219
220
  )
220
221
  rt.start()
221
222
  return rt
@@ -88,6 +88,7 @@ class JSRuntime:
88
88
  startup_timeout: float = 20.0,
89
89
  session_handler: Callable[[str, str, Any, dict], Awaitable[dict]] | None = None,
90
90
  cancelled_probe: Callable[[], bool] | None = None,
91
+ ext_name: str = "",
91
92
  ) -> None:
92
93
  self.ext_path = Path(ext_path)
93
94
  self.settings = settings or {}
@@ -98,6 +99,16 @@ class JSRuntime:
98
99
  # never cached here: the point of the probe is that the answer
99
100
  # changes underneath a call that is already running.
100
101
  self.cancelled_probe = cancelled_probe
102
+ # The extension's own log lines go out under a logger named for the
103
+ # extension. A JS extension has no module of its own to be known by,
104
+ # and the host needs to know *which* one said "503 … try again in
105
+ # about 51 minute(s)" to keep that one out of the rotation until
106
+ # then (core/provider_cooldown.py). Unnamed — a bare JSRuntime in a
107
+ # test — they stay on this module's logger, as they always were.
108
+ self.ext_name = ext_name
109
+ self._ext_logger = (
110
+ logging.getLogger(f"{__name__}.{ext_name}") if ext_name else logger
111
+ )
101
112
 
102
113
  self._proc: subprocess.Popen | None = None
103
114
  self._seq = 0
@@ -410,7 +421,8 @@ class JSRuntime:
410
421
  return
411
422
  if msg.get("type") == "log":
412
423
  level = msg.get("level", "info")
413
- getattr(logger, level, logger.info)("[EXT] %s", msg.get("msg", ""))
424
+ log = self._ext_logger
425
+ getattr(log, level, log.info)("[EXT] %s", msg.get("msg", ""))
414
426
  return
415
427
  if msg.get("type") == "session_signed_fetch":
416
428
  self._handle_session_signed_fetch(msg)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: SpotiFLAC
3
- Version: 4.2.2
3
+ Version: 4.2.3
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
@@ -87,6 +87,7 @@ SpotiFLAC/core/paths.py
87
87
  SpotiFLAC/core/playlist_sync.py
88
88
  SpotiFLAC/core/profiles.py
89
89
  SpotiFLAC/core/progress.py
90
+ SpotiFLAC/core/provider_cooldown.py
90
91
  SpotiFLAC/core/provider_stats.py
91
92
  SpotiFLAC/core/quality.py
92
93
  SpotiFLAC/core/recording_guard.py
@@ -282,7 +283,9 @@ tests/test_profiles.py
282
283
  tests/test_progress_and_csv_counters.py
283
284
  tests/test_progress_cross_loop.py
284
285
  tests/test_progress_manager_loops.py
286
+ tests/test_provider_cooldown.py
285
287
  tests/test_quotas.py
288
+ tests/test_rate_limit_logging.py
286
289
  tests/test_recording_guard.py
287
290
  tests/test_registry_signing_cli.py
288
291
  tests/test_release_date_hydration.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "SpotiFLAC"
7
- version = "4.2.2"
7
+ version = "4.2.3"
8
8
  description = "Fetch Spotify track metadata and retrieve matching lossless audio through configurable Tidal, Qobuz & Amazon Music provider backends."
9
9
  readme = "README.md"
10
10
  # 3.10, not 3.9: core/models.py declares PEP 604 unions (`str | None`) on