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