SpotiFLAC 4.2.0__tar.gz → 4.2.2__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 (340) hide show
  1. {spotiflac-4.2.0/SpotiFLAC.egg-info → spotiflac-4.2.2}/PKG-INFO +2 -2
  2. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/api_mixins/search.py +66 -24
  3. spotiflac-4.2.2/SpotiFLAC/api_mixins/signed_sessions.py +55 -0
  4. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/app.py +9 -5
  5. spotiflac-4.2.2/SpotiFLAC/core/cross_loop_lock.py +53 -0
  6. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/dns_doh.py +4 -2
  7. spotiflac-4.2.2/SpotiFLAC/core/extension_enrichment.py +340 -0
  8. spotiflac-4.2.2/SpotiFLAC/core/extension_metadata.py +805 -0
  9. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/isrc_cache.py +5 -6
  10. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/lyrics.py +5 -7
  11. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/metadata_enrichment.py +292 -36
  12. spotiflac-4.2.2/SpotiFLAC/core/metadata_fallback.py +269 -0
  13. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/output_sink.py +24 -0
  14. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/profiles.py +4 -2
  15. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/progress.py +9 -26
  16. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/provider_stats.py +5 -2
  17. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/session_memory.py +3 -1
  18. spotiflac-4.2.2/SpotiFLAC/core/signed_session_status.py +438 -0
  19. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/tagger.py +12 -0
  20. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/tracklist.py +7 -4
  21. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/downloader.py +47 -8
  22. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/extensions/_bridge.js +170 -5
  23. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/extensions/manager.py +9 -16
  24. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/extensions/provider.py +17 -0
  25. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/frontend/app.js +237 -6
  26. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/frontend/index.html +28 -4
  27. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/frontend/styles.css +12 -0
  28. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/frontend/web-shim.js +2 -1
  29. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/launcher.py +58 -0
  30. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/tui/app.py +50 -9
  31. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/tui/banner.py +1 -0
  32. spotiflac-4.2.2/SpotiFLAC/tui/clipboard.py +82 -0
  33. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/tui/help_screen.py +1 -0
  34. spotiflac-4.2.2/SpotiFLAC/tui/signed_sessions_view.py +135 -0
  35. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/tui/spotiflac.tcss +2 -0
  36. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/webapp.py +11 -3
  37. {spotiflac-4.2.0 → spotiflac-4.2.2/SpotiFLAC.egg-info}/PKG-INFO +2 -2
  38. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC.egg-info/SOURCES.txt +16 -0
  39. {spotiflac-4.2.0 → spotiflac-4.2.2}/pyproject.toml +2 -2
  40. spotiflac-4.2.2/tests/test_bridge_mobile_api.py +268 -0
  41. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_core_logic.py +57 -0
  42. spotiflac-4.2.2/tests/test_cross_loop_locks.py +110 -0
  43. spotiflac-4.2.2/tests/test_extension_enrichment.py +560 -0
  44. spotiflac-4.2.2/tests/test_extension_metadata.py +739 -0
  45. spotiflac-4.2.2/tests/test_metadata_fallback.py +299 -0
  46. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_search_mixin.py +31 -0
  47. spotiflac-4.2.2/tests/test_signed_session_status.py +305 -0
  48. spotiflac-4.2.2/tests/test_tqdm_lock_under_capture.py +60 -0
  49. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_tui_app.py +140 -0
  50. spotiflac-4.2.2/tests/test_tui_signed_sessions.py +116 -0
  51. {spotiflac-4.2.0 → spotiflac-4.2.2}/LICENSE +0 -0
  52. {spotiflac-4.2.0 → spotiflac-4.2.2}/MANIFEST.in +0 -0
  53. {spotiflac-4.2.0 → spotiflac-4.2.2}/README.md +0 -0
  54. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/__init__.py +0 -0
  55. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/__main__.py +0 -0
  56. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/api_mixins/__init__.py +0 -0
  57. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/api_mixins/covers_lyrics.py +0 -0
  58. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/api_mixins/csv_import.py +0 -0
  59. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/api_mixins/dedup.py +0 -0
  60. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/api_mixins/discovery.py +0 -0
  61. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/api_mixins/extension_health.py +0 -0
  62. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/api_mixins/failed_tracks.py +0 -0
  63. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/api_mixins/local_tagging.py +0 -0
  64. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/api_mixins/stats.py +0 -0
  65. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/api_mixins/subscriptions.py +0 -0
  66. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/api_mixins/trust.py +0 -0
  67. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/check_update.py +0 -0
  68. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/client.py +0 -0
  69. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/__init__.py +0 -0
  70. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/acoustid_lookup.py +0 -0
  71. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/apple_music_metadata.py +0 -0
  72. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/apple_ttml.py +0 -0
  73. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/atomic_io.py +0 -0
  74. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/audio_fingerprint.py +0 -0
  75. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/base.py +0 -0
  76. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/cache_admin.py +0 -0
  77. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/canvas.py +0 -0
  78. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/cli_preview.py +0 -0
  79. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/code_search.py +0 -0
  80. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/console.py +0 -0
  81. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/csv_picker.py +0 -0
  82. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/csv_source.py +0 -0
  83. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/db.py +0 -0
  84. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/download_log.py +0 -0
  85. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/download_validation.py +0 -0
  86. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/errors.py +0 -0
  87. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/failed_tracks.py +0 -0
  88. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/ffmpeg_check.py +0 -0
  89. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/flac_validation.py +0 -0
  90. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/health_check.py +0 -0
  91. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/hires_check.py +0 -0
  92. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/history.py +0 -0
  93. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/hooks.py +0 -0
  94. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/http.py +0 -0
  95. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/isrc_finder.py +0 -0
  96. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/isrc_helper.py +0 -0
  97. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/isrc_utils.py +0 -0
  98. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/job_queue.py +0 -0
  99. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/library_dedup.py +0 -0
  100. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/library_index_cache.py +0 -0
  101. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/library_notify.py +0 -0
  102. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/library_upgrade.py +0 -0
  103. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/link_resolver.py +0 -0
  104. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/local_matcher.py +0 -0
  105. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/local_processor.py +0 -0
  106. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/local_scanner.py +0 -0
  107. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/loop_runner.py +0 -0
  108. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/models.py +0 -0
  109. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/musicbrainz.py +0 -0
  110. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/node_check.py +0 -0
  111. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/notifiers.py +0 -0
  112. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/output_lock.py +0 -0
  113. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/paths.py +0 -0
  114. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/playlist_sync.py +0 -0
  115. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/quality.py +0 -0
  116. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/recording_guard.py +0 -0
  117. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/replaygain.py +0 -0
  118. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/report.py +0 -0
  119. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/response_cache.py +0 -0
  120. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/signed_session_desktop.py +0 -0
  121. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/signed_session_errors.py +0 -0
  122. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/signed_session_mobile.py +0 -0
  123. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/signed_session_mono.py +0 -0
  124. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/solver.py +0 -0
  125. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/soundplate.py +0 -0
  126. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/spotfetch.py +0 -0
  127. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/spotify_metadata.py +0 -0
  128. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/spotify_protobuf.py +0 -0
  129. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/spotify_totp.py +0 -0
  130. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/stats.py +0 -0
  131. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/subscription_scheduler.py +0 -0
  132. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/subscriptions.py +0 -0
  133. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/text_match.py +0 -0
  134. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/tidal_metadata.py +0 -0
  135. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/transcode.py +0 -0
  136. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/url_utils.py +0 -0
  137. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/core/web_users.py +0 -0
  138. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/extensions/__init__.py +0 -0
  139. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/extensions/_fsguard.js +0 -0
  140. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/extensions/_netguard.js +0 -0
  141. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/extensions/catalog.py +0 -0
  142. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/extensions/directories.py +0 -0
  143. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/extensions/python_provider.py +0 -0
  144. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/extensions/registry_config.py +0 -0
  145. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/extensions/runtime.py +0 -0
  146. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/extensions/runtime_features.py +0 -0
  147. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/extensions/sandbox.py +0 -0
  148. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/extensions/trust.py +0 -0
  149. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/frontend/assets/icons/am.png +0 -0
  150. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/frontend/assets/icons/amzn.png +0 -0
  151. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/frontend/assets/icons/apple-touch-icon.png +0 -0
  152. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/frontend/assets/icons/download.svg +0 -0
  153. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/frontend/assets/icons/dzr.png +0 -0
  154. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/frontend/assets/icons/flacdownloader.png +0 -0
  155. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/frontend/assets/icons/joox.svg +0 -0
  156. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/frontend/assets/icons/kuwo.png +0 -0
  157. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/frontend/assets/icons/logo-192.png +0 -0
  158. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/frontend/assets/icons/logo-512.png +0 -0
  159. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/frontend/assets/icons/logo.svg +0 -0
  160. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/frontend/assets/icons/lrclib.png +0 -0
  161. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/frontend/assets/icons/migu.jpeg +0 -0
  162. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/frontend/assets/icons/musicbrainz_d.png +0 -0
  163. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/frontend/assets/icons/musicbrainz_l.png +0 -0
  164. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/frontend/assets/icons/musixmatch.svg +0 -0
  165. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/frontend/assets/icons/netease.svg +0 -0
  166. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/frontend/assets/icons/next.svg +0 -0
  167. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/frontend/assets/icons/pandora.svg +0 -0
  168. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/frontend/assets/icons/qbz.png +0 -0
  169. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/frontend/assets/icons/songlink_d.png +0 -0
  170. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/frontend/assets/icons/songlink_l.png +0 -0
  171. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/frontend/assets/icons/songstats.png +0 -0
  172. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/frontend/assets/icons/soundcloud.svg +0 -0
  173. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/frontend/assets/icons/spotify.svg +0 -0
  174. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/frontend/assets/icons/spotubedl.svg +0 -0
  175. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/frontend/assets/icons/tidal_d.png +0 -0
  176. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/frontend/assets/icons/tidal_l.png +0 -0
  177. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/frontend/assets/icons/xbatchdl.svg +0 -0
  178. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/frontend/assets/icons/youtube.svg +0 -0
  179. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/frontend/manifest.json +0 -0
  180. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/frontend/sw.js +0 -0
  181. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/frontend/toast-system.js +0 -0
  182. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/py.typed +0 -0
  183. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/tools/__init__.py +0 -0
  184. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/tools/bump_cache_bust.py +0 -0
  185. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/tools/cloud_config_cli.py +0 -0
  186. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/tools/dedup_check_cli.py +0 -0
  187. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/tools/ext_dryrun.py +0 -0
  188. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/tools/ext_scaffold.py +0 -0
  189. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/tools/hires_check_cli.py +0 -0
  190. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/tools/jellyfin_lyrics_gap.py +0 -0
  191. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/tools/library_dedup_cli.py +0 -0
  192. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/tools/library_upgrade_cli.py +0 -0
  193. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/tools/registry_signing_cli.py +0 -0
  194. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/tui/__init__.py +0 -0
  195. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/tui/branding.py +0 -0
  196. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/tui/config_state.py +0 -0
  197. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/tui/config_view.py +0 -0
  198. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/tui/csv_picker_screen.py +0 -0
  199. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/tui/extensions_view.py +0 -0
  200. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/tui/health_view.py +0 -0
  201. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/tui/queue_view.py +0 -0
  202. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/tui/runner.py +0 -0
  203. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/tui/search_view.py +0 -0
  204. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/tui/session_view.py +0 -0
  205. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/tui/tracklist_view.py +0 -0
  206. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/tui/window_size.py +0 -0
  207. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/webapi/__init__.py +0 -0
  208. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/webapi/routes.py +0 -0
  209. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC/webapi/schemas.py +0 -0
  210. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC.egg-info/dependency_links.txt +0 -0
  211. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC.egg-info/entry_points.txt +0 -0
  212. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC.egg-info/requires.txt +0 -0
  213. {spotiflac-4.2.0 → spotiflac-4.2.2}/SpotiFLAC.egg-info/top_level.txt +0 -0
  214. {spotiflac-4.2.0 → spotiflac-4.2.2}/setup.cfg +0 -0
  215. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_acoustid_lookup.py +0 -0
  216. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_app_api_mixins.py +0 -0
  217. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_artist_credit_split.py +0 -0
  218. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_audio_fingerprint.py +0 -0
  219. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_bridge_download_segments.py +0 -0
  220. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_bridge_retry_utils.py +0 -0
  221. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_bridge_startup_errors.py +0 -0
  222. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_cache_admin.py +0 -0
  223. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_canvas.py +0 -0
  224. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_canvas_sidecar.py +0 -0
  225. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_cli_preview.py +0 -0
  226. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_cover_fitting.py +0 -0
  227. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_cover_url_plumbing.py +0 -0
  228. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_credential_storage.py +0 -0
  229. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_csv_download.py +0 -0
  230. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_csv_picker.py +0 -0
  231. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_csv_source.py +0 -0
  232. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_db_persistence.py +0 -0
  233. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_dedup_mixin.py +0 -0
  234. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_directories.py +0 -0
  235. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_disc_number_from_album.py +0 -0
  236. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_discovery_mixin.py +0 -0
  237. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_dns_doh.py +0 -0
  238. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_download_queue_reporting.py +0 -0
  239. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_download_recording_guard_wiring.py +0 -0
  240. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_download_resume.py +0 -0
  241. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_download_services.py +0 -0
  242. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_ext_scaffold_and_dryrun.py +0 -0
  243. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_extension_fsguard.py +0 -0
  244. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_extension_install_limits.py +0 -0
  245. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_extension_netguard.py +0 -0
  246. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_extension_output_containment.py +0 -0
  247. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_extension_sandbox.py +0 -0
  248. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_failed_tracks_and_resume.py +0 -0
  249. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_fake_hires_redownload.py +0 -0
  250. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_ffmpeg_check.py +0 -0
  251. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_frontend_new_ui_wiring.py +0 -0
  252. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_gui_batch_downloads.py +0 -0
  253. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_gui_folder_options.py +0 -0
  254. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_gui_hires_verification.py +0 -0
  255. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_gui_prefetched_selection.py +0 -0
  256. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_hires_check.py +0 -0
  257. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_http_redirects.py +0 -0
  258. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_job_queue.py +0 -0
  259. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_json_report.py +0 -0
  260. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_launcher_tui_flag.py +0 -0
  261. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_launcher_web_flags.py +0 -0
  262. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_library_dedup.py +0 -0
  263. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_library_index_cache.py +0 -0
  264. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_library_notify.py +0 -0
  265. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_library_upgrade.py +0 -0
  266. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_link_resolver_resolve_api.py +0 -0
  267. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_link_resolver_songstats.py +0 -0
  268. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_local_matcher.py +0 -0
  269. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_loop_runner.py +0 -0
  270. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_lrc_sidecar.py +0 -0
  271. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_lyrics_apple_match.py +0 -0
  272. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_lyrics_jellyfin_gap.py +0 -0
  273. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_lyrics_provider_order.py +0 -0
  274. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_m4a_no_roundtrip.py +0 -0
  275. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_mono_browser_cleanup.py +0 -0
  276. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_mono_page_fallback.py +0 -0
  277. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_musicbrainz_fallback.py +0 -0
  278. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_musicbrainz_release_pick.py +0 -0
  279. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_musicbrainz_tag_names.py +0 -0
  280. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_node_check.py +0 -0
  281. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_node_options_quoting.py +0 -0
  282. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_notifiers.py +0 -0
  283. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_output_lock.py +0 -0
  284. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_output_sink.py +0 -0
  285. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_playcount_throttle.py +0 -0
  286. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_post_download_command_gate.py +0 -0
  287. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_post_download_hooks.py +0 -0
  288. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_profiles.py +0 -0
  289. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_progress_and_csv_counters.py +0 -0
  290. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_progress_cross_loop.py +0 -0
  291. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_progress_manager_loops.py +0 -0
  292. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_quotas.py +0 -0
  293. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_recording_guard.py +0 -0
  294. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_registry_signing_cli.py +0 -0
  295. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_release_date_hydration.py +0 -0
  296. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_replaygain.py +0 -0
  297. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_runtime_node_check.py +0 -0
  298. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_signed_session_backoff.py +0 -0
  299. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_signed_session_errors.py +0 -0
  300. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_smoke.py +0 -0
  301. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_solver_turnstile_locate.py +0 -0
  302. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_solver_virtual_display.py +0 -0
  303. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_spotify_metadata_nulls.py +0 -0
  304. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_stats.py +0 -0
  305. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_stream_url_validation.py +0 -0
  306. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_subscription_scheduler.py +0 -0
  307. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_subscriptions.py +0 -0
  308. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_subscriptions_mixin.py +0 -0
  309. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_tag_number_parsing.py +0 -0
  310. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_title_floor.py +0 -0
  311. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_track_identity.py +0 -0
  312. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_track_isrc_wiring.py +0 -0
  313. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_tracklist.py +0 -0
  314. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_transcode_ffmpeg_install.py +0 -0
  315. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_transcode_lossless.py +0 -0
  316. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_trust.py +0 -0
  317. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_trust_enforcement.py +0 -0
  318. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_trust_mixin.py +0 -0
  319. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_tui_config_profile.py +0 -0
  320. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_tui_config_state.py +0 -0
  321. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_tui_csv_picker.py +0 -0
  322. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_tui_extensions.py +0 -0
  323. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_tui_fixes.py +0 -0
  324. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_tui_health.py +0 -0
  325. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_tui_look.py +0 -0
  326. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_tui_runner.py +0 -0
  327. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_tui_search.py +0 -0
  328. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_tui_session.py +0 -0
  329. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_tui_tracklist.py +0 -0
  330. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_tui_window_size.py +0 -0
  331. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_watch.py +0 -0
  332. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_web_shim_methods_in_sync.py +0 -0
  333. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_web_users.py +0 -0
  334. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_webapi_integration.py +0 -0
  335. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_webapi_v1.py +0 -0
  336. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_webapp_auth.py +0 -0
  337. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_webapp_isolation.py +0 -0
  338. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_webapp_multiuser.py +0 -0
  339. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_webapp_ops.py +0 -0
  340. {spotiflac-4.2.0 → spotiflac-4.2.2}/tests/test_webapp_pwa.py +0 -0
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: SpotiFLAC
3
- Version: 4.2.0
3
+ Version: 4.2.2
4
4
  Summary: Fetch Spotify track metadata and retrieve matching lossless audio through configurable Tidal, Qobuz & Amazon Music provider backends.
