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.
- {spotiflac-4.2.2/SpotiFLAC.egg-info → spotiflac-4.2.3}/PKG-INFO +1 -1
- spotiflac-4.2.3/SpotiFLAC/core/provider_cooldown.py +226 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/signed_session_desktop.py +23 -2
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/signed_session_errors.py +29 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/signed_session_mobile.py +30 -12
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/downloader.py +8 -1
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/extensions/_bridge.js +39 -8
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/extensions/provider.py +1 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/extensions/runtime.py +13 -1
- {spotiflac-4.2.2 → spotiflac-4.2.3/SpotiFLAC.egg-info}/PKG-INFO +1 -1
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC.egg-info/SOURCES.txt +3 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/pyproject.toml +1 -1
- spotiflac-4.2.3/tests/test_provider_cooldown.py +266 -0
- spotiflac-4.2.3/tests/test_rate_limit_logging.py +300 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/LICENSE +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/MANIFEST.in +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/README.md +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/__init__.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/__main__.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/api_mixins/__init__.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/api_mixins/covers_lyrics.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/api_mixins/csv_import.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/api_mixins/dedup.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/api_mixins/discovery.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/api_mixins/extension_health.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/api_mixins/failed_tracks.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/api_mixins/local_tagging.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/api_mixins/search.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/api_mixins/signed_sessions.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/api_mixins/stats.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/api_mixins/subscriptions.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/api_mixins/trust.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/app.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/check_update.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/client.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/__init__.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/acoustid_lookup.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/apple_music_metadata.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/apple_ttml.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/atomic_io.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/audio_fingerprint.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/base.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/cache_admin.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/canvas.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/cli_preview.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/code_search.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/console.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/cross_loop_lock.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/csv_picker.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/csv_source.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/db.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/dns_doh.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/download_log.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/download_validation.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/errors.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/extension_enrichment.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/extension_metadata.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/failed_tracks.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/ffmpeg_check.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/flac_validation.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/health_check.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/hires_check.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/history.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/hooks.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/http.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/isrc_cache.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/isrc_finder.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/isrc_helper.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/isrc_utils.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/job_queue.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/library_dedup.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/library_index_cache.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/library_notify.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/library_upgrade.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/link_resolver.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/local_matcher.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/local_processor.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/local_scanner.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/loop_runner.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/lyrics.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/metadata_enrichment.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/metadata_fallback.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/models.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/musicbrainz.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/node_check.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/notifiers.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/output_lock.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/output_sink.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/paths.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/playlist_sync.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/profiles.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/progress.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/provider_stats.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/quality.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/recording_guard.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/replaygain.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/report.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/response_cache.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/session_memory.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/signed_session_mono.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/signed_session_status.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/solver.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/soundplate.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/spotfetch.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/spotify_metadata.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/spotify_protobuf.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/spotify_totp.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/stats.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/subscription_scheduler.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/subscriptions.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/tagger.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/text_match.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/tidal_metadata.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/tracklist.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/transcode.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/url_utils.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/core/web_users.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/extensions/__init__.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/extensions/_fsguard.js +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/extensions/_netguard.js +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/extensions/catalog.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/extensions/directories.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/extensions/manager.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/extensions/python_provider.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/extensions/registry_config.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/extensions/runtime_features.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/extensions/sandbox.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/extensions/trust.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/app.js +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/am.png +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/amzn.png +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/apple-touch-icon.png +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/download.svg +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/dzr.png +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/flacdownloader.png +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/joox.svg +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/kuwo.png +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/logo-192.png +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/logo-512.png +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/logo.svg +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/lrclib.png +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/migu.jpeg +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/musicbrainz_d.png +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/musicbrainz_l.png +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/musixmatch.svg +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/netease.svg +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/next.svg +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/pandora.svg +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/qbz.png +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/songlink_d.png +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/songlink_l.png +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/songstats.png +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/soundcloud.svg +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/spotify.svg +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/spotubedl.svg +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/tidal_d.png +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/tidal_l.png +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/xbatchdl.svg +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/assets/icons/youtube.svg +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/index.html +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/manifest.json +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/styles.css +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/sw.js +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/toast-system.js +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/frontend/web-shim.js +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/launcher.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/py.typed +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tools/__init__.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tools/bump_cache_bust.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tools/cloud_config_cli.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tools/dedup_check_cli.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tools/ext_dryrun.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tools/ext_scaffold.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tools/hires_check_cli.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tools/jellyfin_lyrics_gap.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tools/library_dedup_cli.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tools/library_upgrade_cli.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tools/registry_signing_cli.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tui/__init__.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tui/app.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tui/banner.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tui/branding.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tui/clipboard.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tui/config_state.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tui/config_view.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tui/csv_picker_screen.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tui/extensions_view.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tui/health_view.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tui/help_screen.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tui/queue_view.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tui/runner.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tui/search_view.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tui/session_view.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tui/signed_sessions_view.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tui/spotiflac.tcss +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tui/tracklist_view.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/tui/window_size.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/webapi/__init__.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/webapi/routes.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/webapi/schemas.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC/webapp.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC.egg-info/dependency_links.txt +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC.egg-info/entry_points.txt +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC.egg-info/requires.txt +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/SpotiFLAC.egg-info/top_level.txt +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/setup.cfg +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_acoustid_lookup.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_app_api_mixins.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_artist_credit_split.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_audio_fingerprint.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_bridge_download_segments.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_bridge_mobile_api.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_bridge_retry_utils.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_bridge_startup_errors.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_cache_admin.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_canvas.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_canvas_sidecar.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_cli_preview.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_core_logic.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_cover_fitting.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_cover_url_plumbing.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_credential_storage.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_cross_loop_locks.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_csv_download.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_csv_picker.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_csv_source.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_db_persistence.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_dedup_mixin.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_directories.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_disc_number_from_album.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_discovery_mixin.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_dns_doh.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_download_queue_reporting.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_download_recording_guard_wiring.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_download_resume.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_download_services.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_ext_scaffold_and_dryrun.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_extension_enrichment.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_extension_fsguard.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_extension_install_limits.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_extension_metadata.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_extension_netguard.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_extension_output_containment.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_extension_sandbox.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_failed_tracks_and_resume.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_fake_hires_redownload.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_ffmpeg_check.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_frontend_new_ui_wiring.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_gui_batch_downloads.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_gui_folder_options.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_gui_hires_verification.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_gui_prefetched_selection.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_hires_check.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_http_redirects.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_job_queue.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_json_report.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_launcher_tui_flag.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_launcher_web_flags.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_library_dedup.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_library_index_cache.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_library_notify.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_library_upgrade.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_link_resolver_resolve_api.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_link_resolver_songstats.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_local_matcher.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_loop_runner.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_lrc_sidecar.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_lyrics_apple_match.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_lyrics_jellyfin_gap.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_lyrics_provider_order.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_m4a_no_roundtrip.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_metadata_fallback.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_mono_browser_cleanup.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_mono_page_fallback.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_musicbrainz_fallback.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_musicbrainz_release_pick.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_musicbrainz_tag_names.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_node_check.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_node_options_quoting.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_notifiers.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_output_lock.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_output_sink.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_playcount_throttle.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_post_download_command_gate.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_post_download_hooks.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_profiles.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_progress_and_csv_counters.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_progress_cross_loop.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_progress_manager_loops.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_quotas.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_recording_guard.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_registry_signing_cli.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_release_date_hydration.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_replaygain.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_runtime_node_check.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_search_mixin.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_signed_session_backoff.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_signed_session_errors.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_signed_session_status.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_smoke.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_solver_turnstile_locate.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_solver_virtual_display.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_spotify_metadata_nulls.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_stats.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_stream_url_validation.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_subscription_scheduler.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_subscriptions.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_subscriptions_mixin.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_tag_number_parsing.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_title_floor.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_tqdm_lock_under_capture.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_track_identity.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_track_isrc_wiring.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_tracklist.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_transcode_ffmpeg_install.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_transcode_lossless.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_trust.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_trust_enforcement.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_trust_mixin.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_tui_app.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_tui_config_profile.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_tui_config_state.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_tui_csv_picker.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_tui_extensions.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_tui_fixes.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_tui_health.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_tui_look.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_tui_runner.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_tui_search.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_tui_session.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_tui_signed_sessions.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_tui_tracklist.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_tui_window_size.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_watch.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_web_shim_methods_in_sync.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_web_users.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_webapi_integration.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_webapi_v1.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_webapp_auth.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_webapp_isolation.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_webapp_multiuser.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_webapp_ops.py +0 -0
- {spotiflac-4.2.2 → spotiflac-4.2.3}/tests/test_webapp_pwa.py +0 -0
|
@@ -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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
1191
|
-
if
|
|
1192
|
-
return
|
|
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
|
|
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
|
-
|
|
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', () =>
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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:
|
|
970
|
+
retry_after_seconds: retryAfterSeconds(res.headers),
|
|
940
971
|
});
|
|
941
972
|
return;
|
|
942
973
|
}
|
|
@@ -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
|
-
|
|
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)
|
|
@@ -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.
|
|
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
|