5
- Author: BartolomeoRusso9, ShuKurenais
5
+ Author: BartolomeoRusso9
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://github.com/BartolomeoRusso9/SpotiFLAC-Module-Version
8
8
  Project-URL: Repository, https://github.com/BartolomeoRusso9/SpotiFLAC-Module-Version
@@ -42,7 +42,9 @@ def empty_results() -> dict[str, list]:
42
42
  return {"tracks": [], "albums": [], "artists": [], "playlists": []}
43
43
 
44
44
 
45
- def shape_search_results(results: dict, limit: int = 50) -> dict[str, list]:
45
+ def shape_search_results(
46
+ results: dict, limit: int = 50, provider: str = "spotify"
47
+ ) -> dict[str, list]:
46
48
  """Turns the client's objects into the dicts every frontend reads.
47
49
 
48
50
  `getattr` with a default throughout: a provider that stops returning one
@@ -72,7 +74,7 @@ def shape_search_results(results: dict, limit: int = 50) -> dict[str, list]:
72
74
  "is_explicit": getattr(t, "is_explicit", False),
73
75
  "explicit": getattr(t, "is_explicit", False),
74
76
  "isrc": getattr(t, "isrc", ""),
75
- "provider": "spotify",
77
+ "provider": provider,
76
78
  },
77
79
  )
78
80
 
@@ -91,7 +93,7 @@ def shape_search_results(results: dict, limit: int = 50) -> dict[str, list]:
91
93
  "release_date": a.get("release_date", ""),
92
94
  "external_urls": a.get("external_url", ""),
93
95
  "external_url": a.get("external_url", ""),
94
- "provider": "spotify",
96
+ "provider": provider,
95
97
  },
96
98
  )
97
99
 
@@ -107,7 +109,7 @@ def shape_search_results(results: dict, limit: int = 50) -> dict[str, list]:
107
109
  "cover": art.get("cover_url", ""),
108
110
  "external_urls": art.get("external_url", ""),
109
111
  "external_url": art.get("external_url", ""),
110
- "provider": "spotify",
112
+ "provider": provider,
111
113
  },
112
114
  )
113
115
 
@@ -124,13 +126,31 @@ def shape_search_results(results: dict, limit: int = 50) -> dict[str, list]:
124
126
  "cover": p.get("cover_url", ""),
125
127
  "external_urls": p.get("external_url", ""),
126
128
  "external_url": p.get("external_url", ""),
127
- "provider": "spotify",
129
+ "provider": provider,
128
130
  },
129
131
  )
130
132
  return out
131
133
 
132
134
 
133
- async def search_metadata_async(query: str, limit: int = 50) -> dict[str, list]:
135
+ def _is_spotify(source: str | None) -> bool:
136
+ return not source or source == "spotify"
137
+
138
+
139
+ def _search_client(source: str | None):
140
+ """Spotify, or the catalogue extension named `source` (see
141
+ core/extension_metadata.py — Melon, Bugs, Genie)."""
142
+ if _is_spotify(source):
143
+ from ..core.spotify_metadata import SpotifyMetadataClient
144
+
145
+ return SpotifyMetadataClient()
146
+ from ..core.extension_metadata import ExtensionMetadataClient
147
+
148
+ return ExtensionMetadataClient.for_source(source)
149
+
150
+
151
+ async def search_metadata_async(
152
+ query: str, limit: int = 50, source: str | None = None
153
+ ) -> dict[str, list]:
134
154
  """Searches and shapes, on the caller's event loop.
135
155
 
136
156
  The client has an async search; the sync `search()` next to it only
@@ -140,51 +160,73 @@ async def search_metadata_async(query: str, limit: int = 50) -> dict[str, list]:
140
160
  if not query:
141
161
  return empty_results()
142
162
 
143
- from ..core.spotify_metadata import SpotifyMetadataClient
144
-
145
- client = SpotifyMetadataClient()
163
+ client = _search_client(source)
146
164
  results = await client.search_async(query, limit=limit)
147
- return shape_search_results(results, limit)
165
+ return shape_search_results(results, limit, source or "spotify")
148
166
 
149
167
 
150
168
  class SearchMixin:
151
169
  """The two blocking entry points the desktop GUI needs."""
152
170
 
153
- def search_provider(self, query, limit=50):
171
+ def get_metadata_sources(self):
172
+ """What the search box can search: Spotify, then every installed
173
+ catalogue extension."""
174
+ try:
175
+ from ..core.extension_metadata import metadata_sources
176
+
177
+ return metadata_sources()
178
+ except Exception as exc:
179
+ self.log(f"get_metadata_sources error: {exc}", "error")
180
+ return [{"id": "spotify", "label": "Spotify"}]
181
+
182
+ def search_provider(self, query, limit=50, source=None):
154
183
  """Search music providers (Spotify) for metadata matching `query`.
155
184
 
156
185
  Returns a dictionary with 4 sections: tracks, albums, artists,
157
186
  playlists (max `limit` results each).
158
187
  """
159
188
  try:
160
- from ..core.spotify_metadata import SpotifyMetadataClient
161
-
162
- client = SpotifyMetadataClient()
163
- return shape_search_results(client.search(query, limit=limit), limit)
189
+ client = _search_client(source)
190
+ return shape_search_results(
191
+ client.search(query, limit=limit), limit, source or "spotify"
192
+ )
164
193
  except Exception as exc:
165
194
  self.log(f"search_provider error: {exc}", "error")
166
195
  return empty_results()
167
196
 
168
- def search_provider_async(self, query, limit=50):
169
- """Starts the search in a thread and pushes the result to the window."""
197
+ def search_provider_async(self, query, limit=50, source=None, request_id=None):
198
+ """Starts the search in a thread and pushes the result to the window.
199
+
200
+ `request_id` comes back with the result or the error, so the window
201
+ can drop an answer that arrives after a newer search — typing on, or
202
+ switching the source, starts another while this one is still out.
203
+ """
170
204
  if not query:
171
205
  return {"status": "empty"}
172
206
  threading.Thread(
173
207
  target=self._search_provider_thread,
174
- args=(query, limit),
208
+ args=(query, limit, source, request_id),
175
209
  daemon=True,
176
210
  ).start()
177
211
  return {"status": "started"}
178
212
 
179
- def _search_provider_thread(self, query, limit) -> None:
213
+ def _search_provider_thread(
214
+ self, query, limit, source=None, request_id=None
215
+ ) -> None:
180
216
  try:
181
- from ..core.spotify_metadata import SpotifyMetadataClient
182
-
183
- client = SpotifyMetadataClient()
184
- out = shape_search_results(client.search(query, limit=limit), limit)
217
+ client = _search_client(source)
218
+ out = shape_search_results(
219
+ client.search(query, limit=limit), limit, source or "spotify"
220
+ )
185
221
  except Exception as exc:
186
- self._push_quietly("app_handle_provider_search_error", str(exc))
222
+ # A bare string without an id, as before, for callers that send none.
223
+ error: Any = str(exc)
224
+ if request_id is not None:
225
+ error = {"message": str(exc), "request_id": request_id}
226
+ self._push_quietly("app_handle_provider_search_error", error)
187
227
  return
228
+ if request_id is not None:
229
+ out["request_id"] = request_id
188
230
  self._push_quietly("app_handle_provider_search_results", out)
189
231
 
190
232
  def _push_quietly(self, event: str, payload: Any) -> None:
@@ -0,0 +1,55 @@
1
+ """api_mixins/signed_sessions.py — which signed sessions are live, and until when.
2
+
3
+ A thin wrapper over `core/signed_session_status.py`, which is also what the
4
+ terminal UI and `--signed-sessions` call, so the three views cannot disagree.
5
+
6
+ Reading is harmless: no row carries a secret. Clearing and pruning edit
7
+ instance-wide state every account's downloads depend on, so in multi-user
8
+ mode (`owner` set) they are refused to anyone who is not an admin.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+
14
+ class SignedSessionsMixin:
15
+ def _may_edit_signed_sessions(self) -> bool:
16
+ owner = getattr(self, "owner", "") or ""
17
+ if not owner:
18
+ return True
19
+ try:
20
+ from ..core.web_users import is_admin
21
+
22
+ return is_admin(owner)
23
+ except Exception:
24
+ return False
25
+
26
+ def get_signed_sessions(self) -> dict:
27
+ """Shape: {"sessions": [row, ...]} — see `list_signed_sessions`."""
28
+ try:
29
+ from ..core.signed_session_status import list_signed_sessions
30
+
31
+ return {"sessions": list_signed_sessions()}
32
+ except Exception as e:
33
+ return {"sessions": [], "error": str(e)}
34
+
35
+ def clear_signed_session(self, key: str) -> dict:
36
+ if not self._may_edit_signed_sessions():
37
+ return {"ok": False, "error": "Only an admin can clear signed sessions."}
38
+ try:
39
+ from ..core.signed_session_status import clear_signed_session
40
+
41
+ if not clear_signed_session(str(key or "")):
42
+ return {"ok": False, "error": "Unknown session."}
43
+ return {"ok": True}
44
+ except Exception as e:
45
+ return {"ok": False, "error": str(e)}
46
+
47
+ def prune_signed_sessions(self) -> dict:
48
+ if not self._may_edit_signed_sessions():
49
+ return {"ok": False, "error": "Only an admin can remove signed sessions."}
50
+ try:
51
+ from ..core.signed_session_status import prune_orphaned_sessions
52
+
53
+ return {"ok": True, "removed": prune_orphaned_sessions()}
54
+ except Exception as e:
55
+ return {"ok": False, "error": str(e)}
@@ -24,6 +24,7 @@ from .api_mixins.extension_health import ExtensionHealthMixin
24
24
  from .api_mixins.failed_tracks import FailedTracksMixin
25
25
  from .api_mixins.local_tagging import LocalTaggingMixin
26
26
  from .api_mixins.search import SearchMixin
27
+ from .api_mixins.signed_sessions import SignedSessionsMixin
27
28
  from .api_mixins.stats import StatsMixin
28
29
  from .api_mixins.subscriptions import SubscriptionsMixin
29
30
  from .api_mixins.trust import TrustMixin
@@ -176,6 +177,7 @@ class SpotiFLAC_API(
176
177
  CsvImportMixin,
177
178
  StatsMixin,
178
179
  FailedTracksMixin,
180
+ SignedSessionsMixin,
179
181
  ):
180
182
  """pywebview/`--web` bridge — every method here (plus the two mixins
181
183
  above) becomes a callable the frontend invokes as `pywebview.api.<name>`
@@ -1047,19 +1049,21 @@ class SpotiFLAC_API(
1047
1049
  # setup — for every search typed into the box.
1048
1050
  from .core.tracklist import metadata_client_for
1049
1051
 
1050
- client = metadata_client_for(url)
1051
-
1052
1052
  # ── Universal call ──────────────────────────────────────────────────
1053
1053
  # get_url() is sync on SpotifyMetadataClient but async on
1054
1054
  # TidalMetadataClient/AppleMusicMetadataClient — calling it directly
1055
1055
  # here would hand back an un-awaited coroutine for the latter two
1056
1056
  # (crashing on the very next line, `result[0]`). Reuse the same
1057
1057
  # sync/async dispatch helper the real download path already relies
1058
- # on instead of duplicating (and re-diverging from) that logic.
1058
+ # on instead of duplicating (and re-diverging from) that logic —
1059
+ # through the fallback that asks an installed metadata extension
1060
+ # when the built-in Spotify or Apple Music client fails.
1059
1061
  # Returns (name, tracks) OR (name, tracks, cover) OR (name, tracks, cover, meta).
1060
- from .downloader import _call_metadata_get_url
1062
+ from .core.metadata_fallback import get_url_with_fallback
1061
1063
 
1062
- result = await _call_metadata_get_url(client, stripped)
1064
+ result = await get_url_with_fallback(
1065
+ stripped, lambda: metadata_client_for(url)
1066
+ )
1063
1067
  collection_name = result[0]
1064
1068
  tracks = result[1]
1065
1069
  collection_cover = result[2] if len(result) > 2 else ""
@@ -0,0 +1,53 @@
1
+ """SpotiFLAC/core/cross_loop_lock.py — a lock `async with` can use from any loop.
2
+
3
+ An ``asyncio.Lock`` binds to the first event loop that has to *wait* on it
4
+ and raises on every other one:
5
+
6
+ RuntimeError: <asyncio.locks.Lock ...> is bound to a different event loop
7
+
8
+ This process runs many loops. ``core/loop_runner.py`` owns a long-lived one
9
+ for the GUI and web bridges, ``client.SpotiFLAC()`` keeps its own
10
+ ``asyncio.run()`` per batch, and under ``--web`` each background download is
11
+ its own ``asyncio.run()`` on its own thread. A module-level ``asyncio.Lock``
12
+ therefore works for the first batch and crashes the first time two later ones
13
+ contend. Uncontended acquires never touch the loop, which is why it only
14
+ shows up under load.
15
+
16
+ A ``threading.Lock`` has no loop affinity. It is taken without blocking, and
17
+ the caller yields while it is busy, so the loop is never held up. It also
18
+ serialises *threads*, which the asyncio lock never did: two downloads on two
19
+ threads writing the same cache file were not actually excluded by it.
20
+
21
+ Holding it across an ``await`` is allowed — waiters just poll a little longer
22
+ — but keep what it guards short.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import threading
29
+
30
+
31
+ class CrossLoopLock:
32
+ """``async with`` mutual exclusion across event loops and threads."""
33
+
34
+ def __init__(self, poll_interval: float = 0.01) -> None:
35
+ self._lock = threading.Lock()
36
+ self._poll_interval = poll_interval
37
+
38
+ def locked(self) -> bool:
39
+ return self._lock.locked()
40
+
41
+ async def __aenter__(self) -> CrossLoopLock:
42
+ # A plain yield first: an uncontended lock is the overwhelmingly
43
+ # common case, and a contended one is usually free again within a
44
+ # single tick, so the poll interval should be the exception.
45
+ if not self._lock.acquire(blocking=False):
46
+ await asyncio.sleep(0)
47
+ while not self._lock.acquire(blocking=False):
48
+ await asyncio.sleep(self._poll_interval)
49
+ return self
50
+
51
+ async def __aexit__(self, exc_type, exc, tb) -> bool:
52
+ self._lock.release()
53
+ return False
@@ -26,11 +26,12 @@ its DNS server would be useless in exactly the situation it exists for.
26
26
 
27
27
  from __future__ import annotations
28
28
 
29
- import asyncio
30
29
  import ipaddress
31
30
  import logging
32
31
  import time
33
32
 
33
+ from .cross_loop_lock import CrossLoopLock
34
+
34
35
  logger = logging.getLogger(__name__)
35
36
 
36
37
  #: Resolvers, by literal address so this never needs DNS itself. Cloudflare
@@ -47,7 +48,8 @@ DOH_ENDPOINTS = (
47
48
  _TTL_S = 300.0
48
49
 
49
50
  _cache: dict[str, tuple[float, list[str]]] = {}
50
- _cache_lock = asyncio.Lock()
51
+ #: Shared by every download, each possibly on its own event loop.
52
+ _cache_lock = CrossLoopLock()
51
53
 
52
54
 
53
55
  def _is_public(address: str) -> bool: