From db04ef2cc4d7c96658497370e364d72bb43089eb Mon Sep 17 00:00:00 2001 From: Andrew Mazzola Date: Wed, 16 Sep 2026 02:27:22 -0400 Subject: [PATCH 1/5] refactor: turn fork into degen-radio --- .all-contributorsrc | 352 -- .all-contributorsrc.upstream-backup | 893 ---- .claude-plugin/marketplace.json | 16 - .coderabbit.yaml | 121 - .github/FUNDING.yml | 3 - .github/ISSUE_TEMPLATE/config.yml | 9 +- .github/copilot-instructions.md | 605 --- .github/dependabot.yml | 11 - .github/scripts/triage.mjs | 210 - .github/workflows/cd.yml | 544 --- .github/workflows/ci.yml | 285 +- .github/workflows/nix.yml | 70 - .github/workflows/publish-aur.yml | 177 - .github/workflows/toc.yml | 40 - .github/workflows/triage.yml | 72 - .gitignore | 10 - .gitmodules | 3 - AGENTS.md | 611 +-- CHANGELOG.md | 1527 +------ CLAUDE.md | 607 --- CONTRIBUTING.md | 95 +- Cargo.lock | 5137 ++-------------------- Cargo.toml | 296 +- README.md | 706 +-- agent-plugin/.claude-plugin/plugin.json | 15 - agent-plugin/.mcp.json | 11 - agent-plugin/README.md | 68 - agent-plugin/mcp.json | 12 - agent-plugin/plugin.json | 16 - agent-plugin/skills/spotatui-dj/SKILL.md | 125 - announcements.json | 59 - context7.json | 33 - default.nix | 6 +- docs/README.md | 25 - docs/ai-dj.md | 450 -- docs/configuration.md | 317 -- docs/installation.md | 162 - docs/keybindings.md | 117 - docs/mcp-setup.md | 262 -- docs/native-streaming.md | 95 - docs/roadmap.md | 78 - docs/scripting.md | 710 --- docs/themes.md | 60 - examples/config.example.yml | 129 - examples/plugins/README.md | 44 - examples/plugins/accent-cycler.lua | 24 - examples/plugins/now-playing-webhook.lua | 38 - examples/plugins/now-playing.lua | 140 - examples/plugins/queue-browser.lua | 115 - examples/plugins/session-stats/main.lua | 37 - examples/plugins/session-stats/stats.lua | 18 - examples/plugins/track-info-popup.lua | 31 - examples/plugins/track-notifier.lua | 20 - examples/theme_roundtrip.yml | 23 - flake.nix | 8 +- how_to_release.md | 40 - install.cmd | 6 +- install.ps1 | 6 +- install.sh | 6 +- snap/gui/spt.desktop | 9 - snap/gui/spt.png | Bin 476640 -> 0 bytes snap/snapcraft.yaml | 46 - spotatui.wiki | 1 - src/bin/spotatui-gui.rs | 39 - src/cli/CLAUDE.md | 11 - src/cli/clap.rs | 400 -- src/cli/cli_app.rs | 725 --- src/cli/handle.rs | 157 - src/cli/history.rs | 51 - src/cli/mcp.rs | 42 - src/cli/mod.rs | 22 - src/cli/plugin.rs | 808 ---- src/cli/update.rs | 342 -- src/cli/util.rs | 323 -- src/core/action/apply.rs | 369 -- src/core/action/mod.rs | 566 --- src/core/action/tests.rs | 3621 --------------- src/core/app/album_theme.rs | 383 -- src/core/app/construction.rs | 344 -- src/core/app/discover.rs | 128 - src/core/app/dj.rs | 397 -- src/core/app/friends.rs | 110 - src/core/app/help.rs | 32 - src/core/app/keybindings.rs | 139 - src/core/app/layout.rs | 45 - src/core/app/library.rs | 786 ---- src/core/app/lyrics.rs | 199 - src/core/app/mod.rs | 625 --- src/core/app/models.rs | 202 - src/core/app/native_backend.rs | 352 -- src/core/app/native_recovery.rs | 733 --- src/core/app/native_shuffle.rs | 375 -- src/core/app/party.rs | 97 - src/core/app/persistence.rs | 565 --- src/core/app/playback_routing.rs | 540 --- src/core/app/playlist_folders.rs | 321 -- src/core/app/playlist_pages.rs | 697 --- src/core/app/playlists.rs | 569 --- src/core/app/plugins.rs | 147 - src/core/app/queue.rs | 418 -- src/core/app/queue_suspend.rs | 705 --- src/core/app/recap.rs | 51 - src/core/app/route.rs | 631 --- src/core/app/scrollable_pages.rs | 216 - src/core/app/seek.rs | 409 -- src/core/app/settings_apply.rs | 833 ---- src/core/app/settings_schema.rs | 1087 ----- src/core/app/shuffle_repeat.rs | 288 -- src/core/app/status.rs | 309 -- src/core/app/test_support.rs | 124 - src/core/app/tick.rs | 438 -- src/core/app/transport.rs | 562 --- src/core/app/view.rs | 191 - src/core/app/volume.rs | 276 -- src/core/art.rs | 313 -- src/core/auth.rs | 1151 ----- src/core/banner.rs | 8 - src/core/config.rs | 502 --- src/core/cover_theme.rs | 488 -- src/core/driver/mod.rs | 974 ---- src/core/driver/plan.rs | 403 -- src/core/driver/presence.rs | 394 -- src/core/first_run.rs | 383 -- src/core/format.rs | 237 - src/core/geometry.rs | 11 - src/core/input.rs | 124 - src/core/limits.rs | 5 - src/core/migrations.rs | 671 --- src/core/mod.rs | 51 - src/core/onboarding.rs | 102 - src/core/pagination.rs | 107 - src/core/paths.rs | 215 - src/core/persisted_playback.rs | 386 -- src/core/plugin_api.rs | 1073 ----- src/core/queue.rs | 295 -- src/core/requirement.rs | 225 - src/core/sort.rs | 383 -- src/core/source.rs | 258 -- src/core/state.rs | 982 ----- src/core/test_helpers.rs | 238 - src/core/theme.rs | 833 ---- src/core/user_config.rs | 3594 --------------- src/gates.rs | 464 -- src/infra/audio/analyzer.rs | 279 -- src/infra/audio/capture.rs | 204 - src/infra/audio/cavacore/builder.rs | 209 - src/infra/audio/cavacore/error.rs | 65 - src/infra/audio/cavacore/mod.rs | 741 ---- src/infra/audio/mod.rs | 90 - src/infra/audio/pipewire_capture.rs | 214 - src/infra/discord_rpc.rs | 144 - src/infra/dj/CLAUDE.md | 34 - src/infra/dj/agent.rs | 683 --- src/infra/dj/brain/agent_cli.rs | 696 --- src/infra/dj/brain/anthropic.rs | 318 -- src/infra/dj/brain/mod.rs | 725 --- src/infra/dj/brain/openai_compat.rs | 404 -- src/infra/dj/brief.rs | 302 -- src/infra/dj/exec.rs | 163 - src/infra/dj/library.rs | 340 -- src/infra/dj/mod.rs | 442 -- src/infra/dj/resolve.rs | 579 --- src/infra/dj/session.rs | 753 ---- src/infra/dj/setup.rs | 1524 ------- src/infra/dj/tools.rs | 1017 ----- src/infra/history.rs | 2819 ------------ src/infra/local/dispatch.rs | 591 --- src/infra/local/mod.rs | 1222 ----- src/infra/macos_media.rs | 374 -- src/infra/mcp/CLAUDE.md | 10 - src/infra/mcp/control.rs | 469 -- src/infra/mcp/executor.rs | 50 - src/infra/mcp/mod.rs | 34 - src/infra/mcp/protocol.rs | 418 -- src/infra/mcp/relay.rs | 1069 ----- src/infra/mcp/server.rs | 643 --- src/infra/mcp/status.rs | 353 -- src/infra/media_metadata.rs | 880 ---- src/infra/mod.rs | 34 - src/infra/mpris.rs | 324 -- src/infra/network/dj.rs | 1244 ------ src/infra/network/friends.rs | 349 -- src/infra/network/ids.rs | 226 - src/infra/network/library.rs | 1926 -------- src/infra/network/mapping.rs | 555 --- src/infra/network/metadata.rs | 522 --- src/infra/network/mod.rs | 2428 ---------- src/infra/network/native_shuffle.rs | 1146 ----- src/infra/network/playback.rs | 3529 --------------- src/infra/network/recommend.rs | 148 - src/infra/network/requests.rs | 1208 ----- src/infra/network/search.rs | 233 - src/infra/network/spotify_source.rs | 412 -- src/infra/network/sync.rs | 195 - src/infra/network/user.rs | 286 -- src/infra/network/utils.rs | 661 --- src/infra/player/backend.rs | 73 - src/infra/player/events.rs | 1570 ------- src/infra/player/mod.rs | 18 - src/infra/player/streaming.rs | 1616 ------- src/infra/qobuz/auth.rs | 527 --- src/infra/qobuz/dispatch.rs | 791 ---- src/infra/qobuz/mod.rs | 934 ---- src/infra/qobuz/sign.rs | 65 - src/infra/qobuz/stream/cmaf.rs | 495 --- src/infra/qobuz/stream/crypto.rs | 127 - src/infra/qobuz/stream/download.rs | 140 - src/infra/qobuz/stream/mod.rs | 7 - src/infra/qobuz/stream/progressive.rs | 429 -- src/infra/qobuz/types.rs | 162 - src/infra/queue/dispatch.rs | 1586 ------- src/infra/queue/mod.rs | 789 ---- src/infra/radio/dispatch.rs | 588 --- src/infra/radio/mod.rs | 492 --- src/infra/radio/types.rs | 32 - src/infra/redirect_uri.html | 48 - src/infra/redirect_uri.rs | 247 -- src/infra/scripting/CLAUDE.md | 12 - src/infra/scripting/api.rs | 1295 ------ src/infra/scripting/engine.rs | 1090 ----- src/infra/scripting/events.rs | 201 - src/infra/scripting/mod.rs | 18 - src/infra/scripting/shared.rs | 143 - src/infra/scripting/tests.rs | 3347 -------------- src/infra/subsonic/dispatch.rs | 696 --- src/infra/subsonic/mod.rs | 929 ---- src/infra/subsonic/types.rs | 157 - src/infra/youtube/dispatch.rs | 1053 ----- src/infra/youtube/mod.rs | 638 --- src/infra/youtube/playlists.rs | 440 -- src/infra/youtube/types.rs | 30 - src/lib.rs | 19 +- src/radio_app/cli.rs | 119 + src/radio_app/config.rs | 116 + src/radio_app/directory.rs | 90 + src/radio_app/mod.rs | 131 + src/radio_app/mpris.rs | 175 + src/{infra/audio => radio_app}/player.rs | 85 +- src/{infra/radio => radio_app}/stream.rs | 6 +- src/radio_app/tui.rs | 466 ++ src/runtime/bootstrap.rs | 1021 ----- src/runtime/cli.rs | 251 -- src/runtime/mod.rs | 188 - src/runtime/pump.rs | 274 -- src/runtime/startup.rs | 1453 ------ src/runtime/streaming/launch.rs | 446 -- src/runtime/streaming/mod.rs | 303 -- src/tui/cover_art.rs | 1162 ----- src/tui/cover_art/dither.rs | 258 -- src/tui/event/events.rs | 138 - src/tui/event/key.rs | 101 - src/tui/event/mod.rs | 7 - src/tui/first_run.rs | 178 - src/tui/fuzzy.rs | 184 - src/tui/handlers/ai_dj.rs | 1185 ----- src/tui/handlers/album_list.rs | 214 - src/tui/handlers/album_tracks.rs | 341 -- src/tui/handlers/analysis.rs | 8 - src/tui/handlers/announcement_prompt.rs | 27 - src/tui/handlers/artist.rs | 488 -- src/tui/handlers/artists.rs | 186 - src/tui/handlers/common_key_events.rs | 225 - src/tui/handlers/community_pin_prompt.rs | 75 - src/tui/handlers/cover_art_view.rs | 71 - src/tui/handlers/create_playlist.rs | 289 -- src/tui/handlers/dialog.rs | 329 -- src/tui/handlers/discover.rs | 158 - src/tui/handlers/empty.rs | 206 - src/tui/handlers/episode_table.rs | 220 - src/tui/handlers/error_screen.rs | 3 - src/tui/handlers/filter_input.rs | 95 - src/tui/handlers/friends.rs | 259 -- src/tui/handlers/help_menu.rs | 234 - src/tui/handlers/home.rs | 97 - src/tui/handlers/input.rs | 664 --- src/tui/handlers/library.rs | 173 - src/tui/handlers/local_browser.rs | 74 - src/tui/handlers/lyrics_view.rs | 69 - src/tui/handlers/miniplayer.rs | 12 - src/tui/handlers/mod.rs | 1206 ----- src/tui/handlers/mouse.rs | 2196 --------- src/tui/handlers/party.rs | 201 - src/tui/handlers/playbar.rs | 69 - src/tui/handlers/playlist.rs | 578 --- src/tui/handlers/plugin_screen.rs | 116 - src/tui/handlers/podcasts.rs | 152 - src/tui/handlers/queue_menu.rs | 254 -- src/tui/handlers/recap_prompt.rs | 54 - src/tui/handlers/recently_played.rs | 181 - src/tui/handlers/resize.rs | 221 - src/tui/handlers/search_results.rs | 963 ---- src/tui/handlers/select_device.rs | 152 - src/tui/handlers/settings.rs | 888 ---- src/tui/handlers/sort_menu.rs | 145 - src/tui/handlers/stats.rs | 133 - src/tui/handlers/track_table.rs | 887 ---- src/tui/keymap.rs | 1312 ------ src/tui/layout.rs | 413 -- src/tui/mod.rs | 12 - src/tui/onboarding.rs | 59 - src/tui/runner.rs | 669 --- src/tui/theme.rs | 67 - src/tui/ui/ai_dj.rs | 1277 ------ src/tui/ui/artist.rs | 107 - src/tui/ui/audio_analysis.rs | 388 -- src/tui/ui/columns.rs | 283 -- src/tui/ui/create_playlist.rs | 215 - src/tui/ui/discover.rs | 207 - src/tui/ui/friends.rs | 559 --- src/tui/ui/help.rs | 193 - src/tui/ui/home.rs | 664 --- src/tui/ui/library.rs | 388 -- src/tui/ui/lyrics.rs | 297 -- src/tui/ui/mod.rs | 233 - src/tui/ui/player.rs | 2261 ---------- src/tui/ui/plugin_screen.rs | 589 --- src/tui/ui/popups.rs | 1452 ------ src/tui/ui/search.rs | 463 -- src/tui/ui/settings.rs | 582 --- src/tui/ui/stats.rs | 277 -- src/tui/ui/tables.rs | 949 ---- src/tui/ui/util.rs | 195 - tools/check_gates_ratchet.sh | 79 - tools/gates.count | 18 - worker-relay/package-lock.json | 1610 ------- worker-relay/package.json | 14 - worker-relay/src/index.ts | 350 -- worker-relay/tsconfig.json | 12 - worker-relay/wrangler.toml | 15 - 329 files changed, 1559 insertions(+), 143420 deletions(-) delete mode 100644 .all-contributorsrc delete mode 100644 .all-contributorsrc.upstream-backup delete mode 100644 .claude-plugin/marketplace.json delete mode 100644 .coderabbit.yaml delete mode 100644 .github/FUNDING.yml delete mode 100644 .github/copilot-instructions.md delete mode 100644 .github/scripts/triage.mjs delete mode 100644 .github/workflows/cd.yml delete mode 100644 .github/workflows/nix.yml delete mode 100644 .github/workflows/publish-aur.yml delete mode 100644 .github/workflows/toc.yml delete mode 100644 .github/workflows/triage.yml delete mode 100644 .gitmodules delete mode 100644 CLAUDE.md delete mode 100644 agent-plugin/.claude-plugin/plugin.json delete mode 100644 agent-plugin/.mcp.json delete mode 100644 agent-plugin/README.md delete mode 100644 agent-plugin/mcp.json delete mode 100644 agent-plugin/plugin.json delete mode 100644 agent-plugin/skills/spotatui-dj/SKILL.md delete mode 100644 announcements.json delete mode 100644 context7.json delete mode 100644 docs/README.md delete mode 100644 docs/ai-dj.md delete mode 100644 docs/configuration.md delete mode 100644 docs/installation.md delete mode 100644 docs/keybindings.md delete mode 100644 docs/mcp-setup.md delete mode 100644 docs/native-streaming.md delete mode 100644 docs/roadmap.md delete mode 100644 docs/scripting.md delete mode 100644 docs/themes.md delete mode 100644 examples/config.example.yml delete mode 100644 examples/plugins/README.md delete mode 100644 examples/plugins/accent-cycler.lua delete mode 100644 examples/plugins/now-playing-webhook.lua delete mode 100644 examples/plugins/now-playing.lua delete mode 100644 examples/plugins/queue-browser.lua delete mode 100644 examples/plugins/session-stats/main.lua delete mode 100644 examples/plugins/session-stats/stats.lua delete mode 100644 examples/plugins/track-info-popup.lua delete mode 100644 examples/plugins/track-notifier.lua delete mode 100644 examples/theme_roundtrip.yml delete mode 100644 how_to_release.md delete mode 100644 snap/gui/spt.desktop delete mode 100644 snap/gui/spt.png delete mode 100644 snap/snapcraft.yaml delete mode 160000 spotatui.wiki delete mode 100644 src/bin/spotatui-gui.rs delete mode 100644 src/cli/CLAUDE.md delete mode 100644 src/cli/clap.rs delete mode 100644 src/cli/cli_app.rs delete mode 100644 src/cli/handle.rs delete mode 100644 src/cli/history.rs delete mode 100644 src/cli/mcp.rs delete mode 100644 src/cli/mod.rs delete mode 100644 src/cli/plugin.rs delete mode 100644 src/cli/update.rs delete mode 100644 src/cli/util.rs delete mode 100644 src/core/action/apply.rs delete mode 100644 src/core/action/mod.rs delete mode 100644 src/core/action/tests.rs delete mode 100644 src/core/app/album_theme.rs delete mode 100644 src/core/app/construction.rs delete mode 100644 src/core/app/discover.rs delete mode 100644 src/core/app/dj.rs delete mode 100644 src/core/app/friends.rs delete mode 100644 src/core/app/help.rs delete mode 100644 src/core/app/keybindings.rs delete mode 100644 src/core/app/layout.rs delete mode 100644 src/core/app/library.rs delete mode 100644 src/core/app/lyrics.rs delete mode 100644 src/core/app/mod.rs delete mode 100644 src/core/app/models.rs delete mode 100644 src/core/app/native_backend.rs delete mode 100644 src/core/app/native_recovery.rs delete mode 100644 src/core/app/native_shuffle.rs delete mode 100644 src/core/app/party.rs delete mode 100644 src/core/app/persistence.rs delete mode 100644 src/core/app/playback_routing.rs delete mode 100644 src/core/app/playlist_folders.rs delete mode 100644 src/core/app/playlist_pages.rs delete mode 100644 src/core/app/playlists.rs delete mode 100644 src/core/app/plugins.rs delete mode 100644 src/core/app/queue.rs delete mode 100644 src/core/app/queue_suspend.rs delete mode 100644 src/core/app/recap.rs delete mode 100644 src/core/app/route.rs delete mode 100644 src/core/app/scrollable_pages.rs delete mode 100644 src/core/app/seek.rs delete mode 100644 src/core/app/settings_apply.rs delete mode 100644 src/core/app/settings_schema.rs delete mode 100644 src/core/app/shuffle_repeat.rs delete mode 100644 src/core/app/status.rs delete mode 100644 src/core/app/test_support.rs delete mode 100644 src/core/app/tick.rs delete mode 100644 src/core/app/transport.rs delete mode 100644 src/core/app/view.rs delete mode 100644 src/core/app/volume.rs delete mode 100644 src/core/art.rs delete mode 100644 src/core/auth.rs delete mode 100644 src/core/banner.rs delete mode 100644 src/core/config.rs delete mode 100644 src/core/cover_theme.rs delete mode 100644 src/core/driver/mod.rs delete mode 100644 src/core/driver/plan.rs delete mode 100644 src/core/driver/presence.rs delete mode 100644 src/core/first_run.rs delete mode 100644 src/core/format.rs delete mode 100644 src/core/geometry.rs delete mode 100644 src/core/input.rs delete mode 100644 src/core/limits.rs delete mode 100644 src/core/migrations.rs delete mode 100644 src/core/mod.rs delete mode 100644 src/core/onboarding.rs delete mode 100644 src/core/pagination.rs delete mode 100644 src/core/paths.rs delete mode 100644 src/core/persisted_playback.rs delete mode 100644 src/core/plugin_api.rs delete mode 100644 src/core/queue.rs delete mode 100644 src/core/requirement.rs delete mode 100644 src/core/sort.rs delete mode 100644 src/core/source.rs delete mode 100644 src/core/state.rs delete mode 100644 src/core/test_helpers.rs delete mode 100644 src/core/theme.rs delete mode 100644 src/core/user_config.rs delete mode 100644 src/gates.rs delete mode 100644 src/infra/audio/analyzer.rs delete mode 100644 src/infra/audio/capture.rs delete mode 100644 src/infra/audio/cavacore/builder.rs delete mode 100644 src/infra/audio/cavacore/error.rs delete mode 100644 src/infra/audio/cavacore/mod.rs delete mode 100644 src/infra/audio/mod.rs delete mode 100644 src/infra/audio/pipewire_capture.rs delete mode 100644 src/infra/discord_rpc.rs delete mode 100644 src/infra/dj/CLAUDE.md delete mode 100644 src/infra/dj/agent.rs delete mode 100644 src/infra/dj/brain/agent_cli.rs delete mode 100644 src/infra/dj/brain/anthropic.rs delete mode 100644 src/infra/dj/brain/mod.rs delete mode 100644 src/infra/dj/brain/openai_compat.rs delete mode 100644 src/infra/dj/brief.rs delete mode 100644 src/infra/dj/exec.rs delete mode 100644 src/infra/dj/library.rs delete mode 100644 src/infra/dj/mod.rs delete mode 100644 src/infra/dj/resolve.rs delete mode 100644 src/infra/dj/session.rs delete mode 100644 src/infra/dj/setup.rs delete mode 100644 src/infra/dj/tools.rs delete mode 100644 src/infra/history.rs delete mode 100644 src/infra/local/dispatch.rs delete mode 100644 src/infra/local/mod.rs delete mode 100644 src/infra/macos_media.rs delete mode 100644 src/infra/mcp/CLAUDE.md delete mode 100644 src/infra/mcp/control.rs delete mode 100644 src/infra/mcp/executor.rs delete mode 100644 src/infra/mcp/mod.rs delete mode 100644 src/infra/mcp/protocol.rs delete mode 100644 src/infra/mcp/relay.rs delete mode 100644 src/infra/mcp/server.rs delete mode 100644 src/infra/mcp/status.rs delete mode 100644 src/infra/media_metadata.rs delete mode 100644 src/infra/mod.rs delete mode 100644 src/infra/mpris.rs delete mode 100644 src/infra/network/dj.rs delete mode 100644 src/infra/network/friends.rs delete mode 100644 src/infra/network/ids.rs delete mode 100644 src/infra/network/library.rs delete mode 100644 src/infra/network/mapping.rs delete mode 100644 src/infra/network/metadata.rs delete mode 100644 src/infra/network/mod.rs delete mode 100644 src/infra/network/native_shuffle.rs delete mode 100644 src/infra/network/playback.rs delete mode 100644 src/infra/network/recommend.rs delete mode 100644 src/infra/network/requests.rs delete mode 100644 src/infra/network/search.rs delete mode 100644 src/infra/network/spotify_source.rs delete mode 100644 src/infra/network/sync.rs delete mode 100644 src/infra/network/user.rs delete mode 100644 src/infra/network/utils.rs delete mode 100644 src/infra/player/backend.rs delete mode 100644 src/infra/player/events.rs delete mode 100644 src/infra/player/mod.rs delete mode 100644 src/infra/player/streaming.rs delete mode 100644 src/infra/qobuz/auth.rs delete mode 100644 src/infra/qobuz/dispatch.rs delete mode 100644 src/infra/qobuz/mod.rs delete mode 100644 src/infra/qobuz/sign.rs delete mode 100644 src/infra/qobuz/stream/cmaf.rs delete mode 100644 src/infra/qobuz/stream/crypto.rs delete mode 100644 src/infra/qobuz/stream/download.rs delete mode 100644 src/infra/qobuz/stream/mod.rs delete mode 100644 src/infra/qobuz/stream/progressive.rs delete mode 100644 src/infra/qobuz/types.rs delete mode 100644 src/infra/queue/dispatch.rs delete mode 100644 src/infra/queue/mod.rs delete mode 100644 src/infra/radio/dispatch.rs delete mode 100644 src/infra/radio/mod.rs delete mode 100644 src/infra/radio/types.rs delete mode 100644 src/infra/redirect_uri.html delete mode 100644 src/infra/redirect_uri.rs delete mode 100644 src/infra/scripting/CLAUDE.md delete mode 100644 src/infra/scripting/api.rs delete mode 100644 src/infra/scripting/engine.rs delete mode 100644 src/infra/scripting/events.rs delete mode 100644 src/infra/scripting/mod.rs delete mode 100644 src/infra/scripting/shared.rs delete mode 100644 src/infra/scripting/tests.rs delete mode 100644 src/infra/subsonic/dispatch.rs delete mode 100644 src/infra/subsonic/mod.rs delete mode 100644 src/infra/subsonic/types.rs delete mode 100644 src/infra/youtube/dispatch.rs delete mode 100644 src/infra/youtube/mod.rs delete mode 100644 src/infra/youtube/playlists.rs delete mode 100644 src/infra/youtube/types.rs create mode 100644 src/radio_app/cli.rs create mode 100644 src/radio_app/config.rs create mode 100644 src/radio_app/directory.rs create mode 100644 src/radio_app/mod.rs create mode 100644 src/radio_app/mpris.rs rename src/{infra/audio => radio_app}/player.rs (92%) rename src/{infra/radio => radio_app}/stream.rs (98%) create mode 100644 src/radio_app/tui.rs delete mode 100644 src/runtime/bootstrap.rs delete mode 100644 src/runtime/cli.rs delete mode 100644 src/runtime/mod.rs delete mode 100644 src/runtime/pump.rs delete mode 100644 src/runtime/startup.rs delete mode 100644 src/runtime/streaming/launch.rs delete mode 100644 src/runtime/streaming/mod.rs delete mode 100644 src/tui/cover_art.rs delete mode 100644 src/tui/cover_art/dither.rs delete mode 100644 src/tui/event/events.rs delete mode 100644 src/tui/event/key.rs delete mode 100644 src/tui/event/mod.rs delete mode 100644 src/tui/first_run.rs delete mode 100644 src/tui/fuzzy.rs delete mode 100644 src/tui/handlers/ai_dj.rs delete mode 100644 src/tui/handlers/album_list.rs delete mode 100644 src/tui/handlers/album_tracks.rs delete mode 100644 src/tui/handlers/analysis.rs delete mode 100644 src/tui/handlers/announcement_prompt.rs delete mode 100644 src/tui/handlers/artist.rs delete mode 100644 src/tui/handlers/artists.rs delete mode 100644 src/tui/handlers/common_key_events.rs delete mode 100644 src/tui/handlers/community_pin_prompt.rs delete mode 100644 src/tui/handlers/cover_art_view.rs delete mode 100644 src/tui/handlers/create_playlist.rs delete mode 100644 src/tui/handlers/dialog.rs delete mode 100644 src/tui/handlers/discover.rs delete mode 100644 src/tui/handlers/empty.rs delete mode 100644 src/tui/handlers/episode_table.rs delete mode 100644 src/tui/handlers/error_screen.rs delete mode 100644 src/tui/handlers/filter_input.rs delete mode 100644 src/tui/handlers/friends.rs delete mode 100644 src/tui/handlers/help_menu.rs delete mode 100644 src/tui/handlers/home.rs delete mode 100644 src/tui/handlers/input.rs delete mode 100644 src/tui/handlers/library.rs delete mode 100644 src/tui/handlers/local_browser.rs delete mode 100644 src/tui/handlers/lyrics_view.rs delete mode 100644 src/tui/handlers/miniplayer.rs delete mode 100644 src/tui/handlers/mod.rs delete mode 100644 src/tui/handlers/mouse.rs delete mode 100644 src/tui/handlers/party.rs delete mode 100644 src/tui/handlers/playbar.rs delete mode 100644 src/tui/handlers/playlist.rs delete mode 100644 src/tui/handlers/plugin_screen.rs delete mode 100644 src/tui/handlers/podcasts.rs delete mode 100644 src/tui/handlers/queue_menu.rs delete mode 100644 src/tui/handlers/recap_prompt.rs delete mode 100644 src/tui/handlers/recently_played.rs delete mode 100644 src/tui/handlers/resize.rs delete mode 100644 src/tui/handlers/search_results.rs delete mode 100644 src/tui/handlers/select_device.rs delete mode 100644 src/tui/handlers/settings.rs delete mode 100644 src/tui/handlers/sort_menu.rs delete mode 100644 src/tui/handlers/stats.rs delete mode 100644 src/tui/handlers/track_table.rs delete mode 100644 src/tui/keymap.rs delete mode 100644 src/tui/layout.rs delete mode 100644 src/tui/mod.rs delete mode 100644 src/tui/onboarding.rs delete mode 100644 src/tui/runner.rs delete mode 100644 src/tui/theme.rs delete mode 100644 src/tui/ui/ai_dj.rs delete mode 100644 src/tui/ui/artist.rs delete mode 100644 src/tui/ui/audio_analysis.rs delete mode 100644 src/tui/ui/columns.rs delete mode 100644 src/tui/ui/create_playlist.rs delete mode 100644 src/tui/ui/discover.rs delete mode 100644 src/tui/ui/friends.rs delete mode 100644 src/tui/ui/help.rs delete mode 100644 src/tui/ui/home.rs delete mode 100644 src/tui/ui/library.rs delete mode 100644 src/tui/ui/lyrics.rs delete mode 100644 src/tui/ui/mod.rs delete mode 100644 src/tui/ui/player.rs delete mode 100644 src/tui/ui/plugin_screen.rs delete mode 100644 src/tui/ui/popups.rs delete mode 100644 src/tui/ui/search.rs delete mode 100644 src/tui/ui/settings.rs delete mode 100644 src/tui/ui/stats.rs delete mode 100644 src/tui/ui/tables.rs delete mode 100644 src/tui/ui/util.rs delete mode 100755 tools/check_gates_ratchet.sh delete mode 100644 tools/gates.count delete mode 100644 worker-relay/package-lock.json delete mode 100644 worker-relay/package.json delete mode 100644 worker-relay/src/index.ts delete mode 100644 worker-relay/tsconfig.json delete mode 100644 worker-relay/wrangler.toml diff --git a/.all-contributorsrc b/.all-contributorsrc deleted file mode 100644 index db4718a9..00000000 --- a/.all-contributorsrc +++ /dev/null @@ -1,352 +0,0 @@ -{ - "projectName": "spotatui", - "projectOwner": "LargeModGames", - "repoType": "github", - "repoHost": "https://github.com", - "files": [ - "README.md" - ], - "imageSize": 100, - "commit": true, - "commitConvention": "angular", - "contributors": [ - { - "login": "LargeModGames", - "name": "LargeModGames", - "avatar_url": "https://avatars.githubusercontent.com/u/84450916?v=4", - "profile": "https://github.com/LargeModGames", - "contributions": [ - "code", - "doc", - "maintenance", - "ideas", - "infra", - "test" - ] - }, - { - "login": "MysteriousWolf", - "name": "MysteriousWolf", - "avatar_url": "https://avatars.githubusercontent.com/u/5306409?v=4", - "profile": "https://github.com/MysteriousWolf", - "contributions": [ - "code" - ] - }, - { - "login": "rawcode1337", - "name": "rawcode1337", - "avatar_url": "https://avatars.githubusercontent.com/u/80097670?v=4", - "profile": "https://github.com/rawcode1337", - "contributions": [ - "code", - "bug" - ] - }, - { - "login": "copeison", - "name": "copeison", - "avatar_url": "https://avatars.githubusercontent.com/u/184175589?v=4", - "profile": "https://github.com/copeison", - "contributions": [ - "platform" - ] - }, - { - "login": "jacklorusso", - "name": "jacklorusso", - "avatar_url": "https://avatars.githubusercontent.com/u/19835679?v=4", - "profile": "https://github.com/jacklorusso", - "contributions": [ - "doc" - ] - }, - { - "login": "H41L33", - "name": "H41L33", - "avatar_url": "https://avatars.githubusercontent.com/u/140116782?v=4", - "profile": "https://github.com/H41L33", - "contributions": [ - "doc" - ] - }, - { - "login": "LordMZTE", - "name": "LordMZTE", - "avatar_url": "https://avatars.githubusercontent.com/u/28735087?v=4", - "profile": "https://mzte.de", - "contributions": [ - "code", - "platform" - ] - }, - { - "login": "bodoque-01", - "name": "Sebastian Sarco", - "avatar_url": "https://avatars.githubusercontent.com/u/63447579?v=4", - "profile": "https://github.com/bodoque-01", - "contributions": [ - "code" - ] - }, - { - "login": "El-Mundos", - "name": "Sergio Tabernero Hernández", - "avatar_url": "https://avatars.githubusercontent.com/u/70759168?v=4", - "profile": "https://github.com/El-Mundos", - "contributions": [ - "code", - "platform" - ] - }, - { - "login": "dpnova", - "name": "David Novakovic", - "avatar_url": "https://avatars.githubusercontent.com/u/229943?v=4", - "profile": "https://dpnova.github.io/", - "contributions": [ - "code" - ] - }, - { - "login": "nthpaul", - "name": "Paul", - "avatar_url": "https://avatars.githubusercontent.com/u/70828466?v=4", - "profile": "http://nthpaul.com", - "contributions": [ - "design", - "code" - ] - }, - { - "login": "AFE123x", - "name": "Arun Felix", - "avatar_url": "https://avatars.githubusercontent.com/u/121839885?v=4", - "profile": "https://github.com/AFE123x", - "contributions": [ - "code", - "bug" - ] - }, - { - "login": "MaySeikatsu", - "name": "MaySeikatsu", - "avatar_url": "https://avatars.githubusercontent.com/u/127960577?v=4", - "profile": "https://github.com/MaySeikatsu", - "contributions": [ - "code", - "doc", - "platform" - ] - }, - { - "login": "topongo", - "name": "Lorenzo Bodini", - "avatar_url": "https://avatars.githubusercontent.com/u/32436755?v=4", - "profile": "http://prabo.org", - "contributions": [ - "code", - "design" - ] - }, - { - "login": "Vi1i", - "name": "Vi1i Petal", - "avatar_url": "https://avatars.githubusercontent.com/u/6485370?v=4", - "profile": "https://github.com/Vi1i", - "contributions": [ - "code", - "bug" - ] - }, - { - "login": "wrsturgeon", - "name": "Will Sturgeon", - "avatar_url": "https://avatars.githubusercontent.com/u/79714036?v=4", - "profile": "https://github.com/wrsturgeon", - "contributions": [ - "code", - "bug" - ] - }, - { - "login": "wfinken", - "name": "wfinken", - "avatar_url": "https://avatars.githubusercontent.com/u/891256?v=4", - "profile": "https://github.com/wfinken", - "contributions": [ - "code", - "bug" - ] - }, - { - "login": "Kathund", - "name": "Jacob", - "avatar_url": "https://avatars.githubusercontent.com/u/55346310?v=4", - "profile": "http://kathund.dev", - "contributions": [ - "platform" - ] - }, - { - "login": "domogami", - "name": "Dominick Lee", - "avatar_url": "https://avatars.githubusercontent.com/u/43938540?v=4", - "profile": "http://dominicklee.net", - "contributions": [ - "code" - ] - }, - { - "login": "higorprado", - "name": "Higor Prado", - "avatar_url": "https://avatars.githubusercontent.com/u/1037397?v=4", - "profile": "https://github.com/higorprado", - "contributions": [ - "code" - ] - }, - { - "login": "eiseq", - "name": "Vitali Kaplich", - "avatar_url": "https://avatars.githubusercontent.com/u/80960013?v=4", - "profile": "https://github.com/eiseq", - "contributions": [ - "doc" - ] - }, - { - "login": "1knth", - "name": "knth", - "avatar_url": "https://avatars.githubusercontent.com/u/115324660?v=4", - "profile": "https://github.com/1knth", - "contributions": [ - "code" - ] - }, - { - "login": "SkyeVault", - "name": "Lorelei Noble", - "avatar_url": "https://avatars.githubusercontent.com/u/64027767?v=4", - "profile": "http://www.dev.arynwood.com", - "contributions": [ - "code" - ] - }, - { - "login": "benalleng", - "name": "Ben Allen", - "avatar_url": "https://avatars.githubusercontent.com/u/108441023?v=4", - "profile": "https://benalleng.com", - "contributions": [ - "code" - ] - }, - { - "login": "shilicioo", - "name": "shilicioo", - "avatar_url": "https://avatars.githubusercontent.com/u/56956072?v=4", - "profile": "https://github.com/shilicioo", - "contributions": [ - "code" - ] - }, - { - "login": "Moaht", - "name": "Thomas Allan", - "avatar_url": "https://avatars.githubusercontent.com/u/93605688?v=4", - "profile": "https://github.com/Moaht", - "contributions": [ - "code" - ] - }, - { - "login": "rlpratyoosh", - "name": "Pratyoosh Prakash", - "avatar_url": "https://avatars.githubusercontent.com/u/170789120?v=4", - "profile": "http://www.pratyoosh.me", - "contributions": [ - "code", - "bug" - ] - }, - { - "login": "tahminator", - "name": "Tahmid Ahmed", - "avatar_url": "https://avatars.githubusercontent.com/u/107484759?v=4", - "profile": "http://tahmid.io", - "contributions": [ - "code" - ] - }, - { - "login": "Ritze03", - "name": "Ritze", - "avatar_url": "https://avatars.githubusercontent.com/u/63347117?v=4", - "profile": "https://github.com/Ritze03", - "contributions": [ - "code", - "bug" - ] - }, - { - "login": "itlogsandwich", - "name": "Hansen", - "avatar_url": "https://avatars.githubusercontent.com/u/155230216?v=4", - "profile": "https://github.com/itlogsandwich", - "contributions": [ - "doc" - ] - }, - { - "login": "yhay81", - "name": "Yusuke Hayashi", - "avatar_url": "https://avatars.githubusercontent.com/u/11132792?v=4", - "profile": "https://github.com/yhay81", - "contributions": [ - "code", - "doc" - ] - }, - { - "login": "felixzieger", - "name": "Felix", - "avatar_url": "https://avatars.githubusercontent.com/u/67903933?v=4", - "profile": "http://felixzieger.de", - "contributions": [ - "code" - ] - }, - { - "login": "DinoLeung", - "name": "Dino Leung", - "avatar_url": "https://avatars.githubusercontent.com/u/7704357?v=4", - "profile": "https://github.com/DinoLeung", - "contributions": [ - "platform" - ] - }, - { - "login": "istipisti113", - "name": "Szabó István", - "avatar_url": "https://avatars.githubusercontent.com/u/42544437?v=4", - "profile": "https://github.com/istipisti113", - "contributions": [ - "doc" - ] - }, - { - "login": "alessandro-zanni", - "name": "Alessandro Zanni", - "avatar_url": "https://avatars.githubusercontent.com/u/78210329?v=4", - "profile": "https://alessandrozanni.it/", - "contributions": [ - "code" - ] - } - ], - "contributorsPerLine": 7, - "skipCi": true, - "contributorsSortAlphabetically": false, - "commitType": "docs" -} diff --git a/.all-contributorsrc.upstream-backup b/.all-contributorsrc.upstream-backup deleted file mode 100644 index e66106bf..00000000 --- a/.all-contributorsrc.upstream-backup +++ /dev/null @@ -1,893 +0,0 @@ -{ - "files": [ - "README.md" - ], - "imageSize": 100, - "commit": false, - "contributors": [ - { - "login": "LargeModGames", - "name": "LargeModGames", - "avatar_url": "https://avatars.githubusercontent.com/u/84450916?v=4", - "profile": "https://github.com/LargeModGames", - "contributions": [ - "code", - "doc", - "test", - "ideas", - "infra", - "maintenance" - ] - }, - { - "login": "Rigellute", - "name": "Alexander Keliris", - "avatar_url": "https://avatars2.githubusercontent.com/u/12150276?v=4", - "profile": "https://keliris.dev/", - "contributions": [ - "code", - "doc", - "design", - "blog", - "ideas", - "infra", - "platform", - "review" - ] - }, - { - "login": "mikepombal", - "name": "Mickael Marques", - "avatar_url": "https://avatars3.githubusercontent.com/u/6864231?v=4", - "profile": "https://github.com/mikepombal", - "contributions": [ - "financial" - ] - }, - { - "login": "HakierGrzonzo", - "name": "Grzegorz Koperwas", - "avatar_url": "https://avatars0.githubusercontent.com/u/36668331?v=4", - "profile": "https://github.com/HakierGrzonzo", - "contributions": [ - "doc" - ] - }, - { - "login": "amgassert", - "name": "Austin Gassert", - "avatar_url": "https://avatars2.githubusercontent.com/u/22896005?v=4", - "profile": "https://github.com/amgassert", - "contributions": [ - "code" - ] - }, - { - "login": "calenrobinette", - "name": "Calen Robinette", - "avatar_url": "https://avatars2.githubusercontent.com/u/30757528?v=4", - "profile": "https://robinette.dev", - "contributions": [ - "code" - ] - }, - { - "login": "MCOfficer", - "name": "M*C*O", - "avatar_url": "https://avatars0.githubusercontent.com/u/22377202?v=4", - "profile": "https://mcofficer.me", - "contributions": [ - "infra" - ] - }, - { - "login": "eminence", - "name": "Andrew Chin", - "avatar_url": "https://avatars0.githubusercontent.com/u/402454?v=4", - "profile": "https://github.com/eminence", - "contributions": [ - "code" - ] - }, - { - "login": "Monkeyanator", - "name": "Sam Naser", - "avatar_url": "https://avatars0.githubusercontent.com/u/4377348?v=4", - "profile": "https://www.samnaser.com/", - "contributions": [ - "code" - ] - }, - { - "login": "radogost", - "name": "Micha", - "avatar_url": "https://avatars0.githubusercontent.com/u/15713820?v=4", - "profile": "https://github.com/radogost", - "contributions": [ - "code" - ] - }, - { - "login": "neriglissar", - "name": "neriglissar", - "avatar_url": "https://avatars2.githubusercontent.com/u/53038761?v=4", - "profile": "https://github.com/neriglissar", - "contributions": [ - "code" - ] - }, - { - "login": "TimonPost", - "name": "Timon", - "avatar_url": "https://avatars3.githubusercontent.com/u/19969910?v=4", - "profile": "https://github.com/TimonPost", - "contributions": [ - "code" - ] - }, - { - "login": "echoSayonara", - "name": "echoSayonara", - "avatar_url": "https://avatars2.githubusercontent.com/u/54503126?v=4", - "profile": "https://github.com/echoSayonara", - "contributions": [ - "code" - ] - }, - { - "login": "D-Nice", - "name": "D-Nice", - "avatar_url": "https://avatars1.githubusercontent.com/u/2888248?v=4", - "profile": "https://github.com/D-Nice", - "contributions": [ - "doc", - "infra" - ] - }, - { - "login": "gpawlik", - "name": "Grzegorz Pawlik", - "avatar_url": "https://avatars3.githubusercontent.com/u/6296883?v=4", - "profile": "http://gpawlik.com", - "contributions": [ - "code" - ] - }, - { - "login": "LennyPenny", - "name": "Lennart Bernhardt", - "avatar_url": "https://avatars1.githubusercontent.com/u/4027243?v=4", - "profile": "http://lenny.ninja", - "contributions": [ - "code" - ] - }, - { - "login": "BlackYoup", - "name": "Arnaud Lefebvre", - "avatar_url": "https://avatars3.githubusercontent.com/u/6098160?v=4", - "profile": "https://github.com/BlackYoup", - "contributions": [ - "code" - ] - }, - { - "login": "tem1029", - "name": "tem1029", - "avatar_url": "https://avatars3.githubusercontent.com/u/57712713?v=4", - "profile": "https://github.com/tem1029", - "contributions": [ - "code" - ] - }, - { - "login": "Peterkmoss", - "name": "Peter K. Moss", - "avatar_url": "https://avatars2.githubusercontent.com/u/12544579?v=4", - "profile": "http://peter.moss.dk", - "contributions": [ - "code" - ] - }, - { - "login": "RadicalZephyr", - "name": "Geoff Shannon", - "avatar_url": "https://avatars1.githubusercontent.com/u/113102?v=4", - "profile": "http://www.zephyrizing.net/", - "contributions": [ - "code" - ] - }, - { - "login": "zacklukem", - "name": "Zachary Mayhew", - "avatar_url": "https://avatars0.githubusercontent.com/u/8787486?v=4", - "profile": "http://zacklukem.info", - "contributions": [ - "code" - ] - }, - { - "login": "jfaltis", - "name": "jfaltis", - "avatar_url": "https://avatars2.githubusercontent.com/u/45465572?v=4", - "profile": "http://jfaltis.de", - "contributions": [ - "code" - ] - }, - { - "login": "Bios-Marcel", - "name": "Marcel Schramm", - "avatar_url": "https://avatars3.githubusercontent.com/u/19377618?v=4", - "profile": "https://marcelschr.me", - "contributions": [ - "doc" - ] - }, - { - "login": "fangyi-zhou", - "name": "Fangyi Zhou", - "avatar_url": "https://avatars3.githubusercontent.com/u/7815439?v=4", - "profile": "https://github.com/fangyi-zhou", - "contributions": [ - "code" - ] - }, - { - "login": "synth-ruiner", - "name": "Max", - "avatar_url": "https://avatars1.githubusercontent.com/u/8642013?v=4", - "profile": "https://github.com/synth-ruiner", - "contributions": [ - "code" - ] - }, - { - "login": "svenvNL", - "name": "Sven van der Vlist", - "avatar_url": "https://avatars1.githubusercontent.com/u/13982006?v=4", - "profile": "https://github.com/svenvNL", - "contributions": [ - "code" - ] - }, - { - "login": "jacobchrismarsh", - "name": "jacobchrismarsh", - "avatar_url": "https://avatars2.githubusercontent.com/u/15932179?v=4", - "profile": "https://github.com/jacobchrismarsh", - "contributions": [ - "code" - ] - }, - { - "login": "TheWalkingLeek", - "name": "Nils Rauch", - "avatar_url": "https://avatars2.githubusercontent.com/u/36076343?v=4", - "profile": "https://github.com/TheWalkingLeek", - "contributions": [ - "code" - ] - }, - { - "login": "sputnick1124", - "name": "Nick Stockton", - "avatar_url": "https://avatars1.githubusercontent.com/u/8843309?v=4", - "profile": "https://github.com/sputnick1124", - "contributions": [ - "code", - "bug", - "maintenance", - "question", - "doc" - ] - }, - { - "login": "stuarth", - "name": "Stuart Hinson", - "avatar_url": "https://avatars3.githubusercontent.com/u/7055?v=4", - "profile": "http://stuarth.github.io", - "contributions": [ - "code" - ] - }, - { - "login": "samcal", - "name": "Sam Calvert", - "avatar_url": "https://avatars3.githubusercontent.com/u/2117940?v=4", - "profile": "https://github.com/samcal", - "contributions": [ - "code", - "doc" - ] - }, - { - "login": "jwijenbergh", - "name": "Jeroen Wijenbergh", - "avatar_url": "https://avatars0.githubusercontent.com/u/46386452?v=4", - "profile": "https://github.com/jwijenbergh", - "contributions": [ - "doc" - ] - }, - { - "login": "KimberleyCook", - "name": "Kimberley Cook", - "avatar_url": "https://avatars3.githubusercontent.com/u/2683270?v=4", - "profile": "https://twitter.com/KimberleyCook91", - "contributions": [ - "doc" - ] - }, - { - "login": "baxtea", - "name": "Audrey Baxter", - "avatar_url": "https://avatars0.githubusercontent.com/u/22502477?v=4", - "profile": "https://github.com/baxtea", - "contributions": [ - "code" - ] - }, - { - "login": "nkoehring", - "name": "Norman", - "avatar_url": "https://avatars2.githubusercontent.com/u/246402?v=4", - "profile": "https://koehr.in", - "contributions": [ - "doc" - ] - }, - { - "login": "blackwolf12333", - "name": "Peter Maatman", - "avatar_url": "https://avatars0.githubusercontent.com/u/1572975?v=4", - "profile": "https://github.com/blackwolf12333", - "contributions": [ - "code" - ] - }, - { - "login": "AlexandreSi", - "name": "AlexandreS", - "avatar_url": "https://avatars1.githubusercontent.com/u/32449369?v=4", - "profile": "https://github.com/AlexandreSi", - "contributions": [ - "code" - ] - }, - { - "login": "fiinnnn", - "name": "Finn Vos", - "avatar_url": "https://avatars2.githubusercontent.com/u/5011796?v=4", - "profile": "https://github.com/fiinnnn", - "contributions": [ - "code" - ] - }, - { - "login": "hurricanehrndz", - "name": "Carlos Hernandez", - "avatar_url": "https://avatars0.githubusercontent.com/u/5804237?v=4", - "profile": "https://github.com/hurricanehrndz", - "contributions": [ - "platform" - ] - }, - { - "login": "pedrohva", - "name": "Pedro Alves", - "avatar_url": "https://avatars3.githubusercontent.com/u/33297928?v=4", - "profile": "https://github.com/pedrohva", - "contributions": [ - "code" - ] - }, - { - "login": "jtagcat", - "name": "jtagcat", - "avatar_url": "https://avatars1.githubusercontent.com/u/38327267?v=4", - "profile": "https://gitlab.com/jtagcat/", - "contributions": [ - "doc" - ] - }, - { - "login": "BKitor", - "name": "Benjamin Kitor", - "avatar_url": "https://avatars0.githubusercontent.com/u/16880850?v=4", - "profile": "https://github.com/BKitor", - "contributions": [ - "code" - ] - }, - { - "login": "littleli", - "name": "Aleš Najmann", - "avatar_url": "https://avatars0.githubusercontent.com/u/544082?v=4", - "profile": "https://ales.rocks", - "contributions": [ - "doc", - "platform" - ] - }, - { - "login": "jeremystucki", - "name": "Jeremy Stucki", - "avatar_url": "https://avatars3.githubusercontent.com/u/7629727?v=4", - "profile": "https://github.com/jeremystucki", - "contributions": [ - "code" - ] - }, - { - "login": "pt2121", - "name": "(´⌣`ʃƪ)", - "avatar_url": "https://avatars0.githubusercontent.com/u/616399?v=4", - "profile": "http://pt2121.github.io", - "contributions": [ - "code" - ] - }, - { - "login": "tim77", - "name": "Artem Polishchuk", - "avatar_url": "https://avatars0.githubusercontent.com/u/5614476?v=4", - "profile": "https://github.com/tim77", - "contributions": [ - "platform" - ] - }, - { - "login": "slumber", - "name": "Chris Sosnin", - "avatar_url": "https://avatars2.githubusercontent.com/u/48099298?v=4", - "profile": "https://github.com/slumber", - "contributions": [ - "code" - ] - }, - { - "login": "bwbuhse", - "name": "Ben Buhse", - "avatar_url": "https://avatars1.githubusercontent.com/u/21225303?v=4", - "profile": "http://www.benbuhse.com", - "contributions": [ - "doc" - ] - }, - { - "login": "ilnaes", - "name": "Sean Li", - "avatar_url": "https://avatars1.githubusercontent.com/u/20805499?v=4", - "profile": "https://github.com/ilnaes", - "contributions": [ - "code" - ] - }, - { - "login": "TimotheeGerber", - "name": "TimotheeGerber", - "avatar_url": "https://avatars3.githubusercontent.com/u/37541513?v=4", - "profile": "https://github.com/TimotheeGerber", - "contributions": [ - "code", - "doc" - ] - }, - { - "login": "fratajczak", - "name": "Ferdinand Ratajczak", - "avatar_url": "https://avatars2.githubusercontent.com/u/33835579?v=4", - "profile": "https://github.com/fratajczak", - "contributions": [ - "code" - ] - }, - { - "login": "sheelc", - "name": "Sheel Choksi", - "avatar_url": "https://avatars0.githubusercontent.com/u/1355710?v=4", - "profile": "https://github.com/sheelc", - "contributions": [ - "code" - ] - }, - { - "login": "mhellwig", - "name": "Michael Hellwig", - "avatar_url": "https://avatars1.githubusercontent.com/u/414112?v=4", - "profile": "http://fnanp.in-ulm.de/microblog/", - "contributions": [ - "doc" - ] - }, - { - "login": "oliver-daniel", - "name": "Oliver Daniel", - "avatar_url": "https://avatars2.githubusercontent.com/u/17235417?v=4", - "profile": "https://github.com/oliver-daniel", - "contributions": [ - "code" - ] - }, - { - "login": "Drewsapple", - "name": "Drew Fisher", - "avatar_url": "https://avatars2.githubusercontent.com/u/4532572?v=4", - "profile": "https://github.com/Drewsapple", - "contributions": [ - "code" - ] - }, - { - "login": "ncoder-1", - "name": "ncoder-1", - "avatar_url": "https://avatars0.githubusercontent.com/u/7622286?v=4", - "profile": "https://github.com/ncoder-1", - "contributions": [ - "doc" - ] - }, - { - "login": "macguirerintoul", - "name": "Macguire Rintoul", - "avatar_url": "https://avatars3.githubusercontent.com/u/18323154?v=4", - "profile": "http://macguire.me", - "contributions": [ - "doc" - ] - }, - { - "login": "RicardoHE97", - "name": "Ricardo Holguin", - "avatar_url": "https://avatars3.githubusercontent.com/u/28399979?v=4", - "profile": "http://ricardohe97.github.io", - "contributions": [ - "code" - ] - }, - { - "login": "ksk001100", - "name": "Keisuke Toyota", - "avatar_url": "https://avatars3.githubusercontent.com/u/13160198?v=4", - "profile": "https://ksk.netlify.com", - "contributions": [ - "code" - ] - }, - { - "login": "jackson15j", - "name": "Craig Astill", - "avatar_url": "https://avatars1.githubusercontent.com/u/3226988?v=4", - "profile": "https://jackson15j.github.io", - "contributions": [ - "code" - ] - }, - { - "login": "onielfa", - "name": "Onielfa", - "avatar_url": "https://avatars0.githubusercontent.com/u/4358172?v=4", - "profile": "https://github.com/onielfa", - "contributions": [ - "code" - ] - }, - { - "login": "usrme", - "name": "usrme", - "avatar_url": "https://avatars3.githubusercontent.com/u/5902545?v=4", - "profile": "https://usrme.xyz", - "contributions": [ - "doc" - ] - }, - { - "login": "murlakatamenka", - "name": "Sergey A.", - "avatar_url": "https://avatars2.githubusercontent.com/u/7361274?v=4", - "profile": "https://github.com/murlakatamenka", - "contributions": [ - "code" - ] - }, - { - "login": "elcih17", - "name": "Hideyuki Okada", - "avatar_url": "https://avatars3.githubusercontent.com/u/17084445?v=4", - "profile": "https://github.com/elcih17", - "contributions": [ - "code" - ] - }, - { - "login": "kepae", - "name": "kepae", - "avatar_url": "https://avatars2.githubusercontent.com/u/4238598?v=4", - "profile": "https://github.com/kepae", - "contributions": [ - "code", - "doc" - ] - }, - { - "login": "ericonr", - "name": "Érico Nogueira Rolim", - "avatar_url": "https://avatars0.githubusercontent.com/u/34201958?v=4", - "profile": "https://github.com/ericonr", - "contributions": [ - "code" - ] - }, - { - "login": "BeneCollyridam", - "name": "Alexander Meinhardt Scheurer", - "avatar_url": "https://avatars2.githubusercontent.com/u/15802915?v=4", - "profile": "https://github.com/BeneCollyridam", - "contributions": [ - "code" - ] - }, - { - "login": "Toaster192", - "name": "Ondřej Kinšt", - "avatar_url": "https://avatars0.githubusercontent.com/u/14369229?v=4", - "profile": "https://github.com/Toaster192", - "contributions": [ - "code" - ] - }, - { - "login": "Kryan90", - "name": "Kryan90", - "avatar_url": "https://avatars3.githubusercontent.com/u/18740821?v=4", - "profile": "https://github.com/Kryan90", - "contributions": [ - "doc" - ] - }, - { - "login": "n-ivanov", - "name": "n-ivanov", - "avatar_url": "https://avatars3.githubusercontent.com/u/11470871?v=4", - "profile": "https://github.com/n-ivanov", - "contributions": [ - "code" - ] - }, - { - "login": "bi1yeu", - "name": "bi1yeu", - "avatar_url": "https://avatars3.githubusercontent.com/u/1185129?v=4", - "profile": "http://matthewbilyeu.com/resume/", - "contributions": [ - "code", - "doc" - ] - }, - { - "login": "Utagai", - "name": "May", - "avatar_url": "https://avatars2.githubusercontent.com/u/10730394?v=4", - "profile": "https://github.com/Utagai", - "contributions": [ - "code" - ] - }, - { - "login": "mucinoab", - "name": "Bruno A. Muciño", - "avatar_url": "https://avatars1.githubusercontent.com/u/28630268?v=4", - "profile": "https://mucinoab.github.io/", - "contributions": [ - "code" - ] - }, - { - "login": "OrangeFran", - "name": "Finn Hediger", - "avatar_url": "https://avatars2.githubusercontent.com/u/55061632?v=4", - "profile": "https://github.com/OrangeFran", - "contributions": [ - "code" - ] - }, - { - "login": "dp304", - "name": "dp304", - "avatar_url": "https://avatars1.githubusercontent.com/u/34493835?v=4", - "profile": "https://github.com/dp304", - "contributions": [ - "code" - ] - }, - { - "login": "marcomicera", - "name": "Marco Micera", - "avatar_url": "https://avatars0.githubusercontent.com/u/13918587?v=4", - "profile": "http://marcomicera.github.io", - "contributions": [ - "doc" - ] - }, - { - "login": "MarcoIeni", - "name": "Marco Ieni", - "avatar_url": "https://avatars3.githubusercontent.com/u/11428655?v=4", - "profile": "http://marcoieni.com", - "contributions": [ - "infra" - ] - }, - { - "login": "ArturKovacs", - "name": "Artúr Kovács", - "avatar_url": "https://avatars3.githubusercontent.com/u/8320264?v=4", - "profile": "https://github.com/ArturKovacs", - "contributions": [ - "code" - ] - }, - { - "login": "aokellermann", - "name": "Antony Kellermann", - "avatar_url": "https://avatars.githubusercontent.com/u/26678747?v=4", - "profile": "https://github.com/aokellermann", - "contributions": [ - "code" - ] - }, - { - "login": "rasmuspeders1", - "name": "Rasmus Pedersen", - "avatar_url": "https://avatars.githubusercontent.com/u/1898960?v=4", - "profile": "https://github.com/rasmuspeders1", - "contributions": [ - "code" - ] - }, - { - "login": "noir-Z", - "name": "noir-Z", - "avatar_url": "https://avatars.githubusercontent.com/u/45096516?v=4", - "profile": "https://github.com/noir-Z", - "contributions": [ - "doc" - ] - }, - { - "login": "davidbailey00", - "name": "David Bailey", - "avatar_url": "https://avatars.githubusercontent.com/u/4248177?v=4", - "profile": "https://davidbailey.codes/", - "contributions": [ - "doc" - ] - }, - { - "login": "sheepwall", - "name": "sheepwall", - "avatar_url": "https://avatars.githubusercontent.com/u/22132993?v=4", - "profile": "https://github.com/sheepwall", - "contributions": [ - "code" - ] - }, - { - "login": "Hwatwasthat", - "name": "Hwatwasthat", - "avatar_url": "https://avatars.githubusercontent.com/u/29790143?v=4", - "profile": "https://github.com/Hwatwasthat", - "contributions": [ - "code" - ] - }, - { - "login": "Jesse-Bakker", - "name": "Jesse", - "avatar_url": "https://avatars.githubusercontent.com/u/22473248?v=4", - "profile": "https://github.com/Jesse-Bakker", - "contributions": [ - "code" - ] - }, - { - "login": "hantatsang", - "name": "Sang", - "avatar_url": "https://avatars.githubusercontent.com/u/11912225?v=4", - "profile": "https://github.com/hantatsang", - "contributions": [ - "doc" - ] - }, - { - "login": "yktakaha4", - "name": "Yuuki Takahashi", - "avatar_url": "https://avatars.githubusercontent.com/u/20282867?v=4", - "profile": "https://yktakaha4.github.io/", - "contributions": [ - "doc" - ] - }, - { - "login": "alejandro-angulo", - "name": "Alejandro Angulo", - "avatar_url": "https://avatars.githubusercontent.com/u/5242883?v=4", - "profile": "https://alejandr0angul0.dev/", - "contributions": [ - "code" - ] - }, - { - "login": "masguit42", - "name": "Anton Kostin", - "avatar_url": "https://avatars.githubusercontent.com/u/11005780?v=4", - "profile": "http://t.me/lego1as", - "contributions": [ - "doc" - ] - }, - { - "login": "JSextonn", - "name": "Justin Sexton", - "avatar_url": "https://avatars.githubusercontent.com/u/20236003?v=4", - "profile": "https://justinsexton.net", - "contributions": [ - "code" - ] - }, - { - "login": "lejiati", - "name": "Jiati Le", - "avatar_url": "https://avatars.githubusercontent.com/u/6442124?v=4", - "profile": "https://github.com/lejiati", - "contributions": [ - "doc" - ] - }, - { - "login": "cobbinma", - "name": "Matthew Cobbing", - "avatar_url": "https://avatars.githubusercontent.com/u/578718?v=4", - "profile": "https://github.com/cobbinma", - "contributions": [ - "code" - ] - }, - { - "login": "Milo123459", - "name": "Milo", - "avatar_url": "https://avatars.githubusercontent.com/u/50248166?v=4", - "profile": "https://milo123459.vercel.app", - "contributions": [ - "infra" - ] - }, - { - "login": "diegov", - "name": "Diego Veralli", - "avatar_url": "https://avatars.githubusercontent.com/u/297206?v=4", - "profile": "https://www.diegoveralli.com", - "contributions": [ - "code" - ] - }, - { - "login": "majabojarska", - "name": "Maja Bojarska", - "avatar_url": "https://avatars.githubusercontent.com/u/33836570?v=4", - "profile": "https://github.com/majabojarska", - "contributions": [ - "code" - ] - } - ], - "contributorsPerLine": 7, - "projectName": "spotify-tui", - "projectOwner": "Rigellute", - "repoType": "github", - "repoHost": "https://github.com", - "skipCi": true, - "commitConvention": "angular" -} diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json deleted file mode 100644 index 76d0eb6b..00000000 --- a/.claude-plugin/marketplace.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "spotatui", - "owner": { - "name": "LargeModGames", - "url": "https://github.com/LargeModGames" - }, - "plugins": [ - { - "name": "spotatui", - "source": "./agent-plugin", - "description": "Turn any MCP-capable coding agent into a DJ for spotatui, the terminal Spotify client.", - "version": "0.1.0", - "license": "MIT" - } - ] -} diff --git a/.coderabbit.yaml b/.coderabbit.yaml deleted file mode 100644 index 1d0be457..00000000 --- a/.coderabbit.yaml +++ /dev/null @@ -1,121 +0,0 @@ -# yaml-language-server: $schema=https://storage.googleapis.com/coderabbit_public_assets/schema.v2.json -language: en-US -tone_instructions: >- - Write plainly and directly. Do not use em-dashes; use commas, colons, or - separate sentences instead. Prefer concrete detail over praise. - -reviews: - profile: chill - # Approve only once review comments are resolved and checks pass. - request_changes_workflow: true - poem: false - - path_filters: - - "!Cargo.lock" - - "!flake.lock" - - "!spotatui.wiki/**" - - "!target/**" - - path_instructions: - - path: "src/tui/handlers/**/*.rs" - instructions: | - Handlers must never call async Spotify code directly; they dispatch work - with `app.dispatch(IoEvent::SomeVariant)`. - - Global key actions must match against `app.user_config.keys.` - rather than hard-coded key literals. - - Closing a dialog requires all three of `app.dialog = None`, - `app.confirm = false`, and `app.clear_playlist_track_dialog_state()`. - Flag any close path that misses one. - - New screens need a `pub fn handler(key: Key, app: &mut App)` registered - in the `handle_block_events` match arm in `src/tui/handlers/mod.rs`. - - - path: "src/tui/ui/**/*.rs" - instructions: | - UI code renders from `App` state only. It must not mutate state or - perform I/O. Flag any network or blocking call in a draw function. - - - path: "src/infra/network/**/*.rs" - instructions: | - Spotify API calls are `IoEvent` variants declared in `mod.rs` and - implemented in the matching concern file. Data returned page-by-page - should use `ScrollableResultPages`. - - - path: "src/**/*.rs" - instructions: | - User feedback goes through the status-message helpers, never by - assigning `app.status_message` directly: `app.set_status_message(msg, - ttl_secs)` in sync handlers/UI, or `self.show_status_message(msg, - ttl_secs)` in the async network layer. The TTL is in seconds. - - Code behind a feature flag (streaming, cover-art, local-files, subsonic, - internet-radio, youtube, qobuz, scripting, mpris, discord-rpc) must compile in - the slim build (`--no-default-features --features telemetry`). Flag - unguarded use of a gated item. - - - path: ".github/workflows/**" - instructions: | - Feature-flag changes in Cargo.toml usually need the matching matrix - entry in ci.yml and the shipped feature list in cd.yml updated too. - - # Outside contributions arrive from forks, but review and merge run through - # the maintainer, so reviewer suggestions have nobody useful to name. - suggested_reviewers: false - in_progress_fortune: false - - tools: - github-checks: - # Default is 90s, which a 3-job Rust matrix build never finishes in, so - # CodeRabbit would stop waiting and review without CI results. 15m is the - # schema maximum. - timeout_ms: 900000 - - pre_merge_checks: - # A Rust TUI does not document 80% of its functions; the default threshold - # would warn on nearly every PR. - docstrings: - mode: "off" - title: - mode: warning - requirements: >- - Title must use a conventional-commit prefix already in use in this repo - (feat:, fix:, perf:, refactor:, docs:, chore:, ci:, test:), optionally - scoped, e.g. `chore(deps): ...`. Keep the subject concise and - imperative. - # A PR that says `Fixes #N` auto-closes the issue on merge, so a fix that - # covers only part of the issue's root cause silently loses the remainder. - # `error` because that is a wrong outcome, not a style nit. - issue_assessment: - mode: error - - finishing_touches: - simplify: - enabled: true - - auto_review: - enabled: true - # Review drafts too. Large contributions sit in draft for a long time here, - # and feedback that only starts at ready-for-review arrives after the design - # is already settled. - drafts: true - # Do not silently stop reviewing long-running branches. - auto_pause_after_reviewed_commits: 0 - # Dependency bumps and contributor-list updates are mechanical; a full - # review on each is noise. - ignore_usernames: - - "dependabot[bot]" - - "allcontributors[bot]" - -knowledge_base: - code_guidelines: - enabled: true - # Listed explicitly because an explicit list may replace CodeRabbit's - # built-in defaults rather than extend them; the first three are defaults - # we do not want to lose. - filePatterns: - - "**/CLAUDE.md" - - "**/AGENTS.md" - - ".github/copilot-instructions.md" - - "CONTRIBUTING.md" diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index 98d4aa57..00000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1,3 +0,0 @@ -# These are supported funding model platforms - -github: LargeModGames diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index faf77665..95b9cd84 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1,5 @@ -blank_issues_enabled: false +blank_issues_enabled: true contact_links: - - name: Discord community - url: https://spotatui.com/discord - about: Questions, install help, feature chat, and listening parties. Come say hi. - name: Security reports - url: https://github.com/LargeModGames/spotatui/blob/master/SECURITY.md - about: Please report vulnerabilities privately per our security policy. + url: https://github.com/ethereumdegen/degen-radio/security/advisories/new + about: Report security issues privately. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 5d7a1cb4..00000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,605 +0,0 @@ -# Copilot Instructions for spotatui - -This file is maintained as three near-identical copies: `CLAUDE.md`, `AGENTS.md`, and -`.github/copilot-instructions.md` (only the opening lines differ). Edit all three together. - -## Build & Run - -```bash -# Full build (native streaming + audio viz + Lua scripting + OS integrations) -cargo run - -# Slim build - no librespot/audio/scripting; fastest iteration, one of CI's seven legs -cargo run --no-default-features --features telemetry,tui - -# With the alternative sources (Local/Subsonic/Radio/YouTube/Qobuz). These are NOT -# in `default`, so a plain `cargo run` is Spotify-only; use the `all-sources` alias -# (or list them individually) to exercise the first-run source picker and playback. -cargo run --features all-sources -``` - -## CI Checks (run before opening a PR) - -```bash -cargo fmt --all -cargo clippy --no-default-features --features telemetry,tui -- -D warnings -cargo test --no-default-features --features telemetry,tui -``` - -These slim commands are the *fast local gate*, not the full picture. GitHub Actions -(`.github/workflows/ci.yml`) runs `check`, `test`, and `clippy` on `ubuntu-latest` -across a **seven-leg** feature matrix, plus one `macos-latest` job (below): - -| Leg | Features | -|-----|----------| -| `default` | a plain `cargo test`: streaming + audio-viz-cpal + scripting + self-update + OS integrations | -| `all-sources` | the **Linux** release feature set from `cd.yml` (adds cover-art, mcp-server, ai-dj, audio-viz, all five sources) | -| `mcp-only` | `telemetry,tui,mcp-server` | -| `ai-dj-only` | `telemetry,tui,ai-dj` | -| `slim` | `telemetry,tui` | -| `headless` | `telemetry` - one of two legs without `tui`. `mod tui` is feature-gated, so this leg turns any `crate::tui` import from core/infra/cli into a compile error - what keeps a second frontend from silently re-coupling to the terminal one | -| `headless-streaming` | `telemetry,streaming` - `check` + `clippy` only, no `test` job. Proves native-streaming startup (`runtime/streaming/`) and the player-event wiring type-check and pass clippy with no terminal frontend in scope. Its two entry points carry `allow(dead_code)` there, so the leg does not prove they are live | - -- `mcp-only` and `ai-dj-only` matter more than their size suggests: both enable - `dj-core` **without** `streaming` (a combination nothing else covers), and each - front door has to build without the other. -- Reproducing legs locally: `cargo test` reproduces `default`. For `all-sources`, - copy the exact `--no-default-features --features …` string out of `ci.yml` - - `cargo test --features all-sources` is **not** it (the alias only adds the five - sources on top of default), and the leg includes `audio-viz` (PipeWire), so it - only compiles on Linux. -- Every CI leg passes `--locked`; the local commands do not. Regenerate - `Cargo.lock` after any `Cargo.toml` edit or all legs fail at once. -- CI runs `clippy` on the **bin target only** (no `--all-targets`), so lints in - `#[cfg(test)]` code are not gated. Run `cargo test` to compile test code. A - test-only helper whose production caller is compiled out on some leg needs - `#[allow(dead_code)]`. -- The `all-sources` leg must stay in sync with `cd.yml`'s Linux release row - (macOS releases ship the same five sources but a different - backend/OS-integration set). -- One further job, `macos`, runs `check` + `clippy` (no `test`) on - `macos-latest` with cd.yml's macOS release feature set. It is the only leg - that compiles the `#[cfg(target_os = "macos")]` arms, the portaudio playback - backend, `macos-media`, and `audio-viz-cpal` - none of which the Linux legs - can speak for. No `test`: the suite is platform-independent logic the Linux - legs already run, and the device tests are `#[ignore]`d (CI runners have no - audio output). Keep its feature list in sync with cd.yml's macOS rows. -- A pull_request-only `Gates ratchet` job diffs `tools/gates.count` against the - merge-base (`tools/check_gates_ratchet.sh`): coupling counters may only fall; - the two adoption counters (`test_attribute_total`, - `action_refs_in_tui_handlers`) may only rise. `src/gates.rs` pins every value - exactly, so move the baseline in the same PR that moves the number, in the - ratchet's direction only. - -## Run a Single Test - -```bash -cargo test --no-default-features --features telemetry,tui -# Example: -cargo test --no-default-features --features telemetry,tui global_shift_w_adds_current_track_from_anywhere -``` - -A filter that matches nothing still exits 0 - when running feature-gated tests in -the slim build, check the `N filtered out` count actually says your test ran. - -## Architecture - -One cargo package: a `[lib]` (`src/lib.rs`, private modules, public API = -`run_cli`) plus two `[[bin]]` shims in `src/bin/` - `spotatui` (console) and -`spotatui-gui`, a placeholder behind the off-by-default `gui` feature that -exists to own the crate-root `windows_subsystem` attribute. Five top-level -units under `src/`: - -| Unit | Role | -|------|------| -| `core/` | Centralized state (`App`), the frontend-neutral tick scheduler (`driver/`), the shared action vocabulary (`action/`), config/state persistence, and the rspotify-free domain types (`plugin_api`, `pagination`, `source`) | -| `infra/` | Spotify Web API (`network/`), native librespot streaming (`player/`), alternative sources (`local/`, `subsonic/`, `qobuz/`, `radio/`, `youtube/`, `queue/`), audio viz (`audio/`), Lua scripting (`scripting/`), AI DJ + MCP (`dj/`, `mcp/`), OS integrations (Discord RPC, MPRIS, macOS/Windows media) | -| `tui/` | Terminal UI: the event/render loop (`runner.rs`), key plumbing (`event/`), per-block input handlers (`handlers/`), immutable draw fns (`ui/`) | -| `cli/` | clap subcommands: playback control, listening history, self-update, MCP relay, plugin management | -| `runtime/` | `mod.rs::run_cli` (entry point + CLI dispatch), `bootstrap.rs::boot` (frontend-neutral config/auth/`App` construction, `run_cli` its sole caller, plus the boot auth rule `spotify_auth_mode`: interactive only right after the client wizard or `--reconfigure-auth`, a subcommand needs a cached token, a UI launch tolerates no session), `cli.rs` (clap assembly + self-update), `pump.rs::start_tokio` (the IoEvent pump), `streaming/` (native-streaming startup every frontend shares: the pure saved-device decision in `mod.rs`, the librespot bring-up in `launch.rs`, gated on `streaming`), `startup.rs` (the UI-launch half, gated on `tui`) | - -### Data flow - -```text -crossterm thread (tui/event/) → runner.rs loop (draws the frame, then reads one event) - → runner::dispatch_key - exit prompt, ActiveBlock::Input, the configurable back key - → handlers::handle_app - plugin popup modal → help filter → global keybindings - → handle_block_events - dispatches to the per-screen handler - → app.dispatch(IoEvent) - hands async work to the pump in runtime/pump.rs - → source routers by URI scheme, then infra/network/ (Spotify) - → mutates App state; tui/ui/ re-renders from App on the next frame -``` - -`tui/event/` is only the crossterm→`Key` plumbing; the event loop itself is -`tui/runner.rs::start_ui`. Everything on a timer lives in `core/driver/` -(`Driver::tick`): the playback tick + debounced flushes, OAuth refresh, -Discord/MPRIS presence sync, the window title, lyrics + cover-art scheduling, -native-queue and decoded-source auto-advance, and `last_session.yml` -persistence. The runner draws frames, reads events, and calls `driver.tick` -with a `TickEnv` carrying the frontend-geometry inputs (visualizer bar count, -cover-art pixel support). A frontend that stops ticking is loud, not silent: -`App::playback_position_ms()` reads stale after 2s and the playbar says so. -Mouse input enters via `handlers::mouse_handler`. - -### The IoEvent pump: source routing, two lanes, an auth gate - -`runtime/pump.rs::start_tokio` drains IoEvents serially. Three structural gates, all -worth knowing before adding an event: - -- **Source routing**: non-Spotify playback is routed by URI scheme *before* the - Spotify handler, in this order: `route_queue_event` → `route_local_event` - (`file:`) → `route_subsonic_event` (`subsonic:`) → `route_qobuz_event` - (`qobuz:`) → `route_radio_event` (`radio:`) → `route_youtube_event` - (`youtube:`) → `Network::handle_network_event`. - This is what keeps `infra/network/` Spotify-only. -- **Claim gate**: before the routers, `start_playback_has_taker` drops a - `StartPlayback` whose URI scheme (`core::queue::queue_item_source`) names no - compiled-in source and that no Spotify session can take. The routers' - foreign-start teardown arms therefore only run for a real source-to-source - handoff. -- **Service lane**: `Network::runs_on_service_lane` lists events that run on a - detached task so slow, source-agnostic work cannot head-of-line-block the serial - pump. The service lane's `Network` is built with **no Spotify client** - adding a - `self.spotify()` call to a service-lane handler panics. -- **Auth gate**: `Network::event_bypasses_spotify_auth` lists events whose handlers - never need a Spotify session. -- **Replay**: `Network::event_is_transport` lists events that drive whoever owns - the sink. One held back by a rate-limit window is stamped with the - `PlaybackOwner` at deferral time, dropped at the flush when the owner changed, - and otherwise re-sent on the pump's channel so the routers see it. - A new IoEvent must be classified against all three lists. - -### Navigation / routing - -`App` holds a private navigation stack of `Route` values (`id: RouteId`, -`active_block: ActiveBlock`, `hovered_block: ActiveBlock` - there is no -`HoveredBlock` *type*). Invariants an agent cannot guess: - -- `push_navigation_stack(RouteId::X, ActiveBlock::X)` is a **no-op when the top - route already has that `RouteId`** - follow it with `set_current_route_state` - when focus must move anyway. -- `pop_navigation_stack()` refuses to empty the stack. -- The stack is private: go through `get_current_route` / `set_current_route_state`. - -### The `core/app/` module folder - -`App` was one 10,920-line file; it is now 37 files. The struct stays **flat** - all -171 fields declared once in `src/core/app/mod.rs` (27 of them feature-gated), plus -the 81 presentation fields grouped in `App.view` - and its ~270 methods are split -across 36 sibling modules by concern, each with its own `impl App` block. The -boundaries are organizational, not architectural. - -Rules when working in here: - -- **Import from `crate::core::app`, never the submodule path.** `mod.rs` re-exports - every module that declares public items, so `use crate::core::app::{App, RouteId, - ActiveBlock}` works no matter which file an item lives in. -- **Child modules open with `use super::*;`** and declare no other top-level - imports; all external imports live in `mod.rs`. When a type is only importable - under a feature that does not gate the function, use a **function-local** `use` - instead of adding a top-level import. -- **A private helper called from a sibling module needs `pub(super)`**; one reached - from `tui/` or `infra/` needs `pub(crate)`. Private *fields* need nothing - they - are declared in `mod.rs` and visible to all descendants. -- **A field with no writer outside `core/app/` is private.** `pub_fields_on_app` - counts the `pub` ones and may only fall. An outside reader gets an intent-named - getter (`status_message()`, `api_error()`); a handler mutates shared state only - through an `App` method or `app.apply(Action::…)`, never through a field write. -- **Feature-gate the method body, not the call site**, when a predicate must exist - in every build: `#[cfg(any(...))] { … } #[cfg(not(any(...)))] { false }` inside - one ungated `fn` (see `queue_owns_playback`), so callers never need their own `#[cfg]`. -- New `impl App` methods go to the concern module that owns that state, not `mod.rs`. -- **Presentation state lives in `App.view: ViewState`** (`core/app/view.rs`): cursor - and selection indices (the track table, search, Recently Played and sidebar - cursors included), scroll offsets, edit buffers, focus, popup flags, the help - pager, the terminal viewport. Handlers and draw functions write `app.view.` - freely and the handler-write ratchet skips those chains. A producer outside `tui/` - and `core/app/` (a network or source handler, a script effect, the CLI) that - resets or clamps a cursor is counted by `view_writes_outside_tui`, which may only - fall: a producer that replaces a list resets its cursor through an `App` method - (`set_track_table`, `clamp_search_cursors`), never by writing `view` itself. A - new field goes in `view` only if it is presentation state; a pending operation, - or anything a second frontend would also need, stays on `App`. -- `dispatch` pins the global loading spinner; long work with its own progress - surface uses `dispatch_without_spinner`. - -Tests are colocated (`#[cfg(test)] mod tests`) in most - not all - modules. Shared -fixtures are `pub(super) fn`s in `test_support.rs`, imported as -`use crate::core::app::test_support::*;`. - -### Playback ownership - -Multiple players share one UI, and the predicate order is the #1 source of -regressions. Check in this order: `queue_owns_playback()` / -`queue_now_is_spotify()`, then `active_decoded_source()`, then -`is_native_streaming_active_for_playback()`. `App::playback_owner()` folds them -into one `PlaybackOwner`, and the transport chains (play/pause, -next, previous, shuffle, repeat, volume) end on `dispatch_spotify_fallback`, -which answers "Nothing is playing" instead of a Spotify dispatch when no -session exists. - -- Starting a decoded source (Local/Subsonic/Qobuz/Radio/YouTube) only **pauses** - librespot - the native flag stays true, so driving librespot directly resumes - the wrong player. -- A decoded start claims the sink (`App::claim_decoded_sink`) before it pauses - librespot, and `active_decoded_source()` reads the claim: the owner is - `Decoded` from the first line of the start, through a failed start or a lost - output device, until the source's queue runs out (the driver's teardown, the - queue's exhausted-context resume) or an explicit Spotify start (a URI or a - context) reaches `Network::start_playback`, which releases it. A bare resume - never releases it, so a media key or Space during a download cannot resume - librespot. -- While the native queue slot owns the sink, `current_playback_context` names the - *suspended* context's track. Inside `core/app/`, resolve the playing *track* - through `App::playing_item()` (`core/app/playback_routing.rs`): it answers - with the slot's `TrackInfo` (`uri`, `album_id`, `artist_refs`; a slot has - no play context) and refuses a decoded owner. Outside `core/app/` read the - `PlaybackSnapshot` (`infra/media_metadata.rs`), never the field: - `direct_playback_context_reads` counts every other reader and may only fall. -- Radio is in `active_decoded_source` but deliberately out of - `active_queueable_decoded_source` (repeat/shuffle) and - `active_source_position_ms` (seek). - -### Native streaming (feature `streaming`, in `default`) - -`StreamingPlayer` (`src/infra/player/`) embeds librespot as a Spotify Connect -device; a supervisor does bounded in-place reconnects without dropping the audio -pipeline, escalating to full backend replacement with parked-request replay. -Durable intent (recovery snapshot, parked `StartPlayback`, client-side shuffle -session) lives in `src/core/app/native_{backend,recovery,shuffle}.rs`. - -- It rides our **maintained librespot fork**, published on crates.io as - `spotatui-librespot-*` and consumed through `package =` renames, so imports - stay `librespot_*`. The fork carries upstream backports *and* a fork-only - `SessionDisconnectReason` API the app depends on - it cannot be swapped for - upstream 0.8. All seven crates are `=`-pinned in lockstep; bump them together - when the fork publishes a new version. -- Direct spirc `load` is the **primary** route for all native starts, context ones - included - a `me/player/play` round trip would head-of-line-block the serial - pump (#386). The Web API route (`start_native_context_via_api`) is a fallback - only for context starts the direct load rejected or the watchdog is replaying. - A native URI-list start must never go through the Web API. -- Anything that replaces or drops a `StreamingPlayer` must call - `player.shutdown()` first; the Connect device id is persisted in - `/device_id`. Both exist to stop ghost Connect devices (#297). -- Session teardowns are classified by librespot's disconnect reason, never - inferred: external handoff → rebuild idle, unexpected → restore playback, - local → stop. The handoff veto is sticky (#437). -- Every background native write is generation-guarded - (`native_playback_generation`, `native_shuffle_generation`) and event handlers - confirm `Arc::ptr_eq` against the current player before writing - stale writes - from a replaced backend are the recurring bug class. -- librespot reports full `spotify:track:` URIs while app state uses bare - base62 ids: normalize with `base62_id_of` at the event boundary - (`spotify:local:` URIs stay whole). -- Verify native playback changes with the full `cargo run` build, not only the - slim telemetry build. - -### Listening Party / sync - -`src/infra/network/sync.rs` is pure WebSocket transport (`SyncMessage`, -`PartyConnection`); lifecycle logic lives in `src/infra/network/mod.rs`. Handlers -dispatch `StartParty` / `JoinParty` / `SetPartyControlMode` / `LeaveParty`; -`SyncPlayback` is fired by `App::on_tick` every 2s while hosting, not by a handler. - -## Key Conventions - -### Adding a new screen - -See `.claude/skills/add-tui-screen/SKILL.md` for the six-place checklist. - -### Dispatching network calls - -Call `app.dispatch(IoEvent::…)` from a handler - never call async Spotify code -directly from handlers or UI code. Draw functions take `&App` and must not mutate. - -Inside the network layer, never call rspotify client methods for Spotify data: -use `self.spotify_api_request_json(...)` / `self.spotify_get_typed::(...)` -from `src/infra/network/requests.rs` (pacing, 401 cooldown, payload -normalization). rspotify supplies OAuth/PKCE, id types, and models only. Spotify -401s are deliberately tolerated (consecutive-failure threshold, forced-refresh -cooldown) - don't "fix" that by escalating on first failure. - -### The shared Action vocabulary - -`src/core/action/` holds `Action` (one enum of frontend-neutral state -changes), `App::apply` (the single write path, in `core/action/apply.rs`), -and `ActionOutcome`. Producers that are not the network layer's own result -handling mutate `App` via `app.apply(Action::…)`: the Lua scripting engine -drains its queued actions into it, the mutating DJ/MCP tools call it -directly, and TUI handlers adopt it as the conversion sub-PRs land -(`action_refs_in_tui_handlers` may only rise). Rules: - -- No rspotify type and no raw `IoEvent` payload in `Action` - payloads are - strings, scalars, and `core::plugin_api` snapshot types. Address by - identity (URIs, ids, names), never by list ordinal. -- Every arm delegates to the same ownership-aware `App` method the - equivalent keybinding uses. Playback starts go through - `App::start_playback_uris` / `start_playback_context` / - `start_playback_track_in_context` - never a hand-built - `IoEvent::StartPlayback` in an arm. -- No catch-all match arm under `src/core/action/` or in `tui/keymap.rs`: - both deny `clippy::wildcard_enum_match_arm` and - `clippy::match_wildcard_for_single_variants` (a named binding like - `_other =>` counts as a wildcard; `matches!` and `Option`/`Result` - scrutinees are exempt), and `wildcard_arms_in_action_tree` pins the - action tree at 0 by a raw text scan that includes tests, comments, and - string literals. CI clippy never compiles tests, so write a test - catch-all as `_other =>` and only on a `Result`/`Option` scrutinee. -- Every `Action` variant has an arm in `tui/keymap.rs::default_binding`, - naming the key or gesture that produces it; a variant no gesture produces - is `Exposure::Unbound("reason")`. A new variant is a compile error until - it has an arm, and a test failure until `sample_actions()` in the same - file has a value for it (an `Unbound` one also goes into the `UNBOUND` - pin, which a producer scan of `src/tui/` checks). Feature-gate an arm's - body, never the arm: clippy skips a match when any arm carries a `#[cfg]`. -- `Action` derives serde (the future frontend wire shape); a payload type - added to it must stay serde-derivable. - -### Paginated results - -Page caches are `ScrollableResultPages>` (domain types from -`core/pagination.rs` - no rspotify `Page` in `App` state; conversion lives in -`infra/network/mapping.rs`): - -- Insert with `upsert_page_by_offset` - never `add_pages`, which repoints the - visible index to the tail. Key and dedupe by `page.offset`. -- Caches may be sparse: next/previous targets adjacent *offsets*, not index ±1. -- Background prefetch carries a generation snapshot, re-checks it (plus table - identity) after every await, and writes via `set_*_to_table_continuous()` - - never by appending into `track_table.tracks`. -- When a page is already cached, render it synchronously from app state instead - of routing through another async event. -- For playlist track tables, `playlist_track_table_id` is the table identity; - `active_playlist_index` is sidebar selection state only. - -This generation-guard pattern is repo-wide: every background write into `App` -re-checks its generation/epoch (`dj.generation`, `native_shuffle_generation`, -`liked_state_epoch`, …) so stale tasks cannot write into a reloaded view. - -### Status messages - -In sync handlers/UI use `app.set_status_message(msg, ttl_secs)`, or -`app.set_error_status_message(...)` for errors - errors block normal messages -until they expire, so use the right one. TTLs are seconds, scaled by the user's -`status_message_ttl_percent`. In the async network layer use -`self.show_status_message(msg, ttl_secs).await`, which takes the `App` lock and -calls `set_status_message`. The message fields are private: `status_message()` -and `status_message_is_error()` read them. - - -### Errors - -`App::handle_error(e)` records the message in `api_error`, stamps a 60s -lifetime on it, and pushes `RouteId::Error`. That route is a *presentation -hint*: the terminal frontend draws it full-screen, another frontend may render -`api_error` as a toast and ignore the frame entirely. Four rules: - -- Dismiss with `app.clear_api_error()`, never by clearing the string or popping - the route by hand. It drops the message, its lifetime, and every frame still - showing it together - a cleared string under a surviving frame renders as an - error page with nothing on it. `pop_navigation_stack` already calls it when - the frame it pops is `Error`. -- Nothing else clears `api_error`. `update_on_tick` retires it once the - lifetime passes, handing the text to the status bar only when the error frame - is the current screen, so a frontend with no dismissal gesture does not latch - the first failure forever. The CLI never ticks, so its latch is intact. -- A non-empty `api_error` is the CLI's only failure signal for every subcommand - that reaches the bottom of `handle_matches` (`src/cli/handle.rs`; the two - `share-*` flags return early and bypass it). Moving a call site off - `handle_error` turns a failing CLI command into exit 0 unless that site is - provably unreachable from the CLI. -- Demote a site to `set_error_status_message` only when all three hold: it - fires from the tick or a self-refreshing retry loop (so every retry restamps - the lifetime and the backstop can never win), it is provably unreachable as a - CLI exit signal, and the failed operation is bookkeeping rather than the thing - the user asked for. `flush_state_save` is the only site that qualifies today, - and it latches its report to once per failure run - repeating it at the retry - rate would hold `status_message_is_error` and silently drop every ordinary - status message for the rest of the session. - -### Dialog state cleanup - -Close dialogs with the single call `app.clear_dialog_state()` (clears `dialog`, -`confirm`, `pending_keybinding_persist`, and playlist-picker state). Do not -hand-clear the fields. Popping the nav stack is a separate step. - -### User-configurable keybindings - -Check `app.user_config.keys.` instead of hard-coding key literals for -global actions (`handle_app` in `src/tui/handlers/mod.rs`); -`common_key_events::{up,down,left,right}_event` extend this to per-screen -navigation. Adding a binding means fields on both `KeyBindings` and -`KeyBindingsString` in `src/core/user_config.rs`, a `help_entries` row in -`src/tui/keymap.rs` with its `Requirement`, and, when the key produces an -`Action`, that variant's `default_binding` arm pointing at the new field. - -### Requirements - -`core/requirement.rs` says what a row or key needs (`Requirement::{None, -SpotifySession, Capability(_), Source(_), AnyOf(_)}`) and `App::availability(req)` -answers for the active source and the session. Three tables carry it: the -sidebar (`library_row_requirements()` in `core/app/library.rs`), the help menu -(`help_entries()` in `tui/keymap.rs`) and the settings rows -(`setting_requirement(id)` in `core/app/settings_schema.rs`). The rule on every -surface: a row with a rebindable key stays visible with the `hint()` as a -suffix, a row with a fixed key or a sidebar row is hidden. Gate a key on the -same predicate the row uses, so the two cannot disagree. - -### Config & on-disk files - -Five files, five owners - a value that changes as the app runs goes in state, -never config: - -| File | Owner | Contents | -|------|-------|----------| -| `config.yml` (config dir) | `core/user_config.rs` | hand-editable settings, keys, theme | -| `client.yml` (config dir) | `core/config.rs` | Spotify app credentials | -| `state.yml` (state dir) | `core/state.rs` | machine-written runtime values | -| `last_session.yml` (state dir) | `core/persisted_playback.rs` | non-Spotify playback + native queue | -| `qobuz_credentials.yml` (config dir) | `infra/qobuz/auth.rs` | the Qobuz login token (feature `qobuz`) | - -- All paths resolve through `core/paths.rs`, never `dirs::` directly. -- `state.yml` saves are read-modify-write **sparse patches** so a second running - instance is never clobbered; never write a whole snapshot. -- Sensitive writes go through `core::auth::write_private_file_atomic`; - `config.yml` can carry plaintext secrets, so never log the serialized config. -- `UserConfig::save_config()` regenerates only `behavior`/`theme`/`keybindings`; - `plugin_commands`, `format`, and `tables` are passed through from disk. -- Adding a `behavior.*` setting = edits in `core/user_config.rs` - (`BehaviorConfigString`, `BehaviorConfig`, `UserConfig::new`, - `load_behaviorconfig`, `save_config`) **plus** matching arms in - `core/app/settings_schema.rs` and `settings_apply.rs`, coupled only by the raw - `"behavior."` string id - a typo silently drops the write. A row that - only works with Spotify also needs a `setting_requirement` arm. - -### Feature flags - -- `default` = `telemetry, tui, streaming, audio-viz-cpal, macos-media, - windows-media, mpris, discord-rpc, self-update, scripting`. Notably **not** - in default: `cover-art` (so a plain `cargo run` has no album art, though - every shipped binary enables it), the five sources, and the DJ features. -- `tui` gates `mod tui` and owns the terminal-only crates (ratatui, crossterm, - tui-bar-graph, colorgrad - the last also pulled by `art-decode` for the - adaptive-theme HSV math). `gui` is a reserved placeholder that only gates the - `spotatui-gui` bin shim. -- Cover art is two features: `art-decode` is the frontend-agnostic decode half - (`dep:image`, fills `core::art::CoverArtStore`, feeds the adaptive theme; - never enabled by hand); `cover-art` layers the ratatui-image terminal - rendering on top and keeps its pre-split meaning for users and CI. -- `audio-viz` (PipeWire, Linux-only) and `audio-viz-cpal` are **visualizer - capture** backends, not playback. The librespot *playback* backend is chosen by - `[target.'cfg(...)']` dependency blocks in Cargo.toml, not by the `*-backend` - features: linux-gnu→alsa, linux-musl→rodio, Windows→rodio, macOS→portaudio - - the non-default picks avoid librespot's `pipe` sink writing raw audio to stdout - and destroying the TUI. -- `audio-decode` (rodio) is the shared engine pulled in by the sources; - `all-sources` = `local-files, subsonic, internet-radio, youtube, qobuz`. -- `dj-core` is a shared implementation feature pulled in by `mcp-server` and - `ai-dj`; none of the three are in `default`, and neither front door may assume - the other (or `streaming`) is present. -- `scripting` (mlua Lua plugins) is default-on and gates `src/infra/scripting/` + - `src/cli/plugin.rs`; the slim gate never compiles it, so verify scripting - changes with a default `cargo test`. - -### Adding a feature-gated sidebar row - -See `.claude/skills/add-tui-screen/SKILL.md`. - -### Domain-specific conventions - -`src/infra/dj/CLAUDE.md` (DJ lanes/guards/avoid-library filter), -`src/infra/mcp/CLAUDE.md` (MCP server), `src/infra/scripting/CLAUDE.md` (Lua -plugins), and `src/cli/CLAUDE.md` (CLI subcommands) load automatically when -working in those directories. - -### Alternative sources (Local / Subsonic / Radio / YouTube / Qobuz) - -- All five decode through **one** shared rodio sink, `LocalPlayer` - (`src/infra/audio/player.rs`) - the only file where rodio types appear. - Subsonic and YouTube download each track to a `NamedTempFile` first (YouTube by - shelling out to `yt-dlp`); Radio streams through a non-seekable ring buffer. -- Qobuz (`src/infra/qobuz/`) plays each track through the web player's - encrypted CMAF stream while it downloads: `stream/progressive.rs` is a - `stream-download` source that yields the decrypted segments (and restarts at - a seek), rebuilt as a FLAC `NamedTempFile` the session keeps. The fetch runs - off the pump behind a `fetch_id` guard; a superseded stream is dropped, which - cancels its download. The transport (`sign.rs`, `stream/`) is pure and unit - tested. The three web-player constants are scraped at runtime (`auth.rs`), - cached in `state.yml`, and overridable through `SPOTATUI_QOBUZ_*` env vars; - they are never embedded. Failures are status messages, never `handle_error`. -- All three platforms play decoded sources. macOS was gated off in - `LocalPlayer` until rodio 0.22 / cpal 0.17 were measured on CoreAudio; the - SIGSEGVs behind that gate (#9/#20) were librespot's own rodio-backend, which - is why librespot still uses portaudio-backend there and this player does not. -- Losing the output device has **two** shapes and only one is an error. cpal - reports the device being *removed* (`DeviceNotAvailable`); it cannot report the - common case - the OS moving its **default output** elsewhere (headphones out, - AirPods in the case), which leaves the stream bound to a device nobody hears. - So `LocalPlayer` both raises a `lost` flag from its own cpal error callback - *and* remembers the device name it opened, comparing it against the current - default in `device_lost()`. It then *refuses* `play_file`/`play_prepared`/ - `stop`/`seek`: those wait on rodio's audio callback with no timeout, and they - run on the serial pump, so blocking one wedges the whole app rather than just - falling silent. Every such wait also goes through `bounded()`, which re-asks - the device every 3s rather than timing out blind - a dead device and a source - stalled on the network are indistinguishable from the caller's side, and - Qobuz's stream stall alone is 60s. - The driver's tick polls `device_lost()` (one device read a second), calls - `LocalPlayer::recover_device()` to rebuild on the new default device (a - spaced, bounded retry - a Bluetooth output takes seconds to come back), and - restages the track: it sets the session's `resume_at` (the seek, and paused - only when `device_removed()` says cpal saw a *removal* or the session was - already paused - a default that merely moved means the user plugged - something in and keeps playing) and dispatches `ReplayCurrentTrack`. The - path that stages the track applies `resume_at` itself - (`infra::queue::restage`), so no `Seek` or `PausePlayback` is ever queued - blind behind work that ends in `play()`. That recovery must stay *before* - every advance block: a dead sink never drains, so `is_finished()` is false - and would otherwise read as a still-playing track. The queue slot has no - replay event: a removal clears `queue_slot_desired_playing`, which the - paths that stage the next item and the suspended context's resume read, - and a device given up on hands the slot to `FinishNativeQueue` (the same - teardown a drained queue runs) rather than dropping `queue_now`. Every - `LocalPlayer` wait runs off the `App` lock (`stop_detached`, - `stop_detached_holding`, `spawn_blocking`): the runner takes that lock on - every frame. -- Repeat/shuffle for decoded sources live in the pure module - `src/infra/queue/mod.rs` (`advance_decision`, `resume_index_after_queue`, …); - state is player-global on `App` (`decoded_repeat`, `decoded_shuffle`). - Repeat-one affects auto-advance only, never a manual skip. Note - `resume_index_after_queue` returning `None` means "context exhausted, tear - down" - the *opposite* of `advance_index`'s `None` ("clamp, no-op"). -- Set the `advancing` flag synchronously before dispatching a track change: the - sink is empty for the whole decode/download, and the tick would otherwise - re-fire auto-advance and skip several tracks. -- Per-source playback state is one `Option` field on `App` - (`local_playback`, …), published only on success; position/pause are read live - from the player at render time. -- `App.active_source` (`core/source.rs`) is **browse scope only** - it never - changes playback routing, so switching sources must not interrupt playback. -- OS integrations (MPRIS, SMTC, macOS Now Playing, Discord RPC, window title) all - read one `PlaybackSnapshot` from `infra/media_metadata.rs`, which checks - decoded sources first so the paused Spotify track is not published. -- Radio tune-in has **three** unbounded steps, all on the serial pump, and all - three are capped in `infra/radio/stream.rs`: connect (`CONNECT_TIMEOUT`), - response headers (`HEADER_TIMEOUT`), and the format probe (`PROBE_TIMEOUT`). - The probe is the subtle one - rodio's symphonia scans for a start-of-stream - marker and a live stream never ends, so a codec it does not register scans - forever. Its `AdtsReader` claims only MPEG-4 ADTS (`ff f1`), not MPEG-2 - (`ff f9`), which much European radio uses. On timeout the fix is to call - `OpenedStream::cancel` - stopping the *download*, not the reader: a probe - waiting for bytes parks inside `read`, so a flag checked between reads is - never seen, while cancelling marks the stream done, wakes every waiter, and - lets the probe thread and its download go. Only `prepare_stream` runs inside - the timed closure: `timeout` abandons a `spawn_blocking` closure but cannot - stop it, and the radio player is shared, so a probe that matched just after - the deadline would otherwise append the new station to the live sink. The - `play_prepared` happens after the timeout check. -- YouTube is unofficial-fragile by design: when it breaks, the fix is a newer - `yt-dlp`, not a spotatui release. One in-repo mitigation: a failed download - retries once through the embedded player clients (`web_embedded,tv_embedded`), - which PO-token enforcement leaves tokenless for embeddable videos - most - label uploads. A non-embeddable gated video still fails. - -### Testing conventions - -- Tests are colocated (`#[cfg(test)] mod tests`) - there is no `tests/` dir. The - only dev-dependency is `tempfile`: HTTP tests bind a real `127.0.0.1:0` - listener into an injected base-URL field; UI tests use ratatui's `TestBackend`. -- `App::new` is `#[cfg(test)]`-only (production uses `App::new_with_state`). - `App::default()` has no IoEvent channel and `dispatch` silently drops events - - tests asserting on IoEvents use the house pattern - `fn app_with_x() -> (App, Receiver)` and keep the receiver alive. -- `IoEvent` derives nothing (not even `Debug`): assert with - `assert!(matches!(rx.try_recv(), Ok(IoEvent::X(..))))`. -- Never `std::env::set_var` in a test (one process, shared threads); split the - env read into a `*_with(value, …)` function and test that. -- Prefer extracting the decision into a pure function or plain-data enum taking - scalars instead of `&App`/`Network` - the house style for testing without a - Spotify client, audio device, network, or real clock. -- Gate whole test modules with `#[cfg(all(test, feature = "…"))]` where needed. -- Test names are behavior sentences without a `test_` prefix - (`enter_on_stats_entry_opens_stats_screen`). -- Shared fixtures: `crate::core::test_helpers` crate-wide; - `crate::core::app::test_support` inside `core/app/`. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e0cd2d73..883da0c8 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -13,21 +13,12 @@ updates: labels: - "dependencies" - "rust" - assignees: - - "LargeModGames" open-pull-requests-limit: 10 groups: - # Group librespot crates together (they usually update in sync) - librespot: - patterns: - - "librespot-*" - # Group minor/patch updates to reduce noise rust-minor-updates: update-types: - "minor" - "patch" - exclude-patterns: - - "librespot-*" # GitHub Actions workflows - package-ecosystem: "github-actions" @@ -42,8 +33,6 @@ updates: labels: - "dependencies" - "ci" - assignees: - - "LargeModGames" groups: # Group all GitHub Actions updates together actions: diff --git a/.github/scripts/triage.mjs b/.github/scripts/triage.mjs deleted file mode 100644 index be8ded08..00000000 --- a/.github/scripts/triage.mjs +++ /dev/null @@ -1,210 +0,0 @@ -// Issue triage for spotatui, powered by DeepSeek V4-Flash. -// -// Two modes: -// node triage.mjs -> calls the model, prints a validated triage -// JSON object to stdout (redirect to triage.json) -// node triage.mjs --render -> reads triage.json and prints a Markdown block -// (posted as the bot comment) -// -// No dependencies: Node 20+ built-in fetch and fs only. Untrusted issue text is -// read from files (issue.json / open-issues.json) written by `gh`, never passed -// through shell interpolation. - -import { readFileSync } from "node:fs"; - -// Rename here if DeepSeek changes the model string. Endpoint is OpenAI-compatible. -const MODEL = "deepseek-v4-flash"; -const API_URL = "https://api.deepseek.com/v1/chat/completions"; - -// Labels the bot may apply. Deliberately excludes `duplicate` (on this repo that -// means "closed as a dup" — a human decision) and judgement labels like -// `good first issue` / `help wanted`. Dedup results go in the comment, not a label. -const ALLOWED_LABELS = ["bug", "enhancement", "question", "documentation"]; - -// ---- render mode ----------------------------------------------------------- -if (process.argv.includes("--render")) { - const r = JSON.parse(readFileSync("triage.json", "utf8")); - const lines = []; - lines.push("### 🎧 spotatui triage"); - lines.push(""); - lines.push("_Auto-triaged with DeepSeek V4-Flash._"); - lines.push(""); - lines.push(`**Label:** ${r.labels.length ? r.labels.map((l) => `\`${l}\``).join(", ") : "_none_"}`); - if (r.likely_area) lines.push(`**Likely area:** ${r.likely_area}`); - lines.push(""); - lines.push("**Summary**"); - lines.push(""); - lines.push(r.summary || "_n/a_"); - if (r.key_points && r.key_points.length) { - lines.push(""); - lines.push("**Key points**"); - for (const p of r.key_points) lines.push(`- ${p}`); - } - if (r.missing_info && r.missing_info.length) { - lines.push(""); - lines.push("**What would help**"); - for (const m of r.missing_info) lines.push(`- ${m}`); - } - lines.push(""); - if (r.dup_candidates.length) { - lines.push("**Possible duplicates**"); - for (const d of r.dup_candidates) lines.push(`- #${d.number} (${d.confidence}) — ${d.reason}`); - } else { - lines.push("**Possible duplicates:** none found"); - } - lines.push(""); - lines.push(`Compared against the ${r.compared} most recently updated open issues.`); - console.log(lines.join("\n")); - process.exit(0); -} - -// ---- triage mode ----------------------------------------------------------- -const apiKey = process.env.DEEPSEEK_API_KEY; -if (!apiKey) { - console.error("DEEPSEEK_API_KEY is not set"); - process.exit(1); -} - -const issue = JSON.parse(readFileSync("issue.json", "utf8")); -let openIssues = []; -try { - openIssues = JSON.parse(readFileSync("open-issues.json", "utf8")); -} catch { - openIssues = []; -} -// Never compare an issue against itself. -openIssues = openIssues.filter((i) => i.number !== issue.number); -const compared = openIssues.length; - -const system = `You triage GitHub issues for "spotatui", a Rust terminal UI Spotify client -(it also has non-Spotify sources: local files, Subsonic, internet radio, YouTube, Qobuz). -Return ONLY a JSON object — no prose, no Markdown fences — with this exact shape: -{ - "category": one of ${JSON.stringify(ALLOWED_LABELS)} or null, - "summary": "2 to 4 sentence plain-language paragraph describing the issue and its impact on the user", - "key_points": ["short bullets for concrete specifics THE ISSUE ACTUALLY STATES: OS/platform, terminal, version, reproduction steps, expected vs actual behavior, error messages"], - "likely_area": "short phrase naming the most likely affected part of spotatui IF the issue clearly indicates it (e.g. 'Spotify OAuth login', 'internet radio playback', 'native streaming device', 'keybindings'), otherwise null", - "missing_info": ["short bullets for details a maintainer would need that are ABSENT from the report, e.g. 'OS and terminal', 'spotatui version', 'steps to reproduce'"], - "dup_candidates": [ { "number": , "confidence": "high"|"medium"|"low", "reason": "short" } ] -} -Rules: -- Ground EVERYTHING in the issue text. Do NOT speculate about code, root causes, or file paths you cannot see. Do not invent facts the reporter did not state. -- "category" is your single best classification, or null if none fit. -- "key_points" and "missing_info": at most 5 items each; omit anything not evident; use [] if none. -- "dup_candidates": only issues from the provided list that plausibly describe the SAME underlying bug/request. Empty array if none. Never invent numbers. -- Be conservative: prefer null / empty over guessing.`; - -const user = `NEW ISSUE #${issue.number} -Title: ${issue.title} -Body: -${(issue.body || "").slice(0, 8000)} - -OPEN ISSUES (${compared} most recently updated; compare for duplicates): -${openIssues.map((i) => `#${i.number}: ${i.title}`).join("\n") || "(none)"}`; - -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); - -async function callModel() { - const resp = await fetch(API_URL, { - method: "POST", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, - body: JSON.stringify({ - model: MODEL, - temperature: 0, - // Roomy enough for the richer JSON so the object is never truncated. - max_tokens: 900, - // If the API ever rejects this field, drop it — the parser below already - // tolerates a model that wraps its JSON in a Markdown fence. - response_format: { type: "json_object" }, - messages: [ - { role: "system", content: system }, - { role: "user", content: user }, - ], - }), - }); - if (!resp.ok) { - const err = new Error(`DeepSeek API error ${resp.status}: ${await resp.text()}`); - // 4xx (bad model string, bad request) won't fix itself; only retry 429/5xx. - err.retryable = resp.status === 429 || resp.status >= 500; - throw err; - } - return (await resp.json()).choices?.[0]?.message?.content ?? ""; -} - -// DeepSeek occasionally returns HTTP 200 with empty content; retry a few times -// for that (and for transient 429/5xx) before giving up. -const MAX_ATTEMPTS = 3; -let raw = ""; -for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { - try { - raw = await callModel(); - if (raw.trim() !== "") break; - if (attempt === MAX_ATTEMPTS) { - console.error("DeepSeek returned empty content after retries"); - process.exit(1); - } - } catch (e) { - if (!e.retryable || attempt === MAX_ATTEMPTS) { - console.error(e.message); - process.exit(1); - } - } - await sleep(1000 * attempt); -} - -// Flash models occasionally wrap JSON in a ```json fence even with -// response_format set; strip it before parsing. -function parseModelJson(s) { - const fenced = s.match(/```(?:json)?\s*([\s\S]*?)```/i); - return JSON.parse((fenced ? fenced[1] : s).trim()); -} - -let triage; -try { - triage = parseModelJson(raw); -} catch (e) { - console.error(`Could not parse model output as JSON: ${e.message}\n---\n${raw}`); - process.exit(1); -} -// A valid JSON scalar/array/null is not a usable triage object; treat as empty -// rather than crashing on field access below. -if (!triage || typeof triage !== "object" || Array.isArray(triage)) { - triage = {}; -} - -// Normalize a model-provided list into <=n clean, length-capped strings. -const strList = (v, max, n) => - Array.isArray(v) - ? v.filter((x) => typeof x === "string" && x.trim()).map((x) => x.trim().slice(0, max)).slice(0, n) - : []; - -// Hard-filter everything the model returned against reality. -const openNumbers = new Set(openIssues.map((i) => i.number)); -const category = ALLOWED_LABELS.includes(triage.category) ? triage.category : null; -const likelyArea = - typeof triage.likely_area === "string" && triage.likely_area.trim() - ? triage.likely_area.trim().slice(0, 120) - : null; -const dupCandidates = Array.isArray(triage.dup_candidates) - ? triage.dup_candidates - .filter((d) => d && typeof d === "object" && openNumbers.has(d.number)) - .map((d) => ({ - number: d.number, - confidence: ["high", "medium", "low"].includes(d.confidence) ? d.confidence : "low", - reason: String(d.reason || "").slice(0, 200), - })) - : []; - -console.log( - JSON.stringify({ - issue: issue.number, - category, - labels: category ? [category] : [], - summary: String(triage.summary || "").slice(0, 800), - likely_area: likelyArea, - key_points: strList(triage.key_points, 200, 5), - missing_info: strList(triage.missing_info, 200, 5), - dup_candidates: dupCandidates, - compared, - }), -); diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml deleted file mode 100644 index 22c21c2d..00000000 --- a/.github/workflows/cd.yml +++ /dev/null @@ -1,544 +0,0 @@ -name: Continuous Deployment - -permissions: - contents: read - -on: - push: - tags: - - "v*.*.*" - workflow_dispatch: - -env: - CARGO_TERM_COLOR: always - BINARY_NAME: spotatui - -jobs: - build: - name: Build ${{ matrix.target }} - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - include: - # Multi-source features (local-files, subsonic, internet-radio, youtube, qobuz) - # ship on all three platforms: they share the rodio output engine, which - # is now opened on macOS too (the #9/#20 crashes were librespot's own - # older rodio backend, not this one — librespot still uses portaudio - # there, which is why `portaudio-backend` stays on the macOS rows). - # The youtube source needs a yt-dlp binary at runtime (ffmpeg - # recommended); without one it shows an actionable error. - - os: ubuntu-latest - target: x86_64-unknown-linux-gnu - artifact_prefix: linux-x86_64 # most Linux distros - binary_postfix: "" - audio_features: "audio-viz,mpris,local-files,subsonic,internet-radio,youtube,qobuz" - - os: ubuntu-24.04-arm # free native ARM64 runner for public repos - target: aarch64-unknown-linux-gnu - artifact_prefix: linux-aarch64 # Raspberry Pi 4/5, ARM servers, Asahi Linux - binary_postfix: "" - audio_features: "audio-viz,mpris,local-files,subsonic,internet-radio,youtube,qobuz" - - os: macos-15-intel # Intel runner for x86_64 (available until Aug 2027) - target: x86_64-apple-darwin - artifact_prefix: macos-x86_64 - binary_postfix: "" - audio_features: "audio-viz-cpal,portaudio-backend,macos-media,local-files,subsonic,internet-radio,youtube,qobuz" - - os: macos-latest # Apple Silicon runner for aarch64 - target: aarch64-apple-darwin - artifact_prefix: macos-aarch64 - binary_postfix: "" - audio_features: "audio-viz-cpal,portaudio-backend,macos-media,local-files,subsonic,internet-radio,youtube,qobuz" - - os: windows-latest - target: x86_64-pc-windows-msvc - artifact_prefix: windows-x86_64 - binary_postfix: ".exe" - audio_features: "audio-viz-cpal,windows-media,local-files,subsonic,internet-radio,youtube,qobuz" - - steps: - - name: Checkout sources - uses: actions/checkout@v7 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.target }} - - - name: Install Linux dependencies - if: runner.os == 'Linux' - run: | - sudo apt-get update - sudo apt-get install -y -qq pkg-config libssl-dev libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libpipewire-0.3-dev libspa-0.2-dev libasound2-dev - - - name: Install cargo-deb - if: runner.os == 'Linux' - run: cargo install cargo-deb --locked - - - name: Install macOS dependencies - if: runner.os == 'macOS' - run: brew install openssl@3 portaudio - - - name: Build release binary - run: | - if [ -z "${{ matrix.audio_features }}" ]; then - cargo build --release --target ${{ matrix.target }} --no-default-features --features telemetry,tui,streaming,discord-rpc,cover-art,self-update,scripting,mcp-server,ai-dj - else - cargo build --release --target ${{ matrix.target }} --no-default-features --features telemetry,tui,streaming,discord-rpc,cover-art,self-update,scripting,mcp-server,ai-dj,${{ matrix.audio_features }} - fi - shell: bash - - - name: Package binary (Unix) - if: runner.os != 'Windows' - shell: bash - run: | - cd target/${{ matrix.target }}/release - BINARY=${{ env.BINARY_NAME }}${{ matrix.binary_postfix }} - strip $BINARY || true - RELEASE_NAME=${{ env.BINARY_NAME }}-${{ matrix.artifact_prefix }} - tar czvf ../../../$RELEASE_NAME.tar.gz $BINARY - cd ../../.. - shasum -a 256 $RELEASE_NAME.tar.gz > $RELEASE_NAME.tar.gz.sha256 - - - name: Package Debian (.deb) - if: runner.os == 'Linux' - shell: bash - run: | - cargo deb --no-build --target ${{ matrix.target }} - DEB_PATH=$(ls target/${{ matrix.target }}/debian/*.deb) - cp "$DEB_PATH" . - sha256sum "$(basename "$DEB_PATH")" > "$(basename "$DEB_PATH").sha256" - - - name: Package binary (Windows) - if: runner.os == 'Windows' - shell: bash - run: | - cd target/${{ matrix.target }}/release - BINARY=${{ env.BINARY_NAME }}${{ matrix.binary_postfix }} - RELEASE_NAME=${{ env.BINARY_NAME }}-${{ matrix.artifact_prefix }} - 7z a ../../../$RELEASE_NAME.zip $BINARY - cd ../../.. - certutil -hashfile $RELEASE_NAME.zip sha256 | grep -E [A-Fa-f0-9]{64} > $RELEASE_NAME.zip.sha256 - - - name: Upload artifact (Unix) - if: runner.os != 'Windows' - uses: actions/upload-artifact@v7 - with: - name: ${{ env.BINARY_NAME }}-${{ matrix.artifact_prefix }} - path: | - ${{ env.BINARY_NAME }}-${{ matrix.artifact_prefix }}.tar.gz - ${{ env.BINARY_NAME }}-${{ matrix.artifact_prefix }}.tar.gz.sha256 - - - name: Upload artifact (Linux .deb) - if: runner.os == 'Linux' - uses: actions/upload-artifact@v7 - with: - name: ${{ env.BINARY_NAME }}-${{ matrix.artifact_prefix }}-deb - path: | - *.deb - *.deb.sha256 - - - name: Upload artifact (Windows) - if: runner.os == 'Windows' - uses: actions/upload-artifact@v7 - with: - name: ${{ env.BINARY_NAME }}-${{ matrix.artifact_prefix }} - path: | - ${{ env.BINARY_NAME }}-${{ matrix.artifact_prefix }}.zip - ${{ env.BINARY_NAME }}-${{ matrix.artifact_prefix }}.zip.sha256 - - release: - name: Create GitHub Release - needs: build - runs-on: ubuntu-latest - permissions: - contents: write - - steps: - - name: Checkout sources - uses: actions/checkout@v7 - - - name: Download all artifacts - uses: actions/download-artifact@v8 - with: - path: artifacts - merge-multiple: true - - - name: Resolve .deb file names - id: deb - # cargo-deb names the package from Cargo.toml, not from the tag, so the - # download links must use the file names that were built. - run: | - for arch in amd64 arm64; do - f=$(find artifacts -maxdepth 1 -type f -name "spotatui_*_${arch}.deb" -print -quit) - if [ -z "$f" ]; then - echo "No ${arch} .deb found in artifacts/" >&2 - exit 1 - fi - echo "${arch}=$(basename "$f")" >> "$GITHUB_OUTPUT" - done - - - name: Create Release - uses: softprops/action-gh-release@v3 - with: - files: artifacts/* - generate_release_notes: true - prerelease: ${{ contains(github.ref_name, '-') }} - body: | - ## Downloads - - - **Windows 10/11 (64-bit)** → [Download spotatui-windows-x86_64.zip](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/spotatui-windows-x86_64.zip) - - **Linux (Ubuntu, Arch, Fedora, etc.)** → [Download spotatui-linux-x86_64.tar.gz](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/spotatui-linux-x86_64.tar.gz) - - **Linux ARM64 (Raspberry Pi 4/5, ARM servers)** → [Download spotatui-linux-aarch64.tar.gz](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/spotatui-linux-aarch64.tar.gz) - - **Debian/Ubuntu (amd64)** → [Download .deb](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/${{ steps.deb.outputs.amd64 }}) - - **Debian/Ubuntu (arm64)** → [Download .deb](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/${{ steps.deb.outputs.arm64 }}) - - **macOS with Intel CPU** → [Download spotatui-macos-x86_64.tar.gz](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/spotatui-macos-x86_64.tar.gz) - - **macOS with Apple Silicon (M1/M2/M3)** → [Download spotatui-macos-aarch64.tar.gz](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/spotatui-macos-aarch64.tar.gz) - - Or just run the one-line installer: `curl -fsSL https://spotatui.com/install.sh | bash` (macOS/Linux) or `irm https://spotatui.com/install.ps1 | iex` (Windows). - - Checksums (`.sha256`) are optional and only needed if you want to verify the download. - - The Linux, macOS, and Windows builds include the extra music sources (Local Files, Subsonic, Internet Radio, YouTube, Qobuz). The YouTube source needs [yt-dlp](https://github.com/yt-dlp/yt-dlp) installed (ffmpeg recommended). - - To update use "spotatui update --install" or use your package manager of choice. - draft: false - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Upate Nix Version Reference - id: nix_version - run: | - OLD_VERSION=$(grep -oP 'version\s*=\s*"\K[^"]+' flake.nix) - echo "Version: $OLD_VERSION" - NEW_VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') - echo "Version: $NEW_VERSION" - sed -i "s/version *= *\"$OLD_VERSION\"/version = \"$NEW_VERSION\"/" flake.nix - - publish-cargo: - name: Publish to crates.io - needs: release - runs-on: ubuntu-latest - if: ${{ !contains(github.ref_name, '-') }} - steps: - - name: Checkout sources - uses: actions/checkout@v7 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Install Linux dependencies - run: | - sudo apt-get update - sudo apt-get install -y -qq pkg-config libssl-dev libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev - - - name: Publish to crates.io - run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} --allow-dirty - - publish-aur: - name: Publish to AUR - needs: release - runs-on: ubuntu-latest - if: ${{ !contains(github.ref_name, '-') }} - steps: - - name: Checkout sources - uses: actions/checkout@v7 - - - name: Extract version - id: version - run: | - VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "Version: $VERSION" - - - name: Calculate SHA256 - id: sha256 - run: | - TARBALL_URL="https://github.com/${{ github.repository }}/archive/refs/tags/v${{ steps.version.outputs.version }}.tar.gz" - SHA256=$(curl -sL "$TARBALL_URL" | sha256sum | awk '{print $1}') - echo "sha256=$SHA256" >> $GITHUB_OUTPUT - echo "SHA256: $SHA256" - - - name: Setup SSH for AUR - run: | - mkdir -p ~/.ssh - echo "${{ secrets.AUR_SSH_PRIVATE_KEY }}" > ~/.ssh/aur - chmod 600 ~/.ssh/aur - ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts - git config --global user.name "${{ secrets.AUR_USERNAME }}" - git config --global user.email "${{ secrets.AUR_EMAIL }}" - - - name: Clone AUR repository - run: | - GIT_SSH_COMMAND='ssh -i ~/.ssh/aur -o IdentitiesOnly=yes' git clone ssh://aur@aur.archlinux.org/spotatui.git aur - - - name: Update PKGBUILD - working-directory: aur - run: | - sed -i "s/^pkgver=.*/pkgver=${{ steps.version.outputs.version }}/" PKGBUILD - sed -i "s/^pkgrel=.*/pkgrel=1/" PKGBUILD - sed -i "s/^sha256sums=.*/sha256sums=('${{ steps.sha256.outputs.sha256 }}')/" PKGBUILD - # Ensure AUR builds work with default system linker (avoids LLVM bitcode/LTO linker errors). - if ! grep -q "CARGO_PROFILE_RELEASE_LTO=false" PKGBUILD; then - sed -i "/^[[:space:]]*build() {[[:space:]]*$/a\\ export CARGO_PROFILE_RELEASE_LTO=false" PKGBUILD - fi - - - name: Generate .SRCINFO - working-directory: aur - run: | - docker run --rm -v $PWD:/pkg archlinux:latest /bin/bash -c " - pacman -Syu --noconfirm --needed base-devel && - useradd -m builder && - chown -R builder:builder /pkg && - cd /pkg && - su builder -c 'makepkg --printsrcinfo' > .SRCINFO - " - sudo chown -R $(id -u):$(id -g) . - - - name: Commit and push to AUR - working-directory: aur - run: | - git add PKGBUILD .SRCINFO - git commit -m "Update to ${{ steps.version.outputs.version }}" - GIT_SSH_COMMAND='ssh -i ~/.ssh/aur -o IdentitiesOnly=yes' git push - - publish-aur-bin: - name: Publish to AUR (spotatui-bin) - needs: release - runs-on: ubuntu-latest - if: ${{ !contains(github.ref_name, '-') }} - steps: - - name: Checkout sources - uses: actions/checkout@v7 - - - name: Extract version - id: version - run: | - VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "Version: $VERSION" - - - name: Calculate SHA256 checksums - id: sha256 - run: | - VERSION="${{ steps.version.outputs.version }}" - - # SHA256 for the binary tarball - BINARY_URL="https://github.com/${{ github.repository }}/releases/download/v${VERSION}/spotatui-linux-x86_64.tar.gz" - SHA256_BINARY=$(curl -sL "$BINARY_URL" | sha256sum | awk '{print $1}') - echo "sha256_binary=$SHA256_BINARY" >> $GITHUB_OUTPUT - echo "Binary SHA256: $SHA256_BINARY" - - # SHA256 for LICENSE - LICENSE_URL="https://raw.githubusercontent.com/${{ github.repository }}/v${VERSION}/LICENSE" - SHA256_LICENSE=$(curl -sL "$LICENSE_URL" | sha256sum | awk '{print $1}') - echo "sha256_license=$SHA256_LICENSE" >> $GITHUB_OUTPUT - echo "LICENSE SHA256: $SHA256_LICENSE" - - # SHA256 for README.md - README_URL="https://raw.githubusercontent.com/${{ github.repository }}/v${VERSION}/README.md" - SHA256_README=$(curl -sL "$README_URL" | sha256sum | awk '{print $1}') - echo "sha256_readme=$SHA256_README" >> $GITHUB_OUTPUT - echo "README SHA256: $SHA256_README" - - - name: Setup SSH for AUR - run: | - mkdir -p ~/.ssh - echo "${{ secrets.AUR_SSH_PRIVATE_KEY }}" > ~/.ssh/aur - chmod 600 ~/.ssh/aur - ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts - git config --global user.name "${{ secrets.AUR_USERNAME }}" - git config --global user.email "${{ secrets.AUR_EMAIL }}" - - - name: Clone AUR repository - run: | - GIT_SSH_COMMAND='ssh -i ~/.ssh/aur -o IdentitiesOnly=yes' git clone ssh://aur@aur.archlinux.org/spotatui-bin.git aur-bin - - - name: Update PKGBUILD - working-directory: aur-bin - run: | - # Update version and release - sed -i "s/^pkgver=.*/pkgver=${{ steps.version.outputs.version }}/" PKGBUILD - sed -i "s/^pkgrel=.*/pkgrel=1/" PKGBUILD - - # Remove existing checksum lines (handles both single and multi-line) - sed -i '/^sha256sums=/d' PKGBUILD - sed -i '/^sha256sums_x86_64=/d' PKGBUILD - sed -i "/^[[:space:]]*'[a-f0-9]\{64\}')/d" PKGBUILD - - # Insert new single-line checksums before package() - SHA_L="${{ steps.sha256.outputs.sha256_license }}" - SHA_R="${{ steps.sha256.outputs.sha256_readme }}" - SHA_B="${{ steps.sha256.outputs.sha256_binary }}" - sed -i "/^package()/i sha256sums=('${SHA_L}' '${SHA_R}')\nsha256sums_x86_64=('${SHA_B}')" PKGBUILD - - # Validation - echo "=== Validating PKGBUILD ===" - grep "^pkgver=" PKGBUILD - grep "^sha256sums=" PKGBUILD || { echo "ERROR: sha256sums not found"; exit 1; } - grep "^sha256sums_x86_64=" PKGBUILD || { echo "ERROR: sha256sums_x86_64 not found"; exit 1; } - grep -q "package()" PKGBUILD || { echo "ERROR: package() function missing"; exit 1; } - echo "=== PKGBUILD validation successful ===" - - - name: Generate .SRCINFO - working-directory: aur-bin - run: | - docker run --rm -v $PWD:/pkg archlinux:latest /bin/bash -c " - pacman -Syu --noconfirm --needed base-devel && - useradd -m builder && - chown -R builder:builder /pkg && - cd /pkg && - su builder -c 'makepkg --printsrcinfo' > .SRCINFO - " - sudo chown -R $(id -u):$(id -g) . - - - name: Commit and push to AUR - working-directory: aur-bin - run: | - git add PKGBUILD .SRCINFO - git commit -m "Update to ${{ steps.version.outputs.version }}" - GIT_SSH_COMMAND='ssh -i ~/.ssh/aur -o IdentitiesOnly=yes' git push - - publish-winget: - name: Publish to winget - needs: release - runs-on: windows-latest - if: ${{ !contains(github.ref_name, '-') }} - steps: - - name: Install cargo-binstall - uses: cargo-bins/cargo-binstall@main - env: - GITHUB_TOKEN: ${{ github.token }} - - - name: Install komac - shell: pwsh - run: cargo binstall komac@2.15.0 -y - env: - GITHUB_TOKEN: ${{ github.token }} - - - name: Submit to winget-pkgs - shell: pwsh - run: | - $ReleaseInfo = Invoke-RestMethod ` - -Uri "https://api.github.com/repos/${{ github.repository }}/releases/tags/${{ github.ref_name }}" ` - -Headers @{ Authorization = "token $env:GITHUB_TOKEN" } - - $Version = "${{ github.ref_name }}" -replace '^v' - $Urls = $ReleaseInfo.assets.Where({ $_.name -match '-windows-x86_64\.zip$' }).browser_download_url -join ' ' - - if ([string]::IsNullOrWhiteSpace($Urls)) { - Write-Error "No Windows x86_64 zip release asset found" - exit 1 - } - - komac update 'LargeModGames.spotatui' --version "$Version" --submit --urls $Urls - env: - GITHUB_TOKEN: ${{ secrets.WINGET_TOKEN }} - KOMAC_FORK_OWNER: LargeModGames - KOMAC_CREATED_WITH: spotatui CD workflow - KOMAC_CREATED_WITH_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - - publish-homebrew: - name: Publish to Homebrew - needs: release - runs-on: ubuntu-latest - if: ${{ !contains(github.ref_name, '-') }} - steps: - - name: Checkout sources - uses: actions/checkout@v7 - - - name: Extract version - id: version - run: | - VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "Version: $VERSION" - - - name: Calculate SHA256 checksums - id: sha256 - run: | - VERSION="${{ steps.version.outputs.version }}" - - # macOS aarch64 - AARCH64_URL="https://github.com/${{ github.repository }}/releases/download/v${VERSION}/spotatui-macos-aarch64.tar.gz" - SHA256_AARCH64=$(curl -sL "$AARCH64_URL" | sha256sum | awk '{print $1}') - echo "sha256_aarch64=$SHA256_AARCH64" >> $GITHUB_OUTPUT - echo "macOS aarch64 SHA256: $SHA256_AARCH64" - - # macOS x86_64 - X86_64_URL="https://github.com/${{ github.repository }}/releases/download/v${VERSION}/spotatui-macos-x86_64.tar.gz" - SHA256_X86_64=$(curl -sL "$X86_64_URL" | sha256sum | awk '{print $1}') - echo "sha256_x86_64=$SHA256_X86_64" >> $GITHUB_OUTPUT - echo "macOS x86_64 SHA256: $SHA256_X86_64" - - - name: Setup SSH for Homebrew tap - run: | - mkdir -p ~/.ssh - echo "${{ secrets.HOMEBREW_TAP_SSH_KEY }}" > ~/.ssh/homebrew_tap - chmod 600 ~/.ssh/homebrew_tap - ssh-keyscan github.com >> ~/.ssh/known_hosts - git config --global user.name "github-actions[bot]" - git config --global user.email "github-actions[bot]@users.noreply.github.com" - - - name: Clone Homebrew tap repository - run: | - GIT_SSH_COMMAND='ssh -i ~/.ssh/homebrew_tap -o IdentitiesOnly=yes' \ - git clone git@github.com:LargeModGames/homebrew-spotatui.git homebrew-tap - - - name: Update formula - working-directory: homebrew-tap - run: | - cat > Formula/spotatui.rb << 'FORMULA_EOF' - # typed: false - # frozen_string_literal: true - - # This formula is automatically updated by the CD workflow on each release. - # Manual changes will be overwritten. - - class Spotatui < Formula - desc "Spotify client for the terminal written in Rust, powered by Ratatui" - homepage "https://github.com/LargeModGames/spotatui" - version "${{ steps.version.outputs.version }}" - license "MIT" - - depends_on :macos - depends_on "portaudio" - depends_on "openssl@3" - - on_arm do - url "https://github.com/LargeModGames/spotatui/releases/download/v#{version}/spotatui-macos-aarch64.tar.gz" - sha256 "${{ steps.sha256.outputs.sha256_aarch64 }}" - end - - on_intel do - url "https://github.com/LargeModGames/spotatui/releases/download/v#{version}/spotatui-macos-x86_64.tar.gz" - sha256 "${{ steps.sha256.outputs.sha256_x86_64 }}" - end - - def install - bin.install "spotatui" - end - - def caveats - <<~EOS - To use spotatui, you need a Spotify Premium account and must set up - Spotify API credentials. Run `spotatui` for first-time setup instructions. - - For native streaming support, ensure you have a Spotify Premium subscription. - Note: Native streaming on macOS requires 'portaudio' (installed as a dependency). - - More info: https://github.com/LargeModGames/spotatui - EOS - end - - test do - assert_match "spotatui", shell_output("#{bin}/spotatui --version") - end - end - FORMULA_EOF - - - name: Commit and push to Homebrew tap - working-directory: homebrew-tap - run: | - git add Formula/spotatui.rb - git diff --cached --quiet && echo "No changes to commit" && exit 0 - git commit -m "Update spotatui to ${{ steps.version.outputs.version }}" - GIT_SSH_COMMAND='ssh -i ~/.ssh/homebrew_tap -o IdentitiesOnly=yes' git push diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65b09d92..d2daae63 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,285 +1,18 @@ +name: CI + on: - pull_request: push: - branches: [main] - -name: Continuous Integration - -permissions: - contents: read + pull_request: jobs: - # Workaround for making Github Actions skip based on commit message `[skip ci]` - # Source https://gist.github.com/ybiquitous/c80f15c18319c63cae8447a3be341267 - prepare: + rust: runs-on: ubuntu-latest - if: | - !contains(format('{0} {1} {2}', github.event.head_commit.message, github.event.pull_request.title, github.event.pull_request.body), '[skip ci]') steps: - # Debug output only. Read the payload from $GITHUB_EVENT_PATH instead of - # templating ${{ toJson(github.event) }} into the script: expression - # expansion happens before the shell runs, so attacker-controlled payload - # fields (PR title/body, commit message) could escape the heredoc and - # execute (CodeQL: actions/cache-poisoning/code-injection). - - run: | - echo "github.event_name: $GITHUB_EVENT_NAME" - echo "github.event:" - cat "$GITHUB_EVENT_PATH" - - check: - name: Check (${{ matrix.name }}) - runs-on: ubuntu-latest - needs: prepare - strategy: - fail-fast: false - matrix: - include: - # Default `cargo run` build. - - name: default - args: "--locked" - # The exact feature set shipped for Linux by cd.yml, so every new - # source subsystem (local-files, subsonic, internet-radio, youtube, qobuz), - # cover-art, and both DJ front doors are compiled and tested in CI. - # Keep this list in sync with cd.yml's `Build release binary` step - # whenever a feature is added there, or the release build becomes the - # first place a break shows up. - - name: all-sources - args: "--locked --no-default-features --features telemetry,tui,streaming,discord-rpc,cover-art,self-update,scripting,mcp-server,ai-dj,audio-viz,mpris,local-files,subsonic,internet-radio,youtube,qobuz" - # `mcp-server` and `ai-dj` are two front doors on the same `dj-core` - # implementation, and each has to build without the other: the - # field-level `#[cfg(feature = "ai-dj")]` on `DjState::setup` exists - # precisely so an MCP-only build still compiles. all-sources turns both - # on, so only a single-door leg can catch one leaking into the other. - # Both are slim, so they cost a fraction of the all-sources leg. - - name: mcp-only - args: "--locked --no-default-features --features telemetry,tui,mcp-server" - - name: ai-dj-only - args: "--locked --no-default-features --features telemetry,tui,ai-dj" - # Slim build used for fast local iteration (matches CLAUDE.md). - - name: slim - args: "--locked --no-default-features --features telemetry,tui" - # Headless: one of two legs without `tui`. `mod tui` is - # feature-gated, so this leg turns any `crate::tui` import from - # core/infra/cli into a compile error, which is what keeps a second - # frontend from silently re-coupling to the terminal one. - - name: headless - args: "--locked --no-default-features --features telemetry" - # Native streaming without the terminal frontend: the combination - # a second frontend or a daemon consumes. Proves the streaming startup - # and the player-event wiring type-check and pass clippy with no - # `crate::tui` in scope. - - name: headless-streaming - args: "--locked --no-default-features --features telemetry,streaming" - env: - RUSTFLAGS: "-C linker-features=-lld" - steps: - - uses: actions/checkout@master - - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - profile: minimal - override: true - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y -qq pkg-config libssl-dev libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libpipewire-0.3-dev libspa-0.2-dev libasound2-dev - - uses: Swatinem/rust-cache@v2 - - uses: actions-rs/cargo@v1 - with: - command: check - args: ${{ matrix.args }} - - test: - name: Test Suite (${{ matrix.name }}) - runs-on: ubuntu-latest - needs: prepare - strategy: - fail-fast: false - matrix: - include: - - name: default - args: "--locked" - - name: all-sources - args: "--locked --no-default-features --features telemetry,tui,streaming,discord-rpc,cover-art,self-update,scripting,mcp-server,ai-dj,audio-viz,mpris,local-files,subsonic,internet-radio,youtube,qobuz" - # See the check job for why the two single-door DJ legs exist. - - name: mcp-only - args: "--locked --no-default-features --features telemetry,tui,mcp-server" - - name: ai-dj-only - args: "--locked --no-default-features --features telemetry,tui,ai-dj" - - name: slim - args: "--locked --no-default-features --features telemetry,tui" - # See the check job for why the headless leg exists. - - name: headless - args: "--locked --no-default-features --features telemetry" - env: - RUSTFLAGS: "-C linker-features=-lld" - steps: - - uses: actions/checkout@master - - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - profile: minimal - override: true - # These dependencies are required for `clipboard` and the audio backends. - - run: | - sudo apt-get update - sudo apt-get install -y -qq pkg-config libssl-dev libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libpipewire-0.3-dev libspa-0.2-dev libasound2-dev - - uses: Swatinem/rust-cache@v2 - - uses: actions-rs/cargo@v1 - with: - command: test - args: ${{ matrix.args }} - - # Line coverage for Codecov. One leg only: the all-sources feature set, so - # the report covers every subsystem that ships in the Linux release. Keep the - # feature list identical to the all-sources row above. The coverage build - # uses its own instrumentation flags, so it does not share the test job's - # cache. Fork pull requests have no secret and upload tokenless. A failed - # upload never turns the job red: coverage is a report, not a gate. - coverage: - name: Coverage - runs-on: ubuntu-latest - needs: prepare - env: - RUSTFLAGS: "-C linker-features=-lld" - steps: - - uses: actions/checkout@master - with: - persist-credentials: false + - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable with: - components: llvm-tools-preview - - run: | - sudo apt-get update - sudo apt-get install -y -qq pkg-config libssl-dev libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libpipewire-0.3-dev libspa-0.2-dev libasound2-dev - - uses: Swatinem/rust-cache@v2 - - uses: taiki-e/install-action@cargo-llvm-cov - - name: Run tests with coverage - run: cargo llvm-cov --locked --no-default-features --features telemetry,tui,streaming,discord-rpc,cover-art,self-update,scripting,mcp-server,ai-dj,audio-viz,mpris,local-files,subsonic,internet-radio,youtube,qobuz --lcov --output-path lcov.info - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v5 - with: - files: lcov.info - fail_ci_if_error: false - token: ${{ secrets.CODECOV_TOKEN }} - - fmt: - name: Rustfmt - runs-on: ubuntu-latest - needs: prepare - steps: - - uses: actions/checkout@master - - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - profile: minimal - override: true components: rustfmt - - uses: Swatinem/rust-cache@v2 - - uses: actions-rs/cargo@v1 - with: - command: fmt - args: --all -- --check - - clippy: - name: Clippy (${{ matrix.name }}) - runs-on: ubuntu-latest - needs: prepare - strategy: - fail-fast: false - matrix: - include: - - name: default - args: "--locked" - - name: all-sources - args: "--locked --no-default-features --features telemetry,tui,streaming,discord-rpc,cover-art,self-update,scripting,mcp-server,ai-dj,audio-viz,mpris,local-files,subsonic,internet-radio,youtube,qobuz" - # See the check job for why the two single-door DJ legs exist. - - name: mcp-only - args: "--locked --no-default-features --features telemetry,tui,mcp-server" - - name: ai-dj-only - args: "--locked --no-default-features --features telemetry,tui,ai-dj" - - name: slim - args: "--locked --no-default-features --features telemetry,tui" - # See the check job for why the headless leg exists. - - name: headless - args: "--locked --no-default-features --features telemetry" - # See the check job for why the headless-streaming leg exists. - - name: headless-streaming - args: "--locked --no-default-features --features telemetry,streaming" - env: - RUSTFLAGS: "-C linker-features=-lld" - steps: - - uses: actions/checkout@master - - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - profile: minimal - override: true - components: clippy - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y -qq pkg-config libssl-dev libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libpipewire-0.3-dev libspa-0.2-dev libasound2-dev - - uses: Swatinem/rust-cache@v2 - - uses: actions-rs/cargo@v1 - with: - command: clippy - args: ${{ matrix.args }} -- -D warnings - - # macOS is the one platform the seven Linux legs above cannot speak for: it - # picks a different playback backend (portaudio, not alsa/rodio), a different - # OS integration (`macos-media`, not `mpris`), a different visualizer capture - # (`audio-viz-cpal`, not the PipeWire `audio-viz`), and it is the only place - # the `#[cfg(target_os = "macos")]` arms are compiled at all. The decoded - # sources ship there now, so a break in any of that reaches users. - # - # Check and clippy only, like `headless-streaming`: what actually risks - # breaking here is a target-gated arm or a feature that does not exist on - # macOS, and the test suite is platform-independent logic the Linux legs - # already run. The device tests that would exercise the audio path are - # `#[ignore]`d — GitHub runners have no audio output — so a `test` job would - # buy nothing for the extra runner time. One job rather than two so the - # slow part, spinning up a macOS runner, happens once. - # - # Keep the feature list in sync with cd.yml's macOS rows, the same way the - # `all-sources` leg tracks its Linux row. - macos: - name: macOS (check + clippy) - runs-on: macos-latest - needs: prepare - env: - FEATURES: "telemetry,tui,streaming,discord-rpc,cover-art,self-update,scripting,mcp-server,ai-dj,audio-viz-cpal,portaudio-backend,macos-media,local-files,subsonic,internet-radio,youtube,qobuz" - steps: - - uses: actions/checkout@master - - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - name: Install system dependencies - run: brew install openssl@3 portaudio - - uses: Swatinem/rust-cache@v2 - - name: Check - run: cargo check --locked --no-default-features --features "$FEATURES" - - name: Clippy - run: cargo clippy --locked --no-default-features --features "$FEATURES" -- -D warnings - - # The src/gates.rs test pins tools/gates.count to the measured counters; this - # job closes the remaining hole by comparing the file against the merge-base, - # so a PR cannot raise a coupling baseline or lower the test floor alongside - # the code that would need it. Pull requests only: pushes to main have no base - # to ratchet against. - gates-ratchet: - name: Gates ratchet - runs-on: ubuntu-latest - needs: prepare - if: github.event_name == 'pull_request' - steps: - # Pinned to an immutable SHA per review; the pre-existing jobs' @master - # refs are a separate hygiene pass. - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - persist-credentials: false - - name: Compare tools/gates.count against merge-base - run: | - git fetch origin "${{ github.base_ref }}" - bash tools/check_gates_ratchet.sh FETCH_HEAD + - run: sudo apt-get update && sudo apt-get install -y libasound2-dev libdbus-1-dev pkg-config + - run: cargo fmt --check + - run: cargo check --locked + - run: cargo test --locked diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml deleted file mode 100644 index 8ec3e7ab..00000000 --- a/.github/workflows/nix.yml +++ /dev/null @@ -1,70 +0,0 @@ -# Nix packaging is only exercised when the inputs that can break it change, -# because `nix build` compiles the whole crate and is far too slow to run on -# every PR. Keep this list in sync with anything the flake reads. -on: - pull_request: - paths: - - "flake.nix" - - "flake.lock" - - "Cargo.toml" - - "Cargo.lock" - - ".github/workflows/nix.yml" - push: - branches: [main] - paths: - - "flake.nix" - - "flake.lock" - - "Cargo.toml" - - "Cargo.lock" - - ".github/workflows/nix.yml" - -name: Nix - -permissions: - contents: read - -jobs: - flake: - name: Flake - runs-on: ubuntu-latest - # A cold `nix build` of the full default feature set (librespot included) - # is the long pole here. - timeout-minutes: 60 - if: | - !contains(format('{0} {1} {2}', github.event.head_commit.message, github.event.pull_request.title, github.event.pull_request.body), '[skip ci]') - steps: - # Pinned to commit SHAs rather than tags or branches: a tag can be - # retargeted, and this job runs `nix build`, so a compromised action would - # be executing arbitrary code against the build. Dependabot already tracks - # the `github-actions` ecosystem and reads the trailing version comment, so - # these still get update PRs. - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - # The job only reads the repo, so leaving GITHUB_TOKEN behind in the - # local git config buys nothing. - persist-credentials: false - - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 - with: - extra_nix_config: | - experimental-features = nix-command flakes - - # Catches outputs declared under the wrong attribute path. `devShells` and - # `apps` nested inside `packages` evaluated fine but were invisible to Nix, - # so `nix run` had no target and `nix develop` silently fell back to the - # package derivation (#401). Evaluation only, no builds. - - name: Check flake outputs - run: nix flake check --all-systems --no-build - - # Direct regression guard for the above: the dev shell has to be reachable - # *and* actually carry the Rust toolchain. - - name: Dev shell provides the Rust toolchain - run: nix develop --command cargo --version - - # The `librespot-*` entries in `cargoLock.outputHashes` pin the content of - # the spotatui-librespot checkout, while the rev they correspond to lives - # in Cargo.toml's [patch] block. Nothing links the two, so bumping the fork - # without regenerating the hash breaks `nix build` for every Nix user, with - # a fixed-output hash mismatch that never mentions the rev. Building here is - # what catches that drift before it reaches main. - - name: Build the package - run: nix build --no-link --print-build-logs .#default diff --git a/.github/workflows/publish-aur.yml b/.github/workflows/publish-aur.yml deleted file mode 100644 index 93ebebc6..00000000 --- a/.github/workflows/publish-aur.yml +++ /dev/null @@ -1,177 +0,0 @@ -name: Publish to AUR - -permissions: - contents: read - -on: - workflow_dispatch: - inputs: - version: - description: "Version to publish (e.g., 0.33.6)" - required: false - type: string - -jobs: - publish-aur: - name: Publish to AUR - runs-on: ubuntu-latest - steps: - - name: Checkout sources - uses: actions/checkout@v7 - - - name: Extract version - id: version - run: | - if [ -n "${{ inputs.version }}" ]; then - VERSION="${{ inputs.version }}" - else - VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') - fi - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "Version: $VERSION" - - - name: Calculate SHA256 - id: sha256 - run: | - TARBALL_URL="https://github.com/${{ github.repository }}/archive/refs/tags/v${{ steps.version.outputs.version }}.tar.gz" - SHA256=$(curl -sL "$TARBALL_URL" | sha256sum | awk '{print $1}') - echo "sha256=$SHA256" >> $GITHUB_OUTPUT - echo "SHA256: $SHA256" - - - name: Setup SSH for AUR - run: | - mkdir -p ~/.ssh - echo "${{ secrets.AUR_SSH_PRIVATE_KEY }}" > ~/.ssh/aur - chmod 600 ~/.ssh/aur - ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts - git config --global user.name "${{ secrets.AUR_USERNAME }}" - git config --global user.email "${{ secrets.AUR_EMAIL }}" - - - name: Clone AUR repository - run: | - GIT_SSH_COMMAND='ssh -i ~/.ssh/aur -o IdentitiesOnly=yes' git clone ssh://aur@aur.archlinux.org/spotatui.git aur - - - name: Update PKGBUILD - working-directory: aur - run: | - sed -i "s/^pkgver=.*/pkgver=${{ steps.version.outputs.version }}/" PKGBUILD - sed -i "s/^pkgrel=.*/pkgrel=1/" PKGBUILD - sed -i "s/^sha256sums=.*/sha256sums=('${{ steps.sha256.outputs.sha256 }}')/" PKGBUILD - - - name: Generate .SRCINFO - working-directory: aur - run: | - docker run --rm -v $PWD:/pkg archlinux:latest /bin/bash -c " - pacman -Syu --noconfirm --needed base-devel && - useradd -m builder && - chown -R builder:builder /pkg && - cd /pkg && - su builder -c 'makepkg --printsrcinfo' > .SRCINFO - " - sudo chown -R $(id -u):$(id -g) . - - - name: Commit and push to AUR - working-directory: aur - run: | - git add PKGBUILD .SRCINFO - git commit -m "Update to ${{ steps.version.outputs.version }}" - GIT_SSH_COMMAND='ssh -i ~/.ssh/aur -o IdentitiesOnly=yes' git push - - publish-aur-bin: - name: Publish to AUR (spotatui-bin) - runs-on: ubuntu-latest - steps: - - name: Checkout sources - uses: actions/checkout@v7 - - - name: Extract version - id: version - run: | - if [ -n "${{ inputs.version }}" ]; then - VERSION="${{ inputs.version }}" - else - VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') - fi - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "Version: $VERSION" - - - name: Calculate SHA256 checksums - id: sha256 - run: | - VERSION="${{ steps.version.outputs.version }}" - - # SHA256 for the binary tarball - BINARY_URL="https://github.com/${{ github.repository }}/releases/download/v${VERSION}/spotatui-linux-x86_64.tar.gz" - SHA256_BINARY=$(curl -sL "$BINARY_URL" | sha256sum | awk '{print $1}') - echo "sha256_binary=$SHA256_BINARY" >> $GITHUB_OUTPUT - echo "Binary SHA256: $SHA256_BINARY" - - # SHA256 for LICENSE - LICENSE_URL="https://raw.githubusercontent.com/${{ github.repository }}/v${VERSION}/LICENSE" - SHA256_LICENSE=$(curl -sL "$LICENSE_URL" | sha256sum | awk '{print $1}') - echo "sha256_license=$SHA256_LICENSE" >> $GITHUB_OUTPUT - echo "LICENSE SHA256: $SHA256_LICENSE" - - # SHA256 for README.md - README_URL="https://raw.githubusercontent.com/${{ github.repository }}/v${VERSION}/README.md" - SHA256_README=$(curl -sL "$README_URL" | sha256sum | awk '{print $1}') - echo "sha256_readme=$SHA256_README" >> $GITHUB_OUTPUT - echo "README SHA256: $SHA256_README" - - - name: Setup SSH for AUR - run: | - mkdir -p ~/.ssh - echo "${{ secrets.AUR_SSH_PRIVATE_KEY }}" > ~/.ssh/aur - chmod 600 ~/.ssh/aur - ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts - git config --global user.name "${{ secrets.AUR_USERNAME }}" - git config --global user.email "${{ secrets.AUR_EMAIL }}" - - - name: Clone AUR repository - run: | - GIT_SSH_COMMAND='ssh -i ~/.ssh/aur -o IdentitiesOnly=yes' git clone ssh://aur@aur.archlinux.org/spotatui-bin.git aur-bin - - - name: Update PKGBUILD - working-directory: aur-bin - run: | - # Update version and release - sed -i "s/^pkgver=.*/pkgver=${{ steps.version.outputs.version }}/" PKGBUILD - sed -i "s/^pkgrel=.*/pkgrel=1/" PKGBUILD - - # Remove existing checksum lines (handles both single and multi-line) - sed -i '/^sha256sums=/d' PKGBUILD - sed -i '/^sha256sums_x86_64=/d' PKGBUILD - sed -i "/^[[:space:]]*'[a-f0-9]\{64\}')/d" PKGBUILD - - # Insert new single-line checksums before package() - SHA_L="${{ steps.sha256.outputs.sha256_license }}" - SHA_R="${{ steps.sha256.outputs.sha256_readme }}" - SHA_B="${{ steps.sha256.outputs.sha256_binary }}" - sed -i "/^package()/i sha256sums=('${SHA_L}' '${SHA_R}')\nsha256sums_x86_64=('${SHA_B}')" PKGBUILD - - # Validation - echo "=== Validating PKGBUILD ===" - grep "^pkgver=" PKGBUILD - grep "^sha256sums=" PKGBUILD || { echo "ERROR: sha256sums not found"; exit 1; } - grep "^sha256sums_x86_64=" PKGBUILD || { echo "ERROR: sha256sums_x86_64 not found"; exit 1; } - grep -q "package()" PKGBUILD || { echo "ERROR: package() function missing"; exit 1; } - echo "=== PKGBUILD validation successful ===" - - - name: Generate .SRCINFO - working-directory: aur-bin - run: | - docker run --rm -v $PWD:/pkg archlinux:latest /bin/bash -c " - pacman -Syu --noconfirm --needed base-devel && - useradd -m builder && - chown -R builder:builder /pkg && - cd /pkg && - su builder -c 'makepkg --printsrcinfo' > .SRCINFO - " - sudo chown -R $(id -u):$(id -g) . - - - name: Commit and push to AUR - working-directory: aur-bin - run: | - git add PKGBUILD .SRCINFO - git commit -m "Update to ${{ steps.version.outputs.version }}" - GIT_SSH_COMMAND='ssh -i ~/.ssh/aur -o IdentitiesOnly=yes' git push diff --git a/.github/workflows/toc.yml b/.github/workflows/toc.yml deleted file mode 100644 index d3d26287..00000000 --- a/.github/workflows/toc.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: TOC Generator - -on: - push: - branches: - - main - paths: - - "README.md" - workflow_dispatch: - -permissions: - contents: write - -jobs: - generateTOC: - name: Generate TOC - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - - uses: technote-space/toc-generator@v4 - with: - # The "Protect main" ruleset forbids direct pushes to main, so the - # generator opens a PR with the TOC update instead of committing. - # NOTE: with the default GITHUB_TOKEN the PR's CI (on: pull_request) - # will NOT run, so the required status checks stay pending and the PR - # must be merged by a bypass actor (repo admin). To have CI run on the - # TOC PR and merge normally, create a PAT with `repo` scope, store it - # as the ACCESS_TOKEN secret, and swap the line below to: - # GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TARGET_PATHS: "README.md" - TOC_TITLE: "## Table of Contents" - FOLDING: false - CREATE_PR: true - PR_DEFAULT_BRANCH_PREFIX: "toc/" - PR_DEFAULT_BRANCH_NAME: "update-readme-toc" - PR_DEFAULT_BRANCH_TITLE: "docs: update README TOC" diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml deleted file mode 100644 index b58e6be9..00000000 --- a/.github/workflows/triage.yml +++ /dev/null @@ -1,72 +0,0 @@ -name: Issue Triage - -# Auto-triages every opened issue with DeepSeek V4-Flash: applies a category label -# and posts a summary + possible-duplicate comment, acting as spotatui[bot]. No -# maintainer approval step — the run generates and applies in one pass. See -# .github/scripts/triage.mjs. - -on: - issues: - types: [opened, reopened] - workflow_dispatch: - inputs: - issue_number: - description: "Issue number to triage" - required: true - -permissions: - contents: read - -concurrency: - group: triage-${{ github.event.issue.number || github.event.inputs.issue_number }} - cancel-in-progress: false - -jobs: - triage: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - - uses: actions/setup-node@v4 - with: - node-version: 20 - - name: Mint spotatui[bot] token (issues:write only) - id: app-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - app-id: ${{ secrets.SPOTATUI_APP_ID }} - private-key: ${{ secrets.SPOTATUI_APP_KEY }} - permission-issues: write - permission-metadata: read - - name: Resolve issue number - id: n - env: - ISSUE_NUMBER: ${{ github.event.issue.number || github.event.inputs.issue_number }} - run: echo "number=${ISSUE_NUMBER}" >> "$GITHUB_OUTPUT" - - name: Gather issue + open issues - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - REPO: ${{ github.repository }} - N: ${{ steps.n.outputs.number }} - run: | - gh issue view "$N" --repo "$REPO" --json number,title,body > issue.json - gh issue list --repo "$REPO" --state open --limit 100 --json number,title > open-issues.json - - name: Triage with DeepSeek - env: - DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} - # tee also echoes the triage JSON into the step log for easy inspection. - run: node .github/scripts/triage.mjs | tee triage.json - - name: Apply label + comment as spotatui[bot] - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - REPO: ${{ github.repository }} - N: ${{ steps.n.outputs.number }} - run: | - node .github/scripts/triage.mjs --render | tee comment.md - gh issue comment "$N" --repo "$REPO" --body-file comment.md - labels=$(node -e 'process.stdout.write(require("./triage.json").labels.join(","))') - if [ -n "$labels" ]; then - gh issue edit "$N" --repo "$REPO" --add-label "$labels" - fi diff --git a/.gitignore b/.gitignore index a9d41d0e..90ab0255 100644 --- a/.gitignore +++ b/.gitignore @@ -3,15 +3,7 @@ .DS_store .env -.spotify_token_cache.json -.cached_device_id.txt -spotify-tui.sketch - -spt*.txt *.snap -.ci/lp-creds -snapcraft-login -secrets.tar *.swp tags @@ -24,6 +16,4 @@ tags # Local cargo config (platform-specific settings) .cargo/ -# build artifacts from the worker-relay subproject -worker-relay/node_modules/ /result diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 8592a5e6..00000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "spotatui.wiki"] - path = spotatui.wiki - url = https://github.com/LargeModGames/spotatui.wiki.git diff --git a/AGENTS.md b/AGENTS.md index b067d5c6..97daaeba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,607 +1,22 @@ -# AGENTS.md +# degen-radio contributor notes -This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. - -This file is maintained as three near-identical copies: `CLAUDE.md`, `AGENTS.md`, and -`.github/copilot-instructions.md` (only the opening lines differ). Edit all three together. - -## Build & Run +## Commands ```bash -# Full build (native streaming + audio viz + Lua scripting + OS integrations) +cargo check +cargo test cargo run - -# Slim build - no librespot/audio/scripting; fastest iteration, one of CI's seven legs -cargo run --no-default-features --features telemetry,tui - -# With the alternative sources (Local/Subsonic/Radio/YouTube/Qobuz). These are NOT -# in `default`, so a plain `cargo run` is Spotify-only; use the `all-sources` alias -# (or list them individually) to exercise the first-run source picker and playback. -cargo run --features all-sources -``` - -## CI Checks (run before opening a PR) - -```bash -cargo fmt --all -cargo clippy --no-default-features --features telemetry,tui -- -D warnings -cargo test --no-default-features --features telemetry,tui -``` - -These slim commands are the *fast local gate*, not the full picture. GitHub Actions -(`.github/workflows/ci.yml`) runs `check`, `test`, and `clippy` on `ubuntu-latest` -across a **seven-leg** feature matrix, plus one `macos-latest` job (below): - -| Leg | Features | -|-----|----------| -| `default` | a plain `cargo test`: streaming + audio-viz-cpal + scripting + self-update + OS integrations | -| `all-sources` | the **Linux** release feature set from `cd.yml` (adds cover-art, mcp-server, ai-dj, audio-viz, all five sources) | -| `mcp-only` | `telemetry,tui,mcp-server` | -| `ai-dj-only` | `telemetry,tui,ai-dj` | -| `slim` | `telemetry,tui` | -| `headless` | `telemetry` - one of two legs without `tui`. `mod tui` is feature-gated, so this leg turns any `crate::tui` import from core/infra/cli into a compile error - what keeps a second frontend from silently re-coupling to the terminal one | -| `headless-streaming` | `telemetry,streaming` - `check` + `clippy` only, no `test` job. Proves native-streaming startup (`runtime/streaming/`) and the player-event wiring type-check and pass clippy with no terminal frontend in scope. Its two entry points carry `allow(dead_code)` there, so the leg does not prove they are live | - -- `mcp-only` and `ai-dj-only` matter more than their size suggests: both enable - `dj-core` **without** `streaming` (a combination nothing else covers), and each - front door has to build without the other. -- Reproducing legs locally: `cargo test` reproduces `default`. For `all-sources`, - copy the exact `--no-default-features --features …` string out of `ci.yml` - - `cargo test --features all-sources` is **not** it (the alias only adds the five - sources on top of default), and the leg includes `audio-viz` (PipeWire), so it - only compiles on Linux. -- Every CI leg passes `--locked`; the local commands do not. Regenerate - `Cargo.lock` after any `Cargo.toml` edit or all legs fail at once. -- CI runs `clippy` on the **bin target only** (no `--all-targets`), so lints in - `#[cfg(test)]` code are not gated. Run `cargo test` to compile test code. A - test-only helper whose production caller is compiled out on some leg needs - `#[allow(dead_code)]`. -- The `all-sources` leg must stay in sync with `cd.yml`'s Linux release row - (macOS releases ship the same five sources but a different - backend/OS-integration set). -- One further job, `macos`, runs `check` + `clippy` (no `test`) on - `macos-latest` with cd.yml's macOS release feature set. It is the only leg - that compiles the `#[cfg(target_os = "macos")]` arms, the portaudio playback - backend, `macos-media`, and `audio-viz-cpal` - none of which the Linux legs - can speak for. No `test`: the suite is platform-independent logic the Linux - legs already run, and the device tests are `#[ignore]`d (CI runners have no - audio output). Keep its feature list in sync with cd.yml's macOS rows. -- A pull_request-only `Gates ratchet` job diffs `tools/gates.count` against the - merge-base (`tools/check_gates_ratchet.sh`): coupling counters may only fall; - the two adoption counters (`test_attribute_total`, - `action_refs_in_tui_handlers`) may only rise. `src/gates.rs` pins every value - exactly, so move the baseline in the same PR that moves the number, in the - ratchet's direction only. - -## Run a Single Test - -```bash -cargo test --no-default-features --features telemetry,tui -# Example: -cargo test --no-default-features --features telemetry,tui global_shift_w_adds_current_track_from_anywhere +spotatui radio list --json ``` -A filter that matches nothing still exits 0 - when running feature-gated tests in -the slim build, check the `N filtered out` count actually says your test ran. - ## Architecture -One cargo package: a `[lib]` (`src/lib.rs`, private modules, public API = -`run_cli`) plus two `[[bin]]` shims in `src/bin/` - `spotatui` (console) and -`spotatui-gui`, a placeholder behind the off-by-default `gui` feature that -exists to own the crate-root `windows_subsystem` attribute. Five top-level -units under `src/`: - -| Unit | Role | -|------|------| -| `core/` | Centralized state (`App`), the frontend-neutral tick scheduler (`driver/`), the shared action vocabulary (`action/`), config/state persistence, and the rspotify-free domain types (`plugin_api`, `pagination`, `source`) | -| `infra/` | Spotify Web API (`network/`), native librespot streaming (`player/`), alternative sources (`local/`, `subsonic/`, `qobuz/`, `radio/`, `youtube/`, `queue/`), audio viz (`audio/`), Lua scripting (`scripting/`), AI DJ + MCP (`dj/`, `mcp/`), OS integrations (Discord RPC, MPRIS, macOS/Windows media) | -| `tui/` | Terminal UI: the event/render loop (`runner.rs`), key plumbing (`event/`), per-block input handlers (`handlers/`), immutable draw fns (`ui/`) | -| `cli/` | clap subcommands: playback control, listening history, self-update, MCP relay, plugin management | -| `runtime/` | `mod.rs::run_cli` (entry point + CLI dispatch), `bootstrap.rs::boot` (frontend-neutral config/auth/`App` construction, `run_cli` its sole caller, plus the boot auth rule `spotify_auth_mode`: interactive only right after the client wizard or `--reconfigure-auth`, a subcommand needs a cached token, a UI launch tolerates no session), `cli.rs` (clap assembly + self-update), `pump.rs::start_tokio` (the IoEvent pump), `streaming/` (native-streaming startup every frontend shares: the pure saved-device decision in `mod.rs`, the librespot bring-up in `launch.rs`, gated on `streaming`), `startup.rs` (the UI-launch half, gated on `tui`) | - -### Data flow - -```text -crossterm thread (tui/event/) → runner.rs loop (draws the frame, then reads one event) - → runner::dispatch_key - exit prompt, ActiveBlock::Input, the configurable back key - → handlers::handle_app - plugin popup modal → help filter → global keybindings - → handle_block_events - dispatches to the per-screen handler - → app.dispatch(IoEvent) - hands async work to the pump in runtime/pump.rs - → source routers by URI scheme, then infra/network/ (Spotify) - → mutates App state; tui/ui/ re-renders from App on the next frame -``` - -`tui/event/` is only the crossterm→`Key` plumbing; the event loop itself is -`tui/runner.rs::start_ui`. Everything on a timer lives in `core/driver/` -(`Driver::tick`): the playback tick + debounced flushes, OAuth refresh, -Discord/MPRIS presence sync, the window title, lyrics + cover-art scheduling, -native-queue and decoded-source auto-advance, and `last_session.yml` -persistence. The runner draws frames, reads events, and calls `driver.tick` -with a `TickEnv` carrying the frontend-geometry inputs (visualizer bar count, -cover-art pixel support). A frontend that stops ticking is loud, not silent: -`App::playback_position_ms()` reads stale after 2s and the playbar says so. -Mouse input enters via `handlers::mouse_handler`. - -### The IoEvent pump: source routing, two lanes, an auth gate - -`runtime/pump.rs::start_tokio` drains IoEvents serially. Three structural gates, all -worth knowing before adding an event: - -- **Source routing**: non-Spotify playback is routed by URI scheme *before* the - Spotify handler, in this order: `route_queue_event` → `route_local_event` - (`file:`) → `route_subsonic_event` (`subsonic:`) → `route_qobuz_event` - (`qobuz:`) → `route_radio_event` (`radio:`) → `route_youtube_event` - (`youtube:`) → `Network::handle_network_event`. - This is what keeps `infra/network/` Spotify-only. -- **Claim gate**: before the routers, `start_playback_has_taker` drops a - `StartPlayback` whose URI scheme (`core::queue::queue_item_source`) names no - compiled-in source and that no Spotify session can take. The routers' - foreign-start teardown arms therefore only run for a real source-to-source - handoff. -- **Service lane**: `Network::runs_on_service_lane` lists events that run on a - detached task so slow, source-agnostic work cannot head-of-line-block the serial - pump. The service lane's `Network` is built with **no Spotify client** - adding a - `self.spotify()` call to a service-lane handler panics. -- **Auth gate**: `Network::event_bypasses_spotify_auth` lists events whose handlers - never need a Spotify session. -- **Replay**: `Network::event_is_transport` lists events that drive whoever owns - the sink. One held back by a rate-limit window is stamped with the - `PlaybackOwner` at deferral time, dropped at the flush when the owner changed, - and otherwise re-sent on the pump's channel so the routers see it. - A new IoEvent must be classified against all three lists. - -### Navigation / routing - -`App` holds a private navigation stack of `Route` values (`id: RouteId`, -`active_block: ActiveBlock`, `hovered_block: ActiveBlock` - there is no -`HoveredBlock` *type*). Invariants an agent cannot guess: - -- `push_navigation_stack(RouteId::X, ActiveBlock::X)` is a **no-op when the top - route already has that `RouteId`** - follow it with `set_current_route_state` - when focus must move anyway. -- `pop_navigation_stack()` refuses to empty the stack. -- The stack is private: go through `get_current_route` / `set_current_route_state`. - -### The `core/app/` module folder - -`App` was one 10,920-line file; it is now 37 files. The struct stays **flat** - all -171 fields declared once in `src/core/app/mod.rs` (27 of them feature-gated), plus -the 81 presentation fields grouped in `App.view` - and its ~270 methods are split -across 36 sibling modules by concern, each with its own `impl App` block. The -boundaries are organizational, not architectural. - -Rules when working in here: - -- **Import from `crate::core::app`, never the submodule path.** `mod.rs` re-exports - every module that declares public items, so `use crate::core::app::{App, RouteId, - ActiveBlock}` works no matter which file an item lives in. -- **Child modules open with `use super::*;`** and declare no other top-level - imports; all external imports live in `mod.rs`. When a type is only importable - under a feature that does not gate the function, use a **function-local** `use` - instead of adding a top-level import. -- **A private helper called from a sibling module needs `pub(super)`**; one reached - from `tui/` or `infra/` needs `pub(crate)`. Private *fields* need nothing - they - are declared in `mod.rs` and visible to all descendants. -- **A field with no writer outside `core/app/` is private.** `pub_fields_on_app` - counts the `pub` ones and may only fall. An outside reader gets an intent-named - getter (`status_message()`, `api_error()`); a handler mutates shared state only - through an `App` method or `app.apply(Action::…)`, never through a field write. -- **Feature-gate the method body, not the call site**, when a predicate must exist - in every build: `#[cfg(any(...))] { … } #[cfg(not(any(...)))] { false }` inside - one ungated `fn` (see `queue_owns_playback`), so callers never need their own `#[cfg]`. -- New `impl App` methods go to the concern module that owns that state, not `mod.rs`. -- **Presentation state lives in `App.view: ViewState`** (`core/app/view.rs`): cursor - and selection indices (the track table, search, Recently Played and sidebar - cursors included), scroll offsets, edit buffers, focus, popup flags, the help - pager, the terminal viewport. Handlers and draw functions write `app.view.` - freely and the handler-write ratchet skips those chains. A producer outside `tui/` - and `core/app/` (a network or source handler, a script effect, the CLI) that - resets or clamps a cursor is counted by `view_writes_outside_tui`, which may only - fall: a producer that replaces a list resets its cursor through an `App` method - (`set_track_table`, `clamp_search_cursors`), never by writing `view` itself. A - new field goes in `view` only if it is presentation state; a pending operation, - or anything a second frontend would also need, stays on `App`. -- `dispatch` pins the global loading spinner; long work with its own progress - surface uses `dispatch_without_spinner`. - -Tests are colocated (`#[cfg(test)] mod tests`) in most - not all - modules. Shared -fixtures are `pub(super) fn`s in `test_support.rs`, imported as -`use crate::core::app::test_support::*;`. - -### Playback ownership - -Multiple players share one UI, and the predicate order is the #1 source of -regressions. Check in this order: `queue_owns_playback()` / -`queue_now_is_spotify()`, then `active_decoded_source()`, then -`is_native_streaming_active_for_playback()`. `App::playback_owner()` folds them -into one `PlaybackOwner`, and the transport chains (play/pause, -next, previous, shuffle, repeat, volume) end on `dispatch_spotify_fallback`, -which answers "Nothing is playing" instead of a Spotify dispatch when no -session exists. - -- Starting a decoded source (Local/Subsonic/Qobuz/Radio/YouTube) only **pauses** - librespot - the native flag stays true, so driving librespot directly resumes - the wrong player. -- A decoded start claims the sink (`App::claim_decoded_sink`) before it pauses - librespot, and `active_decoded_source()` reads the claim: the owner is - `Decoded` from the first line of the start, through a failed start or a lost - output device, until the source's queue runs out (the driver's teardown, the - queue's exhausted-context resume) or an explicit Spotify start (a URI or a - context) reaches `Network::start_playback`, which releases it. A bare resume - never releases it, so a media key or Space during a download cannot resume - librespot. -- While the native queue slot owns the sink, `current_playback_context` names the - *suspended* context's track. Inside `core/app/`, resolve the playing *track* - through `App::playing_item()` (`core/app/playback_routing.rs`): it answers - with the slot's `TrackInfo` (`uri`, `album_id`, `artist_refs`; a slot has - no play context) and refuses a decoded owner. Outside `core/app/` read the - `PlaybackSnapshot` (`infra/media_metadata.rs`), never the field: - `direct_playback_context_reads` counts every other reader and may only fall. -- Radio is in `active_decoded_source` but deliberately out of - `active_queueable_decoded_source` (repeat/shuffle) and - `active_source_position_ms` (seek). - -### Native streaming (feature `streaming`, in `default`) - -`StreamingPlayer` (`src/infra/player/`) embeds librespot as a Spotify Connect -device; a supervisor does bounded in-place reconnects without dropping the audio -pipeline, escalating to full backend replacement with parked-request replay. -Durable intent (recovery snapshot, parked `StartPlayback`, client-side shuffle -session) lives in `src/core/app/native_{backend,recovery,shuffle}.rs`. - -- It rides our **maintained librespot fork**, published on crates.io as - `spotatui-librespot-*` and consumed through `package =` renames, so imports - stay `librespot_*`. The fork carries upstream backports *and* a fork-only - `SessionDisconnectReason` API the app depends on - it cannot be swapped for - upstream 0.8. All seven crates are `=`-pinned in lockstep; bump them together - when the fork publishes a new version. -- Direct spirc `load` is the **primary** route for all native starts, context ones - included - a `me/player/play` round trip would head-of-line-block the serial - pump (#386). The Web API route (`start_native_context_via_api`) is a fallback - only for context starts the direct load rejected or the watchdog is replaying. - A native URI-list start must never go through the Web API. -- Anything that replaces or drops a `StreamingPlayer` must call - `player.shutdown()` first; the Connect device id is persisted in - `/device_id`. Both exist to stop ghost Connect devices (#297). -- Session teardowns are classified by librespot's disconnect reason, never - inferred: external handoff → rebuild idle, unexpected → restore playback, - local → stop. The handoff veto is sticky (#437). -- Every background native write is generation-guarded - (`native_playback_generation`, `native_shuffle_generation`) and event handlers - confirm `Arc::ptr_eq` against the current player before writing - stale writes - from a replaced backend are the recurring bug class. -- librespot reports full `spotify:track:` URIs while app state uses bare - base62 ids: normalize with `base62_id_of` at the event boundary - (`spotify:local:` URIs stay whole). -- Verify native playback changes with the full `cargo run` build, not only the - slim telemetry build. - -### Listening Party / sync - -`src/infra/network/sync.rs` is pure WebSocket transport (`SyncMessage`, -`PartyConnection`); lifecycle logic lives in `src/infra/network/mod.rs`. Handlers -dispatch `StartParty` / `JoinParty` / `SetPartyControlMode` / `LeaveParty`; -`SyncPlayback` is fired by `App::on_tick` every 2s while hosting, not by a handler. - -## Key Conventions - -### Adding a new screen - -See `.claude/skills/add-tui-screen/SKILL.md` for the six-place checklist. - -### Dispatching network calls - -Call `app.dispatch(IoEvent::…)` from a handler - never call async Spotify code -directly from handlers or UI code. Draw functions take `&App` and must not mutate. - -Inside the network layer, never call rspotify client methods for Spotify data: -use `self.spotify_api_request_json(...)` / `self.spotify_get_typed::(...)` -from `src/infra/network/requests.rs` (pacing, 401 cooldown, payload -normalization). rspotify supplies OAuth/PKCE, id types, and models only. Spotify -401s are deliberately tolerated (consecutive-failure threshold, forced-refresh -cooldown) - don't "fix" that by escalating on first failure. - -### The shared Action vocabulary - -`src/core/action/` holds `Action` (one enum of frontend-neutral state -changes), `App::apply` (the single write path, in `core/action/apply.rs`), -and `ActionOutcome`. Producers that are not the network layer's own result -handling mutate `App` via `app.apply(Action::…)`: the Lua scripting engine -drains its queued actions into it, the mutating DJ/MCP tools call it -directly, and TUI handlers adopt it as the conversion sub-PRs land -(`action_refs_in_tui_handlers` may only rise). Rules: - -- No rspotify type and no raw `IoEvent` payload in `Action` - payloads are - strings, scalars, and `core::plugin_api` snapshot types. Address by - identity (URIs, ids, names), never by list ordinal. -- Every arm delegates to the same ownership-aware `App` method the - equivalent keybinding uses. Playback starts go through - `App::start_playback_uris` / `start_playback_context` / - `start_playback_track_in_context` - never a hand-built - `IoEvent::StartPlayback` in an arm. -- No catch-all match arm under `src/core/action/` or in `tui/keymap.rs`: - both deny `clippy::wildcard_enum_match_arm` and - `clippy::match_wildcard_for_single_variants` (a named binding like - `_other =>` counts as a wildcard; `matches!` and `Option`/`Result` - scrutinees are exempt), and `wildcard_arms_in_action_tree` pins the - action tree at 0 by a raw text scan that includes tests, comments, and - string literals. CI clippy never compiles tests, so write a test - catch-all as `_other =>` and only on a `Result`/`Option` scrutinee. -- Every `Action` variant has an arm in `tui/keymap.rs::default_binding`, - naming the key or gesture that produces it; a variant no gesture produces - is `Exposure::Unbound("reason")`. A new variant is a compile error until - it has an arm, and a test failure until `sample_actions()` in the same - file has a value for it (an `Unbound` one also goes into the `UNBOUND` - pin, which a producer scan of `src/tui/` checks). Feature-gate an arm's - body, never the arm: clippy skips a match when any arm carries a `#[cfg]`. -- `Action` derives serde (the future frontend wire shape); a payload type - added to it must stay serde-derivable. - -### Paginated results - -Page caches are `ScrollableResultPages>` (domain types from -`core/pagination.rs` - no rspotify `Page` in `App` state; conversion lives in -`infra/network/mapping.rs`): - -- Insert with `upsert_page_by_offset` - never `add_pages`, which repoints the - visible index to the tail. Key and dedupe by `page.offset`. -- Caches may be sparse: next/previous targets adjacent *offsets*, not index ±1. -- Background prefetch carries a generation snapshot, re-checks it (plus table - identity) after every await, and writes via `set_*_to_table_continuous()` - - never by appending into `track_table.tracks`. -- When a page is already cached, render it synchronously from app state instead - of routing through another async event. -- For playlist track tables, `playlist_track_table_id` is the table identity; - `active_playlist_index` is sidebar selection state only. - -This generation-guard pattern is repo-wide: every background write into `App` -re-checks its generation/epoch (`dj.generation`, `native_shuffle_generation`, -`liked_state_epoch`, …) so stale tasks cannot write into a reloaded view. - -### Status messages - -In sync handlers/UI use `app.set_status_message(msg, ttl_secs)`, or -`app.set_error_status_message(...)` for errors - errors block normal messages -until they expire, so use the right one. TTLs are seconds, scaled by the user's -`status_message_ttl_percent`. In the async network layer use -`self.show_status_message(msg, ttl_secs).await`, which takes the `App` lock and -calls `set_status_message`. The message fields are private: `status_message()` -and `status_message_is_error()` read them. - - -### Errors - -`App::handle_error(e)` records the message in `api_error`, stamps a 60s -lifetime on it, and pushes `RouteId::Error`. That route is a *presentation -hint*: the terminal frontend draws it full-screen, another frontend may render -`api_error` as a toast and ignore the frame entirely. Four rules: - -- Dismiss with `app.clear_api_error()`, never by clearing the string or popping - the route by hand. It drops the message, its lifetime, and every frame still - showing it together - a cleared string under a surviving frame renders as an - error page with nothing on it. `pop_navigation_stack` already calls it when - the frame it pops is `Error`. -- Nothing else clears `api_error`. `update_on_tick` retires it once the - lifetime passes, handing the text to the status bar only when the error frame - is the current screen, so a frontend with no dismissal gesture does not latch - the first failure forever. The CLI never ticks, so its latch is intact. -- A non-empty `api_error` is the CLI's only failure signal for every subcommand - that reaches the bottom of `handle_matches` (`src/cli/handle.rs`; the two - `share-*` flags return early and bypass it). Moving a call site off - `handle_error` turns a failing CLI command into exit 0 unless that site is - provably unreachable from the CLI. -- Demote a site to `set_error_status_message` only when all three hold: it - fires from the tick or a self-refreshing retry loop (so every retry restamps - the lifetime and the backstop can never win), it is provably unreachable as a - CLI exit signal, and the failed operation is bookkeeping rather than the thing - the user asked for. `flush_state_save` is the only site that qualifies today, - and it latches its report to once per failure run - repeating it at the retry - rate would hold `status_message_is_error` and silently drop every ordinary - status message for the rest of the session. - -### Dialog state cleanup - -Close dialogs with the single call `app.clear_dialog_state()` (clears `dialog`, -`confirm`, `pending_keybinding_persist`, and playlist-picker state). Do not -hand-clear the fields. Popping the nav stack is a separate step. - -### User-configurable keybindings - -Check `app.user_config.keys.` instead of hard-coding key literals for -global actions (`handle_app` in `src/tui/handlers/mod.rs`); -`common_key_events::{up,down,left,right}_event` extend this to per-screen -navigation. Adding a binding means fields on both `KeyBindings` and -`KeyBindingsString` in `src/core/user_config.rs`, a `help_entries` row in -`src/tui/keymap.rs` with its `Requirement`, and, when the key produces an -`Action`, that variant's `default_binding` arm pointing at the new field. - -### Requirements - -`core/requirement.rs` says what a row or key needs (`Requirement::{None, -SpotifySession, Capability(_), Source(_), AnyOf(_)}`) and `App::availability(req)` -answers for the active source and the session. Three tables carry it: the -sidebar (`library_row_requirements()` in `core/app/library.rs`), the help menu -(`help_entries()` in `tui/keymap.rs`) and the settings rows -(`setting_requirement(id)` in `core/app/settings_schema.rs`). The rule on every -surface: a row with a rebindable key stays visible with the `hint()` as a -suffix, a row with a fixed key or a sidebar row is hidden. Gate a key on the -same predicate the row uses, so the two cannot disagree. - -### Config & on-disk files - -Five files, five owners - a value that changes as the app runs goes in state, -never config: - -| File | Owner | Contents | -|------|-------|----------| -| `config.yml` (config dir) | `core/user_config.rs` | hand-editable settings, keys, theme | -| `client.yml` (config dir) | `core/config.rs` | Spotify app credentials | -| `state.yml` (state dir) | `core/state.rs` | machine-written runtime values | -| `last_session.yml` (state dir) | `core/persisted_playback.rs` | non-Spotify playback + native queue | -| `qobuz_credentials.yml` (config dir) | `infra/qobuz/auth.rs` | the Qobuz login token (feature `qobuz`) | - -- All paths resolve through `core/paths.rs`, never `dirs::` directly. -- `state.yml` saves are read-modify-write **sparse patches** so a second running - instance is never clobbered; never write a whole snapshot. -- Sensitive writes go through `core::auth::write_private_file_atomic`; - `config.yml` can carry plaintext secrets, so never log the serialized config. -- `UserConfig::save_config()` regenerates only `behavior`/`theme`/`keybindings`; - `plugin_commands`, `format`, and `tables` are passed through from disk. -- Adding a `behavior.*` setting = edits in `core/user_config.rs` - (`BehaviorConfigString`, `BehaviorConfig`, `UserConfig::new`, - `load_behaviorconfig`, `save_config`) **plus** matching arms in - `core/app/settings_schema.rs` and `settings_apply.rs`, coupled only by the raw - `"behavior."` string id - a typo silently drops the write. A row that - only works with Spotify also needs a `setting_requirement` arm. - -### Feature flags - -- `default` = `telemetry, tui, streaming, audio-viz-cpal, macos-media, - windows-media, mpris, discord-rpc, self-update, scripting`. Notably **not** - in default: `cover-art` (so a plain `cargo run` has no album art, though - every shipped binary enables it), the five sources, and the DJ features. -- `tui` gates `mod tui` and owns the terminal-only crates (ratatui, crossterm, - tui-bar-graph, colorgrad - the last also pulled by `art-decode` for the - adaptive-theme HSV math). `gui` is a reserved placeholder that only gates the - `spotatui-gui` bin shim. -- Cover art is two features: `art-decode` is the frontend-agnostic decode half - (`dep:image`, fills `core::art::CoverArtStore`, feeds the adaptive theme; - never enabled by hand); `cover-art` layers the ratatui-image terminal - rendering on top and keeps its pre-split meaning for users and CI. -- `audio-viz` (PipeWire, Linux-only) and `audio-viz-cpal` are **visualizer - capture** backends, not playback. The librespot *playback* backend is chosen by - `[target.'cfg(...)']` dependency blocks in Cargo.toml, not by the `*-backend` - features: linux-gnu→alsa, linux-musl→rodio, Windows→rodio, macOS→portaudio - - the non-default picks avoid librespot's `pipe` sink writing raw audio to stdout - and destroying the TUI. -- `audio-decode` (rodio) is the shared engine pulled in by the sources; - `all-sources` = `local-files, subsonic, internet-radio, youtube, qobuz`. -- `dj-core` is a shared implementation feature pulled in by `mcp-server` and - `ai-dj`; none of the three are in `default`, and neither front door may assume - the other (or `streaming`) is present. -- `scripting` (mlua Lua plugins) is default-on and gates `src/infra/scripting/` + - `src/cli/plugin.rs`; the slim gate never compiles it, so verify scripting - changes with a default `cargo test`. - -### Adding a feature-gated sidebar row - -See `.claude/skills/add-tui-screen/SKILL.md`. - -### Domain-specific conventions - -`src/infra/dj/CLAUDE.md` (DJ lanes/guards/avoid-library filter), -`src/infra/mcp/CLAUDE.md` (MCP server), `src/infra/scripting/CLAUDE.md` (Lua -plugins), and `src/cli/CLAUDE.md` (CLI subcommands) load automatically when -working in those directories. - -### Alternative sources (Local / Subsonic / Radio / YouTube / Qobuz) - -- All five decode through **one** shared rodio sink, `LocalPlayer` - (`src/infra/audio/player.rs`) - the only file where rodio types appear. - Subsonic and YouTube download each track to a `NamedTempFile` first (YouTube by - shelling out to `yt-dlp`); Radio streams through a non-seekable ring buffer. -- Qobuz (`src/infra/qobuz/`) plays each track through the web player's - encrypted CMAF stream while it downloads: `stream/progressive.rs` is a - `stream-download` source that yields the decrypted segments (and restarts at - a seek), rebuilt as a FLAC `NamedTempFile` the session keeps. The fetch runs - off the pump behind a `fetch_id` guard; a superseded stream is dropped, which - cancels its download. The transport (`sign.rs`, `stream/`) is pure and unit - tested. The three web-player constants are scraped at runtime (`auth.rs`), - cached in `state.yml`, and overridable through `SPOTATUI_QOBUZ_*` env vars; - they are never embedded. Failures are status messages, never `handle_error`. -- All three platforms play decoded sources. macOS was gated off in - `LocalPlayer` until rodio 0.22 / cpal 0.17 were measured on CoreAudio; the - SIGSEGVs behind that gate (#9/#20) were librespot's own rodio-backend, which - is why librespot still uses portaudio-backend there and this player does not. -- Losing the output device has **two** shapes and only one is an error. cpal - reports the device being *removed* (`DeviceNotAvailable`); it cannot report the - common case - the OS moving its **default output** elsewhere (headphones out, - AirPods in the case), which leaves the stream bound to a device nobody hears. - So `LocalPlayer` both raises a `lost` flag from its own cpal error callback - *and* remembers the device name it opened, comparing it against the current - default in `device_lost()`. It then *refuses* `play_file`/`play_prepared`/ - `stop`/`seek`: those wait on rodio's audio callback with no timeout, and they - run on the serial pump, so blocking one wedges the whole app rather than just - falling silent. Every such wait also goes through `bounded()`, which re-asks - the device every 3s rather than timing out blind - a dead device and a source - stalled on the network are indistinguishable from the caller's side, and - Qobuz's stream stall alone is 60s. - The driver's tick polls `device_lost()` (one device read a second), calls - `LocalPlayer::recover_device()` to rebuild on the new default device (a - spaced, bounded retry - a Bluetooth output takes seconds to come back), and - restages the track: it sets the session's `resume_at` (the seek, and paused - only when `device_removed()` says cpal saw a *removal* or the session was - already paused - a default that merely moved means the user plugged - something in and keeps playing) and dispatches `ReplayCurrentTrack`. The - path that stages the track applies `resume_at` itself - (`infra::queue::restage`), so no `Seek` or `PausePlayback` is ever queued - blind behind work that ends in `play()`. That recovery must stay *before* - every advance block: a dead sink never drains, so `is_finished()` is false - and would otherwise read as a still-playing track. The queue slot has no - replay event: a removal clears `queue_slot_desired_playing`, which the - paths that stage the next item and the suspended context's resume read, - and a device given up on hands the slot to `FinishNativeQueue` (the same - teardown a drained queue runs) rather than dropping `queue_now`. Every - `LocalPlayer` wait runs off the `App` lock (`stop_detached`, - `stop_detached_holding`, `spawn_blocking`): the runner takes that lock on - every frame. -- Repeat/shuffle for decoded sources live in the pure module - `src/infra/queue/mod.rs` (`advance_decision`, `resume_index_after_queue`, …); - state is player-global on `App` (`decoded_repeat`, `decoded_shuffle`). - Repeat-one affects auto-advance only, never a manual skip. Note - `resume_index_after_queue` returning `None` means "context exhausted, tear - down" - the *opposite* of `advance_index`'s `None` ("clamp, no-op"). -- Set the `advancing` flag synchronously before dispatching a track change: the - sink is empty for the whole decode/download, and the tick would otherwise - re-fire auto-advance and skip several tracks. -- Per-source playback state is one `Option` field on `App` - (`local_playback`, …), published only on success; position/pause are read live - from the player at render time. -- `App.active_source` (`core/source.rs`) is **browse scope only** - it never - changes playback routing, so switching sources must not interrupt playback. -- OS integrations (MPRIS, SMTC, macOS Now Playing, Discord RPC, window title) all - read one `PlaybackSnapshot` from `infra/media_metadata.rs`, which checks - decoded sources first so the paused Spotify track is not published. -- Radio tune-in has **three** unbounded steps, all on the serial pump, and all - three are capped in `infra/radio/stream.rs`: connect (`CONNECT_TIMEOUT`), - response headers (`HEADER_TIMEOUT`), and the format probe (`PROBE_TIMEOUT`). - The probe is the subtle one - rodio's symphonia scans for a start-of-stream - marker and a live stream never ends, so a codec it does not register scans - forever. Its `AdtsReader` claims only MPEG-4 ADTS (`ff f1`), not MPEG-2 - (`ff f9`), which much European radio uses. On timeout the fix is to call - `OpenedStream::cancel` - stopping the *download*, not the reader: a probe - waiting for bytes parks inside `read`, so a flag checked between reads is - never seen, while cancelling marks the stream done, wakes every waiter, and - lets the probe thread and its download go. Only `prepare_stream` runs inside - the timed closure: `timeout` abandons a `spawn_blocking` closure but cannot - stop it, and the radio player is shared, so a probe that matched just after - the deadline would otherwise append the new station to the live sink. The - `play_prepared` happens after the timeout check. -- YouTube is unofficial-fragile by design: when it breaks, the fix is a newer - `yt-dlp`, not a spotatui release. One in-repo mitigation: a failed download - retries once through the embedded player clients (`web_embedded,tv_embedded`), - which PO-token enforcement leaves tokenless for embeddable videos - most - label uploads. A non-embeddable gated video still fails. - -### Testing conventions +- `src/radio_app/config.rs`: compatible loading of saved radio stations and volume. +- `src/radio_app/directory.rs`: radio-browser.info search with mirror failover. +- `src/radio_app/stream.rs`: HTTP/ICY stream reader and metadata extraction. +- `src/radio_app/player.rs`: bounded decoded-audio output engine. +- `src/radio_app/mpris.rs`: Linux MPRIS server and OpenUri client. +- `src/radio_app/tui.rs`: terminal rendering, input, playback state, and MPRIS routing. +- `src/radio_app/cli.rs`: `spotatui radio list|play` protocol used by the Omarchy plugin. -- Tests are colocated (`#[cfg(test)] mod tests`) - there is no `tests/` dir. The - only dev-dependency is `tempfile`: HTTP tests bind a real `127.0.0.1:0` - listener into an injected base-URL field; UI tests use ratatui's `TestBackend`. -- `App::new` is `#[cfg(test)]`-only (production uses `App::new_with_state`). - `App::default()` has no IoEvent channel and `dispatch` silently drops events - - tests asserting on IoEvents use the house pattern - `fn app_with_x() -> (App, Receiver)` and keep the receiver alive. -- `IoEvent` derives nothing (not even `Debug`): assert with - `assert!(matches!(rx.try_recv(), Ok(IoEvent::X(..))))`. -- Never `std::env::set_var` in a test (one process, shared threads); split the - env read into a `*_with(value, …)` function and test that. -- Prefer extracting the decision into a pure function or plain-data enum taking - scalars instead of `&App`/`Network` - the house style for testing without a - Spotify client, audio device, network, or real clock. -- Gate whole test modules with `#[cfg(all(test, feature = "…"))]` where needed. -- Test names are behavior sentences without a `test_` prefix - (`enter_on_stats_entry_opens_stats_screen`). -- Shared fixtures: `crate::core::test_helpers` crate-wide; - `crate::core::app::test_support` inside `core/app/`. +Keep stdout machine-readable for `radio list --json`. Diagnostics belong on stderr or in the state log. diff --git a/CHANGELOG.md b/CHANGELOG.md index fec4650f..e811f921 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,1524 +1,9 @@ # Changelog -## [Unreleased] +## 0.43.0-radio.1 -### Added - -- **Optional theme-aware cover-art dithering** (`cover-art` builds): Enable it in Settings or with `behavior.cover_art_dither: true` to render two-color art in the playbar, full-screen view, and plugin widgets. Stucki is the default algorithm; Bayer 8×8 and Atkinson are also available, with a pixel scale from 1 to 3. The accent follows the current theme by default, or can be set independently with `theme.cover_art_dither_color`. Terminal cover-art requirements still apply. - -### Fixed - -- **Spotify can no longer take over during a source's start, or after its output device is gone**: a Local Files, Subsonic, Internet Radio, YouTube or Qobuz start pauses Spotify first and publishes its session only when the download, the stream probe or the decode succeeds, which is seconds to minutes. In that window, and after the source lost its output device, spotatui believed nothing owned the output: a media key or Space resumed the paused Spotify track on top of the incoming one, and a rate-limited Spotify command that had waited out its window ran against whoever played by then. A source now claims the output before it pauses Spotify, keeps the claim through a failed start or a lost device, and releases it when its queue runs out or when you start a Spotify track or context yourself; until then media keys, Space and the Spotify transport do nothing to Spotify. Commands held back by a rate limit remember who was playing and are dropped when that changed, a queued seek is dropped when its player is gone instead of hitting the next one, a volume change made under another source no longer replays at Spotify later, a seek under a queued Spotify track measures against that track, the media Stop key pauses Spotify instead of unloading it, and the token refresh timer stays quiet while another source plays and backs off for 15 s after a failure instead of retrying every tick and pinning an error page over the playback. - -## [v0.42.0] 2026-09-08 - -### Changed - -- **Your own Spotify app now comes first**: Spotify rate limits are per app, and the shared ncspot client ID that the quick setup uses is counted against every ncspot and spotatui user at once, so playlists stopped loading and the log filled with `429 Too Many Requests` no matter what spotatui itself did. Option 2 of the setup wizard (`spotatui --reconfigure-auth`) now writes your own app as the primary `client_id` and keeps the shared id only as the fallback for a failed login; before, it was the other way round and your own app was never used while the shared one still answered. A `client.yml` from an older version is read the new way round, but your app still needs one login: the first launch after the upgrade runs on the shared id and says so until you run `spotatui --reconfigure-auth` once. When a session runs on the shared id, the rate-limit status message and the log say so and name `spotatui --reconfigure-auth` as the way out, the wizard's text says which option shares its limit, the wizard asks for the port before it prints the Redirect URI to register, and the login's wait for the browser callback gives up after three minutes instead of holding startup forever. A rate limit on the plan check at startup no longer switches native streaming off for the session: the check waits for the window and asks again. - -- **Status messages and errors share the bar more predictably**: every status message the network layer shows now lasts as long as `status_message_ttl_percent` says, the way the in-app ones already did, and none of them overwrites an error that is still on screen; the error stays until its own time is up. A normal message shown right after an error is no longer drawn in the error colour. - -- **GitHub releases list every download as a link**: the release page now carries direct download links for each binary instead of bare file names, adds the Debian/Ubuntu `.deb` packages (amd64 and arm64) to the list, and takes its notes from the merged pull requests instead of an excerpt of this file. - -### Added - -- **Qobuz source** (`--features qobuz`, included in the Linux, macOS, and Windows release binaries): spotatui can now play your Qobuz library. Pick Qobuz in the first-run picker or the `d` menu and log in through the browser; the token is saved in `qobuz_credentials.yml` (owner-only on Unix), and `SPOTATUI_QOBUZ_TOKEN` overrides it. The sidebar lists your favorite tracks, playlists, and favorite albums, search finds tracks, and every playback feature the other sources have works: next/previous, seek, repeat and shuffle, the cross-source queue, `last_session.yml` resume, cover art, lyrics, and OS media controls. Playback uses the same encrypted stream the Qobuz web player uses: each track is decrypted and rebuilt as a FLAC temporary file while it plays through spotatui's own engine (playback starts after the first segments arrive, and a seek past the downloaded part continues the download from there), all off the event pump so the UI stays responsive. `behavior.qobuz_quality` picks the stream (5 MP3 320, 6 FLAC 16/44.1, 7 FLAC 24/96, 27 FLAC 24/192; default 6). The constants the API needs are read from the Qobuz web player at runtime and cached by bundle version, never embedded; `SPOTATUI_QOBUZ_APP_ID`, `SPOTATUI_QOBUZ_APP_SECRET`, and `SPOTATUI_QOBUZ_OAUTH_KEY` override them when Qobuz changes its web player. - -- **Fuzzy filter for the Settings screen**: Press the search key (`/` by default) in Settings to filter the rows instead of scrolling for one. The query is a fuzzy subsequence, so its characters only have to appear in order: `volinc` finds **Volume Increment** and `skms` finds **Seek Duration (ms)**. Rows match on their name and on their `config.yml` key, and are ranked best-first with the matched characters highlighted, so every row the filter leaves shows why it is there. `Enter` applies the filter, `Esc` clears it before it means "leave Settings", and the filter survives `←`/`→` so one query can be walked across tabs. It reuses the Help menu's filter input, so the editing keys (`Ctrl+W`, `Ctrl+U`, `Backspace`) are the same on both screens ([#469](https://github.com/LargeModGames/spotatui/issues/469)). - -- **Tokyo Night theme**: `preset: "Tokyo Night"` under `theme:` in `config.yml` (or the Theme row in Settings) switches every colour to the [Tokyo Night](https://github.com/folke/tokyonight.nvim) palette, next to the existing presets. - -- **Adaptive theme from the cover art**: with `behavior.cover_art_theme: true` (the **Adaptive Theme** row in Settings; off by default), the accent colours follow the album that plays. The two dominant colours of the cover become the active border, banner, hover and selection colours, the playbar progress, the highlighted lyric line and the visualizer bars, fading in over under a second at every track change and fading back to your preset when you switch it off. Text, background, hints and error colours stay as the preset sets them so nothing becomes unreadable on a washed-out cover. The cover is fetched for its colours even when the art pane is hidden. Only in builds with the `cover-art` feature, which every release binary has. - -- **cava visualizer**: the analysis screen's default visualizer is now a port of [cava](https://github.com/karlstav/cava)'s engine, running inside spotatui with no cava install needed. Bars are drawn in eighth-blocks as a mirrored stereo layout with the bass meeting in the middle, and the bar count follows the terminal width. `V` on the analysis screen cycles between it and the Braille bar graph; in `config.yml` the styles are `Cava` and `BarGraph`, and a file that still says `Equalizer` or `Classic` loads as `Cava`. - -### Fixed - -- **Your own Spotify app works again for search, follow, and playlists**: since March 2026 Spotify runs Development Mode apps (every app you create yourself; the shared ncspot client ID is exempt) against a smaller API. The search endpoint caps `limit` at 10, while spotatui sized every search request by the terminal height, up to 50, so with your own client ID every search on the search screen and in the add-to-playlist picker ended in `Spotify API 400 Bad Request ... "Invalid limit"`. Search requests are now capped at 10 results per category; the library pages keep their 50, and `spotatui search --limit` and the DJ's `search_tracks` tool accept 1 to 10. The artist follow check that runs after every search, follow and unfollow of an artist, creating a playlist, removing a track from a playlist, and the DJ's Liked Songs check, playlist crawl and queue-by-URI lookup still used endpoints Spotify removed for those apps (`me/following`, `users/{id}/playlists`, `playlists/{id}/tracks`, `me/tracks/contains`, `tracks?ids=`), which answered `403 Forbidden`; each now uses its replacement (`me/library`, `me/playlists`, `playlists/{id}/items`, one `tracks/{id}` per track), and a failed follow check is logged instead of taking over the screen. The artist screen's top tracks and related artists, Discover, and Recommendations have no replacement for Development Mode apps yet. - -- **A Listening Party can no longer start Spotify on top of another source**: with Local Files, Subsonic, Internet Radio, YouTube or Qobuz playing, native Spotify streaming is only paused, and the party relay (a guest following the host's state, a host running a guest's command) still picked the Spotify player from its device identity. It transferred and activated the Connect device and loaded the host's track into it, or paused, skipped and seeked the paused player, so Spotify audio started on top of the track you were hearing. Every Spotify playback call now checks who owns the output first and does nothing while another source plays (switching Spotify to the spotatui device says so; switching it to another device still works), and the party ignores messages until Spotify plays again. The host publishes only a Spotify track it plays itself: while it plays another source or the cross-source queue it sends nothing instead of the suspended Spotify track, and a guest ignores a state it cannot play instead of pausing or resuming its own music to match. Party commands now run through the same path as a keypress. - -- **The jump and add-to-playlist keys follow the track that plays**: with a Spotify track playing from the cross-source queue, jump to album (`a`), jump to artist (`A`) and add the playing track to a playlist (`w`/`W`) acted on the track of the *suspended* Spotify context, so they opened the album, the artist and the picker for a song you were not hearing; they now use the queued track itself, and jump to context (`o`) says the queue slot has no play context. Under Local Files, Subsonic, Internet Radio, YouTube or Qobuz those five keys (`a`, `A`, `o`, `w`, `W`) say they need a Spotify track playing instead of acting on the suspended one, and with nothing playing the three jump keys say so instead of doing nothing. Like (`F`) already followed the queued track and keeps doing so; it now shares the same resolution. - -- **Spotify's rate limit no longer crashes spotatui at startup**: with a cached login, startup verifies the token with one `/me` request, and that request went straight through rspotify with none of the pacing and `Retry-After` retries every other Spotify call gets. A 429 there ended the process with `Error: http error: status code 429 Too Many Requests` before the UI existed, and relaunching only added more unpaced hits on a rate limit that is shared by everyone using the same client ID. The check now goes through the same paced, retrying request path as the rest of the app, and when Spotify still answers with anything other than a rejected token (a rate limit that outlasts the retries, an outage, no network) spotatui keeps the cached token and starts instead of quitting; the native-streaming account check asks again with its own retries ([#504](https://github.com/LargeModGames/spotatui/issues/504)). - -- **Logging in to Spotify no longer fails with "Unable to connect" when the browser was not already running**: both login flows (the startup wizard and `d` then Spotify in the app) opened the browser first and only then started listening for the OAuth redirect on `127.0.0.1:8989`. On Linux desktops where `xdg-open` runs the browser in the foreground (Hyprland and other sessions it does not recognise), a browser that was not already open kept that launch call from returning until the browser was closed, so the listener never started and the redirect landed on a closed port; inside the app, the network layer was stuck for the same time. The listener is now bound before the browser opens, and every browser launch (Spotify login, Qobuz login, the listening recap) is detached so a launcher that waits can never stall spotatui. - -- **The sidebar, the help menu and Settings only offer what your source can do**: without a Spotify session the Library panel used to be hidden under every other source, which also hid Friends and Stats even though neither needs Spotify, while under the Spotify scope it kept offering Liked Songs, Albums, Artists, Podcasts, Discover and Recently Played, each ending in the "Spotify not connected" message. The panel is now drawn under every source with the rows that work: Friends and Stats always, Local Files and AI DJ when built in, and the six Spotify rows only with a Spotify session; a mouse click on the panel lands on the row you see. The help menu (`?`) applies the same rule: a rebindable key that needs Spotify or another source stays listed with a `(needs Spotify)` / `(not for Local Files)` / `(Internet Radio only)` suffix, and a fixed key of a screen your source cannot reach is left out. Settings hides the Spotify-only behavior rows (the playback poll, the liked and episode icons, the saved-library sort orders, the community pin) without a session, marks the Spotify-only key rows the same way, offers only `home` and `stats` as startup routes without a session instead of five screens that would open empty, and names the `d` row "Switch Music Source" to match the help menu. The copy-song-URL and copy-album-URL keys now follow the track that plays, not the browse scope: a Spotify track playing under another source still copies, and with nothing playing both keys say so instead of doing nothing. The search key without a Spotify session says so too, instead of opening an input that cannot search. - -- **Disconnecting headphones no longer freezes spotatui**: with a decoded source playing (Local Files, Subsonic, Internet Radio, YouTube, Qobuz), unplugging the output device — Bluetooth headphones disconnecting, a USB DAC pulled — left the audio engine waiting on an audio callback that would never run again. Because that wait happens on the same serial queue every other request goes through, the whole app stopped responding: playback was silent *and* searches, browsing, and playback controls stopped working until restart. The engine now notices the device is gone - both when it is unplugged and when the system simply moves its default output elsewhere, which nothing reports - and refuses those calls instead of waiting, then rebuilds the output on the new default device and puts the track back where it was. Unplugging pauses it there, the same thing macOS does when AirPods come out; plugging something in keeps it playing, now through the device you just connected. Radio stops instead (a live stream has no position to return to), and a track playing from the queue resumes at the next queued item ([#496](https://github.com/LargeModGames/spotatui/issues/496)). - -- **A radio station spotatui cannot decode no longer freezes the app**: picking certain stations played nothing, showed no error, and left the app unusable — the music already playing kept going, but search and the playback controls stopped responding until restart. Starting a station ends with working out its audio format, and that search reads the stream until it recognises something; a live stream never ends, so a station in a format the decoder does not know left it reading forever, blocking everything else. Tune-in is now bounded like the connection and header steps already were: it gives up, stops the download, and says the format was not recognised. Common on Italian and other European stations, which often broadcast a variety of AAC the bundled decoder does not identify — those still will not play, but they now fail in a second or two and leave the app working ([#497](https://github.com/LargeModGames/spotatui/issues/497)). - -- **The extra music sources now play on macOS**: Local Files, Subsonic, Internet Radio, YouTube, and Qobuz all decode through one shared audio engine, and that engine refused to open an output device on macOS, so picking any of those sources answered "No audio output for local playback" instead of playing. The refusal was inherited from crashes in librespot's own older audio backend ([#9](https://github.com/LargeModGames/spotatui/issues/9)/[#20](https://github.com/LargeModGames/spotatui/issues/20)) and never applied to this engine, which is a different, newer output path; measured on CoreAudio, it plays. All five sources now work on macOS and are included in the macOS release binaries. Native Spotify streaming is unaffected: it keeps its own portaudio backend. The macOS media keys and Now Playing now work for these sources without a Spotify login. - -- **Local files: nested music folders are now browsable**: the local-files library only listed the immediate subfolders of `behavior.local_music_path` and only counted the audio sitting *directly* inside them, so a library organised one level deeper — `Artist/Album/…`, or a downloader's `Jamendo/Playlist - Fresh & New/…` — showed those container folders with a `0` track count and an empty track table when selected. The scan now walks the tree up to eight levels deep: every folder that actually holds audio becomes a playlist, named by its path relative to the music root (`Jamendo/Playlist - Fresh & New`), while folders that only contain other folders are no longer listed at all. Linked folders are walked once, hidden entries are skipped in both the folder count and the track table, and a folder that cannot be read is skipped instead of failing the scan, so neither a link loop nor one permission-denied folder can hide the rest of the library. - -- **Self-update now works on Linux ARM64**: the checksum verifier only knew four platforms, so `spotatui update` aborted with "unsupported platform" on aarch64 Linux (Raspberry Pi, ARM servers, Asahi) even though every release publishes `spotatui-linux-aarch64.tar.gz`. The platform table now covers all five published targets, with a test pinning it against the release workflow ([#440](https://github.com/LargeModGames/spotatui/issues/440)). - -- **A malformed keybinding no longer crashes spotatui at startup**: a binding like `ctrl-`, a bare `ctrl`, or a multi-key suffix like `ctrl-ab` in `config.yml` panicked (or was silently mis-read) during config load, before the UI existed. It is now a logged config warning naming the offending shortcut, that binding keeps its default, and the app starts normally, like every other invalid config value ([#441](https://github.com/LargeModGames/spotatui/issues/441)). - -- **The Stats screen can be chosen as the startup screen from Settings**: `startup_route: "stats"` always worked when typed into `config.yml`, but the in-app Settings cycle never offered it. The cycle now matches the full startup-route list, and a test keeps the two from drifting again ([#443](https://github.com/LargeModGames/spotatui/issues/443)). - -- **Plugin keybindings can no longer silently shadow remove-from-queue**: the collision check that rejects plugin commands bound to named action keys was missing `remove_from_queue` (default `x`), so a plugin bound to `x` loaded without a warning and fought the queue view for the key. It is now rejected with the same collision warning as every other named action ([#445](https://github.com/LargeModGames/spotatui/issues/445)). - -- **Native streaming no longer steals playback back after a Spotify Connect handoff**: session teardowns are now classified by librespot's disconnect reason instead of inferred from connection state. When playback moves from spotatui to a phone, car, or another device, the rebuilt Connect backend stays idle: the published queue slot and any parked playback request are cleared instead of replayed, the dead queue slot no longer owns the playbar and transport controls, recovery re-registration no longer transfers playback off a device that took over, and the "Playback moved to another Spotify device." message is no longer overwritten by a bogus "recovered" notice. Unexpected shutdowns still restore playback, including a queue playing over an idle app, and local shutdowns stay stopped ([#437](https://github.com/LargeModGames/spotatui/issues/437)). - -- **Spamming Previous on the first track no longer bricks native streaming**: under native streaming, pressing Previous within the first 3 seconds of the first track of a context (or after skipping back through every earlier track) told librespot to go to a track that does not exist, and librespot answered by *stopping* playback: audio went silent, the progress bar froze, and Play, Pause, and Seek could not bring it back until a track was started by hand or playback was transferred from another device. That is upstream librespot behaviour, so the fix lives in our maintained fork (0.8.2): Previous with nothing before it now restarts the current track, the way Spotify's own clients do, and a seek made on the player also updates the position librespot uses for its 3-second rule, so a second press right after a restart goes to the previous track instead of restarting again ([#366](https://github.com/LargeModGames/spotatui/issues/366)). - -- **`nix build` and `nix run` download crates again**: since 2026-08-27 every crate fetch in the flake build failed with HTTP 403, on released tags too. crates.io now refuses the deprecated `api/v1/crates///download` endpoint to the fetcher nixpkgs uses, and the nixpkgs revision pinned in `flake.lock` (March 2026) downloaded every crate through it. The pin moves to current `nixos-unstable`, whose `importCargoLock` fetches from `static.crates.io` instead, and the Nix toolchain goes from Rust 1.93 to 1.97 with it. That nixpkgs (26.11) also dropped x86_64-darwin, so the flake no longer declares that system; Intel Mac users keep the release tarball, which is still built ([#493](https://github.com/LargeModGames/spotatui/issues/493)). - - -- **spotatui starts and plays without a Spotify login**: a launch used to open the browser for a Spotify login whenever no token was cached, even for someone who had picked Local Files, Subsonic, Internet Radio, YouTube or Qobuz and never wanted one, and a cancelled login was written to `state.yml` so the next launch asked again. The browser now opens only right after the setup wizard or `--reconfigure-auth`; a plain launch loads the cached token silently and starts without one, and `d` then Spotify logs in from inside the app. A CLI subcommand without a token stops with "Spotify is not connected. Start `spotatui`, press `d` and pick Spotify to log in." instead of opening a browser. Without a session, the transport keys say "Nothing is playing" instead of sending commands to a Spotify that is not there, starting a track from a source the binary was built without says which feature is missing instead of tearing down what plays, and starting or joining a Listening Party says Spotify is not connected instead of hanging the app. On Windows, the media keys and the system media overlay now work for the decoded sources without a Spotify login. - -- **A Spotify rate limit no longer stalls the other sources**: a `429 Too Many Requests` used to be retried in place, sleeping on the same serial queue every other request goes through, so a saturated shared client ID delayed a Qobuz or Local Files track change or skip by up to half a minute and put the error page up. Spotify's `Retry-After` is now remembered, requests that would hit Spotify inside that window are queued in order and released when it ends, and the bar says "Spotify rate limit: waiting 30s" once. The playback poll also stops while another source owns the sink, so it no longer spends the rate limit for nothing. - -- **The error screen no longer latches for the rest of the session**: an error, once shown, stayed recorded forever; leaving the screen by pressing the search key rewrote the error frame into the search input, after which no later error could render at all. Errors now expire after a minute (an expired error still on screen hands its text to the status bar), Esc and the back key dismiss the frame and the message together, and the search key on the error screen dismisses it first. A failed `state.yml` save is a status-bar message instead of the error page, reported once per failure run rather than twice a second. - -- **`cargo install spotatui` gets the maintained librespot fork**: the fork was pulled in through a `[patch]` section that cargo drops when a crate is published, so a crates.io install silently built against upstream librespot 0.8.0 and missed every fork fix (the CDN 530 fallback, dealer reconnects, the hardened OAuth listener). The fork is now published as the `spotatui-librespot-*` crates and pinned directly, so every install method builds the same player. - -- **A symlinked `config.yml` keeps its link, and a read-only one no longer aborts startup**: config files managed by home-manager, stow or chezmoi are symlinks into a store that may be read-only, and every write replaced the link with a plain file or, for a read-only target, crashed at boot. Writes now follow the link and swap the file at its target, a dangling link gets its target created, and a write into a read-only store fails with an error and leaves the link alone. The global song counter prompt, the first-run source picker and the Subsonic setup warn that the answer could not be saved and carry on (the prompt names `enable_global_song_count` as the key to set by hand); the settings editor still shows the error and applies the change for the session ([#512](https://github.com/LargeModGames/spotatui/issues/512)). - -- **A queued Spotify track no longer goes silent at its end**: under native streaming, when a track playing from the cross-source queue ended, librespot looked for the next track of a context it had never loaded, found none and stopped the player, so the next queued item never played; the playback poll also redrew a paused playbar over queued playback it could not see. The fork (0.8.3) now tells the queue's loads apart from its own context, and the poll no longer marks queued playback paused. - -- **The CLI works again when no Spotify device is active**: `spotatui playback --play`, `--pause`, `--next`, `--previous`, `--seek`, `--shuffle`, `--repeat`, `--volume` and `spotatui play` all failed with `404 NO_ACTIVE_DEVICE` as soon as the TUI had exited, which is the CLI's normal state. On that answer, each command now retries once on the device saved by `spotatui set-device`, which also activates it; a failed retry shows the original error. `play --queue` cannot be retried this way, since queueing cannot activate a device ([#464](https://github.com/LargeModGames/spotatui/issues/464)). - -- **Plugins and agents respect who owns playback**: a Lua plugin's `set_repeat` reached the real Spotify device while the cross-source queue or a radio station owned the sink, changing the repeat mode of a player that was not on screen; it now gets "Repeat does not apply to this source" like the key does ([#376](https://github.com/LargeModGames/spotatui/issues/376)). The MCP and AI DJ `skip_track` tool went straight to Spotify as well, skipping the wrong player while the queue or a decoded source played; it now takes the same path as the next-track key. - -- **YouTube downloads retry through the embedded player**: YouTube's PO-token enforcement makes most label uploads fail with a 403 on the default player clients, so those videos would not play. A failed download is retried once through the `web_embedded` and `tv_embedded` clients, which still serve embeddable videos without a token; a video that is both token-gated and not embeddable still fails, with the original error. - -- **AI DJ no longer crashes when the config directory cannot be written**: the DJ's agent CLI ran in a scratch directory under the config dir and failed outright when that could not be created, as in a Nix sandbox or a read-only home. It now falls back to a fresh, owner-only directory under the system temp dir, and a failed launch of the agent command names the directory and the OS error instead of implying the binary is missing ([#478](https://github.com/LargeModGames/spotatui/issues/478)). - -## [v0.41.0] 2026-08-10 - -### Added - -- **MCP server: drive spotatui from your coding agent, with no API key** (`--features mcp-server`): spotatui can expose your player and listening history as [Model Context Protocol](https://modelcontextprotocol.io) tools, so Claude Code, Codex, Gemini CLI, or any MCP client can act as your DJ — reading what you have been listening to, searching the catalogue, and queueing tracks. It rides the agent subscription you already have, so there is no key to configure and no LLM client compiled into spotatui. Setup is one line handed to your agent: point it at [`docs/mcp-setup.md`](docs/mcp-setup.md), which is written as instructions for an agent to follow, and `spotatui mcp status` gives it a safe, machine-checkable probe of each step (never run `spotatui mcp` to test — that command *is* the server and blocks on stdin). Off by default: set `behavior.mcp_enabled: true`, and the socket binds loopback-only behind a token in `~/.config/spotatui/mcp.json`. Written against protocol revision `2026-07-28` — which removed the `initialize` handshake and made MCP stateless — and **dual-era**, so it also answers `initialize` for the clients shipping today, which would otherwise fail outright against a modern-only server. Ask an agent for "songs like X" and a good recommender names tracks you already own, so `search_tracks` marks every result `[owned]` or `[new]` — Liked Songs plus playlists you own or collaborate on — which reaches the agent exactly when it is choosing, with no extra round trip. `queue_tracks` also takes `exclude_owned: true` for an agent that wants the guarantee rather than the hint; it reports what it skipped and substitutes nothing. Neither is on by default: an agent told to queue one specific track you happen to own should get that track, not an explanation. - -- **Agent plugin: connect your agent to the MCP server, with a DJ skill, in one install**: `agent-plugin/` packages the MCP server registration together with a `spotatui-dj` skill that teaches the agent how to DJ — read the listening history first, search before queueing by name, queue rather than interrupt. It ships in both the vendor-neutral [Agent Plugins 1.0](https://agent-plugins.org) format and Claude Code's plugin format from the same directory, and installs from this repo's marketplace (`/plugin marketplace add LargeModGames/spotatui`, then `/plugin install spotatui@spotatui`). It replaces the client-registration step of [`docs/mcp-setup.md`](docs/mcp-setup.md) only; the binary still needs `--features mcp-server` and `behavior.mcp_enabled: true`. These are *agent* plugins for AI clients, unrelated to spotatui's in-app Lua plugins. - -- **AI DJ inside spotatui** (`--features ai-dj`): a DJ screen (Ctrl + J) with a chat prompt, continuous auto-queue (Ctrl + T), an "only tracks I don't already have" filter (Ctrl + O), and a vibe shift (Ctrl + Y) that drops the DJ's own queued tracks so a change of direction is audible now rather than six tracks from now. It **holds a conversation and drives the same tools an MCP client does**: a turn is up to four steps, and each step the model can search the catalogue, read your queue, queue tracks, or simply reply — so it can ask what you actually want before it plays anything, and answer a question without queueing at all. The tool list is the MCP server's own table rather than a copy, so the two front doors can never drift apart in what they can do. Every tool call is shown in the transcript as it runs, and the `…thinking (2/4)` row keeps a slow turn distinguishable from a hang, which matters because each step of an agent-CLI turn is a fresh subprocess. The auto-queue refill and the vibe shift deliberately get two steps and may not stop to ask anything: nobody is watching the screen for either. The brain is pluggable: a **locally installed agent CLI** (`claude`, `codex`, `agy` for Antigravity, `copilot` for GitHub Copilot, `opencode`, or anything headless, with no API key), the Anthropic Messages API, or any OpenAI-compatible endpoint including **local models** via Ollama and LM Studio. The first time you open it, the DJ asks which of your installed agents to use and which of that agent's models, because the default backend spends the coding subscription you already pay for and the heaviest model exhausts a Claude Pro plan in a handful of turns; the answer reaches an agent CLI as that CLI's own flag (`claude --model haiku`, `agy --model gemini-3.6-flash-low`) and the API backends as a model id priced per token, and Ctrl + G reopens the picker at any time. Only aggregate names from your local listening history ever reach a model (no identifiers, no timestamps), and suggestions the catalogue cannot confidently match are dropped rather than approximated, so an invented track never becomes a near-miss in your queue. This is spotatui's own DJ rather than Spotify's, which cannot be started through the public API ([#196](https://github.com/LargeModGames/spotatui/issues/196)); it also sidesteps Spotify's restriction of `/recommendations` to apps registered before 2024-11-27 by having the model do the recommending. See [`docs/ai-dj.md`](docs/ai-dj.md). - -- **Help search highlights its matches**: While filtering the Help menu (search key, `/` by default), every occurrence of your search terms is now highlighted in the visible rows, so you can see at a glance which part of a row matched. Highlighting follows the same smart-case rule as the filter itself ([#408](https://github.com/LargeModGames/spotatui/issues/408)). - -- **Generated app state now uses XDG state/cache directories**: `config.yml` stays in the app config directory for user-authored settings, while runtime-managed state (`state.yml`), listening history, last playback session, and Spotify token caches move to the app state directory. Native streaming credentials and audio cache move to the app cache directory. Existing config-dir runtime fields, radio favorites, listening history, playback-session files, Spotify token caches, and legacy native streaming credentials and audio cache are migrated on first use when the new target path does not already exist, preserving free-source startup, existing in-app radio favorites, Spotify login sessions, and native streaming setup during upgrade. If a new state/cache target already exists, the legacy file or directory is left in place instead of being merged or overwritten. -- **Community playlist pinned in the sidebar**: A shared, Discord-curated Spotify playlist now sits pinned at the top of your Spotify playlists, so you can open it in one keystroke without following it first. It is toggleable from Settings → Behavior (`behavior.pin_community_playlist`, default on), and a one-time prompt on first launch explains that you add songs to it with the `/queue` command in the spotatui Discord and lets you hide the pin. The pin disappears automatically once you follow the playlist yourself, so it never shows twice. The `+ Add Playlist` button also moved to the top of the sidebar and is now clickable with the mouse. - -### Changed - -- The `Logging to: …` startup notice now goes to **stderr** instead of stdout, so piping a subcommand's output (`spotatui history recap > file`) no longer captures it. stdout is program output; this matters most for `spotatui mcp`, where the MCP spec requires that nothing but protocol messages reach stdout. - -### Fixed - -- **Lyrics fall back to a fuzzy search when the exact lookup misses**: spotatui asked LRCLIB only through `/api/get`, which is an exact signature match: title, artist, and duration (in whole seconds) all have to agree with LRCLIB's record. A small metadata difference, a duration off by a second or a slightly different title spelling, returned a 404, and the track showed "No lyrics for this track" even when LRCLIB clearly had it. A miss now falls back to the fuzzy `/api/search` endpoint, and among its hits spotatui prefers timestamped lyrics over plain ones, then the result whose duration is closest to the playing track so the synced timestamps line up ([#410](https://github.com/LargeModGames/spotatui/issues/410)). -- **Lyrics now load for collaborations**: A track credited to more than one artist (e.g. "Take Me Back" by Kygo and Max McNown) showed "No lyrics for this track" even when LRCLIB clearly had it. LRCLIB indexes each track under a single artist string, almost always the primary one, but spotatui sent the full joined credit ("Kygo, Max McNown") to both the exact `/api/get` and the fuzzy `/api/search` endpoint, so every collaboration missed. The playback snapshot now carries the artist names as a structured list instead of one pre-joined string (which also makes MPRIS `xesam:artist` a real array), and the LRCLIB lookup falls back to the primary artist alone when the full credit finds nothing ([#410](https://github.com/LargeModGames/spotatui/issues/410)). -- **Pausing a natively queued Spotify track actually pauses it**: With spotatui as the playback device, pressing play/pause on a track played through the native queue (or from a raw track list like Liked Songs) flipped the playbar to paused while the audio kept playing, and resume was broken the same way. Those tracks are loaded directly into the player rather than through a Spotify Connect context, and the pause/resume commands were routed only to the Connect layer, which silently ignores them for tracks it did not start itself. Play and pause now also drive the player directly, so they always act on whatever is audibly playing. This same routing gap made several internal safeguards silent no-ops (the guard that stops stray Spotify audio from playing over a queued track, and the preemptive pause during queue handoff at end of track), so ghost audio and occasional stuck auto-advances around the native queue should be resolved as well. -- **Transient native 403 "Restriction violated" no longer throws the full-screen error**: With spotatui as the playback device, an intermittent Spotify `403 "Player command failed: Restriction violated"` could take over the screen during ordinary playback while the native Connect session was still settling, most visibly when the shuffle command that follows a successful play was rejected even though the music had already started. A rejected post-start shuffle toggle is now a status message instead of the Error screen (and shuffle state is no longer recorded as applied when the toggle was refused), and a transient restriction violation on other player commands de-escalates to a status message while native activation or recovery is pending. A genuine, steady-state restriction still surfaces ([#424](https://github.com/LargeModGames/spotatui/issues/424)). -- **Streaming login survives a browser's stray connection**: The native-streaming consent hands callback port 8989 to librespot, whose callback server accepted exactly one connection and gave up if it was not the redirect. Some browsers, LibreWolf in particular, open a connection without writing to it (or send a bare CRLF) before the real callback arrives. librespot consumed that, failed to parse it, and dropped the listener, so the redirect carrying the login code hit a closed port: the browser showed "unable to connect", and because the flow errored before credentials were saved, it repeated on every launch, which is the "streaming cookie does not get cached" several reporters described. spotatui's own Web API callback server never had this problem, which is why Web API login worked on the same machine where streaming login did not. Fixed in the pinned librespot fork: the accept loop keeps listening across unrelated connections under an overall deadline, and a captured code is no longer discarded when the success page fails to write. The port probe that precedes the login is now advisory rather than fatal as well: it works by binding and releasing, so the port is unowned between its release and librespot's own bind and the probe can never be authoritative. A busy port is logged and the login goes ahead, leaving librespot's bind as the real answer, instead of refusing an attempt that would probably have succeeded ([#414](https://github.com/LargeModGames/spotatui/issues/414); the same root cause behind [#234](https://github.com/LargeModGames/spotatui/issues/234) and [#364](https://github.com/LargeModGames/spotatui/issues/364), and upstream [librespot#1705](https://github.com/librespot-org/librespot/issues/1705)). -- **Startup no longer deadlocks when an update installs during login**: The auto-update check ran concurrently with authentication and re-executed the new binary the moment an install succeeded. That re-exec blocks its own task, so the authentication future it was joined with stopped being polled while still holding the OAuth callback port, and the child process, which repeats startup from scratch, could not bind that port. The parent waited on the child, the child waited on a port the parent would never release, and both fought over the same terminal. The update check still overlaps authentication, since that is a network round trip worth overlapping; only the restart moved, to after both have finished. Authentication persists its token before returning, so the child reuses it instead of opening a second browser login, and the restart still runs ahead of a propagated auth error so a broken auth state can restart into the newer build that may fix it. -- **Log files land somewhere you can open on Windows**: The log path was hard-coded to `/tmp/spotatui_logs/`, and the help screen and docs repeated that literal string. On Windows that is a drive-relative path the user's own shell cannot resolve, printed directly above the line inviting them to report a bug, so anyone asked for a log file had to guess where it went. The path now resolves through `std::env::temp_dir()`, so Windows lands in `%TEMP%` and POSIX platforms keep `TMPDIR` or `/tmp`, with a single helper owning it so the location shown is always the location written. Logs stay in the temp directory rather than moving to the state directory, because a file is written per process id and temp is the one place the platform clears on its own; the directory is now created with `0700` permissions instead of the default mode, since it sits in a shared location under a predictable name where any other local user could read it. - -## [v0.40.3] 2026-07-27 - -### Added - -- **Lua scripting API v6: cover art and composable layouts in plugin screens**: Custom plugin screens are no longer limited to a vertical stack of paragraphs, lists, and gauges. A new `{ type = "cover_art" }` widget draws the current track's artwork from the image spotatui has *already* downloaded and decoded, so a plugin never re-downloads it and never writes terminal graphics escapes itself; the app keeps ownership of the protocol, resizing, placement, and cleanup, and the widget works for every source that provides art. New `row` and `column` containers nest to build grids, and every widget accepts a per-axis size hint (`height`/`width` in cells, or `height_percent`/`width_percent`), so "cover art beside synchronized lyrics" is now a handful of lines — see the new `examples/plugins/now-playing.lua`. Paragraphs gained `scroll = false` so a fixed header stays put while `PageUp`/`PageDown` scroll the rest. All v5 widget tables keep working unchanged ([#396](https://github.com/LargeModGames/spotatui/issues/396)). - -- **Searchable Help menu**: Press the configured search key (`/` by default) while viewing Help to filter keybinding rows across descriptions, keys, and contexts. Multiple terms are matched together using smart-case matching; press `Enter` to apply the filter and `Esc` to clear it. - -- **Lyrics view: smooth scrolling, browsing, and a timing nudge**: The lyrics view now glides between lines with a short eased animation instead of snapping. Scroll with `↑`/`↓` (or the mouse wheel) to read ahead or behind: auto-follow pauses while you browse (the browsed line is highlighted and a hint row appears) and resumes with `f`, `Esc`, or automatically after 8 seconds without input. `Ctrl+d`/`Ctrl+u` page five lines at a time and `Ctrl+a`/`Ctrl+e` jump to the first/last line. For LRC files whose timestamps do not line up with your audio, `→`/`←` nudge the lyric timing half a second earlier/later per press; the current offset is shown in the block title and resets on track change. The title also notes "(timing estimated)" when LRCLIB only had plain lyrics, so synthesized line timings are no longer presented as real ones. - -- **Banner Gradient toggle**: A new setting (Settings → Theme → Banner Gradient, or `behavior.banner_gradient` in `config.yml`) turns off the home banner's animated RGB gradient and draws it in the theme's banner color instead. The Terminal (ANSI) preset defaults the gradient off so the banner follows the terminal palette out of the box — tools like pywal recolor it live along with the rest of the UI; an explicit `banner_gradient:` in the config overrides the preset default either way ([#336](https://github.com/LargeModGames/spotatui/issues/336)). - -- **Shuffle and repeat for Local Files, Subsonic, and YouTube**: The shuffle (`Ctrl+s`) and repeat (`Ctrl+r`) keys now work when one of the decoded sources owns playback, not just on Spotify. Repeat cycles Off → All → Track exactly as it does for Spotify: **All** wraps the context from the last track back to the first, **Track** replays the current song. Shuffle reorders in place and keeps the currently playing song playing (it becomes the new first track), so toggling it never interrupts audio; turning it back off restores the original order and keeps your place in it. Both modes, plus the shuffle order itself, survive a restart along with the rest of the playback session in `last_session.yml`. The modes are also routed through MPRIS, so `playerctl shuffle`/`loop` and desktop media controls drive them. Internet radio and native queue slots have no track queue of their own, so their playbar and MPRIS snapshot drop the shuffle/repeat controls entirely rather than showing inert ones — which is why the default `format.playbar_status_source` template gained self-contained `{shuffle}`/`{repeat}` segments (see `docs/configuration.md`). - -- **Spotify-style shuffle for native streaming**: When shuffle is on and you start a playlist, album, or Liked Songs with spotatui as the playback device, the app now owns the shuffled play order itself instead of leaving it to Spotify. It shuffles the tracklist once (the song you picked plays first and everything else follows exactly once per pass), and on a queue suspend/resume it reloads that same order rather than reshuffling, so interrupting playback with the native queue no longer repeats songs you already heard. The full playlist or Liked Songs context loads in the background (capped at 3000 tracks) so playback starts instantly, turning shuffle off restores the original order and keeps your place, and each repeat-all lap reshuffles for a fresh order like the Spotify client does. - -- **Global Song Counter toggle**: The anonymous global song counter can now be turned on and off at any time from Settings → Behavior, and the worldwide count refreshes live when you enable it. The first-run opt-in prompt also moved ahead of the source picker so the choice applies to whichever source you pick, and it now keys off the absence of `client.yml` (the picker's own first-run signal) rather than the contents of `config.yml`, so a fresh login asks again instead of silently inheriting a stale setting. - -### Changed - -- **Plugin screens: a `gauge` now honors its `height`**: `docs/scripting.md` has always documented that a widget's `height` takes exactly that many rows, but gauges silently ignored it and were pinned to 3 rows. They now take the height they ask for, keeping 3 rows as the default when none is given. A plugin that already set `height` on a gauge will see that gauge change size. - -### Fixed - -- **Cover art no longer sticks on the previous album with native streaming**: With spotatui as the playback device, the cover pane kept showing the last album for seconds after a skip, and for a track started from the native queue it showed the wrong art for the entire song. The art was read from the polled Spotify context, which lags behind a skip and never reports a natively queued track at all - those play through a direct `player.load` that Spirc does not surface - so the pane stayed pinned to whatever the context still held. librespot already hands over the album art in the very `TrackChanged` event that starts the song, so that art now wins. Each URL it carries is already the same resolved `i.scdn.co` form the Web API returns, so the cover-art cache key stays stable when the polled context later catches up and takes over. The context item is still used when librespot supplies no covers at all, which is the normal case for local files, whose art is embedded in the file ([#402](https://github.com/LargeModGames/spotatui/issues/402)). - -- **Plugins: `spotatui.get_lyrics` no longer returns the previous track's lyrics**: Calling it from a `track_change` handler - the obvious place to call it - delivered the outgoing track's lyrics and spent the one-shot callback, so a plugin's lyrics never updated on skip. The runner ticks the script engine before its shared track-change detector, so at that moment `lyrics_status` was still `Found` for the old track and the request resolved synchronously against it. The immediate-delivery path now also checks that the stored lyrics belong to the item playing right now, falling back to waiting for the in-flight fetch when they do not. - -- **No more "Access token missing" error screen between songs**: With playback on an external Spotify Connect device, a track transition could throw up the full-screen error route with a 401 `Access token missing` from `me/player`, even though the music never stopped and the very next poll succeeded with the same token. Spotify's player service intermittently rejects a valid token, and spotatui amplified it: every 401 forced a full token refresh (which rotates the PKCE refresh token, so a whole new token family was minted every few seconds), the single retry fired immediately and landed back inside the same failure window, and the playback poll then treated the result as fatal. Forced refreshes are now rate limited to one per 30 seconds and a 401 inside that window is retried with the token already in hand, the post-401 retry backs off first so it clears the transition, and the playback poll tolerates a short run of 401s before surfacing anything. Every non-2xx Spotify response is now logged with its endpoint, status, body, and the age of the token that was attached (never the token itself), so the remaining server-side behaviour is diagnosable from a log. Startup now also says which Spotify app the session actually signed in as: the setup wizard makes the shared ncspot client ID the primary for *both* of its options and keeps your own app as the fallback, so "I set up my own app" has never meant "I am using my own app" — and nothing said so ([#395](https://github.com/LargeModGames/spotatui/issues/395)). -- **Native shuffle: the queue resumes at the right track**: With shuffle on and spotatui as the playback device, finishing a queued track jumped playback back to an already-played song, or replayed the current one from the beginning, instead of advancing. The client-side shuffle session's position was never updated: librespot 0.8 reports full `spotify:track:` URIs while the index sync compared bare track ids, so the session index stayed frozen at its starting value and every queue resume targeted the second shuffled track. Track ids are now normalized once at the player-event boundary, which also stops the self-advance guard needlessly reloading every queued track, stops the global song count double-counting, and removes redundant playback polls. A playlist whose background tracklist fetch fails no longer strands the session on a single track: the fetch is retried, the failure is surfaced, and a queue drain falls back to resuming the Spotify context instead of dead-ending in "Queue finished". Disconnect recovery likewise converts a pending shuffled resume to the context route (using the context captured at suspension time) instead of silently dropping the queued tracks, and playback restored at startup (which has no server-side context) resumes from the recovery snapshot after a queued song instead of stopping at "No active playback" ([#385](https://github.com/LargeModGames/spotatui/issues/385)). -- **Native playback starts no longer freeze behind a stalled Spotify API call**: Starting a playlist or album on the native device went through a `me/player/play` Web API round trip on the serial event pump, so one stalled call (30 s timeout, retried) froze every queued playback command for 25 s or more on a degraded connection. Context starts now use the direct librespot load first (context resolution happens inside the player, off the pump), with the Web API route kept as a watchdog-covered fallback for loads the player rejects or that fail silently, so a bad session recovers in seconds instead of freezing the app. Liked-state lookups (the heart icons) no longer run on that pump either: opening a large playlist used to queue 8-15 sequential `me/library/contains` calls that delayed a skip-to-queued-track by ~20 seconds; a single detached worker now resolves them in the background, coalescing rapid navigation into one deduped sweep and never clobbering a like you toggled while it was reading ([#386](https://github.com/LargeModGames/spotatui/issues/386)). -- **Startup login no longer hangs**: On a fresh install or with a stale token, the auth wizard's "Waiting for authorization callback" step ran a blocking callback server on the async runtime, which could park a worker thread and hang startup before the browser redirect was ever served. The wizard now uses the same async callback server as the in-TUI login ([#364](https://github.com/LargeModGames/spotatui/issues/364)). -- **First-run double login completes without a restart**: On a fresh first run, both OAuth consents (the Web API login and the native-streaming "Spotify for Desktop" login) use callback port 8989 back-to-back, and the second could fail because the first server hadn't fully released the port — forcing a quit-and-relaunch to finish the streaming login. The streaming flow now waits for the port to be free before opening its consent, so both logins succeed in one run. -- **Playback keys pressed during streaming recovery are no longer lost**: If native streaming is mid-recovery when you hit play, the request is stashed and replayed once the session is back (with a 30s recency gate), instead of being silently swallowed. Transfer/activate errors are surfaced, and pressing play with no usable backend shows a status message instead of the Error screen. -- **Home banner animates again**: The banner gradient's animation tick was gated on the Home block having keyboard focus, which was almost never the case; it now runs whenever the Home screen is displayed. -- **Playlist sidebar robustness**: Refreshing playlists no longer clobbers the folder you have open or your selection, and a playlist list that fails to paginate fully keeps the previous complete list instead of silently publishing a truncated one. Playlist folders also reconcile correctly after deferred streaming startup. -- **Stale cover art and lyrics races**: Cover-art and lyrics results are now dropped unless they match the currently playing track, so a slow fetch can no longer overwrite the display with data for a previous song. -- **Native streaming survives a dropped connection**: Losing the network - a Wi-Fi blip, a laptop suspend, an access point handing the session off - killed the librespot session, and playback stayed dead until you restarted spotatui. The bundled librespot fork now carries the in-place dealer reconnect from [librespot-org/librespot#1692](https://github.com/librespot-org/librespot/pull/1692) and exits its spirc task promptly on a session TCP loss, and spotatui reacts to that exit rather than sitting on a dead session: it rebuilds immediately when idle, and defers until the buffered audio actually stalls when a song is playing, so a brief blip never interrupts the music. A published queue slot survives the reconnect window instead of being skipped past, a pause you made before the drop is honoured through it, and the stall watchdog no longer rebuilds the backend when a URI list simply runs out with repeat off ([#388](https://github.com/LargeModGames/spotatui/pull/388)). -- **Per-song cloud sync silently stopped firing**: With `behavior.sync_token` set, listening history was meant to upload each song as it finished; instead nothing was sent until you quit, which made quitting slow while the whole session went up in one batch. The history collector read the sync token once through a `try_lock`, and startup pre-work added later (the recap check and the listens-file load) pushed that read into the live render loop, where tokio's fair mutex hands a freed permit straight to a queued waiter - so the `try_lock` intermittently lost and pinned the token to `None` for the rest of the process. The collector now takes the lock properly and re-reads the token every tick, so pasting or clearing it in Settings takes effect mid-session, and startup logs whether cloud sync is enabled. The listens-file parse moved off the runtime worker, and the exit path restores the terminal before its network calls and bounds each one separately, keeping the now-playing clear strictly last so an upsert cannot undo it ([#393](https://github.com/LargeModGames/spotatui/pull/393)). -- **A failing audio backend no longer takes playback down**: If the output device could not be opened or a write failed - a misconfigured ALSA setup, a device that disappears mid-session - the sink error propagated into librespot's player thread and killed native playback with nothing on screen to explain it. The sink now absorbs start and write failures and keeps consuming packets so the player thread stays alive, and the error is handed to the TUI, which pauses native playback and shows `Audio backend failed: …` instead of leaving the app looping on a dead device ([#384](https://github.com/LargeModGames/spotatui/issues/384)). -- **Play/Pause works on a queued Spotify track**: With the native queue playing a queued Spotify track, the playbar showed that track but Play/Pause would not pause it. The toggle fell past every per-source arm to the streaming branch, found no item on the polled Spotify context, and dispatched a bare resume that no queue router consumed - so it reached the Web API as a plain resume, and Spotify either errored with no active device or resumed the previous context on top of the queued song. The queue slot now consumes its own transport controls, pausing and resuming the queued track while leaving the suspended Spotify context intact, and internet radio's controls likewise no longer fall through into streaming resume logic ([#374](https://github.com/LargeModGames/spotatui/issues/374)). -- **Liking no longer saves the wrong track**: Liking always acted on the polled Spotify context's item, but starting a local, Subsonic, or YouTube file only pauses librespot and never clears that context - so it still held whatever Spotify track played earlier in the session, and liking quietly saved that instead, with nothing on screen to say so. The same happened while the native queue owned playback, where the context holds the suspended track rather than the queued one you are hearing. Liking now acts on the queued Spotify track when the queue owns playback, and shows "The current playback source cannot be liked" when the active source has nothing to save ([#375](https://github.com/LargeModGames/spotatui/issues/375)). -- **Plugin repeat no longer leaks to Spotify during internet radio**: A plugin changing the repeat mode while a station was playing changed repeat on your real Spotify device instead of being ignored. The decoded routers report "not handled" for radio, which has no queue to repeat, and the runtime then forwarded the event to the Spotify handler. Radio now consumes repeat as a no-op alongside seek and next/previous, matching what the keyboard and MPRIS paths already do. This covers the internet-radio path only, so [#376](https://github.com/LargeModGames/spotatui/issues/376) stays open for the rest. -- **`nix develop` opens a dev shell again**: `devShells` and `apps` were nested under `packages` in `flake.nix`, which evaluates fine but leaves them invisible to Nix - so `nix run` had no target and `nix develop` silently fell back to building the package derivation, the case since v0.37.0. Both move to the proper top-level per-system attributes, the dev shell shares the package's native build inputs and sets `LIBCLANG_PATH` for bindgen-backed dependencies, and `cargoLock.outputHashes` now covers the patched librespot git dependencies so `nix build` vendors them reproducibly ([#401](https://github.com/LargeModGames/spotatui/pull/401)). - -### Performance - -- **Instant startup**: The TUI now appears immediately; the update check, token validation, and librespot session init run as background tasks, startup reuses one `/me` response instead of three round trips, and the first page of playlists is shown right away with the rest (and rootlist folders) fetched in the background. -- **Concurrent event pump**: Non-Spotify work (lyrics, cover art, Subsonic/radio/local sources, telemetry) runs on a concurrent service lane, so a slow Spotify API call no longer head-of-line-blocks everything behind it. Playlists open on `Enter` immediately with a loading state. -- **Track tables render only visible rows**: Tables format just the rows that fit on screen instead of the entire backing collection every frame (10k-track frame draw: 58ms → 2.5ms in debug builds), and the track table keeps its scroll position anchored while the cursor moves within the visible rows. -- **Home screen frame cost**: The changelog pane and banner gradient are cached instead of being rebuilt (and deep-cloned) every frame, changelog lines are pre-wrapped, and scrolling no longer re-composes every line above the offset — frame cost no longer grows with scroll depth. -- **Less redundant work per input**: A keypress no longer triggers a second full redraw, API pacing allows bursts of 5 so fan-outs (search, artist pages) start immediately, config saves from volume/resize/shuffle keys are debounced instead of hitting disk per key repeat, and album/artist metadata is cached with HTTP clients reused across requests. - -### Internal - -- **Dependency maintenance**: Bumped `rand` to 0.10 (call sites migrated to the new API), `tokio-tungstenite` to 0.30, `open` to 5.4, and `tempfile` to 3.27. -- **Nix CI leg**: A new workflow runs `nix flake check --all-systems`, `nix develop --command cargo --version`, and `nix build .#default`. Between them they catch outputs declared under the wrong attribute path (the class of bug behind the `nix develop` fix above) and drift between the `librespot-*` entries in `cargoLock.outputHashes` and the fork rev in `Cargo.toml`'s `[patch]` block, which breaks `nix build` for every Nix user and is only realized by an actual build. Neither is reachable from the Rust test matrix ([#405](https://github.com/LargeModGames/spotatui/pull/405)). - -## [v0.40.2] 2026-07-10 - -### Fixed - -- **Liked icons now show reliably everywhere**: Album tracklists, artist pages, Recently Played, Discover lists, and recommendations now fetch the liked state of their tracks when opened, so the liked marker no longer appears only for songs played or searched this session. Artist pages also fetch saved-album and followed-artist state for their heart markers. In addition, liked-state lookups are now batched at Spotify's documented 40-URI limit instead of 50, fixing a `400 Bad Request "Too many uris"` error that silently blanked the icons for any playlist or album longer than 40 tracks ([#118](https://github.com/LargeModGames/spotatui/issues/118)). -- **Native idle recovery no longer repeats forever**: When Spotify reports no active playback after a stale native session, spotatui still tries to restore the local device automatically. It now stops after two recovery attempts for the same idle session instead of sending `transfer(None)` and `activate()` every few seconds ([#311](https://github.com/LargeModGames/spotatui/issues/311)). -- **Album tracklists no longer cut off at 50 songs**: Opening an album now pages through the full Spotify tracklist instead of stopping at the API's 50-track page limit, whether the album is opened from search results, an artist's discography, a track's context, or the saved-albums library ([#324](https://github.com/LargeModGames/spotatui/issues/324)). -- **`cargo install spotatui` builds again**: A fresh dependency resolve pulled in `vergen 9.1.0`, which breaks librespot's build script (two incompatible `vergen-lib` versions in the graph, error E0277). A constraint-only pin keeps `vergen` below 9.1 until librespot ships a fix; the README now also recommends `cargo install --locked` ([#350](https://github.com/LargeModGames/spotatui/issues/350)). -- **`s` saves tracks in search results**: Pressing `s` on a highlighted track in the Songs search block now toggles its saved/liked state, matching the track-table binding shown in the help menu. Search results also fetch the liked state of listed tracks, so already-liked songs show the liked marker and toggle correctly ([#348](https://github.com/LargeModGames/spotatui/issues/348)). -- **MPRIS controls survive native-player recovery**: Linux MPRIS events now look up the active native streaming player when they arrive, rather than retaining the instance created at startup. Media controls therefore continue to work after spotatui recreates its native player during streaming recovery ([#356](https://github.com/LargeModGames/spotatui/pull/356)). -- **Unmapped high function keys no longer crash the TUI**: `F13` through `F24` now safely map to the unknown-key fallback instead of panicking, which protects spotatui from extended function keys emitted by global hotkeys ([#332](https://github.com/LargeModGames/spotatui/issues/332)). - -### Added - -- **Lua scripting API v5**: A major expansion of the plugin API. Plugins can now read library data asynchronously (`get_playlists`, `get_queue`, `get_search_results`, `get_saved_tracks`/`albums`/`shows`, `get_recently_played`, `get_devices`, `get_lyrics`, all with the `(data, err)` callback convention), drive far more of the app (`set_repeat`/`cycle_repeat`, `transfer_playback`, `play_uri`/`play_context`, `add_to_queue`, playlist create/edit, save/follow actions), schedule work with `set_timeout`/`set_interval`/`cancel_timer`, persist state in a per-plugin JSON store (`storage_get`/`set`/`remove`/`keys`), navigate (`navigate`/`back`/`current_route`), read the user config (`config()`), react to new events (`shuffle_change`, `repeat_change`, `device_change`, `search_results`, `route_change`), and register full custom screens with paragraph/list/gauge widgets and per-key callbacks (`register_screen`, `set_screen`, `show_screen`, `close_screen`). See `docs/scripting.md` and the new `examples/plugins/queue-browser.lua`. -- **In-app listening stats and recap flow**: The Library now includes a Stats screen with selectable 7-day, 30-day, monthly, yearly, and all-time views of total plays/listening time, top tracks/artists/albums, recent daily activity, plus current and longest listening streaks. Press the configurable recap key (`R` by default) to generate a shareable HTML card for the active period; the app can also offer a monthly recap on first launch of a new month (disable it with `behavior.enable_monthly_recap_prompt`). Listening history now syncs individual completed songs to spotatui.com when `behavior.sync_token` is configured, while retaining unsynced records locally for later retry. -- **Playlist folders in the add-to-playlist picker**: The `w` "Add Track To Playlist" dialog now shows your Spotify playlist folders and navigates them like the sidebar: `Enter` on a 📁 row opens the folder, the `←` row goes back, and `Enter` on a playlist adds the track. Only editable (owned or collaborative) playlists are offered, and the "Playlist Folders First" setting orders the dialog too. If folder data is unavailable (librespot rootlist fetch fails, or a build without native streaming), the dialog falls back to the previous flat list. -- **Global customization**: Nearly every part of the UI is now configurable via `config.yml` and the Settings screen. Pick the screen that opens at launch (`startup_route`: home, recently played, podcasts, discover, artists, or saved albums — fetched on startup so it arrives populated) and a default sort order per screen (`default_sort_playlist_tracks` / `_saved_albums` / `_saved_artists` / `_recently_played`, as `field` or `field:desc`). Rearrange the layout with `sidebar_position: left|right|hidden` (hidden auto-reveals while the sidebar has focus) and `playbar_position: bottom|top`, and tune the responsive breakpoints (`small_terminal_width`/`_height`). New icon fields (progress-gauge fill, sort arrows, episode-played marker, active-source dot, list cursor) join the existing ones in a new **Icons** Settings tab, with one-column width validation where table alignment depends on it. The playbar buttons can be relabeled (`behavior.playbar_control_labels`, hitboxes resize automatically), the playbar status line and window title are format templates (`format:` section with `{state}`, `{device}`, `{volume}`, … placeholders), and every table's columns can be reordered, removed, renamed, and resized (`tables:` section). Timing constants are exposed too: status-message duration (`status_message_ttl_percent`), playback poll interval, table scroll padding, and like-animation length. Invalid values never brick the app — structural config errors log a warning and fall back to the built-in default. Documented in `docs/configuration.md` with a full commented example at `examples/config.example.yml`. -- **Native cross-source play queue**: Press `z` on any track to add it to a native queue that plays across sources (Spotify, Local Files, Subsonic, and YouTube in one FIFO; internet radio is excluded since a live stream is not a finite track). Queued tracks play before the underlying playlist/album context, then that context resumes where it left off. Open the queue with `Shift+Q`: remove the selected item with `x` (rebindable via `remove_from_queue`, default `x`), reorder it with `J`/`K`, or press `Enter` to skip ahead and play it now. The Queue screen also previews what resumes from the underlying context once the queue drains. The queue survives restarts (persisted in `last_session.yml`). Spotify tracks queue through native (librespot) streaming; when you are controlling an external Spotify Connect device instead, `z` keeps the previous Web-API "add to Spotify's queue" behavior ([#206](https://github.com/LargeModGames/spotatui/issues/206)). -- **Terminal (ANSI) theme preset**: A new theme preset built entirely from the terminal's own ANSI palette colors instead of hardcoded RGB, so terminal-theming tools like pywal recolor spotatui live, without a restart ([#336](https://github.com/LargeModGames/spotatui/issues/336)). -- **Playlist Folders First setting**: A new `behavior.group_folders_first` toggle (Settings → "Playlist Folders First") lists your Spotify playlist folders at the top of the Playlists tab, above playlists, keeping each group's existing order. Off by default. Handy because folders keep their creation date and otherwise sink to the bottom of the recency-sorted list. -- **GitHub profile now-playing widget**: With a spotatui.com account you can now embed a live "now playing" card in your GitHub profile README (`![Now playing](https://spotatui.com/widget/.svg)`), enabled from the spotatui.com dashboard by picking a public username and turning the widget on. To power it, the now-playing cloud sync (requires `behavior.sync_token`) now includes the album, cover-art URL, track duration and progress, play/pause state, and a LIVE flag for internet radio; it also pushes immediately on pause/resume and seeks, keeps heartbeating while paused so the card shows "paused" instead of going offline, and clears your status as soon as playback stops rather than only on exit. - -### Changed - -- **Documentation refresh**: Reworked the README around spotatui as a multi-source terminal music player, with a shorter quickstart, source overview, feature summary, and updated `cargo install --locked` guidance. The configuration and scripting documentation now cover the expanded customization and Lua APIs. - -### Internal - -- **Dependency maintenance**: Updated the Rust dependency set, including `rodio` 0.22, `symphonia` 0.6, `lofty` 0.24, `mlua` 0.12, `md-5` 0.11, and the `clap_complete`/`open`/`tempfile` rollup. -- **CI maintenance**: Documentation table-of-contents updates now open a pull request instead of attempting to push directly to protected `main`. - -## [v0.40.1] 2026-07-04 - -### Changed - -- **Spotify is now optional at first-time setup**: On a fresh install spotatui shows an interactive multi-select picker of the sources compiled into your build (Spotify, YouTube, Subsonic/Navidrome, Internet Radio, and Local Files). Use the arrow keys or `j`/`k` to move, `space` to toggle, `enter` to confirm, or `esc` to skip; you can enable several sources at once. Choosing a free source skips the Spotify login entirely and drops you straight into the TUI; Subsonic collects and verifies your server credentials inline, and YouTube checks for `yt-dlp` on your `PATH`. Picking Spotify (alone or alongside others) runs the existing auth wizard unchanged. Non-TTY/piped runs fall back to the original numbered single-select prompt, and slim/macOS builds keep the Spotify-only behavior. - -### Added - -- **Add Spotify without restarting**: If you started with a free source, press `d` and select **Spotify** to log in via your browser in-place. This enables Spotify's Web API (browsing, playlists, controlling external devices) immediately; native (librespot) streaming still requires a restart since it initializes at startup. -- **`all-sources` build feature**: convenience alias that enables `local-files`, `subsonic`, `internet-radio`, and `youtube` together (e.g. `cargo run --features all-sources`). Not part of `default`. - -## [v0.40.0] 2026-07-04 - -### Fixed - -- **Native streaming silent playback (HTTP 530)**: Native audio no longer fails silently when Spotify's CDN returns HTTP 530 for the first of the candidate URLs it hands out. spotatui now builds librespot from a maintained fork ([spotatui-librespot](https://github.com/LargeModGames/spotatui-librespot)) that backports upstream [PR #1722](https://github.com/librespot-org/librespot/pull/1722) ([#1725](https://github.com/librespot-org/librespot/issues/1725)), falling back to the next CDN URL on any non-206 response. Repeated unavailable tracks now also halt playback instead of stampeding the queue and risking a rate-limit. -- **Native streaming startup device recovery**: Startup now trusts the local `spotatui` Connect device id, preserves saved external devices, activates the native device when Spotify reports no active playback, and renders an actionable idle playbar so paused native sessions restore without manually opening the device selector ([#301](https://github.com/LargeModGames/spotatui/issues/301)). -- **Windows feature/build compatibility**: The Windows audio feature set and Cargo feature wiring were corrected so native audio and the new scripting support build together cleanly on Windows instead of failing behind mismatched feature gates. -- **Transport controls drive the active source**: Next/Previous, volume, seek, and Play/Pause now act on whichever non-Spotify source (Local Files, Subsonic, Internet Radio, YouTube) currently owns playback instead of the paused Spotify streaming session underneath it. Internet radio can now be paused/resumed, volume and skip keys affect the audible source, and seek moves within the source track (radio, being live, correctly ignores seek). Media keys and the on-screen playbar buttons route the same way. -- **Now Playing follows the active source**: The window title, Discord Rich Presence, and MPRIS / macOS Now Playing widgets now show the currently playing local/Subsonic/radio/YouTube track instead of a stale paused Spotify track. -- **Network stalls no longer freeze the app**: The Spotify, Subsonic, and internet-radio HTTP clients now have connect/read timeouts, and radio directory mirrors are bounded per-request, so a hung server, dead mirror, or captive portal times out instead of wedging every transport control. The internet-radio audio stream is exempted from the request timeout so playback isn't cut off mid-song. -- **No UI freeze on track change** (`cover-art`): Album-art downloads now run off the UI lock with an explicit timeout, so the interface no longer freezes for the CDN round-trip on every track change. -- **Local playback robustness**: Skipping into an undecodable file no longer leaves the previous track playing or desyncs the track index, and file types the decoder can't play (`aiff`, `wv`, `ape`, `opus`, …) are no longer offered by the library browser. -- **Dead radio streams recover**: When a live stream drops or ends, the session now tears down cleanly (with a status message) instead of leaving a frozen "LIVE" playbar and a pinned audio thread. -- **Crash fix**: Pressing `D` (unfollow) on a playlist search result after a newer, shorter search no longer panics; selected indices are clamped when result pages are replaced. -- **Subsonic memory use**: Track downloads now stream to disk chunk-by-chunk instead of buffering the whole file in RAM (large lossless files/audiobooks no longer spike memory per track change). -- **Config file permissions**: The config file (which can hold the Subsonic password and party sync token) is now written with `0600` permissions via an atomic temp-file-and-rename, and the config directory is restricted to `0700`, on Unix. -- **MPRIS volume control**: `playerctl volume` (and other MPRIS clients) can now actually set spotatui's volume — previously only reads worked, since no `connect_set_volume` handler was ever registered. Volume changes made via the native streaming fast path (keyboard, Lua) now also sync back to the MPRIS `Volume` property immediately, so relative adjustments like `playerctl volume 0.05+` no longer compute from a stale cached value ([#327](https://github.com/LargeModGames/spotatui/issues/327)). - -### Added - -- **Multi-source: Local Files** (opt-in `local-files` feature; included in the Linux/Windows release builds): Browse a configured music directory (`behavior.local_music_path`, defaulting to the OS music folder) and play local audio (FLAC, MP3, OGG, WAV, …) through spotatui's own audio engine — with the visualizer, volume control, folder queues with next/previous/auto-advance, and MPRIS/media-key support. Switch sources with the `d` picker; the active source persists across restarts. Not yet on macOS (audio output path gated off). -- **Multi-source: Subsonic/OpenSubsonic** (opt-in `subsonic` feature; included in the Linux/Windows release builds): Connect to any Subsonic-compatible server (Navidrome, Gonic, Airsonic, Funkwhale, LMS, …) via `behavior.subsonic_url`/`subsonic_username`/`subsonic_password` (or the `SPOTATUI_SUBSONIC_PASSWORD` env var, preferred). Browse server playlists, search the catalog, and stream tracks with queue advance. -- **Multi-source: Internet Radio** (opt-in `internet-radio` feature; included in the Linux/Windows release builds): Play icecast/shoutcast streams from a `behavior.radio_stations` config list or by searching the radio-browser.info community directory (30k+ stations) in-app. The playbar shows a `LIVE` badge with the stream's ICY now-playing title updating live. -- **Radio station favorites management**: Internet radio stations found in the browser can now be saved into `behavior.radio_stations` directly from the UI and removed later, so the in-app browser can also maintain the persistent station list instead of requiring manual config edits. -- **Multi-source: YouTube** (opt-in `youtube` feature; included in the Linux/Windows release builds): Search YouTube and play audio through an external [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) binary (`behavior.ytdlp_path` overrides the `PATH` lookup; ffmpeg recommended) — anonymous, no Google account or API key. When YouTube changes and extraction breaks, updating yt-dlp is the fix; no spotatui release needed. -- **Local YouTube playlists**: Build playlists of YouTube tracks stored in a plain human-editable file (`~/.config/spotatui/youtube_playlists.yml`) — create from the sidebar (`+ New Playlist`), add search results with `w` (the same picker dialog as Spotify playlists), remove tracks with `x`, delete playlists with `D`, and play a playlist as a queue with auto-advance. Together with the other new sources, spotatui is now fully usable without any paid account. -- **Playback session persistence across sources**: `Continue` and startup restore now resume the last non-Spotify playback session too, including Local Files, Subsonic, Internet Radio, and YouTube, instead of only reviving Spotify state. -- **Cover art and lyrics for all playback sources**: The track detail surfaces now request artwork and lyrics for Local Files, Subsonic, Internet Radio, and YouTube where metadata is available, rather than limiting those enrichments to Spotify playback. -- **Setting to disable OS media key controls**: A new config/settings toggle lets users opt out of global media-key capture entirely when they do not want spotatui responding to hardware Play/Pause, Next, and Previous buttons ([#309](https://github.com/LargeModGames/spotatui/issues/309)). -- **Configurable movement keybindings**: `h`/`j`/`k`/`l` keys (traditionally left/down/up/right) are now configurable via `move_left`, `move_down`, `move_up`, and `move_right` under `keybindings` in `config.yml` and via the Settings screen. Defaults match the previous hard-coded bindings, and the arrow key(s) fallbacks still work. - -## [v0.39.1] 2026-06-12 - -### Fixed - -- **Self-update silently failing since v0.38.5**: Release-asset downloads during update checksum verification now send a `User-Agent` header (GitHub rejects requests without one with `403 Forbidden`) and `Accept: application/octet-stream` (without it the asset API URL returns JSON metadata instead of the file), so auto-update and `spotatui update --install` work again. Auto-update failures are now logged instead of silently discarded, and `spotatui update` runs on a blocking thread so it can no longer panic the async runtime. Clients on v0.38.5 through v0.39.0 carry the broken verification in their own binaries and need one manual update to reach this release ([#303](https://github.com/LargeModGames/spotatui/pull/303)). - -## [v0.39.0] 2026-06-12 - -### Added - -- **Lua plugin scripting**: Added an embedded Lua engine behind the `scripting` feature that loads `~/.config/spotatui/init.lua` and `~/.config/spotatui/plugins/*.lua` at startup. Plugins register callbacks via `spotatui.on(event, fn)` for `start`, `quit`, `track_change`, `playback_state_change`, `seek`, `volume_change`, and `queue_change`, read playback/track/device snapshots, and drive playback through a curated action API (`play`, `pause`, `next`, `previous`, `seek`, `set_volume`, `shuffle`, `search`, `notify`). A broken plugin is disabled with a status message instead of crashing the app. See `docs/scripting.md`. -- **Lua plugin commands and keybindings**: Plugins can now register named commands via `spotatui.register_command(name, fn)` and users can bind those commands to keys in `config.yml` under a new `plugin_commands` map. An erroring command reports a status message but stays bound. See `docs/scripting.md`. -- **Lua plugin UI extension (api_version 2)**: Plugins can now push a persistent segment into the playbar title via `spotatui.set_playbar(text)` (pass `nil` to clear), open a scrollable modal popup via `spotatui.popup(title, lines)` (lines support per-line fg/bold/italic styling; `j`/`k` scroll, `Esc`/`q` close), and apply runtime theme color overrides via `spotatui.set_theme(tbl)` (runtime-only, not persisted to config). See `docs/scripting.md`. -- **Lua plugin HTTP and JSON (api_version 3)**: Plugins can make async HTTP requests with `spotatui.http_get(url, cb)` and `spotatui.http_post(url, body, headers, cb)` (callbacks run on a later UI tick), and convert payloads with `spotatui.json_encode`/`spotatui.json_decode`. See `docs/scripting.md`. -- **Lua plugin installer and ecosystem**: Added a `spotatui plugin` command (`add`/`list`/`remove`/`update`) that installs plugins from git repositories into `~/.config/spotatui/plugins//` and tracks them in a `plugins.lock` file. The loader now also loads directory plugins (`plugins//main.lua`, falling back to `init.lua`) with the plugin's own folder on `package.path` so it can `require` sibling modules. Ships runnable example plugins under `examples/plugins/` and a `PLUGINS.md` index. See `docs/scripting.md`. -- **Lua plugin API-version guard and scaffold (api_version 4)**: Plugins can declare their minimum required API version with `spotatui.require_api(n)`; if the build is too old the plugin fails to load with a clear "requires spotatui scripting API vN" message instead of a cryptic nil-call error. Added `spotatui plugin new ` to scaffold a working directory plugin (`main.lua` + `README.md`) to start from. See `docs/scripting.md`. -- **SMTC Integration**: System Media Transport Controls is now integrated with the app for Windows users. Users can now control playback state using media keys and check playback state in media flyouts ([#229](https://github.com/LargeModGames/spotatui/issues/229)). -- **Click and drag to seek on the playbar**: The progress bar is now interactive. Click anywhere on the gauge to jump to that position, or click and drag to scrub. Control buttons keep priority, the time label stays non-clickable, and seeks reuse the existing native and throttled-API paths ([#157](https://github.com/LargeModGames/spotatui/issues/157)). - -### Fixed - -- **Ghost `spotatui` Connect devices**: The native streaming device id is now generated once and persisted in the streaming cache, and the old librespot session is shut down whenever the player is replaced, so app restarts and streaming recoveries no longer leave a trail of dead `spotatui` entries in Spotify's device list ([#297](https://github.com/LargeModGames/spotatui/issues/297)). -- **Native device selection and playback startup**: Made the local `spotatui` Connect device selectable when Spotify's devices API omits it, recovered stale native streaming sessions after long idle, fixed `Esc`/`Enter` handling in the device selector, and avoided `NO_ACTIVE_DEVICE` playback failures when native streaming is connected but no Spotify playback context is active ([#292](https://github.com/LargeModGames/spotatui/issues/292)). -- **Native streaming keeps playback local and surfaces failures**: With native streaming active, playback now stays on the local `spotatui` device instead of silently leaking to the official Spotify client, and streaming errors are reported instead of failing quietly ([#282](https://github.com/LargeModGames/spotatui/issues/282)). -- **Search box no longer traps focus on submit**: Pressing `Enter` to run a search now always moves focus to the results list, including when re-searching while already on the Search screen (previously focus stayed stuck in the input box) ([#191](https://github.com/LargeModGames/spotatui/issues/191)). -- **Cover-art load failures are non-fatal**: A failed album-image fetch is now logged and ignored instead of surfacing a blocking error and aborting the now-playing update, so playback metadata keeps updating when artwork can't be loaded ([#142](https://github.com/LargeModGames/spotatui/issues/142)). -- **Settings list scrolls to follow the selection**: The Settings screen now renders its item list statefully, so the viewport scrolls to keep the highlighted setting in view instead of clipping items below the fold on shorter terminals (previously only fully visible when the terminal was tall enough to show every item). The selected row is now also marked with a `▶` indicator, matching the other list screens. - -### Changed - -- **Dependency maintenance**: Bumped the grouped Rust minor dependency set (`ratatui`, `chrono`, `log`, `ratatui-image`, `serde_json`), `sha2`, and `tar` ([#283](https://github.com/LargeModGames/spotatui/pull/283), [#284](https://github.com/LargeModGames/spotatui/pull/284), [#286](https://github.com/LargeModGames/spotatui/pull/286), [#289](https://github.com/LargeModGames/spotatui/pull/289)). - -## [v0.38.6] 2026-05-28 - -### Fixed - -- **Playback API false errors**: Fixed Spotify playback mutation requests so starting playback no longer surfaces bogus API errors like `411 Length Required` or JSON parse failures when Spotify accepts the action but returns an empty or non-JSON success response. - -## [v0.38.5] 2026-05-27 - -### Added - -- **Miniplayer view**: Added a new full-screen miniplayer mode that centers the playbar, accessible via a configurable keybinding (`M` by default), with full keyboard and mouse playbar control and proper navigation stack integration ([#278](https://github.com/LargeModGames/spotatui/pull/278)). -- **Friends screen**: Added a new Library entry and full Friends screen backed by spotatui.com, including friend-code display, online filtering, inline name search, and live now-playing status for followed users. -- **Friend management flows**: Added add-friend support by friend code or username search, plus unfollow actions and periodic background refresh while the Friends screen is open. -- **Wayland clipboard support**: Enabled Linux `arboard` Wayland data-control support so clipboard operations like copying the friend code work reliably on Wayland sessions. - -### Security - -- **Token cache file permissions**: OAuth token cache is now written with mode `0600` (owner read/write only) on Unix, preventing other local users from reading the Spotify refresh token. -- **Self-update checksum verification**: The auto-update and `spotatui update --install` flows now download and verify the SHA-256 sidecar published alongside each release asset before replacing the binary, so a compromised release asset is rejected before installation. - -### Fixed - -- **Friends key handling**: Friends-specific keys and inline input now take precedence over conflicting global shortcuts, so add/search/copy/filter actions stay local to the Friends UI. -- **Playlist track search**: Added playlist-internal track search from playlist track tables with ``, client-side matching across track title, artists, and album, loading feedback while large playlists are scanned, and `q`/the configured back key to clear the active playlist filter and restore the cached playlist view ([#198](https://github.com/LargeModGames/spotatui/issues/198)). -- **Spotify auth retry path**: Centralized authenticated Spotify API requests behind a shared refresh-and-retry flow so expired access tokens are handled consistently across playback, library, search, recommendation, metadata, and user calls. - -## [v0.38.4] 2026-05-26 - -### Added - -- **Optional self-update feature**: Added a default-on `self-update` Cargo feature so package builds can omit update checks and installer code with `--no-default-features --features telemetry` ([#270](https://github.com/LargeModGames/spotatui/issues/270)). -- **Listening history export & recap**: Added local listening history collection to `~/.config/spotatui/history/listens.jsonl` and a new CLI command `spotatui history recap` to generate a shareable HTML recap. Recaps exclude short/skipped plays and power future sync and analytics features. -- **Generate Recap keybinding**: Added a configurable `R` keybinding to generate and open a 30-day listening recap HTML card directly from the app. -- **Listening history sync token**: Added optional `behavior.sync_token` setting for syncing listening history with spotatui.com - ([#275](https://github.com/LargeModGames/spotatui/pull/275)). -- **Native playback origin tracking**: Added `NativePlaybackOrigin` enum to distinguish between context-backed playback (safe for Spotify API handoff) and raw URI-list playback (stays on native route for stability). -- **Track kind detection**: Extended native track info to capture whether a track is a regular `Track` or an `Episode`, enabling proper URI construction and future episode-specific UI handling. - -### Fixed - -- **Native streaming device handoff**: Kept native streaming recovery from stealing playback back after the user transfers listening from spotatui to another Spotify Connect device ([#254](https://github.com/LargeModGames/spotatui/issues/254)). -- **Context-backed native playback**: Native playback started from a Spotify context (e.g., album, playlist, queue) now prefers Spotify-visible playback starts when safe, while raw URI-list playback remains on the stable direct native path to avoid regression. - -## [v0.38.3] - 2026-05-23 - -### Added - -- **Current track terminal title**: Updated the `Set Window Title` setting to show the currently playing track as `Track — Artist`, falling back to the default spotatui title when nothing is playing ([#262](https://github.com/LargeModGames/spotatui/issues/262)). - -### Fixed - -- **Adaptive tick rates**: Split normal UI refreshes from animation-heavy refreshes so regular screens default to a lower CPU cadence while audio visualization stays smooth, and migrate legacy saved tick-rate defaults automatically ([#252](https://github.com/LargeModGames/spotatui/issues/252)). -- **Playbar cover art sizing**: Added configurable playbar cover art sizing and improved layout handling so the image, controls, and progress bar scale cleanly in the playbar ([#253](https://github.com/LargeModGames/spotatui/issues/253)). -- **Mouse song selection**: Changed song-table left clicks so the first click highlights a row and a second click on the highlighted row selects/plays it, matching arrow-key plus Enter behavior ([#258](https://github.com/LargeModGames/spotatui/issues/258)). -- **Playlist and Liked Songs infinite scroll**: Made playlist track tables and Liked Songs scroll continuously across cached pages, prefetch additional pages in the background, and wrap `Down` from the end back to the first row and `Up` from the first row back to the last loaded row. -- **Startup playback hijacking**: Made the default `Continue` startup behavior passive so launching spotatui discovers devices without transferring playback to the native Spotatui Connect device ([#254](https://github.com/LargeModGames/spotatui/issues/254)). -- **Native startup playbar metadata**: Kept native startup playback metadata authoritative while Spotify's playback API catches up, preventing the playbar from switching to an unrelated stale API track while the native player keeps playing the started song. - -## [v0.38.2] - 2026-05-10 - -### Added - -- **Keep System Awake during playback**: Added a `behavior.keepawake_enabled` setting, enabled by default, that prevents idle sleep, system sleep, and display suspend while music is playing ([#242](https://github.com/LargeModGames/spotatui/pull/242)). -- **Theme customization from Settings**: Added full theme color editing in Settings, a `Custom` theme preset, live preset previews, and config persistence for custom theme values ([#232](https://github.com/LargeModGames/spotatui/pull/232)). - -### Fixed - -- **Native streaming metadata freshness**: Kept native player track metadata authoritative while Spotify's playback API is still catching up, preventing stale API responses from overwriting the current native track and suppressing duplicate song-count, lyrics, and saved-track checks for stale items. -- **Recommendation search UI freeze**: Reduced recommendation result loading latency by fetching full track details concurrently and avoiding holding the app lock during network calls ([#239](https://github.com/LargeModGames/spotatui/pull/239)). -- **Theme preset persistence and upgrades**: Fixed theme preset display, saving/loading of edited theme values, and backwards compatibility for existing configs with individual theme colors ([#232](https://github.com/LargeModGames/spotatui/pull/232)). -- **Nix flake macOS build**: Fixed the flake build on `aarch64-darwin` by separating Linux-only dependencies and adding macOS-specific SDK/PortAudio inputs ([#233](https://github.com/LargeModGames/spotatui/pull/233)). - -### Internal - -- **Runtime module split**: Moved authentication, runtime startup, TUI runner, native player event handling, and shared playback metadata extraction out of the monolithic `main.rs` into focused `core`, `infra`, and `tui` modules. -- **Playback integration metadata sharing**: Centralized playback snapshot construction for Discord RPC, MPRIS, and macOS media integrations so native streaming and Spotify API metadata are resolved consistently. -- **Module path cleanup**: Updated imports to use the current `core`, `infra`, and `tui` module paths after the runtime split. -- **Dependency maintenance**: Bumped `mpris-server` and the grouped Rust minor dependency set ([#226](https://github.com/LargeModGames/spotatui/pull/226), [#227](https://github.com/LargeModGames/spotatui/pull/227), [#236](https://github.com/LargeModGames/spotatui/pull/236)). - - -## [v0.38.1] - 2026-04-24 - -### Added - -- **Create Playlist flow**: Added an in-app create-playlist screen with name entry, track search, selected-track management, and playlist creation through the Spotify API ([#193](https://github.com/LargeModGames/spotatui/pull/193)). -- **Global like/unlike hotkey**: Added a configurable global keybinding (`F` by default) to like or unlike the currently playing track from any screen, complementing the existing context-specific `s` key. -- **Mouse input disable mode**: Added `behavior.disable_mouse_inputs` support for users who want to run spotatui without mouse interactions enabled ([#200](https://github.com/LargeModGames/spotatui/pull/200)). - -### Changed - -- **Fullscreen playbar resizing**: `LyricsView` and `CoverArtView` now honor the existing playbar height setting, so the lower player can be resized or fully hidden with the same keybindings used on the main screen (fixes [#208](https://github.com/LargeModGames/spotatui/issues/208)). -- **Settings UI simplification**: Simplified settings screen block handling as part of the mouse-input disable work ([#200](https://github.com/LargeModGames/spotatui/pull/200)). - -### Fixed - -- **PKCE refresh-token persistence**: Fixed refreshed auth tokens being written without preserving an existing refresh token, which could cause spotatui to require re-login after every reboot ([#217](https://github.com/LargeModGames/spotatui/pull/217)). -- **Startup auth cache recovery**: Fixed startup behavior around failed token-cache loading and prevented in-memory `refresh_token = None` states from replacing a valid cached refresh token. -- **Followed Artists list appears empty**: Fixed Artists view in Library to correctly display followed artists ([#220](https://github.com/LargeModGames/spotatui/pull/220); fixes [#219](https://github.com/LargeModGames/spotatui/issues/219)). -- **Hidden fullscreen cover-art centering**: Fixed the fullscreen cover art image rendering slightly off-center when the playbar was completely hidden. -- **Create Playlist results controls**: Improved keyboard focus and tab controls in the create-playlist search results and selected-track panes. -- **Volume display glitch on rapid changes**: Fixed the volume percentage briefly reverting to an old value after the user changed it, especially noticeable when spamming volume up/down. The UI now always shows the user's intended volume until Spotify's API confirms it matches. - -### Internal - -- **Dependency maintenance**: Bumped `tui-bar-graph`, `self_update`, grouped Rust minor dependencies, `rustls-webpki`, and `openssl` ([#197](https://github.com/LargeModGames/spotatui/pull/197), [#204](https://github.com/LargeModGames/spotatui/pull/204), [#205](https://github.com/LargeModGames/spotatui/pull/205), [#213](https://github.com/LargeModGames/spotatui/pull/213), [#224](https://github.com/LargeModGames/spotatui/pull/224), [#225](https://github.com/LargeModGames/spotatui/pull/225)). -- **CI maintenance**: Bumped `softprops/action-gh-release` from `2` to `3` in the Actions dependency group ([#212](https://github.com/LargeModGames/spotatui/pull/212)). -- **Lint/format cleanup**: Fixed clippy errors and rustfmt issues after the post-`v0.38.0` changes ([#222](https://github.com/LargeModGames/spotatui/pull/222)). - - -## [v0.38.0] - 2026-03-23 - -### Added - -- **Expanded add-to-playlist flows**: Added `w` add-to-playlist support from search song results, artist top tracks, and recently played, limited the picker to editable playlists, and refresh the open playlist table after a successful add ([#168](https://github.com/LargeModGames/spotatui/pull/168)). -- **Fullscreen Cover Art View**: Added a dedicated `CoverArtView` with its own route, handler, UI, help entry, and configurable keybinding when the `cover-art` feature is enabled ([#186](https://github.com/LargeModGames/spotatui/pull/186)). -- **MPRIS on Linux without native streaming**: Added MPRIS availability and full playback/metadata state sync for Linux builds even when spotatui is controlling an external Spotify device instead of the native streaming backend ([#172](https://github.com/LargeModGames/spotatui/pull/172)). - -### Changed - -- **Basic View renamed to Lyrics View**: Renamed the former fullscreen `BasicView` to `LyricsView`, kept `basic_view` as a config alias for backward compatibility, and split full-screen artwork into the new Cover Art view ([#186](https://github.com/LargeModGames/spotatui/pull/186)). -- **Nix flake packaging/version handling**: Refreshed `flake.nix` and simplified release-version handling for Nix packaging ([#149](https://github.com/LargeModGames/spotatui/pull/149), [#161](https://github.com/LargeModGames/spotatui/pull/161)). - -### Fixed - -- **Liked Songs playback/paging drift**: Fixed saved-track playback starting the wrong song, prevented rows from reordering while pages load, stabilized background prefetch around the current page, and made liked hearts render immediately when opening Liked Songs ([#163](https://github.com/LargeModGames/spotatui/pull/163); fixes [#139](https://github.com/LargeModGames/spotatui/issues/139)). -- **Playlist paging/cache stability**: Fixed playlist tracks duplicating or shuffling while additional pages load, made next/previous navigation target adjacent page offsets instead of sparse cache indexes, and ensured playlist sort/fetch actions target the open playlist table instead of stale sidebar selection state ([#163](https://github.com/LargeModGames/spotatui/pull/163)). -- **Recommendations playback targeting**: Fixed recommendation results so Enter and queue actions use the currently visible selected track list instead of stale cached recommendation state ([#151](https://github.com/LargeModGames/spotatui/issues/151)). -- **OAuth callback/auth retry handling**: Fixed malformed callback requests and browser preflight noise triggering repeated auth prompts at startup, and tightened streaming OAuth retry behavior so only auth failures force a fresh credential retry ([#176](https://github.com/LargeModGames/spotatui/pull/176); fixes [#170](https://github.com/LargeModGames/spotatui/issues/170)). -- **Spotify 5xx playback polling**: Temporary Spotify `502`/`503`/`504` errors during playback polling now show a retry status message and recover automatically instead of surfacing as a hard failure ([#178](https://github.com/LargeModGames/spotatui/pull/178)). -- **External-device/native playback recovery**: Fixed playlist playback on external devices always starting from track 1, and added recovery for native streaming sessions that could stop after the connection dropped or went stale ([#177](https://github.com/LargeModGames/spotatui/pull/177); fixes [#162](https://github.com/LargeModGames/spotatui/issues/162) and [#156](https://github.com/LargeModGames/spotatui/issues/156)). -- **Audio backend panic reporting**: Expanded the panic hook to recognize recoverable PortAudio and Rodio backend failures and show clearer error messaging ([#184](https://github.com/LargeModGames/spotatui/pull/184)). -- **Playlist page-boundary scrolling**: Fixed playlist and track-table scrolling that could briefly render only a single visible row when crossing a page boundary ([#185](https://github.com/LargeModGames/spotatui/pull/185)). - -### Internal - -- **Dependency maintenance**: Bumped `self_update`, `tokio`, `tokio-tungstenite` (twice), `quinn-proto`, `rustls-webpki`, `tar`, and `worker-relay`'s `undici`/`wrangler` dependencies ([#153](https://github.com/LargeModGames/spotatui/pull/153), [#154](https://github.com/LargeModGames/spotatui/pull/154), [#155](https://github.com/LargeModGames/spotatui/pull/155), [#158](https://github.com/LargeModGames/spotatui/pull/158), [#175](https://github.com/LargeModGames/spotatui/pull/175), [#182](https://github.com/LargeModGames/spotatui/pull/182), [#183](https://github.com/LargeModGames/spotatui/pull/183), [#188](https://github.com/LargeModGames/spotatui/pull/188)). -- **Rust minor dependency rollup**: Updated the grouped Rust minor dependency set, including `clap`, `clap_complete`, `image`, `openssl`, and `openssl-sys` ([#164](https://github.com/LargeModGames/spotatui/pull/164)). -- **rspotify 0.16 migration**: Upgraded `rspotify` from `0.14.0` to `0.16.0` and applied the follow-on compatibility updates across CLI, network, metadata, playback, and UI code ([#187](https://github.com/LargeModGames/spotatui/pull/187)). - -### Docs - -- **Void Linux installation guide**: Added README installation steps for Void Linux via the `Blackhole-vl` repository ([#171](https://github.com/LargeModGames/spotatui/pull/171)). - - -## [0.37.3] - 2026-03-06 - - -### Added - -- **Startup Playback Behavior Setting**: Added `behavior.startup_behavior` with `Continue`, `Play`, and `Pause` options, plus a new Settings UI cycle control so startup playback state can be configured without editing YAML ([#146](https://github.com/LargeModGames/spotatui/pull/146)). -- **Mouse Playbar Controls**: Added clickable playbar controls for previous, play/pause, next, shuffle, repeat, like, and volume in both the main layout and Basic View, including hitbox handling for resized playbars ([#147](https://github.com/LargeModGames/spotatui/pull/147)). -- **Force Previous Track Action**: Added a dedicated force-previous command and configurable keybinding (`P` by default) that always skips to the previous track instead of restarting the current one first ([#141](https://github.com/LargeModGames/spotatui/pull/141)). -- **Auto-Update Controls**: Added startup auto-update controls via `behavior.disable_auto_update`, `behavior.auto_update_delay`, and the `--no-update` CLI flag. Delayed installs now persist pending state and announce when an update will be applied. - -### Changed - -- **Settings Editing Model**: Settings now support fixed-option cycle values directly in the list UI, enabling one-step editing for items like startup playback behavior. -- **Update Flow Messaging**: Automatic update checks now distinguish between immediate installs, delayed pending installs, and skipped startup checks instead of always updating right away. - -### Fixed - -- **Linux Release Features**: Updated the Linux CD build to include the `mpris` feature again so release binaries keep desktop media-control integration working (fixes [#150](https://github.com/LargeModGames/spotatui/issues/150)). -- **Shift Keybinding Normalization**: Normalized kitty-protocol `Shift+letter` input so uppercase bindings like the new default `P` action are recognized reliably across terminals. - -## [0.37.2] - 2026-03-04 - -### Changed - -- **Silent Auto-Update on Launch**: Replaced the in-app update prompt with a silent, automatic update mechanism ([#140](https://github.com/LargeModGames/spotatui/pull/140)). The app now checks for, downloads, and installs updates automatically at startup and restarts itself if an update is applied. The update check can be skipped by setting the `SPOTATUI_SKIP_UPDATE` environment variable. - -### Removed - -- **In-App Update Prompt**: Removed all UI, navigation, and logic related to the previous interactive update prompt (`UpdatePrompt` state, handlers, and popup) ([#140](https://github.com/LargeModGames/spotatui/pull/140)). - -### Fixed - -- **macOS Build Feature Missing**: Fixed missing `macos-media` feature flag in CD workflow for macOS runners, preventing native media controls from working correctly. - -## [0.37.1] - 2026-03-03 - -### Added - -- **Resizable Sidebar and Playbar**: Added user-configurable layout dimensions with runtime keyboard shortcuts ([#138](https://github.com/LargeModGames/spotatui/pull/138)). - - New config options: `behavior.sidebar_width_percent`, `behavior.playbar_height_rows`, `behavior.library_height_percent`. - - Keyboard shortcuts to resize at runtime: `{`/`}` (sidebar width), `(`/`)` (playbar height), `|` (library/playlists split). - - New `src/core/layout.rs` module with layout constraint utilities and tests. - - Help menu updated to document all new layout adjustment shortcuts. -- **Listening Party Mode (Host/Join)**: Added an in-app listening party flow with a relay-backed session model, including host/join states, party status in the playbar, and a dedicated party popup (`Ctrl+p` by default). -- **Party Guest Name and Room Code UX**: Added required guest name input and 6-character code entry for joining parties, plus host-side guest list and control-mode display. -- **Queue View Route**: Added a dedicated queue screen with selectable entries and a configurable keybinding to open it (`Q` by default). -- **Relay Subproject for Party Sync**: Added `worker-relay/` Cloudflare Worker + Durable Object implementation for room creation, join routing, websocket relaying, and basic rate limiting. -- **Config and Keybinding Extensions**: Added `behavior.relay_server_url`, `behavior.stop_after_current_track`, `keys.show_queue`, and `keys.listening_party` configuration support. - -### Changed - -- **Token Refresh State Handling**: Authentication refresh now updates in-memory expiry tracking after successful token refresh to avoid repeated refresh dispatch loops. -- **Main Loop Network Polling**: Tokio network loop now interleaves IO event handling with periodic party-message processing. - -### Fixed - -- **macOS Now Playing Album Artwork**: macOS Control Center / Now Playing now correctly shows album and episode cover art ([#136](https://github.com/LargeModGames/spotatui/pull/136)). -- **Stop-After-Current-Track Behavior**: Native streaming playback now correctly pauses after a natural track transition when `stop_after_current_track` is enabled, instead of auto-continuing. -- **PortAudio Device-Switch Recovery (macOS/AirPods)**: Added recovery for recoverable PortAudio backend panics when the output device changes (for example, AirPods connect/switch events), reducing playback interruptions and preventing app crashes. -- **Mac Tahoe 26.2 Build Failure (macOS Media Activation Policy)**: Fixed a macOS Tahoe 26.2 build break by correcting Objective-C FFI types for `NSApplication setActivationPolicy:` (`BOOL` return and `NSInteger` argument). -- **Settings Shortcut Reliability on macOS Terminals**: Added terminal keyboard-capability detection and runtime fallback handling for `Ctrl+,` when terminal/tmux stacks drop punctuation modifiers; spotatui now applies a session fallback to `Alt+,`, prompts before persisting to `config.yml`, and displays effective shortcuts in Help/Settings UI. - -### Internal - -- **Dependency Maintenance**: Bumped Rust minor dependency updates ([#130](https://github.com/LargeModGames/spotatui/pull/130)) and CI Actions bumps ([#129](https://github.com/LargeModGames/spotatui/pull/129)). - -## [0.37.0] - 2026-02-27 - -### Added - -- **Mouse Support Across the TUI**: Added click/scroll interactions throughout the interface for navigation and settings adjustments. -- **Unsaved Settings Confirmation Prompt**: Added a confirmation flow when leaving settings with unsaved edits. -- **Exit Confirmation Dialog**: Added a quit prompt to prevent accidental exits. -- **Pookie Pink Theme Preset**: Added a new built-in `Pookie Pink` theme preset and improved selection highlight visibility. -- **Animated Home Banner Gradient**: Added animation to the home banner gradient. -- **Remote Announcements and Free-Account Messaging**: Added remote announcement support and in-app messaging for free-account playback limitations. -- **New Logging System**: Added a centralized runtime logging pipeline and session log files in `/tmp/spotatui_logs/spotatuilog{PID}` for diagnostics ([#102](https://github.com/LargeModGames/spotatui/pull/102)). -- **Playbar Cover Art Rendering (Optional)**: Added optional cover art rendering next to the currently playing song/episode title via the `cover-art` feature flag and `ratatui-image` integration ([#111](https://github.com/LargeModGames/spotatui/pull/111)). -- **Cover Art Settings Controls**: Added behavior toggles to enable/disable artwork rendering and force rendering on terminals without full image support (`behavior.draw_cover_art`, `behavior.draw_cover_art_forced`) ([#111](https://github.com/LargeModGames/spotatui/pull/111)). -- **Nix Flake Support**: Added `flake.nix` for flake-based NixOS/Nix installs, including `nix run`/`nix develop` workflow documentation ([#117](https://github.com/LargeModGames/spotatui/pull/117)). - -### Changed - -- **Architecture Refactor**: Reorganized the codebase into `core`, `infra`, and `tui` modules, including network layer extraction for maintainability. -- **Announcement Fetching Controls**: Refactored announcement fetching to support environment-variable overrides and improved error handling. -- **Documentation Updates**: Updated README content, including playback limitations for free Spotify accounts and Homebrew installation instructions for macOS. - - -### Fixed - -- **Playback Device Auto-Selection**: Fixed auto-select behavior for the `spotatui` playback device. -- **Rapid Skip Playbar Desync**: Fixed playbar state not updating correctly when skipping tracks rapidly. -- **Like Action 400 Errors**: Fixed `400 Bad Request` failures when liking songs. -- **macOS Media Integration Regressions**: Fixed macOS media support regressions in TUI handlers. -- **Global Song Counter Reliability**: Fixed issues affecting global song counter updates. -- **macOS Media Keys and Now Playing Reliability**: Fixed media-key handling and Control Center integration by wiring required macOS app/run-loop behavior and publishing now-playing metadata so spotatui responds to play/pause/next/previous correctly ([#120](https://github.com/LargeModGames/spotatui/pull/120)). - -### Internal - -- **Dependency Maintenance**: Bumped Rust minor dependency updates ([#104](https://github.com/LargeModGames/spotatui/pull/104)) and refreshed `Cargo.lock`. -- **Code Quality/Formatting**: Fixed clippy warnings and applied rustfmt cleanups ([#106](https://github.com/LargeModGames/spotatui/pull/106)). -- **Nix Release Automation**: Updated release workflow to sync `flake.nix` package version from `Cargo.toml` during releases ([#117](https://github.com/LargeModGames/spotatui/pull/117)). -- **Dependency Maintenance (Follow-up)**: Bumped `clap` to `4.5.60` and `anyhow` to `1.0.102` ([#115](https://github.com/LargeModGames/spotatui/pull/115)). - -## [0.36.2] - 2026-02-15 - -### Added - -- **Vesper Theme Preset**: Added a new built-in `Vesper` theme preset ([#98](https://github.com/LargeModGames/spotatui/pull/98)). -- **Playlist Track Management Actions**: Added flows to add tracks to playlists from track-table contexts and quick-add the currently playing track from anywhere (`W`) ([#99](https://github.com/LargeModGames/spotatui/pull/99)). -- **Playlist Track Removal Dialog**: Added remove-from-playlist action (`x` in playlist track tables) with a confirmation dialog and vim-style `h`/`l` confirmation navigation ([#99](https://github.com/LargeModGames/spotatui/pull/99)). - -### Fixed - -- **Deterministic Duplicate Removal in Playlists**: Removing a track now targets only the selected occurrence by position instead of broadly deleting matching duplicates; when mutation payload safety cannot be guaranteed (for local/unavailable rows), spotatui now reports a clear status error instead of mutating the wrong rows ([#99](https://github.com/LargeModGames/spotatui/pull/99)). -- **Recurring Spirc Startup Failures with Cached Streaming Credentials**: If Spirc initialization fails or times out while using cached streaming credentials, spotatui now clears stale `streaming_cache/credentials.json`, requests fresh streaming OAuth credentials, and retries initialization once before falling back. - - -## [0.36.1] - 2026-02-14 - -### Fixed - -- **Expired Token Startup Loop**: Startup now validates cached auth tokens and forces a clean re-auth only when the cache is stale/invalid, preventing repeated `status code 400 Bad Request` failures on launch. -- **Recurring 401 Error Modal**: Direct Spotify API calls now refresh the access token on `401 Unauthorized` and retry, preventing the error screen from immediately reappearing after dismissal when a token expires mid-session. -- **Windows Compilation Regression**: Fixed the Windows build break introduced in `0.36.0` ([#97](https://github.com/LargeModGames/spotatui/pull/97)). - -## [0.36.0] - 2026-02-13 - -### Added - -- **First-Run Auth Mode Selection**: New users now choose between two setup paths during `client.yml` creation: (1) use the shared ncspot client ID, or (2) use ncspot with a user-provided fallback client ID. -- **Automatic OAuth Client Fallback**: If authentication or profile verification fails with the primary client ID, spotatui now retries automatically with `fallback_client_id` when configured. -- **Client-Specific Token Caches**: OAuth tokens are now stored per client ID to avoid collisions when switching between primary and fallback credentials. -- **Spotify API Compatibility Layer**: Added a raw request/normalization path for Spotify's February 2026 payload changes so responses can still map into existing models. -- **Auth Reconfiguration Command**: Added `--reconfigure-auth` to rerun client authentication setup without deleting config files. -- **One-Time Auth Migration Prompt**: Existing users are prompted once to update authentication setup after config format changes. - -### Changed - -- **Authentication Flow Migrated to PKCE**: spotatui now authenticates with `AuthCodePkceSpotify` for better compatibility with current Spotify client-ID-based flows. -- **Setup Prompt Order**: On first launch, global song counter opt-in is now prompted after client-ID setup (option 1/2), matching the onboarding sequence. -- **ncspot Redirect URI Handling**: The shared ncspot client path now uses its expected redirect URI and callback port (`http://127.0.0.1:8989/login`). -- **Help Text and Migration Copy**: Updated `--help` and startup migration wording to reflect the new authentication/configuration flow. - -### Fixed - -- **Spotify Feb 2026 Breaking API Changes**: Resolved multiple regressions for newly created/restricted apps, including missing-field deserialization failures (`tracks`, `track`, `followers`, `external_ids`, `available_markets`, and related shape changes). -- **Discover Reliability**: Reworked Discover top tracks/artists mix loading to avoid removed endpoints and to degrade gracefully when recommendation endpoints are unavailable. -- **Library/Follow Endpoint Migration**: Updated save/follow checks and mutations to use the new `/me/library` and `/me/library/contains` style flows for tracks, albums, shows, artists, and playlists. -- **Startup/Discover 429 Handling**: Added Spotify rate-limit retries with `Retry-After` support, global API pacing, and non-fatal UI handling for transient 429 responses. -- **Excess Startup API Pressure**: Reduced repeated liked-state checks on playback polling so idle startup no longer spams containment checks. -- **Random Playback Poll Network Failures**: Transient request-send/network errors during playback polling now auto-retry with status messaging instead of forcing the blocking error modal. - -## [0.35.7] - 2026-02-10 - -### Added - -- **Playlist Folder Navigation**: Playlist folders from your Spotify library are now displayed as a navigable hierarchy in the UI, with optimized background fetching and immediate folder structure rendering ([#92](https://github.com/LargeModGames/spotatui/pull/92)) - -### Fixed - -- **Seek Was Completely Unusable**: Rapid seeking (`<`/`>` keys) would freeze the player, corrupt audio, cause looping glitches, and require Ctrl+C to kill. Fixed by throttling native seeks to 50ms and API seeks to 200ms, queueing pending seeks, and ignoring stale position events after seeking (thanks @El-Mundos - [#86](https://github.com/LargeModGames/spotatui/pull/86)) -- **MPRIS Position Tracking and Seeking**: Fixed relative seek calculation (was treating offset as absolute position), added `SetPosition` support for widgets using absolute positioning, and emit `Seeked` signals so external clients update their displays (thanks @El-Mundos - [#85](https://github.com/LargeModGames/spotatui/pull/85)) -- **MPRIS Shuffle and Loop Support**: MPRIS now advertises shuffle and loop status capabilities so desktop media controls and `playerctl` can toggle shuffle and repeat modes bidirectionally (thanks @El-Mundos - [#86](https://github.com/LargeModGames/spotatui/pull/86)) -- **Progress Reset on Resume**: Resuming playback on external devices no longer briefly flashes 0:00 before the API returns the real position (thanks @El-Mundos - [#86](https://github.com/LargeModGames/spotatui/pull/86)) -- **Seek Timing Bug**: Fixed a timing issue where queued API seeks didn't start the position-ignore window, causing the UI to jump back to stale positions during rapid seeks (thanks @El-Mundos - [#86](https://github.com/LargeModGames/spotatui/pull/86)) -- **Help Menu Closing App**: Dismissing the help menu no longer closes the entire application; it is now handled as a proper route (thanks @dpnova - [#89](https://github.com/LargeModGames/spotatui/pull/89)) -- **Playlist Folder Refresh Races**: Guarded against background refresh races and unified playlist resolution logic to prevent inconsistent folder state ([#92](https://github.com/LargeModGames/spotatui/pull/92)) - -### Changed - -- **Dependency Updates**: Updated `clap` to 4.5.57 and `anyhow` to 1.0.101 ([#90](https://github.com/LargeModGames/spotatui/pull/90)) - -## [0.35.6] - 2026-02-07 - -### Added - -- **MPRIS Album Art Metadata (Linux)**: MPRIS metadata now includes artwork URLs so desktop media controls can show album covers. -- **Automated Homebrew Publishing**: Added a `publish-homebrew` release job to update the Homebrew tap formula on tagged releases. -- **Config Directory .gitignore**: Automatically creates a `.gitignore` in `~/.config/spotatui/` to protect sensitive files (`client.yml`, `streaming_cache/credentials.json`) from being accidentally committed when users sync their dotfiles. - -### Fixed - -- **Slim Build Compilation**: Fixed feature gating and conditional imports so builds without `streaming` compile cleanly. -- **Update Check Arc Cloning**: Corrected `Arc` cloning in the async update check task. -- **AUR PKGBUILD Checksum Updates**: Reworked checksum replacement using `awk` and added validation checks to prevent malformed PKGBUILD updates in release workflows. -- **macOS Streaming Audio Backend**: Defaulted `librespot-playback` to `portaudio-backend` on macOS to avoid pipe-sink fallback issues. -- **Reqwest Dependency Resolution**: Made `reqwest` mandatory to prevent missing-dependency build failures (fixes #73). - -### Changed - -- **Dependency Updates**: Updated `discord-rich-presence` to 1.1.0, `clap` to 4.5.56, `time` to 0.3.47, and `bytes` to 1.11.1. -- **Project Metadata and Docs**: Updated installation docs (Homebrew and Winget), funding metadata, and contributor acknowledgements. - -## [0.35.5] - 2026-01-26 - -### Added - -- **Settings Keybindings**: Add customizable keybindings for settings actions. - -### Fixed - -- **Playback Controls Responsiveness**: Improve responsiveness of playback controls while streaming. - -## [0.35.4] - 2026-01-24 - -### Added - -- **Discord Rich Presence**: Show track info, album art, and a GitHub callout in Discord; enabled by default with a built-in application ID and optional overrides via config/env. -- **AUR Binary Package**: `spotatui-bin` is now automatically published alongside releases for faster installation on Arch Linux. - -## [0.35.3] - 2026-01-24 - -### Added - -- **Playbar Status Messages**: Added transient status messages in the playbar, used to notify when the saved playback device is unavailable and spotatui falls back to native streaming. - -### Fixed - -- **Startup Playback Device Selection**: Restored automatic selection of the last used playback device on startup, with graceful fallback to the native device when it is missing. -- **Native Device Activation Latency**: Made Spotify Connect activation non-blocking and reduced delays during device selection/playback. - -### Changed - -- **Home Screen Rendering**: Cached the home banner gradient and changelog parsing to reduce per-frame work. -- **Changelog Styling**: Changelog section headers now use theme colors for a softer, theme-consistent look. -- **Native Track Metadata**: Store a preformatted artist display string for faster playbar updates. -- **UI Render Loop**: Cache terminal size per tick to avoid repeated backend queries. - -## [0.35.1] - 2026-01-07 - -### Fixed - -- **CLI Playback Crash**: Fixed crash when using `spotatui playback --like` or `--dislike` CLI commands ([#50](https://github.com/LargeModGames/spotatui/issues/50)) - - Root cause: Incorrect type access on clap `ArgGroup` caused type mismatch panic - - Other `playback` flags (`--shuffle`, `--repeat`) were also affected - -## [0.35.0] - 2026-01-03 - -### Added - -- **Audio Visualization**: Real-time audio visualization with multiple display styles (requires `audio-viz` or `audio-viz-cpal` feature) - - **Equalizer Mode**: Uses `tui-equalizer` with half-block bars and brightness effect - - **Bar Graph Mode**: Uses `tui-bar-graph` with full-height vertical bars - - Visualizer style can be configured in `config.yml` under `behavior.visualizer_style` - - Supports custom color gradients via `colorgrad` integration - - Dynamic frequency analysis displays while audio is playing -- **tui-equalizer Integration**: Added Josh McKinney's `tui-equalizer` widget for modern audio visualization -- **tui-bar-graph Integration**: Added Josh McKinney's `tui-bar-graph` widget for alternative visualization style -- **Dynamic Theme Gradients**: The home banner now features a dynamic, animated gradient that adapts to your current theme's colors -- **Heart Burst Animation**: Added a "heart burst" animation when liking a track for better visual feedback - -### Changed - -- **Upgraded to Ratatui 0.30**: Major framework upgrade from Ratatui 0.26 to 0.30 - - Replaced deprecated `Frame::size()` with `Frame::area()` across all UI components - - Updated `App` size field to use `ratatui::layout::Size` - - Refactored terminal initialization and main event loop for new Frame API -- **Upgraded to Crossterm 0.29**: Updated from Crossterm 0.27 to 0.29 for improved terminal handling -- **Modernized UI Aesthetics**: Refactored UI rendering for cleaner, more modern appearance -- **Audio Capture Lifecycle**: Refactored lazy audio capture initialization for better resource management -- **Unified Border Styles**: Standardized all UI borders (Home, Search, Playbar, Lists) to use `Rounded` corners for a more modern and consistent look - -### Internal - -- Added comprehensive `docs/RATATUI_0.30_CONVERSION.md` documenting the upgrade process -- Updated audio-viz feature flags to include new visualization dependencies -- Improved audio analysis widget architecture for better extensibility - -## [0.34.6] - 2025-12-26 - -### Added - -- **Sorting for Playlists, Albums, and Artists**: Press `,` to open a sort menu with multiple options - - **Playlist Tracks**: Sort by Name, Date Added, Artist, Album, or Duration - - **Saved Albums**: Sort by Name, Date Added, or Artist - - **Saved Artists**: Sort by Name - - Quick keyboard shortcuts: lowercase letter for ascending (e.g., `n` for Name), uppercase for descending (`N`) - - Full playlist sorting fetches all tracks for proper cross-page sorting - -### Fixed - -- **Track Repeat Mode**: Fixed track repeat not working with native streaming player - - Previously, selecting "Track" repeat mode only updated the UI but continued playing the next song - - Now properly sends repeat commands to the librespot Spirc player - - Correctly cycles through Off --> Context --> Track --> Off repeat modes - -## [0.34.5] - 2025-12-19 - -### Added - -- **Discover: Top Tracks**: View your 50 most-listened songs with time range selection - - Uses `[` / `]` keys to cycle time range: **4 weeks**, **6 months**, **All time** - - Full track table with playback, queue, and navigation support -- **Discover: Top Artists Mix**: A shuffled playlist from your top 5 artists - - Fetches top tracks from each of your favorite artists - - Creates a unique mix every time you load it -- **Loading Indicators**: Shows "Loading..." while fetching Discover playlists -- **Track Counts**: Displays number of tracks loaded in the Discover menu -- **Gruvbox Light Theme**: Added a new warm light theme preset for better readability in bright environments - -### Fixed - -- **Made for You API Error**: Silenced JSON parsing error caused by Spotify's November 2024 API changes that restrict access to algorithmic playlists for apps without extended mode access - - Previously showed cryptic `json parse error: invalid type: null, expected struct SimplifiedPlaylist` error - - Now gracefully handles the API restriction without showing an error -- **Theme Background Colors**: Added full support for theme background colors - - Fixed issue where light themes appeared dark due to terminal background fallback - - UI widgets now strictly respect the theme's background set in `config.yml` - -### Changed - -- **Renamed "Made For You" to "Discover"**: The Library's first option is now called "Discover" with a new UI for personalized music features - -## [0.34.4] - 2025-12-17 - -### Added - -- **Crash Log on Panic**: When spotatui panics, it now writes a crash log to `~/.config/spotatui/spotatui_panic.log` to help debug hard-to-reproduce issues (set `SPOTATUI_PAUSE_ON_PANIC=1` on Windows to keep the terminal open after a panic). -- **macOS Now Playing Integration**: Native media key support on macOS via Apple's `MPRemoteCommandCenter` API (requires native streaming). -- **Streaming Debug Overrides**: Added `SPOTATUI_STREAMING_AUDIO_BACKEND` and `SPOTATUI_STREAMING_AUDIO_DEVICE` environment variables to help debug native streaming audio output issues. - -### Fixed - -- **Playlist Track Selection with Shuffle**: Fixed selecting a specific track in a playlist sometimes starting playback from the first track and desyncing the shuffle indicator/state. -- **Spirc Initialization Hangs/Crashes**: Added a startup timeout for native streaming (Spirc) initialization so spotatui can fall back to API-based playback instead of hanging indefinitely (configurable via `SPOTATUI_STREAMING_INIT_TIMEOUT_SECS`). - -## [0.34.3] - 2025-12-16 - -### Added - -- **Catppuccin Mocha Theme Preset**: Added the popular Catppuccin Mocha Lavender color scheme as a new built-in theme preset (thanks @MysteriousWolf - PR #19) -- **Nix Support**: Added Nix derivation and build instructions for Nix users (thanks @copeison - PR #16) - - Basic Nix derivation for building spotatui - - Documentation for Nix-based installation - -### Fixed - -- **ALSA Warnings and Lock Contention**: Fixed ALSA warnings and lock contention issues that could cause freezing when viewing liked songs (thanks @rawcode1337 - PR #18, fixes #17) -- **External Device Playback Controls**: Fixed play/pause, skip, and volume controls not working when using the native Spotify app as the active playback device (fixes #14) -- **Silent Crash During Streaming Init**: Panics during Spotify Connect (Spirc) initialization no longer exit silently in release builds, and are handled by falling back to API-based playback control (fixes #15, untested). - -## [0.34.2] - 2025-12-13 - -### Added - -- **Persistent Playback Device**: Saves the last used playback device (for example, `spotatui` or `spotifyd`) and re-selects it automatically on the next startup so playback resumes on the same device. - -### Fixed - -- **macOS SIGSEGV Crash on Startup**: Fixed segmentation fault when launching spotatui on macOS with Bluetooth audio devices connected - - Switched from rodio to portaudio audio backend for macOS builds - - Portaudio provides better compatibility with macOS CoreAudio and Bluetooth devices (AirPods, etc.) - - Pre-built macOS binaries now use portaudio backend by default - - Fixes crash during "Initializing Spirc" on macOS Sequoia and later - -## [0.34.0] - 2025-12-10 - -### Added - -- **MPRIS D-Bus Integration (Linux)**: Desktop media control support for Linux users - - Control spotatui via media keys (play/pause, next, previous) - - Compatible with `playerctl` command-line tool - - Desktop environment integration (GNOME, KDE media widgets) - - Track metadata exposed (title, artist, album, duration) - - Playback status and volume synced to D-Bus - - Requires native streaming feature (enabled by default on Linux) - -- **Multi-Page Playback Support**: Enhanced playback functionality for large playlists and saved tracks - - Play seamlessly across all loaded pages, not just the current page - - Automatically calculates correct track offset across multiple pages - - Supports both saved tracks (Liked Songs) and playlists - -- **Background Prefetching**: Intelligent prefetching system for improved performance - - Automatically loads additional tracks in the background when viewing playlists or saved tracks - - Prefetches up to 500 tracks (~10 pages) for seamless playback - - Non-blocking implementation - prefetching runs in separate async tasks - - Prefetched tracks are immediately available for playback without delay - -### Fixed - -- **First Song Playback Delay**: Fixed 5-10 second delay when playing the first song after startup - - Root cause: Prefetch operations were blocking the network thread, preventing playback events from being processed - - Fix: Converted prefetch operations to spawn as independent async tasks using `tokio::spawn()` - - Result: Playback starts instantly while prefetching happens in the background - -- **Track Skip Metadata Sync**: Implemented retry mechanism for track skip operations - - Ensures Spotify API returns updated track metadata after skipping - - Prevents showing stale track information in the UI - - Improves reliability of track transitions - - -## [0.33.8] - 2025-12-09 - -### Changed - -- **Smaller Binary Size**: Added Ratatui-recommended release optimizations, reducing binary size by ~62% (26MB → 9.9MB) - - Enabled Link-Time Optimization (LTO) - - Single codegen unit for better optimization - - Strip debug symbols - - Optimized for size (`opt-level = "s"`) - - Thanks to zamazan4ik for the suggestion in Issue #5! - -### Fixed - -- **Next Track Skip Shows Stale Progress**: Fixed bug where skipping to the next track with native streaming would show stale progress from the previous song and appear paused - - Root cause: `get_current_playback()` was preserving the old track's `is_playing` state (often `false` during transition), which overwrote the new track's correct state from the Spotify API - - Fix: Only preserve volume, shuffle, and repeat states when native streaming is active - `is_playing` now comes from the API response or player events - - Result: Playbar correctly shows "Playing" and reset progress when skipping tracks - -- **Shuffle Not Actually Enabling with Native Streaming**: Shuffle preference is now sent to librespot on startup and when toggling, with device activation to ensure it applies; UI and saved config stay in sync so shuffle really plays shuffled. - -- **Playbar Shows Old Track After Skip**: Fixed delay where playbar would briefly show the previous song's name/artist after skipping - - Root cause: `native_track_info` (instant track info from native player) was unconditionally cleared when API response arrived, even if API returned stale data for the old track - - Fix: Only clear `native_track_info` when API track name matches the native player's track - - Result: Playbar immediately shows the new track's name from native player, only switching to API data when it catches up - -## [0.33.7] - 2025-12-09 - -### Fixed - -- **Artist View from Search**: Fixed issue where selecting an artist from search results would show a 404 error instead of loading the artist view - - Root cause: The deprecated `related-artists` Spotify API endpoint was returning 404, blocking the entire artist view from loading - - Fix: Made related artists fetch optional - artist view now loads successfully with albums and top tracks even if related artists endpoint fails - - Related artists section will be empty when the endpoint is unavailable, but core artist information displays correctly - -## [0.33.6] - 2025-12-09 - -### Added - -- **Persistent Volume**: Volume changes are saved immediately to `config.yml` and restored on startup so your level sticks between sessions -- **Persistent Shuffle**: Shuffle state is now saved and reapplied on launch, including when using native streaming, so you restart right where you left off -- (thanks u/Ratox for the ideas) - -## [0.33.5] - 2025-12-09 - -### Fixed - -- **Program Hanging on Exit**: Fixed issue where pressing "q" to exit the TUI would close the interface but leave the program running in the background - - Root cause: Network thread continued running because the IO channel sender was never dropped, keeping the channel open indefinitely - - Fix: Added `close_io_channel()` method that explicitly drops the sender when exiting, allowing the network thread to terminate gracefully - - Result: Program now exits cleanly without requiring an additional Ctrl+C - -- **Butter-Smooth Playbar Updates**: Completely rewrote playbar progress calculation for silky-smooth updates - - **Previously**: Progress jumped every 5 seconds when the Spotify API was polled, causing visible stuttering - - **Now**: Smooth incremental updates every tick (16ms by default, configurable via `tick_rate_milliseconds`) - - **How it works**: - - On each tick, progress increments by the tick rate when playing - - Resyncs with Spotify API every 5 seconds to prevent drift - - Responds to API updates within 300ms for instant feedback on play/pause/seek - - **Result**: Progress bar now flows smoothly like a native music player, not in 5-second jumps - - Optimized code paths to reduce CPU usage and unnecessary calculations - -- **First Song Pause Bug**: Fixed issue where pausing the first song after startup required pressing pause twice - - Root cause: `is_playing` state wasn't immediately updated to `true` when starting playback, staying `false` until API poll completed - - Fix: Now immediately sets `is_playing = true` when `StartPlayback` succeeds, matching the behavior of resume playback - - Result: Pause button works correctly on first press, even immediately after selecting a song - -## [0.33.4] - 2025-12-08 - -### Fixed - -- **Instant Track Skip with Native Streaming**: When using native streaming, skipping tracks (n/p keys) now updates the playbar instantly - - Previously, the UI waited for the Spotify API response before updating, causing a noticeable delay where you'd hear the new song while the playbar still showed the old song - - Now uses the native player's `next()`/`prev()` methods via the Spirc controller for immediate skip - - Added `NativeTrackInfo` - extracts track name, artists, and duration from librespot's `TrackChanged` event for instant playbar display - - The playbar now shows native player info immediately, then seamlessly transitions to full API data when it arrives - -- **Real-Time Playbar Progress**: The progress bar now updates every second instead of every 5 seconds when using native streaming - - Enabled `position_update_interval` in librespot's PlayerConfig to receive periodic `PositionChanged` events - - Added `is_streaming_active` flag to disable API-based progress calculation when native streaming is active - - Progress bar time now shows accurate, real-time playback position (0:01, 0:02, 0:03...) instead of jumping in 5-second increments - -## [0.33.3] - 2025-12-08 - -### Fixed - -- **Critical: UI Freeze on Rapid Pause/Play**: Fixed terminal freeze that occurred when rapidly pressing pause/play - - Root cause: `handle_player_events` used blocking `lock().await` for every player event, creating a lock convoy with the main UI loop - - Fix: Changed to non-blocking `try_lock()` - if lock is busy, skip the update (UI catches up on next tick) -- **Playbar Not Updating**: Fixed playbar only updating every 5 seconds instead of in real-time - - Root cause: `get_current_playback()` incorrectly overwrote API's `is_playing` state with stale local state - - Fix: `is_playing` is no longer preserved locally - it now comes from API responses and player events - -## [0.33.2] - 2025-12-08 - -### Fixed - -- **Audio Visualization Now Works on `cargo install`**: Added `audio-viz-cpal` to default features so audio visualization works out of the box when installing via `cargo install spotatui` - - Previously, only pre-built binaries had audio visualization enabled - - Uses cross-platform `cpal` library for Windows, macOS, and Linux support - -### Added - -- **Volume Persistence**: Volume setting now persists across application restarts (thanks to Reddit user u/Ratox for the suggestion!) - - Saved in `config.yml` under `behavior.volume_percent` - - Applied automatically when native streaming starts - -### Changed - -- Updated README with detailed audio visualization platform support table: - - **Windows**: Works out of the box (WASAPI loopback) - - **Linux**: Works out of the box (PipeWire/PulseAudio monitor) - - **macOS**: Requires virtual audio device (BlackHole or Loopback) - -## [0.33.0] - 2025-12-08 - -### Added - -- **In-App Settings Screen**: Press `Alt-,` to open a new settings UI for customizing spotatui without editing config files - - **Behavior Settings**: Adjust seek duration, volume increment, tick rate, icons, and toggle options - - **Keybindings**: View current keybindings (editing coming in future release) - - **Theme Presets**: Choose from 7 built-in themes with instant preview -- **Theme Presets**: Added 6 new color schemes in addition to the default: - - **Spotify** - Official Spotify green (#1DB954) theme - - **Dracula** - Popular dark purple/pink theme - - **Nord** - Arctic, bluish color palette - - **Solarized Dark** - Classic dark theme - - **Monokai** - Vibrant colors on dark background - - **Gruvbox** - Warm retro groove colors -- **Configuration Persistence**: Settings changes are saved immediately to `config.yml` - no restart required - -### Changed - -- Updated README with In-App Settings documentation and theme preset table -- Updated bug report template with terminal-specific fields (OS, Terminal, Version) instead of browser/smartphone fields -- **Native Streaming Optimizations**: When using native streaming, seek/pause/volume changes now happen instantly without API round-trips -- **Reduced API Delays**: Lowered playback control delays (seek: 1000ms → 200ms, next/prev: 250ms → 100ms) for snappier response -- Added Settings keybinding (`Alt-,`) to the help menu - -### Fixed - -- **Cross-Terminal Color Compatibility**: Use explicit RGB values instead of named ANSI colors throughout the UI for consistent rendering across terminals (fixes display issues on Kitty, Alacritty, etc.) - - Audio visualization bar colors - - Lyrics display (active/inactive lines) - - Changelog section headers (Added/Fixed/Changed/etc.) -- **Streaming Player Events**: Improved player event handling to avoid deadlocks by releasing mutex locks before dispatching IO events -- **Settings Route Handling**: Added missing `RouteId::Settings` case in navigation handler to prevent unexpected behavior - -## [0.32.0] - 2025-12-07 - -### Added - -- **Native Spotify Streaming (Experimental)**: spotatui can now play audio directly! No more need for spotifyd or an external Spotify client - - "spotatui" appears as a Spotify Connect device in your device list - - Control playback from the TUI, phone, or any other Spotify client - - Powered by [librespot](https://github.com/librespot-org/librespot) for native audio - - New `streaming` feature flag (enabled by default) - - Requires separate OAuth flow with redirect URI `http://127.0.0.1:8989/login` - -### Changed - -- Updated README with Native Streaming documentation and setup instructions -- Added second redirect URI requirement for Spotify app configuration - -## [0.31.0] - 2025-12-07 - -### Added - -- **Lyrics in Basic View**: Introduced lyrics support in the basic view mode (press `B` to toggle) - -### Changed - -- **Network Layer**: Implement network layer for Spotify API interactions and I/O event handling -- **Improved Playlist Scrolling**: Increased playlist fetch batch size to 50 for smoother scrolling performance - -### Fixed - -- **UI Lag on Skip**: Fixed issue where UI showed the old song for a few seconds after skipping by adding a small delay for state propagation - -## [0.30.1] - 2025-12-07 - -### Fixed - -- Fix audio visualization UI rendering on Windows (replaced emoji icons with ASCII alternatives) -- Remove debug output that was bleeding into TUI display on Windows - -## [0.30.0] - 2025-12-07 - -### Added - -- **Unskippable Update Prompt**: When a new version is available, users are shown a mandatory modal that must be acknowledged before using the app - - Displays current and latest version with update instructions - - Press Enter, Esc, q, or Space to dismiss - - Replaces the old auto-dismissing notification banner - -### Changed - -- **Audio Visualization Improvements**: - - Added noise gate to filter out background noise when no audio is playing - - Boosted high frequency bands (Air, Ultra) for better visibility - - Added gradient colors to spectrum bars based on bar height (green → yellow → orange → red) - -## [0.29.0] - 2025-12-07 - -### Added - -- **Real-time Audio Visualization**: Press `v` to see a live spectrum analyzer visualization - - Native PipeWire integration on Linux for seamless audio capture without playback interference - - FFT-based frequency analysis with 12 frequency bands (Sub to Ultra) - - Smooth 60 FPS animation with pleasing visual aesthetics - - Automatic sink monitor detection on Linux via PipeWire - - No longer depends on Spotify's deprecated Audio Analysis API -- Added `audio-viz` feature flag (enabled by default on Linux) -- Added `pipewire` and `realfft` dependencies for audio processing - -### Changed - -- Default tick rate changed from 250ms to 16ms (~60 FPS) for smoother UI -- Audio visualization UI shows cleaner status with just "🎵 Capturing audio" and peak level - -### Linux Requirements - -- **PipeWire** development libraries required for audio visualization: - - Debian/Ubuntu: `libpipewire-0.3-dev libspa-0.2-dev` - - Arch Linux: `pipewire` - - Fedora: `pipewire-devel` - -## [0.28.0] - 2025-12-06 - -### Added - -- **Global Song Counter**: Anonymous telemetry feature tracking total songs played by all users worldwide - - Completely anonymous - no personal information, song names, or listening history collected - - Opt-in by default with clear privacy notice and easy opt-out via config - - Real-time counter displayed in README badge - - Powered by Cloudflare Workers for free, globally-distributed edge computing - - Rate-limited to prevent abuse (60-second cooldown per IP) - - Can be disabled by setting `enable_global_song_count: false` in config.yml - - Startup prompt for existing users to opt-in or opt-out - -### Changed - -- Added `reqwest` dependency with `rustls-tls` for telemetry HTTP requests -- Added `telemetry` feature flag (enabled by default) -- Updated README with privacy notice and global counter badge - -## [0.27.15] - 2025-12-05 - -### Changed - -- Improved changelog display in home screen with styled markdown rendering (colored headers, bullet points, section-specific colors) - -## [0.27.1] - 2025-12-05 - -### Fixed - -- Fix duplicate key events on Windows by filtering for `KeyEventKind::Press` only - -## [0.26.0] - 2025-12-05 - -### Changed - -- **Rebranded**: Project renamed from `spotify-tui` to `spotatui` -- **Config Directory**: Changed config path from `~/.config/spt/` to `~/.config/spotatui/` -- Construct Spotify config immutably in auth flow -- Update window title handling - -### Fixed - -- Simplify Option handling and unify key-event flows across handlers -- Small correctness, arithmetic and parsing improvements in CLI, app, and banner -- Use typed `id()` keys for HashSet operations and simplify collections -- Minor rendering and text updates, default ColumnId and ID checks in UI - -### Added - -- Add `spotatui update` command for self-updating from GitHub releases -- Add automatic update check on startup with notification banner (auto-dismisses after 15 seconds) -- Add comprehensive Copilot instructions documentation (`.github/copilot-instructions.md`) -- Updated GitHub Actions workflow for automated cross-platform releases - -### Security - -- Upgrade `clap` from 2.33.3 to 4.5 to remove unmaintained `atty` dependency (GHSA-g98v-hv3f-hcfr) -- Add `clap_complete` 4.5 for shell completion generation - -## [0.25.1] - 2025-12-01 - -### Fixed - -- Enhance track navigation: load previous tracks when at the start and clamp selected index after loading new tracks - -## [0.25.0] - 2025-11-13 - -### Changed - -- **Handlers Migration Complete**: All handlers now use typed IDs (`PlaylistId`, `PlayableId`, `PlayContextId`) -- Refactor track_table to use typed PlaylistId/PlayContextId and simplify logic -- Update artist handler to use PlayableId for playback/queue and recommendations -- Convert album_tracks handler to typed PlayContextId/PlayableId - -### Fixed - -- Fix input key event pattern matches to account for new `KeyEvent` fields in crossterm -- Fix shuffle behavior: temporarily disable shuffle when playing a specific track to preserve selection -- Fix search handling: avoid passing market parameter incorrectly and handle null playlists -- Fix playback: play selected track directly within context and reorder URIs for correct first-track playback -- Fix track table: load next page of playlist tracks when navigating past last item -- Clone market when calling spotify.search to preserve ownership -- Minor compile-time fixes (app/event/main/redirect/config imports) - -### Added - -- Add manual token cache with load/save helpers for authentication persistence -- Document deprecated Spotify endpoints and silence deprecation warnings - -### Removed - -- Remove unused `futures` dependency -- Remove Debug derive from `IoEvent` in network module - -## [0.25.0-beta.2] - 2025-11-12 - -### Changed - -- **Network Layer Migration**: Complete migration to typed IDs for all network API calls -- Network: adapt search API signature and map search results to typed IDs -- Network: migrate manual pagination for playlists, albums, saved tracks -- Network: migrate saved-shows and show/episode endpoints -- Network: rename follow/unfollow playlist/artist APIs to new method names -- Network: update recommendations/seed handling and PlayableId mapping -- Migrate playlist and recently_played handlers to typed IDs - -### Fixed - -- Fix device volume handling: safely handle optional `volume_percent` field -- Fix clipboard helpers: use typed IDs and bail gracefully when data is missing -- Fix user country retrieval with defensive error handling -- Fix mutable borrow issues for current route handling -- App: migrate playback progress/duration to rspotify 0.12 Duration fields - -### Added - -- Add `chrono` dependency for time handling -- App: use typed TrackId when requesting audio analysis -- App: convert recommendation seed id handling to typed IDs -- App: switch follow/unfollow and saved-album flows to typed IDs (into_static) - -## [0.25.0-beta.1] - 2025-11-11 - -### Changed - -- **Forked**: Project forked and maintained by LargeModGames -- **Major Dependency Update**: - - Migrated from `tui` to `ratatui` (v0.26) for UI rendering - - Upgraded `rspotify` to v0.13 with new authentication API (`AuthCodeSpotify`) - - Updated all dependencies to latest compatible versions -- **Typed ID System**: Begin migration to rspotify's typed ID system (`TrackId`, `PlaylistId`, `PlayableId`, `PlayContextId`) -- **Duration Handling**: Switch from legacy duration fields to rspotify 0.12+ `Duration` / `TimeDelta` types -- **UI Frame API**: Update all ratatui draw helpers to use `Frame<'_>` parameter style -- Migrate Spotify authentication and network layer to new rspotify API (AuthCodeSpotify) -- App: switch to rspotify idtypes and convert app dispatches to typed IDs -- CLI: normalize to typed IDs, remove lifetime param, handle optional device IDs and duration conversions -- Network: adopt rspotify idtypes for IoEvent payloads -- Handlers/track_table: use typed IDs/PlayableId and context IDs for playback/queue - -### Added - -- Add futures dependency for network stream handling & device API - -## [Upstream 0.25.0] - 2021-08-24 - -### Fixed - -- Fixed rate limiting issue [#852](https://github.com/Rigellute/spotify-tui/pull/852) -- Fix double navigation to same route [#826](https://github.com/Rigellute/spotify-tui/pull/826) - -## [0.24.0] - 2021-04-26 - -### Fixed - -- Handle invalid Client ID/Secret [#668](https://github.com/Rigellute/spotify-tui/pull/668) -- Fix default liked, shuffle, etc. icons to be more recognizable symbols [#702](https://github.com/Rigellute/spotify-tui/pull/702) -- Replace black and white default colors with reset [#742](https://github.com/Rigellute/spotify-tui/pull/742) - -### Added - -- Add ability to seek from the CLI [#692](https://github.com/Rigellute/spotify-tui/pull/692) -- Replace `clipboard` with `arboard` [#691](https://github.com/Rigellute/spotify-tui/pull/691) -- Implement some episode table functions [#698](https://github.com/Rigellute/spotify-tui/pull/698) -- Change `--like` that toggled the liked-state to explicit `--like` and `--dislike` flags [#717](https://github.com/Rigellute/spotify-tui/pull/717) -- Add to config: `enforce_wide_search_bar` to make search bar bigger [#738](https://github.com/Rigellute/spotify-tui/pull/738) -- Add Daily Drive to Made For You lists search [#743](https://github.com/Rigellute/spotify-tui/pull/743) - -## [0.23.0] - 2021-01-06 - -### Fixed - -- Fix app crash when pressing Enter before a screen has loaded [#599](https://github.com/Rigellute/spotify-tui/pull/599) -- Make layout more responsive to large/small screens [#502](https://github.com/Rigellute/spotify-tui/pull/502) -- Fix use of incorrect playlist index when playing from an associated track table [#632](https://github.com/Rigellute/spotify-tui/pull/632) -- Fix flickering help menu in small screens [#638](https://github.com/Rigellute/spotify-tui/pull/638) -- Optimize seek [#640](https://github.com/Rigellute/spotify-tui/pull/640) -- Fix centering of basic_view [#664](https://github.com/Rigellute/spotify-tui/pull/664) - -### Added - -- Implement next/previous page behavior for the Artists table [#604](https://github.com/Rigellute/spotify-tui/pull/604) -- Show saved albums when getting an artist [#612](https://github.com/Rigellute/spotify-tui/pull/612) -- Transfer playback when changing device [#408](https://github.com/Rigellute/spotify-tui/pull/408) -- Search using Spotify share URLs and URIs like the desktop client [#623](https://github.com/Rigellute/spotify-tui/pull/623) -- Make the liked icon configurable [#659](https://github.com/Rigellute/spotify-tui/pull/659) -- Add CLI for controlling Spotify [#645](https://github.com/Rigellute/spotify-tui/pull/645) -- Implement Podcasts Library page [#650](https://github.com/Rigellute/spotify-tui/pull/650) - -## [0.22.0] - 2020-10-05 - -### Fixed - -- Show ♥ next to album name in saved list [#540](https://github.com/Rigellute/spotify-tui/pull/540) -- Fix to be able to follow an artist in search result view [#565](https://github.com/Rigellute/spotify-tui/pull/565) -- Don't add analysis view to stack if already in it [#580](https://github.com/Rigellute/spotify-tui/pull/580) - -### Added - -- Add additional line of help to show that 'w' can be used to save/like an album [#548](https://github.com/Rigellute/spotify-tui/pull/548) -- Add handling Home and End buttons in user input [#550](https://github.com/Rigellute/spotify-tui/pull/550) -- Add `playbar_progress_text` to user config and upgrade tui lib [#564](https://github.com/Rigellute/spotify-tui/pull/564) -- Add basic playbar support for podcasts [#563](https://github.com/Rigellute/spotify-tui/pull/563) -- Add 'enable_text_emphasis' behavior config option [#573](https://github.com/Rigellute/spotify-tui/pull/573) -- Add next/prev page, jump to start/end to user config [#566](https://github.com/Rigellute/spotify-tui/pull/566) -- Add possibility to queue a song [#567](https://github.com/Rigellute/spotify-tui/pull/567) -- Add user-configurable header styling [#583](https://github.com/Rigellute/spotify-tui/pull/583) -- Show active keybindings in Help [#585](https://github.com/Rigellute/spotify-tui/pull/585) -- Full Podcast support [#581](https://github.com/Rigellute/spotify-tui/pull/581) - -## [0.21.0] - 2020-07-24 - -### Fixed - -- Fix typo in help menu [#485](https://github.com/Rigellute/spotify-tui/pull/485) - -### Added - -- Add save album on album view [#506](https://github.com/Rigellute/spotify-tui/pull/506) -- Add feature to like a song from basic view [#507](https://github.com/Rigellute/spotify-tui/pull/507) -- Enable Unix and Linux shortcut keys in the input [#511](https://github.com/Rigellute/spotify-tui/pull/511) -- Add album artist field to full album view [#519](https://github.com/Rigellute/spotify-tui/pull/519) -- Handle track saving in non-album contexts (eg. playlist/Made for you). [#525](https://github.com/Rigellute/spotify-tui/pull/525) - -## [0.20.0] - 2020-05-28 - -### Fixed - -- Move pagination instructions to top of help menu [#442](https://github.com/Rigellute/spotify-tui/pull/442) - -### Added - -- Add user configuration toggle for the loading indicator [#447](https://github.com/Rigellute/spotify-tui/pull/447) -- Add support for saving an album and following an artist in artist view [#445](https://github.com/Rigellute/spotify-tui/pull/445) -- Use the `▶` glyph to indicate the currently playing song [#472](https://github.com/Rigellute/spotify-tui/pull/472) -- Jump to play context (if available) - default binding is `o` [#474](https://github.com/Rigellute/spotify-tui/pull/474) - -## [0.19.0] - 2020-05-04 - -### Fixed - -- Fix re-authentication [#415](https://github.com/Rigellute/spotify-tui/pull/415) -- Fix audio analysis feature [#435](https://github.com/Rigellute/spotify-tui/pull/435) - -### Added - -- Add more readline shortcuts to the search input [#425](https://github.com/Rigellute/spotify-tui/pull/425) - -## [0.18.0] - 2020-04-21 - -### Fixed - -- Fix crash when opening playlist [#398](https://github.com/Rigellute/spotify-tui/pull/398) -- Fix crash when there are no artists avaliable [#388](https://github.com/Rigellute/spotify-tui/pull/388) -- Correctly handle playlist unfollowing [#399](https://github.com/Rigellute/spotify-tui/pull/399) - -### Added - -- Allow specifying alternative config file path [#391](https://github.com/Rigellute/spotify-tui/pull/391) -- List artists names in the album view [#393](https://github.com/Rigellute/spotify-tui/pull/393) -- Add confirmation modal for delete playlist action [#402](https://github.com/Rigellute/spotify-tui/pull/402) - -## [0.17.1] - 2020-03-30 - -### Fixed - -- Artist name in songs block [#365](https://github.com/Rigellute/spotify-tui/pull/365) (fixes regression) -- Add basic view key binding to help menu - -## [0.17.0] - 2020-03-20 - -### Added - -- Show if search results are liked/followed [#342](https://github.com/Rigellute/spotify-tui/pull/342) -- Show currently playing track in song search menu and play through the searched tracks [#343](https://github.com/Rigellute/spotify-tui/pull/343) -- Add a "basic view" that only shows the playbar. Press `B` to get there [#344](https://github.com/Rigellute/spotify-tui/pull/344) -- Show currently playing top track [#347](https://github.com/Rigellute/spotify-tui/pull/347) -- Press shift-s (`S`) to pick a random song on track-lists [#339](https://github.com/Rigellute/spotify-tui/pull/339) - -### Fixed - -- Prevent search when there is no input [#351](https://github.com/Rigellute/spotify-tui/pull/351) - -## [0.16.0] - 2020-03-12 - -### Fixed - -- Fix empty UI when pressing escape in the device and analysis views [#315](https://github.com/Rigellute/spotify-tui/pull/315) -- Fix slow and frozen UI by implementing an asynchronous runtime for network events [#322](https://github.com/Rigellute/spotify-tui/pull/322). This fixes issues #24, #92, #207 and #218. Read more [here](https://keliris.dev/improving-spotify-tui/). - -## [0.15.0] - 2020-02-24 - -- Add experimental audio visualizer (press `v` to navigate to it). The feature uses the audio analysis data from Spotify and animates the pitch information. -- Display Artist layout when searching an artist url [#298](https://github.com/Rigellute/spotify-tui/pull/298) -- Add pagination to the help menu [#302](https://github.com/Rigellute/spotify-tui/pull/302) - -## [0.14.0] - 2020-02-11 - -### Added - -- Add high-middle-low navigation (`H`, `M`, `L` respectively) for jumping around lists [#234](https://github.com/Rigellute/spotify-tui/pull/234). -- Play every known song with `e` [#228](https://github.com/Rigellute/spotify-tui/pull/228) -- Search album by url: paste a spotify album link into the search input to go to that album [#281](https://github.com/Rigellute/spotify-tui/pull/281) -- Implement 'Made For You' section of Library [#278](https://github.com/Rigellute/spotify-tui/pull/278) -- Add user theme configuration [#284](https://github.com/Rigellute/spotify-tui/pull/284) -- Allow user to define the volume increment [#288](https://github.com/Rigellute/spotify-tui/pull/288) - -### Fixed - -- Fix crash on small terminals [#231](https://github.com/Rigellute/spotify-tui/pull/231) - -## [0.13.0] - 2020-01-26 - -### Fixed - -- Don't error if failed to open clipboard [#217](https://github.com/Rigellute/spotify-tui/pull/217) -- Fix scrolling beyond the end of pagination. [#216](https://github.com/Rigellute/spotify-tui/pull/216) -- Add copy album url functionality [#226](https://github.com/Rigellute/spotify-tui/pull/226) - -### Added - -- Allow user to configure the port for the Spotify auth Redirect URI [#204](https://github.com/Rigellute/spotify-tui/pull/204) -- Add play recommendations for song/artist on pressing 'r' [#200](https://github.com/Rigellute/spotify-tui/pull/200) -- Added continuous deployment for Windows [#222](https://github.com/Rigellute/spotify-tui/pull/222) - -### Changed - -- Change behavior of previous button (`p`) to mimic behavior in official Spotify client. When the track is more than three seconds in, pressing previous will restart the track. When less than three seconds it will jump to previous. [#219](https://github.com/Rigellute/spotify-tui/pull/219) - -## [0.12.0] - 2020-01-23 - -### Added - -- Add Windows support [#99](https://github.com/Rigellute/spotify-tui/pull/99) -- Add support for Related artists and top tacks [#191](https://github.com/Rigellute/spotify-tui/pull/191) - -## [0.11.0] - 2019-12-23 - -### Added - -- Add support for adding an album and following a playlist. NOTE: that this will require the user to grant more permissions [#172](https://github.com/Rigellute/spotify-tui/pull/172) -- Add shortcuts to jump to the start or the end of a playlist [#167](https://github.com/Rigellute/spotify-tui/pull/167) -- Make seeking amount configurable [#168](https://github.com/Rigellute/spotify-tui/pull/168) - -### Fixed - -- Fix playlist index after search [#177](https://github.com/Rigellute/spotify-tui/pull/177) -- Fix cursor offset in search input [#183](https://github.com/Rigellute/spotify-tui/pull/183) - -### Changed - -- Remove focus on input when jumping back [#184](https://github.com/Rigellute/spotify-tui/pull/184) -- Pad strings in status bar to prevent reformatting [#188](https://github.com/Rigellute/spotify-tui/pull/188) - -## [0.10.0] - 2019-11-30 - -### Added - -- Added pagination to user playlists [#150](https://github.com/Rigellute/spotify-tui/pull/150) -- Add ability to delete a saved album (hover over the album you wish to delete and press `D`) [#152](https://github.com/Rigellute/spotify-tui/pull/152) -- Add support for following/unfollowing artists [#155](https://github.com/Rigellute/spotify-tui/pull/155) -- Add hotkey to copy url of currently playing track (default binding is `c`)[#156](https://github.com/Rigellute/spotify-tui/pull/156) - -### Fixed - -- Refine Spotify result limits, which should fit your current terminal size. Most notably this will increase the number of results from a search [#154](https://github.com/Rigellute/spotify-tui/pull/154) -- Navigation from "Liked Songs" [#151](https://github.com/Rigellute/spotify-tui/pull/151) -- App hang upon trying to authenticate with Spotify on FreeBSD [#148](https://github.com/Rigellute/spotify-tui/pull/148) -- Showing "Release Date" in saved albums table [#162](https://github.com/Rigellute/spotify-tui/pull/162) -- Showing "Length" in library -> recently played table [#164](https://github.com/Rigellute/spotify-tui/pull/164) -- Typo: "AlbumTracks" -> "Albums" [#165](https://github.com/Rigellute/spotify-tui/pull/165) -- Janky volume control [#166](https://github.com/Rigellute/spotify-tui/pull/166) -- Volume bug that would prevent volumes of 0 and 100 [#170](https://github.com/Rigellute/spotify-tui/pull/170) -- Playing a wrong track in playlist [#173](https://github.com/Rigellute/spotify-tui/pull/173) - -## [0.9.0] - 2019-11-13 - -### Added - -- Add custom keybindings feature. Check the README for an example configuration [#112](https://github.com/Rigellute/spotify-tui/pull/112) - -### Fixed - -- Fix panic when seeking beyond track boundaries [#124](https://github.com/Rigellute/spotify-tui/pull/124) -- Add scrolling to library album list. Can now use `ctrl+d/u` to scroll between result pages [#128](https://github.com/Rigellute/spotify-tui/pull/128) -- Fix showing wrong album in library album view - [#130](https://github.com/Rigellute/spotify-tui/pull/130) -- Fix scrolling in table views [#135](https://github.com/Rigellute/spotify-tui/pull/135) -- Use space more efficiently in small terminals [#143](https://github.com/Rigellute/spotify-tui/pull/143) - -## [0.8.0] - 2019-10-29 - -### Added - -- Improve onboarding: auto fill the redirect url [#98](https://github.com/Rigellute/spotify-tui/pull/98) -- Indicate if a track is "liked" in Recently Played, Album tracks and song list views using "♥" - [#103](https://github.com/Rigellute/spotify-tui/pull/103) -- Add ability to toggle the saved state of a track: pressing `s` on an already saved track will unsave it. [#104](https://github.com/Rigellute/spotify-tui/pull/104) -- Add collaborative playlists scope. You'll need to reauthenticate due to this change. [#115](https://github.com/Rigellute/spotify-tui/pull/115) -- Add Ctrl-f and Ctrl-b emacs style keybindings for left and right motion. [#114](https://github.com/Rigellute/spotify-tui/pull/114) - -### Fixed - -- Fix app crash when pressing `enter`, `q` and then `down`. [#109](https://github.com/Rigellute/spotify-tui/pull/109) -- Fix trying to save a track in the album view [#119](https://github.com/Rigellute/spotify-tui/pull/119) -- Fix UI saved indicator when toggling saved track [#119](https://github.com/Rigellute/spotify-tui/pull/119) - -## [0.7.0] - 2019-10-20 - -- Implement library "Artists" view - [#67](https://github.com/Rigellute/spotify-tui/pull/67) thanks [@svenvNL](https://github.com/svenvNL). NOTE that this adds an additional scope (`user-follow-read`), so you'll be prompted to grant this new permissions when you upgrade. -- Fix searching with non-english characters - [#30](https://github.com/Rigellute/spotify-tui/pull/30). Thanks to [@fangyi-zhou](https://github.com/fangyi-zhou) -- Remove hardcoded country (was always set to UK). We now fetch the user to get their country. [#68](https://github.com/Rigellute/spotify-tui/pull/68). Thanks to [@svenvNL](https://github.com/svenvNL) -- Save currently playing track - the playbar is now selectable/hoverable [#80](https://github.com/Rigellute/spotify-tui/pull/80) -- Lay foundation for showing if a track is saved. You can now see if the currently playing track is saved (indicated by ♥) - -## [0.6.0] - 2019-10-14 - -### Added - -- Start a web server on localhost to display a simple webpage for the Redirect URI. Should hopefully improve the onboarding experience. -- Add ability to skip to tracks using `n` for next and `p` for previous - thanks to [@samcal](https://github.com/samcal) -- Implement seek functionality - you can now use `<` to seek backwards 5 seconds and `>` to go forwards 5 seconds -- The event `A` will jump to the album list of the first artist in the track's artists list - closing [#45](https://github.com/Rigellute/spotify-tui/issues/45) -- Add volume controls - use `-` to decrease and `+` to increase volume in 10% increments. Closes [#57](https://github.com/Rigellute/spotify-tui/issues/57) - -### Fixed - -- Keep format of highlighted track when it is playing - [#44](https://github.com/Rigellute/spotify-tui/pull/44) thanks to [@jfaltis](https://github.com/jfaltis) -- Search input bug: Fix "out-of-bounds" crash when pressing left too many times [#63](https://github.com/Rigellute/spotify-tui/issues/63) -- Search input bug: Fix issue that backspace always deleted the end of input, not where the cursor was [#33](https://github.com/Rigellute/spotify-tui/issues/33) - -## [0.5.0] - 2019-10-11 - -### Added - -- Add `Ctrl-r` to cycle repeat mode ([@baxtea](https://github.com/baxtea)) -- Refresh token when token expires ([@fangyi-zhou](https://github.com/fangyi-zhou)) -- Upgrade `rspotify` to fix [#39](https://github.com/Rigellute/spotify-tui/issues/39) ([@epwalsh](https://github.com/epwalsh)) - -### Changed - -- Fix duplicate albums showing in artist discographies ([@baxtea](https://github.com/baxtea)) -- Slightly better error message with some debug tips when tracks fail to play - -## [0.4.0] - 2019-10-05 - -### Added - -- Can now install `spotify-tui` using `brew reinstall Rigellute/tap/spotify-tui` and `cargo install spotify-tui` 🎉 -- Credentials (auth token, chosen device, and CLIENT_ID & CLIENT_SECRET) are now all stored in the same place (`${HOME}/.config/spotify-tui/client.yml`), which closes [this issue](https://github.com/Rigellute/spotify-tui/issues/4) - -## [0.3.0] - 2019-10-04 - -### Added - -- Improved onboarding experience -- On first startup instructions will (hopefully) guide the user on how to get setup - -## [0.2.0] - 2019-09-17 - -### Added - -- General navigation improvements -- Improved search input: it should now behave how one would expect -- Add `Ctrl-d/u` for scrolling up and down through result pages (currently only implemented for "Liked Songs") -- Minor theme improvements -- Make tables responsive -- Implement resume playback feature -- Add saved albums table -- Show which track is currently playing within a table or list -- Add `a` event to jump to currently playing track's album -- Add `s` event to save a track from within the "Recently Played" view (eventually this should be everywhere) -- Add `Ctrl-s` to toggle shuffle -- Add the following journey: search -> select artist and see their albums -> select album -> go to album and play tracks - -# What is this? - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +- Renamed the fork repository to `ethereumdegen/degen-radio`. +- Removed the Spotify playback, Web API, authentication, and login subsystems. +- Removed the YouTube source and downloader integration. +- Retained internet-radio playback, saved stations, directory search, ICY metadata, and MPRIS controls. +- Added `spotatui radio list --json` and `spotatui radio play URL` for desktop integrations. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index d64567c5..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,607 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -This file is maintained as three near-identical copies: `CLAUDE.md`, `AGENTS.md`, and -`.github/copilot-instructions.md` (only the opening lines differ). Edit all three together. - -## Build & Run - -```bash -# Full build (native streaming + audio viz + Lua scripting + OS integrations) -cargo run - -# Slim build - no librespot/audio/scripting; fastest iteration, one of CI's seven legs -cargo run --no-default-features --features telemetry,tui - -# With the alternative sources (Local/Subsonic/Radio/YouTube/Qobuz). These are NOT -# in `default`, so a plain `cargo run` is Spotify-only; use the `all-sources` alias -# (or list them individually) to exercise the first-run source picker and playback. -cargo run --features all-sources -``` - -## CI Checks (run before opening a PR) - -```bash -cargo fmt --all -cargo clippy --no-default-features --features telemetry,tui -- -D warnings -cargo test --no-default-features --features telemetry,tui -``` - -These slim commands are the *fast local gate*, not the full picture. GitHub Actions -(`.github/workflows/ci.yml`) runs `check`, `test`, and `clippy` on `ubuntu-latest` -across a **seven-leg** feature matrix, plus one `macos-latest` job (below): - -| Leg | Features | -|-----|----------| -| `default` | a plain `cargo test`: streaming + audio-viz-cpal + scripting + self-update + OS integrations | -| `all-sources` | the **Linux** release feature set from `cd.yml` (adds cover-art, mcp-server, ai-dj, audio-viz, all five sources) | -| `mcp-only` | `telemetry,tui,mcp-server` | -| `ai-dj-only` | `telemetry,tui,ai-dj` | -| `slim` | `telemetry,tui` | -| `headless` | `telemetry` - one of two legs without `tui`. `mod tui` is feature-gated, so this leg turns any `crate::tui` import from core/infra/cli into a compile error - what keeps a second frontend from silently re-coupling to the terminal one | -| `headless-streaming` | `telemetry,streaming` - `check` + `clippy` only, no `test` job. Proves native-streaming startup (`runtime/streaming/`) and the player-event wiring type-check and pass clippy with no terminal frontend in scope. Its two entry points carry `allow(dead_code)` there, so the leg does not prove they are live | - -- `mcp-only` and `ai-dj-only` matter more than their size suggests: both enable - `dj-core` **without** `streaming` (a combination nothing else covers), and each - front door has to build without the other. -- Reproducing legs locally: `cargo test` reproduces `default`. For `all-sources`, - copy the exact `--no-default-features --features …` string out of `ci.yml` - - `cargo test --features all-sources` is **not** it (the alias only adds the five - sources on top of default), and the leg includes `audio-viz` (PipeWire), so it - only compiles on Linux. -- Every CI leg passes `--locked`; the local commands do not. Regenerate - `Cargo.lock` after any `Cargo.toml` edit or all legs fail at once. -- CI runs `clippy` on the **bin target only** (no `--all-targets`), so lints in - `#[cfg(test)]` code are not gated. Run `cargo test` to compile test code. A - test-only helper whose production caller is compiled out on some leg needs - `#[allow(dead_code)]`. -- The `all-sources` leg must stay in sync with `cd.yml`'s Linux release row - (macOS releases ship the same five sources but a different - backend/OS-integration set). -- One further job, `macos`, runs `check` + `clippy` (no `test`) on - `macos-latest` with cd.yml's macOS release feature set. It is the only leg - that compiles the `#[cfg(target_os = "macos")]` arms, the portaudio playback - backend, `macos-media`, and `audio-viz-cpal` - none of which the Linux legs - can speak for. No `test`: the suite is platform-independent logic the Linux - legs already run, and the device tests are `#[ignore]`d (CI runners have no - audio output). Keep its feature list in sync with cd.yml's macOS rows. -- A pull_request-only `Gates ratchet` job diffs `tools/gates.count` against the - merge-base (`tools/check_gates_ratchet.sh`): coupling counters may only fall; - the two adoption counters (`test_attribute_total`, - `action_refs_in_tui_handlers`) may only rise. `src/gates.rs` pins every value - exactly, so move the baseline in the same PR that moves the number, in the - ratchet's direction only. - -## Run a Single Test - -```bash -cargo test --no-default-features --features telemetry,tui -# Example: -cargo test --no-default-features --features telemetry,tui global_shift_w_adds_current_track_from_anywhere -``` - -A filter that matches nothing still exits 0 - when running feature-gated tests in -the slim build, check the `N filtered out` count actually says your test ran. - -## Architecture - -One cargo package: a `[lib]` (`src/lib.rs`, private modules, public API = -`run_cli`) plus two `[[bin]]` shims in `src/bin/` - `spotatui` (console) and -`spotatui-gui`, a placeholder behind the off-by-default `gui` feature that -exists to own the crate-root `windows_subsystem` attribute. Five top-level -units under `src/`: - -| Unit | Role | -|------|------| -| `core/` | Centralized state (`App`), the frontend-neutral tick scheduler (`driver/`), the shared action vocabulary (`action/`), config/state persistence, and the rspotify-free domain types (`plugin_api`, `pagination`, `source`) | -| `infra/` | Spotify Web API (`network/`), native librespot streaming (`player/`), alternative sources (`local/`, `subsonic/`, `qobuz/`, `radio/`, `youtube/`, `queue/`), audio viz (`audio/`), Lua scripting (`scripting/`), AI DJ + MCP (`dj/`, `mcp/`), OS integrations (Discord RPC, MPRIS, macOS/Windows media) | -| `tui/` | Terminal UI: the event/render loop (`runner.rs`), key plumbing (`event/`), per-block input handlers (`handlers/`), immutable draw fns (`ui/`) | -| `cli/` | clap subcommands: playback control, listening history, self-update, MCP relay, plugin management | -| `runtime/` | `mod.rs::run_cli` (entry point + CLI dispatch), `bootstrap.rs::boot` (frontend-neutral config/auth/`App` construction, `run_cli` its sole caller, plus the boot auth rule `spotify_auth_mode`: interactive only right after the client wizard or `--reconfigure-auth`, a subcommand needs a cached token, a UI launch tolerates no session), `cli.rs` (clap assembly + self-update), `pump.rs::start_tokio` (the IoEvent pump), `streaming/` (native-streaming startup every frontend shares: the pure saved-device decision in `mod.rs`, the librespot bring-up in `launch.rs`, gated on `streaming`), `startup.rs` (the UI-launch half, gated on `tui`) | - -### Data flow - -```text -crossterm thread (tui/event/) → runner.rs loop (draws the frame, then reads one event) - → runner::dispatch_key - exit prompt, ActiveBlock::Input, the configurable back key - → handlers::handle_app - plugin popup modal → help filter → global keybindings - → handle_block_events - dispatches to the per-screen handler - → app.dispatch(IoEvent) - hands async work to the pump in runtime/pump.rs - → source routers by URI scheme, then infra/network/ (Spotify) - → mutates App state; tui/ui/ re-renders from App on the next frame -``` - -`tui/event/` is only the crossterm→`Key` plumbing; the event loop itself is -`tui/runner.rs::start_ui`. Everything on a timer lives in `core/driver/` -(`Driver::tick`): the playback tick + debounced flushes, OAuth refresh, -Discord/MPRIS presence sync, the window title, lyrics + cover-art scheduling, -native-queue and decoded-source auto-advance, and `last_session.yml` -persistence. The runner draws frames, reads events, and calls `driver.tick` -with a `TickEnv` carrying the frontend-geometry inputs (visualizer bar count, -cover-art pixel support). A frontend that stops ticking is loud, not silent: -`App::playback_position_ms()` reads stale after 2s and the playbar says so. -Mouse input enters via `handlers::mouse_handler`. - -### The IoEvent pump: source routing, two lanes, an auth gate - -`runtime/pump.rs::start_tokio` drains IoEvents serially. Three structural gates, all -worth knowing before adding an event: - -- **Source routing**: non-Spotify playback is routed by URI scheme *before* the - Spotify handler, in this order: `route_queue_event` → `route_local_event` - (`file:`) → `route_subsonic_event` (`subsonic:`) → `route_qobuz_event` - (`qobuz:`) → `route_radio_event` (`radio:`) → `route_youtube_event` - (`youtube:`) → `Network::handle_network_event`. - This is what keeps `infra/network/` Spotify-only. -- **Claim gate**: before the routers, `start_playback_has_taker` drops a - `StartPlayback` whose URI scheme (`core::queue::queue_item_source`) names no - compiled-in source and that no Spotify session can take. The routers' - foreign-start teardown arms therefore only run for a real source-to-source - handoff. -- **Service lane**: `Network::runs_on_service_lane` lists events that run on a - detached task so slow, source-agnostic work cannot head-of-line-block the serial - pump. The service lane's `Network` is built with **no Spotify client** - adding a - `self.spotify()` call to a service-lane handler panics. -- **Auth gate**: `Network::event_bypasses_spotify_auth` lists events whose handlers - never need a Spotify session. -- **Replay**: `Network::event_is_transport` lists events that drive whoever owns - the sink. One held back by a rate-limit window is stamped with the - `PlaybackOwner` at deferral time, dropped at the flush when the owner changed, - and otherwise re-sent on the pump's channel so the routers see it. - A new IoEvent must be classified against all three lists. - -### Navigation / routing - -`App` holds a private navigation stack of `Route` values (`id: RouteId`, -`active_block: ActiveBlock`, `hovered_block: ActiveBlock` - there is no -`HoveredBlock` *type*). Invariants an agent cannot guess: - -- `push_navigation_stack(RouteId::X, ActiveBlock::X)` is a **no-op when the top - route already has that `RouteId`** - follow it with `set_current_route_state` - when focus must move anyway. -- `pop_navigation_stack()` refuses to empty the stack. -- The stack is private: go through `get_current_route` / `set_current_route_state`. - -### The `core/app/` module folder - -`App` was one 10,920-line file; it is now 37 files. The struct stays **flat** - all -171 fields declared once in `src/core/app/mod.rs` (27 of them feature-gated), plus -the 81 presentation fields grouped in `App.view` - and its ~270 methods are split -across 36 sibling modules by concern, each with its own `impl App` block. The -boundaries are organizational, not architectural. - -Rules when working in here: - -- **Import from `crate::core::app`, never the submodule path.** `mod.rs` re-exports - every module that declares public items, so `use crate::core::app::{App, RouteId, - ActiveBlock}` works no matter which file an item lives in. -- **Child modules open with `use super::*;`** and declare no other top-level - imports; all external imports live in `mod.rs`. When a type is only importable - under a feature that does not gate the function, use a **function-local** `use` - instead of adding a top-level import. -- **A private helper called from a sibling module needs `pub(super)`**; one reached - from `tui/` or `infra/` needs `pub(crate)`. Private *fields* need nothing - they - are declared in `mod.rs` and visible to all descendants. -- **A field with no writer outside `core/app/` is private.** `pub_fields_on_app` - counts the `pub` ones and may only fall. An outside reader gets an intent-named - getter (`status_message()`, `api_error()`); a handler mutates shared state only - through an `App` method or `app.apply(Action::…)`, never through a field write. -- **Feature-gate the method body, not the call site**, when a predicate must exist - in every build: `#[cfg(any(...))] { … } #[cfg(not(any(...)))] { false }` inside - one ungated `fn` (see `queue_owns_playback`), so callers never need their own `#[cfg]`. -- New `impl App` methods go to the concern module that owns that state, not `mod.rs`. -- **Presentation state lives in `App.view: ViewState`** (`core/app/view.rs`): cursor - and selection indices (the track table, search, Recently Played and sidebar - cursors included), scroll offsets, edit buffers, focus, popup flags, the help - pager, the terminal viewport. Handlers and draw functions write `app.view.` - freely and the handler-write ratchet skips those chains. A producer outside `tui/` - and `core/app/` (a network or source handler, a script effect, the CLI) that - resets or clamps a cursor is counted by `view_writes_outside_tui`, which may only - fall: a producer that replaces a list resets its cursor through an `App` method - (`set_track_table`, `clamp_search_cursors`), never by writing `view` itself. A - new field goes in `view` only if it is presentation state; a pending operation, - or anything a second frontend would also need, stays on `App`. -- `dispatch` pins the global loading spinner; long work with its own progress - surface uses `dispatch_without_spinner`. - -Tests are colocated (`#[cfg(test)] mod tests`) in most - not all - modules. Shared -fixtures are `pub(super) fn`s in `test_support.rs`, imported as -`use crate::core::app::test_support::*;`. - -### Playback ownership - -Multiple players share one UI, and the predicate order is the #1 source of -regressions. Check in this order: `queue_owns_playback()` / -`queue_now_is_spotify()`, then `active_decoded_source()`, then -`is_native_streaming_active_for_playback()`. `App::playback_owner()` folds them -into one `PlaybackOwner`, and the transport chains (play/pause, -next, previous, shuffle, repeat, volume) end on `dispatch_spotify_fallback`, -which answers "Nothing is playing" instead of a Spotify dispatch when no -session exists. - -- Starting a decoded source (Local/Subsonic/Qobuz/Radio/YouTube) only **pauses** - librespot - the native flag stays true, so driving librespot directly resumes - the wrong player. -- A decoded start claims the sink (`App::claim_decoded_sink`) before it pauses - librespot, and `active_decoded_source()` reads the claim: the owner is - `Decoded` from the first line of the start, through a failed start or a lost - output device, until the source's queue runs out (the driver's teardown, the - queue's exhausted-context resume) or an explicit Spotify start (a URI or a - context) reaches `Network::start_playback`, which releases it. A bare resume - never releases it, so a media key or Space during a download cannot resume - librespot. -- While the native queue slot owns the sink, `current_playback_context` names the - *suspended* context's track. Inside `core/app/`, resolve the playing *track* - through `App::playing_item()` (`core/app/playback_routing.rs`): it answers - with the slot's `TrackInfo` (`uri`, `album_id`, `artist_refs`; a slot has - no play context) and refuses a decoded owner. Outside `core/app/` read the - `PlaybackSnapshot` (`infra/media_metadata.rs`), never the field: - `direct_playback_context_reads` counts every other reader and may only fall. -- Radio is in `active_decoded_source` but deliberately out of - `active_queueable_decoded_source` (repeat/shuffle) and - `active_source_position_ms` (seek). - -### Native streaming (feature `streaming`, in `default`) - -`StreamingPlayer` (`src/infra/player/`) embeds librespot as a Spotify Connect -device; a supervisor does bounded in-place reconnects without dropping the audio -pipeline, escalating to full backend replacement with parked-request replay. -Durable intent (recovery snapshot, parked `StartPlayback`, client-side shuffle -session) lives in `src/core/app/native_{backend,recovery,shuffle}.rs`. - -- It rides our **maintained librespot fork**, published on crates.io as - `spotatui-librespot-*` and consumed through `package =` renames, so imports - stay `librespot_*`. The fork carries upstream backports *and* a fork-only - `SessionDisconnectReason` API the app depends on - it cannot be swapped for - upstream 0.8. All seven crates are `=`-pinned in lockstep; bump them together - when the fork publishes a new version. -- Direct spirc `load` is the **primary** route for all native starts, context ones - included - a `me/player/play` round trip would head-of-line-block the serial - pump (#386). The Web API route (`start_native_context_via_api`) is a fallback - only for context starts the direct load rejected or the watchdog is replaying. - A native URI-list start must never go through the Web API. -- Anything that replaces or drops a `StreamingPlayer` must call - `player.shutdown()` first; the Connect device id is persisted in - `/device_id`. Both exist to stop ghost Connect devices (#297). -- Session teardowns are classified by librespot's disconnect reason, never - inferred: external handoff → rebuild idle, unexpected → restore playback, - local → stop. The handoff veto is sticky (#437). -- Every background native write is generation-guarded - (`native_playback_generation`, `native_shuffle_generation`) and event handlers - confirm `Arc::ptr_eq` against the current player before writing - stale writes - from a replaced backend are the recurring bug class. -- librespot reports full `spotify:track:` URIs while app state uses bare - base62 ids: normalize with `base62_id_of` at the event boundary - (`spotify:local:` URIs stay whole). -- Verify native playback changes with the full `cargo run` build, not only the - slim telemetry build. - -### Listening Party / sync - -`src/infra/network/sync.rs` is pure WebSocket transport (`SyncMessage`, -`PartyConnection`); lifecycle logic lives in `src/infra/network/mod.rs`. Handlers -dispatch `StartParty` / `JoinParty` / `SetPartyControlMode` / `LeaveParty`; -`SyncPlayback` is fired by `App::on_tick` every 2s while hosting, not by a handler. - -## Key Conventions - -### Adding a new screen - -See `.claude/skills/add-tui-screen/SKILL.md` for the six-place checklist. - -### Dispatching network calls - -Call `app.dispatch(IoEvent::…)` from a handler - never call async Spotify code -directly from handlers or UI code. Draw functions take `&App` and must not mutate. - -Inside the network layer, never call rspotify client methods for Spotify data: -use `self.spotify_api_request_json(...)` / `self.spotify_get_typed::(...)` -from `src/infra/network/requests.rs` (pacing, 401 cooldown, payload -normalization). rspotify supplies OAuth/PKCE, id types, and models only. Spotify -401s are deliberately tolerated (consecutive-failure threshold, forced-refresh -cooldown) - don't "fix" that by escalating on first failure. - -### The shared Action vocabulary - -`src/core/action/` holds `Action` (one enum of frontend-neutral state -changes), `App::apply` (the single write path, in `core/action/apply.rs`), -and `ActionOutcome`. Producers that are not the network layer's own result -handling mutate `App` via `app.apply(Action::…)`: the Lua scripting engine -drains its queued actions into it, the mutating DJ/MCP tools call it -directly, and TUI handlers adopt it as the conversion sub-PRs land -(`action_refs_in_tui_handlers` may only rise). Rules: - -- No rspotify type and no raw `IoEvent` payload in `Action` - payloads are - strings, scalars, and `core::plugin_api` snapshot types. Address by - identity (URIs, ids, names), never by list ordinal. -- Every arm delegates to the same ownership-aware `App` method the - equivalent keybinding uses. Playback starts go through - `App::start_playback_uris` / `start_playback_context` / - `start_playback_track_in_context` - never a hand-built - `IoEvent::StartPlayback` in an arm. -- No catch-all match arm under `src/core/action/` or in `tui/keymap.rs`: - both deny `clippy::wildcard_enum_match_arm` and - `clippy::match_wildcard_for_single_variants` (a named binding like - `_other =>` counts as a wildcard; `matches!` and `Option`/`Result` - scrutinees are exempt), and `wildcard_arms_in_action_tree` pins the - action tree at 0 by a raw text scan that includes tests, comments, and - string literals. CI clippy never compiles tests, so write a test - catch-all as `_other =>` and only on a `Result`/`Option` scrutinee. -- Every `Action` variant has an arm in `tui/keymap.rs::default_binding`, - naming the key or gesture that produces it; a variant no gesture produces - is `Exposure::Unbound("reason")`. A new variant is a compile error until - it has an arm, and a test failure until `sample_actions()` in the same - file has a value for it (an `Unbound` one also goes into the `UNBOUND` - pin, which a producer scan of `src/tui/` checks). Feature-gate an arm's - body, never the arm: clippy skips a match when any arm carries a `#[cfg]`. -- `Action` derives serde (the future frontend wire shape); a payload type - added to it must stay serde-derivable. - -### Paginated results - -Page caches are `ScrollableResultPages>` (domain types from -`core/pagination.rs` - no rspotify `Page` in `App` state; conversion lives in -`infra/network/mapping.rs`): - -- Insert with `upsert_page_by_offset` - never `add_pages`, which repoints the - visible index to the tail. Key and dedupe by `page.offset`. -- Caches may be sparse: next/previous targets adjacent *offsets*, not index ±1. -- Background prefetch carries a generation snapshot, re-checks it (plus table - identity) after every await, and writes via `set_*_to_table_continuous()` - - never by appending into `track_table.tracks`. -- When a page is already cached, render it synchronously from app state instead - of routing through another async event. -- For playlist track tables, `playlist_track_table_id` is the table identity; - `active_playlist_index` is sidebar selection state only. - -This generation-guard pattern is repo-wide: every background write into `App` -re-checks its generation/epoch (`dj.generation`, `native_shuffle_generation`, -`liked_state_epoch`, …) so stale tasks cannot write into a reloaded view. - -### Status messages - -In sync handlers/UI use `app.set_status_message(msg, ttl_secs)`, or -`app.set_error_status_message(...)` for errors - errors block normal messages -until they expire, so use the right one. TTLs are seconds, scaled by the user's -`status_message_ttl_percent`. In the async network layer use -`self.show_status_message(msg, ttl_secs).await`, which takes the `App` lock and -calls `set_status_message`. The message fields are private: `status_message()` -and `status_message_is_error()` read them. - - -### Errors - -`App::handle_error(e)` records the message in `api_error`, stamps a 60s -lifetime on it, and pushes `RouteId::Error`. That route is a *presentation -hint*: the terminal frontend draws it full-screen, another frontend may render -`api_error` as a toast and ignore the frame entirely. Four rules: - -- Dismiss with `app.clear_api_error()`, never by clearing the string or popping - the route by hand. It drops the message, its lifetime, and every frame still - showing it together - a cleared string under a surviving frame renders as an - error page with nothing on it. `pop_navigation_stack` already calls it when - the frame it pops is `Error`. -- Nothing else clears `api_error`. `update_on_tick` retires it once the - lifetime passes, handing the text to the status bar only when the error frame - is the current screen, so a frontend with no dismissal gesture does not latch - the first failure forever. The CLI never ticks, so its latch is intact. -- A non-empty `api_error` is the CLI's only failure signal for every subcommand - that reaches the bottom of `handle_matches` (`src/cli/handle.rs`; the two - `share-*` flags return early and bypass it). Moving a call site off - `handle_error` turns a failing CLI command into exit 0 unless that site is - provably unreachable from the CLI. -- Demote a site to `set_error_status_message` only when all three hold: it - fires from the tick or a self-refreshing retry loop (so every retry restamps - the lifetime and the backstop can never win), it is provably unreachable as a - CLI exit signal, and the failed operation is bookkeeping rather than the thing - the user asked for. `flush_state_save` is the only site that qualifies today, - and it latches its report to once per failure run - repeating it at the retry - rate would hold `status_message_is_error` and silently drop every ordinary - status message for the rest of the session. - -### Dialog state cleanup - -Close dialogs with the single call `app.clear_dialog_state()` (clears `dialog`, -`confirm`, `pending_keybinding_persist`, and playlist-picker state). Do not -hand-clear the fields. Popping the nav stack is a separate step. - -### User-configurable keybindings - -Check `app.user_config.keys.` instead of hard-coding key literals for -global actions (`handle_app` in `src/tui/handlers/mod.rs`); -`common_key_events::{up,down,left,right}_event` extend this to per-screen -navigation. Adding a binding means fields on both `KeyBindings` and -`KeyBindingsString` in `src/core/user_config.rs`, a `help_entries` row in -`src/tui/keymap.rs` with its `Requirement`, and, when the key produces an -`Action`, that variant's `default_binding` arm pointing at the new field. - -### Requirements - -`core/requirement.rs` says what a row or key needs (`Requirement::{None, -SpotifySession, Capability(_), Source(_), AnyOf(_)}`) and `App::availability(req)` -answers for the active source and the session. Three tables carry it: the -sidebar (`library_row_requirements()` in `core/app/library.rs`), the help menu -(`help_entries()` in `tui/keymap.rs`) and the settings rows -(`setting_requirement(id)` in `core/app/settings_schema.rs`). The rule on every -surface: a row with a rebindable key stays visible with the `hint()` as a -suffix, a row with a fixed key or a sidebar row is hidden. Gate a key on the -same predicate the row uses, so the two cannot disagree. - -### Config & on-disk files - -Five files, five owners - a value that changes as the app runs goes in state, -never config: - -| File | Owner | Contents | -|------|-------|----------| -| `config.yml` (config dir) | `core/user_config.rs` | hand-editable settings, keys, theme | -| `client.yml` (config dir) | `core/config.rs` | Spotify app credentials | -| `state.yml` (state dir) | `core/state.rs` | machine-written runtime values | -| `last_session.yml` (state dir) | `core/persisted_playback.rs` | non-Spotify playback + native queue | -| `qobuz_credentials.yml` (config dir) | `infra/qobuz/auth.rs` | the Qobuz login token (feature `qobuz`) | - -- All paths resolve through `core/paths.rs`, never `dirs::` directly. -- `state.yml` saves are read-modify-write **sparse patches** so a second running - instance is never clobbered; never write a whole snapshot. -- Sensitive writes go through `core::auth::write_private_file_atomic`; - `config.yml` can carry plaintext secrets, so never log the serialized config. -- `UserConfig::save_config()` regenerates only `behavior`/`theme`/`keybindings`; - `plugin_commands`, `format`, and `tables` are passed through from disk. -- Adding a `behavior.*` setting = edits in `core/user_config.rs` - (`BehaviorConfigString`, `BehaviorConfig`, `UserConfig::new`, - `load_behaviorconfig`, `save_config`) **plus** matching arms in - `core/app/settings_schema.rs` and `settings_apply.rs`, coupled only by the raw - `"behavior."` string id - a typo silently drops the write. A row that - only works with Spotify also needs a `setting_requirement` arm. - -### Feature flags - -- `default` = `telemetry, tui, streaming, audio-viz-cpal, macos-media, - windows-media, mpris, discord-rpc, self-update, scripting`. Notably **not** - in default: `cover-art` (so a plain `cargo run` has no album art, though - every shipped binary enables it), the five sources, and the DJ features. -- `tui` gates `mod tui` and owns the terminal-only crates (ratatui, crossterm, - tui-bar-graph, colorgrad - the last also pulled by `art-decode` for the - adaptive-theme HSV math). `gui` is a reserved placeholder that only gates the - `spotatui-gui` bin shim. -- Cover art is two features: `art-decode` is the frontend-agnostic decode half - (`dep:image`, fills `core::art::CoverArtStore`, feeds the adaptive theme; - never enabled by hand); `cover-art` layers the ratatui-image terminal - rendering on top and keeps its pre-split meaning for users and CI. -- `audio-viz` (PipeWire, Linux-only) and `audio-viz-cpal` are **visualizer - capture** backends, not playback. The librespot *playback* backend is chosen by - `[target.'cfg(...)']` dependency blocks in Cargo.toml, not by the `*-backend` - features: linux-gnu→alsa, linux-musl→rodio, Windows→rodio, macOS→portaudio - - the non-default picks avoid librespot's `pipe` sink writing raw audio to stdout - and destroying the TUI. -- `audio-decode` (rodio) is the shared engine pulled in by the sources; - `all-sources` = `local-files, subsonic, internet-radio, youtube, qobuz`. -- `dj-core` is a shared implementation feature pulled in by `mcp-server` and - `ai-dj`; none of the three are in `default`, and neither front door may assume - the other (or `streaming`) is present. -- `scripting` (mlua Lua plugins) is default-on and gates `src/infra/scripting/` + - `src/cli/plugin.rs`; the slim gate never compiles it, so verify scripting - changes with a default `cargo test`. - -### Adding a feature-gated sidebar row - -See `.claude/skills/add-tui-screen/SKILL.md`. - -### Domain-specific conventions - -`src/infra/dj/CLAUDE.md` (DJ lanes/guards/avoid-library filter), -`src/infra/mcp/CLAUDE.md` (MCP server), `src/infra/scripting/CLAUDE.md` (Lua -plugins), and `src/cli/CLAUDE.md` (CLI subcommands) load automatically when -working in those directories. - -### Alternative sources (Local / Subsonic / Radio / YouTube / Qobuz) - -- All five decode through **one** shared rodio sink, `LocalPlayer` - (`src/infra/audio/player.rs`) - the only file where rodio types appear. - Subsonic and YouTube download each track to a `NamedTempFile` first (YouTube by - shelling out to `yt-dlp`); Radio streams through a non-seekable ring buffer. -- Qobuz (`src/infra/qobuz/`) plays each track through the web player's - encrypted CMAF stream while it downloads: `stream/progressive.rs` is a - `stream-download` source that yields the decrypted segments (and restarts at - a seek), rebuilt as a FLAC `NamedTempFile` the session keeps. The fetch runs - off the pump behind a `fetch_id` guard; a superseded stream is dropped, which - cancels its download. The transport (`sign.rs`, `stream/`) is pure and unit - tested. The three web-player constants are scraped at runtime (`auth.rs`), - cached in `state.yml`, and overridable through `SPOTATUI_QOBUZ_*` env vars; - they are never embedded. Failures are status messages, never `handle_error`. -- All three platforms play decoded sources. macOS was gated off in - `LocalPlayer` until rodio 0.22 / cpal 0.17 were measured on CoreAudio; the - SIGSEGVs behind that gate (#9/#20) were librespot's own rodio-backend, which - is why librespot still uses portaudio-backend there and this player does not. -- Losing the output device has **two** shapes and only one is an error. cpal - reports the device being *removed* (`DeviceNotAvailable`); it cannot report the - common case - the OS moving its **default output** elsewhere (headphones out, - AirPods in the case), which leaves the stream bound to a device nobody hears. - So `LocalPlayer` both raises a `lost` flag from its own cpal error callback - *and* remembers the device name it opened, comparing it against the current - default in `device_lost()`. It then *refuses* `play_file`/`play_prepared`/ - `stop`/`seek`: those wait on rodio's audio callback with no timeout, and they - run on the serial pump, so blocking one wedges the whole app rather than just - falling silent. Every such wait also goes through `bounded()`, which re-asks - the device every 3s rather than timing out blind - a dead device and a source - stalled on the network are indistinguishable from the caller's side, and - Qobuz's stream stall alone is 60s. - The driver's tick polls `device_lost()` (one device read a second), calls - `LocalPlayer::recover_device()` to rebuild on the new default device (a - spaced, bounded retry - a Bluetooth output takes seconds to come back), and - restages the track: it sets the session's `resume_at` (the seek, and paused - only when `device_removed()` says cpal saw a *removal* or the session was - already paused - a default that merely moved means the user plugged - something in and keeps playing) and dispatches `ReplayCurrentTrack`. The - path that stages the track applies `resume_at` itself - (`infra::queue::restage`), so no `Seek` or `PausePlayback` is ever queued - blind behind work that ends in `play()`. That recovery must stay *before* - every advance block: a dead sink never drains, so `is_finished()` is false - and would otherwise read as a still-playing track. The queue slot has no - replay event: a removal clears `queue_slot_desired_playing`, which the - paths that stage the next item and the suspended context's resume read, - and a device given up on hands the slot to `FinishNativeQueue` (the same - teardown a drained queue runs) rather than dropping `queue_now`. Every - `LocalPlayer` wait runs off the `App` lock (`stop_detached`, - `stop_detached_holding`, `spawn_blocking`): the runner takes that lock on - every frame. -- Repeat/shuffle for decoded sources live in the pure module - `src/infra/queue/mod.rs` (`advance_decision`, `resume_index_after_queue`, …); - state is player-global on `App` (`decoded_repeat`, `decoded_shuffle`). - Repeat-one affects auto-advance only, never a manual skip. Note - `resume_index_after_queue` returning `None` means "context exhausted, tear - down" - the *opposite* of `advance_index`'s `None` ("clamp, no-op"). -- Set the `advancing` flag synchronously before dispatching a track change: the - sink is empty for the whole decode/download, and the tick would otherwise - re-fire auto-advance and skip several tracks. -- Per-source playback state is one `Option` field on `App` - (`local_playback`, …), published only on success; position/pause are read live - from the player at render time. -- `App.active_source` (`core/source.rs`) is **browse scope only** - it never - changes playback routing, so switching sources must not interrupt playback. -- OS integrations (MPRIS, SMTC, macOS Now Playing, Discord RPC, window title) all - read one `PlaybackSnapshot` from `infra/media_metadata.rs`, which checks - decoded sources first so the paused Spotify track is not published. -- Radio tune-in has **three** unbounded steps, all on the serial pump, and all - three are capped in `infra/radio/stream.rs`: connect (`CONNECT_TIMEOUT`), - response headers (`HEADER_TIMEOUT`), and the format probe (`PROBE_TIMEOUT`). - The probe is the subtle one - rodio's symphonia scans for a start-of-stream - marker and a live stream never ends, so a codec it does not register scans - forever. Its `AdtsReader` claims only MPEG-4 ADTS (`ff f1`), not MPEG-2 - (`ff f9`), which much European radio uses. On timeout the fix is to call - `OpenedStream::cancel` - stopping the *download*, not the reader: a probe - waiting for bytes parks inside `read`, so a flag checked between reads is - never seen, while cancelling marks the stream done, wakes every waiter, and - lets the probe thread and its download go. Only `prepare_stream` runs inside - the timed closure: `timeout` abandons a `spawn_blocking` closure but cannot - stop it, and the radio player is shared, so a probe that matched just after - the deadline would otherwise append the new station to the live sink. The - `play_prepared` happens after the timeout check. -- YouTube is unofficial-fragile by design: when it breaks, the fix is a newer - `yt-dlp`, not a spotatui release. One in-repo mitigation: a failed download - retries once through the embedded player clients (`web_embedded,tv_embedded`), - which PO-token enforcement leaves tokenless for embeddable videos - most - label uploads. A non-embeddable gated video still fails. - -### Testing conventions - -- Tests are colocated (`#[cfg(test)] mod tests`) - there is no `tests/` dir. The - only dev-dependency is `tempfile`: HTTP tests bind a real `127.0.0.1:0` - listener into an injected base-URL field; UI tests use ratatui's `TestBackend`. -- `App::new` is `#[cfg(test)]`-only (production uses `App::new_with_state`). - `App::default()` has no IoEvent channel and `dispatch` silently drops events - - tests asserting on IoEvents use the house pattern - `fn app_with_x() -> (App, Receiver)` and keep the receiver alive. -- `IoEvent` derives nothing (not even `Debug`): assert with - `assert!(matches!(rx.try_recv(), Ok(IoEvent::X(..))))`. -- Never `std::env::set_var` in a test (one process, shared threads); split the - env read into a `*_with(value, …)` function and test that. -- Prefer extracting the decision into a pure function or plain-data enum taking - scalars instead of `&App`/`Network` - the house style for testing without a - Spotify client, audio device, network, or real clock. -- Gate whole test modules with `#[cfg(all(test, feature = "…"))]` where needed. -- Test names are behavior sentences without a `test_` prefix - (`enter_on_stats_entry_opens_stats_screen`). -- Shared fixtures: `crate::core::test_helpers` crate-wide; - `crate::core::app::test_support` inside `core/app/`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 570b2623..13bb2c89 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,97 +1,22 @@ -# Contributing to spotatui +# Contributing to degen-radio -Thanks for your interest in spotatui! 💚 We welcome all kinds of contributions, not just code. +Report bugs and propose changes at . -## Ways to Contribute +## Development -### 🐛 Report Bugs -Found something broken? [Open an issue](https://github.com/LargeModGames/spotatui/issues/new) with: -- What you expected vs what happened -- Steps to reproduce -- Your OS and spotatui version +Install a recent stable Rust toolchain plus the platform audio and D-Bus development packages. On Debian/Ubuntu: -### 💡 Suggest Features -Have an idea? Start a [Discussion](https://github.com/LargeModGames/spotatui/discussions) or open an issue. We love hearing what would make spotatui better for you. - -### 📖 Improve Documentation -- Fix typos or unclear instructions -- Add examples or clarify setup steps -- Translate docs (we'd love to support more languages!) - -The wiki is included as a submodule in `spotatui.wiki/`. To contribute: ```bash -git clone --recurse-submodules https://github.com/LargeModGames/spotatui.git -# Make your changes in spotatui.wiki/ -# Then commit and open a PR +sudo apt-get install libasound2-dev libdbus-1-dev pkg-config ``` -### 🎨 Create Themes -Love customization? Add a new theme preset! Check out `ThemePreset` in `src/core/user_config.rs` for examples, and `docs/themes.md` for the theming docs. - -### 🧪 Test on Your Setup -- Try pre-releases and report issues -- Test on unusual setups (BSD, WSL, specific distros) -- Verify audio works with different backends - -### ⭐ Spread the Word -- Star the repo -- Share spotatui with music lovers -- Write about your experience - ---- - -## Code Contributions +Run the application and checks: -### Ground Rules -- Be kind and follow our [Code of Conduct](CODE_OF_CONDUCT.md) -- Open an issue first for new features or larger refactors -- Keep PRs focused and scoped for easier review - -### Getting Set Up - -1. Install a recent stable Rust toolchain (`rustup` recommended) -2. Install platform dependencies from [Development](README.md#development): - - OpenSSL - - `xorg-dev` (Linux; clipboard support) - - PipeWire dev libraries (Linux; audio visualization) - - `portaudio` via Homebrew (macOS) -3. Clone your fork and create a topic branch from `main` - -Run locally: ```bash cargo run +cargo fmt --all --check +cargo check --locked +cargo test --locked ``` -Slim build (no audio/streaming): -```bash -cargo run --no-default-features --features telemetry -``` - -### Before Opening a PR - -Run these checks (the fast local gate): -```bash -cargo fmt --all -cargo clippy --no-default-features --features telemetry -- -D warnings -cargo test --no-default-features --features telemetry -``` - -CI runs a wider five-leg feature matrix (see `.github/workflows/ci.yml`), including -a plain `cargo test` with default features, and passes `--locked` - regenerate -`Cargo.lock` if you touch `Cargo.toml`. See `AGENTS.md` for the full matrix. - -### PR Tips -- Add/adjust tests when changing behavior -- Update `README.md` and `CHANGELOG.md` for user-facing changes -- Include screenshots for UI changes -- Keep commits logical; squashing welcome but not required - ---- - -## Recognition - -We use [all-contributors](https://allcontributors.org/) to recognize everyone who helps—code or not! After your contribution is merged, the maintainer will add you to the contributors list. - -## Questions? - -Start a [Discussion](https://github.com/LargeModGames/spotatui/discussions) or ping us in an issue. We're happy to help! +Keep changes focused. Update `README.md` and `CHANGELOG.md` for user-visible behavior. diff --git a/Cargo.lock b/Cargo.lock index 75173ebd..edf3987c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,77 +2,12 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", -] - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "aligned" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" -dependencies = [ - "as-slice", -] - -[[package]] -name = "aligned-vec" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" -dependencies = [ - "equator", -] - [[package]] name = "allocator-api2" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" -[[package]] -name = "alsa" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" -dependencies = [ - "alsa-sys", - "bitflags 2.13.0", - "cfg-if", - "libc", -] - [[package]] name = "alsa" version = "0.10.0" @@ -95,25 +30,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "annotate-snippets" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "710e8eae58854cdc1790fcb56cca04d712a17be849eeb81da2a724bf4bae2bc4" -dependencies = [ - "anstyle", - "unicode-width", -] - [[package]] name = "anstream" version = "1.0.0" @@ -179,62 +95,12 @@ dependencies = [ "num-traits", ] -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] - -[[package]] -name = "arboard" -version = "3.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" -dependencies = [ - "clipboard-win", - "image", - "log", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation", - "parking_lot", - "percent-encoding", - "windows-sys 0.60.2", - "wl-clipboard-rs", - "x11rb", -] - -[[package]] -name = "arg_enum_proc_macro" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "arrayvec" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" -[[package]] -name = "as-slice" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" -dependencies = [ - "stable_deref_trait", -] - [[package]] name = "async-broadcast" version = "0.7.2" @@ -286,7 +152,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.4", + "rustix", "slab", "windows-sys 0.61.2", ] @@ -317,7 +183,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix 1.1.4", + "rustix", ] [[package]] @@ -343,34 +209,12 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.4", + "rustix", "signal-hook-registry", "slab", "windows-sys 0.61.2", ] -[[package]] -name = "async-stream" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" -dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-stream-impl" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "async-task" version = "4.7.1" @@ -394,61 +238,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" -[[package]] -name = "atomic_refcell" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41e67cd8309bbd06cd603a9e693a784ac2e5d1e955f11286e355089fcab3047c" - [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "av-scenechange" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" -dependencies = [ - "aligned", - "anyhow", - "arg_enum_proc_macro", - "arrayvec", - "log", - "num-rational", - "num-traits", - "pastey 0.1.1", - "rayon", - "thiserror 2.0.18", - "v_frame", - "y4m", -] - -[[package]] -name = "av1-grain" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" -dependencies = [ - "anyhow", - "arrayvec", - "log", - "nom 8.0.0", - "num-rational", - "v_frame", -] - -[[package]] -name = "avif-serialize" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d" -dependencies = [ - "arrayvec", -] - [[package]] name = "aws-lc-rs" version = "1.17.1" @@ -472,68 +267,12 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "backtrace" -version = "0.3.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-link 0.2.1", -] - [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64-simd" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" -dependencies = [ - "outref", - "vsimd", -] - -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - -[[package]] -name = "bindgen" -version = "0.72.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" -dependencies = [ - "annotate-snippets", - "bitflags 2.13.0", - "cexpr", - "clang-sys", - "itertools 0.13.0", - "proc-macro2", - "quote", - "regex", - "rustc-hash", - "shlex", - "syn", -] - -[[package]] -name = "bit_field" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" - [[package]] name = "bitflags" version = "1.3.2" @@ -546,54 +285,6 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" -[[package]] -name = "bitstream-io" -version = "4.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" -dependencies = [ - "core2", -] - -[[package]] -name = "bitvec" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" -dependencies = [ - "funty", - "radium", - "tap", - "wyz", -] - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "block-buffer" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "block-padding" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" -dependencies = [ - "generic-array", -] - [[package]] name = "block2" version = "0.6.2" @@ -616,22 +307,6 @@ dependencies = [ "piper", ] -[[package]] -name = "bstr" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" -dependencies = [ - "memchr", - "serde", -] - -[[package]] -name = "built" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" - [[package]] name = "bumpalo" version = "3.19.1" @@ -649,32 +324,6 @@ name = "bytemuck" version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" -dependencies = [ - "bytemuck_derive", -] - -[[package]] -name = "bytemuck_derive" -version = "1.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "byteorder-lite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" @@ -691,15 +340,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "cbc" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" -dependencies = [ - "cipher", -] - [[package]] name = "cc" version = "1.2.54" @@ -718,25 +358,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" -[[package]] -name = "cexpr" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" -dependencies = [ - "nom 7.1.3", -] - -[[package]] -name = "cfg-expr" -version = "0.20.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78cef5b5a1a6827c7322ae2a636368a573006b27cfa76c7ebd53e834daeaab6a" -dependencies = [ - "smallvec", - "target-lexicon", -] - [[package]] name = "cfg-if" version = "1.0.4" @@ -756,73 +377,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", + "cpufeatures", + "rand_core", ] [[package]] -name = "chrono" -version = "0.4.45" +name = "clap" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link 0.2.1", + "clap_builder", ] [[package]] -name = "cipher" -version = "0.4.4" +name = "clap_builder" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ - "crypto-common 0.1.7", - "inout", -] - -[[package]] -name = "clang-sys" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" -dependencies = [ - "glob", - "libc", - "libloading 0.8.9", -] - -[[package]] -name = "clap" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" -dependencies = [ - "clap_builder", -] - -[[package]] -name = "clap_builder" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_complete" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db8b397918185f0161ff3d6fcaa9e4bfc09b8367caf6e1d4a2848e5477ed027b" -dependencies = [ - "clap", + "anstream", + "anstyle", + "clap_lex", + "strsim", ] [[package]] @@ -831,15 +408,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" -[[package]] -name = "clipboard-win" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" -dependencies = [ - "error-code", -] - [[package]] name = "cmake" version = "0.1.58" @@ -849,34 +417,12 @@ dependencies = [ "cc", ] -[[package]] -name = "cmov" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" - -[[package]] -name = "color_quant" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" - [[package]] name = "colorchoice" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" -[[package]] -name = "colorgrad" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aee94de557db6ddae3ca7b37b5fbe77ed6f159219f1815a8c2c1a9854c73087e" -dependencies = [ - "csscolorparser", - "libm", -] - [[package]] name = "combine" version = "4.6.7" @@ -910,31 +456,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "console" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4" -dependencies = [ - "encode_unicode", - "libc", - "once_cell", - "unicode-width", - "windows-sys 0.61.2", -] - -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - [[package]] name = "convert_case" version = "0.10.0" @@ -944,51 +465,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "cookie" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" -dependencies = [ - "percent-encoding", - "time", - "version_check", -] - -[[package]] -name = "cookie-factory" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" - -[[package]] -name = "cookie_store" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" -dependencies = [ - "cookie", - "document-features", - "idna", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "time", - "url", -] - -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation" version = "0.10.1" @@ -1005,15 +481,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "core2" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" -dependencies = [ - "memchr", -] - [[package]] name = "coreaudio-rs" version = "0.13.0" @@ -1028,46 +495,19 @@ dependencies = [ "objc2-core-foundation", ] -[[package]] -name = "cpal" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbd307f43cc2a697e2d1f8bc7a1d824b5269e052209e28883e5bc04d095aaa3f" -dependencies = [ - "alsa 0.9.1", - "coreaudio-rs", - "dasp_sample", - "jack", - "jni", - "js-sys", - "libc", - "mach2 0.4.3", - "ndk", - "ndk-context", - "num-derive", - "num-traits", - "objc2-audio-toolbox", - "objc2-core-audio", - "objc2-core-audio-types", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "windows 0.54.0", -] - [[package]] name = "cpal" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b1f9c7312f19fc2fa12fd7acaf38de54e8320ba10d1a02dcbe21038def51ccb" dependencies = [ - "alsa 0.10.0", + "alsa", "coreaudio-rs", "dasp_sample", "jni", "js-sys", "libc", - "mach2 0.5.0", + "mach2", "ndk", "ndk-context", "num-derive", @@ -1082,16 +522,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows 0.62.2", -] - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", + "windows", ] [[package]] @@ -1103,40 +534,12 @@ dependencies = [ "libc", ] -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - [[package]] name = "critical-section" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" -[[package]] -name = "crossbeam-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1155,7 +558,7 @@ dependencies = [ "document-features", "mio", "parking_lot", - "rustix 1.1.4", + "rustix", "signal-hook", "signal-hook-mio", "winapi", @@ -1171,385 +574,133 @@ dependencies = [ ] [[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - -[[package]] -name = "crypto-common" -version = "0.1.7" +name = "darling" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "generic-array", - "typenum", + "darling_core", + "darling_macro", ] [[package]] -name = "crypto-common" -version = "0.2.2" +name = "darling_core" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" dependencies = [ - "hybrid-array", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", ] [[package]] -name = "csscolorparser" -version = "0.8.1" +name = "darling_macro" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02ee6eae4d99456f92dc379ba21cf08f783ef5525f193c3854b4e921ece045c5" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "num-traits", - "phf", - "serde", + "darling_core", + "quote", + "syn", ] [[package]] -name = "ctr" -version = "0.9.2" +name = "dasp_sample" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" -dependencies = [ - "cipher", -] +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" [[package]] -name = "ctutils" -version = "0.4.2" +name = "deranged" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" dependencies = [ - "cmov", + "powerfmt", ] [[package]] -name = "curve25519-dalek" -version = "4.1.3" +name = "derive_more" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "curve25519-dalek-derive", - "digest 0.10.7", - "fiat-crypto", - "rustc_version", - "subtle", - "zeroize", + "derive_more-impl", ] [[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" +name = "derive_more-impl" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ + "convert_case", "proc-macro2", "quote", + "rustc_version", "syn", ] [[package]] -name = "darling" -version = "0.20.11" +name = "dirs" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "darling_core 0.20.11", - "darling_macro 0.20.11", + "dirs-sys", ] [[package]] -name = "darling" -version = "0.23.0" +name = "dirs-sys" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", ] [[package]] -name = "darling_core" -version = "0.20.11" +name = "dispatch2" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn", + "bitflags 2.13.0", + "objc2", ] [[package]] -name = "darling_core" -version = "0.23.0" +name = "displaydoc" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ - "ident_case", "proc-macro2", "quote", - "strsim", "syn", ] [[package]] -name = "darling_macro" -version = "0.20.11" +name = "document-features" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" dependencies = [ - "darling_core 0.20.11", - "quote", - "syn", + "litrs", ] [[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core 0.23.0", - "quote", - "syn", -] - -[[package]] -name = "dasp_sample" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" - -[[package]] -name = "data-encoding" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" - -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid 0.9.6", - "pem-rfc7468", - "zeroize", -] - -[[package]] -name = "deranged" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" -dependencies = [ - "powerfmt", -] - -[[package]] -name = "derive_arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "derive_builder" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" -dependencies = [ - "derive_builder_macro", -] - -[[package]] -name = "derive_builder_core" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" -dependencies = [ - "darling 0.20.11", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "derive_builder_macro" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" -dependencies = [ - "derive_builder_core", - "syn", -] - -[[package]] -name = "derive_more" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer 0.10.4", - "const-oid 0.9.6", - "crypto-common 0.1.7", - "subtle", -] - -[[package]] -name = "digest" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer 0.12.0", - "const-oid 0.10.2", - "crypto-common 0.2.2", - "ctutils", -] - -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.61.2", -] - -[[package]] -name = "discord-rich-presence" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90c55d69cab17c19677ce3a5f8face993a9e6eaf847fecac3547f3a3ff4a2494" -dependencies = [ - "log", - "serde", - "serde_derive", - "serde_json", - "serde_repr", - "thiserror 2.0.18", - "uuid 0.8.2", -] - -[[package]] -name = "dispatch2" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" -dependencies = [ - "bitflags 2.13.0", - "block2", - "objc2", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] - -[[package]] -name = "dotenvy" -version = "0.15.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" - -[[package]] -name = "downcast-rs" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" - -[[package]] -name = "dunce" -version = "1.0.5" +name = "dunce" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" -[[package]] -name = "ed25519" -version = "2.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" -dependencies = [ - "pkcs8", - "signature", -] - -[[package]] -name = "ed25519-dalek" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" -dependencies = [ - "curve25519-dalek", - "ed25519", - "serde", - "sha2 0.10.9", - "signature", - "subtle", - "zeroize", -] - [[package]] name = "educe" version = "0.6.0" @@ -1568,12 +719,6 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" -[[package]] -name = "encode_unicode" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" - [[package]] name = "encoding_rs" version = "0.8.35" @@ -1609,18 +754,6 @@ dependencies = [ "syn", ] -[[package]] -name = "enum_dispatch" -version = "0.3.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" -dependencies = [ - "once_cell", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "enumflags2" version = "0.7.12" @@ -1642,43 +775,12 @@ dependencies = [ "syn", ] -[[package]] -name = "equator" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" -dependencies = [ - "equator-macro", -] - -[[package]] -name = "equator-macro" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" -[[package]] -name = "erased-serde" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" -dependencies = [ - "serde", - "serde_core", - "typeid", -] - [[package]] name = "errno" version = "0.3.14" @@ -1689,12 +791,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "error-code" -version = "3.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" - [[package]] name = "event-listener" version = "5.4.1" @@ -1716,21 +812,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "exr" -version = "1.74.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" -dependencies = [ - "bit_field", - "half", - "lebe", - "miniz_oxide", - "rayon-core", - "smallvec", - "zune-inflate", -] - [[package]] name = "extended" version = "0.1.0" @@ -1749,35 +830,6 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" -[[package]] -name = "fax" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" -dependencies = [ - "fax_derive", -] - -[[package]] -name = "fax_derive" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "fdeflate" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" -dependencies = [ - "simd-adler32", -] - [[package]] name = "fern" version = "0.7.1" @@ -1787,52 +839,12 @@ dependencies = [ "log", ] -[[package]] -name = "fiat-crypto" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" - -[[package]] -name = "filetime" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" -dependencies = [ - "cfg-if", - "libc", - "libredox", -] - [[package]] name = "find-msvc-tools" version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" -[[package]] -name = "fixedbitset" -version = "0.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" - -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", - "zlib-rs", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "foldhash" version = "0.1.5" @@ -1845,21 +857,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1875,27 +872,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - -[[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - [[package]] name = "futures-channel" version = "0.3.32" @@ -1903,7 +879,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", - "futures-sink", ] [[package]] @@ -1912,17 +887,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - [[package]] name = "futures-io" version = "0.3.32" @@ -1965,19 +929,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" -[[package]] -name = "futures-timer" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" - [[package]] name = "futures-util" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ - "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1988,26 +945,6 @@ dependencies = [ "slab", ] -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "gethostname" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" -dependencies = [ - "rustix 1.1.4", - "windows-link 0.2.1", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -2040,274 +977,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", - "js-sys", - "libc", - "r-efi 6.0.0", - "rand_core 0.10.1", - "wasip2", - "wasip3", - "wasm-bindgen", -] - -[[package]] -name = "gif" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" -dependencies = [ - "color_quant", - "weezl", -] - -[[package]] -name = "gimli" -version = "0.32.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" - -[[package]] -name = "gio-sys" -version = "0.21.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0071fe88dba8e40086c8ff9bbb62622999f49628344b1d1bf490a48a29d80f22" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", - "windows-sys 0.61.2", -] - -[[package]] -name = "glib" -version = "0.21.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16de123c2e6c90ce3b573b7330de19be649080ec612033d397d72da265f1bd8b" -dependencies = [ - "bitflags 2.13.0", - "futures-channel", - "futures-core", - "futures-executor", - "futures-task", - "futures-util", - "gio-sys", - "glib-macros", - "glib-sys", - "gobject-sys", - "libc", - "memchr", - "smallvec", -] - -[[package]] -name = "glib-macros" -version = "0.21.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf59b675301228a696fe01c3073974643365080a76cc3ed5bc2cbc466ad87f17" -dependencies = [ - "heck", - "proc-macro-crate", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "glib-sys" -version = "0.21.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d95e1a3a19ae464a7286e14af9a90683c64d70c02532d88d87ce95056af3e6c" -dependencies = [ - "libc", - "system-deps", -] - -[[package]] -name = "glob" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" - -[[package]] -name = "gobject-sys" -version = "0.21.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dca35da0d19a18f4575f3cb99fe1c9e029a2941af5662f326f738a21edaf294" -dependencies = [ - "glib-sys", - "libc", - "system-deps", -] - -[[package]] -name = "governor" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8" -dependencies = [ - "cfg-if", - "futures-sink", - "futures-timer", - "futures-util", - "hashbrown 0.16.1", - "nonzero_ext", - "parking_lot", - "portable-atomic", - "smallvec", - "spinning_top", - "web-time", -] - -[[package]] -name = "gstreamer" -version = "0.24.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bed73742c5d54cb48533be608b67d89f96e1ebbba280be7823f1ef995e3a9d7" -dependencies = [ - "cfg-if", - "futures-channel", - "futures-core", - "futures-util", - "glib", - "gstreamer-sys", - "itertools 0.14.0", - "kstring", - "libc", - "muldiv", - "num-integer", - "num-rational", - "option-operations", - "pastey 0.2.1", - "pin-project-lite", - "smallvec", - "thiserror 2.0.18", -] - -[[package]] -name = "gstreamer-app" -version = "0.24.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "895753fb0f976693f321e6b9d68f746ef9095f1a5b8277c11d85d807a949fbfc" -dependencies = [ - "futures-core", - "futures-sink", - "glib", - "gstreamer", - "gstreamer-app-sys", - "gstreamer-base", - "libc", -] - -[[package]] -name = "gstreamer-app-sys" -version = "0.24.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7719cee28afda1a48ab1ee93769628bd0653d3c5be1923bce9a8a4550fcc980" -dependencies = [ - "glib-sys", - "gstreamer-base-sys", - "gstreamer-sys", - "libc", - "system-deps", -] - -[[package]] -name = "gstreamer-audio" -version = "0.24.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92829dbca7c59ed4bf0c9154dd8c0cf3185d6bf9dad821b058b801d9671fa763" -dependencies = [ - "cfg-if", - "glib", - "gstreamer", - "gstreamer-audio-sys", - "gstreamer-base", - "libc", - "smallvec", -] - -[[package]] -name = "gstreamer-audio-sys" -version = "0.24.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6acd80847b78122c45983597f74a29071d63273c1eded14be5f7381301711475" -dependencies = [ - "glib-sys", - "gobject-sys", - "gstreamer-base-sys", - "gstreamer-sys", - "libc", - "system-deps", -] - -[[package]] -name = "gstreamer-base" -version = "0.24.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dd15c7e37d306573766834a5cbdd8ee711265f217b060f40a9a8eda45298488" -dependencies = [ - "atomic_refcell", - "cfg-if", - "glib", - "gstreamer", - "gstreamer-base-sys", - "libc", -] - -[[package]] -name = "gstreamer-base-sys" -version = "0.24.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27a2eda2c61e13c11883bf19b290d07ea6b53d04fd8bfeb7af64b6006c6c9ee6" -dependencies = [ - "glib-sys", - "gobject-sys", - "gstreamer-sys", - "libc", - "system-deps", -] - -[[package]] -name = "gstreamer-sys" -version = "0.24.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d88630697e757c319e7bcec7b13919ba80492532dd3238481c1c4eee05d4904" -dependencies = [ - "cfg-if", - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "h2" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "zerocopy", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core", + "wasip2", + "wasip3", + "wasm-bindgen", ] [[package]] @@ -2341,30 +1017,6 @@ dependencies = [ "foldhash 0.2.0", ] -[[package]] -name = "headers" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" -dependencies = [ - "base64", - "bytes", - "headers-core", - "http", - "httpdate", - "mime", - "sha1 0.10.6", -] - -[[package]] -name = "headers-core" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" -dependencies = [ - "http", -] - [[package]] name = "heck" version = "0.5.0" @@ -2383,42 +1035,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "hkdf" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" -dependencies = [ - "hmac 0.13.0", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest 0.10.7", -] - -[[package]] -name = "hmac" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" -dependencies = [ - "digest 0.11.3", -] - -[[package]] -name = "home" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "http" version = "1.4.0" @@ -2458,21 +1074,6 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hybrid-array" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" -dependencies = [ - "typenum", -] - [[package]] name = "hyper" version = "1.8.1" @@ -2483,7 +1084,6 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", "http", "http-body", "httparse", @@ -2495,26 +1095,6 @@ dependencies = [ "want", ] -[[package]] -name = "hyper-proxy2" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9043b7b23fb0bc4a1c7014c27b50a4fc42cc76206f71d34fc0dfe5b28ddc3faf" -dependencies = [ - "bytes", - "futures-util", - "headers", - "http", - "hyper", - "hyper-tls", - "hyper-util", - "native-tls", - "pin-project-lite", - "tokio", - "tokio-native-tls", - "tower-service", -] - [[package]] name = "hyper-rustls" version = "0.27.7" @@ -2529,23 +1109,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.5", -] - -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", + "webpki-roots", ] [[package]] @@ -2567,35 +1131,9 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", - "system-configuration", "tokio", "tower-service", "tracing", - "windows-registry", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.64" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core 0.62.2", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", ] [[package]] @@ -2690,16 +1228,6 @@ dependencies = [ "urlencoding", ] -[[package]] -name = "icy_sixel" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85518b9086bf01117761b90e7691c0ef3236fa8adfb1fb44dd248fe5f87215d5" -dependencies = [ - "quantette", - "thiserror 2.0.18", -] - [[package]] name = "id-arena" version = "2.3.0" @@ -2733,46 +1261,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "image" -version = "0.25.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" -dependencies = [ - "bytemuck", - "byteorder-lite", - "color_quant", - "exr", - "gif", - "image-webp", - "moxcms", - "num-traits", - "png", - "qoi", - "ravif", - "rayon", - "rgb", - "tiff", - "zune-core", - "zune-jpeg", -] - -[[package]] -name = "image-webp" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" -dependencies = [ - "byteorder-lite", - "quick-error", -] - -[[package]] -name = "imgref" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" - [[package]] name = "indexmap" version = "2.13.0" @@ -2785,19 +1273,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "indicatif" -version = "0.18.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" -dependencies = [ - "console", - "portable-atomic", - "unicode-width", - "unit-prefix", - "web-time", -] - [[package]] name = "indoc" version = "2.0.7" @@ -2807,40 +1282,19 @@ dependencies = [ "rustversion", ] -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "block-padding", - "generic-array", -] - [[package]] name = "instability" version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" dependencies = [ - "darling 0.23.0", + "darling", "indoc", "proc-macro2", "quote", "syn", ] -[[package]] -name = "interpolate_name" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "ipnet" version = "2.11.0" @@ -2857,40 +1311,12 @@ dependencies = [ "serde", ] -[[package]] -name = "is-docker" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" -dependencies = [ - "once_cell", -] - -[[package]] -name = "is-wsl" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" -dependencies = [ - "is-docker", - "once_cell", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.14.0" @@ -2906,33 +1332,6 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" -[[package]] -name = "jack" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef009705c2f230e5ae113e2319a2151913e8d898e075ec44f8372cf33b90cfdd" -dependencies = [ - "bitflags 2.13.0", - "jack-sys", - "lazy_static", - "libc", - "log", -] - -[[package]] -name = "jack-sys" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6013b7619b95a22b576dfb43296faa4ecbe40abbdb97dfd22ead520775fc86ab" -dependencies = [ - "bitflags 1.3.2", - "lazy_static", - "libc", - "libloading 0.7.4", - "log", - "pkg-config", -] - [[package]] name = "jni" version = "0.21.1" @@ -2950,176 +1349,65 @@ dependencies = [ ] [[package]] -name = "jni-sys" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" - -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - -[[package]] -name = "js-sys" -version = "0.3.85" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "kasuari" -version = "0.4.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fe90c1150662e858c7d5f945089b7517b0a80d8bf7ba4b1b5ffc984e7230a5b" -dependencies = [ - "hashbrown 0.16.1", - "portable-atomic", - "thiserror 2.0.18", -] - -[[package]] -name = "keepawake" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5521b450ec179362595d5cbda7c3abd5da3af3dff58456434ad3ca33c95226b7" -dependencies = [ - "cfg-if", - "derive_builder", - "objc2-core-foundation", - "objc2-io-kit", - "thiserror 2.0.18", - "windows 0.62.2", - "zbus", -] - -[[package]] -name = "kstring" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "558bf9508a558512042d3095138b1f7b8fe90c5467d94f9f1da28b3731c5dbd1" -dependencies = [ - "static_assertions", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin", -] - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "lebe" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" - -[[package]] -name = "libc" -version = "0.2.184" +name = "jni-sys" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" [[package]] -name = "libfuzzer-sys" -version = "0.4.12" +name = "jobserver" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "arbitrary", - "cc", + "getrandom 0.3.4", + "libc", ] [[package]] -name = "libloading" -version = "0.7.4" +name = "js-sys" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ - "cfg-if", - "winapi", + "once_cell", + "wasm-bindgen", ] [[package]] -name = "libloading" -version = "0.8.9" +name = "kasuari" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +checksum = "8fe90c1150662e858c7d5f945089b7517b0a80d8bf7ba4b1b5ffc984e7230a5b" dependencies = [ - "cfg-if", - "windows-link 0.2.1", + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", ] [[package]] -name = "libm" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" - -[[package]] -name = "libpulse-binding" -version = "2.30.1" +name = "lazy_static" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "909eb3049e16e373680fe65afe6e2a722ace06b671250cc4849557bc57d6a397" -dependencies = [ - "bitflags 2.13.0", - "libc", - "libpulse-sys", - "num-derive", - "num-traits", - "winapi", -] +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] -name = "libpulse-simple-binding" -version = "2.29.0" +name = "leb128fmt" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7bebef0381c8e3e4b23cc24aaf36fab37472bece128de96f6a111efa464cfef" -dependencies = [ - "libpulse-binding", - "libpulse-simple-sys", - "libpulse-sys", -] +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] -name = "libpulse-simple-sys" -version = "1.22.0" +name = "libc" +version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bd96888fe37ad270d16abf5e82cccca1424871cf6afa2861824d2a52758eebc" -dependencies = [ - "libpulse-sys", - "pkg-config", -] +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" [[package]] -name = "libpulse-sys" -version = "1.23.0" +name = "libm" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d74371848b22e989f829cc1621d2ebd74960711557d8b45cfe740f60d0a05e61" -dependencies = [ - "libc", - "num-derive", - "num-traits", - "pkg-config", - "winapi", -] +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "libredox" @@ -3129,34 +1417,6 @@ checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ "bitflags 2.13.0", "libc", - "redox_syscall 0.7.0", -] - -[[package]] -name = "libspa" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2909f3be29d674e7f10604aff18d1bbe1bb03c4cd61c8a8ba19c0b1d162f7d4e" -dependencies = [ - "bitflags 2.13.0", - "cc", - "cookie-factory", - "libc", - "libspa-sys", - "nom 8.0.0", - "rustix 1.1.4", - "system-deps", -] - -[[package]] -name = "libspa-sys" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69ad52764fca54818486f3cf75afec844d1f1a1568c24dcee25d41b1ab007dda" -dependencies = [ - "bindgen", - "cc", - "system-deps", ] [[package]] @@ -3168,12 +1428,6 @@ dependencies = [ "bitflags 2.13.0", ] -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -3201,47 +1455,12 @@ dependencies = [ "scopeguard", ] -[[package]] -name = "lofty" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec4feeff6c7d75093278133a06e827d7af6d2bfe20b0f331f9d10338a5ec7ca" -dependencies = [ - "byteorder", - "data-encoding", - "flate2", - "lofty_attr", - "log", - "ogg_pager", - "paste", -] - -[[package]] -name = "lofty_attr" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "458ace39169e4b83c4f77ae3d42d5d1d11c422feef590219a97c973d3b524557" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "log" version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" -[[package]] -name = "loop9" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" -dependencies = [ - "imgref", -] - [[package]] name = "lru" version = "0.18.0" @@ -3257,34 +1476,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "lua-src" -version = "550.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75c110c2fa33f34e0de05448e1f3eb2e0631e7a69e2d8ae1586cffc9fc9f9949" -dependencies = [ - "cc", -] - -[[package]] -name = "luajit-src" -version = "210.7.2+b925b3e" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "920cf654b23d217c550ceea57c32cd2a413ea27b6d47ed77b5ee0cf655adefa6" -dependencies = [ - "cc", - "which 8.0.3", -] - -[[package]] -name = "mach2" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" -dependencies = [ - "libc", -] - [[package]] name = "mach2" version = "0.5.0" @@ -3294,37 +1485,6 @@ dependencies = [ "libc", ] -[[package]] -name = "maybe-async" -version = "0.2.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cf92c10c7e361d6b99666ec1c6f9805b0bea2c3bd8c78dc6fe98ac5bd78db11" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "maybe-rayon" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" -dependencies = [ - "cfg-if", - "rayon", -] - -[[package]] -name = "md-5" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" -dependencies = [ - "cfg-if", - "digest 0.11.3", -] - [[package]] name = "mediatype" version = "0.21.0" @@ -3346,28 +1506,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - [[package]] name = "mio" version = "1.2.0" @@ -3380,48 +1518,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "mlua" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad72ffa037cf5970c9860674f32f703fda25d86cf217475fe7a79c5f9961bcaa" -dependencies = [ - "bstr", - "either", - "erased-serde", - "libc", - "mlua-sys", - "num-traits", - "parking_lot", - "rustc-hash", - "serde", - "serde-value", -] - -[[package]] -name = "mlua-sys" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92136787b906d4e55cfe96cd6c62e010bb1a56889d0d6cf83eb016dbad07576b" -dependencies = [ - "cc", - "cfg-if", - "libc", - "lua-src", - "luajit-src", - "pkg-config", -] - -[[package]] -name = "moxcms" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" -dependencies = [ - "num-traits", - "pxfm", -] - [[package]] name = "mpris-server" version = "0.10.0" @@ -3435,29 +1531,6 @@ dependencies = [ "zbus", ] -[[package]] -name = "muldiv" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "956787520e75e9bd233246045d19f42fb73242759cc57fba9611d940ae96d4b0" - -[[package]] -name = "native-tls" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe 0.1.6", - "openssl-sys", - "schannel", - "security-framework 2.11.1", - "security-framework-sys", - "tempfile", -] - [[package]] name = "ndk" version = "0.9.0" @@ -3469,102 +1542,31 @@ dependencies = [ "log", "ndk-sys", "num_enum", - "thiserror 1.0.69", -] - -[[package]] -name = "ndk-context" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" - -[[package]] -name = "ndk-sys" -version = "0.6.0+11769913" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" -dependencies = [ - "jni-sys", -] - -[[package]] -name = "new_debug_unreachable" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - -[[package]] -name = "nom" -version = "8.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" -dependencies = [ - "memchr", -] - -[[package]] -name = "nonzero_ext" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" - -[[package]] -name = "noop_proc_macro" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" - -[[package]] -name = "ntapi" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c70f219e21142367c70c0b30c6a9e3a14d55b4d12a204d897fbec83a0363f081" -dependencies = [ - "winapi", -] - -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", + "thiserror 1.0.69", ] [[package]] -name = "num-bigint-dig" -version = "0.8.6" +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" dependencies = [ - "lazy_static", - "libm", - "num-integer", - "num-iter", - "num-traits", - "rand 0.8.7", - "smallvec", - "zeroize", + "jni-sys", ] [[package]] -name = "num-complex" +name = "num-bigint" version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ + "num-integer", "num-traits", ] @@ -3594,17 +1596,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-iter" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - [[package]] name = "num-rational" version = "0.4.2" @@ -3657,26 +1648,6 @@ dependencies = [ "libc", ] -[[package]] -name = "oauth2" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" -dependencies = [ - "base64", - "chrono", - "getrandom 0.2.17", - "http", - "rand 0.8.7", - "reqwest 0.12.28", - "serde", - "serde_json", - "serde_path_to_error", - "sha2 0.10.9", - "thiserror 1.0.69", - "url", -] - [[package]] name = "objc2" version = "0.6.4" @@ -3686,18 +1657,6 @@ dependencies = [ "objc2-encode", ] -[[package]] -name = "objc2-app-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" -dependencies = [ - "bitflags 2.13.0", - "objc2", - "objc2-core-graphics", - "objc2-foundation", -] - [[package]] name = "objc2-audio-toolbox" version = "0.3.2" @@ -3713,17 +1672,6 @@ dependencies = [ "objc2-foundation", ] -[[package]] -name = "objc2-av-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "478ae33fcac9df0a18db8302387c666b8ef08a3e2d62b510ca4fc278a384b6c0" -dependencies = [ - "bitflags 2.13.0", - "objc2", - "objc2-foundation", -] - [[package]] name = "objc2-avf-audio" version = "0.3.2" @@ -3770,19 +1718,6 @@ dependencies = [ "objc2", ] -[[package]] -name = "objc2-core-graphics" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" -dependencies = [ - "bitflags 2.13.0", - "dispatch2", - "objc2", - "objc2-core-foundation", - "objc2-io-surface", -] - [[package]] name = "objc2-encode" version = "4.1.0" @@ -3802,64 +1737,6 @@ dependencies = [ "objc2-core-foundation", ] -[[package]] -name = "objc2-io-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" -dependencies = [ - "bitflags 2.13.0", - "block2", - "dispatch2", - "libc", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-io-surface" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" -dependencies = [ - "bitflags 2.13.0", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-media-player" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85f2a98483f6e76313cb85a5a185eaa3cda86a177b13a8852d56cbd0085bf635" -dependencies = [ - "bitflags 2.13.0", - "block2", - "objc2", - "objc2-app-kit", - "objc2-av-foundation", - "objc2-core-foundation", - "objc2-foundation", -] - -[[package]] -name = "object" -version = "0.37.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "memchr", -] - -[[package]] -name = "ogg_pager" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d36b1d6964c3ac92b7aea701057e02b6b91143d70d83b20abf75a231a3c0216" -dependencies = [ - "byteorder", -] - [[package]] name = "once_cell" version = "1.21.3" @@ -3872,108 +1749,18 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "open" -version = "5.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5" -dependencies = [ - "is-wsl", - "libc", -] - -[[package]] -name = "openssl" -version = "0.10.81" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" -dependencies = [ - "bitflags 2.13.0", - "cfg-if", - "foreign-types", - "libc", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "openssl-src" -version = "300.5.4+3.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a507b3792995dae9b0df8a1c1e3771e8418b7c2d9f0baeba32e6fe8b06c7cb72" -dependencies = [ - "cc", -] - -[[package]] -name = "openssl-sys" -version = "0.9.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" -dependencies = [ - "cc", - "libc", - "openssl-src", - "pkg-config", - "vcpkg", -] - [[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" -[[package]] -name = "option-operations" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aca39cf52b03268400c16eeb9b56382ea3c3353409309b63f5c8f0b1faf42754" -dependencies = [ - "pastey 0.2.1", -] - -[[package]] -name = "ordered-float" -version = "2.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" -dependencies = [ - "num-traits", -] - -[[package]] -name = "ordered-float" -version = "5.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4779c6901a562440c3786d08192c6fbda7c1c2060edd10006b05ee35d10f2d" -dependencies = [ - "num-traits", -] - [[package]] name = "ordered-stream" version = "0.2.0" @@ -3985,173 +1772,63 @@ dependencies = [ ] [[package]] -name = "os_pipe" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "outref" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" - -[[package]] -name = "palette" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" -dependencies = [ - "approx", - "bytemuck", - "fast-srgb8", - "libm", - "palette_derive", -] - -[[package]] -name = "palette_derive" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" -dependencies = [ - "by_address", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall 0.5.18", - "smallvec", - "windows-link 0.2.1", -] - -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "pastey" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" - -[[package]] -name = "pastey" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" - -[[package]] -name = "pbkdf2" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" -dependencies = [ - "digest 0.10.7", - "hmac 0.12.1", -] - -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" +name = "palette" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "libm", + "palette_derive", +] [[package]] -name = "petgraph" -version = "0.8.3" +name = "palette_derive" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" dependencies = [ - "fixedbitset", - "hashbrown 0.15.5", - "indexmap", + "by_address", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "phf" -version = "0.13.1" +name = "parking" +version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" -dependencies = [ - "phf_macros", - "phf_shared", - "serde", -] +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] -name = "phf_generator" -version = "0.13.1" +name = "parking_lot" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ - "fastrand", - "phf_shared", + "lock_api", + "parking_lot_core", ] [[package]] -name = "phf_macros" -version = "0.13.1" +name = "parking_lot_core" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ - "phf_generator", - "phf_shared", - "proc-macro2", - "quote", - "syn", - "uncased", + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", ] [[package]] -name = "phf_shared" -version = "0.13.1" +name = "percent-encoding" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" -dependencies = [ - "siphasher", - "uncased", -] +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pin-project-lite" @@ -4176,71 +1853,12 @@ dependencies = [ "futures-io", ] -[[package]] -name = "pipewire" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8585aba8a52ad74ccc633b8e293c1dc4277976bd5d510b925533f34fd6685f38" -dependencies = [ - "bitflags 2.13.0", - "libc", - "libspa", - "libspa-sys", - "pipewire-sys", - "rustix 1.1.4", -] - -[[package]] -name = "pipewire-sys" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2089f245b548723e60325773c27f586b7a2372c79ea941b246cd0d654706adc" -dependencies = [ - "bindgen", - "libspa-sys", - "system-deps", -] - -[[package]] -name = "pkcs1" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" -dependencies = [ - "der", - "pkcs8", - "spki", -] - -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] - [[package]] name = "pkg-config" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" -[[package]] -name = "png" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" -dependencies = [ - "bitflags 2.13.0", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - [[package]] name = "polling" version = "3.11.0" @@ -4251,7 +1869,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.4", + "rustix", "windows-sys 0.61.2", ] @@ -4261,27 +1879,6 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" -[[package]] -name = "portaudio-rs" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb6b5eff96ccc9bf44d34c379ab03ae944426d83d1694345bdf8159d561d562" -dependencies = [ - "bitflags 1.3.2", - "libc", - "portaudio-sys", -] - -[[package]] -name = "portaudio-sys" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5194a4fa953b4ffd851c320ef6f0484cd7278cb7169ea9d6c433e49b23f7b7f5" -dependencies = [ - "libc", - "pkg-config", -] - [[package]] name = "potential_utf" version = "0.1.4" @@ -4297,15 +1894,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "prettyplease" version = "0.2.37" @@ -4316,26 +1904,6 @@ dependencies = [ "syn", ] -[[package]] -name = "primal-check" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" -dependencies = [ - "num-integer", -] - -[[package]] -name = "priority-queue" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93980406f12d9f8140ed5abe7155acb10bb1e69ea55c88960b9c2f117445ef96" -dependencies = [ - "equivalent", - "indexmap", - "serde", -] - [[package]] name = "proc-macro-crate" version = "3.4.0" @@ -4354,150 +1922,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "profiling" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" -dependencies = [ - "profiling-procmacros", -] - -[[package]] -name = "profiling-procmacros" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" -dependencies = [ - "quote", - "syn", -] - -[[package]] -name = "protobuf" -version = "3.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4" -dependencies = [ - "once_cell", - "protobuf-support", - "thiserror 1.0.69", -] - -[[package]] -name = "protobuf-codegen" -version = "3.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d3976825c0014bbd2f3b34f0001876604fe87e0c86cd8fa54251530f1544ace" -dependencies = [ - "anyhow", - "once_cell", - "protobuf", - "protobuf-parse", - "regex", - "tempfile", - "thiserror 1.0.69", -] - -[[package]] -name = "protobuf-json-mapping" -version = "3.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0d6e4be637b310d8a5c02fa195243328e2d97fa7df1127a27281ef1187fcb1d" -dependencies = [ - "protobuf", - "protobuf-support", - "thiserror 1.0.69", -] - -[[package]] -name = "protobuf-parse" -version = "3.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4aeaa1f2460f1d348eeaeed86aea999ce98c1bded6f089ff8514c9d9dbdc973" -dependencies = [ - "anyhow", - "indexmap", - "log", - "protobuf", - "protobuf-support", - "tempfile", - "thiserror 1.0.69", - "which 4.4.2", -] - -[[package]] -name = "protobuf-support" -version = "3.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6" -dependencies = [ - "thiserror 1.0.69", -] - -[[package]] -name = "pxfm" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" -dependencies = [ - "num-traits", -] - -[[package]] -name = "qoi" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "quantette" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c98fecda8b16396ff9adac67644a523dd1778c42b58606a29df5c31ca925d174" -dependencies = [ - "bitvec", - "bytemuck", - "image", - "libm", - "num-traits", - "ordered-float 5.1.0", - "palette", - "rand 0.9.2", - "rand_xoshiro", - "rayon", - "ref-cast", - "wide", -] - -[[package]] -name = "quick-error" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" - -[[package]] -name = "quick-xml" -version = "0.38.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" -dependencies = [ - "memchr", - "serde", -] - -[[package]] -name = "quick-xml" -version = "0.39.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" -dependencies = [ - "memchr", -] - [[package]] name = "quinn" version = "0.11.9" @@ -4528,7 +1952,7 @@ dependencies = [ "bytes", "getrandom 0.4.2", "lru-slab", - "rand 0.10.2", + "rand", "rand_pcg", "ring", "rustc-hash", @@ -4572,84 +1996,19 @@ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - -[[package]] -name = "rand" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" -dependencies = [ - "chacha20", - "getrandom 0.4.2", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.6.4" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] -name = "rand_core" -version = "0.9.5" +name = "rand" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "getrandom 0.3.4", + "chacha20", + "getrandom 0.4.2", + "rand_core", ] [[package]] @@ -4658,16 +2017,6 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" -[[package]] -name = "rand_distr" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" -dependencies = [ - "num-traits", - "rand 0.9.2", -] - [[package]] name = "rand_distr" version = "0.6.0" @@ -4675,7 +2024,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8" dependencies = [ "num-traits", - "rand 0.10.2", + "rand", ] [[package]] @@ -4684,16 +2033,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core 0.10.1", -] - -[[package]] -name = "rand_xoshiro" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" -dependencies = [ - "rand_core 0.9.5", + "rand_core", ] [[package]] @@ -4725,12 +2065,12 @@ dependencies = [ "compact_str", "critical-section", "hashbrown 0.17.1", - "itertools 0.14.0", + "itertools", "kasuari", "lru", "palette", "serde", - "strum 0.28.0", + "strum", "thiserror 2.0.18", "unicode-segmentation", "unicode-truncate", @@ -4749,23 +2089,6 @@ dependencies = [ "ratatui-core", ] -[[package]] -name = "ratatui-image" -version = "11.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e000b7a22eae639460bc6ec8bb1cc689ecae5b0ed21935cd7d7dd52d38270c86" -dependencies = [ - "base64-simd", - "icy_sixel", - "image", - "rand 0.8.7", - "ratatui", - "rustix 0.38.44", - "self_cell", - "thiserror 1.0.69", - "windows 0.58.0", -] - [[package]] name = "ratatui-widgets" version = "0.3.2" @@ -4776,95 +2099,16 @@ dependencies = [ "hashbrown 0.17.1", "indoc", "instability", - "itertools 0.14.0", + "itertools", "line-clipping", "ratatui-core", "serde", - "strum 0.28.0", + "strum", "time", "unicode-segmentation", "unicode-width", ] -[[package]] -name = "rav1e" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" -dependencies = [ - "aligned-vec", - "arbitrary", - "arg_enum_proc_macro", - "arrayvec", - "av-scenechange", - "av1-grain", - "bitstream-io", - "built", - "cfg-if", - "interpolate_name", - "itertools 0.14.0", - "libc", - "libfuzzer-sys", - "log", - "maybe-rayon", - "new_debug_unreachable", - "noop_proc_macro", - "num-derive", - "num-traits", - "paste", - "profiling", - "rand 0.9.2", - "rand_chacha 0.9.0", - "simd_helpers", - "thiserror 2.0.18", - "v_frame", - "wasm-bindgen", -] - -[[package]] -name = "ravif" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" -dependencies = [ - "avif-serialize", - "imgref", - "loop9", - "quick-error", - "rav1e", - "rayon", - "rgb", -] - -[[package]] -name = "rayon" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - -[[package]] -name = "realfft" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f821338fddb99d089116342c46e9f1fbf3828dba077674613e734e01d6ea8677" -dependencies = [ - "rustfft", -] - [[package]] name = "redox_syscall" version = "0.5.18" @@ -4874,15 +2118,6 @@ dependencies = [ "bitflags 2.13.0", ] -[[package]] -name = "redox_syscall" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" -dependencies = [ - "bitflags 2.13.0", -] - [[package]] name = "redox_users" version = "0.5.2" @@ -4894,61 +2129,6 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "ref-cast" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "regex" -version = "1.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-lite" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" - -[[package]] -name = "regex-syntax" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" - [[package]] name = "reqwest" version = "0.12.28" @@ -4957,19 +2137,15 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", - "futures-channel", "futures-core", - "futures-util", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", - "hyper-tls", "hyper-util", "js-sys", "log", - "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -4980,18 +2156,15 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-native-tls", "tokio-rustls", - "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams 0.4.2", "web-sys", - "webpki-roots 1.0.5", + "webpki-roots", ] [[package]] @@ -5002,31 +2175,24 @@ checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ "base64", "bytes", - "futures-channel", "futures-core", "futures-util", - "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", - "hyper-tls", "hyper-util", "js-sys", "log", - "native-tls", "percent-encoding", "pin-project-lite", "quinn", "rustls", "rustls-pki-types", "rustls-platform-verifier", - "serde", - "serde_json", "sync_wrapper", "tokio", - "tokio-native-tls", "tokio-rustls", "tokio-util", "tower", @@ -5035,16 +2201,10 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams 0.5.0", + "wasm-streams", "web-sys", ] -[[package]] -name = "rgb" -version = "0.8.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" - [[package]] name = "ring" version = "0.17.14" @@ -5059,111 +2219,19 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rodio" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40ecf59e742e03336be6a3d53755e789fd05a059fa22dfa0ed624722319e183" -dependencies = [ - "cpal 0.16.0", - "dasp_sample", - "num-rational", -] - [[package]] name = "rodio" version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a536bb79db59098ef71a4dd4246c02eb87b316deceb1b68e0cde7167ec01eb" dependencies = [ - "cpal 0.17.1", + "cpal", "dasp_sample", "num-rational", - "rand 0.10.2", - "rand_distr 0.6.0", + "rand", + "rand_distr", "rtrb", - "symphonia 0.5.5", - "thiserror 2.0.18", -] - -[[package]] -name = "rsa" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" -dependencies = [ - "const-oid 0.9.6", - "digest 0.10.7", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8", - "rand_core 0.6.4", - "signature", - "spki", - "subtle", - "zeroize", -] - -[[package]] -name = "rspotify" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f906f59b800945449fbcb8ae4e3bcc971e3350e116a560e7aafd5cfadc605a56" -dependencies = [ - "async-stream", - "async-trait", - "base64", - "chrono", - "dotenvy", - "futures", - "getrandom 0.3.4", - "getrandom 0.4.2", - "log", - "maybe-async", - "rspotify-http", - "rspotify-macros", - "rspotify-model", - "serde", - "serde_json", - "sha2 0.10.9", - "thiserror 2.0.18", - "url", - "webbrowser", -] - -[[package]] -name = "rspotify-http" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35d53699d56aa4412f56321600b6c816288cb6f60af6311236f47f8329e9fe5e" -dependencies = [ - "async-trait", - "log", - "maybe-async", - "reqwest 0.12.28", - "serde_json", - "thiserror 2.0.18", -] - -[[package]] -name = "rspotify-macros" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45373be0355f53d1ca1065b9585be9924c7090c4bdc327ebf5a7924feeea6325" - -[[package]] -name = "rspotify-model" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84b95fb66a9915464f94c7ce94af7f7b429708583bfd6b612f165f335364a8c5" -dependencies = [ - "chrono", - "enum_dispatch", - "serde", - "serde_json", - "strum 0.27.2", + "symphonia", "thiserror 2.0.18", ] @@ -5173,12 +2241,6 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ade083ccbb4bf536df69d1f6432cc23deb7acccff86b183f3923a6fd56a1153" -[[package]] -name = "rustc-demangle" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" - [[package]] name = "rustc-hash" version = "2.1.1" @@ -5194,33 +2256,6 @@ dependencies = [ "semver", ] -[[package]] -name = "rustfft" -version = "6.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" -dependencies = [ - "num-complex", - "num-integer", - "num-traits", - "primal-check", - "strength_reduce", - "transpose", -] - -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags 2.13.0", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - [[package]] name = "rustix" version = "1.1.4" @@ -5230,7 +2265,7 @@ dependencies = [ "bitflags 2.13.0", "errno", "libc", - "linux-raw-sys 0.12.1", + "linux-raw-sys", "windows-sys 0.61.2", ] @@ -5255,10 +2290,10 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ - "openssl-probe 0.2.1", + "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.5.1", + "security-framework", ] [[package]] @@ -5277,7 +2312,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ - "core-foundation 0.10.1", + "core-foundation", "core-foundation-sys", "jni", "log", @@ -5286,7 +2321,7 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework 3.5.1", + "security-framework", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -5322,15 +2357,6 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" -[[package]] -name = "safe_arch" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "629516c85c29fe757770fa03f2074cf1eac43d44c02a3de9fc2ef7b0e207dfdd" -dependencies = [ - "bytemuck", -] - [[package]] name = "same-file" version = "1.0.6" @@ -5355,42 +2381,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sdl2" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d42407afc6a8ab67e36f92e80b8ba34cbdc55aaeed05249efe9a2e8d0e9feef" -dependencies = [ - "bitflags 1.3.2", - "lazy_static", - "libc", - "sdl2-sys", -] - -[[package]] -name = "sdl2-sys" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff61407fc75d4b0bbc93dc7e4d6c196439965fbef8e4a4f003a36095823eac0" -dependencies = [ - "cfg-if", - "libc", - "version-compare 0.1.1", -] - -[[package]] -name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags 2.13.0", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - [[package]] name = "security-framework" version = "3.5.1" @@ -5398,7 +2388,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" dependencies = [ "bitflags 2.13.0", - "core-foundation 0.10.1", + "core-foundation", "core-foundation-sys", "libc", "security-framework-sys", @@ -5407,54 +2397,11 @@ dependencies = [ [[package]] name = "security-framework-sys" version = "2.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "self-replace" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ec815b5eab420ab893f63393878d89c90fdd94c0bcc44c07abb8ad95552fb7" -dependencies = [ - "fastrand", - "tempfile", - "windows-sys 0.52.0", -] - -[[package]] -name = "self_cell" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" - -[[package]] -name = "self_update" -version = "0.44.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e79722b5a505d4ddc77527455a97244e9e8c4c07533ff44cf4421cce7bb6d17" -dependencies = [ - "either", - "flate2", - "http", - "indicatif", - "log", - "quick-xml 0.38.4", - "regex", - "reqwest 0.13.2", - "self-replace", - "semver", - "serde", - "serde_json", - "tar", - "tempfile", - "ureq", - "urlencoding", - "zip", - "zipsign-api", +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", ] [[package]] @@ -5473,16 +2420,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "serde-value" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" -dependencies = [ - "ordered-float 2.10.1", - "serde", -] - [[package]] name = "serde_core" version = "1.0.228" @@ -5516,17 +2453,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - [[package]] name = "serde_repr" version = "0.1.20" @@ -5538,15 +2464,6 @@ dependencies = [ "syn", ] -[[package]] -name = "serde_spanned" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" -dependencies = [ - "serde_core", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -5572,65 +2489,6 @@ dependencies = [ "unsafe-libyaml", ] -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha1" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.3", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.3", -] - -[[package]] -name = "shannon" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ea5b41c9427b56caa7b808cb548a04fb50bb5b9e98590b53f28064ff4174561" -dependencies = [ - "byteorder", -] - -[[package]] -name = "shell-words" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" - [[package]] name = "shlex" version = "1.3.0" @@ -5668,37 +2526,6 @@ dependencies = [ "libc", ] -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "digest 0.10.7", - "rand_core 0.6.4", -] - -[[package]] -name = "simd-adler32" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" - -[[package]] -name = "simd_helpers" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" -dependencies = [ - "quote", -] - -[[package]] -name = "siphasher" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" - [[package]] name = "slab" version = "0.4.11" @@ -5711,17 +2538,6 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "smtc-tokio" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efdb8287e392ee9fe68f7dbf87d5673e6ed465f0aaab99b0e6680c346fdc1e6e" -dependencies = [ - "tokio", - "windows 0.62.2", - "windows-strings 0.5.1", -] - [[package]] name = "socket2" version = "0.6.3" @@ -5732,287 +2548,31 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "socks" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" -dependencies = [ - "byteorder", - "libc", - "winapi", -] - -[[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" - -[[package]] -name = "spinning_top" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" -dependencies = [ - "lock_api", -] - -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - [[package]] name = "spotatui" -version = "0.42.0" +version = "0.43.0-radio.1" dependencies = [ - "aes", "anyhow", - "arboard", - "backtrace", - "base64", - "block2", - "bytes", - "cbc", - "chrono", "clap", - "clap_complete", - "colorgrad", - "cpal 0.17.1", "crossterm", - "ctr", "dirs", - "discord-rich-presence", "fern", - "futures", - "hex", - "hkdf", "icy-metadata", - "image", - "keepawake", - "lofty", "log", - "md-5", - "mlua", "mpris-server", - "objc2", - "objc2-app-kit", - "objc2-foundation", - "objc2-media-player", - "open", - "openssl", - "openssl-sys", - "pipewire", - "protobuf", - "rand 0.10.2", + "rand", "ratatui", - "ratatui-image", - "realfft", - "regex", "reqwest 0.12.28", - "rodio 0.22.2", - "rspotify", - "self_update", + "rodio", "serde", "serde_json", "serde_yaml", - "sha2 0.11.0", - "smtc-tokio", - "spotatui-librespot-connect", - "spotatui-librespot-core", - "spotatui-librespot-metadata", - "spotatui-librespot-oauth", - "spotatui-librespot-playback", - "spotatui-librespot-protocol", "stream-download", - "symphonia 0.6.0", - "tempfile", - "tokio", - "tokio-tungstenite 0.30.0", - "tui-bar-graph", - "unicode-width", - "url", - "vergen", -] - -[[package]] -name = "spotatui-librespot-audio" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4323a85ca33dc31eab5f4d6f4ad539450e693153d4ac156524020d9a6ccc09cc" -dependencies = [ - "aes", - "bytes", - "ctr", - "futures-util", - "http-body-util", - "hyper", - "hyper-util", - "log", - "spotatui-librespot-core", "tempfile", - "thiserror 2.0.18", - "tokio", -] - -[[package]] -name = "spotatui-librespot-connect" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f35486fe6e0877df6125718c81907f1ef93abcfbcece9f4c48ac78b535d0cb" -dependencies = [ - "futures-util", - "log", - "protobuf", - "rand 0.9.2", - "serde_json", - "spotatui-librespot-core", - "spotatui-librespot-playback", - "spotatui-librespot-protocol", - "thiserror 2.0.18", - "tokio", - "tokio-stream", - "uuid 1.19.0", -] - -[[package]] -name = "spotatui-librespot-core" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d37d2d4de620069919084341d5c05fed4c58ddd05fb4ea54d5ee6626c2e01890" -dependencies = [ - "aes", - "base64", - "byteorder", - "bytes", - "data-encoding", - "flate2", - "form_urlencoded", - "futures-core", - "futures-util", - "governor", - "hmac 0.12.1", - "http", - "http-body-util", - "httparse", - "hyper", - "hyper-proxy2", - "hyper-tls", - "hyper-util", - "log", - "nonzero_ext", - "num-bigint", - "num-derive", - "num-integer", - "num-traits", - "pbkdf2", - "pin-project-lite", - "priority-queue", - "protobuf", - "protobuf-json-mapping", - "quick-xml 0.38.4", - "rand 0.9.2", - "rand_distr 0.5.1", - "rsa", - "serde", - "serde_json", - "sha1 0.10.6", - "shannon", - "spotatui-librespot-oauth", - "spotatui-librespot-protocol", - "sysinfo", - "thiserror 2.0.18", - "time", "tokio", - "tokio-stream", - "tokio-tungstenite 0.28.0", - "tokio-util", - "url", - "uuid 1.19.0", - "vergen-gitcl", -] - -[[package]] -name = "spotatui-librespot-metadata" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "505a730134dc62ece09147c832b6d0bd05175c77d4b3e630f0c20c9d459fd6b3" -dependencies = [ - "async-trait", - "bytes", - "log", - "protobuf", - "serde", - "serde_json", - "spotatui-librespot-core", - "spotatui-librespot-protocol", - "thiserror 2.0.18", - "uuid 1.19.0", -] - -[[package]] -name = "spotatui-librespot-oauth" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88426b06aeefe49e343780db5e4247a43079456d806cccc855f1b9994b556263" -dependencies = [ - "log", - "oauth2", - "open", - "reqwest 0.12.28", - "thiserror 2.0.18", "url", ] -[[package]] -name = "spotatui-librespot-playback" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26f14436fa70657b9ebfa54729338c310480a57cd48032a190b9469265860a1b" -dependencies = [ - "alsa 0.10.0", - "cpal 0.16.0", - "form_urlencoded", - "futures-util", - "gstreamer", - "gstreamer-app", - "gstreamer-audio", - "jack", - "libpulse-binding", - "libpulse-simple-binding", - "log", - "portable-atomic", - "portaudio-rs", - "rand 0.9.2", - "rand_distr 0.5.1", - "rodio 0.21.1", - "sdl2", - "shell-words", - "spotatui-librespot-audio", - "spotatui-librespot-core", - "spotatui-librespot-metadata", - "symphonia 0.5.5", - "thiserror 2.0.18", - "tokio", - "zerocopy", -] - -[[package]] -name = "spotatui-librespot-protocol" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "955e5707148d4cf73eb5f829cb2a719b34559f1ad0f99adb58481fca9e8f29ad" -dependencies = [ - "protobuf", - "protobuf-codegen", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -6044,46 +2604,19 @@ dependencies = [ "tracing", ] -[[package]] -name = "strength_reduce" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" - [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "strum" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" -dependencies = [ - "strum_macros 0.27.2", -] - [[package]] name = "strum" version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" dependencies = [ - "strum_macros 0.28.0", -] - -[[package]] -name = "strum_macros" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", + "strum_macros", ] [[package]] @@ -6108,85 +2641,43 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" name = "symphonia" version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" -dependencies = [ - "lazy_static", - "symphonia-bundle-flac 0.5.5", - "symphonia-bundle-mp3 0.5.5", - "symphonia-codec-aac", - "symphonia-codec-pcm 0.5.5", - "symphonia-codec-vorbis 0.5.5", - "symphonia-core 0.5.5", - "symphonia-format-isomp4 0.5.5", - "symphonia-format-ogg 0.5.5", - "symphonia-format-riff 0.5.5", - "symphonia-metadata 0.5.5", -] - -[[package]] -name = "symphonia" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1758d6c853020a7244de03cc3e0185eaea3f58715122422dd3cc7452e6d4c16a" -dependencies = [ - "lazy_static", - "symphonia-bundle-flac 0.6.0", - "symphonia-bundle-mp3 0.6.0", - "symphonia-codec-pcm 0.6.0", - "symphonia-codec-vorbis 0.6.0", - "symphonia-core 0.6.0", - "symphonia-format-isomp4 0.6.0", - "symphonia-format-mkv", - "symphonia-format-ogg 0.6.0", - "symphonia-format-riff 0.6.0", - "symphonia-metadata 0.6.0", -] - -[[package]] -name = "symphonia-bundle-flac" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" -dependencies = [ - "log", - "symphonia-core 0.5.5", - "symphonia-metadata 0.5.5", - "symphonia-utils-xiph", -] - -[[package]] -name = "symphonia-bundle-flac" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee69ad01236a67260b82fd1ff9790dd75ead29f2f46af145e63b7e72273e0e03" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" dependencies = [ - "log", - "symphonia-common", - "symphonia-core 0.6.0", - "symphonia-metadata 0.6.0", + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-isomp4", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", ] [[package]] -name = "symphonia-bundle-mp3" +name = "symphonia-bundle-flac" version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" dependencies = [ - "lazy_static", "log", - "symphonia-core 0.5.5", - "symphonia-metadata 0.5.5", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", ] [[package]] name = "symphonia-bundle-mp3" -version = "0.6.0" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "350f1f2f2e19ad4dd315db94304d1eb361b29af070681f94e51b8fdaad769546" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" dependencies = [ "lazy_static", "log", - "symphonia-core 0.6.0", + "symphonia-core", + "symphonia-metadata", ] [[package]] @@ -6197,7 +2688,7 @@ checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" dependencies = [ "lazy_static", "log", - "symphonia-core 0.5.5", + "symphonia-core", ] [[package]] @@ -6207,17 +2698,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" dependencies = [ "log", - "symphonia-core 0.5.5", -] - -[[package]] -name = "symphonia-codec-pcm" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50baee168f0e9dcf6ba7fc06e8b57eb62072a4490cc7cf13af77e72baae5d328" -dependencies = [ - "log", - "symphonia-core 0.6.0", + "symphonia-core", ] [[package]] @@ -6227,32 +2708,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" dependencies = [ "log", - "symphonia-core 0.5.5", + "symphonia-core", "symphonia-utils-xiph", ] -[[package]] -name = "symphonia-codec-vorbis" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45b07b4423cd8e0fc472575909a5554b12c2f58e3c190b38c24f042e732fd8de" -dependencies = [ - "log", - "symphonia-common", - "symphonia-core 0.6.0", -] - -[[package]] -name = "symphonia-common" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8257891ffa7f05e02b58f4761e2abf7e5278c8744fd59e981559e050f86eef55" -dependencies = [ - "log", - "symphonia-core 0.6.0", - "symphonia-metadata 0.6.0", -] - [[package]] name = "symphonia-core" version = "0.5.5" @@ -6266,20 +2725,6 @@ dependencies = [ "log", ] -[[package]] -name = "symphonia-core" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95ec293b5f288383b72a7bffcade6b2860b642cf66f28b3bd5967349a49938b1" -dependencies = [ - "bitflags 2.13.0", - "bytemuck", - "lazy_static", - "log", - "num-complex", - "smallvec", -] - [[package]] name = "symphonia-format-isomp4" version = "0.5.5" @@ -6288,35 +2733,11 @@ checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" dependencies = [ "encoding_rs", "log", - "symphonia-core 0.5.5", - "symphonia-metadata 0.5.5", + "symphonia-core", + "symphonia-metadata", "symphonia-utils-xiph", ] -[[package]] -name = "symphonia-format-isomp4" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d179a01305b3505940135a9f0180d6ef4b487912748fe97554756f120fbd05e" -dependencies = [ - "log", - "symphonia-common", - "symphonia-core 0.6.0", - "symphonia-metadata 0.6.0", -] - -[[package]] -name = "symphonia-format-mkv" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb17713e134f5ad316c2690fa3104590ccc85842cdbcf82c3cd1a845cb08aa74" -dependencies = [ - "lazy_static", - "log", - "symphonia-common", - "symphonia-core 0.6.0", -] - [[package]] name = "symphonia-format-ogg" version = "0.5.5" @@ -6324,23 +2745,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" dependencies = [ "log", - "symphonia-core 0.5.5", - "symphonia-metadata 0.5.5", + "symphonia-core", + "symphonia-metadata", "symphonia-utils-xiph", ] -[[package]] -name = "symphonia-format-ogg" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05a67e02b1e4fca1a261ba4fe06910a9357489ad8c36aafdd2960e9c6559433" -dependencies = [ - "log", - "symphonia-common", - "symphonia-core 0.6.0", - "symphonia-metadata 0.6.0", -] - [[package]] name = "symphonia-format-riff" version = "0.5.5" @@ -6349,20 +2758,8 @@ checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" dependencies = [ "extended", "log", - "symphonia-core 0.5.5", - "symphonia-metadata 0.5.5", -] - -[[package]] -name = "symphonia-format-riff" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17424452a777666d3eaf09a5c651029b15b6a333812fcc5b5474f2a3f0cff3f0" -dependencies = [ - "extended", - "log", - "symphonia-core 0.6.0", - "symphonia-metadata 0.6.0", + "symphonia-core", + "symphonia-metadata", ] [[package]] @@ -6374,20 +2771,7 @@ dependencies = [ "encoding_rs", "lazy_static", "log", - "symphonia-core 0.5.5", -] - -[[package]] -name = "symphonia-metadata" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31acf5cd623398a6208e2225d18f4b20f761c55098a796a5247ad516a4a8681" -dependencies = [ - "lazy_static", - "log", - "regex-lite", - "smallvec", - "symphonia-core 0.6.0", + "symphonia-core", ] [[package]] @@ -6396,8 +2780,8 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" dependencies = [ - "symphonia-core 0.5.5", - "symphonia-metadata 0.5.5", + "symphonia-core", + "symphonia-metadata", ] [[package]] @@ -6431,77 +2815,6 @@ dependencies = [ "syn", ] -[[package]] -name = "sysinfo" -version = "0.36.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252800745060e7b9ffb7b2badbd8b31cfa4aa2e61af879d0a3bf2a317c20217d" -dependencies = [ - "libc", - "memchr", - "ntapi", - "objc2-core-foundation", - "objc2-io-kit", - "windows 0.61.3", -] - -[[package]] -name = "system-configuration" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" -dependencies = [ - "bitflags 2.13.0", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "system-deps" -version = "7.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c8f33736f986f16d69b6cb8b03f55ddcad5c41acc4ccc39dd88e84aa805e7f" -dependencies = [ - "cfg-expr", - "heck", - "pkg-config", - "toml", - "version-compare 0.2.1", -] - -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - -[[package]] -name = "tar" -version = "0.4.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" -dependencies = [ - "filetime", - "libc", - "xattr", -] - -[[package]] -name = "target-lexicon" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" - [[package]] name = "tempfile" version = "3.27.0" @@ -6511,7 +2824,7 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix 1.1.4", + "rustix", "windows-sys 0.61.2", ] @@ -6555,20 +2868,6 @@ dependencies = [ "syn", ] -[[package]] -name = "tiff" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" -dependencies = [ - "fax", - "flate2", - "half", - "quick-error", - "weezl", - "zune-jpeg", -] - [[package]] name = "time" version = "0.3.47" @@ -6576,14 +2875,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", - "itoa", "libc", "num-conv", "num_threads", "powerfmt", "serde_core", "time-core", - "time-macros", ] [[package]] @@ -6592,16 +2889,6 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] - [[package]] name = "tinystr" version = "0.8.2" @@ -6655,16 +2942,6 @@ dependencies = [ "syn", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" @@ -6675,47 +2952,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-stream" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tokio-tungstenite" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" -dependencies = [ - "futures-util", - "log", - "native-tls", - "tokio", - "tokio-native-tls", - "tungstenite 0.28.0", -] - -[[package]] -name = "tokio-tungstenite" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" -dependencies = [ - "futures-util", - "log", - "rustls", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tungstenite 0.30.0", - "webpki-roots 0.26.11", -] - [[package]] name = "tokio-util" version = "0.7.18" @@ -6729,21 +2965,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "toml" -version = "0.9.11+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" -dependencies = [ - "indexmap", - "serde_core", - "serde_spanned", - "toml_datetime", - "toml_parser", - "toml_writer", - "winnow 0.7.14", -] - [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -6774,12 +2995,6 @@ dependencies = [ "winnow 0.7.14", ] -[[package]] -name = "toml_writer" -version = "1.0.6+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" - [[package]] name = "tower" version = "0.5.3" @@ -6820,139 +3035,58 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" [[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "trait-variant" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "transpose" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" -dependencies = [ - "num-integer", - "strength_reduce", -] - -[[package]] -name = "tree_magic_mini" -version = "3.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" -dependencies = [ - "memchr", - "nom 8.0.0", - "petgraph", -] - -[[package]] -name = "try-lock" -version = "0.2.5" +name = "tower-service" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] -name = "tui-bar-graph" -version = "0.3.5" +name = "tracing" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "730c88948c610533fcfd117dacb051bccb19de70d8d96775379867a1550d9540" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "colorgrad", - "document-features", - "ratatui-core", - "strum 0.28.0", + "pin-project-lite", + "tracing-attributes", + "tracing-core", ] [[package]] -name = "tungstenite" -version = "0.28.0" +name = "tracing-attributes" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "native-tls", - "rand 0.9.2", - "sha1 0.10.6", - "thiserror 2.0.18", - "utf-8", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "tungstenite" -version = "0.30.0" +name = "tracing-core" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.10.2", - "rustls", - "rustls-pki-types", - "sha1 0.11.0", - "thiserror 2.0.18", + "once_cell", ] [[package]] -name = "typeid" -version = "1.0.3" +name = "trait-variant" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" +checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] -name = "typenum" -version = "1.20.1" +name = "try-lock" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "uds_windows" @@ -6965,15 +3099,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "uncased" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" -dependencies = [ - "version_check", -] - [[package]] name = "unicode-ident" version = "1.0.22" @@ -6992,7 +3117,7 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ - "itertools 0.14.0", + "itertools", "unicode-segmentation", "unicode-width", ] @@ -7009,12 +3134,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "unit-prefix" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" - [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -7027,41 +3146,6 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" -[[package]] -name = "ureq" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdc97a28575b85cfedf2a7e7d3cc64b3e11bd8ac766666318003abbacc7a21fc" -dependencies = [ - "base64", - "cookie_store", - "der", - "encoding_rs", - "flate2", - "log", - "native-tls", - "percent-encoding", - "rustls-pki-types", - "serde", - "serde_json", - "socks", - "ureq-proto", - "utf-8", - "webpki-root-certs", -] - -[[package]] -name = "ureq-proto" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f" -dependencies = [ - "base64", - "http", - "httparse", - "log", -] - [[package]] name = "url" version = "2.5.8" @@ -7072,7 +3156,6 @@ dependencies = [ "idna", "percent-encoding", "serde", - "serde_derive", ] [[package]] @@ -7081,12 +3164,6 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -7099,106 +3176,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "uuid" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" -dependencies = [ - "getrandom 0.2.17", -] - [[package]] name = "uuid" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" dependencies = [ - "getrandom 0.3.4", "js-sys", "serde_core", "wasm-bindgen", ] -[[package]] -name = "v_frame" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" -dependencies = [ - "aligned-vec", - "num-traits", - "wasm-bindgen", -] - -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] -name = "vergen" -version = "9.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b2bf58be11fc9414104c6d3a2e464163db5ef74b12296bda593cac37b6e4777" -dependencies = [ - "anyhow", - "derive_builder", - "rustversion", - "time", - "vergen-lib", -] - -[[package]] -name = "vergen-gitcl" -version = "1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9dfc1de6eb2e08a4ddf152f1b179529638bedc0ea95e6d667c014506377aefe" -dependencies = [ - "anyhow", - "derive_builder", - "rustversion", - "time", - "vergen", - "vergen-lib", -] - -[[package]] -name = "vergen-lib" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b07e6010c0f3e59fcb164e0163834597da68d1f864e2b8ca49f74de01e9c166" -dependencies = [ - "anyhow", - "derive_builder", - "rustversion", -] - -[[package]] -name = "version-compare" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "579a42fc0b8e0c63b76519a339be31bed574929511fa53c1a3acae26eb258f29" - -[[package]] -name = "version-compare" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "vsimd" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" - [[package]] name = "walkdir" version = "2.5.0" @@ -7323,19 +3311,6 @@ dependencies = [ "wasmparser", ] -[[package]] -name = "wasm-streams" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "wasm-streams" version = "0.5.0" @@ -7357,178 +3332,46 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.13.0", "hashbrown 0.15.5", - "indexmap", - "semver", -] - -[[package]] -name = "wayland-backend" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa75f400b7f719bcd68b3f47cd939ba654cedeef690f486db71331eec4c6a406" -dependencies = [ - "cc", - "downcast-rs", - "rustix 1.1.4", - "smallvec", - "wayland-sys", -] - -[[package]] -name = "wayland-client" -version = "0.31.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab51d9f7c071abeee76007e2b742499e535148035bb835f97aaed1338cf516c3" -dependencies = [ - "bitflags 2.13.0", - "rustix 1.1.4", - "wayland-backend", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols" -version = "0.32.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b23b5df31ceff1328f06ac607591d5ba360cf58f90c8fad4ac8d3a55a3c4aec7" -dependencies = [ - "bitflags 2.13.0", - "wayland-backend", - "wayland-client", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-wlr" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78248e4cc0eff8163370ba5c158630dcae1f3497a586b826eca2ef5f348d6235" -dependencies = [ - "bitflags 2.13.0", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - -[[package]] -name = "wayland-scanner" -version = "0.31.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c86287151a309799b821ca709b7345a048a2956af05957c89cb824ab919fa4e3" -dependencies = [ - "proc-macro2", - "quick-xml 0.39.4", - "quote", -] - -[[package]] -name = "wayland-sys" -version = "0.31.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "374f6b70e8e0d6bf9461a32988fd553b59ff630964924dad6e4a4eb6bd538d17" -dependencies = [ - "pkg-config", -] - -[[package]] -name = "web-sys" -version = "0.3.85" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webbrowser" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00f1243ef785213e3a32fa0396093424a3a6ea566f9948497e5a2309261a4c97" -dependencies = [ - "core-foundation 0.10.1", - "jni", - "log", - "ndk-context", - "objc2", - "objc2-foundation", - "url", - "web-sys", -] - -[[package]] -name = "webpki-root-certs" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.5", -] - -[[package]] -name = "webpki-roots" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" -dependencies = [ - "rustls-pki-types", + "indexmap", + "semver", ] [[package]] -name = "weezl" -version = "0.1.12" +name = "web-sys" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] [[package]] -name = "which" -version = "4.4.2" +name = "web-time" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ - "either", - "home", - "once_cell", - "rustix 0.38.44", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "which" -version = "8.0.3" +name = "webpki-root-certs" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c789537cf2f7f55be8e6192f92e464174ee55f91af622777f7f1ceb0dbccd03e" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" dependencies = [ - "libc", + "rustls-pki-types", ] [[package]] -name = "wide" -version = "0.8.3" +name = "webpki-roots" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13ca908d26e4786149c48efcf6c0ea09ab0e06d1fe3c17dc1b4b0f1ca4a7e788" +checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" dependencies = [ - "bytemuck", - "safe_arch", + "rustls-pki-types", ] [[package]] @@ -7562,58 +3405,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.54.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" -dependencies = [ - "windows-core 0.54.0", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" -dependencies = [ - "windows-core 0.58.0", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows" -version = "0.61.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" -dependencies = [ - "windows-collections 0.2.0", - "windows-core 0.61.2", - "windows-future 0.2.1", - "windows-link 0.1.3", - "windows-numerics 0.2.0", -] - [[package]] name = "windows" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ - "windows-collections 0.3.2", - "windows-core 0.62.2", - "windows-future 0.3.2", - "windows-numerics 0.3.1", -] - -[[package]] -name = "windows-collections" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" -dependencies = [ - "windows-core 0.61.2", + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", ] [[package]] @@ -7622,43 +3423,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-core 0.62.2", -] - -[[package]] -name = "windows-core" -version = "0.54.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" -dependencies = [ - "windows-result 0.1.2", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-core" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" -dependencies = [ - "windows-implement 0.58.0", - "windows-interface 0.58.0", - "windows-result 0.2.0", - "windows-strings 0.1.0", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", + "windows-core", ] [[package]] @@ -7667,22 +3432,11 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - -[[package]] -name = "windows-future" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", - "windows-threading 0.1.0", + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] @@ -7691,20 +3445,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core 0.62.2", - "windows-link 0.2.1", - "windows-threading 0.2.1", -] - -[[package]] -name = "windows-implement" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "windows-core", + "windows-link", + "windows-threading", ] [[package]] @@ -7718,17 +3461,6 @@ dependencies = [ "syn", ] -[[package]] -name = "windows-interface" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "windows-interface" version = "0.59.3" @@ -7740,74 +3472,20 @@ dependencies = [ "syn", ] -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-numerics" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", -] - [[package]] name = "windows-numerics" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-core 0.62.2", - "windows-link 0.2.1", -] - -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - -[[package]] -name = "windows-result" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-result" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link 0.1.3", + "windows-core", + "windows-link", ] [[package]] @@ -7816,26 +3494,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-strings" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" -dependencies = [ - "windows-result 0.2.0", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link 0.1.3", + "windows-link", ] [[package]] @@ -7844,7 +3503,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -7865,15 +3524,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.60.2" @@ -7889,7 +3539,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -7929,7 +3579,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link 0.2.1", + "windows-link", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -7940,22 +3590,13 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] -[[package]] -name = "windows-threading" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" -dependencies = [ - "windows-link 0.1.3", -] - [[package]] name = "windows-threading" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -8202,72 +3843,12 @@ dependencies = [ "wasmparser", ] -[[package]] -name = "wl-clipboard-rs" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" -dependencies = [ - "libc", - "log", - "os_pipe", - "rustix 1.1.4", - "thiserror 2.0.18", - "tree_magic_mini", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-protocols-wlr", -] - [[package]] name = "writeable" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - -[[package]] -name = "x11rb" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" -dependencies = [ - "gethostname", - "rustix 1.1.4", - "x11rb-protocol", -] - -[[package]] -name = "x11rb-protocol" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" - -[[package]] -name = "xattr" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" -dependencies = [ - "libc", - "rustix 1.1.4", -] - -[[package]] -name = "y4m" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" - [[package]] name = "yoke" version = "0.8.1" @@ -8313,12 +3894,12 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix 1.1.4", + "rustix", "serde", "serde_repr", "tracing", "uds_windows", - "uuid 1.19.0", + "uuid", "windows-sys 0.61.2", "winnow 1.0.2", "zbus_macros", @@ -8352,26 +3933,6 @@ dependencies = [ "zvariant", ] -[[package]] -name = "zerocopy" -version = "0.8.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "zerofrom" version = "0.1.6" @@ -8432,80 +3993,12 @@ dependencies = [ "syn", ] -[[package]] -name = "zip" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b" -dependencies = [ - "arbitrary", - "crc32fast", - "flate2", - "indexmap", - "memchr", - "time", - "zopfli", -] - -[[package]] -name = "zipsign-api" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dba6063ff82cdbd9a765add16d369abe81e520f836054e997c2db217ceca40c0" -dependencies = [ - "base64", - "ed25519-dalek", - "thiserror 2.0.18", -] - -[[package]] -name = "zlib-rs" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5431d5661c32445236631278f27946e444ddafe4684cac70b185272d4f9c52d5" - [[package]] name = "zmij" version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" -[[package]] -name = "zopfli" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" -dependencies = [ - "bumpalo", - "crc32fast", - "log", - "simd-adler32", -] - -[[package]] -name = "zune-core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" - -[[package]] -name = "zune-inflate" -version = "0.2.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" -dependencies = [ - "simd-adler32", -] - -[[package]] -name = "zune-jpeg" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" -dependencies = [ - "zune-core", -] - [[package]] name = "zvariant" version = "5.10.1" diff --git a/Cargo.toml b/Cargo.toml index 6d6757a1..63a107f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,245 +1,49 @@ [package] name = "spotatui" -description = "A Spotify client for the terminal written in Rust, powered by Ratatui" -homepage = "https://github.com/LargeModGames/spotatui" -documentation = "https://github.com/LargeModGames/spotatui" -repository = "https://github.com/LargeModGames/spotatui" -keywords = ["spotify", "tui", "cli", "terminal"] +description = "An internet-radio terminal player with MPRIS control" +homepage = "https://github.com/ethereumdegen/degen-radio" +documentation = "https://github.com/ethereumdegen/degen-radio" +repository = "https://github.com/ethereumdegen/degen-radio" +keywords = ["radio", "audio", "tui", "terminal"] categories = ["command-line-utilities"] -version = "0.42.0" -authors = ["LargeModGames "] +version = "0.43.0-radio.1" +authors = ["ethereumdegen"] edition = "2021" license = "MIT" -# Two [[bin]] targets make a plain `cargo run` ambiguous; the console binary -# stays the default. default-run = "spotatui" -exclude = [ - ".github/demo.gif", - ".github/workflows", - ".github/ISSUE_TEMPLATE", - "target/", - "snap/", -] - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -rspotify = { version = "0.16", default-features = false, features = ["cli", "env-file", "client-reqwest", "reqwest-rustls-tls"] } -ratatui = { version = "0.30", features = ["crossterm", "layout-cache"], default-features = false, optional = true } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -serde_yaml = "0.9" -dirs = "6.0" +anyhow = "1.0.102" clap = { version = "4.6", features = ["cargo"] } -clap_complete = "4.6" -unicode-width = "0.2.2" -backtrace = "0.3.76" crossterm = { version = "0.29", optional = true } -tui-bar-graph = { version = "0.3.3", optional = true } -# Optional because the headless build needs neither half of it: `tui` pulls it -# for the banner gradient and visualizer, `art-decode` for the HSV math in -# `core/cover_theme.rs`. -colorgrad = { version = "0.8.0", optional = true } -tokio = { version = "1.52", features = ["full"] } -rand = "0.10.2" -anyhow = "1.0.102" -chrono = "0.4.45" -log = "0.4.32" +dirs = "6.0" fern = "0.7.1" -open = "5.3" -self_update = { version = "0.44", features = ["archive-tar", "archive-zip", "compression-flate2", "compression-zip-deflate"], optional = true } -sha2 = { version = "0.11", optional = true } -hex = { version = "0.4", optional = true } -reqwest = { version = "0.12", features = ["json", "rustls-tls", "blocking"], default-features = false } -openssl = { version = "0.10", optional = true } -cpal = { version = "0.17", optional = true } -realfft = { version = "3.4", optional = true } -discord-rich-presence = { version = "1.1", optional = true } -ratatui-image = { version = "11.0.4", optional = true, default-features = false, features = ["crossterm"] } -image = { version = "0.25", optional = true } -mlua = { version = "0.12", features = ["lua54", "vendored", "serde"], optional = true } -md-5 = { version = "0.11", optional = true } -# Subsonic, YouTube and Qobuz stream each track to a tempfile before playing it through the shared -# rodio sink. Also a dev-dependency (used by tests across features). -tempfile = { version = "3", optional = true } -# Qobuz transport: the signed-request and key-derivation primitives plus the -# bundle scrape. Same `cipher 0.4` line as librespot, so no graph fork. -base64 = { version = "0.22", optional = true } -regex = { version = "1", optional = true } -aes = { version = "0.8", optional = true } -cbc = { version = "0.1", optional = true } -ctr = { version = "0.9", optional = true } -hkdf = { version = "0.13", optional = true } -# Qobuz progressive delivery: the chunk type of a stream-download source. -bytes = { version = "1", optional = true } - -# stream-download buffers a byte stream behind a Read+Seek adapter that rodio -# can decode while the download runs. Internet radio uses it on an infinite HTTP -# (ICY) stream, without seeking; Qobuz uses it on its segment transport, with -# seeking. icy-metadata parses in-band StreamTitle now-playing metadata and is -# only compiled under the opt-in `internet-radio` feature. -stream-download = { version = "0.24", optional = true, default-features = false, features = ["reqwest-rustls"] } icy-metadata = { version = "0.6", optional = true, default-features = false } - -# Local-files media source dependencies -symphonia = { version = "0.6", features = ["mp3", "flac", "ogg", "wav", "aiff", "isomp4", "mkv", "pcm", "vorbis"], optional = true, default-features = false } -lofty = { version = "0.24", optional = true } -# Local-files audio output. Defaults pull in cpal `playback` plus the common -# codecs (flac/mp3/mp4-aac/vorbis/wav) via symphonia. Drives the default device -# on all three platforms; the macOS gate in `LocalPlayer` is gone since rodio -# 0.22 / cpal 0.17 were measured on CoreAudio. Unrelated to librespot's own -# rodio-backend, which is why librespot keeps portaudio-backend on macOS. +log = "0.4.32" +rand = "0.10.2" +ratatui = { version = "0.30", features = ["crossterm", "layout-cache"], default-features = false, optional = true } +reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } rodio = { version = "0.22", optional = true } - -# Streaming dependencies: our maintained librespot fork, published on crates.io -# as spotatui-librespot-* (upstream 0.8.0 + CDN 530 fallback for silent playback, -# dealer reconnect/session-loss recovery, OAuth callback listener hardening, -# previous-on-first-track restarting instead of stopping (#366), Spirc ignoring -# play-request ids of direct `Player::load`s so a queued track's end cannot stop -# the next one, and a fork-only session disconnect-reason API the app depends -# on - upstream 0.8 cannot be substituted). The `package =` rename keeps `librespot_*` imports and -# `librespot-*/feature` refs unchanged; `=`-pinned so all seven crates upgrade in -# lockstep. See SPOTATUI_FORK.md in the fork repo for the carried patches. -librespot-core = { version = "=0.8.3", package = "spotatui-librespot-core", optional = true } -librespot-connect = { version = "=0.8.3", package = "spotatui-librespot-connect", optional = true } -librespot-oauth = { version = "=0.8.3", package = "spotatui-librespot-oauth", optional = true } -librespot-metadata = { version = "=0.8.3", package = "spotatui-librespot-metadata", optional = true } -librespot-protocol = { version = "=0.8.3", package = "spotatui-librespot-protocol", optional = true, default-features = false } -protobuf = { version = "3.7", optional = true } -futures = "0.3.32" -tokio-tungstenite = { version = "0.30", features = ["rustls-tls-webpki-roots"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +serde_yaml = "0.9" +stream-download = { version = "0.24", optional = true, default-features = false, features = ["reqwest-rustls"] } +tokio = { version = "1.52", features = ["full"] } url = "2.5" -keepawake = "0.6" - -[target.'cfg(all(target_os = "linux", not(target_env = "musl")))'.dependencies] -arboard = { version = "3.4", features = ["wayland-data-control"] } -pipewire = { version = "0.10", optional = true } -librespot-playback = { version = "=0.8.3", package = "spotatui-librespot-playback", optional = true, default-features = false, features = ["alsa-backend"] } -[target.'cfg(all(target_os = "linux", target_env = "musl"))'.dependencies] -arboard = { version = "3.4", features = ["wayland-data-control"] } -librespot-playback = { version = "=0.8.3", package = "spotatui-librespot-playback", optional = true, default-features = false, features = ["rodio-backend"] } - -[target.'cfg(target_os = "windows")'.dependencies] -arboard = "3.4" -# On Windows we default to rodio for audio output. -# Without this, librespot-playback falls back to the `pipe` sink which writes raw audio bytes -# to stdout (breaking the TUI and producing no audible output). -librespot-playback = { version = "=0.8.3", package = "spotatui-librespot-playback", optional = true, default-features = false, features = ["rodio-backend"] } -smtc-tokio = { version = "0.1.0", optional = true } - -# MPRIS D-Bus support (Linux only) [target.'cfg(target_os = "linux")'.dependencies] mpris-server = { version = "0.10", optional = true } -# macOS: librespot-playback + Now Playing / Media Key support -# On macOS we use portaudio-backend by default to avoid the pipe sink fallback (which outputs to stdout). -# Rodio has compatibility issues with macOS CoreAudio and Bluetooth devices (AirPods, etc.) -# causing SIGSEGV crashes. See Issue #9 and #20. -[target.'cfg(target_os = "macos")'.dependencies] -arboard = "3.4" -librespot-playback = { version = "=0.8.3", package = "spotatui-librespot-playback", optional = true, default-features = false, features = ["portaudio-backend"] } -objc2-media-player = { version = "0.3", optional = true } -objc2-foundation = { version = "0.3", optional = true } -objc2-app-kit = { version = "0.3", optional = true, default-features = false, features = ["NSImage"] } -objc2 = { version = "0.6", optional = true } -block2 = { version = "0.6", optional = true } - [features] -default = ["telemetry", "streaming", "audio-viz-cpal", "macos-media", "discord-rpc", "mpris", "self-update", "windows-media", "scripting", "tui"] -telemetry = [] -# The terminal frontend: gates `mod tui` and owns the terminal-only crates. -# The headless CI leg omits it, so any `crate::tui` import from core/infra/cli -# is a compile error there - that is what keeps a second frontend from silently -# re-coupling to the terminal one. -tui = ["dep:ratatui", "dep:crossterm", "dep:tui-bar-graph", "dep:colorgrad"] -# Reserved for the windowed frontend. Currently gates only the `spotatui-gui` -# bin shim (which claims the binary name and the Windows console-attach -# plumbing); the frontend itself has not landed yet. -gui = [] -self-update = ["dep:self_update", "dep:sha2", "dep:hex"] -streaming = ["librespot-core", "librespot-playback", "librespot-connect", "librespot-oauth", "librespot-metadata", "librespot-protocol", "protobuf", "queue"] -# Audio backend features -alsa-backend = ["streaming", "librespot-playback/alsa-backend"] -pulseaudio-backend = ["streaming", "librespot-playback/pulseaudio-backend"] -rodio-backend = ["streaming", "librespot-playback/rodio-backend"] -portaudio-backend = ["streaming", "librespot-playback/portaudio-backend"] -jackaudio-backend = ["streaming", "librespot-playback/jackaudio-backend"] -rodiojack-backend = ["streaming", "librespot-playback/rodiojack-backend"] -sdl-backend = ["streaming", "librespot-playback/sdl-backend"] -gstreamer-backend = ["streaming", "librespot-playback/gstreamer-backend"] -audio-viz = ["realfft", "pipewire"] -audio-viz-cpal = ["realfft", "cpal"] # Alternative for Windows/macOS or if pipewire issues -mpris = ["mpris-server"] # MPRIS D-Bus integration (Linux only) -macos-media = ["objc2-media-player", "objc2-foundation", "objc2-app-kit", "objc2", "block2", "streaming"] # macOS Now Playing integration -windows-media = ["smtc-tokio", "streaming"] # windows SMTC integration -discord-rpc = ["discord-rich-presence"] -# The frontend-agnostic decode half of cover art: fetch + `image`-decode into -# the store frontends render from (also feeds the adaptive theme's palette -# extraction). Not meant to be enabled by hand: `cover-art` layers the -# terminal rendering on top and keeps its pre-split meaning for users and CI. -art-decode = ["dep:image", "dep:colorgrad"] -cover-art = ["tui", "art-decode", "dep:ratatui-image"] -scripting = ["dep:mlua"] -# Shared audio decode/output engine (rodio bundles symphonia). Pulled by any -# source that plays decoded audio through the local sink (local files, Subsonic, Qobuz). +default = ["tui", "internet-radio", "mpris"] +tui = ["dep:ratatui", "dep:crossterm"] audio-decode = ["dep:rodio"] -# Meta-feature (only used by sources), this ensures at least one source that supports -# queues and needs decoding is enabled. -audio-decode-queue = ["audio-decode", "queue"] -# Meta-feature (only used by sources), this ensures at least one source with queue -# support. Namely: `audio-decode-queue` + `streaming` -queue = [] -# Meta-feature (only used by sources), this ensures at least one source that supports -# queues and needs track download / track info fetching. -queue-download = ["audio-decode-queue", "dep:tempfile"] -# Convenience alias to pull in every alternative (non-Spotify) source at once, -# e.g. `cargo run --features all-sources`. Deliberately NOT part of `default`: -# the release binaries enable these explicitly (see .github/workflows/cd.yml) and -# adding them to `default` would pull lofty/symphonia/md-5/stream-download into -# every plain `cargo build`. -all-sources = ["local-files", "subsonic", "internet-radio", "youtube", "qobuz"] -local-files = ["audio-decode-queue", "queue", "dep:lofty", "dep:symphonia"] -subsonic = ["dep:md-5", "audio-decode-queue", "queue", "queue-download", "dep:tempfile", "reqwest/stream"] internet-radio = ["audio-decode", "dep:stream-download", "dep:icy-metadata"] -# YouTube via an external `yt-dlp` binary (no new crates): search with -# `--flat-playlist --dump-json`, download the AAC/M4A audio (format 140, which -# rodio's default `mp4` feature decodes) to a tempfile, play through the shared -# LocalPlayer. Unofficial-fragile by design — see plan-multi-source.md. -youtube = ["audio-decode-queue", "queue", "queue-download"] -# Qobuz through its encrypted CMAF stream: each track is decrypted and rebuilt -# as a FLAC tempfile while it plays through the shared LocalPlayer. -qobuz = ["audio-decode-queue", "queue", "queue-download", "reqwest/stream", "dep:stream-download", "dep:bytes", "dep:md-5", "dep:sha2", "dep:hex", "dep:base64", "dep:regex", "dep:aes", "dep:cbc", "dep:ctr", "dep:hkdf"] - -# --- AI DJ / MCP ------------------------------------------------------------- -# Shared tool layer behind both front doors: the taste brief built from the local -# listening history, the name -> URI resolver, and the bulk enqueue. Pure logic, -# no new crates (reqwest / tokio "full" / serde_json are already non-optional). -# Pulled by `mcp-server` and `ai-dj` the same way `audio-decode` is pulled by the -# media sources. -dj-core = [] -# Expose the player and listening history as MCP tools over a `spotatui mcp` -# stdio server, so Claude Code / Codex / any MCP client can drive playback. No -# LLM client and no API key are compiled in: `--features mcp-server` alone is a -# complete "drive it from your coding agent" build. -mcp-server = ["dj-core"] -# The in-TUI DJ: a DJ screen with a chat prompt, continuous auto-queue and a -# vibe-shift key, backed by a locally-installed agent CLI, an API key, or a local -# model. Deliberately not in `default`: it egresses to a third-party provider. -ai-dj = ["dj-core"] +mpris = ["dep:mpris-server"] [dev-dependencies] tempfile = "3" -# Constraint-only pin (spotatui has no build script): vergen 9.1.0 breaks -# librespot-core 0.8.0's build script on a fresh resolve (#350). Remove once -# librespot ships a fix. -[build-dependencies] -vergen = { version = ">=9.0.6, <9.1", default-features = false } - -[target.'cfg(target_env = "musl")'.dependencies] -openssl-sys = { version = "0.9", features = ["vendored"] } - [lib] name = "spotatui" path = "src/lib.rs" @@ -249,64 +53,20 @@ bench = false path = "src/bin/spotatui.rs" name = "spotatui" -# The windowed shim. A separate binary because `windows_subsystem` is a -# crate-root attribute that cannot vary at runtime, and because flipping the -# console binary would detach the stdout that `spotatui mcp` (JSON-RPC) and -# `spotatui history recap` (HTML) treat as protocol. Never built unless `gui` -# is enabled explicitly. -[[bin]] -bench = false -path = "src/bin/spotatui-gui.rs" -name = "spotatui-gui" -required-features = ["gui"] - [profile.release] -codegen-units = 1 # Better optimization -lto = true # Link-time optimization -opt-level = "s" # Prioritize small binary size -strip = true # Remove debug symbols +codegen-units = 1 +lto = true +opt-level = "s" +strip = true [package.metadata.deb] -maintainer = "LargeModGames " -copyright = "2025, LargeModGames" +maintainer = "ethereumdegen" +copyright = "2026 ethereumdegen" license-file = ["LICENSE", "4"] depends = "$auto" -# The YouTube source shells out to yt-dlp at runtime (ffmpeg improves the -# downloaded audio container); both are optional — spotatui works without them. -suggests = "yt-dlp, ffmpeg" -section = "utils" +section = "sound" priority = "optional" -# cargo-deb packages every [[bin]] by default, and spotatui-gui only exists -# in a `gui` build, so the assets are listed explicitly. assets = [ ["target/release/spotatui", "usr/bin/", "755"], ["README.md", "usr/share/doc/spotatui/README", "644"], ] - -# cargo-binstall: fetch the prebuilt release binaries instead of compiling. -# cargo binstall spotatui -# Our release assets use friendly names (spotatui--) rather than the -# Rust target triple, and the binary sits at the archive root, so every target -# needs an explicit override with an archive-root bin-dir. -[package.metadata.binstall] -bin-dir = "{ bin }{ binary-ext }" - -[package.metadata.binstall.overrides.x86_64-unknown-linux-gnu] -pkg-url = "{ repo }/releases/download/v{ version }/spotatui-linux-x86_64.tar.gz" -pkg-fmt = "tgz" - -[package.metadata.binstall.overrides.aarch64-unknown-linux-gnu] -pkg-url = "{ repo }/releases/download/v{ version }/spotatui-linux-aarch64.tar.gz" -pkg-fmt = "tgz" - -[package.metadata.binstall.overrides.x86_64-apple-darwin] -pkg-url = "{ repo }/releases/download/v{ version }/spotatui-macos-x86_64.tar.gz" -pkg-fmt = "tgz" - -[package.metadata.binstall.overrides.aarch64-apple-darwin] -pkg-url = "{ repo }/releases/download/v{ version }/spotatui-macos-aarch64.tar.gz" -pkg-fmt = "tgz" - -[package.metadata.binstall.overrides.x86_64-pc-windows-msvc] -pkg-url = "{ repo }/releases/download/v{ version }/spotatui-windows-x86_64.zip" -pkg-fmt = "zip" diff --git a/README.md b/README.md index 109521a1..6d7c17e6 100644 --- a/README.md +++ b/README.md @@ -1,702 +1,68 @@ -

- spotatui logo
- spotatui -

-

- A terminal music player written in Rust, powered by Ratatui
- Native Spotify streaming, synced lyrics, a real-time audio visualizer, local file,
Subsonic/Navidrome, Internet Radio, YouTube, and Qobuz sources. Spotify is optional. -

+# degen-radio -[![Crates.io](https://img.shields.io/crates/v/spotatui.svg)](https://crates.io/crates/spotatui) -[![codecov](https://codecov.io/gh/LargeModGames/spotatui/graph/badge.svg)](https://codecov.io/gh/LargeModGames/spotatui) -[![Discord](https://img.shields.io/discord/1534820237122207815?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://spotatui.com/discord) -[![Upstream](https://img.shields.io/badge/upstream-Rigellute%2Fspotify--tui-blue)](https://github.com/Rigellute/spotify-tui) -[![X](https://img.shields.io/badge/@LargeModGames-000000?logo=x&logoColor=white)](https://twitter.com/LargeModGames) -[![Songs played using Spotatui](https://img.shields.io/badge/dynamic/json?url=https://spotatui-counter.spotatui.workers.dev&query=count&label=Songs%20played%20using%20spotatui&labelColor=0b0f14&color=1ed760&logo=spotify&logoColor=1ed760&style=flat-square&cacheSeconds=600)](https://github.com/LargeModGames/spotatui) -[![spotatui Contributors](https://img.shields.io/badge/dynamic/json?url=https://raw.githubusercontent.com/LargeModGames/spotatui/main/.all-contributorsrc&query=%24.contributors.length&label=spotatui%20contributors&color=1ed760&style=flat-square)](#spotatui-contributors) -[![Upstream Contributors](https://img.shields.io/badge/upstream_contributors-94-orange.svg?style=flat-square)](#upstream-contributors-spotify-tui) +A focused internet-radio terminal player derived from Spotatui. This fork contains no Spotify client, Spotify authentication flow, Spotify playback backend, YouTube integration, or YouTube downloader. - -![Demo](.github/demo.gif) - - - -## Table of Contents - -- [About](#about) -- [Features](#features) -- [Installation](#installation) -- [Quickstart](#quickstart) - - [Adding Spotify later](#adding-spotify-later) -- [Music Sources](#music-sources) - - [Local Files](#local-files) - - [Subsonic / Navidrome](#subsonic--navidrome) - - [Internet Radio](#internet-radio) - - [YouTube](#youtube) - - [Qobuz](#qobuz) -- [AI DJ](#ai-dj) -- [Native Streaming](#native-streaming) -- [Configuration](#configuration) - - [Discord Rich Presence](#discord-rich-presence) - - [Anonymous Song Counter](#anonymous-song-counter) - - [GitHub Profile Widget](#github-profile-widget) -- [Plugins](#plugins) -- [Performance](#performance) -- [Playback Requirements](#playback-requirements) - - [Deprecated Spotify API Features](#deprecated-spotify-api-features) -- [Using with spotifyd](#using-with-spotifyd) -- [Migrating from spotify-tui](#migrating-from-spotify-tui) -- [Song History](#song-history) -- [Star History](#star-history) -- [Libraries used](#libraries-used) -- [Development](#development) - - [Windows Subsystem for Linux](#windows-subsystem-for-linux) -- [Help Wanted](#help-wanted) -- [Maintainer](#maintainer) -- [spotatui Contributors](#spotatui-contributors) -- [Upstream Contributors (spotify-tui)](#upstream-contributors-spotify-tui) -- [Roadmap](#roadmap) - - - -## About -A community-maintained, actively developed fork of [spotify-tui](https://github.com/Rigellute/spotify-tui) that has since diverged on multiple fronts. - -| Key differences | spotify-tui | spotatui | -|---|---|---| -| Spotify backend | external Connect device (spotifyd, official client, ...) | built-in native client | -| Source(s) | Spotify | Spotify, YouTube, local, Subsonic, Navidrome, Internet Radio, Qobuz | +The installed command remains `spotatui` so existing desktop entries, MPRIS integrations, and the Omarchy bar plugin continue to work. ## Features -- **Multiple sources — Spotify optional.** Play from Spotify, [Local Files](#local-files), a [Subsonic/Navidrome](#subsonic--navidrome) server, [Internet Radio](#internet-radio), [YouTube](#youtube), or [Qobuz](#qobuz). The free sources need no Spotify account; press `d` to switch between them at any time. -- **[Native streaming](#native-streaming).** Play Spotify audio directly, no official app or spotifyd required — spotatui appears as its own Spotify Connect device (Premium required). -- **Synced lyrics.** Line-by-line lyrics that follow playback. -- **Real-time audio visualizer.** A system-wide FFT visualizer (press `v`) that reacts to whatever is playing. -- **Cross-source play queue.** Press `z` on any track to queue it — the queue plays across every source before your current context resumes. -- **[Lua plugins](#plugins).** Extend spotatui with event hooks, commands, keybindings, popups, and theming. -- **Listening history & recap.** spotatui keeps a local play history and can generate a shareable HTML recap (`spotatui history recap`). -- **Full CLI.** Most of what the UI does is scriptable — playback, search, playlists, shell completions. Run `spotatui --help`. -- **Lightweight.** ~78 MB RAM while streaming, versus a full Electron client. See [Performance](#performance). - -## Installation +- Saved stations from the existing Spotatui `config.yml` and `state.yml` +- radio-browser.info station search +- MP3/AAC internet-stream playback through the system audio output +- ICY `StreamTitle` now-playing metadata +- Linux MPRIS controls and metadata +- External station control for the Omarchy plugin -> **Spotify is optional.** On first launch spotatui asks which source you want to use. YouTube, Subsonic/Navidrome, Internet Radio, Local Files, and Qobuz all work with no Spotify account. Spotify Premium is only needed for the Spotify source; you can add it anytime from the `d` menu. +## Build and run ```bash -# One-line installer — macOS, Linux, WSL. Grabs the prebuilt binary for your -# system, verifies its checksum, and puts spotatui on your PATH. No Rust needed. -curl -fsSL https://spotatui.com/install.sh | bash - -# Homebrew (macOS only) -brew tap LargeModGames/spotatui -brew install spotatui - -# Cargo — compile from crates.io … -cargo install --locked spotatui -# … or fetch the prebuilt binary without compiling -cargo binstall spotatui - -# Arch Linux (AUR) — prebuilt binary (faster) -yay -S spotatui-bin -# …or build from source -yay -S spotatui - -# Void Linux (unofficial repo) -echo repository=https://raw.githubusercontent.com/Event-Horizon-VL/blackhole-vl/repository-x86_64 | sudo tee /etc/xbps.d/20-repository-extra.conf -sudo xbps-install -S spotatui -``` - -```powershell -# One-line installer — Windows PowerShell -irm https://spotatui.com/install.ps1 | iex - -# …or via Winget -winget install spotatui -``` -```nix -# NixOS (Flake, recommended for NixOS) - -# Add spotatui to your flake inputs: -inputs = { - spotatui = { - url = "github:LargeModGames/spotatui"; - inputs.nixpkgs.follows = "nixpkgs"; - }; -} - -# Add the spotatui package from your inputs to your config: -{ inputs, ...}:{ - # Your other configurations - environment.systemPackages = with pkgs; [ - inputs.spotatui.packages.${pkgs.stdenv.hostPlatform.system}.default - ]; -} -``` - -The flake will take care of all the dependencies for you, except for setting the audio backend. -If you use alsa: - ```nix - hardware.alsa.enable = true; - ``` -Or if you use pipewire: - ```nix - services.pipewire = { - enable = true; - alsa.enable = true; - #your other settings - }; - ``` - - -The flake provides `x86_64-linux`, `aarch64-linux`, and `aarch64-darwin`. nixpkgs 26.11 dropped Intel Macs (`x86_64-darwin`), so on those use the Homebrew formula, the one-line installer, or the release tarball below. - -Or download pre-built binaries from [GitHub Releases](https://github.com/LargeModGames/spotatui/releases/latest). - -> **Which build has which sources?** The prebuilt Linux, macOS, and Windows binaries (the one-line installer, GitHub Releases, and `cargo binstall`) include the extra music sources — Local Files, Subsonic, Internet Radio, YouTube, and Qobuz. A plain `cargo install --locked spotatui` does not; enable them with `--features local-files,subsonic,internet-radio,youtube,qobuz`. - -See the [Installation Wiki](https://github.com/LargeModGames/spotatui/wiki/Installation) for platform-specific requirements and building from source. - -## Quickstart - -Run `spotatui`. On the first launch it asks which source to set up: - +cargo run ``` -Welcome to spotatui! Choose your music source: - - 1) Spotify (needs login) - 2) YouTube (free, needs the yt-dlp binary) - 3) Subsonic (free, needs a Subsonic/Navidrome server) - 4) Internet Radio (free) - 5) Local Files (free) - 6) Qobuz (paid subscription, logs in through the browser) -``` - -Pick a **free source** to skip Spotify entirely, or pick **Spotify** to run the auth wizard (you'll create a Spotify Developer app — see the [Installation Wiki](https://github.com/LargeModGames/spotatui/wiki/Installation#connecting-to-spotify)). Only sources compiled into your build are listed. -Once you're in: - -- Press `?` for the in-app help menu of all key events. -- Press `d` to open the **Source & Device** picker and switch sources. -- Press `z` on a track to queue it; open the queue with `Shift+Q`. -- Run `spotatui --help` for the CLI. See the [Keybindings Wiki](https://github.com/LargeModGames/spotatui/wiki/Keybindings) for every shortcut. - -A few CLI examples to get you started: +Release install: ```bash -spotatui --completions zsh # Shell completions (bash, powershell, and more supported) -spotatui play --name "Your Playlist" --playlist --random # Play a random song from a playlist -spotatui playback --toggle # Play/pause current playback -spotatui list --liked --limit 50 # List your liked songs -spotatui history recap --period 30d --output ./recap.html # Generate a shareable listening recap +cargo install --path . --force ``` -### Adding Spotify later - -Started with a free source and want Spotify too? Press `d`, select **Spotify**, and spotatui opens your browser to log in without restarting — enabling browsing, playlists, and controlling external devices right away. **Native (librespot) streaming still requires a restart**, since it initializes at startup. - -## Music Sources - -spotatui is a general music player, not just a Spotify client. Press `d` to open the **Source & Device** picker; the sidebar and search re-scope to the active source. Playback for these sources runs through spotatui's own audio engine, so volume control and the visualizer work exactly as they do for Spotify — and none of them need Spotify Premium. - -| Source | What it does | Needs | -|---|---|---| -| **Local Files** | Browse and play a folder of audio files (FLAC, MP3, OGG, WAV, …) | Nothing; set `local_music_path` or use the OS music dir | -| **Subsonic** | Browse, search, and stream from any Subsonic-compatible server (Navidrome, Gonic, Airsonic, Funkwhale, …) | A server account | -| **Internet Radio** | Play icecast/shoutcast streams with live now-playing metadata; search the [radio-browser.info](https://www.radio-browser.info) directory (30k+ stations) | Nothing | -| **YouTube** | Search YouTube and play audio; build **local playlists** stored in a plain file | [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) on your `PATH` (ffmpeg recommended) | -| **Qobuz** | Stream your Qobuz library (favorites, playlists, albums, search) at the configured quality, MP3 320 up to FLAC 24/192, through spotatui's own engine | A paid Qobuz account | - -**Resuming your last session:** quit while playing from a non-Spotify source and spotatui restores that track and its position on the next launch, following the `startup_behavior` setting (`continue`, `play`, or `pause`). - -**Availability:** included in the Linux, macOS, and Windows release binaries. When building from source, enable them with cargo features: - -```bash -cargo install --locked spotatui --features local-files,subsonic,internet-radio,youtube,qobuz -``` - -Each source has a few config keys; the essentials are below, and the full reference lives in the [Configuration Wiki](https://github.com/LargeModGames/spotatui/wiki/Configuration). - -### Local Files - -Set a folder (defaults to the OS music directory), then pick **Local Files** in the `d` picker: - -```yaml -behavior: - local_music_path: "/home/you/Music" -``` - -### Subsonic / Navidrome - -```yaml -behavior: - subsonic_url: "https://music.example.com" - subsonic_username: "you" -``` - -Prefer setting the password via the `SPOTATUI_SUBSONIC_PASSWORD` environment variable so it never sits in the config file in plaintext. - -### Internet Radio - -Search the radio-browser.info directory in-app (Enter plays a station directly), and press the save key (`F` by default) to keep a station in your sidebar. Stations can also be preconfigured in `config.yml`; stations saved in-app live in `state.yml`. The playbar shows a `LIVE` badge with the stream's now-playing title. - -### YouTube - -Requires the [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) binary (`ffmpeg` recommended). No Google account, API key, or cookies — search and playback are anonymous. If playback breaks after a YouTube change, updating yt-dlp (`yt-dlp -U`) is the fix; no spotatui update needed. - -**Local YouTube playlists** live in the spotatui config directory as `youtube_playlists.yml`, a plain human-editable file you can back up or share. Create one from the sidebar, add tracks with `w`, and play a playlist as a queue with `Enter`. - -### Qobuz - -Pick **Qobuz** in the first-run picker or the `d` menu: spotatui opens the Qobuz login page in your browser and saves the token to `qobuz_credentials.yml` in the config directory (owner-only on Unix; on Windows the file keeps the config folder's permissions). No app registration is needed. The sidebar lists your favorite tracks, playlists, and favorite albums; search finds tracks. Each track plays while it downloads: the encrypted stream is decrypted and rebuilt as a FLAC temporary file, playback starts after the first segments arrive, and a seek past the downloaded part continues the download from there. When a track plays from a Qobuz listing, the playbar shows the format that Qobuz delivered, for example `FLAC 16/44.1`. This can be lower than the configured quality. - -```yaml -behavior: - qobuz_quality: 6 # 5 = MP3 320, 6 = FLAC 16/44.1, 7 = FLAC 24/96, 27 = FLAC 24/192 -``` - -Environment variables: `SPOTATUI_QOBUZ_TOKEN` overrides the saved token. spotatui reads the three constants it needs from the Qobuz web player at runtime; when Qobuz changes its web player and the login stops working, set `SPOTATUI_QOBUZ_APP_ID`, `SPOTATUI_QOBUZ_APP_SECRET`, and `SPOTATUI_QOBUZ_OAUTH_KEY` from the bundle instead of waiting for a release. - -## AI DJ - -spotatui can pick music for you, from your own listening history. Two ways in: - -**Drive it from your coding agent** (`--features mcp-server`) — spotatui exposes the player and your listening history as [MCP](https://modelcontextprotocol.io) tools, so Claude Code, Codex, Gemini CLI, or any MCP client becomes the DJ. **No API key**: it uses the agent subscription you already have. Setup is one line you hand to your agent: - -> Read `docs/mcp-setup.md` in the spotatui repo and set up the spotatui MCP server for me, following it exactly. - -That file is written as instructions *for the agent*, and `spotatui mcp status` gives it a safe, checkable probe of every step. Full guide: [`docs/mcp-setup.md`](docs/mcp-setup.md). - -Clients that support agent plugins can do the whole registration in one step, and get a DJ skill along with the server: in Claude Code, `/plugin marketplace add LargeModGames/spotatui` then `/plugin install spotatui@spotatui`; clients that read the new [Agent Plugins](https://agent-plugins.org) format load [`agent-plugin/`](agent-plugin/README.md) from this repo. (These are *agent* plugins, for AI clients — spotatui's in-app Lua plugins live in [`PLUGINS.md`](PLUGINS.md).) - -**Or use the DJ built into the TUI** (`--features ai-dj`) — press `Ctrl-j` for a DJ screen with a chat prompt, `Ctrl-t` for continuous auto-queue, `Ctrl-y` to shift the vibe, `Ctrl-o` for "only tracks I don't already have", `Ctrl-g` to choose which AI and model it uses. It drives the same tools as the MCP server, so it can search, check your queue and queue tracks, and it will ask what you actually want before it plays anything rather than queueing on every message. Its brain is a locally installed agent CLI (`claude`, `codex`, `agy`, `copilot`, `opencode`, no key), an API key, or a local model via Ollama / LM Studio; it asks which one on your first visit, since the agent CLIs spend the coding subscription you already pay for. Full guide: [`docs/ai-dj.md`](docs/ai-dj.md). - -What reaches a model is track, artist, and album *names*: aggregates from your local history, what is playing now, and the conversation you typed. No identifiers, no timestamps, no account. Full breakdown in [`docs/ai-dj.md`](docs/ai-dj.md#what-is-sent-to-the-model). - -```bash -cargo install --locked spotatui --features mcp-server,ai-dj -``` - -## Native Streaming - -spotatui can play Spotify audio directly, without spotifyd or the official app — just run it and it appears as a Spotify Connect device. +## Controls -- Premium account required -- Works with media keys, MPRIS (Linux), and macOS Now Playing -- Runs on our maintained [librespot fork](https://github.com/LargeModGames/spotatui-librespot), which backports upstream fixes for Spotify's evolving audio delivery (e.g. the HTTP 530 CDN issue that silenced native playback) +| Key | Action | +|---|---| +| `j` / `Down` | Next station | +| `k` / `Up` | Previous station | +| `Enter` | Play selected station | +| `/` | Search radio-browser.info | +| `Space` | Pause or resume | +| `+` / `-` | Adjust volume | +| `s` | Stop | +| `q` / `Esc` | Quit | -See the [Native Streaming Wiki](https://github.com/LargeModGames/spotatui/wiki/Native-Streaming) for setup details. +## Station storage -## Configuration - -The config file is at `$XDG_CONFIG_HOME/spotatui/config.yml` when `XDG_CONFIG_HOME` is set to an absolute path, falling back to `${HOME}/.config/spotatui/config.yml` when it is unset or not absolute. You can also configure spotatui in-app by pressing `Alt-,` to open Settings. - -Nearly everything is customizable: keybindings, themes, icons, playbar button labels, status-line and window-title format templates, table columns (reorder/rename/resize), default sorting per screen, startup screen, and layout (sidebar/playbar position). Invalid values fall back to defaults with a logged warning — a config typo never blocks startup. - -With the `cover-art` feature, Settings offers optional two-color dithering for the playbar, full-screen art view, and plugin cover-art widgets. In `config.yml`, set `behavior.cover_art_dither: true`, use the default `stucki` algorithm (or choose `bayer8x8`/`atkinson` with `behavior.cover_art_dither_algorithm`), and set `behavior.cover_art_dither_pixel_scale` from `1` to `3` for larger visual pixels. Scale `1` uses every output pixel on graphics terminals or every Braille dot on basic terminals. Each surface makes its mask from the original decoded image at its own output resolution. `theme.cover_art_dither_color` accepts a named color or an exact `R, G, B` value; omit it or set it to `auto` to follow the current playbar progress color, including adaptive-theme changes. The color override survives preset changes. Dithering does not enable cover art on a terminal where it is otherwise disabled; `behavior.draw_cover_art_forced` still controls the terminal fallback. - -- Customization guide: [`docs/configuration.md`](docs/configuration.md), with a commented [`examples/config.example.yml`](examples/config.example.yml) -- Full config reference: [Configuration Wiki](https://github.com/LargeModGames/spotatui/wiki/Configuration) -- Built-in themes (Spotify, Dracula, Nord, …): [Themes Wiki](https://github.com/LargeModGames/spotatui/wiki/Themes) - -spotatui also stores local listening history at `$XDG_STATE_HOME/spotatui/history/listens.jsonl` when `XDG_STATE_HOME` is set to an absolute path, falling back to `${HOME}/.local/state/spotatui/history/listens.jsonl` when it is unset or not absolute. This powers `spotatui history recap`. Short or skipped plays are stored but excluded from recap totals. - -### Discord Rich Presence - -Enabled by default using the built-in spotatui application ID, so no setup is required. Optional overrides: +The player reads configured stations from: ```yaml behavior: - enable_discord_rpc: true - discord_rpc_client_id: "your_client_id" -``` - -You can also override the app ID via `SPOTATUI_DISCORD_APP_ID`, or disable it in Settings or with `behavior.enable_discord_rpc: false`. - -### Anonymous Song Counter - -spotatui includes an opt-in global counter showing how many songs have been played by all users worldwide (the badge and chart at the top of this README). It is **completely anonymous** — no personal information, song names, artists, or listening history is collected; it only sends a simple increment when a new song starts. It is enabled by default and can be disabled with `enable_global_song_count: false` in `config.yml`. This is purely a fun community metric with zero tracking of individual users. - -### GitHub Profile Widget - -Show what you're listening to as a live card on your GitHub profile. Create an account at [spotatui.com](https://spotatui.com), paste your sync token into Settings → `sync_token`, then pick a public username and enable the widget on your dashboard. Add this to your profile README: - -```markdown -[![Now playing on spotatui](https://spotatui.com/widget/your-username.svg)](https://spotatui.com) + radio_stations: + - name: Groove Salad + url: https://ice1.somafm.com/groovesalad-128-mp3 ``` -The card shows cover art, title, artist, a progress bar, and an animated equalizer while playing; internet radio gets a LIVE badge. Append `?theme=light` for the light variant. Only your current track is public, and only after you opt in. +It also reads saved stations from `$XDG_STATE_HOME/spotatui/state.yml` (normally `~/.local/state/spotatui/state.yml`) to preserve existing radio favorites. -## Plugins +## External control -spotatui runs user-written Lua plugins. They react to playback events, add commands and key bindings, draw popups and playbar segments, restyle the theme, and make async HTTP requests. Install one published as a git repository (requires `git`): +List saved stations as JSON: ```bash -spotatui plugin add owner/repo -spotatui plugin list -spotatui plugin update -spotatui plugin remove +spotatui radio list --json ``` -See [`PLUGINS.md`](PLUGINS.md) for the ecosystem overview, [`examples/plugins/`](examples/plugins) for runnable examples, and [`docs/scripting.md`](docs/scripting.md) for the full API reference. - -## Performance - -spotatui is extremely lightweight compared to the official Electron client. - -| Mode | RAM Usage | -| :------------------------------ | :-------- | -| **Native Streaming (Base)** | ~78 MB | -| **With Synced Lyrics** | ~78 MB | -| **With System-Wide Visualizer** | ~80 MB | - -*Tested on Arch Linux (Hyprland).* - -## Playback Requirements - -The non-Spotify sources (Local Files, Subsonic, Internet Radio, YouTube, Qobuz) play through spotatui's own audio engine and need no extra setup. - -Spotify is different: it uses the [Web API](https://developer.spotify.com/documentation/web-api/), which doesn't stream audio itself. To play Spotify tracks you need **one** of: - -1. **Native Streaming** — spotatui plays audio directly using its built-in streaming. See [Native Streaming](#native-streaming). *(Recommended.)* -2. **Official Spotify Client** — have the official app open on your computer. -3. **[spotifyd](https://github.com/Spotifyd/spotifyd)** — a lightweight background alternative. - -Playing Spotify tracks requires a **Premium** account. With a free Spotify account spotatui can authenticate and browse your library/search results, but playback actions (play/pause/seek/transfer) will not work in either native streaming or Web API playback control mode. - -Spotify rate limits are per app. The shared ncspot client ID from the quick setup is shared by every ncspot and spotatui user and is often rate limited; your own Spotify app gets a limit of its own. Run `spotatui --reconfigure-auth` and choose option 2, see [Connecting to Spotify](docs/installation.md#connecting-to-spotify). - -### Deprecated Spotify API Features - -As of November 2024, Spotify removed access to certain API endpoints for new applications. The following features **only work if your Spotify Developer application was created before November 27, 2024**: - -- **Related Artists** — the "Related Artists" section on an artist page. -- **Recommendations** — seed-based recommendations. The **AI DJ** does not depend on this endpoint: it has a model do the recommending and only uses Spotify to look the tracks up, so it works regardless of when your app was created. -- **Audio Analysis** — spotatui no longer depends on it. The **audio visualizer** (press `v`) now uses **local real-time FFT analysis** of your system audio, so it works regardless of your app's creation date: - - | Platform | Status | Notes | - | ----------- | -------------------- | ---------------------------------------- | - | **Windows** | Works out of the box | Uses WASAPI loopback | - | **Linux** | Works out of the box | Uses PipeWire/PulseAudio monitor devices | - | **macOS** | Requires setup | Needs a virtual audio device (see below) | - - > **macOS:** macOS doesn't natively expose system audio loopback. Install a virtual audio device like [BlackHole](https://github.com/ExistentialAudio/BlackHole) (free) or [Loopback](https://rogueamoeba.com/loopback/) (paid), route system audio through it, and set it as your default input device. - > - > **Note:** The visualizer is **system-wide** — it captures all audio on your system, so it also reacts to YouTube videos, games, and any other source. - -For more information, see [Spotify's announcement about API changes](https://developer.spotify.com/blog/2024-11-27-changes-to-the-web-api). - -## Using with [spotifyd](https://github.com/Spotifyd/spotifyd) - -> **Note:** If you're using native streaming, you don't need spotifyd! - -Follow the spotifyd documentation to get set up. After that: - -1. Start the spotifyd daemon. -1. Start `spotatui`. -1. Press `d` to open the device selection menu — the spotifyd "device" should appear (if not, check [these docs](https://github.com/Spotifyd/spotifyd#logging)). - -## Migrating from spotify-tui - -If you used the original `spotify-tui` before: - -- The binary name changed from `spt` to `spotatui`. -- Config paths changed: `~/.config/spotify-tui/` -> `$XDG_CONFIG_HOME/spotatui/` when `XDG_CONFIG_HOME` is set to an absolute path, or `~/.config/spotatui/` when it is unset or not absolute. - -You can copy your existing config: +Ask the running player to switch stations through MPRIS: ```bash -case "${XDG_CONFIG_HOME:-}" in - /*) config_home="$XDG_CONFIG_HOME" ;; - *) config_home="$HOME/.config" ;; -esac -mkdir -p "$config_home/spotatui" -cp -r ~/.config/spotify-tui/* "$config_home/spotatui/" +spotatui radio play https://ice1.somafm.com/groovesalad-128-mp3 ``` -You may be asked to re-authenticate with Spotify the first time. - -## Song History - -![Song History](https://spotatui-counter.spotatui.workers.dev/chart.svg) - -## Star History - - - - - - Star History Chart - - - -## Libraries used - -- [ratatui](https://github.com/ratatui-org/ratatui) - Terminal UI framework -- [rspotify](https://github.com/ramsayleung/rspotify) - Spotify Web API client -- [librespot](https://github.com/librespot-org/librespot) - Spotify Connect streaming (via our maintained fork [spotatui-librespot](https://github.com/LargeModGames/spotatui-librespot), which backports fixes for Spotify's CDN changes) -- [tokio](https://github.com/tokio-rs/tokio) - Async runtime -- [crossterm](https://github.com/crossterm-rs/crossterm) - Terminal manipulation -- [clap](https://github.com/clap-rs/clap) - CLI argument parsing - -## Development - -1. [Install OpenSSL](https://docs.rs/openssl/0.10.25/openssl/#automatic) -1. [Install Rust](https://www.rust-lang.org/tools/install) -1. [Install `xorg-dev`](https://github.com/aweinstock314/rust-clipboard#prerequisites) (required for clipboard support) -1. **Linux only:** Install PipeWire development libraries (required for audio visualization) - ```bash - # Debian/Ubuntu - sudo apt-get install libpipewire-0.3-dev libspa-0.2-dev - - # Arch Linux - sudo pacman -S pipewire - - # Fedora - sudo dnf install pipewire-devel - - # NixOS - nix develop github:LargeModGames/spotatui - ``` -1. Clone or fork this repo and `cd` to it -1. And then `cargo run` - -See [CONTRIBUTING.md](CONTRIBUTING.md) for pull request guidelines. - -### Windows Subsystem for Linux - -You might get a linking error. If so, you'll probably need to install additional dependencies required by the clipboard package: - -```bash -sudo apt-get install -y -qq pkg-config libssl-dev libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev -``` - -## Help Wanted - -**spotatui is currently maintained by a solo developer.** More contributors would be hugely appreciated — and **you don't need to write code to help**: - -- **Star the repo** to help others discover the project -- **Report bugs** or request features in [Issues](https://github.com/LargeModGames/spotatui/issues) -- **Join the community** in [Discussions](https://github.com/LargeModGames/spotatui/discussions) -- **Submit a PR** for code, docs, or themes - -See [CONTRIBUTING.md](CONTRIBUTING.md) for more details! - -## Maintainer - -Maintained by **[LargeModGames](https://github.com/LargeModGames)** ([@LargeModGames](https://twitter.com/LargeModGames) on Twitter). - -Originally forked from [spotify-tui](https://github.com/Rigellute/spotify-tui) by [Alexander Keliris](https://github.com/Rigellute). - -## spotatui Contributors - -**Looking for contributors!** spotatui is actively maintained but could use your help. Whether it's bug fixes, new features, documentation, or testing - all contributions are welcome! - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
LargeModGames
LargeModGames

💻 📖 🚧 🤔 🚇 ⚠️
MysteriousWolf
MysteriousWolf

💻
rawcode1337
rawcode1337

💻 🐛
copeison
copeison

📦
jacklorusso
jacklorusso

📖
H41L33
H41L33

📖
LordMZTE
LordMZTE

💻 📦
Sebastian Sarco
Sebastian Sarco

💻
Sergio Tabernero Hernández
Sergio Tabernero Hernández

💻 📦
David Novakovic
David Novakovic

💻
Paul
Paul

🎨 💻
Arun Felix
Arun Felix

💻 🐛
MaySeikatsu
MaySeikatsu

💻 📖 📦
Lorenzo Bodini
Lorenzo Bodini

💻 🎨
Vi1i Petal
Vi1i Petal

💻 🐛
Will Sturgeon
Will Sturgeon

💻 🐛
wfinken
wfinken

💻 🐛
Jacob
Jacob

📦
Dominick Lee
Dominick Lee

💻
Higor Prado
Higor Prado

💻
Vitali Kaplich
Vitali Kaplich

📖
knth
knth

💻
Lorelei Noble
Lorelei Noble

💻
Ben Allen
Ben Allen

💻
shilicioo
shilicioo

💻
Thomas Allan
Thomas Allan

💻
Pratyoosh Prakash
Pratyoosh Prakash

💻 🐛
Tahmid Ahmed
Tahmid Ahmed

💻
Ritze
Ritze

💻 🐛
Hansen
Hansen

📖
Yusuke Hayashi
Yusuke Hayashi

💻 📖
Felix
Felix

💻
Dino Leung
Dino Leung

📦
Szabó István
Szabó István

📖
Alessandro Zanni
Alessandro Zanni

💻
- - - - - - -*Want to see your name here? Check out our [open issues](https://github.com/LargeModGames/spotatui/issues) or the [Roadmap](#roadmap) below!* - ---- - -## Upstream Contributors (spotify-tui) - -Thanks to all the contributors who built the original [spotify-tui](https://github.com/Rigellute/spotify-tui) that this project is forked from: - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Alexander Keliris
Alexander Keliris

💻 📖 🎨 📝 🤔 🚇 📦 👀
Mickael Marques
Mickael Marques

💵
Grzegorz Koperwas
Grzegorz Koperwas

📖
Austin Gassert
Austin Gassert

💻
Calen Robinette
Calen Robinette

💻
M*C*O
M*C*O

🚇
Andrew Chin
Andrew Chin

💻
Sam Naser
Sam Naser

💻
Micha
Micha

💻
neriglissar
neriglissar

💻
Timon
Timon

💻
echoSayonara
echoSayonara

💻
D-Nice
D-Nice

📖 🚇
Grzegorz Pawlik
Grzegorz Pawlik

💻
Lennart Bernhardt
Lennart Bernhardt

💻
Arnaud Lefebvre
Arnaud Lefebvre

💻
tem1029
tem1029

💻
Peter K. Moss
Peter K. Moss

💻
Geoff Shannon
Geoff Shannon

💻
Zachary Mayhew
Zachary Mayhew

💻
jfaltis
jfaltis

💻
Marcel Schramm
Marcel Schramm

📖
Fangyi Zhou
Fangyi Zhou

💻
Max
Max

💻
Sven van der Vlist
Sven van der Vlist

💻
jacobchrismarsh
jacobchrismarsh

💻
Nils Rauch
Nils Rauch

💻
Nick Stockton
Nick Stockton

💻 🐛 🚧 💬 📖
Stuart Hinson
Stuart Hinson

💻
Sam Calvert
Sam Calvert

💻 📖
Jeroen Wijenbergh
Jeroen Wijenbergh

📖
Kimberley Cook
Kimberley Cook

📖
Audrey Baxter
Audrey Baxter

💻
Norman
Norman

📖
Peter Maatman
Peter Maatman

💻
AlexandreS
AlexandreS

💻
Finn Vos
Finn Vos

💻
Carlos Hernandez
Carlos Hernandez

📦
Pedro Alves
Pedro Alves

💻
jtagcat
jtagcat

📖
Benjamin Kitor
Benjamin Kitor

💻
Aleš Najmann
Aleš Najmann

📖 📦
Jeremy Stucki
Jeremy Stucki

💻
(´⌣`ʃƪ)
(´⌣`ʃƪ)

💻
Artem Polishchuk
Artem Polishchuk

📦
Chris Sosnin
Chris Sosnin

💻
Ben Buhse
Ben Buhse

📖
Sean Li
Sean Li

💻
TimotheeGerber
TimotheeGerber

💻 📖
Ferdinand Ratajczak
Ferdinand Ratajczak

💻
Sheel Choksi
Sheel Choksi

💻
Michael Hellwig
Michael Hellwig

📖
Oliver Daniel
Oliver Daniel

💻
Drew Fisher
Drew Fisher

💻
ncoder-1
ncoder-1

📖
Macguire Rintoul
Macguire Rintoul

📖
Ricardo Holguin
Ricardo Holguin

💻
Keisuke Toyota
Keisuke Toyota

💻
Craig Astill
Craig Astill

💻
Onielfa
Onielfa

💻
usrme
usrme

📖
Sergey A.
Sergey A.

💻
Hideyuki Okada
Hideyuki Okada

💻
kepae
kepae

💻 📖
Érico Nogueira Rolim
Érico Nogueira Rolim

💻
Alexander Meinhardt Scheurer
Alexander Meinhardt Scheurer

💻
Ondřej Kinšt
Ondřej Kinšt

💻
Kryan90
Kryan90

📖
n-ivanov
n-ivanov

💻
bi1yeu
bi1yeu

💻 📖
May
May

💻
Bruno A. Muciño
Bruno A. Muciño

💻
Finn Hediger
Finn Hediger

💻
dp304
dp304

💻
Marco Micera
Marco Micera

📖
Marco Ieni
Marco Ieni

🚇
Artúr Kovács
Artúr Kovács

💻
Antony Kellermann
Antony Kellermann

💻
Rasmus Pedersen
Rasmus Pedersen

💻
noir-Z
noir-Z

📖
David Bailey
David Bailey

📖
sheepwall
sheepwall

💻
Hwatwasthat
Hwatwasthat

💻
Jesse
Jesse

💻
Sang
Sang

📖
Yuuki Takahashi
Yuuki Takahashi

📖
Alejandro Angulo
Alejandro Angulo

💻
Anton Kostin
Anton Kostin

📖
Justin Sexton
Justin Sexton

💻
Jiati Le
Jiati Le

📖
Matthew Cobbing
Matthew Cobbing

💻
Milo
Milo

🚇
Diego Veralli
Diego Veralli

💻
Maja Bojarska
Maja Bojarska

💻
- - - - - - -This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! - -## Roadmap - -The goal is to eventually implement almost every Spotify feature. - -**High-priority features:** -- Scroll through result pages in every view - -See the [Roadmap Wiki](https://github.com/LargeModGames/spotatui/wiki/Roadmap) for the full API coverage table. +Repository: diff --git a/agent-plugin/.claude-plugin/plugin.json b/agent-plugin/.claude-plugin/plugin.json deleted file mode 100644 index 8e19befe..00000000 --- a/agent-plugin/.claude-plugin/plugin.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "spotatui", - "version": "0.1.0", - "description": "Turn any MCP-capable coding agent into a DJ for spotatui, the terminal Spotify client.", - "homepage": "https://github.com/LargeModGames/spotatui", - "repository": "https://github.com/LargeModGames/spotatui", - "license": "MIT", - "keywords": [ - "spotify", - "music", - "dj", - "mcp", - "tui" - ] -} diff --git a/agent-plugin/.mcp.json b/agent-plugin/.mcp.json deleted file mode 100644 index 40fe8a0c..00000000 --- a/agent-plugin/.mcp.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "mcpServers": { - "spotatui": { - "type": "stdio", - "command": "spotatui", - "args": [ - "mcp" - ] - } - } -} diff --git a/agent-plugin/README.md b/agent-plugin/README.md deleted file mode 100644 index c59fb07c..00000000 --- a/agent-plugin/README.md +++ /dev/null @@ -1,68 +0,0 @@ -# spotatui agent plugin - -One directory that registers spotatui's MCP server with an AI coding agent *and* -installs a DJ skill alongside it, in two formats at once: - -- **[Agent Plugins 1.0](https://agent-plugins.org)** (`plugin.json`, `mcp.json`) — - the vendor-neutral format, understood by VS Code, Cursor, GitHub Copilot, OpenAI - Codex, and Kiro. -- **Claude Code plugin** (`.claude-plugin/plugin.json`, `.mcp.json`) — Claude Code - does not read the Agent Plugins format yet, so the same identity and the same - server registration are mirrored in its own layout. - -> Not to be confused with spotatui's **Lua plugins**, which are in-app scripts that -> react to playback events and draw UI. Those are documented in -> [`PLUGINS.md`](../PLUGINS.md). - -## What is inside - -```text -agent-plugin/ -├── plugin.json Agent Plugins 1.0 manifest -├── mcp.json Agent Plugins 1.0 MCP server config -├── .claude-plugin/plugin.json Claude Code manifest -├── .mcp.json Claude Code MCP server config -└── skills/spotatui-dj/SKILL.md the DJ skill, shared by both formats -``` - -Both MCP configs register the same thing: a **stdio** server run as -`spotatui mcp`. The skill teaches the agent the DJ workflow — read the listening -history first, search before queueing by name, queue rather than interrupt, and -what `owned`, `exclude_owned`, and `set_dj_vibe` actually mean. - -## Prerequisites - -The plugin registers the server; it cannot install the binary. Before installing: - -1. spotatui built with the feature: `cargo install --locked spotatui --features mcp-server` - (it is not in `default`), or, from a checkout: `cargo install --path . --features mcp-server`. -2. `behavior.mcp_enabled: true` in `config.yml` — at the path `spotatui mcp status` - reports, since `XDG_CONFIG_HOME` and Windows resolve it elsewhere. -3. spotatui running — the socket opens at startup, so restart it after step 2. -4. `spotatui` on the `PATH` the agent client spawns processes with. - -Check all of it with `spotatui mcp status` (safe; exits non-zero on the failing -step). Never run `spotatui mcp` to test — that command *is* the server and blocks -on stdin. - -## Install - -**Claude Code** - -```text -/plugin marketplace add LargeModGames/spotatui -/plugin install spotatui@spotatui -``` - -**Agent Plugins 1.0 clients** (VS Code, Cursor, GitHub Copilot, Codex, Kiro) - -Point the client at this `agent-plugin/` directory using its own plugin install -mechanism. The spec defines the package layout and leaves distribution to each -client, so check your client's docs for how it adds a local or repository-hosted -plugin. - -## Full setup and troubleshooting - -[`docs/mcp-setup.md`](../docs/mcp-setup.md) is the complete guide, written as -instructions you can hand straight to an agent, with a troubleshooting table for -every failure mode. diff --git a/agent-plugin/mcp.json b/agent-plugin/mcp.json deleted file mode 100644 index 9c0f7a90..00000000 --- a/agent-plugin/mcp.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", - "mcpServers": { - "spotatui": { - "type": "stdio", - "command": "spotatui", - "args": [ - "mcp" - ] - } - } -} diff --git a/agent-plugin/plugin.json b/agent-plugin/plugin.json deleted file mode 100644 index 845d45d0..00000000 --- a/agent-plugin/plugin.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", - "name": "spotatui", - "version": "0.1.0", - "description": "Turn any MCP-capable coding agent into a DJ for spotatui, the terminal Spotify client.", - "homepage": "https://github.com/LargeModGames/spotatui", - "repository": "https://github.com/LargeModGames/spotatui", - "license": "MIT", - "keywords": [ - "spotify", - "music", - "dj", - "mcp", - "tui" - ] -} diff --git a/agent-plugin/skills/spotatui-dj/SKILL.md b/agent-plugin/skills/spotatui-dj/SKILL.md deleted file mode 100644 index b863c338..00000000 --- a/agent-plugin/skills/spotatui-dj/SKILL.md +++ /dev/null @@ -1,125 +0,0 @@ ---- -name: spotatui-dj -description: Be the DJ for spotatui, the terminal music player, by driving its MCP server. Use whenever the user asks for music, asks you to DJ, wants tracks queued, played, skipped, or searched, asks what they have been listening to, or mentions spotatui. ---- - -# DJ for spotatui - -You control a music player the user is watching. Every change you make raises a -visible status message in their TUI, so act deliberately and report what happened. - -## Before anything else - -The eight spotatui tools are visible whenever the plugin's server is registered. -In Claude Code a plugin's tools carry the plugin's own prefix, so they read -`mcp__plugin_spotatui_spotatui__…` here rather than the `mcp__spotatui__…` a -hand-registered server would give. Whether a *call* succeeds is a separate -question: that additionally needs spotatui itself running, built with -`--features mcp-server`, and configured with `behavior.mcp_enabled: true`. -Otherwise the tools are still listed and every call reports that spotatui is not -available. - -If the tools are missing or every call reports spotatui is not available: - -```bash -spotatui mcp status # safe, returns immediately, exits non-zero on the failing step -``` - -Then follow -. - -**Never run `spotatui mcp` to test anything.** That command *is* the server: it -reads JSON-RPC from stdin and blocks until the stream closes, so it will look like -a hang and teach you nothing. Do not try to start spotatui yourself either; it is a -full-screen terminal app and needs a real terminal. Ask the user to start it. - -## The tools - -| Tool | Use it for | -|---|---| -| `get_listening_history` | Aggregate taste summary: top artists, tracks, albums, recent plays, current vibe, now playing | -| `get_now_playing` | Current track, whether playback is active, `queue_depth` | -| `get_queue` | Upcoming tracks, in play order | -| `search_tracks` | Find tracks and their URIs; each result marked `owned` or `new` | -| `queue_tracks` | Add tracks by `uri`, or by `title` + `artist` | -| `play_now` | Start one track immediately | -| `skip_track` | Skip to the next track | -| `set_dj_vibe` | Record a standing direction for the built-in auto-queue DJ | - -That is the whole surface. Do not promise the user control beyond it; in -particular there is no tool to toggle continuous auto-queue. - -## Start with their taste - -Call `get_listening_history` **first** whenever you are choosing music for them. -`period` accepts `7d`, `30d`, `month`, `year`, `all`, and defaults to `30d`. A -normal summary returns aggregate names only, no identifiers or timestamps, plus -whatever is playing now and the current DJ vibe. - -When there is very little history it returns none of that — no top lists, no vibe, -no now playing, just a short note saying so. Take the note at face value and **ask -the user what they feel like** rather than inferring a taste profile from a handful -of plays. - -## Search before you queue by name - -When you are not certain a track exists in the catalogue, call `search_tracks` -first (`query` required, `limit` 1-20, default 10) and queue the URIs it returns. - -Every result is marked **`owned`** (in their Liked Songs, or in a playlist they own -or collaborate on) or **`new`**. - -* They asked for something they do not already have: prefer results marked `new`. - `new` means "not in their library", not "never heard" — nothing here reads their - play history. -* They named a specific track: queue it whether they own it or not. That is what - they asked for. - -## Queue, do not interrupt - -`queue_tracks` takes an array of tracks **in play order**. Each entry is either a -`uri` (preferred, straight from `search_tracks`) or both a `title` and an `artist` -to be looked up. **At most eight tracks per call.** A longer array is rejected as -invalid arguments and nothing at all is queued, so split a longer run into batches -of eight, sent in play order. - -It can partially succeed. Tracks it cannot find are skipped and reported back. -**Read the result** and tell the user what actually landed; never assume everything -queued. If a name was not found, that track is not in the catalogue under that -name, so pick an alternative rather than retrying the same string. - -`exclude_owned: true` makes it skip anything they already have: - -* Set it only when they asked for music they do **not** already have. -* Leave it off when they named specific tracks. -* It substitutes nothing, so fewer tracks may land than you sent. -* If it errors with "could not read your playlists", the filter could not run and - nothing was queued. Retry, or call again without the flag. - -Use `play_now` **only when the user asked to hear something right now** — it -interrupts whatever is playing. Otherwise queue. - -## `set_dj_vibe` starts nothing - -It records a standing direction (for example "mellow instrumental for focusing") -for the *optional* in-TUI auto-queue DJ, which may be switched off or not built -into this binary at all. Pass `null` to clear it. Read the result: it tells you -whether anything will actually act on the vibe. - -The vibe is stored either way and comes back from `get_listening_history`, so when -nothing else will act on it, **honour it yourself** when you pick tracks. - -## Keeping the music flowing - -There is no tool to switch continuous auto-queue on; that is the user's key inside -the TUI. To DJ continuously yourself, poll `get_now_playing`, watch `queue_depth`, -and top up with `queue_tracks` before it runs dry. - -## Quirks worth knowing - -* The first `search_tracks` after spotatui starts may note that the playlist index - is still building. For that one search, `owned` reflects Liked Songs only; the - crawl finishes in the background, so search again if ownership matters. -* A tool reporting "spotatui has no Spotify session" means the catalogue tools need - a login. Ask the user to log in from the spotatui UI. -* Every change you make is announced in the user's TUI. They can see what you did. diff --git a/announcements.json b/announcements.json deleted file mode 100644 index 7a517c25..00000000 --- a/announcements.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "version": 1, - "announcements": [ - { - "id": "2026-08-06-discord-community", - "title": "Join the spotatui Discord", - "body": "spotatui now has a community Discord: a place for install help, feature ideas, listening parties, and showing off your terminal setup. Come hang out, get support faster, and hear about new releases first.", - "level": "info", - "url": "https://spotatui.com/discord", - "starts_at": "2026-08-06T00:00:00Z", - "ends_at": null - }, - { - "id": "2026-07-04-multi-source-playback", - "title": "New: play from more than just Spotify", - "body": "spotatui is no longer Spotify-only. You can now stream from local files, a Subsonic/Navidrome server, internet radio, and YouTube, all from the same interface. Spotify is now optional at first-time setup, so pick whichever sources you want from the interactive source picker on first run. Cover art, lyrics, Discord Rich Presence, and media-key controls work across every source. Enable the free sources with the `all-sources` build feature.", - "level": "info", - "url": "https://github.com/LargeModGames/spotatui/pull/329", - "starts_at": "2026-07-04T00:00:00Z", - "ends_at": null - }, - { - "id": "2026-06-12-self-update-broken", - "title": "Action needed: auto-update is broken in v0.38.5 to v0.39.0", - "body": "A bug in update checksum verification means spotatui v0.38.5 through v0.39.0 cannot update themselves: the update check fails silently on every launch. The fix ships in v0.39.1, but affected versions cannot reach it on their own. Please update once manually (winget upgrade, brew upgrade, your package manager, or the GitHub releases page). Auto-update works again from v0.39.1 onward.", - "level": "warning", - "url": "https://github.com/LargeModGames/spotatui/pull/303", - "starts_at": "2026-06-12T00:00:00Z", - "ends_at": "2026-07-03T23:59:59Z" - }, - { - "id": "2026-06-12-lua-scripting", - "title": "New: Lua plugin scripting", - "body": "spotatui now supports Lua plugins, letting you extend and automate the app with your own scripts. Use `spotatui plugin new` to scaffold a plugin, then hook into the plugin API to add custom behavior. See the docs to get started, and share what you build with the spotatui-plugin topic on GitHub.", - "level": "info", - "url": "https://github.com/LargeModGames/spotatui/pull/302", - "starts_at": "2026-06-12T00:00:00Z", - "ends_at": null - }, - { - "id": "2026-05-19-sonos-help-wanted", - "title": "Help wanted: Sonos integration", - "body": "Sonos support was requested in Discussion #247 and is now tracked in Issue #251. I started a WIP branch named sonosWIP, but discovery currently only sees a Sonos speaker for a few seconds before it disappears. My test speaker broke, so I cannot continue debugging locally. If you have Sonos speakers and know Rust, help testing or completing the integration would be very welcome.", - "level": "info", - "url": "https://github.com/LargeModGames/spotatui/issues/251", - "starts_at": "2026-05-19T00:00:00Z", - "ends_at": "2026-07-03T23:59:59Z" - }, - { - "id": "2026-02-27-major-refactor-complete", - "title": "Major Refactor Complete", - "body": "A large refactor has just landed to make spotatui cleaner, more stable, and easier to build on. If you have been thinking about contributing, now is a great time to jump in! Issues, fixes, docs, and feature ideas are all welcome!", - "level": "info", - "url": "https://github.com/LargeModGames/spotatui/blob/main/CONTRIBUTING.md", - "starts_at": "2026-02-27T00:00:00Z", - "ends_at": "2026-07-03T23:59:59Z" - } - ] -} diff --git a/context7.json b/context7.json deleted file mode 100644 index 70123f37..00000000 --- a/context7.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "$schema": "https://context7.com/schema/context7.json", - "projectTitle": "spotatui", - "description": "A fast, standalone terminal music player in Rust: native Spotify streaming plus local, Subsonic, radio, YouTube, and Qobuz sources.", - "folders": [ - "docs", - "examples" - ], - "excludeFolders": [ - "src", - "target", - "worker-relay", - "snap", - "graphify-out", - "spotatui.wiki", - ".github" - ], - "excludeFiles": [ - "CHANGELOG.md", - "CLAUDE.md", - "AGENTS.md", - "CODE_OF_CONDUCT.md", - "CONTRIBUTING.md", - "SECURITY.md", - "how_to_release.md" - ], - "rules": [ - "spotatui is a terminal application, not a library. A plain `cargo run` builds the Spotify-only default; use `cargo run --features all-sources` to enable the Local, Subsonic, Radio, YouTube, and Qobuz sources.", - "Native streaming (librespot) and audio visualization are part of the default features; the slim CI build uses `--no-default-features --features telemetry`.", - "Keybindings and most behavior are user-configurable via the YAML config; see docs/configuration.md and examples/config.example.yml.", - "The Lua scripting/plugin API is documented in docs/scripting.md with runnable examples under examples/plugins/." - ] -} diff --git a/default.nix b/default.nix index c8908f45..596ee668 100644 --- a/default.nix +++ b/default.nix @@ -2,7 +2,7 @@ pkgs.rustPlatform.buildRustPackage rec { pname = "spotatui"; - version = "0.34.3"; + version = "0.43.0-radio.1"; src = pkgs.lib.cleanSource ./.; @@ -36,8 +36,8 @@ ''; meta = with pkgs.lib; { - description = "Terminal UI Spotify client"; - homepage = "https://github.com/LargeModGames/spotatui"; + description = "An internet-radio terminal player with MPRIS control"; + homepage = "https://github.com/ethereumdegen/degen-radio"; license = licenses.mit; mainProgram = "spotatui"; platforms = platforms.linux; diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 8323588a..00000000 --- a/docs/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# spotatui Documentation - -Detailed documentation for [spotatui](https://github.com/LargeModGames/spotatui), a Spotify client for the terminal written in Rust. - -## Getting Started - -1. **[Installation](installation.md)** - Install spotatui on your platform -2. **[Configuration](configuration.md)** - Customize behavior and settings -3. **[Keybindings](keybindings.md)** - Learn the keyboard controls - -## Features - -- **[Themes](themes.md)** - Built-in presets and custom color schemes -- **[Native Streaming](native-streaming.md)** - Play music directly without the Spotify app -- **[Scripting](scripting.md)** - Lua plugin API for extending spotatui - -## Development - -- **[Roadmap](roadmap.md)** - Planned features and API coverage -- **[CONTRIBUTING.md](https://github.com/LargeModGames/spotatui/blob/main/CONTRIBUTING.md)** - How to contribute - -## Getting Help - -- **[Report a bug](https://github.com/LargeModGames/spotatui/issues/new)** - Found an issue? -- **[Discussions](https://github.com/LargeModGames/spotatui/discussions)** - Questions and ideas diff --git a/docs/ai-dj.md b/docs/ai-dj.md deleted file mode 100644 index 8f6c6179..00000000 --- a/docs/ai-dj.md +++ /dev/null @@ -1,450 +0,0 @@ -# AI DJ - -spotatui can pick music for you. It reads the listening history it already -collects locally, and holds a conversation with a model that can search the -catalogue, look at your queue, and queue tracks. It can also just talk: ask it what -you have been listening to, or answer a question it asks you before it plays -anything. - -There are **two ways to use it**, and they share everything below the model — -including the tool list, so both can do exactly the same things: - -| | [MCP server](mcp-setup.md) | In-TUI DJ (this page) | -|---|---|---| -| Where you talk | Your coding agent (Claude Code, Codex, …) | A DJ screen inside spotatui | -| Needs an API key | No | Only for the API backends | -| Build with | `--features mcp-server` | `--features ai-dj` | -| Tools | The full set, driven by your agent | The same set, driven by spotatui | - -Use the MCP server if you would rather DJ from a window you already have open; -**one command sets it up**, see [`docs/mcp-setup.md`](mcp-setup.md). - -## How a turn works - -A turn is up to **four steps**. Each step the model either says something, asks for -tools to be run, or both; spotatui runs what it asked for and hands back the -results, so it can look something up before committing. A step with words and no -tool calls ends the turn — which is what an ordinary conversational reply is. - -```text -you something chill for focusing, nothing I already own -dj Let me see what you've been on lately. - · get_listening_history → 30 days, 412 plays - · search_tracks → Says — Nils Frahm [spotify:track:…] [new] -dj Six ambient and modern-classical picks, none already yours. - · queue_tracks → Queued 6 track(s): - -you actually more beats, less piano -dj Downtempo, or closer to house tempo? -``` - -The tools are the same table the MCP server publishes, so anything added there the -in-TUI DJ can use too. The step cap matters most on an agent CLI, where **every -step is a fresh subprocess**: a four-step turn costs four process launches and four -times the quota of one. The `…thinking (2/4)` row tells you where a slow turn is. - -Two paths deliberately never stop to ask a question, because nobody is watching the -screen: the auto-queue refill and the vibe shift. They get two steps and are told to -queue immediately. - -> **Why the DJ is home-grown rather than Spotify's.** Spotify's own AI DJ cannot be -> driven through the public API (issue #196), and Spotify -> [restricted `/recommendations`](https://developer.spotify.com/blog/2024-11-27-changes-to-the-web-api) -> to apps that already held extended quota on 2024-11-27. spotatui users register -> their own client ID, so that endpoint returns 403 for them. The model is the -> recommender here; Spotify and YouTube are only catalogue and player. - ---- - -## Quick start - -```bash -cargo run --features ai-dj -``` - -Press Ctrl+J to open the DJ, type what you want, press -Enter. - -Out of the box the DJ uses **whichever coding agent you already have installed**, -so there is no API key to configure. The default is `claude -p`. - -**The first time you open the DJ it asks which AI to use and which of that AI's -models**, because the default backend spends the coding subscription you already pay -for, and the heaviest model exhausts a Claude Pro plan in a handful of turns. See -[Which AI, which model](#which-ai-which-model). Press Esc to keep -whatever is already configured and it will not ask again; -Ctrl+G reopens the picker whenever you want to change it. A -config that already names a backend, a model or a key is left alone and never asked -at all, which is [why some installs never see it](#it-never-asked-me). - -### Keys - -| Key | Action | -|---|---| -| Ctrl+J | Open the DJ screen | -| Ctrl+T | Toggle continuous auto-queue | -| Ctrl+Y | Vibe shift: drop the DJ's queued tracks and change direction | -| Ctrl+O | Toggle "only tracks I don't already have" | -| Ctrl+G | Choose which AI and model the DJ uses | -| Enter | Send the prompt | -| ↑ ↓ PgUp PgDn | Scroll the transcript | -| Esc | Clear the prompt, or leave the screen when it is empty | - -All five are rebindable, see [`docs/keybindings.md`](keybindings.md). - -The DJ is also reachable from the **AI DJ** row in the sidebar, which is offered -under every source. The global key opens the screen from anywhere without a -trip through the sidebar. - -### Continuous auto-queue - -With auto-queue on, the DJ tops the queue up as tracks finish, keeping roughly six -tracks ahead. It refills when the queue drops to two — about 6–8 minutes of -runway, comfortably more than the worst-case round trip. - -### Only tracks you don't already have - -Ask for "songs like Remind Me to Forget" and a good recommender will name tracks -you already own — they *are* the best answer to the question. Press -Ctrl+O to switch that off and get only tracks that are new -to you. The panel title shows the mode. - -"Already have it" means: - -* anything in your **Liked Songs**, and -* anything in a playlist **you own or collaborate on**. - -Playlists you merely follow do not count. Discover Weekly, Release Radar and the -large editorial playlists hold thousands of tracks between them, and treating -those as yours would reject nearly every good recommendation. - -Two things to expect the first time you turn it on: - -* **A short pause while it indexes.** Liked Songs are checked exactly, one call - per batch, with nothing cached. Playlists have no such lookup, so spotatui - crawls yours once per session and caches the result. The title reads - `fresh only (indexing…)` while that runs. -* **Smaller batches sometimes.** The DJ asks for roughly double what it needs so - the rejections have somewhere to come from, and it gets one retry when most of a - batch is filtered out. With a very large library it can still come up short; it - says so rather than quietly queueing two tracks when you asked for six. - -Start in this mode every session with: - -```yaml -behavior: - dj_avoid_library: true -``` - -Over MCP the same knowledge is offered rather than imposed, because there an agent -queues what it was asked to queue and silently dropping a track because you own it -would be answering a question nobody asked. So `search_tracks` marks every result -`[owned]` or `[new]` — the agent sees it while it is still choosing — and -`queue_tracks` takes `exclude_owned: true` for an agent that wants the same hard -guarantee this toggle gives you. See [`docs/mcp-setup.md`](mcp-setup.md). - ---- - -## Which AI, which model - -The first time you open the DJ it shows a two-step picker: which AI, then which of -that AI's models. It appears whether you got there with Ctrl+J -or from the sidebar's **AI DJ** row, and Ctrl+G brings it back -at any time. - -**Step 1, the AI.** Every backend spotatui supports, and what each one bills -against: - -| Row | Bills against | -|---|---| -| `claude` | your Claude Pro/Max plan | -| `codex` | your ChatGPT plan | -| `agy` | your Antigravity plan (Antigravity replaced Google's Gemini CLI) | -| `copilot` | your GitHub Copilot plan | -| `opencode` | whatever provider opencode is logged into | -| `anthropic` | an Anthropic API key, per token | -| `openai_compat` | nothing for a local model; per token for a hosted one | - -`opencode` names models as `provider/model` (`openai/gpt-5.4`), not a bare name; -the picker says so where you type one. - -An agent row you have not installed is marked **not on PATH** and is still -selectable, because "I am about to install it" is a real answer. spotatui identifies -them by looking for the binary on your `PATH` and never runs anything to find out, -so opening the picker cannot stall the UI. The legacy `gemini` row is only offered -if you actually have that binary. - -**Step 2, the model.** A short list of known-good values, each with its cost, then -two rows that are always present: *Use the CLI's default* (or *Use the built-in -default* for the API backends), which passes no model at all, and *Custom…*, which -takes any name you type. The lists are conveniences rather than a contract, since -the vendor decides which models its CLI accepts, so the free-text row is the escape -hatch when a list goes out of date. `codex`, `copilot`, `opencode` and the legacy -`gemini` have no list at all: which model ids their subscription accepts is not -knowable without asking them, and the picker never runs a subprocess. - -Keys in the picker: ↑ ↓ (or `j`/`k`) to move, -1–9 to take a numbered row outright without a further -Enter, Enter to choose the highlighted one, Esc to -step back. Rows past the ninth are left unnumbered, because there is no key that -would select them. In the free-text step `j` and `k` type themselves instead of -navigating, the way any text field has to. - -Esc from the first step closes the picker, leaves your current brain -exactly as it was, and **records that the question was answered** -(`behavior.dj_configured: true`), so it is not asked again on every visit. If that -write fails the status bar says so, and the next launch asks again. - -The picker deliberately never asks for an API key: `behavior.dj_api_key` is -plaintext YAML, so it points you at `SPOTATUI_DJ_API_KEY` instead. It also leaves -`dj_base_url` alone, so a self-hosted `openai_compat` endpoint is still configured -by hand. - -### It never asked me - -Then something in your config already counts as a deliberate choice, and spotatui -does not interrogate a user who has already decided. Any one of these is enough: - -* `dj_configured: true`, which is what the picker writes when you finish or dismiss it -* a `dj_backend` other than `agent_cli` -* a `dj_agent_command` other than the shipped `["claude", "-p"]` -* any of `dj_agent_model`, `dj_model`, `dj_api_key` or `dj_base_url` set - -Tuning `dj_batch_size`, `dj_history_period`, `dj_avoid_library` or the timeout does -not count, because none of them is a choice of AI. Either way, -Ctrl+G opens the picker on demand. - ---- - -## Backends - -Set `behavior.dj_backend` in `~/.config/spotatui/config.yml`, or let the picker -write it for you. - -**The two ways of paying are not interchangeable, and that is why the model matters -so much more in one of them.** `agent_cli` costs no money and spends the usage -limits of the subscription that CLI is logged into, so on a Claude Pro plan the -model is the difference between a DJ that runs all evening and one that stops after -a handful of turns. The API backends cost money per token and have no subscription -limit to hit, so there the model is a bill rather than a quota. - -### `agent_cli` (default) — no API key - -Runs a coding agent you already have installed and authenticated, so it costs -nothing beyond the subscription you already pay for. - -```yaml -behavior: - dj_backend: agent_cli - dj_agent_command: ["claude", "-p"] - dj_agent_model: haiku # optional; that CLI's own model name - # dj_agent_prompt_via: stdin # only to override the preset; see below - dj_agent_timeout_secs: 90 -``` - -A **bare binary name expands to a known preset**, so this is enough: - -```yaml - dj_agent_command: ["codex"] # becomes ["codex", "exec", "-"] -``` - -```yaml - dj_agent_command: ["agy"] # becomes ["agy", "-p"], with arg delivery -``` - -| CLI | Expands to | With `dj_agent_model` set | Prompt via | -|---|---|---|---| -| `claude` | `claude -p` | `claude --model haiku -p` | stdin | -| `codex` | `codex exec -` | `codex exec --model MODEL -` | stdin | -| `agy` (Antigravity) | `agy -p` | `agy --model gemini-3.6-flash-low -p` | argv | -| `gemini` (legacy) | `gemini -p` | `gemini -m MODEL -p` | argv | - -`agy` is Google's current CLI. The Gemini CLI it superseded is kept as a preset only -so an existing config keeps working, and it is not offered in the picker unless the -binary is installed. - -Note where the model flag lands: **before** the flag that carries the prompt. For -argv delivery the prompt is appended as the last argument, so the other order -(`agy -p --model X "…"`) would hand `--model` to `-p` as its value and the prompt -would never arrive. - -`agy` also **ignores stdin entirely**, so it needs `arg` delivery. Leave -`dj_agent_prompt_via` out of your config and the preset picks the right one; the -picker clears that key when you choose an agent, precisely so a `stdin` left behind -by an older config cannot swallow the prompt. - -What to put in `dj_agent_model` is whatever that CLI itself accepts. For `claude` -the picker offers its public aliases, cheapest first: `haiku` (easiest on a Pro -plan), `sonnet`, `opus` (a Pro plan hits its limit fast), and `fable`. For `agy` it -offers a snapshot of what `agy models` prints: `gemini-3.6-flash-low` / `-medium` / -`-high`, `gemini-3.5-flash-low`, `gemini-3.1-pro-low` / `-high`, -`claude-sonnet-4-6`, `claude-opus-4-6-thinking`, and `gpt-oss-120b-medium`. Google -changes that list server-side, so run `agy models` for the current one. A name the -CLI does not know fails locally in milliseconds, before anything is sent, so a stale -suggestion costs you one error message and nothing else. - -Any other headless command works too, since spotatui writes the prompt and reads -stdout and nothing is hardcoded about these four. **A multi-part command you wrote -yourself is used exactly as written and never gains a model flag**, so your own argv -keeps ownership of its own flags. The single exception is the shipped -`["claude", "-p"]`: it is byte-identical to what the preset produces, so it still -counts as the preset shape and can still take a model. - -**Be clear about the cost of this mode:** every step is a fresh subprocess with -several seconds of startup latency, so a multi-step turn is several of those. -That is what the four-step cap bounds. The API backends below keep one HTTP -connection per step instead and are correspondingly quicker. - -The agent runs in a scratch directory (`~/.config/spotatui/dj-scratch`), *not* your -current directory — otherwise it reads the `CLAUDE.md` and source files of whatever -project you happen to be in and starts recommending music about Rust. - -### `anthropic` — API key - -Pay per token against an API key. **A Claude Pro or Max subscription is not involved -here**: its usage limits do not apply, and it does not cover this bill either. - -```yaml -behavior: - dj_backend: anthropic - dj_model: claude-haiku-4-5 # optional; this is also the default -``` - -```bash -export SPOTATUI_DJ_API_KEY=sk-ant-... -``` - -The models the picker offers, with list price per million tokens: - -| Model | Input / output per MTok | Context | -|---|---|---| -| `claude-haiku-4-5` (default) | $1 / $5 | 200K | -| `claude-sonnet-5` | $3 / $15 | 1M | -| `claude-opus-5` | $5 / $25 | 1M | -| `claude-opus-4-8` | $5 / $25 | 1M | -| `claude-fable-5` | $10 / $50 | 1M | - -One step is a compact brief and a short reply, so a single ask is cheap on any of -them; it is a multi-step turn, and continuous auto-queue over a long evening, that -makes the choice add up. Any other model id works too, since the list is a -convenience rather than a restriction. - -### `openai_compat` — API key or a local model - -One adapter for anything speaking `/chat/completions`: OpenAI, OpenRouter, -**Ollama**, LM Studio, vLLM, llama.cpp. - -```yaml -behavior: - dj_backend: openai_compat - dj_base_url: http://localhost:11434/v1 # Ollama; the default - dj_model: llama3.2 -``` - -A local model needs no key at all. For a hosted one, `export SPOTATUI_DJ_API_KEY=…`. - -There is no model list to offer here, because only your endpoint knows what it -serves. The picker's second step gives you *Use the built-in default*, which is -`gpt-4o-mini`, and a free-text row: an Ollama user wants the free-text row -(`llama3.2`), not the default. `dj_base_url` is only ever set by hand. - -### The API key - -`SPOTATUI_DJ_API_KEY` wins over `behavior.dj_api_key`, and never lands on disk — -prefer it. The config field exists for convenience, but **it is plaintext YAML**; -the config directory is `0700` on unix and carries a `.gitignore`, which is the -same protection the Spotify token cache relies on, but a plaintext secret is still -a plaintext secret. This mirrors how the Subsonic password is handled. - ---- - -## What is sent to the model - -Track, artist, and album **names** only. Three sources, and nothing else: - -* the taste brief, built from your local `listens.jsonl`: top artists, tracks, and - albums over the configured window, plus the last ~25 distinct plays so the DJ can - be told not to repeat them -* what is playing now, and your current steer if you gave one -* the conversation itself — the last 24 lines of the DJ transcript, which is - whatever you typed and whatever it said back - -**Not sent:** timestamps, play counts tied to a clock, Spotify IDs, URIs, your -account, or anything identifying you. For the brief that is a property of building -it from aggregates rather than raw records, not a filter applied afterwards. The -conversation is the exception you control directly: it is sent verbatim, so treat -the prompt as you would any chat window. - -Tune the window with `behavior.dj_history_period` (`7d`, `30d`, `month`, `year`, -`all`). - -Nothing leaves your machine at all if you use `openai_compat` against a local -model. - ---- - -## How it decides, and where it fails - -The model returns *names*; spotatui resolves each one against Spotify (then -YouTube, if that feature is on) and **drops anything it cannot confidently match**. -Search always returns something, so a fuzzy near-miss would otherwise be queued in -place of a track that does not exist. A remaster or a "/ Arpeggi" suffix still -matches; a different artist with the same title does not. - -Dropped tracks are logged at debug level and never shown as an error — a model -inventing a track is routine. If most of a round fails, the DJ asks once more, -naming the failures. Exactly once: the resolve step is what discovers failures, so -without a cap the retry could loop. - -Duplicates are skipped too, against both the current queue and the recent window. -A DJ that re-queues the track that just finished reads as broken. - -### Batch size - -`behavior.dj_batch_size` (default 6, max 8). Capped for three converging reasons: -on an external Spotify Connect device each queued track costs its own Web API -call, a deep queue cannot respond to a vibe shift, and one model invocation per -batch rather than per track is what keeps latency and quota sane. - ---- - -## Troubleshooting - -| Symptom | Fix | -|---|---| -| "could not run `claude`. Is it installed and on PATH?" | Install the CLI, or point `dj_agent_command` at the right binary. | -| "did not answer within 90s" | Raise `dj_agent_timeout_secs`. If you go much above ~180, also revisit auto-queue: the refill watermark assumes a round trip well under the queue's runway. | -| "could not read a decision out of … output" | The CLI answered with prose and no JSON. Check it is logged in by running the same command by hand. | -| "There's an issue with the selected model (…)" | `dj_agent_model` names something that CLI does not accept. It fails locally in milliseconds without calling anything, so nothing was spent: pick another model with Ctrl+G, or clear the key. | -| `codex`: "Not inside a trusted directory and --skip-git-repo-check was not specified" | `codex exec` refuses to run in the DJ's scratch directory. Known limitation: use another agent (`claude`, `agy`) or an API backend, or trust `~/.config/spotatui/dj-scratch` in your codex config. | -| The DJ answers a question you did not ask | `agy` ignores stdin, so a config carrying `dj_agent_prompt_via: stdin` delivers the prompt nowhere. Remove that key so the preset decides, or set it to `arg`. | -| It never asked which AI to use | Something in your config already counts as a deliberate choice. See [It never asked me](#it-never-asked-me), or just press Ctrl+G. | -| "no API key for the Anthropic DJ backend" | `export SPOTATUI_DJ_API_KEY=…`. | -| "the model declined this request" | A safety refusal. Rephrase, or switch backend. | -| Everything is "not found" | Usually no Spotify session — log in from the UI. The catalogue lookup needs it. | -| "Very little listening history so far" | The brief needs a handful of qualifying plays. Play some music, or just tell the DJ what you want. | - -Tracks shorter than 30 seconds and internet-radio streams never enter the history -by design, so they never influence the DJ. - ---- - -## Config reference - -| Key | Default | Meaning | -|---|---|---| -| `dj_backend` | `agent_cli` | `agent_cli`, `anthropic`, or `openai_compat` | -| `dj_agent_command` | `["claude", "-p"]` | argv for `agent_cli`; a bare name expands to a preset | -| `dj_agent_model` | unset | Model name passed as that CLI's own model flag (`claude --model haiku`). Unset passes no flag at all | -| `dj_agent_prompt_via` | unset (the preset decides) | `stdin` or `arg` | -| `dj_agent_timeout_secs` | `90` | Clamped to 5–600 | -| `dj_model` | unset | Model id for the API backends; the agent CLIs use `dj_agent_model` instead | -| `dj_base_url` | Ollama's local endpoint | For `openai_compat` | -| `dj_api_key` | unset | Prefer `SPOTATUI_DJ_API_KEY` | -| `dj_batch_size` | `6` | Tracks per round, max 8 | -| `dj_history_period` | `30d` | `7d`, `30d`, `month`, `year`, `all` | -| `dj_avoid_library` | `false` | Start in "only tracks I don't already have" mode | -| `dj_configured` | unset | Written by the picker; `true` stops spotatui asking which AI to use | - -An invalid value is reported with a warning in the log and the default is kept — -a bad config never stops spotatui from starting. diff --git a/docs/configuration.md b/docs/configuration.md deleted file mode 100644 index 11bee25d..00000000 --- a/docs/configuration.md +++ /dev/null @@ -1,317 +0,0 @@ -# Configuration - -spotatui reads `config.yml` from the app config directory: - -- `$XDG_CONFIG_HOME/spotatui/config.yml` when `XDG_CONFIG_HOME` is set to an absolute path. -- `${HOME}/.config/spotatui/config.yml` otherwise. - -You can also point spotatui at a specific config file with `--config `. - -All fields are optional; omitted values use the built-in defaults. A complete, commented example lives in [`examples/config.example.yml`](../examples/config.example.yml). - -Simple values (numbers, toggles, icons, positions) can also be changed live in the in-app **Settings** screen (see the hint in the top-right of the UI). Press the search key (`/` by default) there to fuzzy-filter the rows by name or by their key in this file, so you can reach a setting without scrolling for it. Structured config — `format:` templates, `tables:` columns, and `playbar_control_labels` — is file-only. Edit the file while the app is closed: saving from the Settings screen rewrites the `behavior`, `theme`, and `keybindings` sections, but your `format:`, `tables:`, and `plugin_commands:` sections survive in-app saves untouched. - -Machine-managed runtime state lives separately in `$XDG_STATE_HOME/spotatui/state.yml` when `XDG_STATE_HOME` is set to an absolute path, or `${HOME}/.local/state/spotatui/state.yml` when it is unset or not absolute. This includes volume, shuffle, active source, seen announcements, resized pane dimensions, and saved radio stations. The Spotify OAuth token cache and local listening history also live under the app state directory. Native streaming credentials and audio cache live under `$XDG_CACHE_HOME/spotatui/streaming_cache` when `XDG_CACHE_HOME` is set to an absolute path, or `${HOME}/.cache/spotatui/streaming_cache` when it is unset or not absolute. `sync_token` remains user config and is stored in `config.yml`. - -## Safe by default - -A typo in `config.yml` never prevents the app from starting. Structural mistakes (an unknown sort field, a bad template placeholder, an invalid column id, an icon that is too wide) are logged as warnings and the affected value falls back to its built-in default. Warnings go to the log file whose path is printed at startup, a `spotatui_logs/spotatuilog` file inside your system temp directory (`%TEMP%` on Windows, `$TMPDIR` where set, otherwise `/tmp`). The startup line reports the resolved path, so copy it from there rather than guessing. - -Only two kinds of errors are fatal: YAML syntax errors (the file cannot be parsed at all) and a handful of out-of-range numeric values that bypass the warn-and-fallback policy: `volume_increment` outside 0–100, a tick rate (or animation tick rate) outside 1–999ms, an unparseable `auto_update_delay`, `playback_poll_seconds` below 1, and `like_animation_frames` below 1. - -## Behavior - -`behavior` controls interaction, timing, icons, and layout. The most commonly customized fields: - -```yaml -behavior: - # Timing - seek_milliseconds: 5000 # seek step for < / > - tick_rate_milliseconds: 250 # UI event-loop cadence (1..=999, fatal if outside) - animation_tick_rate_milliseconds: 16 - # animation-only cadence, e.g. the like-heart burst - # (1..=999, fatal if outside) - playback_poll_seconds: 5 # how often playback state is polled (min 1, fatal if 0; - # near track end the app polls faster regardless) - status_message_ttl_percent: 100 # scales how long status messages stay visible - # (10..=1000; 200 = twice as long) - like_animation_frames: 10 # length of the heart burst when liking a track (min 1, fatal if 0) - - # Volume - volume_increment: 10 # step for + / - (0..=100, fatal if outside) - volume_percent: 100 # initial volume default; saved runtime volume wins once present - - # Scrolling - table_scroll_padding: 5 # rows kept visible below the selection before - # the table scrolls; clamped to half the table - # height so huge values cannot break scrolling -``` - -## Cover art dithering - -Build with `--features cover-art` to show the cover-art settings. Dithering is off by default and does not turn on cover art where terminal support has disabled it; `draw_cover_art_forced` still controls the basic-terminal fallback. - -```yaml -behavior: - cover_art_dither: true - cover_art_dither_algorithm: stucki # default; also bayer8x8 or atkinson - cover_art_dither_pixel_scale: 1 # 1..=3; higher means larger visual pixels -theme: - cover_art_dither_color: auto # follows live playbar_progress - # cover_art_dither_color: '120, 160, 220' # exact RGB override -``` - -The color override can also be a named theme color such as `LightBlue`. It survives theme-preset changes, and setting it back to `auto` clears it. Dithered art uses that accent with the surface's current background. Every slot, including a small playbar, makes its mask from the original decoded image at its own output resolution. Scale `1` uses every output pixel on graphics terminals or every Braille dot on basic terminals; higher values make larger visual pixels. The slot stays clear while a new dither is prepared, so the first artwork shown is already dithered. The basic-terminal grid has fewer dots than a graphics protocol, so its smallest images retain less detail. To give playbar art more room, increase `behavior.playbar_cover_art_size_percent` (up to `200`); the available playbar height still limits the final size. - -## Startup route - -`startup_route` picks which screen opens at launch. Its data is fetched automatically, so the screen arrives populated. Only context-free screens are valid (nothing that needs an album id, artist id, or search query): - -| Value | Screen | Alias | Needs Spotify | -|---|---|---|---| -| `home` (default) | Home | | no | -| `recently_played` | Recently Played | `recent` | yes | -| `podcasts` | Podcasts | | yes | -| `discover` | Discover | | yes | -| `artists` | Followed Artists | `library` | yes | -| `album_list` | Saved Albums | `albums` | yes | -| `stats` | Stats | | no | - -Unknown values fall back to `home` with a warning. Without a Spotify session -the Settings screen offers only `home` and `stats`, unless `config.yml` already -names a Spotify screen, which then stays selectable. A Spotify screen still -opens without a session, but empty. - -## Default sorting - -Each sortable screen can start pre-sorted. The value is `""` for ascending or `":desc"` for descending: - -```yaml -behavior: - default_sort_playlist_tracks: artist - default_sort_saved_albums: date_added:desc - default_sort_saved_artists: name - default_sort_recently_played: name:desc -``` - -Valid fields per screen: - -| Setting | Valid fields | -|---|---| -| `default_sort_playlist_tracks` | `default`, `name`, `date_added`, `artist`, `album`, `duration` | -| `default_sort_saved_albums` | `default`, `name`, `date_added`, `artist` | -| `default_sort_saved_artists` | `default`, `name` | -| `default_sort_recently_played` | `default`, `name`, `artist`, `album` | - -`default` keeps the order the API returns (playlist order, date saved, play order). A field that is not valid for that screen falls back to `default` with a warning. - -## Internet Radio - -Preconfigured stations can be declared in `config.yml`: - -```yaml -behavior: - radio_stations: - - name: SomaFM Groove Salad - url: https://ice1.somafm.com/groovesalad-128-mp3 -``` - -Stations saved from inside the app are stored in `state.yml`. The sidebar merges configured stations first and app-saved stations second, deduped by stream URL. To remove a configured station, edit `config.yml`; the in-app remove action only removes app-saved stations from `state.yml`. - -## Qobuz - -Qobuz logs in through the browser (first-run picker, or `d` then Qobuz) and keeps the token in `qobuz_credentials.yml` next to `config.yml`. One key selects the stream quality: - -```yaml -behavior: - qobuz_quality: 6 # 5 = MP3 320, 6 = FLAC 16/44.1, 7 = FLAC 24/96, 27 = FLAC 24/192 -``` - -This is the requested quality. Qobuz serves some tracks at a lower one, so when a track plays from a Qobuz listing, the playbar shows the format it delivered, for example `FLAC 16/44.1`. - -Environment variables: `SPOTATUI_QOBUZ_TOKEN` overrides the saved token. `SPOTATUI_QOBUZ_APP_ID`, `SPOTATUI_QOBUZ_APP_SECRET`, and `SPOTATUI_QOBUZ_OAUTH_KEY` (all three together) replace the constants spotatui otherwise reads from the Qobuz web player at runtime. - -## Layout - -```yaml -behavior: - sidebar_position: left # left | right | hidden - playbar_position: bottom # bottom | top - sidebar_width_percent: 20 # initial sidebar width default; saved runtime size wins once present - library_height_percent: 30 # initial library height default; saved runtime size wins once present - playbar_height_rows: 6 # initial playbar height default, 0..=50; 0 hides it; saved runtime size wins once present - small_terminal_width: 150 - small_terminal_height: 45 -``` - -- `sidebar_position: hidden` gives the content the full width, but the sidebar auto-reveals while the Library or Playlists panel has keyboard focus or is hovered, so it never becomes unreachable. -- `sidebar_width_percent`, `library_height_percent`, and `playbar_height_rows` are configured initial defaults. Runtime resize changes use `{` / `}` for sidebar width, `(` / `)` for playbar height, `(` / `)` while hovering Library or Playlists for the library/sidebar split, and `|` to reset sizes; those changes persist in `state.yml` and are not overwritten by configured defaults after state exists. Configured `playbar_height_rows` is capped at 50 rows when applied at startup or when resetting the layout. -- `small_terminal_width` / `small_terminal_height` are the responsive-layout breakpoints. At or above `small_terminal_width` columns the app uses the wide layout (search box inside the sidebar); below it the search box gets its own full-width top row. `enforce_wide_search_bar: true` forces the full-width search row regardless of width. -- Unknown position strings fall back to the default with a warning. -- Mouse hit-testing follows every arrangement automatically. - -## Icons - -All icons are under `behavior` and can also be edited in the Settings screen. - -```yaml -behavior: - # Free-form (any width) - liked_icon: "♥" - shuffle_icon: "🔀" - repeat_track_icon: "🔂" - repeat_context_icon: "🔁" - paused_icon: "⏸" - active_source_icon: "●" # marks the active source in the device picker - list_highlight_icon: "▶" # cursor prefix in sidebar/menu lists - - # Fixed-cell: must be exactly ONE terminal column wide - playing_icon: "▶" # prefixes the playing row in track tables - gauge_filled_icon: "⣿" # progress/volume gauge fill - gauge_unfilled_icon: "⣉" # progress/volume gauge background - episode_played_icon: "✔" # played marker in the episodes table - sort_ascending_icon: "↑" # sort direction indicator in table headers - sort_descending_icon: "↓" -``` - -The fixed-cell icons sit in columns whose alignment math assumes one cell; a wider glyph is rejected at load and the default is used, with a warning. - -**Ambiguous-width caveat:** some glyphs (e.g. `↑ ↓ ● ▶`) have Unicode "East Asian Ambiguous" width. Terminals configured to render ambiguous-width characters as wide (common with CJK locales) draw them 2 cells wide, shifting column alignment even though the config validates. If that happens, set your terminal's ambiguous-width option to "narrow" or pick unambiguous glyphs. - -## Playbar control labels - -The clickable playbar buttons can be relabeled (config-only). Mouse hitboxes resize to fit the labels automatically. - -```yaml -behavior: - playbar_control_labels: - prev: "⏮" - play_pause: "[PLAY/PAUSE]" - next: "⏭" - shuffle: "shfl" - repeat: "rpt" - like: "♥+" - vol_down: "vol-" - vol_up: "vol+" -``` - -All eight keys are optional; omit a key (or set it to an empty string) to keep that button's built-in label. Unknown keys are skipped with a warning. - -## Format templates - -`format` controls the playbar status line and the terminal window title. Templates use `{key}` placeholders; write a literal brace as `{{` or `}}`. Newlines and tabs are stripped from rendered values. An unknown key or unbalanced brace falls back to the default template with a warning listing the valid keys. - -```yaml -format: - playbar_status: "{state} ({device} | Shuffle: {shuffle} | Repeat: {repeat} | Volume: {volume}%){party}" - playbar_status_source: "{state} ({source}{queue}{shuffle}{repeat} | Volume: {volume}%)" - window_title: "{title}{artist}" -``` - -The defaults above reproduce the built-in output exactly. - -### `playbar_status` (Spotify playback) and `playbar_status_source` (local/Subsonic/Qobuz/Radio/YouTube playback) - -Both templates accept the same keys; keys that don't apply to the current mode render empty. - -`{shuffle}` and `{repeat}` render differently in each template, so the two are not interchangeable: - -- In `playbar_status`, they are bare values you label yourself (hence the `Shuffle: {shuffle}` in the default). -- In `playbar_status_source`, they come pre-composed with their own ` | Shuffle: ` / ` | Repeat: ` prefix, like `{queue}` and `{party}`. This lets the whole segment — label included — disappear for sources that have no such control (internet radio, and native queue slots), rather than leaving a blank `Shuffle:` behind. Don't write your own label around them there. - -| Key | Renders as | Notes | -|---|---|---| -| `{state}` | `Playing` / `Paused` | padded to 7 characters, matching the default layout | -| `{device}` | Spotify device name | Spotify playback only | -| `{source}` | active source label (e.g. `Local`, `Subsonic`) | source playback only | -| `{queue}` | ` \| 3/12` queue position, or empty | source playback only | -| `{shuffle}` | `On` / `Off` | padded to 3 characters. In `playbar_status_source`: pre-composed as ` \| Shuffle: On `, empty for radio/queue slots | -| `{repeat}` | `Off` / `Track` / `All` | padded to 5 characters. In `playbar_status_source`: pre-composed as ` \| Repeat: All `, empty for radio/queue slots | -| `{volume}` | volume percentage number | append your own `%` | -| `{party}` | ` \| Party: 3 listeners` / ` \| Party: following `, or empty | pre-composed with its own separator | - -### `window_title` - -Applied only when `set_window_title: true`. Valid keys: `{title}` and `{artist}`. `{artist}` comes pre-composed as ` — ` and renders empty when the artist is unknown, so the default `"{title}{artist}"` produces `Song — Artist` or just `Song`. - -## Tables - -`tables` reorders, removes, renames, and resizes the columns of every track/album/podcast table. Omit a table (or the whole section) to keep its built-in columns. - -```yaml -tables: - songs: - - { id: liked } - - { id: title, width_percent: 40 } - - { id: artist, header: "Band", width_percent: 30 } - - { id: album } - - { id: length } -``` - -Each column entry supports: - -| Field | Meaning | -|---|---| -| `id` | required — which column (see valid ids below) | -| `header` | optional display-name override | -| `width_percent` | optional width as a percentage of the table (0–100, exclusive of 0) | -| `width` | optional absolute width in cells (non-zero) | - -Rules, all enforced at load (violations degrade that table to its defaults with a warning): - -- ids must be valid for the table, with no duplicates and at least one column -- a column may set `width_percent` **or** `width`, not both; neither means the built-in default width -- no zero widths, and a table's `width_percent` values may not sum past 100 (trailing columns would be clipped) - -Valid ids and default order per table: - -| Table | Screen | Default columns | All valid ids | -|---|---|---|---| -| `songs` | playlists, liked songs, search results | `liked, title, artist, album, length` | + `index` | -| `album_tracks` | an album's track list | `liked, index, title, artist, length` | + `album` | -| `recently_played` | Recently Played | `liked, title, artist, length` | + `index`, `album` | -| `albums` | Saved Albums | `title, artist, date` | + `liked` | -| `podcasts` | Podcasts | `title, publisher` | — | -| `episodes` | a podcast's episodes | `played, date, title, duration` | — | - -The ▶ now-playing marker attaches to the `title` column, or to the first column if you remove `title`. Sort keyboard shortcuts are unaffected by column layout. - -## Keybindings, theme, and plugins - -The `keybindings:` section rebinds ~40 named actions (`back: q`, `next_track: n`, modifier syntax like `ctrl-s` / `alt-,`), and `theme:` sets a preset plus 16 individual color slots (`"R, G, B"` or named colors) — both are easiest to edit from the in-app Settings screen, which writes them back to this file. `behavior.banner_gradient: false` draws the home-screen banner in the theme's banner color instead of the animated RGB gradient. The Terminal (ANSI) preset defaults this to `false` so the banner follows the terminal palette live (pywal etc.); every other preset defaults to `true`. An explicit `banner_gradient:` value in the config wins over the preset default. `plugin_commands:` maps extra keys to Lua plugin commands. See [`examples/config.example.yml`](../examples/config.example.yml) and the [scripting docs](scripting.md). - -## AI DJ and the MCP server - -Both are opt-in cargo features, so these keys only exist in a build that includes -them. - -| Key | Feature | Default | Meaning | -|---|---|---|---| -| `mcp_enabled` | `mcp-server` | `false` | Open the local MCP control socket so a coding agent can drive playback | -| `dj_backend` | `ai-dj` | `agent_cli` | `agent_cli`, `anthropic`, or `openai_compat` | -| `dj_agent_command` | `ai-dj` | `["claude", "-p"]` | argv for `agent_cli`; a bare binary name expands to a known preset (`claude`, `codex`, `agy`, or the legacy `gemini`) | -| `dj_agent_model` | `ai-dj` | unset | Model passed as that CLI's own flag (`claude --model haiku`, `agy --model gemini-3.6-flash-low`); unset passes no flag | -| `dj_agent_prompt_via` | `ai-dj` | unset (the preset decides) | `stdin` or `arg`. `agy` ignores stdin, so leaving this unset is what keeps it working | -| `dj_agent_timeout_secs` | `ai-dj` | `90` | Clamped to 5–600 | -| `dj_model` | `ai-dj` | unset | Model id for the API backends; the agent CLIs use `dj_agent_model` instead | -| `dj_base_url` | `ai-dj` | Ollama's local endpoint | Base URL for `openai_compat` | -| `dj_api_key` | `ai-dj` | unset | Plaintext; prefer `SPOTATUI_DJ_API_KEY` | -| `dj_batch_size` | `ai-dj` | `6` | Tracks queued per round, max 8 | -| `dj_history_period` | `ai-dj` | `30d` | History window summarised for the model | -| `dj_avoid_library` | `ai-dj` | `false` | Start in "only tracks I don't already have" mode | -| `dj_configured` | `ai-dj` | unset | Written by the in-TUI AI/model picker (`Ctrl-g`). `true` stops the DJ asking which AI to use on your first visit | - -`mcp_enabled` is off by default because opening a socket that can control the -player and read your listening history is a deliberate act. It binds loopback only -and requires the token in `~/.config/spotatui/mcp.json` (mode `0600`). - -`SPOTATUI_DJ_API_KEY` overrides `dj_api_key` at request time and never touches -disk — the same arrangement as `SPOTATUI_SUBSONIC_PASSWORD`. - -You do not have to write the `dj_backend` / `dj_agent_command` / model keys by hand: -the DJ asks which AI and which model on your first visit and writes the answer back -here, and `Ctrl-g` reopens that picker. It never touches `dj_api_key` or -`dj_base_url`, so those two remain yours. - -Setup, tools, and troubleshooting: [`docs/mcp-setup.md`](mcp-setup.md) and -[`docs/ai-dj.md`](ai-dj.md). diff --git a/docs/installation.md b/docs/installation.md deleted file mode 100644 index aa0d0a02..00000000 --- a/docs/installation.md +++ /dev/null @@ -1,162 +0,0 @@ -# Installation - -## Quick Install - -### Cargo (Recommended) - -If you have Rust installed: - -```bash -cargo install --locked spotatui -``` - -### Windows (winget) - -```bash -winget install spotatui -``` - -### macOS (Homebrew) - -```bash -brew tap LargeModGames/spotatui -brew install spotatui -``` - -### Pre-built Binaries - -Download from [GitHub Releases](https://github.com/LargeModGames/spotatui/releases/latest): - -| Platform | File | -| --- | --- | -| Windows 10/11 (64-bit) | `spotatui-windows-x86_64.zip` | -| Linux (Ubuntu, Arch, Fedora, etc.) | `spotatui-linux-x86_64.tar.gz` | -| macOS (Intel) | `spotatui-macos-x86_64.tar.gz` | -| macOS (Apple Silicon M1/M2/M3) | `spotatui-macos-aarch64.tar.gz` | - -Checksums (`.sha256`) are provided if you want to verify the download. - -### Arch Linux (AUR) - -```bash -# Pre-built binary (faster) -yay -S spotatui-bin -# or -paru -S spotatui-bin - -# Build from source -yay -S spotatui -# or -paru -S spotatui -``` - ---- - -## Platform-Specific Requirements - -### Linux - -The pre-built Linux binary (and the `spotatui-bin` AUR package) is compiled with the PipeWire audio-visualization backend, so you need PipeWire installed for the visualizer: - -```bash -# Debian/Ubuntu -sudo apt-get install libpipewire-0.3-0 - -# Arch Linux (already included with pipewire) -sudo pacman -S pipewire - -# Fedora (already included with pipewire) -sudo dnf install pipewire -``` - -> **Note:** Most modern Linux distributions already have PipeWire installed by default. If you build from source instead (`cargo install`), the default build uses the `cpal`-based visualizer backend and does not require PipeWire. - -### macOS - -spotatui uses the `portaudio` backend for better stability and bluetooth device support (AirPods, etc.): - -```bash -brew install portaudio -``` - ---- - -## Building from Source - -Ensure you have [Rust](https://www.rust-lang.org/tools/install) installed. - -### Prerequisites - -**macOS:** -```bash -brew install portaudio -``` - -**Linux (Debian/Ubuntu):** -```bash -sudo apt-get install libssl-dev libasound2-dev libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev pkg-config -``` - -**Linux (Arch):** -```bash -sudo pacman -S openssl alsa-lib pkg-config -``` - -**Linux (Fedora):** -```bash -sudo dnf install openssl-devel alsa-lib-devel pkg-config -``` - -### Build - -```bash -git clone https://github.com/LargeModGames/spotatui.git -cd spotatui -``` - -**macOS:** -```bash -cargo install --path . --no-default-features --features telemetry,streaming,discord-rpc,cover-art,self-update,scripting,portaudio-backend,audio-viz-cpal,macos-media,local-files,subsonic,internet-radio,youtube,qobuz -``` - -**Linux/Windows:** -```bash -cargo install --path . -``` - -**Nix:** -```bash -nix-build -``` - ---- - -## Updating - -When a new version is available, you'll see a popup notification on startup. To update: - -1. Close spotatui -2. Run: - ```bash - spotatui update --install - ``` - -If you installed via a package manager (AUR, cargo, etc.), update through there instead. - ---- - -## Connecting to Spotify - -spotatui talks to Spotify's Web API through a Spotify app. The first run asks which app to use: - -1. **The shared ncspot client ID.** No dashboard needed. Spotify counts every ncspot and spotatui user against this one app, so it is often rate limited: playlists load slowly or not at all, and the status bar says so. -2. **Your own Spotify app** (recommended). Spotify gives each app a rate limit of its own. - -To use your own app: - -1. Go to the [Spotify Dashboard](https://developer.spotify.com/dashboard/applications) and click **Create app**. -2. Add `http://127.0.0.1:8888/callback` to the Redirect URIs. The wizard asks for the port; 8888 is the default. -3. Copy the `Client ID`. No client secret is needed. -4. Run `spotatui --reconfigure-auth`, choose option 2, and paste the id. The browser opens once to sign in. - -The wizard keeps the shared id as `fallback_client_id` in `client.yml`, for a login that fails on your own app. A `client.yml` from an older version, with the shared id as `client_id` and your app as `fallback_client_id`, is read the other way round: your app leads. Your app still has no login of its own, so the first launch after the upgrade runs on the shared id and the status bar says so; run `spotatui --reconfigure-auth` once, choose 2, and paste the id. Native streaming signs in on its own and needs no dashboard entry. diff --git a/docs/keybindings.md b/docs/keybindings.md deleted file mode 100644 index 76c4be08..00000000 --- a/docs/keybindings.md +++ /dev/null @@ -1,117 +0,0 @@ -# Keybindings - -Press `?` in spotatui to see the help menu with all keybindings. Inside the -help menu, press the search key (`/` by default) to filter rows by key, -description, or context; matching text is highlighted in the visible rows. -Press `Enter` to apply the filter and `Esc` to clear it. - -The menu lists what the active source and your Spotify session can do. A -rebindable key that needs more stays listed with a suffix such as -`(needs Spotify)` or `(not for Local Files)`; a fixed key of a screen the -source cannot reach is left out until you switch source or log in. - -The same search key filters the Settings screen, so you can jump to a setting -instead of scrolling for it. The query is fuzzy: its characters only have to -appear in order, so `volinc` finds **Volume Increment** and `skms` finds -**Seek Duration (ms)**. Rows are matched on their name and on their -`config.yml` key, and ranked best-first with the matched characters -highlighted. The filter applies to the tab you are on and survives `←`/`→`, so -one query can be walked across tabs. - -## Default Keybindings - -| Key | Action | -| ----------- | ------------------------- | -| `Space` | Toggle play/pause | -| `n` | Next track | -| `p` | Previous track | -| `+` / `-` | Volume up/down | -| `<` / `>` | Seek backward/forward | -| `/` | Search | -| `h`/`j`/`k`/`l` | Navigate (vim-style: left/down/up/right) | -| `Enter` | Select / confirm | -| `a` | Jump to album | -| `A` | Jump to artist's albums | -| `o` | Jump to context | -| `d` | Switch music source | -| `c` | Copy song URL | -| `C` | Copy album URL | -| `Ctrl-r` | Toggle repeat mode | -| `Ctrl-s` | Toggle shuffle | -| `v` | Audio visualization | -| `z` | Add to queue | -| `Q` | Show queue | -| `F` | Like / save track | -| `B` | Lyrics view | -| `T` | Toggle miniplayer view | -| `R` | Generate recap | -| `Ctrl-p` | Listening party | -| `,` | Open sort menu | -| `Alt-,` | Open settings (`Ctrl-,` on macOS) | -| `?` | Show help | -| `q` | Go back / Quit | -| `Ctrl-j` | Open the AI DJ (`ai-dj` builds) | -| `Ctrl-t` | Toggle DJ auto-queue | -| `Ctrl-y` | DJ vibe shift | -| `Ctrl-o` | DJ fresh tracks only | -| `Ctrl-g` | Choose the DJ's AI/model | - -## Customizing Keybindings - -Edit `config.yml` in the spotatui app config directory (`$XDG_CONFIG_HOME/spotatui` -when `XDG_CONFIG_HOME` is set to an absolute path, or `~/.config/spotatui` when -it is unset or not absolute): - -```yaml -keybindings: - back: "q" - jump_to_album: "a" - toggle_playback: " " - # ... etc -``` - -The `keybindings:` section rebinds around 40 named actions in total; see -[`examples/config.example.yml`](../examples/config.example.yml) and -[`docs/configuration.md`](configuration.md) for the full picture of how the -config file is structured. - -### Key Format - -- Single keys: `"a"`, `"/"`, `" "` (space) -- With Ctrl: `"ctrl-q"`, `"ctrl-s"` -- With Alt: `"alt-,"`, `"alt-s"` -- With Shift: Use capital letter `"A"`, `"C"` -- Special keys: `"enter"`, `"esc"`, `"tab"` - -> **Note:** Three-key combinations like `ctrl-alt-q` are not supported. - -## AI DJ keys - -Only present in builds with the `ai-dj` feature. All five are rebindable as -`dj_open`, `dj_toggle_auto_queue`, `dj_vibe_shift`, `dj_toggle_fresh_only`, and -`dj_pick_model`. - -| Action | Default | Config key | -|---|---|---| -| Open the AI DJ screen | `Ctrl-j` | `dj_open` | -| Toggle continuous auto-queue | `Ctrl-t` | `dj_toggle_auto_queue` | -| Vibe shift (drop the DJ's queued tail, change direction) | `Ctrl-y` | `dj_vibe_shift` | -| Toggle "only tracks I don't already have" | `Ctrl-o` | `dj_toggle_fresh_only` | -| Choose which AI and model the DJ uses | `Ctrl-g` | `dj_pick_model` | - -On the DJ screen itself the prompt takes every printable key, so `j`/`k` type -rather than navigate; scroll the transcript with the arrow and page keys. `Esc` -clears a half-typed prompt, and leaves the screen when the prompt is already empty. - -The four action keys other than `dj_open` still work while the prompt has focus, -because they carry a modifier. If you rebind one to a bare character, that character -types instead, since a typing surface has to be able to contain it. - -While the AI/model picker is open it is modal and takes every key: `↑`/`↓` (or -`j`/`k`) move, `1`-`9` pick a numbered row, `Enter` chooses, and `Esc` steps back -one step at a time, closing the picker from the first step and keeping whatever -brain you already had. Nothing else reaches the DJ or the rest of the app, so a -keypress cannot start background work with the backend you are mid-way through -replacing. - -See [`docs/ai-dj.md`](ai-dj.md). diff --git a/docs/mcp-setup.md b/docs/mcp-setup.md deleted file mode 100644 index 797883d6..00000000 --- a/docs/mcp-setup.md +++ /dev/null @@ -1,262 +0,0 @@ -# Set up the spotatui MCP server - -> **Give this to your AI agent.** Copy the line below into Claude Code, Codex, -> Gemini CLI, Cursor, or any agent that can read files and run commands: -> -> ```text -> Read docs/mcp-setup.md in the spotatui repo (or -> https://raw.githubusercontent.com/LargeModGames/spotatui/main/docs/mcp-setup.md) -> and set up the spotatui MCP server for me, following it exactly. -> ``` -> -> Everything the agent needs is in this file. It is written as instructions **for -> the agent**, so it is fine to hand over verbatim. - ---- - -## What this sets up - -spotatui exposes your music player and listening history as MCP tools, so an AI -agent can act as your DJ: read what you have been listening to, search the -catalogue, and queue tracks. **No API key is required** — the agent you already -have installed and authenticated does the thinking. - -```text -Your agent ──stdio(MCP)──> `spotatui mcp` ──loopback TCP──> running spotatui -``` - ---- - -## Instructions for the agent - -Follow these steps in order. After each step, run the verification command and do -not continue until it passes. - -### Step 0 — Read this before running anything - -**Never run `spotatui mcp` to test the setup.** That command *is* the server: it -reads JSON-RPC from stdin and blocks until the stream closes, so it will appear -to hang and you will learn nothing from it. - -Use `spotatui mcp status` instead. It is safe, returns immediately, and exits -non-zero when the setup is not ready: - -```bash -spotatui mcp status # human-readable report -spotatui mcp status --json # same thing, machine-readable -``` - -Run it now to see the starting state. Two outcomes are both normal before setup, -and they mean different things: - -* it fails at `control-file` — the binary is right, the socket is not on yet. - Go to Step 2. -* it fails with `unrecognized subcommand 'mcp'` — this build does not have the - feature at all. Go to Step 1. - -### Step 1 — Confirm the binary has the feature compiled in - -```bash -spotatui mcp status -``` - -* If the first line reads `ok binary spotatui with the mcp-server feature`, - continue to Step 2. -* If the command fails with something like `unrecognized subcommand 'mcp'`, this - build does not include the feature. Install one that does: - - ```bash - cargo install --locked spotatui --features mcp-server - # or, from a checkout: - cargo install --locked --path . --features mcp-server - ``` - - The feature is not in `default`, so a plain `cargo build` omits it. Do not - assume any particular binary has it — the `spotatui mcp status` check above is - the way to tell. - -### Step 2 — Turn on the control socket - -The socket is **opt-in**. Edit spotatui's `config.yml` and set, under the -`behavior:` key: - -```yaml -behavior: - mcp_enabled: true -``` - -Create the `behavior:` section if the file does not have one. Do not remove or -reorder anything else in the file. - -> **Which `config.yml`.** spotatui resolves its config directory as -> `$XDG_CONFIG_HOME/spotatui` when `XDG_CONFIG_HOME` is set to an *absolute* -> path, and `~/.config/spotatui` otherwise — so do not assume the second one. -> `spotatui mcp status` prints the resolved directory; use the path it reports. -> On Windows the fallback is `%USERPROFILE%\.config\spotatui`. - -### Step 3 — Restart spotatui - -The socket is opened at startup, so a running instance will not pick this up. - -Ask the user to quit spotatui and start it again. **Do not try to start spotatui -yourself** — it is a full-screen terminal application and needs a real terminal; -launching it from a tool call will either fail or hang. - -When it starts, spotatui briefly shows `MCP server listening on 127.0.0.1:`. - -### Step 4 — Verify the connection - -```bash -spotatui mcp status -``` - -All three checks must read `ok`, and the exit code must be `0`: - -```text -ok binary spotatui 0.40.3 with the mcp-server feature -ok control-file found (port 43219, pid 12345) at /home/you/.config/spotatui/mcp.json -ok connection handshake accepted; server speaks protocol 2026-07-28 -``` - -The `control-file` line reports the path spotatui actually resolved, which is -where its `config.yml` lives too. - -If `connection` fails but `control-file` passed, the control file is stale from a -previous run — have the user restart spotatui again. - -### Step 5 — Register the server with your client - -> **Plugin-capable clients can replace this step.** The repo ships an *agent -> plugin* (`agent-plugin/`) that registers this server and adds a DJ skill in one -> install: in Claude Code, `/plugin marketplace add LargeModGames/spotatui` then -> `/plugin install spotatui@spotatui`; clients that read the -> [Agent Plugins](https://agent-plugins.org) format load the same directory. See -> [`agent-plugin/README.md`](../agent-plugin/README.md). Steps 1-4 above are still -> required either way — the plugin registers the server, it cannot build the -> binary, set `mcp_enabled`, or start spotatui. -> -> **If you installed the plugin, skip the rest of this step and go to Step 6.** -> Running a registration command as well would register the server twice, and its -> tools would appear under both names. - -Otherwise, use whichever line matches the client you are running in: - -```bash -# Claude Code -claude mcp add spotatui -- spotatui mcp - -# Codex (usage is `codex mcp add -- ...`) -codex mcp add spotatui -- spotatui mcp -``` - -For any other client, add a **stdio** server with command `spotatui` and args -`["mcp"]`. As raw JSON (Claude Desktop, Cursor, Windsurf, Zed and others use this -shape): - -```json -{ - "mcpServers": { - "spotatui": { - "command": "spotatui", - "args": ["mcp"] - } - } -} -``` - -TOML, for `~/.codex/config.toml`: - -```toml -[mcp_servers.spotatui] -command = "spotatui" -args = ["mcp"] -``` - -If `spotatui` is not on `PATH` for the client's environment, use the absolute -path: `command -v spotatui` on Linux and macOS, `Get-Command spotatui` in -PowerShell. - -### Step 6 — Confirm the tools are visible - -Reload or restart your client so it picks up the new server, then list your tools. -You should see eight, all prefixed with the server name (`mcp__spotatui__…` in -Claude Code, or `mcp__plugin_spotatui_spotatui__…` if you installed the plugin — -a plugin's tools carry its name too): - -| Tool | What it does | -|---|---| -| `get_listening_history` | Aggregate taste summary: top artists, tracks, albums, recent plays | -| `get_now_playing` | Current track, play state, queue depth | -| `get_queue` | Upcoming tracks | -| `search_tracks` | Find tracks and their URIs, each marked `owned` or `new` | -| `queue_tracks` | Add tracks by URI, or by title + artist; `exclude_owned` skips ones they already have | -| `play_now` | Start a track immediately | -| `skip_track` | Skip to the next track | -| `set_dj_vibe` | Record a standing direction for the built-in auto-queue DJ (does not start it) | - -Then confirm it end-to-end by calling `get_now_playing`. A successful call proves -the whole chain. Report the result to the user and stop — setup is done. - ---- - -## Troubleshooting - -| Symptom | Cause and fix | -|---|---| -| `unrecognized subcommand 'mcp'` | Binary built without the feature — Step 1. | -| `spotatui mcp status` fails at `control-file` | `mcp_enabled` is not set, or spotatui has not been restarted since it was — Steps 2 and 3. | -| Fails at `connection`, `control-file` passed | Stale control file from a killed process. Restart spotatui. | -| `spotatui mcp` seems to hang | Working as intended: that command is the server. Use `spotatui mcp status`. | -| Client shows the server as failed | The client spawns `spotatui mcp`, which needs `spotatui` on its `PATH`. Re-register with the absolute path from `command -v spotatui`. | -| Tools appear but every call returns "spotatui is not available" | The relay started but no player is running. Start spotatui. | -| A tool returns "spotatui has no Spotify session" | The catalogue tools need Spotify. Log in from the spotatui UI. | -| Tracks are "not found" when queueing by name | Expected for a track that is not in the catalogue. `queue_tracks` reports which ones it dropped — read the result and pick alternatives. | -| The first `search_tracks` says the playlist index is still building | Expected once per spotatui run. `owned` reflects Liked Songs only for that one search; the crawl runs in the background so every search after it is complete. Search again if it matters. | -| `exclude_owned` returns "could not read your playlists" | The playlist crawl failed, so the filter could not run and nothing was queued rather than being queued unfiltered. Retry, or call again without `exclude_owned`. | - -To undo everything: remove the server from your client -(`claude mcp remove spotatui`), set `mcp_enabled: false`, and restart spotatui. - ---- - -## Notes for the agent, once it is working - -* **Call `get_listening_history` first** when choosing music. It returns aggregate - names only — no identifiers, no timestamps. -* **`queue_tracks` can partially fail.** It reports which tracks it could not - find. Read the result rather than assuming everything was queued. -* **Prefer `queue_tracks` over `play_now`** unless the user asked to hear - something immediately; `play_now` interrupts what is playing. -* **`search_tracks` tells you what they already have.** Every result is marked - `[owned]` or `[new]` — `owned` meaning it is in their Liked Songs or in a - playlist they own or collaborate on. When they asked for something *new*, pick - the ones marked new. When they asked for a specific track, queue it whether they - own it or not; that is what they asked for. -* **`queue_tracks` takes `exclude_owned: true`** if you would rather have the - guarantee than do the filtering yourself. It skips anything they already have - and reports what it skipped, and it substitutes nothing — so you may get back - fewer tracks than you asked for. Leave it off (the default) when the user named - specific tracks. -* **`set_dj_vibe` does not start anything.** It records a standing direction for - the *optional* in-TUI auto-queue DJ, which may be switched off or not built into - this binary at all. The result tells you which; when nothing will act on it, the - vibe is still stored and comes back from `get_listening_history`, so honour it - yourself when you queue. There is no tool to turn continuous auto-queue on — - that is the user's key in the TUI. To keep music flowing, poll `get_now_playing` - for `queue_depth` and top up with `queue_tracks`. -* Every change you make raises a visible status message in the user's TUI, so - they can see what you did. - -## What the user should know - -* The socket listens on **loopback only** and requires a token from `mcp.json` - in the same config directory (mode `0600`, in a `0700` directory). A local - process that can read that file can control your player and read your listening - history — the same trust level as the existing Spotify token cache. -* **Data sent to your agent's model provider:** whatever the agent chooses to - send. `get_listening_history` returns track, artist, and album *names* only. -* Turning it off is one line: `mcp_enabled: false`, then restart. - -See also [`docs/ai-dj.md`](ai-dj.md) for the DJ that runs **inside** spotatui -without an agent, and [`docs/configuration.md`](configuration.md) for every -config key. diff --git a/docs/native-streaming.md b/docs/native-streaming.md deleted file mode 100644 index f3ff4857..00000000 --- a/docs/native-streaming.md +++ /dev/null @@ -1,95 +0,0 @@ -# Native Streaming - -spotatui includes **native Spotify Connect** support, allowing it to play audio directly on your computer without needing an external player like spotifyd. - -## Setup - -The native streaming feature uses a separate authentication flow. On first run: - -1. Your browser will open to Spotify's authorization page -2. **Important:** The redirect URI will be `http://127.0.0.1:8989/login` - this is different from the main app's callback URL -3. After authorizing, "spotatui" will appear in your Spotify Connect device list -4. Credentials are cached so you only need to do this once - -## How It Works - -- When streaming is enabled, "spotatui" registers as a Spotify Connect device -- You can control playback from the TUI, your phone, or any other Spotify client -- Audio plays directly on the computer running spotatui - -## Audio Quality - -Native streaming uses 320 kbps by default. To select a different quality, set -`streaming_bitrate` in `client.yml` and restart spotatui: - -```yaml -streaming_bitrate: 320 # 96, 160, or 320 kbps -``` - -`client.yml` is in the spotatui app config directory (for example, -`$XDG_CONFIG_HOME/spotatui/client.yml` when `XDG_CONFIG_HOME` is set to an -absolute path, or `~/.config/spotatui/client.yml` when it is unset or not -absolute). This setting controls the librespot native player directly; the -Spotify app may still describe a Connect device's quality as "Automatic". - -Native-streaming credentials and audio cache are stored in the app cache -directory, for example `$XDG_CACHE_HOME/spotatui/streaming_cache` when -`XDG_CACHE_HOME` is set to an absolute path, or -`~/.cache/spotatui/streaming_cache` when it is unset or not absolute. - -## Notes - -- Native streaming is **enabled by default** when built with the `streaming` feature -- Premium account is required for playback -- The streaming authentication uses a different client than the main app's API controls - ---- - -## MPRIS D-Bus Integration (Linux) - -When using native streaming on Linux, spotatui automatically registers with the [MPRIS D-Bus interface](https://specifications.freedesktop.org/mpris-spec/latest/), enabling: - -- **Media key support** - Play/pause, next, previous via keyboard media keys -- **Desktop integration** - Track info appears in GNOME/KDE media widgets -- **playerctl compatibility** - Control spotatui from the command line: - -```bash -# Check available players -playerctl -l -# Should show: spotatui - -# Control playback -playerctl -p spotatui play-pause -playerctl -p spotatui next -playerctl -p spotatui previous - -# View current track metadata -playerctl -p spotatui metadata -``` - -MPRIS is enabled by default on Linux builds with native streaming. - ---- - -## macOS Now Playing Integration - -When using native streaming on macOS, spotatui registers with the system's [Now Playing](https://developer.apple.com/documentation/mediaplayer/mpnowplayinginfocenter) interface, enabling: - -- **Media key support** - Play/pause, next, previous via keyboard media keys -- **Control Center integration** - Control playback from macOS Control Center -- **Touch Bar support** - Media controls on MacBook Pro Touch Bar -- **AirPods / Headphone controls** - Play/pause and skip via Bluetooth headphone buttons - -This feature uses Apple's `MPRemoteCommandCenter` API and is enabled by default on macOS builds with native streaming. - ---- - -## Windows System Media Transport Controls (SMTC) - -When using native streaming on Windows, spotatui registers with the [System Media Transport Controls](https://learn.microsoft.com/en-us/uwp/api/windows.media.systemmediatransportcontrols) (SMTC), enabling: - -- **Media key support** - Play/pause, next, previous via keyboard media keys -- **Media overlay integration** - Track title, artist, album, and cover art appear in the Windows volume flyout / media overlay -- **Transport controls** - Play, pause, next, previous, stop, and seek requests from the OS are routed back into spotatui - -This feature uses the `smtc-tokio` crate and is enabled by default on Windows builds with native streaming. diff --git a/docs/roadmap.md b/docs/roadmap.md deleted file mode 100644 index 500cf937..00000000 --- a/docs/roadmap.md +++ /dev/null @@ -1,78 +0,0 @@ -# Roadmap - -The goal is to eventually implement almost every Spotify feature. - -## High Priority - -Both items previously listed here (adding songs to a playlist, and scrolling -through result pages) are now implemented. - -## Spotify API Coverage - -This table shows what is possible with the Spotify API, what is implemented, and whether it's essential. - -| API Method | Implemented | Description | Essential | -| --- | :---: | --- | :---: | -| **Tracks** | -| track | ✅ | Get a single track by ID | No | -| tracks | ✅ | Get multiple tracks by IDs | No | -| **Artists** | -| artist | ❌ | Get a single artist by ID | Yes | -| artists | ❌ | Get multiple artists by IDs | No | -| artist_albums | ✅ | Get an artist's albums | Yes | -| artist_top_tracks | ✅ | Get an artist's top 10 tracks | Yes | -| artist_related_artists | ⚠️ | Get similar artists — restricted alongside `recommendations` (see below) | Yes | -| **Albums** | -| album | ✅ | Get a single album by ID | Yes | -| albums | ❌ | Get multiple albums by IDs | No | -| album_track | ✅ | Get an album's tracks | Yes | -| **Search** | -| search_album | ✅ | Search albums | Yes | -| search_artist | ✅ | Search artists | Yes | -| search_track | ✅ | Search tracks | Yes | -| search_playlist | ✅ | Search playlists | Yes | -| **Playlists** | -| playlist | ✅ | Get playlist details | Yes | -| current_user_playlists | ✅ | Get user's playlists | Yes | -| user_playlists | ❌ | Get another user's playlists | No | -| user_playlist_tracks | ✅ | Get playlist tracks | Yes | -| user_playlist_create | ✅ | Create a playlist | Yes | -| user_playlist_change_detail | ❌ | Change playlist name/visibility | Yes | -| user_playlist_unfollow | ✅ | Unfollow (delete) playlist | Yes | -| user_playlist_add_track | ✅ | Add tracks to playlist | Yes | -| user_playlist_follow_playlist | ✅ | Follow a playlist | Yes | -| **Library** | -| current_user_saved_albums | ✅ | Get saved albums | Yes | -| current_user_saved_tracks | ✅ | Get liked songs | Yes | -| current_user_followed_artists | ✅ | Get followed artists | Yes | -| current_user_saved_tracks_add | ✅ | Like a track | Yes | -| current_user_saved_tracks_delete | ✅ | Unlike a track | Yes | -| current_user_saved_albums_add | ✅ | Save an album | Yes | -| current_user_saved_albums_delete | ✅ | Remove saved album | Yes | -| user_follow_artists | ✅ | Follow artists | Yes | -| user_unfollow_artists | ✅ | Unfollow artists | Yes | -| current_user_recently_played | ✅ | Get recently played | Yes | -| current_user_top_artists | ✅ | Get top artists | Yes | -| current_user_top_tracks | ✅ | Get top tracks | Yes | -| **Playback** | -| device | ✅ | Get available devices | Yes | -| current_playback | ✅ | Get current playback state | Yes | -| transfer_playback | ✅ | Transfer to another device | Yes | -| start_playback | ✅ | Start/resume playback | Yes | -| pause_playback | ✅ | Pause playback | Yes | -| next_track | ✅ | Skip to next | Yes | -| previous_track | ✅ | Skip to previous | Yes | -| seek_track | ✅ | Seek position | Yes | -| repeat | ✅ | Set repeat mode | Yes | -| volume | ✅ | Set volume | Yes | -| shuffle | ✅ | Toggle shuffle | Yes | -| **Other** | -| recommendations | ⚠️ | Get recommendations — **403 for any app registered after 2024-11-27** ([announcement](https://developer.spotify.com/blog/2024-11-27-changes-to-the-web-api)). spotatui users register their own client ID, so this is effectively dead for new users; the AI DJ deliberately does not use it and has the model do the recommending instead | Yes | -| audio_analysis | ❌ | Get audio analysis (visualization now uses local FFT, not this endpoint) | Yes | -| featured_playlists | ❌ | Get featured playlists | Yes | -| new_releases | ❌ | Get new releases | Yes | -| categories | ❌ | Get categories | Yes | - -## Want to Help? - -Pick an unimplemented feature and [contribute](https://github.com/LargeModGames/spotatui/blob/main/CONTRIBUTING.md)! diff --git a/docs/scripting.md b/docs/scripting.md deleted file mode 100644 index cdf78f13..00000000 --- a/docs/scripting.md +++ /dev/null @@ -1,710 +0,0 @@ -# Lua scripting - -spotatui can run user-written Lua plugins. Plugins react to playback events and can drive -playback through a small, curated API. Scripting is compiled in behind the `scripting` -feature, which is enabled in the default build. - -## File locations - -Plugins are loaded from your app config directory (`$XDG_CONFIG_HOME/spotatui` -when `XDG_CONFIG_HOME` is set to an absolute path, or `~/.config/spotatui` -when it is unset or not absolute) at startup, in this order: - -1. `init.lua`, if present. -2. Single-file plugins: every `plugins/*.lua` file, sorted by filename. -3. Directory plugins: every `plugins//` folder, sorted by name. The entry point is - `main.lua`, falling back to `init.lua`. A directory with neither is skipped, and directories - whose name starts with `.` are ignored. - -A directory plugin's own folder is added to Lua's `package.path`, so it can split itself across -files and load them with `require("module")` (resolving to `plugins//module.lua`). -`package.path` and the module cache are shared across all plugins: if two plugins both -`require("util")`, the first-loaded plugin's `util.lua` is cached under that name and silently -handed to the later plugin as well. Give helper modules distinctive (e.g. plugin-prefixed) names. - -Directory plugins are how the `spotatui plugin` installer (below) lays out git-cloned plugins. - -Missing files or a missing `plugins/` directory are fine. If a file fails to load, the error -is logged and shown as a status message, and the remaining plugins still load. - -## Trust and safety - -Plugins are not sandboxed. A plugin runs with the same privileges as spotatui itself: it has the -full Lua standard library (including filesystem access via `io`/`os`) and can make arbitrary -network requests through `spotatui.http_get`/`http_post`. `spotatui plugin add` clones a git -repository and runs whatever its `main.lua` contains the next time you start spotatui. - -Treat installing a plugin like running any other program from the internet: only install plugins -whose source you have read or whose author you trust, and prefer repositories you control. There is -no permission prompt and no isolation between a plugin and your account. - -## Installing and managing plugins - -A plugin published as a git repository can be installed with the `spotatui plugin` command. This -requires `git` on your PATH and does not need Spotify authentication. - -```bash -spotatui plugin add owner/repo # GitHub shorthand -spotatui plugin add https://gitlab.com/owner/repo.git -spotatui plugin add owner/repo --force # reinstall over an existing copy - -spotatui plugin list # show installed plugins -spotatui plugin update # update every plugin to its latest commit -spotatui plugin update # update just one -spotatui plugin remove # uninstall - -spotatui plugin new # scaffold a new plugin to start from -``` - -`add` clones the repository into `plugins//` under the spotatui app config -directory (a shallow clone) and records it in `plugins.lock` in that same -directory. `update` fast-forwards each clone to the remote's latest commit. -Restart spotatui after installing or updating for changes to take effect, and -bind any commands the plugin registers under `plugin_commands` in `config.yml`. - -Single-file plugins you drop into `plugins/` by hand are not tracked in the lockfile; `plugin list` -shows them under "untracked". - -## Publishing a plugin - -The quickest start is `spotatui plugin new `, which scaffolds a working directory plugin in -your config directory: - -```bash -spotatui plugin new my-plugin -``` - -This writes `plugins/my-plugin/main.lua` under the spotatui app config directory -(with a `require_api` guard, a sample command, and a suggested key binding) plus -a `README.md`. Edit it, then `git init` and push to share it. - -A shareable plugin is a git repository with a `main.lua` (or `init.lua`) entry point at its root: - -``` -my-plugin/ - main.lua -- entry point; runs at startup - lib.lua -- optional helper module, loaded with require("lib") - README.md -- document the command(s) and a suggested key binding -``` - -The repository name becomes the local plugin name (its last path segment, minus `.git`). Ship a -*suggested* key binding in your README rather than writing to the user's `config.yml`; command -names are decoupled from keys by design. - -To help others find it, add the GitHub topic `spotatui-plugin` to your repository, and open a pull -request adding it to [`PLUGINS.md`](../PLUGINS.md). - -## The `spotatui` API - -A global table named `spotatui` is available in every plugin. - -### Constants - -- `spotatui.api_version` - integer API version (currently `6`). - -### Declaring API compatibility - -The scripting API is versioned and grows over time. If your plugin uses a feature added in a -particular version, declare it on the first line so users on an older spotatui get a clear -message instead of a cryptic `attempt to call nil` error: - -```lua -spotatui.require_api(6) -``` - -`spotatui.require_api(n)` raises a load error (`requires spotatui scripting API v{n} ...`) when -the running build's `api_version` is lower than `n`, which stops that plugin from loading while -leaving the others untouched. Calling it with a version your build supports is a no-op. - -### Events - -Register a callback with `spotatui.on(event, fn)`. Passing an unknown event name raises an -error. Valid events: - -| Event | Argument | Fires when | -|-------|----------|------------| -| `start` | none | The app finishes its first render. | -| `quit` | none | The app is shutting down. | -| `track_change` | playback table or nil | The current track identity changes (by uri, or name as a fallback), including the first track. | -| `playback_state_change` | playback table or nil | Playing/paused state changes (no playback counts as not playing). | -| `seek` | playback table or nil | Same track, same play state, and progress jumps backward by more than 1.5s or forward by more than 6.5s. Forward jumps inside that window are treated as normal Connect polling, not seeks. | -| `volume_change` | playback table or nil | The device volume percentage changes. | -| `queue_change` | none | The queue contents change. | -| `shuffle_change` | playback table or nil | Shuffle flips while playback exists on both sides of the tick. | -| `repeat_change` | playback table or nil | The repeat mode changes while playback exists on both sides of the tick. | -| `device_change` | none | The device list changes (by id, name, or which device is active). | -| `search_results` | none | New search results arrive (from the Search screen or `spotatui.search`). | -| `route_change` | `{ name = "..." }` | The visible screen changes. Names are listed under Navigation below; plugin screens are `"plugin:"`. | - -You can register multiple callbacks for the same event. - -### Reads - -These return a snapshot of the cached state. Snapshots are refreshed before callbacks run. - -- `spotatui.playback()` - playback table, or `nil` when there is no playback. -- `spotatui.current_track()` - track table, or `nil`. -- `spotatui.devices()` - array of device tables. -- `spotatui.playlists()` - array of playlist tables (cached; empty until the app has fetched playlists). -- `spotatui.queue()` - `{ currently_playing = item or nil, items = { ... } }` (cached). -- `spotatui.search_results()` - `{ tracks, albums, artists, playlists, shows }` (cached). -- `spotatui.current_route()` - the current screen name as a string. -- `spotatui.config()` - theme + behavior settings (see "Reading configuration" below). - -The playback table has these fields: - -``` -{ - track = { - uri, name, artists = { ... }, album, duration_ms, - id, album_id, - artist_refs = { { id, name }, ... }, - is_playable, is_local, track_number, explicit, - image_url, - } or nil, - is_playing = bool, - progress_ms = number, - shuffle = bool, - repeat = "off" | "track" | "context", - volume_percent = number or nil, - device = { id, name, kind, is_active, volume_percent } or nil, -} -``` - -`repeat` is a Lua reserved word, so index it with `pb["repeat"]`, not `pb.repeat`. (The matching -action is named `set_repeat` for the same reason.) - -The track's additional fields: `id` is the Spotify base62 track id (`nil` for local/unknown -tracks); `album_id` is the album's base62 id, when known; `artist_refs` is an array of -`{ id, name }` navigable artist references, populated when the source provides per-artist data -and empty when only the combined `artists` display list is available (e.g. native-playback -snapshots); `is_playable` and `is_local` default to `true`/`false`; `track_number` defaults to -`0` and `explicit` to `false` when unknown; `image_url` is a directly-fetchable cover-art URL -when the source provides one (e.g. Subsonic, YouTube, Qobuz), otherwise `nil`. - -The cached reads for playlists, queue and search results refresh when the underlying data -changes; they are cheap to call but can be empty until the app has actually fetched that data. -Use the async `get_*` reads below when you want to trigger a fetch and be told when it lands. - -### Async data reads - -These request fresh data from Spotify and deliver it to a callback, following the same -`callback(data, err)` convention as HTTP. The call returns immediately; the callback runs on a -later UI tick once the data has arrived. If nothing arrives within 15 seconds the callback -receives `(nil, "request timed out")`. Callbacks are one-shot. - -- `spotatui.get_playlists(cb)` - `cb(array of playlist tables, err)`. -- `spotatui.get_queue(cb)` - `cb({ currently_playing, items }, err)`. Each queue item is - `{ kind = "track" | "episode", track = {...} or nil, episode = {...} or nil }`. An - unavailable queue (no active device) delivers an empty snapshot. -- `spotatui.get_search_results(query, cb)` - runs a search (without leaving the current - screen) and delivers `{ tracks, albums, artists, playlists, shows }`. -- `spotatui.get_saved_tracks(cb)` - liked songs fetched so far. -- `spotatui.get_saved_albums(cb)` - `{ album = {...}, added_at }` entries. -- `spotatui.get_saved_shows(cb)` - saved podcasts. -- `spotatui.get_recently_played(cb)` - recently played tracks. -- `spotatui.get_devices(cb)` - refreshes the device list without opening the device picker. -- `spotatui.get_lyrics(cb)` - `cb({ status, lines }, err)` for the current track, where - `status` is `"not_started" | "loading" | "found" | "not_found"` and each line is - `{ time_ms, text }`. Delivered immediately when the current track's lyrics are already - resolved; otherwise waits for the in-flight fetch. Calling it from a `track_change` handler - is safe: it waits for the new track's lyrics rather than returning the previous track's. - -```lua -spotatui.get_playlists(function(playlists, err) - if err then - spotatui.notify("playlists: " .. err, 4) - return - end - spotatui.notify("you have " .. #playlists .. " playlists", 4) -end) -``` - -### Actions - -Actions are queued and applied by the app on the next opportunity; they do not return a -result. Every action follows the exact same code path as the equivalent keybinding, including -native streaming fast paths (librespot) when the native player is active. - -- `spotatui.play()` - resume playback. No-op if already playing. -- `spotatui.pause()` - pause playback. No-op if already paused. -- `spotatui.next()` - skip to the next track. -- `spotatui.previous()` - go to the previous track, or restart the current track when more - than 3 seconds in (matching the previous-track key behaviour). -- `spotatui.seek(ms)` - seek to a position in milliseconds. -- `spotatui.set_volume(pct)` - set volume; clamped to 0-100. -- `spotatui.shuffle(on)` - set shuffle to the desired state. No-op if already in that state. -- `spotatui.search(query)` - run a search and open the Search screen. -- `spotatui.notify(msg, ttl_secs?)` - show a status message (default ttl 4 seconds). -- `spotatui.set_repeat(mode)` - set repeat to `"off"`, `"track"` or `"context"` (named - `set_repeat` because `repeat` is a Lua reserved word). -- `spotatui.cycle_repeat()` - cycle repeat exactly like the repeat keybinding (keeps the - native-streaming fast path). -- `spotatui.transfer_playback(device_id)` - transfer playback to a device (does not overwrite - your saved device preference). -- `spotatui.play_uri(uri)` - play a `spotify:track:`/`spotify:episode:` uri directly, or start - a `spotify:album:`/`playlist:`/`artist:`/`show:` uri as a context. Anything else raises. -- `spotatui.play_context(uri, offset?)` - play a container uri, optionally from a 0-based - track offset. -- `spotatui.add_to_queue(uri)` - add a track/episode to the queue. -- `spotatui.create_playlist(name, uris?)` - create a playlist, optionally seeded with track uris. -- `spotatui.playlist_add_track(playlist, track)` - add a track to a playlist (ids or uris). -- `spotatui.playlist_remove_track(playlist, track, position)` - remove the track occurrence at - the given 0-based position (required, like the Web API). -- `spotatui.follow_playlist(playlist)` / `spotatui.unfollow_playlist(playlist)`. -- `spotatui.toggle_save_track(uri)` - like/unlike a track. -- `spotatui.save_album(id)` / `spotatui.unsave_album(id)`. -- `spotatui.save_show(id)` / `spotatui.unsave_show(id)`. -- `spotatui.follow_artist(id)` / `spotatui.unfollow_artist(id)`. - -Arguments are lightly validated (non-empty, well-formed uri kinds, in-range numbers); invalid -arguments raise a Lua error at call time. The network call itself still happens later, so a -valid-looking id that Spotify rejects surfaces as a normal API error message, not a Lua error. - -### Timers - -- `spotatui.set_timeout(ms, fn) -> handle` - run `fn` once after roughly `ms` milliseconds. -- `spotatui.set_interval(ms, fn) -> handle` - run `fn` repeatedly every roughly `ms` - milliseconds (`ms` must be at least 1). -- `spotatui.cancel_timer(handle)` - cancel a pending timeout or interval. Unknown or expired - handles are a no-op. - -Timers fire from the app's tick loop, so their real resolution is the UI tick rate -(`behavior.tick_rate_milliseconds`, at most 999ms) -- a 10ms timeout still waits for the next -tick. If the app stalls past several interval periods, the interval fires once and reschedules -(no catch-up burst). An interval whose callback errors is removed after the first failure. - -### Navigation - -- `spotatui.navigate(target)` - open a screen, doing exactly what the matching keybinding does - (including any data fetch it performs). Valid targets: `home`, `queue`, `settings`, - `devices`, `help`, `lyrics`, `recently_played`, `party`, `analysis`, `miniplayer`. Unknown - targets raise. -- `spotatui.back()` - pop the navigation stack, like the back key. -- `spotatui.current_route()` - the current screen name. Screen names also show up in the - `route_change` event: `home`, `search`, `track_table`, `album_tracks`, `album_list`, - `artist`, `artists`, `recently_played`, `devices`, `queue`, `settings`, `help`, `lyrics`, - `cover_art`, `miniplayer`, `analysis`, `discover`, `podcasts`, `podcast_episodes`, - `recommendations`, `party`, `friends`, `local_browser`, `create_playlist`, `dialog`, - `announcement`, `recap_prompt`, `exit_prompt`, `error`, `stats`, and `plugin:` for - plugin screens. - -### Persistent storage - -Each plugin gets a private key-value store persisted as plain JSON at -`plugin-data/.json` under the spotatui app config directory. Values must -be JSON-serializable (tables, strings, numbers, booleans); functions and userdata -raise. - -- `spotatui.storage_get(key)` - the stored value, or `nil`. -- `spotatui.storage_set(key, value)` - store a value. `nil` removes the key. -- `spotatui.storage_remove(key)` - remove a key. -- `spotatui.storage_keys()` - array of stored key names. - -Writes are flushed to disk in the background (roughly every 3 seconds) and always on quit. -The files are plain JSON on disk -- other programs (and other plugins, via `io`) can read -them, so do not store secrets. If two spotatui instances run at once, the last writer wins. -A missing or corrupt file starts the plugin with an empty store (corruption is logged). - -```lua -local plays = (spotatui.storage_get("play_count") or 0) + 1 -spotatui.storage_set("play_count", plays) -``` - -### Reading configuration - -`spotatui.config()` returns the live user configuration: - -``` -{ - theme = { active = "0, 180, 180", playbar_text = "Reset", ... }, - behavior = { - seek_milliseconds, volume_increment, - tick_rate_milliseconds, animation_tick_rate_milliseconds, - liked_icon, shuffle_icon, repeat_track_icon, repeat_context_icon, - playing_icon, paused_icon, - enable_text_emphasis, show_loading_indicator, enforce_wide_search_bar, - set_window_title, shuffle_enabled, stop_after_current_track, - sidebar_width_percent, playbar_height_rows, library_height_percent, - active_source, - }, -} -``` - -Theme values use the same string forms as `config.yml` (named color or `"r, g, b"`), so they -can round-trip through `spotatui.set_theme`. Secrets and service credentials (sync token, -relay URL, Subsonic credentials, the Qobuz token, Discord client id) are never exposed, and -source-specific settings (Subsonic, YouTube, Qobuz) are not part of the snapshot. The snapshot is -populated once the app is running; reading it at load time (before the `start` event) returns -empty defaults. - -### Logging - -- `spotatui.log(msg)` - write an info-level line to the app log. - -### JSON utilities - -- `spotatui.json_decode(json)` - parse a JSON string into Lua tables, strings, numbers, - booleans, and nil-compatible values. Invalid JSON raises a Lua error. -- `spotatui.json_encode(value)` - serialize a Lua value to a compact JSON string. Values that - cannot be represented as JSON, such as functions or userdata, raise a Lua error. - -JSON `null` decodes to a light userdata sentinel, not Lua `nil`, and the sentinel is truthy in -Lua. To detect it, compare against a known null value: - -```lua -local NULL = spotatui.json_decode("null") -local decoded = spotatui.json_decode('{"artist":null}') -if decoded.artist == NULL then - -- field was present but null -end -``` - -```lua -local body = spotatui.json_encode({ - track = "spotify:track:...", - rating = 5, -}) - -local decoded = spotatui.json_decode('{"ok":true,"items":[1,2]}') -spotatui.log("first item: " .. decoded.items[1]) -``` - -### HTTP requests - -HTTP runs asynchronously. Calls return immediately; the callback runs on a later UI tick after -the response arrives. Only `http://` and `https://` URLs are accepted. - -- `spotatui.http_get(url, callback)` - send a GET request. -- `spotatui.http_post(url, body, headers, callback)` - send a POST request. `body` is a string. - `headers` must be a table of string keys and string values, or `nil` for no headers. The - four-argument form is required, so pass `nil` when you do not need headers. - -Callbacks receive `callback(resp, err)`: - -- Success: `resp = { status = number, ok = bool, body = string }`, `err = nil`. -- Transport failure such as DNS, timeout, or connection failure: `resp = nil`, `err = string`. -- HTTP 4xx and 5xx responses are not transport failures. They call the success path with - `resp.ok = false`. - -Response bodies are decoded with lossy UTF-8 conversion. In-flight requests are dropped when -spotatui exits. - -```lua -spotatui.on("track_change", function(pb) - if not pb or not pb.track then - return - end - - local url = "https://example.com/lyrics?uri=" .. pb.track.uri - spotatui.http_get(url, function(resp, err) - if err then - spotatui.notify("lyrics fetch failed: " .. err, 4) - return - end - if resp.ok then - local parsed = spotatui.json_decode(resp.body) - spotatui.popup("Lyrics", parsed.lines) - else - spotatui.notify("lyrics service returned " .. resp.status, 4) - end - end) -end) -``` - -```lua -local body = spotatui.json_encode({ event = "track_started" }) - -spotatui.http_post( - "https://example.com/webhook", - body, - { ["content-type"] = "application/json" }, - function(resp, err) - if err then - spotatui.log("webhook failed: " .. err) - elseif not resp.ok then - spotatui.log("webhook returned " .. resp.status) - end - end -) -``` - -## Commands and keybindings - -`spotatui.register_command(name, fn)` registers a named, callable action. The name must be a -non-empty string with no whitespace. Registering the same name twice (from any plugin) raises a -Lua error at load time. - -```lua -spotatui.register_command("toggle_lyrics", function() - spotatui.notify("lyrics toggled", 3) -end) -``` - -To bind a command to a key, add a `plugin_commands` section to `config.yml`: - -```yaml -plugin_commands: - toggle_lyrics: "ctrl-l" - show_stats: "ctrl-k" -``` - -Each entry maps a command name to a key string. The key string uses the same format as the -built-in keybindings (e.g. `ctrl-l`, `alt-x`, `f1`, `space`). Entries are silently skipped when -the key string is invalid, the key is a reserved navigation key, or the key already has a named -action bound to it. The remaining entries are loaded normally. - -Which keys are already taken depends on the build: an `ai-dj` build claims `ctrl-j`, `ctrl-t`, -`ctrl-y`, `ctrl-o` and `ctrl-g` for the DJ, so a plugin binding on one of those is dropped on -load with only a log line to say so. Check `?` in a running spotatui for the live list. - -When the bound key is pressed, the corresponding command callback fires after the current key -handler returns. An error in the callback is reported as a highlighted status message (6-second -ttl) and logged, but the command stays registered -- a transient failure does not permanently -unbind a key. - -Plugin authors should document a suggested binding in their plugin rather than shipping one -in config. Command names are decoupled from keys by design: the user decides which key to use. - -## UI extension - -### Playbar segment - -`spotatui.set_playbar(text)` sets a persistent text segment for the calling plugin, shown in -the playbar title as `" | {text}"` after any status message. Each plugin has its own segment -slot; calling `set_playbar` again replaces it. Pass `nil` to clear the segment. - -```lua -spotatui.on("track_change", function(pb) - if pb and pb.track then - spotatui.set_playbar(pb.track.name) - else - spotatui.set_playbar(nil) - end -end) -``` - -The segment persists until the plugin explicitly clears it. Multiple plugins each show their -own segment in alphabetical plugin-name order. - -### Popup - -`spotatui.popup(title, lines)` opens a centered modal dialog. The dialog overlays every -screen, including the help menu and queue. Press `j`/Down to scroll down, `k`/Up to scroll -up, and `Esc` or `q` to close. All other keys are swallowed while the popup is open. - -`lines` can be: -- A single string. -- An array where each item is a string or a table `{ text, fg?, bold?, italic? }`. - - `fg` is a color string in the same format as `config.yml` theme values (e.g. `"Red"`, - `"Magenta"`, `"0, 200, 200"`). - - `bold` and `italic` are booleans (default `false`). - - Missing `text`, an unparseable color, or a non-string/non-table item raises a Lua error. - -```lua -spotatui.popup("Track info", { - { text = "Now playing", bold = true }, - { text = "Song title here", fg = "Cyan" }, - "", - "Press Esc to close", -}) -``` - -### Theme overrides - -`spotatui.set_theme(tbl)` applies runtime color overrides to the active theme. Keys are -theme field names and values are color strings. Changes are applied immediately and affect all -subsequent renders. They are never written back to `config.yml` -- they are runtime-only and -reset on app restart. - -Valid field names: `active`, `banner`, `error_border`, `error_text`, `hint`, `hovered`, -`inactive`, `playbar_background`, `playbar_progress`, `playbar_progress_text`, `playbar_text`, -`selected`, `text`, `background`, `header`, `highlighted_lyrics`, `analysis_bar`, -`analysis_bar_text`. - -Color string format is the same as in `config.yml` (named ANSI color or `"r, g, b"`). - -An unknown field name or an invalid color raises a Lua error. - -```lua -spotatui.set_theme({ - playbar_text = "Magenta", - hint = "0, 200, 0", -}) -``` - -### Custom screens - -Plugins can register full-screen views. Screens are retained-mode: you publish content with -`set_screen`, the app renders it from its own state, and you update it again whenever you want -the view to change (typically from `on_key`, a timer, or a data callback). There is no -per-frame draw callback. - -- `spotatui.register_screen(name, spec)` - register a screen. `name` must be non-empty with no - whitespace and unique across all plugins. `spec` is a table: - - `title` (optional string) - the window title (defaults to the screen name). - - `on_key(key)` (required) - called for every key pressed while the screen is focused. `key` - is a `config.yml`-style string (`"a"`, `"ctrl-x"`, `"enter"`, `"up"`, `"space"`, ...). - - `on_open()` / `on_close()` (optional) - called when the screen gains/loses the route. -- `spotatui.set_screen(name, widgets)` - publish the screen's content (see widgets below). -- `spotatui.show_screen(name)` - navigate to the screen. -- `spotatui.close_screen(name)` - leave the screen if it is currently shown. - -`set_screen`/`show_screen`/`close_screen` verify the screen is registered and owned by the -calling plugin. - -`widgets` is an array; each entry is a table with a `type`: - -- `{ type = "paragraph", lines = , scroll? }` - styled text. `lines` uses the same - format as `spotatui.popup`. `scroll` defaults to `true`; set it to `false` on a fixed - header so `PageUp`/`PageDown` leave it alone. -- `{ type = "list", items = , title?, selected? }` - a bordered list. - `selected` is 1-based (like Lua arrays) and highlights that item. -- `{ type = "gauge", ratio, label? }` - a progress bar; `ratio` is clamped to 0..1. -- `{ type = "cover_art", source?, fit? }` - the current track's cover art (API v6). -- `{ type = "row", children = { ... } }` / `{ type = "column", children = { ... } }` - - layout containers (API v6). - -#### Sizing - -Any widget may carry a size hint on either axis: - -| Field | Meaning | -|---|---| -| `height` / `width` | an absolute number of cells; `0` hides the widget | -| `height_percent` / `width_percent` | `1`..`100` percent of the parent | - -The top level is an implicit vertical stack, a `column` stacks vertically and a `row` stacks -horizontally. Only the hint matching the parent's axis is used - `width` is ignored at the top -level, `height` is ignored inside a `row`. A widget with no matching hint shares the remaining -space evenly with the other unsized widgets. Setting both `height` and `height_percent` (or -both width forms) is an error, as is a percentage outside `1`..`100`. A cell count of `0` is -not an error - it collapses the widget to nothing, which is a way to hide one conditionally. - -A `gauge` with no `height` takes 3 rows, as before. Since API v6 an explicit `height` on a -gauge is honored instead of ignored. - -#### Containers - -`row` and `column` are pure layout: they draw no border and no title, they only split their -area among `children`. Nest them to build a grid: - -```lua -spotatui.set_screen("now_playing", { - { type = "paragraph", lines = { "q to close" }, height = 1, scroll = false }, - { - type = "row", - children = { - { type = "cover_art", width_percent = 40 }, - { type = "paragraph", lines = lyrics, width_percent = 60 }, - }, - }, -}) -``` - -Trees are capped at 8 levels of nesting and 256 widgets per screen. - -#### Cover art - -`{ type = "cover_art" }` renders the artwork spotatui has *already* downloaded and decoded for -the current track - there is no second download, and the app keeps ownership of the terminal -graphics protocol, resizing, placement and cleanup. It works for every source that supplies -art (Spotify, Subsonic, YouTube, Qobuz, local files with an embedded picture). - -- `source` - optional; only `"current"` is accepted today. Any other value is an error, so a - future source fails loudly on an old build rather than showing the wrong image. -- `fit` - `"contain"` (default) fits the image inside its area keeping the aspect ratio and - never upscaling; `"scale"` also upscales an image smaller than its area. The image is - centered in whatever space the layout gives it. - -At most **one** `cover_art` widget may appear anywhere in a screen's tree; a second one is a -`set_screen` error. - -When no image can be shown the widget draws a centered status line instead ("Loading cover -art...", "No cover art for this source", ...). That is what you get when the track has no -artwork, when the build was compiled without the `cover-art` feature, when -`behavior.draw_cover_art` is off, or when the terminal has no graphics protocol and -`behavior.draw_cover_art_forced` is off. A Kitty-, iTerm2- or Sixel-capable terminal shows -graphics art by default; set `draw_cover_art_forced` for the basic-terminal fallback. - -When `behavior.cover_art_dither` is enabled, plugin cover-art widgets use the same optional -two-color dither style as the playbar and full-screen art view. The accent comes from -`theme.cover_art_dither_color`, or from the live playbar progress color when that setting is -`auto`; the widget uses the current theme background for its other color. The mask is made -from the original decoded image at the widget's output resolution, including for small slots. - -#### Keys - -Keys reach `on_key` only after the global keybindings have run, so keys like the back key or -volume keys keep their normal meaning. `Esc` (and the back key) leave the screen, and -`PageUp`/`PageDown` scroll paragraph text; everything else is forwarded. An `on_key` that -errors is disabled after the first failure (one strike). - -`PageUp`/`PageDown` apply one shared scroll offset to every paragraph with `scroll = true`, so -a header with `scroll = false` stays pinned while the rest of the screen moves. - -A minimal interactive screen: - -```lua -spotatui.require_api(5) - -local selected = 1 -local names = {} - -local function render() - spotatui.set_screen("my_playlists", { - { type = "paragraph", lines = { { text = "j/k to move, Esc to leave", italic = true } }, height = 2 }, - { type = "list", title = "Playlists", items = names, selected = selected }, - }) -end - -spotatui.register_screen("my_playlists", { - title = "My Playlists", - on_key = function(key) - if key == "j" and selected < #names then selected = selected + 1 end - if key == "k" and selected > 1 then selected = selected - 1 end - render() - end, - on_open = function() - spotatui.get_playlists(function(playlists, err) - names = {} - for _, p in ipairs(playlists or {}) do - names[#names + 1] = p.name - end - render() - end) - end, -}) - -spotatui.register_command("my_playlists", function() - spotatui.show_screen("my_playlists") -end) -``` - -Bind `my_playlists` under `plugin_commands` in `config.yml` to open it with a key. - -## Error behavior - -Plugin code can never crash the app. If a callback raises an error or panics, the error is -logged, a highlighted status message is shown in the playbar, and that one callback is -disabled (one strike). Other callbacks, including other callbacks for the same event, keep -running. - -Plugin errors are shown using the theme's error color and stay visible for 6 seconds. -Normal notifications (e.g. a "Now playing" message from `spotatui.notify`) cannot overwrite -a live plugin error -- the error is shown first, and the notification takes effect only after -the error expires. A later plugin error always replaces an earlier one immediately. - -## Sample init.lua - -```lua -spotatui.on("track_change", function(pb) - if pb and pb.track then - spotatui.notify("Now playing: " .. pb.track.name .. " by " .. table.concat(pb.track.artists, ", "), 4) - end -end) - -spotatui.on("start", function() - spotatui.log("plugins loaded, api version " .. spotatui.api_version) -end) -``` diff --git a/docs/themes.md b/docs/themes.md deleted file mode 100644 index 74257aaf..00000000 --- a/docs/themes.md +++ /dev/null @@ -1,60 +0,0 @@ -# Themes - -spotatui comes with several built-in theme presets. Access them via `Alt-,` > Theme. - -## Built-in Presets - -| Preset | Description | -| ---------------- | -------------------------------------------- | -| Default (Cyan) | Original spotatui theme | -| Terminal (ANSI) | Uses your terminal's ANSI colors | -| Pookie Pink | Bright pink theme | -| Spotify | Official Spotify green (#1DB954) | -| Vesper | Minimal dark theme with warm orange accents | -| Dracula | Popular dark purple/pink theme | -| Nord | Arctic, bluish color palette | -| Solarized Dark | Classic dark theme | -| Monokai | Vibrant colors on dark background | -| Gruvbox | Warm retro groove colors | -| Gruvbox Light | Light variant with warm colors | -| Catppuccin Mocha | Popular pastel dark theme | - -## Custom Themes - -You can create custom themes in `config.yml` in the spotatui app config directory -(`$XDG_CONFIG_HOME/spotatui` when `XDG_CONFIG_HOME` is set to an absolute path, -or `~/.config/spotatui` when it is unset or not absolute): - -```yaml -theme: - active: "137, 180, 250" # Current playing song - banner: "180, 190, 254" # The "spotatui" banner - error_border: "243, 139, 168" - error_text: "243, 139, 168" - hint: "249, 226, 175" - hovered: "203, 166, 247" - inactive: "108, 112, 134" - playbar_background: "30, 30, 46" - playbar_progress: "166, 227, 161" - playbar_progress_text: "166, 227, 161" - playbar_text: "205, 214, 244" - selected: "137, 180, 250" - text: "205, 214, 244" - header: "255, 255, 255" - background: "30, 30, 46" - highlighted_lyrics: "166, 227, 161" -``` - -### Color Values - -**Always use RGB strings** for consistent colors across different terminals: - -```yaml -text: "205, 214, 244" # RGB format: "red, green, blue" (0-255) -``` - -> **Note:** Terminal color names like `Red`, `Cyan`, etc. are also supported but render differently depending on your terminal's color scheme. For consistent themes, use RGB values. - -## Contributing Themes - -Want to add a new theme preset? See [CONTRIBUTING.md](https://github.com/LargeModGames/spotatui/blob/main/CONTRIBUTING.md) and check out `src/core/user_config.rs` for examples. diff --git a/examples/config.example.yml b/examples/config.example.yml deleted file mode 100644 index 98810c7b..00000000 --- a/examples/config.example.yml +++ /dev/null @@ -1,129 +0,0 @@ -# Example spotatui config.yml. Every field is optional — omit anything to keep -# the built-in default. A typo in any of these fields logs a warning and falls -# back to the default; it never prevents the app from starting. -# Full reference: docs/configuration.md - -behavior: - # --- Timing / behavior constants --- - status_message_ttl_percent: 100 # 10..=1000; 200 = status messages last twice as long - playback_poll_seconds: 5 # min 1; playback-state poll interval - table_scroll_padding: 5 # rows kept visible below the selection while scrolling - like_animation_frames: 10 # min 1; length of the heart burst when liking a track - - # --- Startup --- - # home | recently_played | podcasts | discover | artists | album_list - startup_route: home - - # --- Default sorting ("" or ":desc") --- - # playlist_tracks: default | name | date_added | artist | album | duration - # saved_albums: default | name | date_added | artist - # saved_artists: default | name - # recently_played: default | name | artist | album - default_sort_playlist_tracks: default - default_sort_saved_albums: default - default_sort_saved_artists: default - default_sort_recently_played: default - - # --- Layout --- - sidebar_position: left # left | right | hidden (auto-reveals when focused) - playbar_position: bottom # bottom | top - small_terminal_width: 150 # wide-layout breakpoint (columns) - small_terminal_height: 45 # margin breakpoint (rows) - - # --- Cover art (requires the cover-art build feature) --- - # draw_cover_art_forced: true # allow art on basic terminals - # cover_art_dither: true # optional two-color theme-aware style - # cover_art_dither_algorithm: stucki # default; stucki | bayer8x8 | atkinson - # cover_art_dither_pixel_scale: 1 # 1..=3; one output pixel/dot at 1 - - # --- Icons --- - # Fixed-cell icons (playing/gauge/episode/sort) must be exactly one terminal - # column wide; invalid glyphs fall back to the defaults with a warning. - # Note: some glyphs below (e.g. ↑ ↓ ● ▶) are Unicode "East Asian Ambiguous" - # width — terminals configured to render ambiguous-width characters as wide - # (common with CJK locales) draw them 2 cells wide, which shifts column - # alignment. If that happens, set your terminal's ambiguous width to - # "narrow" or pick unambiguous glyphs. - liked_icon: "♥" - shuffle_icon: "🔀" - playing_icon: "▶" - paused_icon: "⏸" - gauge_filled_icon: "⣿" - gauge_unfilled_icon: "⣉" - active_source_icon: "●" - episode_played_icon: "✔" - sort_ascending_icon: "↑" - sort_descending_icon: "↓" - list_highlight_icon: "▶" - -# The dither accent is independent of the preset. Omit or use auto to follow -# the live playbar progress color, including adaptive-theme changes. -# theme: -# cover_art_dither_color: auto -# cover_art_dither_color: '120, 160, 220' # exact RGB override - - # --- Playbar button labels (config-only) --- - # All keys optional; omit (or set "") to keep a button's built-in label. - # Mouse hitboxes resize to fit automatically. - # playbar_control_labels: - # prev: "⏮" - # play_pause: "[PLAY/PAUSE]" - # next: "⏭" - # shuffle: "shfl" - # repeat: "rpt" - # like: "♥+" - # vol_down: "vol-" - # vol_up: "vol+" - -# --- Status line & window title templates (config-only) --- -# {key} placeholders, {{ / }} for literal braces. Unknown keys fall back to -# the default template with a warning listing the valid keys. -# playbar keys: state, device, source, queue, shuffle, repeat, volume, party -# window_title keys: title, artist ({artist} includes its own " — " separator -# and renders empty when unknown) -# In playbar_status_source only, {shuffle} and {repeat} carry their own -# " | Label: value" prefix and render empty for sources without those controls -# (internet radio, native queue slots), so their labels never show up blank. -format: - playbar_status: "{state} ({device} | Shuffle: {shuffle} | Repeat: {repeat} | Volume: {volume}%){party}" - playbar_status_source: "{state} ({source}{queue}{shuffle}{repeat} | Volume: {volume}%)" - window_title: "{title}{artist}" - -# --- Table columns (config-only) --- -# Reorder, remove, rename (header:) and resize (width_percent: or width:, not -# both, no zeros, percents may not sum past 100). Omit a table for defaults. -# Valid ids: -# songs / album_tracks / recently_played: liked, index, title, artist, album, length -# albums: title, artist, date, liked -# podcasts: title, publisher -# episodes: played, date, title, duration -tables: - songs: - - { id: liked } - - { id: title, width_percent: 40 } - - { id: artist, header: "Band", width_percent: 30 } - - { id: album } - - { id: length } - album_tracks: - - { id: liked } - - { id: index } - - { id: title } - - { id: artist } - - { id: length } - albums: - - { id: title } - - { id: artist } - - { id: date } - podcasts: - - { id: title } - - { id: publisher } - episodes: - - { id: played } - - { id: date } - - { id: title } - - { id: duration } - recently_played: - - { id: liked } - - { id: title } - - { id: artist } - - { id: length } diff --git a/examples/plugins/README.md b/examples/plugins/README.md deleted file mode 100644 index a53104f5..00000000 --- a/examples/plugins/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# Example plugins - -Small, self-contained Lua plugins that demonstrate the spotatui plugin API. Copy one into your -config directory and restart spotatui to try it. See [`docs/scripting.md`](../../docs/scripting.md) -for the full API reference. These examples are first-party and auditable; plugins run with full app -privileges, so read anything you install from elsewhere (see -[Trust and safety](../../docs/scripting.md#trust-and-safety)). - -| Plugin | What it shows | -|--------|---------------| -| [`track-notifier.lua`](track-notifier.lua) | Events, `notify`, `set_playbar` | -| [`track-info-popup.lua`](track-info-popup.lua) | `register_command`, reads, `popup` | -| [`accent-cycler.lua`](accent-cycler.lua) | `register_command`, `set_theme` | -| [`now-playing-webhook.lua`](now-playing-webhook.lua) | `http_post`, `json_encode` | -| [`queue-browser.lua`](queue-browser.lua) | Custom screens, async data reads, timers, storage | -| [`now-playing.lua`](now-playing.lua) | `cover_art` widget, `row` layout, synced lyrics | -| [`session-stats/`](session-stats) | A directory plugin with a `require`-d helper module | - -## Installing - -Single-file plugins go straight into `plugins/` under the spotatui app config -directory (see [`PLUGINS.md`](../../PLUGINS.md) for config path rules): - -```bash -mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}/spotatui/plugins" -cp track-notifier.lua "${XDG_CONFIG_HOME:-$HOME/.config}/spotatui/plugins/" -``` - -Directory plugins (a folder with a `main.lua` entry point) are copied as a whole: - -```bash -mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}/spotatui/plugins" -cp -r session-stats "${XDG_CONFIG_HOME:-$HOME/.config}/spotatui/plugins/" -``` - -Restart spotatui after installing. Plugins that register commands need a key binding; add one to -`config.yml` in the spotatui app config directory under `plugin_commands` (each -plugin documents a suggested key in its header comment). - -To install a plugin published as a git repository, use the built-in installer instead: - -```bash -spotatui plugin add owner/repo -``` diff --git a/examples/plugins/accent-cycler.lua b/examples/plugins/accent-cycler.lua deleted file mode 100644 index 8a641431..00000000 --- a/examples/plugins/accent-cycler.lua +++ /dev/null @@ -1,24 +0,0 @@ --- accent-cycler: a `cycle_accent` command that rotates the theme accent color at runtime. --- --- Theme overrides from set_theme are runtime-only; they reset when spotatui restarts. --- --- Install (single file; see PLUGINS.md for config path rules): --- cp accent-cycler.lua "${XDG_CONFIG_HOME:-$HOME/.config}/spotatui/plugins/" --- --- Suggested binding, in config.yml in the spotatui app config directory: --- plugin_commands: --- cycle_accent: "ctrl-y" - -local accents = { "Magenta", "Cyan", "Green", "Yellow", "Red", "Blue" } -local index = 0 - -spotatui.register_command("cycle_accent", function() - index = (index % #accents) + 1 - local color = accents[index] - spotatui.set_theme({ - playbar_progress = color, - hint = color, - selected = color, - }) - spotatui.notify("Accent: " .. color, 2) -end) diff --git a/examples/plugins/now-playing-webhook.lua b/examples/plugins/now-playing-webhook.lua deleted file mode 100644 index 62bc6545..00000000 --- a/examples/plugins/now-playing-webhook.lua +++ /dev/null @@ -1,38 +0,0 @@ --- now-playing-webhook: POST a JSON payload to a webhook whenever the track changes. --- --- A small example of the async HTTP + JSON API. Useful for scrobblers, Discord/Slack --- webhooks, home-automation triggers, or a "what am I listening to" endpoint. --- --- Install (single file; see PLUGINS.md for config path rules): --- cp now-playing-webhook.lua "${XDG_CONFIG_HOME:-$HOME/.config}/spotatui/plugins/" --- Edit WEBHOOK_URL below first. - -local WEBHOOK_URL = "https://example.com/webhook" - -spotatui.on("track_change", function(pb) - if not pb or not pb.track then - return - end - - local payload = spotatui.json_encode({ - event = "now_playing", - uri = pb.track.uri, - name = pb.track.name, - artists = pb.track.artists, - album = pb.track.album, - is_playing = pb.is_playing, - }) - - spotatui.http_post( - WEBHOOK_URL, - payload, - { ["content-type"] = "application/json" }, - function(resp, err) - if err then - spotatui.log("now-playing-webhook: request failed: " .. err) - elseif not resp.ok then - spotatui.log("now-playing-webhook: server returned " .. resp.status) - end - end - ) -end) diff --git a/examples/plugins/now-playing.lua b/examples/plugins/now-playing.lua deleted file mode 100644 index ae4015a5..00000000 --- a/examples/plugins/now-playing.lua +++ /dev/null @@ -1,140 +0,0 @@ --- now-playing: a custom now-playing screen showing the v6 API - the cover_art --- widget beside synchronized lyrics, laid out in a row container with --- per-axis size hints. --- --- Install (single file; see PLUGINS.md for config path rules): --- cp now-playing.lua "${XDG_CONFIG_HOME:-$HOME/.config}/spotatui/plugins/" --- --- Suggested binding, in config.yml in the spotatui app config directory: --- plugin_commands: --- now_playing: "ctrl-n" --- --- Keys inside the screen: Esc leaves. Lyrics follow playback automatically. --- --- Cover art needs a build with the `cover-art` feature and a terminal with a --- graphics protocol (Kitty, iTerm2, Sixel). Without one the widget draws a --- status line in its place and the rest of the screen still works. - -spotatui.require_api(6) - -local SCREEN = "now_playing" - --- How many lyric lines to show around the current one. -local CONTEXT_LINES = 8 - -local lyrics = {} -- { { time_ms = ..., text = ... }, ... } -local lyrics_status = "not_started" -local lyrics_track = nil -- uri the loaded lyrics belong to - -local function track_uri() - local pb = spotatui.playback() - return pb and pb.track and pb.track.uri or nil -end - --- Index of the last lyric line whose timestamp has passed, or nil. -local function current_line_index(progress_ms) - local found = nil - for i, line in ipairs(lyrics) do - if line.time_ms <= progress_ms then - found = i - else - break - end - end - return found -end - --- A window of lyric lines centred on the current one, with it highlighted. -local function lyric_lines(progress_ms) - if #lyrics == 0 then - local message = "No lyrics for this track" - if lyrics_status == "loading" or lyrics_status == "not_started" then - message = "Loading lyrics..." - end - return { { text = message, italic = true } } - end - - local current = current_line_index(progress_ms) - local first = math.max(1, (current or 1) - 2) - local last = math.min(#lyrics, first + CONTEXT_LINES) - - local out = {} - for i = first, last do - if i == current then - out[#out + 1] = { text = lyrics[i].text, bold = true } - else - out[#out + 1] = { text = lyrics[i].text, fg = "DarkGray" } - end - end - return out -end - -local function render() - local pb = spotatui.playback() - local title = "Nothing playing" - if pb and pb.track then - title = pb.track.name .. " - " .. table.concat(pb.track.artists, ", ") - end - - spotatui.set_screen(SCREEN, { - -- scroll = false keeps the header pinned; PageUp/PageDown move the lyrics. - { type = "paragraph", height = 2, scroll = false, lines = { - { text = title, bold = true }, - { text = "Esc leaves", italic = true }, - } }, - { - type = "row", - children = { - { type = "cover_art", source = "current", fit = "contain", width_percent = 45 }, - { type = "paragraph", width_percent = 55, lines = lyric_lines(pb and pb.progress_ms or 0) }, - }, - }, - }) -end - -local function load_lyrics() - local uri = track_uri() - lyrics = {} - lyrics_status = "loading" - lyrics_track = uri - render() - - spotatui.get_lyrics(function(data, err) - -- The track may have changed while the fetch was in flight. - if err or not data or track_uri() ~= lyrics_track then - lyrics_status = "not_found" - render() - return - end - lyrics_status = data.status - lyrics = data.lines or {} - render() - end) -end - -spotatui.register_screen(SCREEN, { - title = "Now Playing", - on_key = function() - -- Esc and the back key are handled globally; nothing else to do here. - end, - on_open = function() - load_lyrics() - end, -}) - --- Advance the highlighted lyric line while the screen is open. -spotatui.set_interval(1000, function() - if spotatui.current_route() == "plugin:" .. SCREEN then - render() - end -end) - -spotatui.on("track_change", function() - if spotatui.current_route() == "plugin:" .. SCREEN then - load_lyrics() - end -end) - -spotatui.register_command("now_playing", function() - spotatui.show_screen(SCREEN) -end) diff --git a/examples/plugins/queue-browser.lua b/examples/plugins/queue-browser.lua deleted file mode 100644 index bc0678ce..00000000 --- a/examples/plugins/queue-browser.lua +++ /dev/null @@ -1,115 +0,0 @@ --- queue-browser: a custom plugin screen that lists the play queue and lets you --- queue more music from your library, showing off the v5 API (custom screens, --- async data reads, timers, storage, navigation). --- --- Install (single file; see PLUGINS.md for config path rules): --- cp queue-browser.lua "${XDG_CONFIG_HOME:-$HOME/.config}/spotatui/plugins/" --- --- Suggested binding, in config.yml in the spotatui app config directory: --- plugin_commands: --- queue_browser: "ctrl-b" --- --- Keys inside the screen: j/k move, enter queues the selected liked song, --- r refreshes, Esc leaves. - -spotatui.require_api(5) - -local SCREEN = "queue_browser" - -local queue_lines = {} -local liked = {} -- { { name = ..., uri = ... }, ... } -local selected = 1 -local status = "loading..." - -local function render() - local items = {} - for _, entry in ipairs(liked) do - items[#items + 1] = entry.name - end - if #items == 0 then - items = { { text = "(no liked songs loaded)", italic = true } } - end - - spotatui.set_screen(SCREEN, { - { - type = "paragraph", - height = 2, - lines = { - { text = "j/k move - enter queues the selected liked song - r refreshes - Esc leaves", italic = true }, - { text = status, fg = "Yellow" }, - }, - }, - { type = "paragraph", lines = queue_lines, height = #queue_lines + 1 }, - { type = "list", title = "Liked songs", items = items, selected = math.min(selected, math.max(#items, 1)) }, - }) -end - -local function refresh() - status = "refreshing..." - render() - - spotatui.get_queue(function(queue, err) - queue_lines = {} - if err then - queue_lines = { { text = "queue: " .. err, fg = "Red" } } - else - local now = queue.currently_playing - local now_name = now and (now.track and now.track.name or (now.episode and now.episode.name)) or "nothing" - queue_lines[1] = { text = "Now playing: " .. now_name, bold = true, fg = "Green" } - for i, item in ipairs(queue.items) do - if i > 5 then - queue_lines[#queue_lines + 1] = { text = (" ... and %d more"):format(#queue.items - 5), italic = true } - break - end - local name = item.track and item.track.name or (item.episode and item.episode.name) or "?" - queue_lines[#queue_lines + 1] = " " .. i .. ". " .. name - end - end - status = "" - render() - end) - - spotatui.get_saved_tracks(function(tracks, err) - if err then - status = "liked songs: " .. err - render() - return - end - liked = {} - for _, t in ipairs(tracks) do - if t.uri then - liked[#liked + 1] = { name = t.name .. " - " .. table.concat(t.artists, ", "), uri = t.uri } - end - end - status = #liked .. " liked songs (queued opens count: " .. (spotatui.storage_get("opens") or 0) .. ")" - render() - end) -end - -spotatui.register_screen(SCREEN, { - title = "Queue Browser", - on_key = function(key) - if key == "j" and selected < #liked then - selected = selected + 1 - render() - elseif key == "k" and selected > 1 then - selected = selected - 1 - render() - elseif key == "enter" and liked[selected] then - spotatui.add_to_queue(liked[selected].uri) - spotatui.notify("Queued: " .. liked[selected].name, 3) - -- The queue changes server-side; re-read it shortly after. - spotatui.set_timeout(1000, refresh) - elseif key == "r" then - refresh() - end - end, - on_open = function() - spotatui.storage_set("opens", (spotatui.storage_get("opens") or 0) + 1) - refresh() - end, -}) - -spotatui.register_command("queue_browser", function() - spotatui.show_screen(SCREEN) -end) diff --git a/examples/plugins/session-stats/main.lua b/examples/plugins/session-stats/main.lua deleted file mode 100644 index b10e9eb3..00000000 --- a/examples/plugins/session-stats/main.lua +++ /dev/null @@ -1,37 +0,0 @@ --- session-stats: counts tracks played this session and shows them in a popup. --- --- This is a *directory* plugin: it ships as a folder with an entry point (main.lua) plus a --- helper module (stats.lua). This is the layout `spotatui plugin add owner/repo` produces. --- --- Install (directory; see PLUGINS.md for config path rules): --- cp -r session-stats "${XDG_CONFIG_HOME:-$HOME/.config}/spotatui/plugins/" --- --- Suggested binding, in config.yml in the spotatui app config directory: --- plugin_commands: --- session_stats: "ctrl-s" - -local stats = require("stats") - -spotatui.on("track_change", function(pb) - if pb and pb.track then - stats.record(pb.track.name) - end -end) - -spotatui.register_command("session_stats", function() - local lines = { - { text = "Tracks played this session: " .. stats.count, bold = true, fg = "Green" }, - "", - } - if #stats.recent == 0 then - table.insert(lines, "No tracks played yet.") - else - table.insert(lines, { text = "Most recent:", fg = "Cyan" }) - for _, name in ipairs(stats.recent) do - table.insert(lines, " " .. name) - end - end - table.insert(lines, "") - table.insert(lines, "Press Esc to close") - spotatui.popup("Session stats", lines) -end) diff --git a/examples/plugins/session-stats/stats.lua b/examples/plugins/session-stats/stats.lua deleted file mode 100644 index 2188a879..00000000 --- a/examples/plugins/session-stats/stats.lua +++ /dev/null @@ -1,18 +0,0 @@ --- Helper module for the session-stats plugin. Loaded via `require("stats")`, which resolves --- against the plugin's own directory (spotatui adds it to package.path for directory plugins). - -local M = { - count = 0, - recent = {}, -} - -function M.record(name) - M.count = M.count + 1 - table.insert(M.recent, 1, name) - -- Keep only the last 10. - while #M.recent > 10 do - table.remove(M.recent) - end -end - -return M diff --git a/examples/plugins/track-info-popup.lua b/examples/plugins/track-info-popup.lua deleted file mode 100644 index 5bf1306e..00000000 --- a/examples/plugins/track-info-popup.lua +++ /dev/null @@ -1,31 +0,0 @@ --- track-info-popup: a `track_info` command that opens a popup with the current track details. --- --- Install (single file; see PLUGINS.md for config path rules): --- cp track-info-popup.lua "${XDG_CONFIG_HOME:-$HOME/.config}/spotatui/plugins/" --- --- Suggested binding, in config.yml in the spotatui app config directory: --- plugin_commands: --- track_info: "ctrl-i" - -spotatui.register_command("track_info", function() - local pb = spotatui.playback() - if not pb or not pb.track then - spotatui.notify("Nothing is playing", 3) - return - end - - local t = pb.track - local minutes = math.floor(t.duration_ms / 60000) - local seconds = math.floor((t.duration_ms % 60000) / 1000) - - spotatui.popup("Track info", { - { text = t.name, bold = true, fg = "Green" }, - { text = "by " .. table.concat(t.artists, ", "), fg = "Cyan" }, - { text = "on " .. t.album }, - "", - string.format("Length: %d:%02d", minutes, seconds), - { text = pb.is_playing and "Playing" or "Paused", fg = pb.is_playing and "Green" or "Yellow" }, - "", - "Press Esc to close", - }) -end) diff --git a/examples/plugins/track-notifier.lua b/examples/plugins/track-notifier.lua deleted file mode 100644 index afa8bca8..00000000 --- a/examples/plugins/track-notifier.lua +++ /dev/null @@ -1,20 +0,0 @@ --- track-notifier: show a "Now playing" toast and a playbar segment on every track change. --- --- Install (single file; see PLUGINS.md for config path rules): --- cp track-notifier.lua "${XDG_CONFIG_HOME:-$HOME/.config}/spotatui/plugins/" --- Then restart spotatui. - -spotatui.on("track_change", function(pb) - if pb and pb.track then - local artists = table.concat(pb.track.artists, ", ") - spotatui.notify("Now playing: " .. pb.track.name .. " - " .. artists, 4) - spotatui.set_playbar(pb.track.name) - else - -- Nothing playing: clear our playbar segment. - spotatui.set_playbar(nil) - end -end) - -spotatui.on("start", function() - spotatui.log("track-notifier loaded (api version " .. spotatui.api_version .. ")") -end) diff --git a/examples/theme_roundtrip.yml b/examples/theme_roundtrip.yml deleted file mode 100644 index 17ae2f25..00000000 --- a/examples/theme_roundtrip.yml +++ /dev/null @@ -1,23 +0,0 @@ -# Golden fixture for the theme round-trip test in src/core/user_config.rs. -# Exercises every on-disk color form the hand-rolled parser accepts: named -# ANSI colors (the three not used here are asserted directly in the test) -# and "r, g, b" triples. Do not reformat the values; the test asserts the -# saved config reproduces them byte-identically. -theme: - preset: "Custom" - active: "Reset" - banner: "Black" - error_border: "Red" - error_text: "LightRed" - hint: "Yellow" - hovered: "Magenta" - inactive: "DarkGray" - playbar_background: "Green" - playbar_progress: "Blue" - playbar_progress_text: "Cyan" - playbar_text: "Gray" - selected: "LightGreen" - text: "White" - background: "LightYellow" - header: "23, 43, 45" - highlighted_lyrics: "255, 145, 205" diff --git a/flake.nix b/flake.nix index 8b644654..e6e74ea8 100644 --- a/flake.nix +++ b/flake.nix @@ -1,5 +1,5 @@ { - description = "A Spotify client for the terminal written in Rust, powered by Ratatui"; + description = "An internet-radio terminal player with MPRIS control"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; flake-utils.url = "github:numtide/flake-utils"; @@ -55,8 +55,8 @@ }; inherit nativeBuildInputs buildInputs; meta = with pkgs.lib; { - description = "A Spotify client for the terminal written in Rust, powered by Ratatui"; - homepage = "https://github.com/LargeModGames/spotatui"; + description = "An internet-radio terminal player with MPRIS control"; + homepage = "https://github.com/ethereumdegen/degen-radio"; license = licenses.mit; mainProgram = "spotatui"; }; @@ -65,7 +65,7 @@ spotatui = self.packages.${system}.default; }; - # Execute with `nix run github:LargeModGames/spotatui` + # Execute with `nix run github:ethereumdegen/degen-radio` apps = { default = { type = "app"; diff --git a/how_to_release.md b/how_to_release.md deleted file mode 100644 index c8978208..00000000 --- a/how_to_release.md +++ /dev/null @@ -1,40 +0,0 @@ -# To create a release - -Releases are automated via GitHub Actions using [`.github/workflows/cd.yml`](https://github.com/LargeModGames/spotatui/blob/master/.github/workflows/cd.yml). - -The workflow runs when you push a tag. - -## Stable release - -1. Bump the version in `Cargo.toml` and run the app to update `Cargo.lock`. -2. Update the "Unreleased" header for the new version in `CHANGELOG.md` (`### Added/Fixed/Changed` as appropriate). -3. Commit and push your changes. -4. Create an annotated tag with a message (the tag message is shown on the GitHub release page): - - `git tag -a v0.7.0 -m "Release v0.7.0"` -5. Push that specific tag: - - `git push origin v0.7.0` -6. Wait for the build on the [Actions page](https://github.com/LargeModGames/spotatui/actions). -7. Stable tags (no suffix) also trigger publish jobs (crates.io, AUR, winget, Homebrew). - -## Pre-release (recommended for canary testing) - -Use a SemVer pre-release tag like `v0.7.0-rc1` / `v0.7.0-beta.1`. - -1. Create an annotated pre-release tag with tester notes: - - `git tag -a v0.7.0-rc1 -m "RC1: canary build for Spotify API migration"` -2. Push that specific tag: - - `git push origin v0.7.0-rc1` -3. The workflow will automatically mark the GitHub release as `prerelease: true`. -4. Pre-release tags skip ecosystem publishing jobs (crates.io, AUR, winget, Homebrew). - -### Homebrew Packaging - -Homebrew publishing is automated via the CD workflow. When you push a new tag: - -1. The `publish-homebrew` job downloads the release artifacts -2. Calculates SHA256 checksums for each platform binary -3. Updates the formula in [homebrew-spotatui](https://github.com/LargeModGames/homebrew-spotatui) - - - -TODO: Scoop packaging is not yet set up for spotatui. If you'd like to contribute packaging, PRs are welcome! diff --git a/install.cmd b/install.cmd index 9afc31ee..a0d9e0f6 100755 --- a/install.cmd +++ b/install.cmd @@ -1,7 +1,7 @@ @echo off -REM spotatui installer for Windows CMD. +REM degen-radio installer for Windows CMD. REM -REM curl -fsSL https://spotatui.com/install.cmd -o install.cmd && install.cmd +REM curl -fsSL https://raw.githubusercontent.com/ethereumdegen/degen-radio/main/install.cmd -o install.cmd && install.cmd REM REM Delegates to the PowerShell installer so there is a single source of truth. -powershell -NoProfile -ExecutionPolicy Bypass -Command "irm https://spotatui.com/install.ps1 | iex" +powershell -NoProfile -ExecutionPolicy Bypass -Command "irm https://raw.githubusercontent.com/ethereumdegen/degen-radio/main/install.ps1 | iex" diff --git a/install.ps1 b/install.ps1 index f5a6e0ff..2964cf27 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,8 +1,8 @@ <# .SYNOPSIS - spotatui installer for Windows. + degen-radio installer for Windows. - irm https://spotatui.com/install.ps1 | iex + irm https://raw.githubusercontent.com/ethereumdegen/degen-radio/main/install.ps1 | iex Environment overrides: SPOTATUI_VERSION install a specific tag (e.g. v0.40.3); default: latest @@ -11,7 +11,7 @@ #> $ErrorActionPreference = 'Stop' -$Repo = 'LargeModGames/spotatui' +$Repo = 'ethereumdegen/degen-radio' $Binary = 'spotatui' $InstallDir = if ($env:SPOTATUI_INSTALL_DIR) { $env:SPOTATUI_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA 'spotatui\bin' } diff --git a/install.sh b/install.sh index e53eafc4..80fe43af 100755 --- a/install.sh +++ b/install.sh @@ -1,14 +1,14 @@ #!/usr/bin/env bash -# spotatui installer for macOS, Linux, and WSL. +# degen-radio installer for macOS, Linux, and WSL. # -# curl -fsSL https://spotatui.com/install.sh | bash +# curl -fsSL https://raw.githubusercontent.com/ethereumdegen/degen-radio/main/install.sh | bash # # Environment overrides: # SPOTATUI_VERSION install a specific tag (e.g. v0.40.3); default: latest # SPOTATUI_INSTALL_DIR where to put the binary; default: $HOME/.local/bin set -euo pipefail -REPO="LargeModGames/spotatui" +REPO="ethereumdegen/degen-radio" BINARY="spotatui" INSTALL_DIR="${SPOTATUI_INSTALL_DIR:-$HOME/.local/bin}" diff --git a/snap/gui/spt.desktop b/snap/gui/spt.desktop deleted file mode 100644 index 7de388a2..00000000 --- a/snap/gui/spt.desktop +++ /dev/null @@ -1,9 +0,0 @@ -[Desktop Entry] -Name=Spotify TUI -Exec=spt -Comment=Spotify for the terminal written in Rust -Icon=${SNAP}/meta/gui/spt.png -Type=Application -Terminal=true -StartupNotify=false -Categories=Music; diff --git a/snap/gui/spt.png b/snap/gui/spt.png deleted file mode 100644 index c64dc90867e575741523b1ef5d7dea1e8c78474e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 476640 zcmV*bKvchpP)004R>004l5008;`004mK004C`008P>0026e000+ooVrmw00006 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

^{LvR#if=IZYXkUf2Gm*s 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%0DyP6bFoZ);qEPh7t$x7Q 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

x2;pu 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 diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml deleted file mode 100644 index e3f87428..00000000 --- a/snap/snapcraft.yaml +++ /dev/null @@ -1,46 +0,0 @@ -name: spt-temp -base: core18 -adopt-info: spotify-tui -summary: Spotify TUI -description: | - Spotify for the terminal written in Rust - -grade: stable -confinement: strict - -parts: - spotify-tui: - plugin: rust - source: . - build-packages: - - libssl-dev - - pkg-config - - libxcb1-dev - - libxcb-render0-dev - - libxcb-shape0-dev - - libxcb-xfixes0-dev - stage-packages: - - libxau6 - - libxcb-render0 - - libxcb-shape0 - - libxcb-xfixes0 - - libxcb1 - - libxdmcp6 - override-pull: | - snapcraftctl pull - last_committed_tag="$(git describe --tags --abbrev=0)" - echo $last_committed_tag - VERSION="$(git describe --first-parent --tags --always)" - echo "Setting version to $VERSION" - snapcraftctl set-version "${VERSION}" - -apps: - spt-temp: - environment: - LD_LIBRARY_PATH: $SNAP/usr/lib/$SNAPCRAFT_ARCH_TRIPLET/pulseaudio:$LD_LIBRARY_PATH - command: spt - plugs: - - desktop - - network - - network-bind - - pulseaudio diff --git a/spotatui.wiki b/spotatui.wiki deleted file mode 160000 index 11bf7af5..00000000 --- a/spotatui.wiki +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 11bf7af5a37537793fb191d870ede543966a1429 diff --git a/src/bin/spotatui-gui.rs b/src/bin/spotatui-gui.rs deleted file mode 100644 index c8f406e4..00000000 --- a/src/bin/spotatui-gui.rs +++ /dev/null @@ -1,39 +0,0 @@ -//! Windowed-binary shim for the future GUI frontend. -//! -//! Exists now so the binary name, the `windows_subsystem` attribute (a -//! crate-root attribute that cannot vary at runtime), and the console-attach -//! plumbing are settled before the frontend lands. Requires the `gui` cargo -//! feature, which nothing enables by default. -#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")] - -fn main() { - // A windowed binary detaches from the console; re-attach to the parent's so - // `spotatui-gui --version` still prints when run from a shell. - #[cfg(windows)] - attach_parent_console(); - - if std::env::args().any(|arg| arg == "--version" || arg == "-V") { - println!("spotatui-gui {}", env!("CARGO_PKG_VERSION")); - return; - } - - eprintln!( - "spotatui-gui: the GUI frontend has not landed yet; run `spotatui` for the terminal UI" - ); - std::process::exit(1); -} - -#[cfg(windows)] -fn attach_parent_console() { - #[link(name = "kernel32")] - extern "system" { - fn AttachConsole(process_id: u32) -> i32; - } - // (DWORD)-1, i.e. ATTACH_PARENT_PROCESS. - const ATTACH_PARENT_PROCESS: u32 = u32::MAX; - // Failure just means there is no parent console (launched from the - // desktop), which is exactly the case the windowed subsystem exists for. - unsafe { - AttachConsole(ATTACH_PARENT_PROCESS); - } -} diff --git a/src/cli/CLAUDE.md b/src/cli/CLAUDE.md deleted file mode 100644 index 7b448c05..00000000 --- a/src/cli/CLAUDE.md +++ /dev/null @@ -1,11 +0,0 @@ -### CLI subcommands - -Subcommands are assembled in `src/runtime/cli.rs::build_clap_app` and -dispatched in `src/runtime/mod.rs::run_cli`, where **dispatch order encodes -auth requirements**: `history`, `mcp`, and `plugin` return before config/auth -load; playback commands require a Spotify session. -Place new subcommands deliberately. stdout is protocol/output-only (`mcp` writes -JSON-RPC, `history recap` writes HTML) - diagnostics go to stderr; never add a -`println!` on a path a subcommand can reach. Self-update needs `User-Agent` + -`Accept: application/octet-stream` on GitHub asset requests and must not re-exec -during authentication (OAuth port deadlock). diff --git a/src/cli/clap.rs b/src/cli/clap.rs deleted file mode 100644 index fb4995d9..00000000 --- a/src/cli/clap.rs +++ /dev/null @@ -1,400 +0,0 @@ -use clap::{builder::ArgPredicate, Arg, ArgAction, ArgGroup, Command}; - -fn device_arg() -> Arg { - Arg::new("device") - .short('d') - .long("device") - .value_name("DEVICE") - .help("Specifies the spotify device to use") -} - -fn format_arg() -> Arg { - Arg::new("format") - .short('f') - .long("format") - .value_name("FORMAT") - .help("Specifies the output format") - .long_help( - "There are multiple format specifiers you can use: %a: artist, %b: album, %p: playlist, \ -%t: track, %h: show, %f: flags (shuffle, repeat, like), %s: playback status, %v: volume, %d: current device. \ -Example: spt pb -s -f 'playing on %d at %v%'", - ) -} - -pub fn playback_subcommand() -> Command { - Command::new("playback") - .version(env!("CARGO_PKG_VERSION")) - .author(env!("CARGO_PKG_AUTHORS")) - .about("Interacts with the playback of a device") - .long_about( - "Use `playback` to interact with the playback of the current or any other device. \ -You can specify another device with `--device`. If no options were provided, spt \ -will default to just displaying the current playback. Actually, after every action \ -spt will display the updated playback. The output format is configurable with the \ -`--format` flag. Some options can be used together, other options have to be alone. - -Here's a list: - -* `--next` and `--previous` cannot be used with other options -* `--status`, `--toggle`, `--transfer`, `--volume`, `--like`, `--repeat` and `--shuffle` \ -can be used together -* `--share-track` and `--share-album` cannot be used with other options", - ) - .visible_alias("pb") - .arg(device_arg()) - .arg( - format_arg() - .default_value("%f %s %t - %a") - .default_value_if("seek", ArgPredicate::IsPresent, "%f %s %t - %a %r") - .default_value_if("volume", ArgPredicate::IsPresent, "%v% %f %s %t - %a") - .default_value_if("transfer", ArgPredicate::IsPresent, "%f %s %t - %a on %d"), - ) - .arg( - Arg::new("toggle") - .short('t') - .long("toggle") - .action(ArgAction::SetTrue) - .help("Pauses/resumes the playback of a device"), - ) - .arg( - Arg::new("status") - .short('s') - .long("status") - .action(ArgAction::SetTrue) - .help("Prints out the current status of a device (default)"), - ) - .arg( - Arg::new("share-track") - .long("share-track") - .action(ArgAction::SetTrue) - .help("Returns the url to the current track"), - ) - .arg( - Arg::new("share-album") - .long("share-album") - .action(ArgAction::SetTrue) - .help("Returns the url to the album of the current track"), - ) - .arg( - Arg::new("transfer") - .long("transfer") - .value_name("DEVICE") - .help("Transfers the playback to new DEVICE"), - ) - .arg( - Arg::new("like") - .long("like") - .action(ArgAction::SetTrue) - .help("Likes the current song if possible"), - ) - .arg( - Arg::new("dislike") - .long("dislike") - .action(ArgAction::SetTrue) - .help("Dislikes the current song if possible"), - ) - .arg( - Arg::new("shuffle") - .long("shuffle") - .action(ArgAction::SetTrue) - .help("Toggles shuffle mode"), - ) - .arg( - Arg::new("repeat") - .long("repeat") - .action(ArgAction::SetTrue) - .help("Switches between repeat modes"), - ) - .arg( - Arg::new("next") - .short('n') - .long("next") - .action(ArgAction::Count) - .help("Jumps to the next song") - .long_help( - "This jumps to the next song if specied once. If you want to jump, let's say 3 songs \ -forward, you can use `--next` 3 times: `spt pb -nnn`.", - ), - ) - .arg( - Arg::new("previous") - .short('p') - .long("previous") - .action(ArgAction::Count) - .help("Jumps to the previous song") - .long_help( - "This jumps to the beginning of the current song if specied once. You probably want to \ -jump to the previous song though, so you can use the previous flag twice: `spt pb -pp`. To jump \ -two songs back, you can use `spt pb -ppp` and so on.", - ), - ) - .arg( - Arg::new("seek") - .long("seek") - .value_name("±SECONDS") - .allow_hyphen_values(true) - .help("Jumps SECONDS forwards (+) or backwards (-)") - .long_help( - "For example: `spt pb --seek +10` jumps ten second forwards, `spt pb --seek -10` ten \ -seconds backwards and `spt pb --seek 10` to the tenth second of the track.", - ), - ) - .arg( - Arg::new("volume") - .short('v') - .long("volume") - .value_name("VOLUME") - .help("Sets the volume of a device to VOLUME (1 - 100)"), - ) - .group( - ArgGroup::new("jumps") - .args(["next", "previous"]) - .multiple(false) - .conflicts_with_all(["single", "flags", "actions"]), - ) - .group( - ArgGroup::new("likes") - .args(["like", "dislike"]) - .multiple(false), - ) - .group( - ArgGroup::new("flags") - .args(["like", "dislike", "shuffle", "repeat"]) - .multiple(true) - .conflicts_with_all(["single", "jumps"]), - ) - .group( - ArgGroup::new("actions") - .args(["toggle", "status", "transfer", "volume"]) - .multiple(true) - .conflicts_with_all(["single", "jumps"]), - ) - .group( - ArgGroup::new("single") - .args(["share-track", "share-album"]) - .multiple(false) - .conflicts_with_all(["actions", "flags", "jumps"]), - ) -} - -pub fn play_subcommand() -> Command { - Command::new("play") - .version(env!("CARGO_PKG_VERSION")) - .author(env!("CARGO_PKG_AUTHORS")) - .about("Plays a uri or another spotify item by name") - .long_about( - "If you specify a uri, the type can be inferred. If you want to play something by \ -name, you have to specify the type: `--track`, `--album`, `--artist`, `--playlist` \ -or `--show`. The first item which was found will be played without confirmation. \ -To add a track to the queue, use `--queue`. To play a random song from a playlist, \ -use `--random`. Again, with `--format` you can specify how the output will look. \ -The same function as found in `playback` will be called.", - ) - .visible_alias("p") - .arg(device_arg()) - .arg(format_arg().default_value("%f %s %t - %a")) - .arg( - Arg::new("uri") - .short('u') - .long("uri") - .value_name("URI") - .help("Plays the URI"), - ) - .arg( - Arg::new("name") - .short('n') - .long("name") - .value_name("NAME") - .requires("contexts") - .help("Plays the first match with NAME from the specified category"), - ) - .arg( - Arg::new("queue") - .short('q') - .long("queue") - .action(ArgAction::SetTrue) - // Only works with tracks - .conflicts_with_all(["album", "artist", "playlist", "show"]) - .help("Adds track to queue instead of playing it directly"), - ) - .arg( - Arg::new("random") - .short('r') - .long("random") - .action(ArgAction::SetTrue) - // Only works with playlists - .conflicts_with_all(["track", "album", "artist", "show"]) - .help("Plays a random track (only works with playlists)"), - ) - .arg( - Arg::new("album") - .short('b') - .long("album") - .action(ArgAction::SetTrue) - .help("Looks for an album"), - ) - .arg( - Arg::new("artist") - .short('a') - .long("artist") - .action(ArgAction::SetTrue) - .help("Looks for an artist"), - ) - .arg( - Arg::new("track") - .short('t') - .long("track") - .action(ArgAction::SetTrue) - .help("Looks for a track"), - ) - .arg( - Arg::new("show") - .short('w') - .long("show") - .action(ArgAction::SetTrue) - .help("Looks for a show"), - ) - .arg( - Arg::new("playlist") - .short('p') - .long("playlist") - .action(ArgAction::SetTrue) - .help("Looks for a playlist"), - ) - .group( - ArgGroup::new("contexts") - .args(["track", "artist", "playlist", "album", "show"]) - .multiple(false), - ) - .group( - ArgGroup::new("actions") - .args(["uri", "name"]) - .multiple(false) - .required(true), - ) -} - -pub fn list_subcommand() -> Command { - Command::new("list") - .version(env!("CARGO_PKG_VERSION")) - .author(env!("CARGO_PKG_AUTHORS")) - .about("Lists devices, liked songs and playlists") - .long_about( - "This will list devices, liked songs or playlists. With the `--limit` flag you are \ -able to specify the amount of results (between 1 and 50). Here, the `--format` is \ -even more awesome, get your output exactly the way you want. The format option will \ -be applied to every item found.", - ) - .visible_alias("l") - .arg( - format_arg() - .default_value_if("devices", ArgPredicate::IsPresent, "%v% %d") - .default_value_if("liked", ArgPredicate::IsPresent, "%t - %a (%u)") - .default_value_if("playlists", ArgPredicate::IsPresent, "%p (%u)"), - ) - .arg( - Arg::new("devices") - .short('d') - .long("devices") - .action(ArgAction::SetTrue) - .help("Lists devices"), - ) - .arg( - Arg::new("playlists") - .short('p') - .long("playlists") - .action(ArgAction::SetTrue) - .help("Lists playlists"), - ) - .arg( - Arg::new("liked") - .long("liked") - .action(ArgAction::SetTrue) - .help("Lists liked songs"), - ) - .arg( - Arg::new("limit") - .long("limit") - .help("Specifies the maximum number of results (1 - 50)"), - ) - .group( - ArgGroup::new("listable") - .args(["devices", "playlists", "liked"]) - .required(true) - .multiple(false), - ) -} - -pub fn search_subcommand() -> Command { - Command::new("search") - .version(env!("CARGO_PKG_VERSION")) - .author(env!("CARGO_PKG_AUTHORS")) - .about("Searches for tracks, albums and more") - .long_about( - "This will search for something on spotify and displays you the items. The output \ -format can be changed with the `--format` flag and the limit can be changed with \ -the `--limit` flag (between 1 and 10). The type can't be inferred, so you have to \ -specify it.", - ) - .visible_alias("s") - .arg( - format_arg() - .default_value_if("tracks", ArgPredicate::IsPresent, "%t - %a (%u)") - .default_value_if("playlists", ArgPredicate::IsPresent, "%p (%u)") - .default_value_if("artists", ArgPredicate::IsPresent, "%a (%u)") - .default_value_if("albums", ArgPredicate::IsPresent, "%b - %a (%u)") - .default_value_if("shows", ArgPredicate::IsPresent, "%h - %a (%u)"), - ) - .arg( - Arg::new("search") - .required(true) - .value_name("SEARCH") - .help("Specifies the search query"), - ) - .arg( - Arg::new("albums") - .short('b') - .long("albums") - .action(ArgAction::SetTrue) - .help("Looks for albums"), - ) - .arg( - Arg::new("artists") - .short('a') - .long("artists") - .action(ArgAction::SetTrue) - .help("Looks for artists"), - ) - .arg( - Arg::new("playlists") - .short('p') - .long("playlists") - .action(ArgAction::SetTrue) - .help("Looks for playlists"), - ) - .arg( - Arg::new("tracks") - .short('t') - .long("tracks") - .action(ArgAction::SetTrue) - .help("Looks for tracks"), - ) - .arg( - Arg::new("shows") - .short('w') - .long("shows") - .action(ArgAction::SetTrue) - .help("Looks for shows"), - ) - .arg( - Arg::new("limit") - .long("limit") - .help("Specifies the maximum number of results (1 - 10)"), - ) - .group( - ArgGroup::new("searchable") - .args(["playlists", "tracks", "albums", "artists", "shows"]) - .required(true) - .multiple(false), - ) -} diff --git a/src/cli/cli_app.rs b/src/cli/cli_app.rs deleted file mode 100644 index e3002131..00000000 --- a/src/cli/cli_app.rs +++ /dev/null @@ -1,725 +0,0 @@ -use crate::core::user_config::UserConfig; -use crate::infra::network::{IoEvent, Network}; - -use super::util::{Flag, Format, FormatType, JumpDirection, Type}; - -use anyhow::{anyhow, Result}; -use rspotify::model::{ - context::CurrentPlaybackContext, idtypes::Id, playlist::FullPlaylist, PlayableItem, -}; - -pub struct CliApp { - pub net: Network, - pub config: UserConfig, -} - -// Non-concurrent functions -// I feel that async in a cli is not working -// I just .await all processes and directly interact -// by calling network.handle_network_event -impl CliApp { - pub fn new(net: Network, config: UserConfig) -> Self { - Self { net, config } - } - - async fn is_a_saved_track(&mut self, id: &str) -> Result { - // Tracks without an ID (e.g. local files) can't be saved; asking the API - // about `spotify:track:` would only fail the whole status output. - if id.is_empty() { - return Ok(false); - } - // The IoEvent handler defers to a detached worker (it must not block the - // TUI's serial pump); the CLI needs the answer before returning. - self.net.resolve_liked_state_now(&[id.to_string()]).await?; - Ok(self.net.app.lock().await.liked_song_ids_set.contains(id)) - } - - pub fn format_output(&self, mut format: String, values: Vec) -> String { - for val in values { - format = format.replace(val.get_placeholder(), &val.inner(self.config.clone())); - } - // Replace unsupported flags with 'None' - for p in &["%a", "%b", "%t", "%p", "%h", "%u", "%d", "%v", "%f", "%s"] { - format = format.replace(p, "None"); - } - format.trim().to_string() - } - - // spt playback -t - pub async fn toggle_playback(&mut self) { - let context = self.net.app.lock().await.current_playback_context.clone(); - if let Some(c) = context { - if c.is_playing { - self.net.handle_network_event(IoEvent::PausePlayback).await; - return; - } - } - self - .net - .handle_network_event(IoEvent::StartPlayback(None, None, None)) - .await; - } - - // spt pb --share-track (share the current playing song) - // Basically copy-pasted the 'copy_song_url' function - pub async fn share_track_or_episode(&mut self) -> Result { - let app = self.net.app.lock().await; - if let Some(CurrentPlaybackContext { - item: Some(item), .. - }) = &app.current_playback_context - { - match item { - PlayableItem::Track(track) => { - if let Some(id) = &track.id { - Ok(format!("https://open.spotify.com/track/{}", id.id())) - } else { - Err(anyhow!("track has no ID")) - } - } - PlayableItem::Episode(episode) => Ok(format!( - "https://open.spotify.com/episode/{}", - episode.id.id() - )), - _ => Err(anyhow!("unknown playable item type")), - } - } else { - Err(anyhow!( - "failed to generate a shareable url for the current song" - )) - } - } - - // spt pb --share-album (share the current album) - // Basically copy-pasted the 'copy_album_url' function - pub async fn share_album_or_show(&mut self) -> Result { - let app = self.net.app.lock().await; - if let Some(CurrentPlaybackContext { - item: Some(item), .. - }) = &app.current_playback_context - { - match item { - PlayableItem::Track(track) => { - if let Some(id) = &track.album.id { - Ok(format!("https://open.spotify.com/album/{}", id.id())) - } else { - Err(anyhow!("album has no ID")) - } - } - PlayableItem::Episode(episode) => Ok(format!( - "https://open.spotify.com/show/{}", - episode.show.id.id() - )), - _ => Err(anyhow!("unknown playable item type")), - } - } else { - Err(anyhow!( - "failed to generate a shareable url for the current song" - )) - } - } - - // spt ... -d ... (specify device to control) - pub async fn set_device(&mut self, name: String) -> Result<()> { - // Change the device if specified by user - let app = self.net.app.lock().await; - if let Some(dp) = &app.devices { - for d in &dp.devices { - if d.name == name { - // Save the id of the device - if let Some(id) = d.id.clone() { - self - .net - .client_config - .set_device_id(id) - .map_err(|_e| anyhow!("failed to use device with name '{}'", d.name))?; - } - } - } - } else { - // Error out if no device is available - return Err(anyhow!("no device available")); - } - Ok(()) - } - - // spt query ... --limit LIMIT (set max search limit) - pub async fn update_query_limits(&mut self, max: String, ceiling: u32) -> Result<()> { - let num = parse_query_limit(&max, ceiling)?; - - self - .net - .handle_network_event(IoEvent::UpdateSearchLimits(num, num)) - .await; - Ok(()) - } - - pub async fn volume(&mut self, vol: String) -> Result<()> { - let num = vol - .parse::() - .map_err(|_e| anyhow!("volume must be between 0 and 100"))?; - - // Check if it's in range - if num > 100 { - return Err(anyhow!("volume must be between 0 and 100")); - }; - - self - .net - .handle_network_event(IoEvent::ChangeVolume(num as u8)) - .await; - Ok(()) - } - - // spt playback --next / --previous - pub async fn jump(&mut self, d: &JumpDirection) { - match d { - JumpDirection::Next => self.net.handle_network_event(IoEvent::NextTrack).await, - JumpDirection::Previous => self.net.handle_network_event(IoEvent::PreviousTrack).await, - } - } - - // spt query -l ... - pub async fn list(&mut self, item: Type, format: &str) -> String { - match item { - Type::Device => { - if let Some(devices) = &self.net.app.lock().await.devices { - devices - .devices - .iter() - .map(|d| { - self.format_output( - format.to_string(), - vec![ - Format::Device(d.name.clone()), - Format::Volume(d.volume_percent.unwrap_or(0)), - ], - ) - }) - .collect::>() - .join("\n") - } else { - "No devices available".to_string() - } - } - Type::Playlist => { - self.net.handle_network_event(IoEvent::GetPlaylists).await; - if let Some(playlists) = &self.net.app.lock().await.playlists { - playlists - .items - .iter() - .map(|p| { - self.format_output( - format.to_string(), - Format::from_type(FormatType::PlaylistInfo(Box::new(p.clone()))), - ) - }) - .collect::>() - .join("\n") - } else { - "No playlists found".to_string() - } - } - Type::Liked => { - self - .net - .handle_network_event(IoEvent::GetCurrentSavedTracks(None)) - .await; - let liked_songs = self - .net - .app - .lock() - .await - .track_table - .tracks - .iter() - .map(|t| { - self.format_output( - format.to_string(), - Format::from_type(FormatType::TrackInfo(Box::new(t.clone()))), - ) - }) - .collect::>(); - // Check if there are any liked songs - if liked_songs.is_empty() { - "No liked songs found".to_string() - } else { - liked_songs.join("\n") - } - } - // Enforced by clap - _ => unreachable!(), - } - } - - // spt playback --transfer DEVICE - pub async fn transfer_playback(&mut self, device: &str) -> Result<()> { - // Get the device id by name - let mut id = String::new(); - if let Some(devices) = &self.net.app.lock().await.devices { - for d in &devices.devices { - if d.name == device { - if let Some(device_id) = &d.id { - id.push_str(device_id); - break; - } - break; - } - } - }; - - if id.is_empty() { - Err(anyhow!("no device with name '{}'", device)) - } else { - self - .net - .handle_network_event(IoEvent::TransferPlaybackToDevice(id.to_string(), true)) - .await; - Ok(()) - } - } - - pub async fn seek(&mut self, seconds_str: String) -> Result<()> { - let seconds = match seconds_str.parse::() { - Ok(s) => s.unsigned_abs(), - Err(_) => return Err(anyhow!("failed to convert seconds to i32")), - }; - - let (current_pos, duration) = { - self - .net - .handle_network_event(IoEvent::GetCurrentPlayback) - .await; - let app = self.net.app.lock().await; - if let Some(CurrentPlaybackContext { - progress: Some(ms), - item: Some(item), - .. - }) = &app.current_playback_context - { - let duration = match item { - PlayableItem::Track(track) => track.duration.num_milliseconds() as u32, - PlayableItem::Episode(episode) => episode.duration.num_milliseconds() as u32, - _ => return Err(anyhow!("unknown playable item type")), - }; - - (ms.num_milliseconds() as u32, duration) - } else { - return Err(anyhow!("no context available")); - } - }; - - // Convert secs to ms - let ms = seconds * 1000; - // Calculate new positon - let position_to_seek = if seconds_str.starts_with('+') { - current_pos + ms - } else if seconds_str.starts_with('-') { - // Jump to the beginning if the position_to_seek would be - // negative, must be checked before the calculation to avoid - // an 'underflow' - current_pos.saturating_sub(ms) - } else { - // Absolute value of the track - seconds * 1000 - }; - - // Check if position_to_seek is greater than duration (next track) - if position_to_seek > duration { - self.jump(&JumpDirection::Next).await; - } else { - // This seeks to a position in the current song - self - .net - .handle_network_event(IoEvent::Seek(position_to_seek)) - .await; - } - - Ok(()) - } - - // spt playback --like / --dislike / --shuffle / --repeat - pub async fn mark(&mut self, flag: Flag) -> Result<()> { - let c = { - let app = self.net.app.lock().await; - app - .current_playback_context - .clone() - .ok_or_else(|| anyhow!("no context available"))? - }; - - match flag { - Flag::Like(s) => { - // Get the id of the current song - let id = match c.item { - Some(i) => match i { - PlayableItem::Track(t) => t.id.ok_or_else(|| anyhow!("item has no id")), - PlayableItem::Episode(_) => Err(anyhow!("saving episodes not yet implemented")), - _ => Err(anyhow!("unknown playable item type")), - }, - None => Err(anyhow!("no item playing")), - }?; - - let id_string = id.id().to_string(); - // Want to like but is already liked -> do nothing - // Want to like and is not liked yet -> like - if s && !self.is_a_saved_track(&id_string).await? { - self - .net - .handle_network_event(IoEvent::ToggleSaveTrack(id.uri())) - .await; - // Want to dislike but is already disliked -> do nothing - // Want to dislike and is liked currently -> remove like - } else if !s && self.is_a_saved_track(&id_string).await? { - self - .net - .handle_network_event(IoEvent::ToggleSaveTrack(id.uri())) - .await; - } - } - Flag::Shuffle => { - self - .net - .handle_network_event(IoEvent::Shuffle(!c.shuffle_state)) - .await - } - Flag::Repeat => { - self - .net - .handle_network_event(IoEvent::Repeat(c.repeat_state)) - .await; - } - } - - Ok(()) - } - - // spt playback -s - pub async fn get_status(&mut self, format: String) -> Result { - // Update info on current playback - self - .net - .handle_network_event(IoEvent::GetCurrentPlayback) - .await; - self - .net - .handle_network_event(IoEvent::GetCurrentSavedTracks(None)) - .await; - - let context = self - .net - .app - .lock() - .await - .current_playback_context - .clone() - .ok_or_else(|| anyhow!("no context available"))?; - - let playing_item = context.item.ok_or_else(|| anyhow!("no track playing"))?; - - let mut hs = match playing_item { - PlayableItem::Track(track) => { - let id = track - .id - .clone() - .map(|track_id| track_id.id().to_string()) - .unwrap_or_default(); - let mut hs = Format::from_type(FormatType::Track(Box::new(track.clone()))); - if let Some(ms) = &context.progress { - hs.push(Format::Position(( - ms.num_milliseconds() as u32, - track.duration.num_milliseconds() as u32, - ))) - } - hs.push(Format::Flags(( - context.repeat_state, - context.shuffle_state, - self.is_a_saved_track(&id).await?, - ))); - hs - } - PlayableItem::Episode(episode) => { - let mut hs = Format::from_type(FormatType::Episode(Box::new(episode.clone()))); - if let Some(ms) = &context.progress { - hs.push(Format::Position(( - ms.num_milliseconds() as u32, - episode.duration.num_milliseconds() as u32, - ))) - } - hs.push(Format::Flags(( - context.repeat_state, - context.shuffle_state, - false, - ))); - hs - } - _ => return Err(anyhow!("unknown playable item type")), - }; - - hs.push(Format::Device(context.device.name)); - hs.push(Format::Volume(context.device.volume_percent.unwrap_or(0))); - hs.push(Format::Playing(context.is_playing)); - - Ok(self.format_output(format, hs)) - } - - // spt play -u URI - pub async fn play_uri(&mut self, uri: String, queue: bool, random: bool) { - let offset = if random { - // Only works with playlists for now - if uri.contains("spotify:playlist:") { - let id_str = uri.split(':').next_back().unwrap(); - if let Ok(playlist_id) = rspotify::model::idtypes::PlaylistId::from_id(id_str) { - match self - .net - .spotify_get_typed::(&format!("playlists/{}", playlist_id.id()), &[]) - .await - { - Ok(p) => { - let num = p.items.total; - Some(rand::random_range(0..num) as usize) - } - Err(e) => { - self - .net - .app - .lock() - .await - .handle_error(anyhow!(e.to_string())); - return; - } - } - } else { - None - } - } else { - None - } - } else { - None - }; - - // The network boundary reconstructs the typed rspotify id from the URI. - if uri.contains("spotify:track:") { - if queue { - self - .net - .handle_network_event(IoEvent::AddItemToQueue(uri)) - .await; - } else { - self - .net - .handle_network_event(IoEvent::StartPlayback(None, Some(vec![uri]), Some(0))) - .await; - } - } else if uri.contains("spotify:playlist:") - || uri.contains("spotify:album:") - || uri.contains("spotify:artist:") - || uri.contains("spotify:show:") - { - // Context URIs (playlist, album, artist, show) - self - .net - .handle_network_event(IoEvent::StartPlayback(Some(uri), None, offset)) - .await; - } - } - - // spt play -n NAME ... - pub async fn play(&mut self, name: String, item: Type, queue: bool, random: bool) -> Result<()> { - self - .net - .handle_network_event(IoEvent::GetSearchResults(name.clone(), None)) - .await; - // Get the uri of the first found - // item + the offset or return an error message - let uri = { - let results = &self.net.app.lock().await.search_results; - match item { - Type::Track => { - if let Some(r) = &results.tracks { - if let Some(ref id) = r.items[0].id { - format!("spotify:track:{}", id) - } else { - return Err(anyhow!("track has no id")); - } - } else { - return Err(anyhow!("no tracks with name '{}'", name)); - } - } - Type::Album => { - if let Some(r) = &results.albums { - let album = &r.items[0]; - if let Some(ref id) = album.id { - format!("spotify:album:{}", id) - } else { - return Err(anyhow!("album {} has no id", album.name)); - } - } else { - return Err(anyhow!("no albums with name '{}'", name)); - } - } - Type::Artist => { - if let Some(r) = &results.artists { - if let Some(ref id) = r.items[0].id { - format!("spotify:artist:{}", id) - } else { - return Err(anyhow!("artist has no id")); - } - } else { - return Err(anyhow!("no artists with name '{}'", name)); - } - } - Type::Show => { - if let Some(r) = &results.shows { - if let Some(ref id) = r.items[0].id { - format!("spotify:show:{}", id) - } else { - return Err(anyhow!("show has no id")); - } - } else { - return Err(anyhow!("no shows with name '{}'", name)); - } - } - Type::Playlist => { - if let Some(r) = &results.playlists { - let p = &r.items[0]; - if let Some(ref id) = p.id { - format!("spotify:playlist:{}", id) - } else { - return Err(anyhow!("playlist has no id")); - } - } else { - return Err(anyhow!("no playlists with name '{}'", name)); - } - } - _ => unreachable!(), - } - }; - - // Play or queue the uri - self.play_uri(uri, queue, random).await; - - Ok(()) - } - - // spt query -s SEARCH ... - pub async fn query(&mut self, search: String, format: String, item: Type) -> String { - self - .net - .handle_network_event(IoEvent::GetSearchResults(search.clone(), None)) - .await; - - let app = self.net.app.lock().await; - match item { - Type::Playlist => { - if let Some(results) = &app.search_results.playlists { - results - .items - .iter() - .map(|r| { - self.format_output( - format.clone(), - Format::from_type(FormatType::PlaylistInfo(Box::new(r.clone()))), - ) - }) - .collect::>() - .join("\n") - } else { - format!("no playlists with name '{}'", search) - } - } - Type::Track => { - if let Some(results) = &app.search_results.tracks { - results - .items - .iter() - .map(|r| { - self.format_output( - format.clone(), - Format::from_type(FormatType::TrackInfo(Box::new(r.clone()))), - ) - }) - .collect::>() - .join("\n") - } else { - format!("no tracks with name '{}'", search) - } - } - Type::Artist => { - if let Some(results) = &app.search_results.artists { - results - .items - .iter() - .map(|r| { - self.format_output( - format.clone(), - Format::from_type(FormatType::ArtistInfo(Box::new(r.clone()))), - ) - }) - .collect::>() - .join("\n") - } else { - format!("no artists with name '{}'", search) - } - } - Type::Show => { - if let Some(results) = &app.search_results.shows { - results - .items - .iter() - .map(|r| { - self.format_output( - format.clone(), - Format::from_type(FormatType::ShowInfo(Box::new(r.clone()))), - ) - }) - .collect::>() - .join("\n") - } else { - format!("no shows with name '{}'", search) - } - } - Type::Album => { - if let Some(results) = &app.search_results.albums { - results - .items - .iter() - .map(|r| { - self.format_output( - format.clone(), - Format::from_type(FormatType::AlbumInfo(Box::new(r.clone()))), - ) - }) - .collect::>() - .join("\n") - } else { - format!("no albums with name '{}'", search) - } - } - // Enforced by clap - _ => unreachable!(), - } - } -} - -fn parse_query_limit(max: &str, ceiling: u32) -> Result { - match max.parse::() { - Ok(num) if (1..=ceiling).contains(&num) => Ok(num), - _ => Err(anyhow!("limit must be between 1 and {ceiling}")), - } -} - -#[cfg(test)] -mod tests { - use super::parse_query_limit; - - #[test] - fn a_query_limit_is_accepted_only_within_its_ceiling() { - assert_eq!(parse_query_limit("50", 50).unwrap(), 50); - assert_eq!(parse_query_limit("10", 10).unwrap(), 10); - assert!(parse_query_limit("51", 50).is_err()); - assert!(parse_query_limit("11", 10).is_err()); - assert!(parse_query_limit("0", 10).is_err()); - assert!(parse_query_limit("ten", 10).is_err()); - } -} diff --git a/src/cli/handle.rs b/src/cli/handle.rs deleted file mode 100644 index 9c5ee9de..00000000 --- a/src/cli/handle.rs +++ /dev/null @@ -1,157 +0,0 @@ -use crate::core::user_config::UserConfig; -use crate::infra::network::{search::SPOTIFY_SEARCH_LIMIT, IoEvent, Network}; - -use super::{ - util::{Flag, JumpDirection, Type}, - CliApp, -}; - -use anyhow::{anyhow, Result}; -use clap::ArgMatches; - -// Handle the different subcommands -pub async fn handle_matches( - matches: &ArgMatches, - cmd: String, - net: Network, - config: UserConfig, -) -> Result { - let mut cli = CliApp::new(net, config); - - cli.net.handle_network_event(IoEvent::GetDevices).await; - cli - .net - .handle_network_event(IoEvent::GetCurrentPlayback) - .await; - - let devices_list = match &cli.net.app.lock().await.devices { - Some(p) => p - .devices - .iter() - .filter_map(|d| d.id.clone()) - .collect::>(), - None => Vec::new(), - }; - - // If the device_id is not specified, select the first available device - let device_id = cli.net.client_config.device_id.clone(); - let needs_device = match &device_id { - Some(id) => !devices_list.contains(id), - None => true, - }; - if needs_device { - // Select the first device available - if let Some(d) = devices_list.first() { - cli.net.client_config.set_device_id(d.clone())?; - } - } - - // Only `playback` and `play` define `device`; a plain `get_one` panics - // under clap's debug assertions when `list`/`search` reach this shared path. - if let Some(d) = matches.try_get_one::("device").ok().flatten() { - cli.set_device(d.to_string()).await?; - } - - // Evalute the subcommand - let output = match cmd.as_str() { - "playback" => { - let format = matches.get_one::("format").unwrap(); - - // Commands that are 'single' - if matches.get_flag("share-track") { - return cli.share_track_or_episode().await; - } else if matches.get_flag("share-album") { - return cli.share_album_or_show().await; - } - - // Run the action, and print out the status - // No 'else if's because multiple different commands are possible - if matches.get_flag("toggle") { - cli.toggle_playback().await; - } - if let Some(d) = matches.get_one::("transfer") { - cli.transfer_playback(d).await?; - } - // Handle flags (like, dislike, shuffle, repeat) - let flags = Flag::from_matches(matches); - for f in flags { - cli.mark(f).await?; - } - if matches.get_count("next") > 0 || matches.get_count("previous") > 0 { - let (direction, amount) = JumpDirection::from_matches(matches); - for _ in 0..amount { - cli.jump(&direction).await; - } - } - if let Some(vol) = matches.get_one::("volume") { - cli.volume(vol.to_string()).await?; - } - if let Some(secs) = matches.get_one::("seek") { - cli.seek(secs.to_string()).await?; - } - - // Print out the status if no errors were found - cli.get_status(format.to_string()).await - } - "play" => { - let queue = matches.get_flag("queue"); - let random = matches.get_flag("random"); - let format = matches.get_one::("format").unwrap(); - - if let Some(uri) = matches.get_one::("uri") { - cli.play_uri(uri.to_string(), queue, random).await; - } else if let Some(name) = matches.get_one::("name") { - let category = Type::play_from_matches(matches); - cli.play(name.to_string(), category, queue, random).await?; - } - - cli.get_status(format.to_string()).await - } - "list" => { - let format = matches.get_one::("format").unwrap().to_string(); - - // Update the limits for the list and search functions - // I think the small and big search limits are very confusing - // so I just set them both to max, is this okay? - if let Some(max) = matches.get_one::("limit") { - cli.update_query_limits(max.to_string(), 50).await?; - } - - let category = Type::list_from_matches(matches); - Ok(cli.list(category, &format).await) - } - "search" => { - let format = matches.get_one::("format").unwrap().to_string(); - - // Update the limits for the list and search functions - // I think the small and big search limits are very confusing - // so I just set them both to max, is this okay? - if let Some(max) = matches.get_one::("limit") { - cli - .update_query_limits(max.to_string(), SPOTIFY_SEARCH_LIMIT) - .await?; - } - - let category = Type::search_from_matches(matches); - Ok( - cli - .query( - matches.get_one::("search").unwrap().to_string(), - format, - category, - ) - .await, - ) - } - // Clap enforces that one of the things above is specified - _ => unreachable!(), - }; - - // Check if there was an error - let api_error = cli.net.app.lock().await.api_error().to_owned(); - if api_error.is_empty() { - output - } else { - Err(anyhow!("{}", api_error)) - } -} diff --git a/src/cli/history.rs b/src/cli/history.rs deleted file mode 100644 index a41626f1..00000000 --- a/src/cli/history.rs +++ /dev/null @@ -1,51 +0,0 @@ -use crate::infra::history::{export_history_recap, parse_recap_period}; -use anyhow::Result; -use clap::{Arg, ArgMatches, Command}; -use std::path::PathBuf; - -pub fn history_subcommand() -> Command { - Command::new("history") - .about("Work with spotatui local listening history") - .subcommand_required(true) - .subcommand( - Command::new("recap") - .about("Generate a shareable HTML recap from local listening history") - .arg( - Arg::new("period") - .long("period") - .default_value("30d") - .value_parser(["7d", "30d", "month", "year", "all"]) - .help("Time range to include in the recap"), - ) - .arg( - Arg::new("output") - .long("output") - .short('o') - .default_value("spotatui-recap.html") - .value_name("PATH") - .help("Path to write the HTML recap file"), - ), - ) -} - -pub fn handle_history_matches(matches: &ArgMatches) -> Result { - let recap_matches = matches - .subcommand_matches("recap") - .expect("clap guarantees a history subcommand"); - let period = parse_recap_period( - recap_matches - .get_one::("period") - .expect("period has default"), - )?; - let output_path = PathBuf::from( - recap_matches - .get_one::("output") - .expect("output is required"), - ); - let listen_count = export_history_recap(period, &output_path)?; - Ok(format!( - "Generated recap from {} qualified listens at {}", - listen_count, - output_path.display() - )) -} diff --git a/src/cli/mcp.rs b/src/cli/mcp.rs deleted file mode 100644 index feaef6eb..00000000 --- a/src/cli/mcp.rs +++ /dev/null @@ -1,42 +0,0 @@ -//! The `spotatui mcp` subcommand. -//! -//! Spawned by an MCP client (`claude mcp add spotatui -- spotatui mcp`), it -//! speaks MCP on stdio and relays to the running TUI. See -//! [`crate::infra::mcp`] for the architecture. - -use clap::{Arg, ArgAction, Command}; - -pub fn mcp_subcommand() -> Command { - Command::new("mcp") - // Deliberately NOT `subcommand_required`: bare `spotatui mcp` is the server - // itself, and that is what clients are registered to run. - .subcommand( - Command::new("status") - .about("Check whether the MCP setup is working (safe to run; does not block)") - .long_about( - "Verifies each step of the MCP setup and exits non-zero if it is not \ - ready.\n\n\ - Run this instead of `spotatui mcp` when testing: `spotatui mcp` IS the \ - server, so it reads JSON-RPC from stdin and blocks until closed.", - ) - .arg( - Arg::new("json") - .long("json") - .action(ArgAction::SetTrue) - .help("Emit machine-readable JSON"), - ), - ) - .about("Run as an MCP server so a coding agent can drive spotatui") - .long_about( - "Serves the Model Context Protocol on stdin/stdout so Claude Code, Codex, \ - Gemini CLI, or any MCP client can control playback and read your listening \ - history.\n\n\ - Register it once with your client, e.g.:\n\ - \x20 claude mcp add spotatui -- spotatui mcp\n\ - \x20 codex mcp add spotatui -- spotatui mcp\n\n\ - The running spotatui instance does the work, so start spotatui and set \ - `behavior.mcp_enabled: true` in its config. Without a running instance the \ - server still starts, but every tool reports that the player is \ - unavailable.", - ) -} diff --git a/src/cli/mod.rs b/src/cli/mod.rs deleted file mode 100644 index 641d1e0b..00000000 --- a/src/cli/mod.rs +++ /dev/null @@ -1,22 +0,0 @@ -mod clap; -mod cli_app; -mod handle; -mod history; -#[cfg(feature = "mcp-server")] -mod mcp; -#[cfg(feature = "scripting")] -mod plugin; -#[cfg(feature = "self-update")] -mod update; -mod util; - -pub use self::clap::{list_subcommand, play_subcommand, playback_subcommand, search_subcommand}; -pub use self::history::{handle_history_matches, history_subcommand}; -#[cfg(feature = "mcp-server")] -pub use self::mcp::mcp_subcommand; -#[cfg(feature = "scripting")] -pub use self::plugin::{handle_plugin_command, plugin_subcommand}; -use cli_app::CliApp; -pub use handle::handle_matches; -#[cfg(feature = "self-update")] -pub use update::{check_for_update, install_update_silent, UpdateOutcome}; diff --git a/src/cli/plugin.rs b/src/cli/plugin.rs deleted file mode 100644 index c077d123..00000000 --- a/src/cli/plugin.rs +++ /dev/null @@ -1,808 +0,0 @@ -//! `spotatui plugin` subcommand: a thin git-based installer for Lua plugins. -//! -//! Plugins are git repositories cloned into the app config directory's -//! `plugins//`, where the -//! engine loads `main.lua` (or `init.lua`) at startup. Installed plugins are tracked in a -//! `plugins.lock` file next to the `plugins/` directory so they can be listed and updated. - -use std::path::{Path, PathBuf}; -use std::process::Command as ProcessCommand; - -use anyhow::{anyhow, bail, Context, Result}; -use clap::{Arg, ArgAction, ArgMatches, Command}; -use serde::{Deserialize, Serialize}; - -/// Build the `plugin` subcommand tree. -pub fn plugin_subcommand() -> Command { - Command::new("plugin") - .version(env!("CARGO_PKG_VERSION")) - .author(env!("CARGO_PKG_AUTHORS")) - .about("Install and manage Lua plugins") - .long_about( - "Manage Lua plugins installed from git repositories. Plugins are cloned into \ -the spotatui config directory's plugins// and loaded at startup (main.lua, or init.lua). \ -Requires `git` on your PATH.", - ) - .subcommand_required(true) - .arg_required_else_help(true) - .subcommand( - Command::new("add") - .about("Install a plugin from a git repository") - .long_about( - "Install a plugin by cloning a git repository. Accepts a GitHub shorthand \ -(owner/repo) or any git URL (https://..., git@...).", - ) - .arg( - Arg::new("repo") - .required(true) - .value_name("REPO") - .help("owner/repo (GitHub) or a full git URL"), - ) - .arg( - Arg::new("force") - .short('f') - .long("force") - .action(ArgAction::SetTrue) - .help("Reinstall if the plugin is already present"), - ), - ) - .subcommand( - Command::new("list") - .visible_alias("ls") - .about("List installed plugins"), - ) - .subcommand( - Command::new("remove") - .visible_alias("rm") - .about("Remove an installed plugin") - .arg( - Arg::new("name") - .required(true) - .value_name("NAME") - .help("Name of the plugin to remove"), - ), - ) - .subcommand( - Command::new("update") - .about("Update installed plugins to their latest commit") - .arg( - Arg::new("name") - .value_name("NAME") - .help("Plugin to update (updates all plugins if omitted)"), - ), - ) - .subcommand( - Command::new("new") - .about("Scaffold a new plugin to start from") - .long_about( - "Create a new directory plugin in the spotatui config directory's plugins// with a working \ -main.lua and a README.md to edit and publish.", - ) - .arg( - Arg::new("name") - .required(true) - .value_name("NAME") - .help("Name of the plugin to create"), - ) - .arg( - Arg::new("force") - .short('f') - .long("force") - .action(ArgAction::SetTrue) - .help("Overwrite an existing plugin directory of the same name"), - ), - ) -} - -/// Entry point dispatched from `runtime/`. Resolves the config dir and runs the chosen action. -pub fn handle_plugin_command(matches: &ArgMatches) -> Result<()> { - let config_dir = crate::core::user_config::default_app_config_dir() - .context("could not determine the spotatui config directory (no home directory found)")?; - - match matches.subcommand() { - Some(("add", m)) => { - let repo = m.get_one::("repo").expect("repo is required"); - add_plugin(&config_dir, repo, m.get_flag("force")) - } - Some(("list", _)) => list_plugins(&config_dir), - Some(("remove", m)) => { - let name = m.get_one::("name").expect("name is required"); - remove_plugin(&config_dir, name) - } - Some(("update", m)) => update_plugins(&config_dir, m.get_one::("name")), - Some(("new", m)) => { - let name = m.get_one::("name").expect("name is required"); - new_plugin(&config_dir, name, m.get_flag("force")) - } - _ => unreachable!("clap enforces a subcommand"), - } -} - -// --- actions --- - -fn add_plugin(config_dir: &Path, spec: &str, force: bool) -> Result<()> { - let spec = parse_repo_spec(spec)?; - ensure_git()?; - - let plugins_dir = config_dir.join("plugins"); - std::fs::create_dir_all(&plugins_dir) - .with_context(|| format!("creating {}", plugins_dir.display()))?; - let dest = plugins_dir.join(&spec.name); - - if dest.exists() { - if !force { - bail!( - "plugin '{}' is already installed. Use `--force` to reinstall.", - spec.name - ); - } - std::fs::remove_dir_all(&dest) - .with_context(|| format!("removing existing {}", dest.display()))?; - } - - println!("Cloning {} into {} ...", spec.url, dest.display()); - git_clone(&spec.url, &dest)?; - let rev = git_head_rev(&dest).unwrap_or_default(); - - let lock_path = lock_path(config_dir); - let mut lock = load_lock(&lock_path)?; - lock.upsert(LockedPlugin { - name: spec.name.clone(), - repo: spec.repo.clone(), - url: spec.url.clone(), - rev: rev.clone(), - }); - save_lock(&lock_path, &lock)?; - - println!("Installed plugin '{}' ({})", spec.name, short_rev(&rev)); - println!( - "Restart spotatui to load it. Bind any commands it registers via the `plugin_commands` map \ -in config.yml." - ); - Ok(()) -} - -fn list_plugins(config_dir: &Path) -> Result<()> { - let lock = load_lock(&lock_path(config_dir))?; - let plugins_dir = config_dir.join("plugins"); - - if lock.plugins.is_empty() { - println!("No plugins installed."); - println!("Install one with: spotatui plugin add owner/repo"); - } else { - println!("Installed plugins:"); - for p in &lock.plugins { - let present = plugins_dir.join(&p.name).is_dir(); - let marker = if present { "" } else { " (missing on disk)" }; - println!( - " {:<20} {:<24} {}{}", - p.name, - p.repo, - short_rev(&p.rev), - marker - ); - } - } - - // Surface plugins on disk that the lockfile doesn't track (hand-installed or single-file). - let tracked: Vec<&str> = lock.plugins.iter().map(|p| p.name.as_str()).collect(); - let mut untracked = untracked_plugins(&plugins_dir, &tracked); - untracked.sort(); - if !untracked.is_empty() { - println!("\nUntracked plugins (not managed by `spotatui plugin`):"); - for name in untracked { - println!(" {name}"); - } - } - Ok(()) -} - -fn remove_plugin(config_dir: &Path, name: &str) -> Result<()> { - if !valid_plugin_name(name) { - bail!("invalid plugin name '{name}'"); - } - let lock_path = lock_path(config_dir); - let mut lock = load_lock(&lock_path)?; - let dest = config_dir.join("plugins").join(name); - - let was_tracked = lock.remove(name); - let existed_on_disk = dest.is_dir(); - - if !was_tracked && !existed_on_disk { - bail!("no plugin named '{name}' is installed"); - } - if existed_on_disk { - std::fs::remove_dir_all(&dest).with_context(|| format!("removing {}", dest.display()))?; - } - if was_tracked { - save_lock(&lock_path, &lock)?; - } - - println!("Removed plugin '{name}'."); - Ok(()) -} - -fn update_plugins(config_dir: &Path, name: Option<&String>) -> Result<()> { - ensure_git()?; - let lock_path = lock_path(config_dir); - let mut lock = load_lock(&lock_path)?; - let plugins_dir = config_dir.join("plugins"); - - let targets: Vec = match name { - Some(name) => { - let found = lock - .plugins - .iter() - .find(|p| p.name == *name) - .cloned() - .ok_or_else(|| anyhow!("no plugin named '{name}' is installed"))?; - vec![found] - } - None => lock.plugins.clone(), - }; - - if targets.is_empty() { - println!("No plugins to update."); - return Ok(()); - } - - let mut changed = false; - for target in targets { - let dir = plugins_dir.join(&target.name); - if !dir.is_dir() { - println!( - " {}: missing on disk, skipping (reinstall with `spotatui plugin add {}`)", - target.name, target.repo - ); - continue; - } - match git_update(&dir) { - Ok(()) => { - let new_rev = git_head_rev(&dir).unwrap_or_default(); - if new_rev != target.rev { - println!( - " {}: {} -> {}", - target.name, - short_rev(&target.rev), - short_rev(&new_rev) - ); - if let Some(entry) = lock.plugins.iter_mut().find(|p| p.name == target.name) { - entry.rev = new_rev; - changed = true; - } - } else { - println!( - " {}: already up to date ({})", - target.name, - short_rev(&new_rev) - ); - } - } - Err(e) => println!(" {}: update failed: {e}", target.name), - } - } - - if changed { - save_lock(&lock_path, &lock)?; - } - Ok(()) -} - -fn new_plugin(config_dir: &Path, name: &str, force: bool) -> Result<()> { - if !valid_plugin_name(name) { - bail!( - "invalid plugin name '{name}'. Use letters, digits, '.', '_', '-', and don't start with '.'." - ); - } - - let plugins_dir = config_dir.join("plugins"); - let dest = plugins_dir.join(name); - if dest.exists() { - if !force { - bail!( - "'{}' already exists. Use `--force` to overwrite.", - dest.display() - ); - } - std::fs::remove_dir_all(&dest) - .with_context(|| format!("removing existing {}", dest.display()))?; - } - - std::fs::create_dir_all(&dest).with_context(|| format!("creating {}", dest.display()))?; - - let main_lua = scaffold_main_lua(name); - let readme = scaffold_readme(name); - std::fs::write(dest.join("main.lua"), main_lua) - .with_context(|| format!("writing {}", dest.join("main.lua").display()))?; - std::fs::write(dest.join("README.md"), readme) - .with_context(|| format!("writing {}", dest.join("README.md").display()))?; - - println!("Created plugin '{name}' at {}", dest.display()); - println!("Next steps:"); - println!(" 1. Edit {}", dest.join("main.lua").display()); - println!( - " 2. Bind its command in config.yml under `plugin_commands` (e.g. {name}_hello: \"ctrl-h\")" - ); - println!(" 3. Restart spotatui to load it"); - println!(" 4. To publish: `git init` in the directory and push to a git host"); - Ok(()) -} - -fn scaffold_main_lua(name: &str) -> String { - let api_version = crate::core::plugin_api::API_VERSION; - format!( - r#"-- {name}: a spotatui plugin. --- Suggested key binding (add to config.yml under `plugin_commands`): --- {name}_hello: "ctrl-h" - -spotatui.require_api({api_version}) - -spotatui.register_command("{name}_hello", function() - spotatui.notify("hello from {name}", 3) -end) - --- Uncomment to react to track changes: --- spotatui.on("track_change", function(pb) --- if pb and pb.track then --- spotatui.set_playbar(pb.track.name) --- end --- end) -"# - ) -} - -fn scaffold_readme(name: &str) -> String { - format!( - r#"# {name} - -A spotatui plugin. - -## What it does - -Registers a `{name}_hello` command that shows a notification. Edit `main.lua` to make it your own. - -## Install - -```bash -spotatui plugin add owner/{name} -``` - -Or copy this directory into the spotatui config directory's `plugins/`. - -## Key binding - -This plugin registers the `{name}_hello` command. Bind it in `config.yml`: - -```yaml -plugin_commands: - {name}_hello: "ctrl-h" -``` -"# - ) -} - -// --- repo spec parsing --- - -struct RepoSpec { - /// Local plugin directory name (the repo's last path segment, minus `.git`). - name: String, - /// Human-facing source label (e.g. `owner/repo` or the raw URL). - repo: String, - /// Git clone URL. - url: String, -} - -/// Parse a plugin spec into a clone URL and a local name. -/// -/// Accepts `owner/repo` (GitHub shorthand), `https://host/owner/repo(.git)`, and -/// `git@host:owner/repo(.git)`. -fn parse_repo_spec(spec: &str) -> Result { - let spec = spec.trim(); - if spec.is_empty() { - bail!("empty plugin spec"); - } - - let is_url = spec.contains("://") || spec.starts_with("git@"); - let (url, repo) = if is_url { - (spec.to_string(), repo_label_from_url(spec)) - } else { - // GitHub shorthand: exactly owner/repo. - let parts: Vec<&str> = spec.split('/').collect(); - if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() { - bail!("expected `owner/repo` or a git URL, got '{spec}'"); - } - let repo = format!("{}/{}", parts[0], strip_git_suffix(parts[1])); - (format!("https://github.com/{repo}.git"), repo) - }; - - let name = name_from_repo_path(spec); - if !valid_plugin_name(&name) { - bail!("could not derive a valid plugin name from '{spec}'"); - } - - Ok(RepoSpec { name, repo, url }) -} - -/// Last path segment of a repo spec/URL, minus any `.git` suffix. -fn name_from_repo_path(spec: &str) -> String { - let tail = spec - .trim_end_matches('/') - .rsplit(['/', ':']) - .next() - .unwrap_or(spec); - strip_git_suffix(tail).to_string() -} - -/// Best-effort `owner/repo` label from a URL, falling back to the full URL. -fn repo_label_from_url(url: &str) -> String { - let trimmed = url.trim_end_matches('/'); - // Take the path after the host. For scp-like git@host:owner/repo and https://host/owner/repo. - let path = trimmed - .rsplit_once("://") - .map(|(_, rest)| rest) - .unwrap_or(trimmed); - let path = path - .split_once(['/', ':']) - .map(|(_, rest)| rest) - .unwrap_or(path); - let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); - if segments.len() >= 2 { - let owner = segments[segments.len() - 2]; - let repo = strip_git_suffix(segments[segments.len() - 1]); - format!("{owner}/{repo}") - } else { - url.to_string() - } -} - -fn strip_git_suffix(s: &str) -> &str { - s.strip_suffix(".git").unwrap_or(s) -} - -/// A plugin name must be a single safe path component. -fn valid_plugin_name(name: &str) -> bool { - // Reject leading dots: covers `.`, `..`, and hidden names like `.foo` that the engine loader - // skips (so the installer never reports a dead install the runtime ignores). - !name.is_empty() - && !name.starts_with('.') - && !name.contains('/') - && !name.contains('\\') - && name - .chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) -} - -// --- lockfile --- - -fn lock_version() -> u32 { - 1 -} - -#[derive(Serialize, Deserialize)] -struct PluginLock { - #[serde(default = "lock_version")] - version: u32, - #[serde(default)] - plugins: Vec, -} - -impl Default for PluginLock { - fn default() -> Self { - PluginLock { - version: lock_version(), - plugins: Vec::new(), - } - } -} - -#[derive(Serialize, Deserialize, Clone)] -struct LockedPlugin { - name: String, - repo: String, - url: String, - #[serde(default)] - rev: String, -} - -impl PluginLock { - /// Insert a plugin, replacing any existing entry with the same name. - fn upsert(&mut self, plugin: LockedPlugin) { - if let Some(existing) = self.plugins.iter_mut().find(|p| p.name == plugin.name) { - *existing = plugin; - } else { - self.plugins.push(plugin); - } - self.plugins.sort_by(|a, b| a.name.cmp(&b.name)); - } - - /// Remove a plugin by name. Returns whether an entry was removed. - fn remove(&mut self, name: &str) -> bool { - let before = self.plugins.len(); - self.plugins.retain(|p| p.name != name); - self.plugins.len() != before - } -} - -fn lock_path(config_dir: &Path) -> PathBuf { - config_dir.join("plugins.lock") -} - -/// Read the lockfile. A missing file yields an empty lock; a malformed file is an error so we -/// never silently clobber a user's record. -fn load_lock(path: &Path) -> Result { - match std::fs::read_to_string(path) { - Ok(contents) if contents.trim().is_empty() => Ok(PluginLock::default()), - Ok(contents) => serde_json::from_str(&contents) - .with_context(|| format!("parsing lockfile {}", path.display())), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(PluginLock::default()), - Err(e) => Err(e).with_context(|| format!("reading lockfile {}", path.display())), - } -} - -fn save_lock(path: &Path, lock: &PluginLock) -> Result<()> { - let mut json = serde_json::to_string_pretty(lock).context("serializing lockfile")?; - json.push('\n'); - // Write to a sibling temp file and atomically rename, so a crash or full disk mid-write can't - // leave a truncated lockfile (which load_lock would then treat as a hard parse error). - let tmp = path.with_extension("lock.tmp"); - std::fs::write(&tmp, json).with_context(|| format!("writing lockfile {}", tmp.display()))?; - std::fs::rename(&tmp, path).with_context(|| format!("replacing lockfile {}", path.display())) -} - -/// Plugin entries on disk the lockfile doesn't track: directory plugins not in `tracked`, plus -/// any loose single-file `*.lua` plugins (which are never lockfile-tracked). Hidden entries are -/// ignored. -fn untracked_plugins(plugins_dir: &Path, tracked: &[&str]) -> Vec { - let Ok(entries) = std::fs::read_dir(plugins_dir) else { - return Vec::new(); - }; - entries - .flatten() - .filter_map(|e| { - let path = e.path(); - let name = e.file_name().into_string().ok()?; - if name.starts_with('.') { - None - } else if path.is_dir() { - (!tracked.contains(&name.as_str())).then_some(name) - } else if path.extension().and_then(|x| x.to_str()) == Some("lua") { - Some(name) - } else { - None - } - }) - .collect() -} - -fn short_rev(rev: &str) -> &str { - if rev.len() >= 7 { - &rev[..7] - } else if rev.is_empty() { - "unknown" - } else { - rev - } -} - -// --- git (thin wrappers over the system `git`) --- - -fn ensure_git() -> Result<()> { - let out = ProcessCommand::new("git") - .arg("--version") - .output() - .map_err(|_| anyhow!("`git` was not found on your PATH. Install git to manage plugins."))?; - if !out.status.success() { - bail!("`git --version` failed; check your git installation."); - } - Ok(()) -} - -fn git_clone(url: &str, dest: &Path) -> Result<()> { - // `--` separates options from positionals so a URL is never mistaken for a flag. - let status = ProcessCommand::new("git") - .args(["clone", "--depth", "1", "--"]) - .arg(url) - .arg(dest) - .status() - .context("running `git clone`")?; - if !status.success() { - bail!("git clone failed for {url}"); - } - Ok(()) -} - -fn git_head_rev(dir: &Path) -> Result { - let out = ProcessCommand::new("git") - .arg("-C") - .arg(dir) - .args(["rev-parse", "HEAD"]) - .output() - .context("running `git rev-parse`")?; - if !out.status.success() { - bail!("git rev-parse failed in {}", dir.display()); - } - Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) -} - -/// Fast-forward a shallow clone to the remote default branch's latest commit. -fn git_update(dir: &Path) -> Result<()> { - run_git(dir, &["fetch", "--depth", "1", "origin", "HEAD"])?; - run_git(dir, &["reset", "--hard", "FETCH_HEAD"])?; - Ok(()) -} - -fn run_git(dir: &Path, args: &[&str]) -> Result<()> { - let out = ProcessCommand::new("git") - .arg("-C") - .arg(dir) - .args(args) - .output() - .with_context(|| format!("running `git {}`", args.join(" ")))?; - if !out.status.success() { - let stderr = String::from_utf8_lossy(&out.stderr); - bail!("git {} failed: {}", args.join(" "), stderr.trim()); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_github_shorthand() { - let s = parse_repo_spec("owner/cool-plugin").unwrap(); - assert_eq!(s.name, "cool-plugin"); - assert_eq!(s.repo, "owner/cool-plugin"); - assert_eq!(s.url, "https://github.com/owner/cool-plugin.git"); - } - - #[test] - fn parse_github_shorthand_strips_git_suffix() { - let s = parse_repo_spec("owner/repo.git").unwrap(); - assert_eq!(s.name, "repo"); - assert_eq!(s.repo, "owner/repo"); - assert_eq!(s.url, "https://github.com/owner/repo.git"); - } - - #[test] - fn parse_https_url() { - let s = parse_repo_spec("https://gitlab.com/owner/repo.git").unwrap(); - assert_eq!(s.name, "repo"); - assert_eq!(s.repo, "owner/repo"); - assert_eq!(s.url, "https://gitlab.com/owner/repo.git"); - } - - #[test] - fn parse_scp_style_url() { - let s = parse_repo_spec("git@github.com:owner/repo.git").unwrap(); - assert_eq!(s.name, "repo"); - assert_eq!(s.repo, "owner/repo"); - assert_eq!(s.url, "git@github.com:owner/repo.git"); - } - - #[test] - fn parse_rejects_garbage() { - assert!(parse_repo_spec("").is_err()); - assert!(parse_repo_spec("just-a-name").is_err()); - assert!(parse_repo_spec("a/b/c").is_err()); - } - - #[test] - fn parse_rejects_path_traversal_name() { - // A spec whose tail would be `..` must be rejected. - assert!(parse_repo_spec("owner/..").is_err()); - } - - #[test] - fn parse_rejects_dotfile_name() { - // A leading-dot name would be cloned but silently ignored by the loader; reject it. - assert!(parse_repo_spec("owner/.hidden").is_err()); - assert!(parse_repo_spec("https://example.com/owner/.foo").is_err()); - } - - #[test] - fn valid_names() { - assert!(valid_plugin_name("lyrics")); - assert!(valid_plugin_name("my-plugin_2.0")); - assert!(!valid_plugin_name("")); - assert!(!valid_plugin_name("..")); - assert!(!valid_plugin_name(".")); - assert!(!valid_plugin_name(".hidden")); - assert!(!valid_plugin_name("a/b")); - assert!(!valid_plugin_name("a\\b")); - assert!(!valid_plugin_name("space name")); - } - - #[test] - fn lock_roundtrip_and_mutations() { - let mut lock = PluginLock::default(); - lock.upsert(LockedPlugin { - name: "b".into(), - repo: "o/b".into(), - url: "u".into(), - rev: "1".into(), - }); - lock.upsert(LockedPlugin { - name: "a".into(), - repo: "o/a".into(), - url: "u".into(), - rev: "2".into(), - }); - // Sorted by name. - assert_eq!(lock.plugins[0].name, "a"); - assert_eq!(lock.plugins[1].name, "b"); - - // Upsert replaces in place. - lock.upsert(LockedPlugin { - name: "a".into(), - repo: "o/a".into(), - url: "u".into(), - rev: "99".into(), - }); - assert_eq!(lock.plugins.len(), 2); - assert_eq!(lock.plugins[0].rev, "99"); - - let json = serde_json::to_string(&lock).unwrap(); - let back: PluginLock = serde_json::from_str(&json).unwrap(); - assert_eq!(back.plugins.len(), 2); - assert_eq!(back.version, 1); - - assert!(lock.remove("a")); - assert!(!lock.remove("a")); - assert_eq!(lock.plugins.len(), 1); - } - - #[test] - fn load_lock_missing_is_empty() { - let path = - std::env::temp_dir().join(format!("spotatui_lock_missing_{}.json", std::process::id())); - let _ = std::fs::remove_file(&path); - let lock = load_lock(&path).unwrap(); - assert!(lock.plugins.is_empty()); - } - - #[test] - fn save_then_load_lock() { - let path = std::env::temp_dir().join(format!("spotatui_lock_rt_{}.json", std::process::id())); - let mut lock = PluginLock::default(); - lock.upsert(LockedPlugin { - name: "lyrics".into(), - repo: "owner/lyrics".into(), - url: "https://github.com/owner/lyrics.git".into(), - rev: "abcdef1234567890".into(), - }); - save_lock(&path, &lock).unwrap(); - let back = load_lock(&path).unwrap(); - assert_eq!(back.plugins.len(), 1); - assert_eq!(back.plugins[0].name, "lyrics"); - let _ = std::fs::remove_file(&path); - } - - #[test] - fn short_rev_handling() { - assert_eq!(short_rev("abcdef1234"), "abcdef1"); - assert_eq!(short_rev("abc"), "abc"); - assert_eq!(short_rev(""), "unknown"); - } - - #[test] - fn untracked_lists_loose_files_and_untracked_dirs() { - let dir = std::env::temp_dir().join(format!("spotatui_untracked_{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(dir.join("tracked-plugin")).unwrap(); - std::fs::create_dir_all(dir.join("hand-installed")).unwrap(); - std::fs::create_dir_all(dir.join(".git")).unwrap(); - std::fs::write(dir.join("loose.lua"), "-- loose").unwrap(); - std::fs::write(dir.join("notes.txt"), "ignore me").unwrap(); - - let mut got = untracked_plugins(&dir, &["tracked-plugin"]); - got.sort(); - assert_eq!( - got, - vec!["hand-installed".to_string(), "loose.lua".to_string()] - ); - - let _ = std::fs::remove_dir_all(&dir); - } -} diff --git a/src/cli/update.rs b/src/cli/update.rs deleted file mode 100644 index dff0bdd5..00000000 --- a/src/cli/update.rs +++ /dev/null @@ -1,342 +0,0 @@ -use std::path::PathBuf; -use std::time::{SystemTime, UNIX_EPOCH}; - -use anyhow::{anyhow, bail, Result}; -use self_update::cargo_crate_version; -use serde::{Deserialize, Serialize}; - -/// Information about an available update -#[allow(dead_code)] -pub struct UpdateInfo { - pub current_version: String, - pub latest_version: String, -} - -#[derive(Debug, Serialize, Deserialize)] -struct UpdatePendingState { - pending_version: String, - detected_at_secs: u64, -} - -fn update_state_file_path() -> Option { - use crate::core::user_config::UserConfig; - UserConfig::get_app_config_dir().map(|dir| dir.join("update_pending.json")) -} - -pub enum UpdateOutcome { - /// Update was installed; contains the new version string - Installed(String), - /// Update is available but waiting for the delay to elapse - Pending { - version: String, - secs_remaining: u64, - }, - /// Already up to date - UpToDate, -} - -/// Check for updates in the background (non-blocking) -/// Returns Some(UpdateInfo) if an update is available, None if up to date -#[allow(dead_code)] -pub fn check_for_update_silent() -> Option { - // ============ TESTING: Uncomment below to simulate update ============ - // return Some(UpdateInfo { - // current_version: env!("CARGO_PKG_VERSION").to_string(), - // latest_version: "99.0.0".to_string(), - // }); - // ===================================================================== - - let current_version = cargo_crate_version!(); - - let status = self_update::backends::github::Update::configure() - .repo_owner("LargeModGames") - .repo_name("spotatui") - .bin_name("spotatui") - .current_version(current_version) - .build() - .ok()?; - - let latest = status.get_latest_release().ok()?; - let latest_version = latest.version.trim_start_matches('v').to_string(); - - if latest_version != current_version { - Some(UpdateInfo { - current_version: current_version.to_string(), - latest_version, - }) - } else { - None - } -} - -/// Returns the artifact prefix used in release asset names for the current platform. -/// Mirrors the `artifact_prefix` matrix in `.github/workflows/cd.yml`. -fn current_platform_prefix() -> Option<&'static str> { - platform_prefix(std::env::consts::OS, std::env::consts::ARCH) -} - -/// Maps (OS, arch) to the release artifact prefix published by cd.yml; must -/// cover every target that workflow builds. -fn platform_prefix(os: &str, arch: &str) -> Option<&'static str> { - match (os, arch) { - ("linux", "x86_64") => Some("linux-x86_64"), - ("linux", "aarch64") => Some("linux-aarch64"), - ("macos", "x86_64") => Some("macos-x86_64"), - ("macos", "aarch64") => Some("macos-aarch64"), - ("windows", "x86_64") => Some("windows-x86_64"), - _ => None, - } -} - -/// Downloads the release asset for this platform, verifies its SHA-256 against the -/// published `.sha256` sidecar, and returns `Err` if the hash does not match or the -/// sidecar is missing. Called before `status.update()` so a compromised asset is -/// rejected before the binary is replaced. -fn verify_release_checksum(release: &self_update::update::Release) -> Result<()> { - use sha2::Digest; - - let prefix = current_platform_prefix() - .ok_or_else(|| anyhow!("unsupported platform, cannot verify update checksum"))?; - - let ext = if cfg!(windows) { "zip" } else { "tar.gz" }; - let asset_name = format!("spotatui-{}.{}", prefix, ext); - let checksum_name = format!("{}.sha256", asset_name); - - let asset = release - .assets - .iter() - .find(|a| a.name == asset_name) - .ok_or_else(|| anyhow!("release asset '{}' not found", asset_name))?; - - let checksum_asset = release - .assets - .iter() - .find(|a| a.name == checksum_name) - .ok_or_else(|| { - anyhow!( - "checksum asset '{}' not found — skipping update", - checksum_name - ) - })?; - - // GitHub's API rejects requests without a User-Agent with 403, and asset URLs - // return JSON metadata instead of the file unless Accept is application/octet-stream. - let client = reqwest::blocking::Client::builder() - .user_agent(concat!("spotatui/", env!("CARGO_PKG_VERSION"))) - .timeout(std::time::Duration::from_secs(60)) - .build()?; - - let checksum_text = client - .get(&checksum_asset.download_url) - .header(reqwest::header::ACCEPT, "application/octet-stream") - .send()? - .error_for_status()? - .text()?; - - // Extract the hex digest: shasum/sha256sum produces " ", - // certutil (Windows) produces just the hash on a line by itself. - let expected_hash = checksum_text - .lines() - .find_map(|line| { - let token = line.split_whitespace().next()?; - if token.len() == 64 && token.chars().all(|c| c.is_ascii_hexdigit()) { - Some(token.to_string()) - } else { - None - } - }) - .ok_or_else(|| anyhow!("could not parse SHA-256 hash from checksum file"))?; - - let binary_bytes = client - .get(&asset.download_url) - .header(reqwest::header::ACCEPT, "application/octet-stream") - .send()? - .error_for_status()? - .bytes()?; - - let actual_hash = hex::encode(sha2::Sha256::digest(&binary_bytes)); - - if actual_hash != expected_hash { - bail!( - "checksum mismatch for '{}': expected {}, got {}", - asset_name, - expected_hash, - actual_hash - ); - } - - Ok(()) -} - -/// Convert self_update::Status to UpdateOutcome -fn status_to_outcome(status: self_update::Status) -> UpdateOutcome { - match status { - self_update::Status::UpToDate(_) => UpdateOutcome::UpToDate, - self_update::Status::Updated(v) => UpdateOutcome::Installed(v), - } -} - -/// Silently check for, download, and install an update, respecting an optional delay. -/// Returns Ok(UpdateOutcome::Installed(v)) if updated, Ok(UpdateOutcome::Pending{..}) if -/// a new version was detected but the delay hasn't elapsed yet, Ok(UpdateOutcome::UpToDate) -/// if already current, or Err on failure. -pub fn install_update_silent(delay_secs: u64) -> Result { - let current_version = cargo_crate_version!(); - - let status = self_update::backends::github::Update::configure() - .repo_owner("LargeModGames") - .repo_name("spotatui") - .bin_name("spotatui") - .show_download_progress(false) - .no_confirm(true) - .current_version(current_version) - .build()?; - - let latest = status.get_latest_release()?; - let latest_version = latest.version.trim_start_matches('v').to_string(); - - if latest_version == current_version { - // Up to date — clear any stale pending state - if let Some(path) = update_state_file_path() { - let _ = std::fs::remove_file(path); - } - return Ok(UpdateOutcome::UpToDate); - } - - // New version available — apply delay logic - if delay_secs == 0 { - // No delay: verify checksum then install immediately - verify_release_checksum(&latest)?; - let result = status.update()?; - return Ok(status_to_outcome(result)); - } - - // Delay > 0: check state file - let now_secs = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - - let state_path = update_state_file_path(); - let mut pending = state_path.as_ref().and_then(|p| { - std::fs::read_to_string(p) - .ok() - .and_then(|s| serde_json::from_str::(&s).ok()) - }); - - // If pending is for a different version (newer release arrived), reset the timer - if let Some(ref p) = pending { - if p.pending_version != latest_version { - pending = None; - } - } - - let state = match pending { - Some(p) => p, - None => { - // First detection: write state file and notify - let s = UpdatePendingState { - pending_version: latest_version.clone(), - detected_at_secs: now_secs, - }; - if let Some(ref path) = state_path { - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - let _ = std::fs::write( - path, - serde_json::to_string(&s).expect("failed to serialize update state"), - ); - } - s - } - }; - - let elapsed = now_secs.saturating_sub(state.detected_at_secs); - if elapsed >= delay_secs { - // Delay elapsed — verify checksum then install - verify_release_checksum(&latest)?; - let result = status.update()?; - // Clean up state file - if let Some(ref path) = state_path { - let _ = std::fs::remove_file(path); - } - Ok(status_to_outcome(result)) - } else { - let secs_remaining = delay_secs - elapsed; - Ok(UpdateOutcome::Pending { - version: latest_version, - secs_remaining, - }) - } -} - -/// Check for updates and optionally install the latest version -pub fn check_for_update(do_update: bool) -> Result<()> { - let current_version = cargo_crate_version!(); - - println!("Current version: v{}", current_version); - println!("Checking for updates..."); - - let status = self_update::backends::github::Update::configure() - .repo_owner("LargeModGames") - .repo_name("spotatui") - .bin_name("spotatui") - .show_download_progress(true) - .current_version(current_version) - .no_confirm(false) - .build()?; - - let latest = status.get_latest_release()?; - - // Remove 'v' prefix if present for comparison - let latest_version = latest.version.trim_start_matches('v'); - - if latest_version == current_version { - println!("✓ You are already running the latest version!"); - return Ok(()); - } - - println!("New version available: v{}", latest_version); - - if do_update { - println!("\nVerifying checksum and installing update..."); - - verify_release_checksum(&latest)?; - let result = status.update()?; - match result { - self_update::Status::UpToDate(_) => { - println!("✓ Already up to date!"); - } - self_update::Status::Updated(v) => { - println!("✓ Successfully updated to v{}!", v); - println!("\nPlease restart spotatui to use the new version."); - } - } - } else { - println!("\nRun `spotatui update --install` to install the update."); - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::platform_prefix; - - #[test] - fn every_published_release_target_resolves_a_checksum_prefix() { - // One row per cd.yml artifact_prefix; a target published without a row - // here makes self-update abort with "unsupported platform" (#440). - for (os, arch, prefix) in [ - ("linux", "x86_64", "linux-x86_64"), - ("linux", "aarch64", "linux-aarch64"), - ("macos", "x86_64", "macos-x86_64"), - ("macos", "aarch64", "macos-aarch64"), - ("windows", "x86_64", "windows-x86_64"), - ] { - assert_eq!(platform_prefix(os, arch), Some(prefix), "{os}/{arch}"); - } - assert_eq!(platform_prefix("linux", "arm"), None); - } -} diff --git a/src/cli/util.rs b/src/cli/util.rs deleted file mode 100644 index 3fd89d00..00000000 --- a/src/cli/util.rs +++ /dev/null @@ -1,323 +0,0 @@ -use clap::ArgMatches; -use rspotify::{ - model::enums::RepeatState, - model::idtypes::Id, - model::{artist::SimplifiedArtist, show::FullEpisode, track::FullTrack}, -}; -use std::time::Duration; - -use crate::core::user_config::UserConfig; - -// Helper function to extract URI from typed IDs or external URLs -fn get_uri_or_fallback( - id: &Option, - external_urls: &std::collections::HashMap, -) -> String { - if let Some(id) = id { - id.uri() - } else { - external_urls - .get("spotify") - .cloned() - .unwrap_or_else(|| "N/A".to_string()) - } -} - -// Possible types to list or search -#[derive(Debug)] -pub enum Type { - Playlist, - Track, - Artist, - Album, - Show, - Device, - Liked, -} - -impl Type { - pub fn play_from_matches(m: &ArgMatches) -> Self { - if m.get_flag("playlist") { - Self::Playlist - } else if m.get_flag("track") { - Self::Track - } else if m.get_flag("artist") { - Self::Artist - } else if m.get_flag("album") { - Self::Album - } else if m.get_flag("show") { - Self::Show - } - // Enforced by clap - else { - unreachable!() - } - } - - pub fn search_from_matches(m: &ArgMatches) -> Self { - if m.get_flag("playlists") { - Self::Playlist - } else if m.get_flag("tracks") { - Self::Track - } else if m.get_flag("artists") { - Self::Artist - } else if m.get_flag("albums") { - Self::Album - } else if m.get_flag("shows") { - Self::Show - } - // Enforced by clap - else { - unreachable!() - } - } - - pub fn list_from_matches(m: &ArgMatches) -> Self { - if m.get_flag("playlists") { - Self::Playlist - } else if m.get_flag("devices") { - Self::Device - } else if m.get_flag("liked") { - Self::Liked - } - // Enforced by clap - else { - unreachable!() - } - } -} - -// -// Possible flags to set -// - -pub enum Flag { - // Does not get toggled - // * User chooses like -> Flag::Like(true) - // * User chooses dislike -> Flag::Like(false) - Like(bool), - Shuffle, - Repeat, -} - -impl Flag { - pub fn from_matches(m: &ArgMatches) -> Vec { - // Multiple flags are possible - let mut flags = Vec::new(); - - // Only one of these two - if m.get_flag("like") { - flags.push(Self::Like(true)); - } else if m.get_flag("dislike") { - flags.push(Self::Like(false)); - } - - if m.get_flag("shuffle") { - flags.push(Self::Shuffle); - } - if m.get_flag("repeat") { - flags.push(Self::Repeat); - } - flags - } -} - -// Possible directions to jump to -pub enum JumpDirection { - Next, - Previous, -} - -impl JumpDirection { - pub fn from_matches(m: &ArgMatches) -> (Self, u64) { - let next_count = m.get_count("next"); - let prev_count = m.get_count("previous"); - if next_count > 0 { - (Self::Next, next_count as u64) - } else if prev_count > 0 { - (Self::Previous, prev_count as u64) - // Enforced by clap - } else { - unreachable!() - } - } -} - -// For fomatting (-f / --format flag) - -// Types to create a Format enum from -// Boxing was proposed by cargo clippy -// to reduce the size of this enum -pub enum FormatType { - /// Domain playlist (the playlist CLI paths — library list and search — are - /// fully migrated off rspotify). - PlaylistInfo(Box), - Track(Box), - /// Domain track (used by sources already migrated off rspotify, e.g. the - /// track table). The rspotify `Track` variant remains for not-yet-migrated - /// CLI paths (current-playback item, search results). - TrackInfo(Box), - Episode(Box), - /// Domain variants — used by search results and other migrated paths. - AlbumInfo(Box), - ArtistInfo(Box), - ShowInfo(Box), -} - -// Types that can be formatted -#[derive(Clone)] -pub enum Format { - Album(String), - Artist(String), - Playlist(String), - Track(String), - Show(String), - Uri(String), - Device(String), - Volume(u32), - // Current position, duration - Position((u32, u32)), - // This is a bit long, should it be splitted up? - Flags((RepeatState, bool, bool)), - Playing(bool), -} - -pub fn join_artists(a: Vec) -> String { - a.iter() - .map(|l| l.name.clone()) - .collect::>() - .join(", ") -} - -impl Format { - // Extract important information from types - pub fn from_type(t: FormatType) -> Vec { - match t { - FormatType::PlaylistInfo(p) => { - vec![Self::Playlist(p.name), Self::Uri(p.uri)] - } - FormatType::Track(t) => { - let joined_artists = join_artists(t.artists.clone()); - let uri = get_uri_or_fallback(&t.id, &t.external_urls); - vec![ - Self::Album(t.album.name), - Self::Artist(joined_artists), - Self::Track(t.name), - Self::Uri(uri), - ] - } - FormatType::TrackInfo(t) => { - // Domain track: artists is already a Vec, album/uri are plain. - vec![ - Self::Album(t.album), - Self::Artist(t.artists.join(", ")), - Self::Track(t.name), - Self::Uri(t.uri.unwrap_or_default()), - ] - } - FormatType::Episode(e) => { - let uri = e.id.uri(); - #[allow(deprecated)] - let publisher = e.show.publisher.clone(); - vec![ - Self::Show(e.show.name), - Self::Artist(publisher), - Self::Track(e.name), - Self::Uri(uri), - ] - } - FormatType::AlbumInfo(a) => { - let joined_artists = a - .artists - .iter() - .map(|r| r.name.clone()) - .collect::>() - .join(", "); - let uri = a.uri.clone().unwrap_or_default(); - vec![ - Self::Album(a.name), - Self::Artist(joined_artists), - Self::Uri(uri), - ] - } - FormatType::ArtistInfo(a) => { - let uri = a.uri.clone().unwrap_or_default(); - vec![Self::Artist(a.name), Self::Uri(uri)] - } - FormatType::ShowInfo(s) => { - let uri = s.uri.clone().unwrap_or_default(); - vec![Self::Show(s.name), Self::Uri(uri)] - } - } - } - - // Is there a better way? - pub fn inner(&self, conf: UserConfig) -> String { - match self { - Self::Album(s) => s.clone(), - Self::Artist(s) => s.clone(), - Self::Playlist(s) => s.clone(), - Self::Track(s) => s.clone(), - Self::Show(s) => s.clone(), - Self::Uri(s) => s.clone(), - Self::Device(s) => s.clone(), - // Because this match statements - // needs to return a &String, I have to do it this way - Self::Volume(s) => s.to_string(), - Self::Position((curr, duration)) => { - let current_progress_ms = *curr as u128; - let duration = Duration::from_millis(*duration as u64); - crate::core::format::display_track_progress(current_progress_ms, duration) - } - Self::Flags((r, s, l)) => { - let like = if *l { - conf.behavior.liked_icon - } else { - String::new() - }; - let shuffle = if *s { - conf.behavior.shuffle_icon - } else { - String::new() - }; - let repeat = match r { - RepeatState::Off => String::new(), - RepeatState::Track => conf.behavior.repeat_track_icon, - RepeatState::Context => conf.behavior.repeat_context_icon, - }; - - // Add them together (only those that aren't empty) - [shuffle, repeat, like] - .iter() - .filter(|a| !a.is_empty()) - // Convert &String to String to join them - .map(|s| s.to_string()) - .collect::>() - .join(" ") - } - Self::Playing(s) => { - if *s { - conf.behavior.playing_icon - } else { - conf.behavior.paused_icon - } - } - } - } - - pub fn get_placeholder(&self) -> &str { - match self { - Self::Album(_) => "%b", - Self::Artist(_) => "%a", - Self::Playlist(_) => "%p", - Self::Track(_) => "%t", - Self::Show(_) => "%h", - Self::Uri(_) => "%u", - Self::Device(_) => "%d", - Self::Volume(_) => "%v", - Self::Position(_) => "%r", - Self::Flags(_) => "%f", - Self::Playing(_) => "%s", - } - } -} diff --git a/src/core/action/apply.rs b/src/core/action/apply.rs deleted file mode 100644 index a84c9b33..00000000 --- a/src/core/action/apply.rs +++ /dev/null @@ -1,369 +0,0 @@ -//! `App::apply`: the single write path from an [`Action`] into `App`. -//! -//! Ported from the scripting engine's effect drain, which this replaces. -//! Every arm routes through the same `App` method as the equivalent -//! keybinding, so native-streaming fast paths and throttling/coalescing are -//! automatically honoured. - -use crate::core::app::{ActiveBlock, App, RouteId}; -use crate::core::plugin_api; -use crate::infra::network::IoEvent; - -use super::{Action, ActionOutcome, NavTarget, RepeatSetting}; - -impl App { - /// Apply one frontend-neutral action. See the module doc for the rules - /// every arm follows. - pub fn apply(&mut self, action: Action) -> ActionOutcome { - match action { - Action::Play => { - if !effective_is_playing(self) { - self.toggle_playback(); - } - } - Action::Pause => { - if effective_is_playing(self) { - self.toggle_playback(); - } - } - Action::TogglePlayback => self.toggle_playback(), - Action::NextTrack => self.next_track(), - Action::PreviousTrack => self.previous_track(), - Action::ForcePreviousTrack => self.force_previous_track(), - Action::SeekTo(ms) => self.seek_to(ms), - Action::SeekForward => self.seek_forwards(), - Action::SeekBackward => self.seek_backwards(), - Action::SetVolume(v) => self.set_volume_percent(v), - Action::VolumeUp => self.increase_volume(), - Action::VolumeDown => self.decrease_volume(), - Action::SetShuffle(desired) => { - let current = plugin_api::playback_state(self) - .map(|p| p.shuffle) - .unwrap_or(false); - if current != desired { - self.shuffle(); - } - } - Action::ToggleShuffle => self.shuffle(), - Action::CycleRepeat => self.repeat(), - Action::SetRepeat(setting) => { - use rspotify::model::RepeatState; - let state = match setting { - RepeatSetting::Off => RepeatState::Off, - RepeatSetting::Track => RepeatState::Track, - RepeatSetting::Context => RepeatState::Context, - }; - self.dispatch_spotify_fallback(IoEvent::Repeat(state)); - } - Action::PlayUris { uris, offset } => self.start_playback_uris(uris, offset), - Action::PlayContext { uri, offset } => self.start_playback_context(uri, offset), - Action::PlayTrackInContext { context, track } => { - self.start_playback_track_in_context(context, track); - } - Action::TransferPlayback { device_id, persist } => { - self.dispatch(IoEvent::TransferPlaybackToDevice(device_id, persist)); - } - Action::AddToQueue(uri) => self.dispatch(IoEvent::AddItemToQueue(uri)), - Action::QueueTrack(track) => self.add_track_to_native_queue(track), - Action::PlayQueueItem { uri, position } => self.play_queue_item(&uri, position), - Action::RemoveFromQueue { uri, position } => { - self.remove_queue_item(&uri, position); - } - Action::MoveQueueItem { uri, from, to } => { - self.move_queue_item(&uri, from, to); - } - Action::Search(query) => { - let country = self.get_user_country(); - self.dispatch(IoEvent::GetSearchResults(query, country)); - } - Action::SearchActiveSource(query) => match self.active_source { - crate::core::source::Source::Subsonic => { - self.dispatch(IoEvent::GetSubsonicSearchResults(query)); - } - crate::core::source::Source::Radio => { - self.dispatch(IoEvent::GetRadioSearchResults(query)); - } - crate::core::source::Source::YouTube => { - self.dispatch(IoEvent::GetYouTubeSearchResults(query)); - } - crate::core::source::Source::Qobuz => { - self.dispatch(IoEvent::GetQobuzSearchResults(query)); - } - // Spotify and Local both land on the Web API search, exactly like - // the search input's if-chain (which has no Local branch). - crate::core::source::Source::Spotify | crate::core::source::Source::Local => { - let country = self.get_user_country(); - self.dispatch(IoEvent::GetSearchResults(query, country)); - } - }, - Action::SearchPlaylistTracks { playlist_id, query } => { - self.pending_playlist_track_search = Some(query.clone()); - self.set_status_message(format!("Searching playlist for \"{query}\"..."), 60); - self.dispatch(IoEvent::SearchPlaylistTracks(playlist_id, query)); - } - Action::CreatePlaylist { name, track_uris } => { - self.dispatch(IoEvent::CreateNewPlaylist(name, track_uris)); - } - Action::CreateYouTubePlaylist(name) => { - self.dispatch(IoEvent::CreateYouTubePlaylist(name)); - } - Action::SearchTracksForPlaylist(query) => { - self.dispatch(IoEvent::SearchTracksForPlaylist(query)); - } - Action::AddTrackToPlaylist { playlist, track } => { - self.add_track_to_playlist(playlist, track); - } - Action::RemoveTrackFromPlaylist { - playlist, - track, - position, - } => self.remove_track_from_playlist(playlist, track, position), - Action::FollowPlaylist(playlist) => { - // The network handler ignores the owner-id parameter; "unknown" - // mirrors the fallback the built-in follow flow uses. - self.dispatch(IoEvent::UserFollowPlaylist( - "unknown".to_string(), - playlist, - None, - )); - } - Action::UnfollowPlaylist(playlist_id) => { - let user_id = self.user.as_ref().map(|u| u.id.clone()); - if let Some(user_id) = user_id { - self.dispatch(IoEvent::UserUnfollowPlaylist(user_id, playlist_id)); - } else { - self.set_error_status_message( - "Cannot unfollow: user profile not loaded yet".to_string(), - 4, - ); - } - } - Action::DeletePlaylist(uri) => self.dispatch(IoEvent::DeleteYouTubePlaylist(uri)), - Action::ToggleSaveTrack(uri) => self.dispatch(IoEvent::ToggleSaveTrack(uri)), - Action::ToggleSaveCurrentItem => self.toggle_save_current_item(), - Action::SaveAlbum(id) => self.dispatch(IoEvent::CurrentUserSavedAlbumAdd(id)), - Action::UnsaveAlbum(id) => self.dispatch(IoEvent::CurrentUserSavedAlbumDelete(id)), - Action::SaveShow(id) => self.dispatch(IoEvent::CurrentUserSavedShowAdd(id)), - Action::UnsaveShow(id) => self.dispatch(IoEvent::CurrentUserSavedShowDelete(id)), - Action::FollowArtist(id) => self.dispatch(IoEvent::UserFollowArtists(vec![id])), - Action::UnfollowArtist(id) => self.dispatch(IoEvent::UserUnfollowArtists(vec![id])), - Action::AddFriendByCode(code) => self.dispatch(IoEvent::AddFriendByCode(code)), - Action::AddFriendById(user_id) => self.dispatch(IoEvent::AddFriendById(user_id)), - Action::UnfollowFriend(user_id) => self.dispatch(IoEvent::UnfollowFriend(user_id)), - Action::SearchFriendUsers(query) => self.search_friend_users(query), - Action::FavoriteRadioStation(station) => self.favorite_radio_station(station), - Action::RemoveRadioStation(uri) => self.remove_saved_radio_station(uri), - Action::Notify(msg, ttl) => self.set_status_message(msg, ttl), - Action::NotifyError(msg, ttl) => self.set_error_status_message(msg, ttl), - Action::Navigate(target) => apply_navigate(self, target), - Action::Back => { - self.pop_navigation_stack(); - } - Action::LoadMore(target) => match target { - super::ListTarget::PlaylistTracks => self.get_playlist_tracks_next(), - super::ListTarget::SavedTracks => self.get_current_user_saved_tracks_next(), - super::ListTarget::SavedShows => self.get_current_user_saved_shows_next(), - super::ListTarget::ShowEpisodes => self.get_episode_table_next(), - }, - Action::Sort { context, field } => self.apply_sort_field(context, field), - Action::ToggleSortOrder(context) => self.toggle_sort_order(context), - Action::Open(target) => match target { - super::OpenTarget::Album { id, from_search } => { - if from_search { - self.track_table.context = Some(crate::core::app::TrackTableContext::AlbumSearch); - } - self.dispatch(IoEvent::GetAlbum(id)); - } - super::OpenTarget::SavedAlbum(id) => self.open_saved_album(id), - super::OpenTarget::Artist { id, name } => self.get_artist(id, name), - super::OpenTarget::Playlist { id, from_search } => { - // Same silent no-op on an unparseable id as today's opening paths. - if let Some(playlist_id) = crate::infra::network::ids::playlist_id(&id) { - let context = if from_search { - crate::core::app::TrackTableContext::PlaylistSearch - } else { - crate::core::app::TrackTableContext::MyPlaylists - }; - self.open_playlist_tracks(playlist_id, context); - } - } - super::OpenTarget::SourcePlaylist(uri) => self.open_source_playlist_tracks(uri), - super::OpenTarget::PlaylistFolder(target_id) => { - self.current_playlist_folder_id = target_id; - } - super::OpenTarget::Show(id) => self.dispatch(IoEvent::GetShow(id)), - super::OpenTarget::TrackAlbum(track_id) => { - self.dispatch(IoEvent::GetAlbumForTrack(track_id)); - } - }, - Action::OpenShowEpisodes(show) => { - self.dispatch(IoEvent::GetShowEpisodes(Box::new(show))); - } - Action::OpenLibrary(target) => self.open_library_section(target), - Action::OpenDiscover(target) => self.open_discover_mix(target), - Action::SelectSource(source) => { - self.set_active_source(source); - self.load_source_sidebar(source); - } - Action::OpenAddTrackDialog => self.begin_add_track_to_playlist_flow_from_selection(), - Action::OpenAddTrackDialogFor { - track_id, - track_name, - } => self.begin_add_track_to_playlist_flow(track_id, track_name), - Action::OpenAddPlayingTrackDialog => self.begin_add_playing_track_to_playlist_flow(), - Action::OpenRemoveTrackDialog => self.begin_remove_track_from_playlist_flow(), - Action::JumpToAlbum => self.jump_to_album(), - Action::JumpToArtist => self.jump_to_artist_album(), - Action::JumpToContext => self.jump_to_context(), - Action::CopyUrl(target) => match target { - super::CopyTarget::CurrentSong => self.copy_song_url(), - super::CopyTarget::CurrentAlbum => self.copy_album_url(), - }, - Action::GenerateRecap => self.generate_recap(), - Action::CycleStatsPeriod { forward } => self.cycle_stats_period(forward), - Action::RecommendFromTrack(track) => self.load_recommendations_for_track(track), - Action::RecommendFromArtist { id, name } => self.load_recommendations_for_artist(id, name), - Action::RecommendFromTrackId { id, name } => { - self.load_recommendations_for_track_id(id, name); - } - Action::StartParty => self.start_party(), - Action::JoinParty { code, name } => self.join_party(code, name), - Action::LeaveParty => self.dispatch(IoEvent::LeaveParty), - Action::TogglePartyControlMode => self.toggle_party_control_mode(), - Action::SetPlaybarSegment { plugin, text } => match text { - Some(t) => { - self.plugin_playbar_segments.insert(plugin, t); - } - None => { - self.plugin_playbar_segments.remove(&plugin); - } - }, - Action::ShowPopup(popup) => self.show_plugin_popup(popup), - Action::ClosePopup => self.close_plugin_popup(), - Action::SetTheme(pairs) => { - for (field, color) in pairs { - self.user_config.theme.set(field, color); - } - } - Action::SaveSettings => { - return ActionOutcome::SettingsSaved { - saved: self.save_settings_from_items(), - }; - } - Action::CycleVisualizerStyle => self.cycle_visualizer_style(), - Action::AnswerCommunityPinPrompt { keep } => self.answer_community_pin_prompt(keep), - Action::OpenRecap => self.open_recap(), - Action::DismissRecapPrompt => self.dismiss_recap_prompt(), - Action::DisableRecapPrompt => self.disable_recap_prompt(), - Action::SetScreenContent { name, content } => { - self.plugin_screens.insert(name, content); - } - Action::ShowScreen(name) => self.open_plugin_screen(name), - Action::CloseScreen(name) => { - if self.get_current_route().id == RouteId::PluginScreen(name) { - self.pop_navigation_stack(); - } - } - Action::QueueTracks(tracks) => { - #[cfg(feature = "dj-core")] - { - return ActionOutcome::Queued { - accepted: self.extend_native_queue_from_dj(tracks), - }; - } - #[cfg(not(feature = "dj-core"))] - { - let _ = tracks; - return ActionOutcome::Queued { accepted: 0 }; - } - } - Action::SetDjVibe(vibe) => { - #[cfg(feature = "dj-core")] - { - // A new standing direction invalidates any refill already in - // flight for the old one. Exactly one bump: the in-TUI agent - // tells its own set_dj_vibe bump apart from the listener's by - // asserting the generation moved by exactly 1. - self.dj.bump_generation(); - self.dj.vibe = vibe; - } - #[cfg(not(feature = "dj-core"))] - { - let _ = vibe; - } - } - // Gated on `ai-dj`, not `dj-core`: the bodies need the in-app DJ's events. - Action::AskDj(text) => { - #[cfg(feature = "ai-dj")] - { - self.ask_dj(text); - } - #[cfg(not(feature = "ai-dj"))] - { - let _ = text; - } - } - Action::DjVibeShift => { - #[cfg(feature = "ai-dj")] - self.dj_vibe_shift(); - } - Action::ToggleDjAutoQueue => { - #[cfg(feature = "ai-dj")] - self.toggle_dj_auto_queue(); - } - Action::ToggleDjFreshOnly => { - #[cfg(feature = "ai-dj")] - self.toggle_dj_fresh_only(); - } - Action::OpenDjSetup => { - #[cfg(feature = "ai-dj")] - self.open_dj_setup(); - } - Action::DismissDjSetup => { - #[cfg(feature = "ai-dj")] - self.dismiss_dj_setup(); - } - Action::CommitDjSetup => { - #[cfg(feature = "ai-dj")] - self.commit_dj_setup(); - } - } - ActionOutcome::Applied - } -} - -/// Returns `true` when the current playback state indicates active playback. -fn effective_is_playing(app: &App) -> bool { - plugin_api::playback_state(app) - .map(|p| p.is_playing) - .unwrap_or(false) -} - -/// Replicate the matching keybinding for each nav target. -fn apply_navigate(app: &mut App, target: NavTarget) { - match target { - NavTarget::Home => app.push_navigation_stack(RouteId::Home, ActiveBlock::Empty), - NavTarget::Queue => { - app.dispatch(IoEvent::GetQueue); - app.push_navigation_stack(RouteId::Queue, ActiveBlock::Queue); - } - NavTarget::Settings => app.open_settings_screen(), - NavTarget::Devices => app.open_source_device_picker(), - // The help KEY also clears the help filter first (`help_menu::open`); that - // reset needs the TUI's filtered-docs count, so it cannot live here. - NavTarget::Help => app.push_navigation_stack(RouteId::HelpMenu, ActiveBlock::HelpMenu), - NavTarget::Lyrics => app.push_navigation_stack(RouteId::LyricsView, ActiveBlock::LyricsView), - // The network handler pushes the route once the data arrives, exactly like - // the keybinding. - NavTarget::RecentlyPlayed => app.dispatch(IoEvent::GetRecentlyPlayed), - NavTarget::Party => app.push_navigation_stack(RouteId::Party, ActiveBlock::Party), - NavTarget::Analysis => app.get_audio_analysis(), - NavTarget::MiniPlayer => { - if app.get_current_route().id == RouteId::MiniPlayer { - app.pop_navigation_stack(); - } else { - app.push_navigation_stack(RouteId::MiniPlayer, ActiveBlock::MiniPlayer); - } - } - } -} diff --git a/src/core/action/mod.rs b/src/core/action/mod.rs deleted file mode 100644 index 3684b83b..00000000 --- a/src/core/action/mod.rs +++ /dev/null @@ -1,566 +0,0 @@ -//! The shared action vocabulary: one enum of frontend-neutral state changes, -//! applied by [`App::apply`](crate::core::app::App). -//! -//! Every producer that is not the network layer's own result handling routes -//! its `App` mutations through here: the Lua scripting engine drains its -//! queued actions into `apply`, the mutating DJ/MCP tools call `apply` -//! directly, and the terminal handlers adopt it screen by screen as the -//! conversion sub-PRs land. Each arm delegates to the same ownership-aware -//! `App` method the equivalent keybinding uses, so the -//! `queue_owns_playback()` / `active_decoded_source()` / -//! native-streaming predicate order is evaluated in one place and the -//! native fast paths and throttling/coalescing are honoured automatically. -//! -//! Design rules (enforced by review plus the gates in `src/gates.rs`): -//! -//! - No rspotify type appears in [`Action`]; payloads are strings, scalars, -//! and the `core::plugin_api` snapshot types. -//! - No raw `IoEvent` payload either: a producer that can smuggle an -//! arbitrary event is outside the vocabulary. Arms may dispatch events, -//! but the variant set is the contract. -//! - Playback starts go through `App::start_playback_uris` / -//! `App::start_playback_context`; no arm builds a `StartPlayback` event -//! by hand. -//! - Matches in this module are exhaustive: the deny below refuses a -//! catch-all arm, and the `wildcard_arms_in_action_tree` gate keeps the -//! count at zero, tests included. -//! - Address by identity (URIs, ids, names), never by list ordinal. -//! -//! The serde derives are the wire shape for future frontend codegen; they -//! are deliberately in place before any second frontend consumes them. - -// The compiler half of the catch-all gate: a new variant must be placed. -#![deny( - clippy::wildcard_enum_match_arm, - clippy::match_wildcard_for_single_variants -)] - -use serde::{Deserialize, Serialize}; - -use crate::core::app::DiscoverTimeRange; -use crate::core::plugin_api::{PluginPopup, PluginScreenContent, ShowInfo, TrackInfo}; -use crate::core::sort::{SortContext, SortField}; -use crate::core::source::Source; -use crate::core::theme::{Color, ThemeField}; - -mod apply; -#[cfg(test)] -mod tests; - -/// A frontend-neutral state change, applied by `App::apply`. -/// -/// Variant payloads are fully resolved except where a value only `App` knows -/// is needed ([`Action::Search`] resolves the user country and -/// [`Action::UnfollowPlaylist`] the user id at apply time). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum Action { - /// Start playback if it is not already playing (intent, not a toggle). - Play, - /// Pause playback if it is playing (intent, not a toggle). - Pause, - /// Toggle play/pause, matching the toggle-playback key (and MPRIS - /// PlayPause) exactly. - TogglePlayback, - NextTrack, - PreviousTrack, - /// Always go to the previous track (or restart the current queue slot), - /// skipping the ">= 3s restarts the current track" rule of - /// [`Action::PreviousTrack`]. - ForcePreviousTrack, - /// Seek to an absolute position in the current track, in milliseconds. - SeekTo(u32), - /// Seek forwards by the user's configured seek step. - SeekForward, - /// Seek backwards by the user's configured seek step. - SeekBackward, - /// Set the volume to an absolute percentage (0-100). - SetVolume(u8), - /// Raise the volume by the user's configured increment. - VolumeUp, - /// Lower the volume by the user's configured increment. - VolumeDown, - /// Ensure shuffle matches the given state (intent, not a toggle). - SetShuffle(bool), - /// Toggle shuffle, matching the shuffle key exactly (source-gated: decoded - /// queues reorder in place, radio and the native queue slot no-op). - ToggleShuffle, - /// Cycle repeat off, then context, then track, matching the repeat key. - CycleRepeat, - /// Set repeat to an absolute mode. Mirrors the historic Lua `set_repeat`: - /// this is the Web API path only; an ownership-aware absolute setter does - /// not exist yet. - SetRepeat(RepeatSetting), - /// Play an explicit list of playable URIs, optionally from an offset into - /// that list. - PlayUris { - uris: Vec, - offset: Option, - }, - /// Play a Spotify context URI (album/playlist/artist/show), optionally - /// from a 0-based track offset. - PlayContext { - uri: String, - offset: Option, - }, - /// Play one track URI as the first track inside a Spotify context URI: - /// the playlist Enter-start that keeps the selected track first even with - /// shuffle on (the network layer deliberately does not trim the uri list - /// when a context is present). - PlayTrackInContext { - context: String, - track: String, - }, - /// Transfer playback to a Connect device. `persist` records the device as - /// the user's saved preference; producers other than the interactive - /// device picker pass `false`. - TransferPlayback { - device_id: String, - persist: bool, - }, - /// Add one playable URI (track or episode) to the queue. - AddToQueue(String), - /// Add one track to the native cross-source queue (the queue key). Distinct - /// from [`Action::AddToQueue`], the Spotify Web API queue. - QueueTrack(TrackInfo), - /// Jump to one native-queue item, addressed by URI with the 0-based position - /// as the tie-breaker for duplicates. - PlayQueueItem { - uri: String, - position: usize, - }, - /// Addressed like [`Action::PlayQueueItem`]. - RemoveFromQueue { - uri: String, - position: usize, - }, - /// Move a native-queue item to an absolute index; addressed like - /// [`Action::PlayQueueItem`], out of range is a no-op. - MoveQueueItem { - uri: String, - from: usize, - to: usize, - }, - /// Run a search against the Spotify catalog; the user country is resolved - /// at apply time. Deliberately source-blind: existing producers (Lua - /// `spotatui.search`) contracted the Web API search, so browsing scope - /// does not reroute it. The terminal search box uses - /// [`Action::SearchActiveSource`] instead. - Search(String), - /// Run a search against the active browse source's catalog; falls back to - /// the Spotify catalog when the active source has no own search (the - /// Local-files scope behaves this way too). - SearchActiveSource(String), - /// Search one playlist's tracks; apply records the pending search and - /// announces it, exactly like the playlist filter input does today. - SearchPlaylistTracks { - playlist_id: String, - query: String, - }, - CreatePlaylist { - name: String, - track_uris: Vec, - }, - /// Create a playlist in the local YouTube playlists file; a no-op without - /// the `youtube` feature. [`Action::CreatePlaylist`] stays Spotify-only. - CreateYouTubePlaylist(String), - /// Search for tracks to add to the playlist being composed; results land - /// in the create form's candidate list, not the search screen. - SearchTracksForPlaylist(String), - /// `playlist` is a URI: `youtube:playlist:` edits the local YouTube file, - /// anything else is the Spotify add (bare id or `spotify:playlist:` URI). - AddTrackToPlaylist { - playlist: String, - track: String, - }, - /// Routed like [`Action::AddTrackToPlaylist`]; `position` is only read by - /// the Spotify removal. - RemoveTrackFromPlaylist { - playlist: String, - track: String, - /// 0-based position of the track within the playlist. - position: usize, - }, - FollowPlaylist(String), - /// Unfollow a playlist; the current user id is resolved at apply time. - UnfollowPlaylist(String), - /// Delete a local `youtube:playlist:` playlist; Spotify playlists leave - /// through [`Action::UnfollowPlaylist`]. - DeletePlaylist(String), - /// Save or unsave a track by bare base62 id or `spotify:track:` URI; the - /// network layer accepts both. - ToggleSaveTrack(String), - /// Save or unsave whatever is playing now, resolved through the - /// playback-ownership order at apply time (the native queue slot's own - /// track before the cached Spotify context). - ToggleSaveCurrentItem, - SaveAlbum(String), - UnsaveAlbum(String), - SaveShow(String), - UnsaveShow(String), - FollowArtist(String), - UnfollowArtist(String), - /// The spotatui.com social graph (friend code / user id), not Spotify. - AddFriendByCode(String), - AddFriendById(String), - UnfollowFriend(String), - /// A query under the server's two-byte minimum clears the stale results - /// instead of asking. The network layer only accepts a result while the - /// live search buffer still equals the query. - SearchFriendUsers(String), - /// Persist an internet-radio station and mirror it into the sidebar. - FavoriteRadioStation(TrackInfo), - /// Remove a saved station by `radio:` URI. - RemoveRadioStation(String), - /// message, ttl_secs - Notify(String, u64), - /// Error message, ttl_secs. Always shown; blocks normal message - /// overwrites until it expires. - NotifyError(String, u64), - /// Navigate to a top-level surface; apply mirrors the matching keybinding - /// exactly. - Navigate(NavTarget), - /// Pop the navigation stack (same as the back key). - Back, - /// Fetch the next page of a paginated list surface - the shared "hit the - /// end of the list" consequence that GUI infinite scroll also fires. - /// Self-guarding: a no-op when no next page exists. Page-flipped surfaces - /// (saved shows, show episodes) move the visible page when it is cached. - LoadMore(ListTarget), - /// Sort a list surface by a field, like the sort menu: the field already - /// in effect flips the direction instead. - Sort { - context: SortContext, - field: SortField, - }, - /// Flip the recorded sort direction without re-sorting loaded rows. - ToggleSortOrder(SortContext), - /// Open a resource page (album/artist/show/playlist) by id. Never starts - /// playback; Open and Play stay disjoint. - Open(OpenTarget), - /// Open a show's episode list from a snapshot the producer holds - /// ([`OpenTarget::Show`] only knows an id). Top-level because `ShowInfo` - /// has no `Eq`. - OpenShowEpisodes(ShowInfo), - /// Open a library sidebar section and fetch its data, mirroring the - /// library row's Enter consequence exactly. - OpenLibrary(LibraryTarget), - /// Show a Discover row's cached mix, or fetch it. - OpenDiscover(DiscoverTarget), - /// Switch the active browse source (and persist the choice), then fetch - /// the sidebar data that source needs. Browse scope only: never - /// interrupts playback. - SelectSource(Source), - /// Open the add-to-playlist picker for the track table's current - /// selection; resolved from the selection at apply time. - OpenAddTrackDialog, - /// The picker for one named track; `track_id` is `None` when the item - /// cannot be added (a local file, an episode) and apply reports that. - OpenAddTrackDialogFor { - track_id: Option, - track_name: String, - }, - /// The picker for the item playing now, resolved through the ownership - /// order at apply time. - OpenAddPlayingTrackDialog, - /// Stage a remove-track-from-playlist confirmation for the track table's - /// current selection; resolved from the selection at apply time. - OpenRemoveTrackDialog, - /// Open the album page of the item that is playing now; resolved through - /// the ownership order at apply time (episodes open their show). - JumpToAlbum, - /// Open the album list of the first artist of the track that is playing - /// now; resolved through the ownership order at apply time. - JumpToArtist, - /// Open the context (album/artist/playlist) that playback runs in; - /// resolved through the ownership order at apply time. - JumpToContext, - /// Copy a share URL for the item playing now to the clipboard; a silent - /// no-op without playback or a clipboard. - CopyUrl(CopyTarget), - /// Generate a listening recap. The period is resolved at apply time: the - /// selected period on the Stats screen when that screen is current, 30 - /// days anywhere else. - GenerateRecap, - /// Step the Stats period through its ring and reload; relative because the - /// period type is not part of the wire shape. - CycleStatsPeriod { - forward: bool, - }, - /// Seed the track-radio recommendations flow from one track. The full - /// snapshot rides along because the results table prepends it as the - /// context row. - RecommendFromTrack(TrackInfo), - /// `name` is the seed label the results header shows. - RecommendFromArtist { - id: String, - name: String, - }, - /// Like [`Action::RecommendFromTrack`] but prepends no context row. - RecommendFromTrackId { - id: String, - name: String, - }, - /// Always starts in host-only control; [`Action::TogglePartyControlMode`] - /// is the only mode change. - StartParty, - JoinParty { - code: String, - name: String, - }, - LeaveParty, - /// A no-op without a session. - TogglePartyControlMode, - /// Set or clear a playbar segment for a plugin (keyed by plugin name). - SetPlaybarSegment { - plugin: String, - text: Option, - }, - /// Show a plugin popup dialog. - ShowPopup(PluginPopup), - /// Dismiss the plugin popup, if one is shown. - ClosePopup, - /// Apply theme color overrides at runtime. - SetTheme(Vec<(ThemeField, Color)>), - /// Commit the Settings screen's staged rows and write `config.yml`; the - /// outcome says whether the write succeeded. - SaveSettings, - /// Cycle the visualizer style and persist it. - CycleVisualizerStyle, - /// Answer the community-playlist pin prompt: keep the pin, or hide it and - /// persist that. Either way the prompt never asks again. - AnswerCommunityPinPrompt { - keep: bool, - }, - /// Open the monthly recap in the browser and close its popup. - OpenRecap, - /// Close the recap popup without opening the recap. - DismissRecapPrompt, - /// Close the recap popup and switch the monthly prompt off in `config.yml`. - DisableRecapPrompt, - /// Publish (retained) content for a registered plugin screen. - SetScreenContent { - name: String, - content: PluginScreenContent, - }, - /// Navigate to a registered plugin screen. - ShowScreen(String), - /// Pop the named plugin screen if it is the current route. - CloseScreen(String), - /// Queue a batch of DJ-chosen tracks; the outcome reports how many were - /// accepted. A no-op outside `dj-core` builds. - #[cfg_attr(not(feature = "dj-core"), allow(dead_code))] - QueueTracks(Vec), - /// Set (or clear, with `None`) the DJ's standing vibe. Bumps the DJ - /// generation exactly once; the in-TUI agent's adopt-one-bump rule - /// depends on that exact count. A no-op outside `dj-core` builds. - #[cfg_attr(not(feature = "dj-core"), allow(dead_code))] - SetDjVibe(Option), - /// The DJ screen's own keys; no-ops outside `ai-dj` builds. `AskDj` refuses - /// a turn already in flight and bumps the DJ generation exactly once. - #[cfg_attr(not(feature = "ai-dj"), allow(dead_code))] - AskDj(String), - #[cfg_attr(not(feature = "ai-dj"), allow(dead_code))] - DjVibeShift, - /// A toggle, not a setter: the body bumps the generation and may dispatch - /// on every call. - #[cfg_attr(not(feature = "ai-dj"), allow(dead_code))] - ToggleDjAutoQueue, - #[cfg_attr(not(feature = "ai-dj"), allow(dead_code))] - ToggleDjFreshOnly, - /// Opens the DJ screen first when it is not current; does not consult the - /// configured flag. - #[cfg_attr(not(feature = "ai-dj"), allow(dead_code))] - OpenDjSetup, - /// Close the DJ brain picker without changing the brain, and persist the - /// "answered" marker so it does not reappear. - #[cfg_attr(not(feature = "ai-dj"), allow(dead_code))] - DismissDjSetup, - /// Apply the finished DJ brain picker, close it, and persist the choice. - #[cfg_attr(not(feature = "ai-dj"), allow(dead_code))] - CommitDjSetup, -} - -/// What applying an [`Action`] produced, beyond the state change itself. -#[derive(Debug, Clone, PartialEq)] -pub enum ActionOutcome { - /// The action was applied (or routed); nothing further to report. - Applied, - /// [`Action::QueueTracks`]: how many offered tracks entered the queue. - #[cfg_attr(not(feature = "dj-core"), allow(dead_code))] - Queued { accepted: usize }, - /// [`Action::SaveSettings`]: whether `config.yml` was written. - SettingsSaved { saved: bool }, -} - -/// An absolute repeat mode, mirroring Spotify's three states without the -/// rspotify type. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum RepeatSetting { - Off, - Track, - Context, -} - -/// Surfaces reachable through [`Action::Navigate`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum NavTarget { - Home, - Queue, - Settings, - Devices, - Help, - Lyrics, - RecentlyPlayed, - Party, - Analysis, - MiniPlayer, -} - -impl NavTarget { - /// Every target, in the order the Lua API has always advertised them. - pub const ALL: [NavTarget; 10] = [ - NavTarget::Home, - NavTarget::Queue, - NavTarget::Settings, - NavTarget::Devices, - NavTarget::Help, - NavTarget::Lyrics, - NavTarget::RecentlyPlayed, - NavTarget::Party, - NavTarget::Analysis, - NavTarget::MiniPlayer, - ]; - - /// The name used by `spotatui.navigate(name)`. - pub fn name(self) -> &'static str { - match self { - NavTarget::Home => "home", - NavTarget::Queue => "queue", - NavTarget::Settings => "settings", - NavTarget::Devices => "devices", - NavTarget::Help => "help", - NavTarget::Lyrics => "lyrics", - NavTarget::RecentlyPlayed => "recently_played", - NavTarget::Party => "party", - NavTarget::Analysis => "analysis", - NavTarget::MiniPlayer => "miniplayer", - } - } - - /// Look up a target by name; `None` for unknown names. - pub fn from_name(name: &str) -> Option { - NavTarget::ALL.into_iter().find(|t| t.name() == name) - } -} - -/// A paginated list surface [`Action::LoadMore`] can advance. Grows -/// additively as later screens adopt continuous pagination. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum ListTarget { - /// The open playlist's track table (MyPlaylists / PlaylistSearch). - PlaylistTracks, - /// The liked-songs (saved tracks) table. - SavedTracks, - /// The saved-podcasts list (page-flipped, not continuous). - SavedShows, - /// The open show's episode list; the show is resolved at apply time. - ShowEpisodes, -} - -/// A resource deep-link [`Action::Open`] can address, by id. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum OpenTarget { - /// Open an album page. `from_search` pins the track table to the - /// album-search context first, matching the search-results Enter; every - /// other producer passes `false` and leaves the table untouched. - Album { id: String, from_search: bool }, - /// Open a saved album from the saved-albums cache; a truncated tracklist is - /// refetched in full. - SavedAlbum(String), - /// Open an artist page. The display name rides along because - /// `App::get_artist` seeds the header with it before data arrives. - Artist { id: String, name: String }, - /// Open a playlist's track table. `from_search` selects the table context - /// the producer uses (search results vs the user's playlists), matching - /// what each opening path has always passed. - Playlist { id: String, from_search: bool }, - /// A decoded source's playlist or folder, routed by URI scheme (`file:`, - /// `subsonic:`, `youtube:playlist:`, `qobuz:`); an unknown scheme is a no-op. - SourcePlaylist(String), - /// Scope the playlist sidebar to a rootlist folder id (session-local, not - /// to be persisted). - PlaylistFolder(usize), - /// Open a podcast show's episode list. - Show(String), - /// Open the album containing this track. - TrackAlbum(String), -} - -/// Library sidebar sections reachable through [`Action::OpenLibrary`]. -/// Deliberately NOT advertised through the Lua `navigate()` API (unlike -/// [`NavTarget`]): adding rows there would expand the published plugin -/// surface. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] -pub enum LibraryTarget { - #[default] - Discover, - RecentlyPlayed, - Friends, - Stats, - LikedSongs, - Albums, - Artists, - Podcasts, - /// Row only present under `local-files`; a no-op without it. - LocalFiles, - /// Row only present under `ai-dj`; a no-op without it. - AiDj, -} - -impl LibraryTarget { - /// Every target, in the order of `library_row_requirements()`. - #[cfg(test)] - pub const ALL: [LibraryTarget; 10] = [ - LibraryTarget::Discover, - LibraryTarget::RecentlyPlayed, - LibraryTarget::Friends, - LibraryTarget::Stats, - LibraryTarget::LikedSongs, - LibraryTarget::Albums, - LibraryTarget::Artists, - LibraryTarget::Podcasts, - LibraryTarget::LocalFiles, - LibraryTarget::AiDj, - ]; - - /// The sidebar label for this target. - pub fn name(self) -> &'static str { - match self { - LibraryTarget::Discover => "Discover", - LibraryTarget::RecentlyPlayed => "Recently Played", - LibraryTarget::Friends => "Friends", - LibraryTarget::Stats => "Stats", - LibraryTarget::LikedSongs => "Liked Songs", - LibraryTarget::Albums => "Albums", - LibraryTarget::Artists => "Artists", - LibraryTarget::Podcasts => "Podcasts", - LibraryTarget::LocalFiles => "Local Files", - LibraryTarget::AiDj => "AI DJ", - } - } -} - -/// Which Discover row [`Action::OpenDiscover`] activates. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum DiscoverTarget { - ArtistsMix, - TopTracks(DiscoverTimeRange), -} - -/// What [`Action::CopyUrl`] copies; both address the item playing now (an -/// episode gives its episode / show URL). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum CopyTarget { - CurrentSong, - CurrentAlbum, -} diff --git a/src/core/action/tests.rs b/src/core/action/tests.rs deleted file mode 100644 index b310485a..00000000 --- a/src/core/action/tests.rs +++ /dev/null @@ -1,3621 +0,0 @@ -//! Differential tests for `App::apply`: one test per arm (or small arm -//! family), pinning the exact observable behavior — dispatched `IoEvent`s -//! and `App` state — that the scripting effect drain had before the port. -//! -//! House pattern: `app_with_channel` keeps the `IoEvent` receiver alive; -//! `IoEvent` derives nothing, so assertions pattern-match. Catch-all match -//! arms are spelled `_other` because this directory's gate counts bare -//! wildcard arms. - -use std::sync::mpsc::{channel, Receiver}; -use std::time::SystemTime; - -use super::{Action, NavTarget, RepeatSetting}; -use crate::core::app::{App, RouteId, UserInfo, NOTHING_PLAYING_STATUS}; -use crate::core::theme::{Color, Theme, ThemeField}; -use crate::core::user_config::UserConfig; -use crate::infra::network::IoEvent; - -fn app_with_channel() -> (App, Receiver) { - let (tx, rx) = channel(); - let app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - (app, rx) -} - -/// The same fixture with no Spotify session (`spotify_connected == false`). -fn session_free_app_with_channel() -> (App, Receiver) { - let (tx, rx) = channel(); - let app = App::new(tx, UserConfig::new(), None); - (app, rx) -} - -#[allow(deprecated)] -fn playback_context( - is_playing: bool, - shuffle_state: bool, -) -> rspotify::model::CurrentPlaybackContext { - use rspotify::model::{ - context::{Actions, CurrentPlaybackContext}, - CurrentlyPlayingType, Device, DeviceType, RepeatState, - }; - CurrentPlaybackContext { - device: Device { - id: Some("dev-test".to_string()), - is_active: true, - is_private_session: false, - is_restricted: false, - name: "Test Device".to_string(), - _type: DeviceType::Computer, - volume_percent: Some(50), - }, - repeat_state: RepeatState::Off, - shuffle_state, - context: None, - timestamp: chrono::Utc::now(), - progress: None, - is_playing, - item: None, - currently_playing_type: CurrentlyPlayingType::Unknown, - actions: Actions::default(), - } -} - -#[allow(deprecated)] -fn playback_context_with_track(is_playing: bool) -> rspotify::model::CurrentPlaybackContext { - use crate::core::test_helpers::full_track; - use rspotify::model::idtypes::ArtistId; - use rspotify::model::PlayableItem; - let mut ctx = playback_context(is_playing, false); - ctx.progress = Some(chrono::Duration::milliseconds(0)); - let mut track = full_track("4uLU6hMCjMI75M1A2tKUQC", "Test Song"); - // `full_track` leaves the artist id unset; the jump-to-artist arm needs one. - track.artists[0].id = Some( - ArtistId::from_id("0OdUWJ0sBjDrqHygGUXeCF") - .unwrap() - .into_static(), - ); - ctx.item = Some(PlayableItem::Track(track)); - ctx.currently_playing_type = rspotify::model::CurrentlyPlayingType::Track; - ctx -} - -// --- transport --- - -#[test] -fn play_when_paused_dispatches_start_playback() { - let (mut app, rx) = app_with_channel(); - app.current_playback_context = Some(playback_context(false, false)); - - app.apply(Action::Play); - - assert!(matches!( - rx.try_recv(), - Ok(IoEvent::StartPlayback(None, None, None)) - )); -} - -#[test] -fn play_when_already_playing_is_a_noop() { - let (mut app, rx) = app_with_channel(); - app.current_playback_context = Some(playback_context(true, false)); - - app.apply(Action::Play); - - assert!(rx.try_recv().is_err(), "expected no IoEvent dispatched"); -} - -#[test] -fn pause_when_playing_dispatches_pause_playback() { - let (mut app, rx) = app_with_channel(); - app.current_playback_context = Some(playback_context(true, false)); - - app.apply(Action::Pause); - - assert!(matches!(rx.try_recv(), Ok(IoEvent::PausePlayback))); -} - -#[test] -fn pause_when_already_paused_is_a_noop() { - let (mut app, rx) = app_with_channel(); - app.current_playback_context = Some(playback_context(false, false)); - - app.apply(Action::Pause); - - assert!(rx.try_recv().is_err(), "expected no IoEvent dispatched"); -} - -#[test] -fn next_track_routes_through_the_transport_method() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::NextTrack); - - assert!(matches!(rx.try_recv(), Ok(IoEvent::NextTrack))); -} - -#[test] -fn previous_track_routes_through_the_transport_method() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::PreviousTrack); - - assert!(matches!(rx.try_recv(), Ok(IoEvent::PreviousTrack))); -} - -#[test] -fn seek_to_with_a_track_context_dispatches_seek() { - let (mut app, rx) = app_with_channel(); - app.current_playback_context = Some(playback_context_with_track(true)); - - app.apply(Action::SeekTo(30_000)); - - match rx.try_recv() { - Ok(IoEvent::Seek(ms)) => assert_eq!(ms, 30_000), - _other => panic!("expected Seek(30000) (IoEvent is not Debug)"), - } -} - -#[test] -fn set_volume_sets_pending_volume() { - let (mut app, _rx) = app_with_channel(); - - app.apply(Action::SetVolume(80)); - - assert_eq!(app.pending_volume, Some(80)); -} - -#[test] -fn set_shuffle_dispatches_only_when_state_differs() { - let (mut app, rx) = app_with_channel(); - app.current_playback_context = Some(playback_context(false, false)); - - app.apply(Action::SetShuffle(true)); - assert!(matches!(rx.try_recv(), Ok(IoEvent::Shuffle(true)))); - - app.apply(Action::SetShuffle(false)); - assert!(rx.try_recv().is_err(), "same state must not dispatch"); -} - -#[test] -fn cycle_repeat_without_playback_context_is_a_noop() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::CycleRepeat); - - assert!(rx.try_recv().is_err(), "expected no IoEvent dispatched"); -} - -#[test] -fn set_repeat_dispatches_the_absolute_repeat_state() { - use rspotify::model::RepeatState; - for (setting, expected) in [ - (RepeatSetting::Off, RepeatState::Off), - (RepeatSetting::Track, RepeatState::Track), - (RepeatSetting::Context, RepeatState::Context), - ] { - let (mut app, rx) = app_with_channel(); - app.apply(Action::SetRepeat(setting)); - match rx.try_recv() { - Ok(IoEvent::Repeat(state)) => assert_eq!(state, expected), - _other => panic!("expected Repeat (IoEvent is not Debug)"), - } - } -} - -#[test] -fn toggle_playback_pauses_when_playing_and_starts_when_paused() { - let (mut app, rx) = app_with_channel(); - app.current_playback_context = Some(playback_context(true, false)); - - app.apply(Action::TogglePlayback); - assert!(matches!(rx.try_recv(), Ok(IoEvent::PausePlayback))); - - app.current_playback_context = Some(playback_context(false, false)); - app.apply(Action::TogglePlayback); - assert!(matches!( - rx.try_recv(), - Ok(IoEvent::StartPlayback(None, None, None)) - )); -} - -#[test] -fn force_previous_track_dispatches_the_force_event() { - let (mut app, rx) = app_with_channel(); - app.song_progress_ms = 20_000; - - app.apply(Action::ForcePreviousTrack); - - assert!(matches!(rx.try_recv(), Ok(IoEvent::ForcePreviousTrack))); - assert_eq!(app.song_progress_ms, 0, "a forced previous restarts at 0"); -} - -#[test] -fn seek_actions_move_by_the_configured_step() { - // Start one step in, so forward lands at `2 * step + 1_000` and backward - // lands at `1_000`. - for (action, forward) in [(Action::SeekForward, true), (Action::SeekBackward, false)] { - let (mut app, _rx) = app_with_channel(); - app.current_playback_context = Some(playback_context_with_track(true)); - let step = app.user_config.behavior.seek_milliseconds as u128; - app.song_progress_ms = step + 1_000; - - app.apply(action); - - let expected = if forward { 2 * step + 1_000 } else { 1_000 }; - assert_eq!(app.song_progress_ms, expected); - } -} - -#[test] -fn volume_actions_dispatch_the_stepped_volume() { - for (action, up) in [(Action::VolumeUp, true), (Action::VolumeDown, false)] { - let (mut app, rx) = app_with_channel(); - app.current_playback_context = Some(playback_context(true, false)); - - app.apply(action); - - let increment = app.user_config.behavior.volume_increment; - let expected = if up { 50 + increment } else { 50 - increment }; - match rx.try_recv() { - Ok(IoEvent::ChangeVolume(v)) => assert_eq!(v, expected), - _other => panic!("expected ChangeVolume (IoEvent is not Debug)"), - } - } -} - -#[test] -fn toggle_shuffle_flips_the_spotify_shuffle_state() { - let (mut app, rx) = app_with_channel(); - app.current_playback_context = Some(playback_context(true, false)); - - app.apply(Action::ToggleShuffle); - - assert!(matches!(rx.try_recv(), Ok(IoEvent::Shuffle(true)))); -} - -// --- transport without a Spotify session --- - -#[test] -fn next_track_without_a_session_reports_nothing_playing() { - let (mut app, rx) = session_free_app_with_channel(); - - app.apply(Action::NextTrack); - - assert!(rx.try_recv().is_err()); - assert_eq!(app.status_message(), Some(NOTHING_PLAYING_STATUS)); -} - -#[test] -fn toggle_playback_without_a_session_reports_nothing_playing() { - let (mut app, rx) = session_free_app_with_channel(); - - app.apply(Action::TogglePlayback); - - assert!(rx.try_recv().is_err()); - assert_eq!(app.status_message(), Some(NOTHING_PLAYING_STATUS)); -} - -#[test] -fn volume_down_without_a_session_leaves_no_latch() { - // The fixture starts at 100%, so only a decrease reaches the API fallback. - let (mut app, rx) = session_free_app_with_channel(); - - app.apply(Action::VolumeDown); - - assert!(rx.try_recv().is_err()); - assert_eq!(app.status_message(), Some(NOTHING_PLAYING_STATUS)); - assert!(!app.is_volume_change_in_flight); - assert!(app.pending_volume.is_none()); -} - -#[test] -fn set_repeat_without_a_session_reports_nothing_playing() { - let (mut app, rx) = session_free_app_with_channel(); - - app.apply(Action::SetRepeat(RepeatSetting::Track)); - - assert!(rx.try_recv().is_err()); - assert_eq!(app.status_message(), Some(NOTHING_PLAYING_STATUS)); -} - -#[test] -fn flush_pending_volume_without_a_session_clears_the_pending_value() { - let (mut app, rx) = session_free_app_with_channel(); - app.pending_volume = Some(40); - - app.flush_pending_volume(); - - assert!(rx.try_recv().is_err()); - assert!(app.pending_volume.is_none()); -} - -// --- jump-to navigation --- - -#[test] -fn jump_to_album_opens_the_current_tracks_album() { - let (mut app, rx) = app_with_channel(); - app.current_playback_context = Some(playback_context_with_track(false)); - - app.apply(Action::JumpToAlbum); - - match rx.try_recv() { - Ok(IoEvent::GetAlbumTracks(album)) => assert_eq!(album.name, "Test Album"), - _other => panic!("expected GetAlbumTracks (IoEvent is not Debug)"), - } -} - -#[test] -fn jump_to_album_without_playback_reports_nothing_playing() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::JumpToAlbum); - - assert!(rx.try_recv().is_err(), "expected no IoEvent dispatched"); - assert_eq!(app.status_message(), Some(NOTHING_PLAYING_STATUS)); -} - -#[test] -fn jump_to_artist_opens_the_first_artists_albums() { - let (mut app, rx) = app_with_channel(); - app.current_playback_context = Some(playback_context_with_track(false)); - - app.apply(Action::JumpToArtist); - - match rx.try_recv() { - Ok(IoEvent::GetArtist(id, name, _country)) => { - assert_eq!(id, "0OdUWJ0sBjDrqHygGUXeCF"); - assert_eq!(name, "Test Artist"); - } - _other => panic!("expected GetArtist (IoEvent is not Debug)"), - } -} - -#[test] -fn jump_to_context_opens_the_playlist_context() { - use rspotify::model::{context::Context, enums::Type}; - let (mut app, rx) = app_with_channel(); - let mut ctx = playback_context_with_track(true); - ctx.context = Some(Context { - uri: "spotify:playlist:37i9dQZF1DX4WYpdgoIcn6".to_string(), - href: String::new(), - external_urls: std::collections::HashMap::new(), - _type: Type::Playlist, - }); - app.current_playback_context = Some(ctx); - - app.apply(Action::JumpToContext); - - assert_eq!(app.get_current_route().id, RouteId::TrackTable); - match rx.try_recv() { - Ok(IoEvent::GetPlaylistItems(id, _offset)) => { - assert_eq!(id, "37i9dQZF1DX4WYpdgoIcn6"); - } - _other => panic!("expected GetPlaylistItems (IoEvent is not Debug)"), - } -} - -// --- recap --- - -#[test] -fn generate_recap_resolves_the_period_from_the_current_route() { - use crate::core::app::ActiveBlock; - use crate::infra::history::RecapPeriod; - // The stats selection is set in both cases, so the off-Stats case proves - // the selection is ignored anywhere else. - for (on_stats_screen, expected) in [(false, RecapPeriod::ThirtyDays), (true, RecapPeriod::Year)] { - let (mut app, rx) = app_with_channel(); - app.stats_period = RecapPeriod::Year; - if on_stats_screen { - app.push_navigation_stack(RouteId::Stats, ActiveBlock::Stats); - } - - app.apply(Action::GenerateRecap); - - match rx.try_recv() { - Ok(IoEvent::GenerateRecap(period)) => assert_eq!(period, expected), - _other => panic!("expected GenerateRecap (IoEvent is not Debug)"), - } - } -} - -// --- playback starts --- - -#[test] -fn play_uris_dispatches_a_uri_list_start() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::PlayUris { - uris: vec!["spotify:track:abc".to_string()], - offset: None, - }); - - match rx.try_recv() { - Ok(IoEvent::StartPlayback(None, Some(uris), None)) => { - assert_eq!(uris, vec!["spotify:track:abc".to_string()]); - } - _other => panic!("expected a uri-list StartPlayback (IoEvent is not Debug)"), - } -} - -#[test] -fn play_uris_carries_the_offset() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::PlayUris { - uris: vec!["spotify:track:abc".to_string()], - offset: Some(0), - }); - - assert!(matches!( - rx.try_recv(), - Ok(IoEvent::StartPlayback(None, Some(_), Some(0))) - )); -} - -#[test] -fn play_uris_with_an_empty_list_dispatches_nothing() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::PlayUris { - uris: vec![], - offset: None, - }); - - assert!(rx.try_recv().is_err()); -} - -#[test] -fn play_context_dispatches_a_context_start() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::PlayContext { - uri: "spotify:playlist:p1".to_string(), - offset: Some(5), - }); - - match rx.try_recv() { - Ok(IoEvent::StartPlayback(Some(ctx), None, Some(offset))) => { - assert_eq!(ctx, "spotify:playlist:p1"); - assert_eq!(offset, 5); - } - _other => panic!("expected a context StartPlayback (IoEvent is not Debug)"), - } -} - -#[test] -fn transfer_playback_carries_device_and_persist() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::TransferPlayback { - device_id: "dev-1".to_string(), - persist: false, - }); - - match rx.try_recv() { - Ok(IoEvent::TransferPlaybackToDevice(id, persist)) => { - assert_eq!(id, "dev-1"); - assert!(!persist); - } - _other => panic!("expected TransferPlaybackToDevice (IoEvent is not Debug)"), - } -} - -#[test] -fn add_to_queue_dispatches_add_item() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::AddToQueue("spotify:track:t1".to_string())); - - match rx.try_recv() { - Ok(IoEvent::AddItemToQueue(uri)) => assert_eq!(uri, "spotify:track:t1"), - _other => panic!("expected AddItemToQueue (IoEvent is not Debug)"), - } -} - -// --- library / playlists --- - -#[test] -fn search_resolves_the_user_country_at_apply_time() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::Search("daft punk".to_string())); - - match rx.try_recv() { - Ok(IoEvent::GetSearchResults(query, country)) => { - assert_eq!(query, "daft punk"); - assert_eq!(country, None, "no user loaded, so no market"); - } - _other => panic!("expected GetSearchResults (IoEvent is not Debug)"), - } -} - -#[test] -fn create_playlist_dispatches_create() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::CreatePlaylist { - name: "Mix".to_string(), - track_uris: vec!["spotify:track:a".to_string(), "spotify:track:b".to_string()], - }); - - match rx.try_recv() { - Ok(IoEvent::CreateNewPlaylist(name, uris)) => { - assert_eq!(name, "Mix"); - assert_eq!(uris.len(), 2); - } - _other => panic!("expected CreateNewPlaylist (IoEvent is not Debug)"), - } -} - -#[test] -fn add_track_to_playlist_dispatches() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::AddTrackToPlaylist { - playlist: "p1".to_string(), - track: "t1".to_string(), - }); - - match rx.try_recv() { - Ok(IoEvent::AddTrackToPlaylist(playlist, track)) => { - assert_eq!(playlist, "p1"); - assert_eq!(track, "t1"); - } - _other => panic!("expected AddTrackToPlaylist (IoEvent is not Debug)"), - } -} - -#[test] -fn remove_track_from_playlist_dispatches_with_position() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::RemoveTrackFromPlaylist { - playlist: "p1".to_string(), - track: "t1".to_string(), - position: 3, - }); - - match rx.try_recv() { - Ok(IoEvent::RemoveTrackFromPlaylistAtPosition(playlist, track, position)) => { - assert_eq!(playlist, "p1"); - assert_eq!(track, "t1"); - assert_eq!(position, 3); - } - _other => panic!("expected RemoveTrackFromPlaylistAtPosition (IoEvent is not Debug)"), - } -} - -#[test] -fn add_track_to_playlist_routes_a_youtube_playlist_to_the_local_edit() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::AddTrackToPlaylist { - playlist: "youtube:playlist:y1".to_string(), - track: "v1".to_string(), - }); - - match rx.try_recv() { - Ok(IoEvent::AddTrackToYouTubePlaylist(playlist, track)) => { - assert_eq!(playlist, "youtube:playlist:y1"); - assert_eq!(track, "v1"); - } - _other => panic!("expected AddTrackToYouTubePlaylist (IoEvent is not Debug)"), - } -} - -#[test] -fn add_track_to_playlist_keeps_a_spotify_uri_on_the_web_api() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::AddTrackToPlaylist { - playlist: "spotify:playlist:37i9dQZF1DXcBWIGoYBM5M".to_string(), - track: "t1".to_string(), - }); - - match rx.try_recv() { - Ok(IoEvent::AddTrackToPlaylist(playlist, track)) => { - assert_eq!(playlist, "spotify:playlist:37i9dQZF1DXcBWIGoYBM5M"); - assert_eq!(track, "t1"); - } - _other => panic!("expected AddTrackToPlaylist (IoEvent is not Debug)"), - } -} - -#[test] -fn remove_track_from_playlist_routes_a_youtube_playlist_to_the_local_edit() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::RemoveTrackFromPlaylist { - playlist: "youtube:playlist:y1".to_string(), - track: "v1".to_string(), - position: 3, - }); - - match rx.try_recv() { - // The local edit removes by video id: the staged position is not carried. - Ok(IoEvent::RemoveTrackFromYouTubePlaylist(playlist, track)) => { - assert_eq!(playlist, "youtube:playlist:y1"); - assert_eq!(track, "v1"); - } - _other => panic!("expected RemoveTrackFromYouTubePlaylist (IoEvent is not Debug)"), - } -} - -#[test] -fn follow_playlist_uses_the_unknown_owner_fallback() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::FollowPlaylist("p1".to_string())); - - match rx.try_recv() { - Ok(IoEvent::UserFollowPlaylist(owner, playlist, public)) => { - assert_eq!(owner, "unknown"); - assert_eq!(playlist, "p1"); - assert_eq!(public, None); - } - _other => panic!("expected UserFollowPlaylist (IoEvent is not Debug)"), - } -} - -#[test] -fn unfollow_playlist_resolves_the_current_user() { - let (mut app, rx) = app_with_channel(); - app.user = Some(UserInfo { - id: "me-123".to_string(), - display_name: None, - country: None, - }); - - app.apply(Action::UnfollowPlaylist("p1".to_string())); - - match rx.try_recv() { - Ok(IoEvent::UserUnfollowPlaylist(user_id, playlist_id)) => { - assert_eq!(user_id, "me-123"); - assert_eq!(playlist_id, "p1"); - } - _other => panic!("expected UserUnfollowPlaylist (IoEvent is not Debug)"), - } -} - -#[test] -fn unfollow_playlist_without_a_user_sets_an_error_status() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::UnfollowPlaylist("p1".to_string())); - - assert!(rx.try_recv().is_err(), "no IoEvent expected"); - assert!(app.status_message_is_error()); - assert_eq!( - app.status_message(), - Some("Cannot unfollow: user profile not loaded yet") - ); -} - -#[test] -fn save_and_follow_actions_dispatch_their_events() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::ToggleSaveTrack("spotify:track:t1".to_string())); - assert!(matches!(rx.try_recv(), Ok(IoEvent::ToggleSaveTrack(_)))); - - app.apply(Action::SaveAlbum("a1".to_string())); - assert!(matches!( - rx.try_recv(), - Ok(IoEvent::CurrentUserSavedAlbumAdd(_)) - )); - - app.apply(Action::UnsaveAlbum("a1".to_string())); - assert!(matches!( - rx.try_recv(), - Ok(IoEvent::CurrentUserSavedAlbumDelete(_)) - )); - - app.apply(Action::SaveShow("s1".to_string())); - assert!(matches!( - rx.try_recv(), - Ok(IoEvent::CurrentUserSavedShowAdd(_)) - )); - - app.apply(Action::UnsaveShow("s1".to_string())); - assert!(matches!( - rx.try_recv(), - Ok(IoEvent::CurrentUserSavedShowDelete(_)) - )); -} - -#[test] -fn follow_artist_wraps_the_id_in_a_batch_event() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::FollowArtist("ar1".to_string())); - match rx.try_recv() { - Ok(IoEvent::UserFollowArtists(ids)) => assert_eq!(ids, vec!["ar1".to_string()]), - _other => panic!("expected UserFollowArtists (IoEvent is not Debug)"), - } - - app.apply(Action::UnfollowArtist("ar1".to_string())); - match rx.try_recv() { - Ok(IoEvent::UserUnfollowArtists(ids)) => assert_eq!(ids, vec!["ar1".to_string()]), - _other => panic!("expected UserUnfollowArtists (IoEvent is not Debug)"), - } -} - -// --- status messages --- - -#[test] -fn notify_sets_a_status_message() { - let (mut app, _rx) = app_with_channel(); - - app.apply(Action::Notify("hello".to_string(), 4)); - - assert_eq!(app.status_message(), Some("hello")); - assert!(!app.status_message_is_error()); -} - -#[test] -fn notify_error_blocks_a_following_notify() { - let (mut app, _rx) = app_with_channel(); - - app.apply(Action::NotifyError("boom".to_string(), 6)); - app.apply(Action::Notify("normal".to_string(), 4)); - - assert_eq!(app.status_message(), Some("boom")); - assert!(app.status_message_is_error()); -} - -// --- navigation --- - -#[test] -fn navigate_home_returns_to_the_home_route() { - let (mut app, _rx) = app_with_channel(); - app.apply(Action::Navigate(NavTarget::Queue)); - assert_eq!(app.get_current_route().id, RouteId::Queue); - - app.apply(Action::Navigate(NavTarget::Home)); - - assert_eq!(app.get_current_route().id, RouteId::Home); -} - -#[test] -fn navigate_queue_pushes_the_route_and_fetches() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::Navigate(NavTarget::Queue)); - - assert_eq!(app.get_current_route().id, RouteId::Queue); - assert!(matches!(rx.try_recv(), Ok(IoEvent::GetQueue))); -} - -#[test] -fn navigate_settings_opens_the_settings_screen() { - let (mut app, _rx) = app_with_channel(); - - app.apply(Action::Navigate(NavTarget::Settings)); - - assert_eq!(app.get_current_route().id, RouteId::Settings); -} - -#[test] -fn navigate_devices_opens_the_device_picker() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::Navigate(NavTarget::Devices)); - - assert_eq!(app.get_current_route().id, RouteId::SelectedDevice); - assert!(matches!(rx.try_recv(), Ok(IoEvent::GetDevices))); -} - -#[test] -fn navigate_help_lyrics_and_party_push_their_routes() { - let (mut app, _rx) = app_with_channel(); - - app.apply(Action::Navigate(NavTarget::Help)); - assert_eq!(app.get_current_route().id, RouteId::HelpMenu); - - app.apply(Action::Navigate(NavTarget::Lyrics)); - assert_eq!(app.get_current_route().id, RouteId::LyricsView); - - app.apply(Action::Navigate(NavTarget::Party)); - assert_eq!(app.get_current_route().id, RouteId::Party); -} - -#[test] -fn navigate_recently_played_only_dispatches() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::Navigate(NavTarget::RecentlyPlayed)); - - // The network handler pushes the route once the data arrives. - assert_eq!(app.get_current_route().id, RouteId::Home); - assert!(matches!(rx.try_recv(), Ok(IoEvent::GetRecentlyPlayed))); -} - -#[test] -fn navigate_analysis_pushes_the_analysis_route() { - let (mut app, _rx) = app_with_channel(); - - app.apply(Action::Navigate(NavTarget::Analysis)); - - assert_eq!(app.get_current_route().id, RouteId::Analysis); -} - -#[test] -fn navigate_miniplayer_toggles() { - let (mut app, _rx) = app_with_channel(); - - app.apply(Action::Navigate(NavTarget::MiniPlayer)); - assert_eq!(app.get_current_route().id, RouteId::MiniPlayer); - - app.apply(Action::Navigate(NavTarget::MiniPlayer)); - assert_eq!(app.get_current_route().id, RouteId::Home); -} - -#[test] -fn back_pops_the_navigation_stack() { - let (mut app, _rx) = app_with_channel(); - app.apply(Action::Navigate(NavTarget::Queue)); - assert_eq!(app.get_current_route().id, RouteId::Queue); - - app.apply(Action::Back); - - assert_eq!(app.get_current_route().id, RouteId::Home); -} - -// --- plugin surfaces --- - -#[test] -fn set_playbar_segment_inserts_and_removes() { - let (mut app, _rx) = app_with_channel(); - - app.apply(Action::SetPlaybarSegment { - plugin: "clock".to_string(), - text: Some("12:00".to_string()), - }); - assert_eq!( - app.plugin_playbar_segments.get("clock").map(String::as_str), - Some("12:00") - ); - - app.apply(Action::SetPlaybarSegment { - plugin: "clock".to_string(), - text: None, - }); - assert!(!app.plugin_playbar_segments.contains_key("clock")); -} - -#[test] -fn show_popup_sets_the_popup() { - // The paired scroll reset is pinned by `core/app/plugins.rs` tests, which - // can seed the scroll field first. - use crate::core::plugin_api::{PluginPopup, PopupLine}; - let (mut app, _rx) = app_with_channel(); - - app.apply(Action::ShowPopup(PluginPopup { - title: "Hi".to_string(), - lines: vec![PopupLine { - text: "line".to_string(), - fg: None, - bold: false, - italic: false, - }], - })); - - assert_eq!( - app.plugin_popup.as_ref().map(|p| p.title.as_str()), - Some("Hi") - ); -} - -#[test] -fn close_popup_clears_the_popup() { - // The paired scroll reset is pinned by `core/app/plugins.rs` tests, which - // can seed the scroll field first. - let (mut app, _rx) = app_with_channel(); - app.show_plugin_popup(crate::core::plugin_api::PluginPopup { - title: "Hi".to_string(), - lines: Vec::new(), - }); - - app.apply(Action::ClosePopup); - - assert!(app.plugin_popup.is_none()); -} - -/// Reverse accessor for the loop test below, exhaustive over `ThemeField` -/// (the `Theme`-field direction is guarded by the full destructuring inside -/// `Theme::set`). -fn theme_color(theme: &Theme, field: ThemeField) -> Color { - match field { - ThemeField::Active => theme.active, - ThemeField::Banner => theme.banner, - ThemeField::ErrorBorder => theme.error_border, - ThemeField::ErrorText => theme.error_text, - ThemeField::Hint => theme.hint, - ThemeField::Hovered => theme.hovered, - ThemeField::Inactive => theme.inactive, - ThemeField::PlaybarBackground => theme.playbar_background, - ThemeField::PlaybarProgress => theme.playbar_progress, - ThemeField::PlaybarProgressText => theme.playbar_progress_text, - ThemeField::PlaybarText => theme.playbar_text, - ThemeField::Selected => theme.selected, - ThemeField::Text => theme.text, - ThemeField::Background => theme.background, - ThemeField::Header => theme.header, - ThemeField::HighlightedLyrics => theme.highlighted_lyrics, - ThemeField::AnalysisBar => theme.analysis_bar, - ThemeField::AnalysisBarText => theme.analysis_bar_text, - } -} - -#[test] -fn set_theme_writes_every_field() { - let sentinel = Color::Rgb(1, 2, 3); - for field in ThemeField::ALL { - let (mut app, _rx) = app_with_channel(); - assert_ne!( - theme_color(&app.user_config.theme, field), - sentinel, - "sentinel must differ from the default for {}", - field.name() - ); - - app.apply(Action::SetTheme(vec![(field, sentinel)])); - - assert_eq!( - theme_color(&app.user_config.theme, field), - sentinel, - "field {} was not written", - field.name() - ); - } -} - -#[test] -fn theme_field_names_round_trip() { - for field in ThemeField::ALL { - assert_eq!(ThemeField::from_name(field.name()), Some(field)); - } - assert_eq!(ThemeField::from_name("not_a_field"), None); -} - -#[test] -fn nav_target_names_round_trip() { - for target in NavTarget::ALL { - assert_eq!(NavTarget::from_name(target.name()), Some(target)); - } - assert_eq!(NavTarget::from_name("nowhere"), None); -} - -#[test] -fn set_screen_content_inserts() { - use crate::core::plugin_api::PluginScreenContent; - let (mut app, _rx) = app_with_channel(); - - app.apply(Action::SetScreenContent { - name: "stats".to_string(), - content: PluginScreenContent { - title: "Stats".to_string(), - widgets: Vec::new(), - }, - }); - - assert!(app.plugin_screens.contains_key("stats")); -} - -#[test] -fn show_screen_pushes_the_plugin_route_and_close_pops() { - let (mut app, _rx) = app_with_channel(); - - app.apply(Action::ShowScreen("stats".to_string())); - assert_eq!( - app.get_current_route().id, - RouteId::PluginScreen("stats".to_string()) - ); - - // Showing the already-current screen must not stack a second frame. The - // scroll reset is pinned by `core/app/plugins.rs` tests. - app.apply(Action::ShowScreen("stats".to_string())); - - app.apply(Action::CloseScreen("stats".to_string())); - assert_eq!(app.get_current_route().id, RouteId::Home); -} - -#[test] -fn close_screen_only_pops_when_current() { - let (mut app, _rx) = app_with_channel(); - app.apply(Action::ShowScreen("stats".to_string())); - - app.apply(Action::CloseScreen("other".to_string())); - - assert_eq!( - app.get_current_route().id, - RouteId::PluginScreen("stats".to_string()) - ); -} - -// --- DJ --- - -#[cfg(feature = "dj-core")] -mod dj_actions { - use super::*; - use crate::core::action::ActionOutcome; - use crate::core::plugin_api::TrackInfo; - - fn dj_track(uri: Option<&str>, name: &str) -> TrackInfo { - TrackInfo { - uri: uri.map(str::to_string), - name: name.to_string(), - artists: vec!["Artist".to_string()], - album: "Album".to_string(), - duration_ms: 200_000, - id: None, - album_id: None, - artist_refs: Vec::new(), - is_playable: true, - is_local: false, - track_number: 0, - explicit: false, - image_url: None, - } - } - - #[test] - fn queue_tracks_reports_the_accepted_count() { - let (mut app, _rx) = app_with_channel(); - - let outcome = app.apply(Action::QueueTracks(vec![ - dj_track(Some("subsonic:track:a"), "A"), - dj_track(Some("subsonic:track:b"), "B"), - ])); - - assert_eq!(outcome, ActionOutcome::Queued { accepted: 2 }); - assert_eq!(app.native_queue.len(), 2); - assert!(app.dj.queued_uris.contains("subsonic:track:a")); - } - - #[test] - fn queue_tracks_skips_tracks_without_a_uri() { - let (mut app, _rx) = app_with_channel(); - - let outcome = app.apply(Action::QueueTracks(vec![ - dj_track(Some("subsonic:track:a"), "A"), - dj_track(None, "No URI"), - ])); - - assert_eq!(outcome, ActionOutcome::Queued { accepted: 1 }); - assert_eq!(app.native_queue.len(), 1); - } - - #[test] - fn set_dj_vibe_bumps_the_generation_exactly_once() { - let (mut app, _rx) = app_with_channel(); - let before = app.dj.generation; - - app.apply(Action::SetDjVibe(Some("mellow".to_string()))); - - assert_eq!( - app.dj.generation, - before.wrapping_add(1), - "the turn loop's adopt-one-bump rule needs exactly one bump" - ); - assert_eq!(app.dj.vibe.as_deref(), Some("mellow")); - } - - #[test] - fn set_dj_vibe_clears_with_none() { - let (mut app, _rx) = app_with_channel(); - app.apply(Action::SetDjVibe(Some("mellow".to_string()))); - - app.apply(Action::SetDjVibe(None)); - - assert_eq!(app.dj.vibe, None); - } -} - -// --- pagination, open family, source-routed search, library rows, dialogs --- - -use super::{LibraryTarget, ListTarget, OpenTarget}; -use crate::core::app::{ActiveBlock, DialogContext, PendingTrackSelection, RecommendationsContext}; -use crate::core::pagination::Paged; -use crate::core::plugin_api::{AlbumInfo, PlaylistInfo, SavedAlbumInfo, TrackInfo}; -use crate::core::source::Source; -use crate::core::test_helpers::{full_track, playlist_info}; - -fn track(id: &str, name: &str) -> TrackInfo { - TrackInfo::from(&full_track(id, name)) -} - -fn track_page(offset: u32, ids: &[&str], has_next: bool) -> Paged { - Paged { - items: ids - .iter() - .enumerate() - .map(|(i, id)| track(id, &format!("Track {offset}-{i}"))) - .collect(), - offset, - limit: ids.len() as u32, - total: 4, - next: has_next.then(|| "https://example.com/next".to_string()), - previous: None, - } -} - -/// A saved-albums row with `cached` of `total` tracks embedded. -fn saved_albums_page(cached: usize, total: u32) -> Paged { - Paged { - items: vec![SavedAlbumInfo { - album: AlbumInfo { - id: Some("5gzLOflH95LkKYE6XSXE9k".to_string()), - uri: Some("spotify:album:5gzLOflH95LkKYE6XSXE9k".to_string()), - name: "One Wayne G".to_string(), - total_tracks: Some(total), - tracks: (0..cached) - .map(|i| track("0000000000000000000001", &format!("Track {i}"))) - .collect(), - ..AlbumInfo::default() - }, - added_at: String::new(), - }], - offset: 0, - limit: 1, - total: 1, - next: None, - previous: None, - } -} - -/// The playlist track table's page type carries snapshot positions. -fn playlist_items_page(has_next: bool) -> Paged<(u32, crate::core::plugin_api::PlayableInfo)> { - Paged { - items: vec![( - 0, - crate::core::plugin_api::PlayableInfo::Track(track("0000000000000000000001", "T1")), - )], - offset: 0, - limit: 1, - total: 4, - next: has_next.then(|| "https://example.com/next".to_string()), - previous: None, - } -} - -// --- LoadMore --- - -#[test] -fn load_more_playlist_tracks_dispatches_the_next_page_fetch() { - let (mut app, rx) = app_with_channel(); - app.playlist_track_table_id = Some( - rspotify::model::idtypes::PlaylistId::from_id("37i9dQZF1DX4WYpdgoIcn6") - .unwrap() - .into_static(), - ); - let page = playlist_items_page(true); - app.track_table.context = Some(crate::core::app::TrackTableContext::MyPlaylists); - app.playlist_tracks = Some(page.clone()); - app.playlist_track_pages.upsert_page_by_offset(page); - - app.apply(Action::LoadMore(ListTarget::PlaylistTracks)); - - match rx.try_recv() { - Ok(IoEvent::GetPlaylistItems(id, offset)) => { - assert_eq!(id, "37i9dQZF1DX4WYpdgoIcn6"); - assert_eq!(offset, 1); - } - _other => panic!("expected GetPlaylistItems"), - } -} - -#[test] -fn load_more_playlist_tracks_is_a_noop_under_an_active_filter() { - let (mut app, rx) = app_with_channel(); - app.playlist_track_table_id = Some( - rspotify::model::idtypes::PlaylistId::from_id("37i9dQZF1DX4WYpdgoIcn6") - .unwrap() - .into_static(), - ); - let page = playlist_items_page(true); - app.track_table.context = Some(crate::core::app::TrackTableContext::MyPlaylists); - app.playlist_tracks = Some(page.clone()); - app.playlist_track_pages.upsert_page_by_offset(page); - app.active_playlist_track_filter = Some("query".to_string()); - - app.apply(Action::LoadMore(ListTarget::PlaylistTracks)); - - assert!(rx.try_recv().is_err(), "filtered playlists never paginate"); -} - -#[test] -fn load_more_saved_tracks_fetches_the_missing_continuous_page() { - let (mut app, rx) = app_with_channel(); - let page = track_page( - 0, - &["0000000000000000000001", "0000000000000000000002"], - true, - ); - app.library.saved_tracks.upsert_page_by_offset(page); - - app.apply(Action::LoadMore(ListTarget::SavedTracks)); - - assert!(matches!( - rx.try_recv(), - Ok(IoEvent::GetCurrentSavedTracks(Some(2))) - )); -} - -#[test] -fn load_more_saved_tracks_is_a_noop_at_the_end_of_the_list() { - let (mut app, rx) = app_with_channel(); - let page = track_page(0, &["0000000000000000000001"], false); - app.library.saved_tracks.upsert_page_by_offset(page); - - app.apply(Action::LoadMore(ListTarget::SavedTracks)); - - assert!(rx.try_recv().is_err()); -} - -#[test] -fn load_more_does_not_touch_pending_track_table_selection() { - let (mut app, rx) = app_with_channel(); - let page = track_page(0, &["0000000000000000000001"], false); - app.library.saved_tracks.upsert_page_by_offset(page); - app.select_row_when_next_page_lands(9); - - app.apply(Action::LoadMore(ListTarget::SavedTracks)); - - assert_eq!( - app.pending_track_table_selection(), - Some(PendingTrackSelection::Index(9)), - "the cursor-follow side effect belongs to the caller, not to LoadMore" - ); - assert!(rx.try_recv().is_err()); -} - -#[test] -fn load_more_saved_tracks_does_not_refetch_an_in_flight_page() { - let (mut app, rx) = app_with_channel(); - let page = track_page( - 0, - &["0000000000000000000001", "0000000000000000000002"], - true, - ); - app.library.saved_tracks.upsert_page_by_offset(page); - - app.apply(Action::LoadMore(ListTarget::SavedTracks)); - assert!(matches!( - rx.try_recv(), - Ok(IoEvent::GetCurrentSavedTracks(Some(2))) - )); - - // The fetch marked offset 2 as in flight; a second apply must not - // re-dispatch it (the prefetch dedupe the arm inherits). - app.apply(Action::LoadMore(ListTarget::SavedTracks)); - assert!(rx.try_recv().is_err()); -} - -#[test] -fn load_more_playlist_tracks_does_not_refetch_an_in_flight_page() { - let (mut app, rx) = app_with_channel(); - app.playlist_track_table_id = Some( - rspotify::model::idtypes::PlaylistId::from_id("37i9dQZF1DX4WYpdgoIcn6") - .unwrap() - .into_static(), - ); - app.track_table.context = Some(crate::core::app::TrackTableContext::MyPlaylists); - let page = playlist_items_page(true); - app.playlist_tracks = Some(page.clone()); - app.playlist_track_pages.upsert_page_by_offset(page); - - app.apply(Action::LoadMore(ListTarget::PlaylistTracks)); - assert!(matches!(rx.try_recv(), Ok(IoEvent::GetPlaylistItems(_, 1)))); - - app.apply(Action::LoadMore(ListTarget::PlaylistTracks)); - assert!( - rx.try_recv().is_err(), - "the in-flight page is not refetched" - ); -} - -// --- Open family --- - -#[test] -fn open_album_without_from_search_leaves_the_track_table_alone() { - let (mut app, rx) = app_with_channel(); - app.track_table.context = Some(crate::core::app::TrackTableContext::SavedTracks); - - app.apply(Action::Open(OpenTarget::Album { - id: "5gzLOflH95LkKYE6XSXE9k".to_string(), - from_search: false, - })); - - assert_eq!( - app.track_table.context, - Some(crate::core::app::TrackTableContext::SavedTracks), - "a plain album open never restamps the table context" - ); - assert!(matches!( - rx.try_recv(), - Ok(IoEvent::GetAlbum(id)) if id == "5gzLOflH95LkKYE6XSXE9k" - )); -} - -#[test] -fn open_album_from_search_pins_the_album_search_context() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::Open(OpenTarget::Album { - id: "5gzLOflH95LkKYE6XSXE9k".to_string(), - from_search: true, - })); - - assert_eq!( - app.track_table.context, - Some(crate::core::app::TrackTableContext::AlbumSearch) - ); - assert!(matches!( - rx.try_recv(), - Ok(IoEvent::GetAlbum(id)) if id == "5gzLOflH95LkKYE6XSXE9k" - )); -} - -#[test] -fn open_saved_album_with_a_complete_cached_tracklist_opens_from_the_cache() { - let (mut app, rx) = app_with_channel(); - app - .library - .saved_albums - .upsert_page_by_offset(saved_albums_page(2, 2)); - - app.apply(Action::Open(OpenTarget::SavedAlbum( - "5gzLOflH95LkKYE6XSXE9k".to_string(), - ))); - - assert!(app.selected_album_full.is_some()); - assert_eq!( - app.album_table_context, - crate::core::app::AlbumTableContext::Full - ); - assert_eq!(app.get_current_route().id, RouteId::AlbumTracks); - assert!( - rx.try_recv().is_err(), - "a complete cached tracklist needs no round trip" - ); -} - -#[test] -fn open_saved_album_with_a_truncated_cached_tracklist_refetches_the_full_album() { - let (mut app, rx) = app_with_channel(); - app - .library - .saved_albums - .upsert_page_by_offset(saved_albums_page(50, 199)); - - app.apply(Action::Open(OpenTarget::SavedAlbum( - "5gzLOflH95LkKYE6XSXE9k".to_string(), - ))); - - // GetAlbum fetches the whole tracklist and pushes the route itself. - assert!(app.selected_album_full.is_none()); - assert_ne!(app.get_current_route().id, RouteId::AlbumTracks); - assert!(matches!( - rx.try_recv(), - Ok(IoEvent::GetAlbum(id)) if id == "5gzLOflH95LkKYE6XSXE9k" - )); -} - -#[test] -fn open_saved_album_with_an_unknown_id_is_a_silent_noop() { - let (mut app, rx) = app_with_channel(); - app - .library - .saved_albums - .upsert_page_by_offset(saved_albums_page(2, 2)); - - app.apply(Action::Open(OpenTarget::SavedAlbum( - "0000000000000000000000".to_string(), - ))); - - assert!(app.selected_album_full.is_none()); - assert_ne!(app.get_current_route().id, RouteId::AlbumTracks); - assert!(rx.try_recv().is_err(), "expected no IoEvent dispatched"); -} - -#[test] -fn open_show_episodes_dispatches_the_show_snapshot() { - let (mut app, rx) = app_with_channel(); - let show = crate::core::plugin_api::ShowInfo { - id: Some("3aNsrV6lkzmcU1w8u8kA7N".to_string()), - name: "A Podcast".to_string(), - ..Default::default() - }; - - app.apply(Action::OpenShowEpisodes(show)); - - match rx.try_recv() { - Ok(IoEvent::GetShowEpisodes(show)) => { - assert_eq!(show.id.as_deref(), Some("3aNsrV6lkzmcU1w8u8kA7N")); - assert_eq!(show.name, "A Podcast"); - } - _other => panic!("expected GetShowEpisodes"), - } -} - -#[test] -fn open_artist_resolves_the_country_and_dispatches_get_artist() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::Open(OpenTarget::Artist { - id: "2ye2Wgw4gimLv2eAKyk1NB".to_string(), - name: String::new(), - })); - - match rx.try_recv() { - Ok(IoEvent::GetArtist(id, name, country)) => { - assert_eq!(id, "2ye2Wgw4gimLv2eAKyk1NB"); - assert_eq!(name, ""); - assert_eq!(country, None, "no user loaded, so no market"); - } - _other => panic!("expected GetArtist"), - } -} - -#[test] -fn open_track_album_dispatches_get_album_for_track() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::Open(OpenTarget::TrackAlbum( - "10igKaIKsSB6ZnWxPxPvKO".to_string(), - ))); - - assert!( - matches!( - rx.try_recv(), - Ok(IoEvent::GetAlbumForTrack(id)) if id == "10igKaIKsSB6ZnWxPxPvKO" - ), - "the track id rides through unchanged" - ); -} - -#[test] -fn open_show_dispatches_get_show() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::Open(OpenTarget::Show( - "3aNsrV6lkzmcU1w8u8kA7N".to_string(), - ))); - - assert!( - matches!( - rx.try_recv(), - Ok(IoEvent::GetShow(id)) if id == "3aNsrV6lkzmcU1w8u8kA7N" - ), - "the show id rides through unchanged" - ); -} - -#[test] -fn open_playlist_opens_the_track_table_in_the_playlists_context() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::Open(OpenTarget::Playlist { - id: "37i9dQZF1DX4WYpdgoIcn6".to_string(), - from_search: false, - })); - - assert_eq!(app.get_current_route().id, RouteId::TrackTable); - assert_eq!( - app.track_table.context, - Some(crate::core::app::TrackTableContext::MyPlaylists) - ); - assert_eq!( - app.pending_playlist_open.as_deref(), - Some("37i9dQZF1DX4WYpdgoIcn6") - ); - match rx.try_recv() { - Ok(IoEvent::GetPlaylistItems(id, offset)) => { - assert_eq!(id, "37i9dQZF1DX4WYpdgoIcn6"); - assert_eq!(offset, 0); - } - _other => panic!("expected GetPlaylistItems"), - } -} - -#[test] -fn open_playlist_from_search_uses_the_search_context() { - let (mut app, _rx) = app_with_channel(); - - app.apply(Action::Open(OpenTarget::Playlist { - id: "37i9dQZF1DX4WYpdgoIcn6".to_string(), - from_search: true, - })); - - assert_eq!( - app.track_table.context, - Some(crate::core::app::TrackTableContext::PlaylistSearch) - ); -} - -#[test] -fn open_playlist_with_an_unparseable_id_is_a_silent_noop() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::Open(OpenTarget::Playlist { - id: "not a base62 id!".to_string(), - from_search: false, - })); - - assert!( - rx.try_recv().is_err(), - "the opening paths drop bad ids silently" - ); - assert_eq!(app.get_current_route().id, RouteId::Home); -} - -#[test] -fn open_playlist_twice_while_pending_open_fetches_only_once() { - let (mut app, rx) = app_with_channel(); - - let target = OpenTarget::Playlist { - id: "37i9dQZF1DX4WYpdgoIcn6".to_string(), - from_search: false, - }; - app.apply(Action::Open(target.clone())); - assert!(matches!(rx.try_recv(), Ok(IoEvent::GetPlaylistItems(_, 0)))); - - // Same open already in flight: no second fetch, only the screen shown. - app.apply(Action::Open(target)); - assert!(rx.try_recv().is_err()); -} - -// --- SearchActiveSource (Action::Search stays Spotify-only) --- - -#[test] -fn search_active_source_routes_by_the_active_source() { - let (mut app, rx) = app_with_channel(); - let query = "coltrane".to_string(); - - app.active_source = Source::Subsonic; - app.apply(Action::SearchActiveSource(query.clone())); - assert!(matches!( - rx.try_recv(), - Ok(IoEvent::GetSubsonicSearchResults(q)) if q == "coltrane" - )); - - app.active_source = Source::Radio; - app.apply(Action::SearchActiveSource(query.clone())); - assert!(matches!( - rx.try_recv(), - Ok(IoEvent::GetRadioSearchResults(q)) if q == "coltrane" - )); - - app.active_source = Source::YouTube; - app.apply(Action::SearchActiveSource(query.clone())); - assert!(matches!( - rx.try_recv(), - Ok(IoEvent::GetYouTubeSearchResults(q)) if q == "coltrane" - )); - - app.active_source = Source::Qobuz; - app.apply(Action::SearchActiveSource(query.clone())); - assert!(matches!( - rx.try_recv(), - Ok(IoEvent::GetQobuzSearchResults(q)) if q == "coltrane" - )); - - app.active_source = Source::Spotify; - app.apply(Action::SearchActiveSource(query.clone())); - assert!(matches!( - rx.try_recv(), - Ok(IoEvent::GetSearchResults(q, _)) if q == "coltrane" - )); - - // Local falls into the Spotify branch, matching the search input's - // if-chain, which has no Local branch. - app.active_source = Source::Local; - app.apply(Action::SearchActiveSource(query)); - assert!(matches!( - rx.try_recv(), - Ok(IoEvent::GetSearchResults(_, None)) - )); -} - -#[test] -fn search_active_source_resolves_a_loaded_users_country() { - let (mut app, rx) = app_with_channel(); - app.user = Some(crate::core::app::UserInfo { - id: "user1".to_string(), - display_name: None, - country: Some("US".to_string()), - }); - app.active_source = Source::Spotify; - - app.apply(Action::SearchActiveSource("coltrane".to_string())); - - assert!( - matches!(rx.try_recv(), Ok(IoEvent::GetSearchResults(_, Some(_)))), - "the loaded user's market rides along" - ); -} - -#[test] -fn search_stays_on_the_spotify_catalog_when_browsing_other_sources() { - let (mut app, rx) = app_with_channel(); - app.active_source = Source::YouTube; - - app.apply(Action::Search("daft punk".to_string())); - - // Lua spotatui.search contracted the Web API search; browsing scope does - // not reroute it (only SearchActiveSource does). - assert!(matches!( - rx.try_recv(), - Ok(IoEvent::GetSearchResults(q, None)) if q == "daft punk" - )); -} - -#[test] -fn search_playlist_tracks_records_the_pending_search_and_dispatches() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::SearchPlaylistTracks { - playlist_id: "37i9dQZF1DX4WYpdgoIcn6".to_string(), - query: "queen rock".to_string(), - }); - - assert_eq!( - app.pending_playlist_track_search.as_deref(), - Some("queen rock") - ); - assert_eq!( - app.status_message(), - Some("Searching playlist for \"queen rock\"...") - ); - match rx.try_recv() { - Ok(IoEvent::SearchPlaylistTracks(id, query)) => { - assert_eq!(id, "37i9dQZF1DX4WYpdgoIcn6"); - assert_eq!(query, "queen rock"); - } - _other => panic!("expected SearchPlaylistTracks"), - } -} - -// --- playback starts --- - -#[test] -fn play_track_in_context_dispatches_the_combined_start() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::PlayTrackInContext { - context: "spotify:playlist:p1".to_string(), - track: "spotify:track:t1".to_string(), - }); - - match rx.try_recv() { - Ok(IoEvent::StartPlayback(context, uris, offset)) => { - assert_eq!(context.as_deref(), Some("spotify:playlist:p1")); - assert_eq!(uris, Some(vec!["spotify:track:t1".to_string()])); - assert_eq!(offset, Some(0)); - } - _other => panic!("expected StartPlayback"), - } -} - -#[test] -fn recommend_from_track_sets_song_context_and_keeps_the_unseeded_request() { - let (mut app, rx) = app_with_channel(); - let seed = track("4uLU6hMCjMI75M1A2tKUQC", "Song A"); - - app.apply(Action::RecommendFromTrack(seed.clone())); - - assert_eq!( - app.recommendations_context, - Some(RecommendationsContext::Song) - ); - assert_eq!(app.recommendations_seed, "Song A"); - match rx.try_recv() { - Ok(IoEvent::GetRecommendationsForSeed(seed_artists, seed_tracks, first_track, country)) => { - assert!(seed_artists.is_none()); - // Preserved historic bug: the full URI is fed as the seed and the - // request goes out unseeded. Fixing it is a separate verified change. - assert_eq!(seed_tracks, Some(vec![seed.uri.clone().unwrap()])); - assert_eq!( - (*first_track).as_ref().map(|t| t.name.as_str()), - Some("Song A") - ); - assert_eq!(country, None); - } - _other => panic!("expected GetRecommendationsForSeed"), - } -} - -#[test] -fn recommend_from_track_without_a_uri_still_carries_the_context_row() { - let (mut app, rx) = app_with_channel(); - let mut seed = track("4uLU6hMCjMI75M1A2tKUQC", "Local Song"); - seed.uri = None; - - app.apply(Action::RecommendFromTrack(seed)); - - assert_eq!( - app.recommendations_context, - Some(RecommendationsContext::Song) - ); - assert_eq!(app.recommendations_seed, "Local Song"); - match rx.try_recv() { - Ok(IoEvent::GetRecommendationsForSeed(seed_artists, seed_tracks, first_track, _)) => { - assert!(seed_artists.is_none()); - assert!(seed_tracks.is_none(), "no uri means no seed list"); - assert_eq!( - (*first_track).as_ref().map(|t| t.name.as_str()), - Some("Local Song") - ); - } - _other => panic!("expected GetRecommendationsForSeed"), - } -} - -#[test] -fn recommend_from_artist_sets_the_artist_context_and_seeds_by_id() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::RecommendFromArtist { - id: "0OdUWJ0sBjDrqHygGUXeCF".to_string(), - name: "Band of Horses".to_string(), - }); - - assert_eq!( - app.recommendations_context, - Some(RecommendationsContext::Artist) - ); - assert_eq!(app.recommendations_seed, "Band of Horses"); - match rx.try_recv() { - Ok(IoEvent::GetRecommendationsForSeed(seed_artists, seed_tracks, first_track, country)) => { - assert_eq!( - seed_artists, - Some(vec!["0OdUWJ0sBjDrqHygGUXeCF".to_string()]) - ); - assert!(seed_tracks.is_none()); - assert!( - (*first_track).is_none(), - "an artist seed prepends no context row" - ); - assert_eq!(country, None); - } - _other => panic!("expected GetRecommendationsForSeed"), - } -} - -#[test] -fn recommend_from_track_id_seeds_the_song_context_without_a_context_row() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::RecommendFromTrackId { - id: "4uLU6hMCjMI75M1A2tKUQC".to_string(), - name: "Song A".to_string(), - }); - - assert_eq!( - app.recommendations_context, - Some(RecommendationsContext::Song) - ); - assert_eq!(app.recommendations_seed, "Song A"); - // The id-only event carries no seed snapshot, so no seed row is prepended. - match rx.try_recv() { - Ok(IoEvent::GetRecommendationsForTrackId(id, country)) => { - assert_eq!(id, "4uLU6hMCjMI75M1A2tKUQC"); - assert_eq!(country, None); - } - _other => panic!("expected GetRecommendationsForTrackId"), - } -} - -// --- dialogs --- - -#[test] -fn open_add_track_dialog_without_a_selection_is_a_noop() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::OpenAddTrackDialog); - - assert!(rx.try_recv().is_err()); - assert!(app.pending_playlist_track_add.is_none()); -} - -#[test] -fn open_add_track_dialog_stages_the_selected_row_for_the_picker() { - let (mut app, rx) = app_with_channel(); - app.active_source = Source::YouTube; - app.youtube_playlists = vec![PlaylistInfo { - uri: "youtube:playlist:y1".to_string(), - ..playlist_info("y1", "Local List", "owner", false) - }]; - app.track_table.tracks = vec![track("0000000000000000000001", "Track 1")]; - app.select_track_row(0); - - app.apply(Action::OpenAddTrackDialog); - - assert_eq!( - app.get_current_route().active_block, - ActiveBlock::Dialog(DialogContext::AddTrackToPlaylistPicker) - ); - let pending = app - .pending_playlist_track_add - .as_ref() - .expect("staged for the picker"); - assert_eq!(pending.track_name, "Track 1"); - assert!( - rx.try_recv().is_err(), - "the YouTube path fetches nothing when playlists exist" - ); -} - -#[test] -fn open_add_track_dialog_without_destinations_requests_them() { - let (mut app, rx) = app_with_channel(); - app.active_source = Source::Spotify; - app.track_table.tracks = vec![track("0000000000000000000001", "Track 1")]; - app.select_track_row(0); - - app.apply(Action::OpenAddTrackDialog); - - // No user profile and no playlists loaded yet: the flow fetches both - // first and tells the user to retry. - assert!(matches!(rx.try_recv(), Ok(IoEvent::GetUser))); - assert!(matches!(rx.try_recv(), Ok(IoEvent::GetPlaylists))); - assert!(app.pending_playlist_track_add.is_none()); -} - -#[test] -fn open_add_track_dialog_for_a_named_track_opens_the_picker() { - let (mut app, rx) = app_with_channel(); - app.user = Some(UserInfo { - id: "spotatui-owner".to_string(), - ..Default::default() - }); - app.playlists = Some(Paged { - total: 1, - ..Default::default() - }); - app.all_playlists = vec![playlist_info( - "37i9dQZF1DXcBWIGoYBM5M", - "Owned Playlist", - "spotatui-owner", - false, - )]; - - app.apply(Action::OpenAddTrackDialogFor { - track_id: Some("0000000000000000000001".to_string()), - track_name: "Search Track".to_string(), - }); - - let pending = app - .pending_playlist_track_add - .as_ref() - .expect("staged for the picker"); - assert_eq!(pending.track_id, "0000000000000000000001"); - assert_eq!(pending.track_name, "Search Track"); - assert_eq!( - app.get_current_route().active_block, - ActiveBlock::Dialog(DialogContext::AddTrackToPlaylistPicker) - ); - assert!( - rx.try_recv().is_err(), - "the destinations are already loaded, so nothing is fetched" - ); -} - -#[test] -fn open_add_track_dialog_for_a_track_without_an_id_reports_it() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::OpenAddTrackDialogFor { - track_id: None, - track_name: "Local File".to_string(), - }); - - assert_eq!( - app.status_message(), - Some("Track cannot be added to playlist") - ); - assert!(app.pending_playlist_track_add.is_none()); - assert!(rx.try_recv().is_err(), "expected no IoEvent dispatched"); -} - -#[test] -fn open_remove_track_dialog_stages_the_spotify_removal_with_position() { - let (mut app, rx) = app_with_channel(); - app.track_table.context = Some(crate::core::app::TrackTableContext::MyPlaylists); - app.playlist_track_table_id = Some( - rspotify::model::idtypes::PlaylistId::from_id("37i9dQZF1DX4WYpdgoIcn6") - .unwrap() - .into_static(), - ); - app.all_playlists = vec![playlist_info( - "37i9dQZF1DX4WYpdgoIcn6", - "My List", - "owner", - false, - )]; - app.track_table.tracks = vec![track("0000000000000000000001", "Track 1")]; - app.select_track_row(0); - app.playlist_track_positions = Some(vec![7]); - - app.apply(Action::OpenRemoveTrackDialog); - - let pending = app - .pending_playlist_track_removal - .as_ref() - .expect("staged removal"); - assert_eq!(pending.playlist_name, "My List"); - assert_eq!(pending.track_id, "0000000000000000000001"); - assert_eq!(pending.position, 7); - assert_eq!( - app.get_current_route().active_block, - ActiveBlock::Dialog(DialogContext::RemoveTrackFromPlaylistConfirm) - ); - assert!(rx.try_recv().is_err()); -} - -#[test] -fn open_remove_track_dialog_without_a_position_reports_an_error() { - let (mut app, rx) = app_with_channel(); - app.track_table.context = Some(crate::core::app::TrackTableContext::MyPlaylists); - app.playlist_track_table_id = Some( - rspotify::model::idtypes::PlaylistId::from_id("37i9dQZF1DX4WYpdgoIcn6") - .unwrap() - .into_static(), - ); - app.all_playlists = vec![playlist_info( - "37i9dQZF1DX4WYpdgoIcn6", - "My List", - "owner", - false, - )]; - app.track_table.tracks = vec![track("0000000000000000000001", "Track 1")]; - app.select_track_row(0); - - app.apply(Action::OpenRemoveTrackDialog); - - assert_eq!( - app.status_message(), - Some("Cannot resolve track position for removal"), - ); - assert!(app.pending_playlist_track_removal.is_none()); - assert!(rx.try_recv().is_err()); -} - -#[test] -fn open_remove_track_dialog_youtube_routes_the_local_edit() { - let (mut app, rx) = app_with_channel(); - app.track_table.context = Some(crate::core::app::TrackTableContext::YouTubePlaylist); - app.youtube_open_playlist = Some("youtube:playlist:y1".to_string()); - app.youtube_playlists = vec![PlaylistInfo { - uri: "youtube:playlist:y1".to_string(), - ..playlist_info("y1", "Local List", "owner", false) - }]; - app.track_table.tracks = vec![track("0000000000000000000001", "Track 1")]; - app.select_track_row(0); - - app.apply(Action::OpenRemoveTrackDialog); - - let pending = app - .pending_playlist_track_removal - .as_ref() - .expect("staged local edit"); - assert_eq!(pending.playlist_id, "youtube:playlist:y1"); - assert_eq!(pending.position, 0, "unused for local YouTube playlists"); - assert_eq!( - app.get_current_route().active_block, - ActiveBlock::Dialog(DialogContext::RemoveTrackFromPlaylistConfirm) - ); - assert!(rx.try_recv().is_err()); -} - -// --- library sections --- - -#[test] -fn open_library_stats_marks_loading_dispatches_and_pushes() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::OpenLibrary(LibraryTarget::Stats)); - - assert!(app.stats_loading); - assert_eq!(app.get_current_route().id, RouteId::Stats); - let expected_period = app.stats_period; - assert!( - matches!( - rx.try_recv(), - Ok(IoEvent::LoadListeningStats(period)) if period == expected_period - ), - "the selected stats period rides along" - ); -} - -#[test] -fn open_library_recently_played_pushes_before_the_data_arrives() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::OpenLibrary(LibraryTarget::RecentlyPlayed)); - - // The row pushes immediately; only the global navigate binding defers - // the push to the network result. - assert_eq!(app.get_current_route().id, RouteId::RecentlyPlayed); - assert!(matches!(rx.try_recv(), Ok(IoEvent::GetRecentlyPlayed))); -} - -#[test] -fn open_library_friends_first_open_fetches_code_and_list() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::OpenLibrary(LibraryTarget::Friends)); - - assert_eq!(app.get_current_route().id, RouteId::Friends); - assert!(matches!(rx.try_recv(), Ok(IoEvent::GetFriendCode))); - assert!(matches!(rx.try_recv(), Ok(IoEvent::GetFriends))); -} - -#[test] -fn open_library_friends_skips_fetched_state_on_reopen() { - let (mut app, rx) = app_with_channel(); - app.friend_code = Some("code".to_string()); - app.friends_loading = true; - - app.apply(Action::OpenLibrary(LibraryTarget::Friends)); - - assert_eq!(app.get_current_route().id, RouteId::Friends); - assert!( - rx.try_recv().is_err(), - "nothing refetches once loaded/loading" - ); -} - -#[test] -fn open_library_rows_push_their_routes_and_fetch() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::OpenLibrary(LibraryTarget::Discover)); - assert_eq!(app.get_current_route().id, RouteId::Discover); - assert!(rx.try_recv().is_err()); - - app.apply(Action::OpenLibrary(LibraryTarget::Albums)); - assert_eq!(app.get_current_route().id, RouteId::AlbumList); - assert!(matches!( - rx.try_recv(), - Ok(IoEvent::GetCurrentUserSavedAlbums(None)) - )); - - app.apply(Action::OpenLibrary(LibraryTarget::Artists)); - assert_eq!(app.get_current_route().id, RouteId::Artists); - assert!(matches!( - rx.try_recv(), - Ok(IoEvent::GetFollowedArtists(None)) - )); - - app.apply(Action::OpenLibrary(LibraryTarget::Podcasts)); - assert_eq!(app.get_current_route().id, RouteId::Podcasts); - assert!(matches!( - rx.try_recv(), - Ok(IoEvent::GetCurrentUserSavedShows(None)) - )); -} - -#[cfg(all(test, feature = "local-files"))] -#[test] -fn open_library_local_files_pushes_the_browser_route() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::OpenLibrary(LibraryTarget::LocalFiles)); - - assert_eq!(app.get_current_route().id, RouteId::LocalBrowser); - assert!(rx.try_recv().is_err()); -} - -#[cfg(all(test, feature = "ai-dj"))] -#[test] -fn open_library_ai_dj_opens_the_screen_through_app() { - let (mut app, _rx) = app_with_channel(); - - app.apply(Action::OpenLibrary(LibraryTarget::AiDj)); - - assert_eq!(app.get_current_route().id, RouteId::AiDj); -} - -#[test] -fn open_library_liked_songs_resets_the_cache_and_fetches() { - let (mut app, rx) = app_with_channel(); - let page = track_page(0, &["0000000000000000000001"], false); - app.library.saved_tracks.upsert_page_by_offset(page); - - app.apply(Action::OpenLibrary(LibraryTarget::LikedSongs)); - - assert_eq!(app.get_current_route().id, RouteId::TrackTable); - assert!(app.library.saved_tracks.pages.is_empty(), "cache reset"); - assert!(matches!( - rx.try_recv(), - Ok(IoEvent::GetCurrentSavedTracks(None)) - )); -} - -#[test] -fn every_library_target_has_one_sidebar_row_with_a_distinct_label() { - let rows = crate::core::app::library_row_requirements(); - let mut labels = Vec::new(); - for (target, _) in rows { - let label = target.name(); - assert!(!labels.contains(&label), "duplicate label {label}"); - labels.push(label); - } - let has_row = |target: LibraryTarget| rows.iter().any(|(row, _)| *row == target); - for target in LibraryTarget::ALL { - let expected = match target { - LibraryTarget::LocalFiles => cfg!(feature = "local-files"), - LibraryTarget::AiDj => cfg!(feature = "ai-dj"), - LibraryTarget::Discover - | LibraryTarget::RecentlyPlayed - | LibraryTarget::Friends - | LibraryTarget::Stats - | LibraryTarget::LikedSongs - | LibraryTarget::Albums - | LibraryTarget::Artists - | LibraryTarget::Podcasts => true, - }; - assert_eq!(has_row(target), expected, "{}", target.name()); - } -} - -#[test] -fn library_rows_drop_the_spotify_rows_without_a_session() { - let (app, _rx) = session_free_app_with_channel(); - let rows = app.library_rows(); - assert!(rows.contains(&LibraryTarget::Friends)); - assert!(rows.contains(&LibraryTarget::Stats)); - assert!(!rows.contains(&LibraryTarget::LikedSongs)); - - let (app, _rx) = app_with_channel(); - assert_eq!( - app.library_rows().len(), - crate::core::app::library_row_requirements().len() - ); -} - -#[test] -fn library_rows_drop_the_spotify_rows_under_a_free_scope() { - let dir = tempfile::tempdir().unwrap(); - let (mut app, _rx) = app_with_channel(); - app.state_path = Some(dir.path().join("state.yml")); - app.apply(Action::SelectSource(Source::Local)); - let rows = app.library_rows(); - assert!(!rows.contains(&LibraryTarget::Discover)); - assert!(rows.contains(&LibraryTarget::Stats)); -} - -// --- SelectSource --- - -#[test] -fn select_source_flips_scope_mirrors_runtime_state_and_persists() { - let dir = tempfile::tempdir().unwrap(); - let (mut app, rx) = app_with_channel(); - app.state_path = Some(dir.path().join("state.yml")); - - app.apply(Action::SelectSource(Source::Local)); - - assert_eq!(app.active_source, Source::Local); - assert_eq!(app.runtime_state.active_source, Source::Local); - let written = std::fs::read_to_string(dir.path().join("state.yml")).unwrap(); - assert!( - written.contains("active_source") && written.contains("Local"), - "sparse patch persisted the choice" - ); - assert!( - matches!(rx.try_recv(), Ok(IoEvent::GetLocalPlaylists)), - "the new scope's sidebar is fetched" - ); -} - -#[test] -fn select_source_fetches_each_scopes_own_sidebar() { - let dir = tempfile::tempdir().unwrap(); - let (mut app, rx) = app_with_channel(); - app.state_path = Some(dir.path().join("state.yml")); - - app.apply(Action::SelectSource(Source::Subsonic)); - assert!(matches!(rx.try_recv(), Ok(IoEvent::GetSubsonicPlaylists))); - - app.apply(Action::SelectSource(Source::Radio)); - assert!(matches!(rx.try_recv(), Ok(IoEvent::GetRadioStations))); - - app.apply(Action::SelectSource(Source::YouTube)); - assert!(matches!(rx.try_recv(), Ok(IoEvent::GetYouTubePlaylists))); - - app.apply(Action::SelectSource(Source::Qobuz)); - assert!(matches!(rx.try_recv(), Ok(IoEvent::GetQobuzPlaylists))); -} - -#[test] -fn select_source_spotify_fetches_no_sidebar() { - let dir = tempfile::tempdir().unwrap(); - let (mut app, rx) = app_with_channel(); - app.state_path = Some(dir.path().join("state.yml")); - - app.apply(Action::SelectSource(Source::Spotify)); - - assert!(rx.try_recv().is_err(), "expected no IoEvent dispatched"); -} - -#[test] -fn selecting_spotify_without_a_session_does_not_reach_disk() { - let dir = tempfile::tempdir().unwrap(); - let (mut app, _rx) = session_free_app_with_channel(); - app.state_path = Some(dir.path().join("state.yml")); - - app.apply(Action::SelectSource(Source::Spotify)); - - assert_eq!(app.active_source, Source::Spotify); - assert!(!dir.path().join("state.yml").exists()); - - app.spotify_connected = true; - app.persist_active_source(); - - let written = std::fs::read_to_string(dir.path().join("state.yml")).unwrap(); - assert!(written.contains("active_source") && written.contains("Spotify")); -} - -#[test] -fn selecting_a_free_source_without_a_session_still_persists() { - let dir = tempfile::tempdir().unwrap(); - let (mut app, _rx) = session_free_app_with_channel(); - app.state_path = Some(dir.path().join("state.yml")); - - app.apply(Action::SelectSource(Source::Local)); - - let written = std::fs::read_to_string(dir.path().join("state.yml")).unwrap(); - assert!(written.contains("active_source") && written.contains("Local")); -} - -// --- the now-playing item family --- - -use super::CopyTarget; - -#[test] -fn toggle_save_current_item_saves_the_playing_track() { - let (mut app, rx) = app_with_channel(); - app.current_playback_context = Some(playback_context_with_track(true)); - - app.apply(Action::ToggleSaveCurrentItem); - - match rx.try_recv() { - Ok(IoEvent::ToggleSaveTrack(uri)) => { - assert_eq!(uri, "spotify:track:4uLU6hMCjMI75M1A2tKUQC") - } - _other => panic!("expected ToggleSaveTrack for the playing track"), - } -} - -#[test] -fn toggle_save_current_item_without_playback_is_a_noop() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::ToggleSaveCurrentItem); - - assert!(rx.try_recv().is_err(), "expected no IoEvent dispatched"); - assert!( - app.status_message().is_none(), - "nothing playing falls through silently" - ); -} - -#[test] -fn open_add_playing_track_dialog_without_playback_reports_no_track() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::OpenAddPlayingTrackDialog); - - assert_eq!(app.status_message(), Some("No track currently playing")); - assert!(rx.try_recv().is_err()); - assert!(app.pending_playlist_track_add.is_none()); -} - -#[test] -fn open_add_playing_track_dialog_stages_the_playing_track() { - let (mut app, rx) = app_with_channel(); - app.active_source = Source::YouTube; - app.youtube_playlists = vec![PlaylistInfo { - uri: "youtube:playlist:y1".to_string(), - ..playlist_info("y1", "Local List", "owner", false) - }]; - app.current_playback_context = Some(playback_context_with_track(true)); - - app.apply(Action::OpenAddPlayingTrackDialog); - - assert_eq!( - app.get_current_route().active_block, - ActiveBlock::Dialog(DialogContext::AddTrackToPlaylistPicker) - ); - let pending = app - .pending_playlist_track_add - .as_ref() - .expect("staged for the picker"); - assert_eq!(pending.track_name, "Test Song"); - assert!( - rx.try_recv().is_err(), - "the YouTube path fetches nothing when playlists exist" - ); -} - -#[test] -fn copy_url_without_playback_is_a_silent_noop() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::CopyUrl(CopyTarget::CurrentSong)); - app.apply(Action::CopyUrl(CopyTarget::CurrentAlbum)); - - // No clipboard assertion: a headless runner has no clipboard. - assert!(rx.try_recv().is_err(), "expected no IoEvent dispatched"); - assert!(app.api_error().is_empty(), "no failure is reported"); -} - -// --- the listening party family --- - -use crate::infra::network::sync::{ControlMode, PartyRole, PartySession}; - -#[test] -fn start_party_hosts_in_host_only_control() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::StartParty); - - match rx.try_recv() { - Ok(IoEvent::StartParty(mode)) => assert_eq!(mode, ControlMode::HostOnly), - _other => panic!("expected a host-only StartParty"), - } -} - -#[test] -fn join_party_carries_the_code_and_the_guest_name() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::JoinParty { - code: "ABC123".to_string(), - name: "Guest".to_string(), - }); - - match rx.try_recv() { - Ok(IoEvent::JoinParty { code, name }) => { - assert_eq!(code, "ABC123"); - assert_eq!(name, "Guest"); - } - _other => panic!("expected JoinParty carrying both fields"), - } -} - -#[test] -fn leave_party_dispatches_the_leave() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::LeaveParty); - - assert!(matches!(rx.try_recv(), Ok(IoEvent::LeaveParty))); -} - -#[test] -fn toggle_party_control_mode_flips_the_session_and_tells_the_relay() { - let (mut app, rx) = app_with_channel(); - app.party_session = Some(PartySession { - role: PartyRole::Host, - code: "ABC123".to_string(), - guests: Vec::new(), - control_mode: ControlMode::HostOnly, - host_name: "Host".to_string(), - }); - - app.apply(Action::TogglePartyControlMode); - - // The relay handler only sends the message; the popup renders from this record. - assert_eq!( - app.party_session.as_ref().map(|s| s.control_mode.clone()), - Some(ControlMode::SharedControl) - ); - match rx.try_recv() { - Ok(IoEvent::SetPartyControlMode(mode)) => assert_eq!(mode, ControlMode::SharedControl), - _other => panic!("expected the relay to be told about shared control"), - } - - app.apply(Action::TogglePartyControlMode); - - assert_eq!( - app.party_session.as_ref().map(|s| s.control_mode.clone()), - Some(ControlMode::HostOnly) - ); - match rx.try_recv() { - Ok(IoEvent::SetPartyControlMode(mode)) => assert_eq!(mode, ControlMode::HostOnly), - _other => panic!("expected the mode to flip back"), - } -} - -#[test] -fn toggle_party_control_mode_without_a_session_dispatches_nothing() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::TogglePartyControlMode); - - assert!(app.party_session.is_none()); - assert!(rx.try_recv().is_err(), "expected no IoEvent dispatched"); -} - -// --- decoded-source playlists, sidebar folders and saved stations --- - -use crate::core::state::RadioStationConfig; - -fn radio_station_row(name: &str, url: &str) -> TrackInfo { - let mut station = track("0000000000000000000001", name); - station.uri = Some(format!("radio:{url}")); - station -} - -#[test] -fn open_source_playlist_local_clears_the_table_and_fetches() { - let (mut app, rx) = app_with_channel(); - app.track_table.tracks = vec![track("0000000000000000000002", "Stale")]; - app.select_track_row(1); - app.playlist_track_positions = Some(vec![7]); - - app.apply(Action::Open(OpenTarget::SourcePlaylist( - "file:///music/Jazz".to_string(), - ))); - - assert!(app.track_table.tracks.is_empty()); - assert_eq!(app.view.track_table_index, 0); - assert_eq!( - app.track_table.context, - Some(crate::core::app::TrackTableContext::LocalPlaylist) - ); - assert_eq!(app.get_current_route().id, RouteId::TrackTable); - assert_eq!( - app.playlist_track_positions, - Some(vec![7]), - "a decoded source opens without the Spotify page reset" - ); - match rx.try_recv() { - Ok(IoEvent::GetLocalTracks(uri)) => assert_eq!(uri, "file:///music/Jazz"), - _other => panic!("expected GetLocalTracks (IoEvent is not Debug)"), - } -} - -#[test] -fn open_source_playlist_qobuz_uses_the_qobuz_context() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::Open(OpenTarget::SourcePlaylist( - "qobuz:album:0060254730301".to_string(), - ))); - - assert_eq!( - app.track_table.context, - Some(crate::core::app::TrackTableContext::QobuzPlaylist) - ); - assert_eq!(app.get_current_route().id, RouteId::TrackTable); - match rx.try_recv() { - Ok(IoEvent::GetQobuzTracks(uri)) => assert_eq!(uri, "qobuz:album:0060254730301"), - _other => panic!("expected GetQobuzTracks (IoEvent is not Debug)"), - } -} - -#[test] -fn open_source_playlist_subsonic_uses_the_subsonic_context() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::Open(OpenTarget::SourcePlaylist( - "subsonic:playlist:42".to_string(), - ))); - - assert_eq!( - app.track_table.context, - Some(crate::core::app::TrackTableContext::SubsonicPlaylist) - ); - assert_eq!(app.get_current_route().id, RouteId::TrackTable); - match rx.try_recv() { - Ok(IoEvent::GetSubsonicTracks(uri)) => assert_eq!(uri, "subsonic:playlist:42"), - _other => panic!("expected GetSubsonicTracks (IoEvent is not Debug)"), - } -} - -#[test] -fn open_source_playlist_youtube_uses_the_youtube_context() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::Open(OpenTarget::SourcePlaylist( - "youtube:playlist:y1".to_string(), - ))); - - assert_eq!( - app.track_table.context, - Some(crate::core::app::TrackTableContext::YouTubePlaylist) - ); - assert_eq!(app.get_current_route().id, RouteId::TrackTable); - match rx.try_recv() { - Ok(IoEvent::GetYouTubeTracks(uri)) => assert_eq!(uri, "youtube:playlist:y1"), - _other => panic!("expected GetYouTubeTracks (IoEvent is not Debug)"), - } -} - -#[test] -fn open_source_playlist_with_an_unknown_scheme_is_a_silent_noop() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::Open(OpenTarget::SourcePlaylist( - "spotify:playlist:37i9dQZF1DXcBWIGoYBM5M".to_string(), - ))); - - assert!(rx.try_recv().is_err(), "expected no IoEvent dispatched"); - assert_eq!(app.get_current_route().id, RouteId::Home); - assert_eq!(app.track_table.context, None); -} - -#[test] -fn open_playlist_folder_scopes_the_visible_items() { - use crate::core::app::{PlaylistFolder, PlaylistFolderItem}; - let (mut app, _rx) = app_with_channel(); - app.user_config.behavior.pin_community_playlist = false; - app.all_playlists = vec![playlist_info( - "37i9dQZF1DXcBWIGoYBM5M", - "Inside", - "spotatui-owner", - false, - )]; - app.playlist_folder_items = vec![ - PlaylistFolderItem::Folder(PlaylistFolder { - name: "Mixes".to_string(), - current_id: 0, - target_id: 1, - }), - PlaylistFolderItem::Playlist { - index: 0, - current_id: 1, - }, - ]; - assert!(matches!( - app.get_playlist_display_item_at(0), - Some(PlaylistFolderItem::Folder(_)) - )); - - app.apply(Action::Open(OpenTarget::PlaylistFolder(1))); - - assert_eq!(app.get_playlist_display_count(), 1); - assert!(matches!( - app.get_playlist_display_item_at(0), - Some(PlaylistFolderItem::Playlist { index: 0, .. }) - )); -} - -#[test] -fn delete_playlist_dispatches_the_local_delete() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::DeletePlaylist("youtube:playlist:y1".to_string())); - - assert!(matches!( - rx.try_recv(), - Ok(IoEvent::DeleteYouTubePlaylist(uri)) if uri == "youtube:playlist:y1" - )); -} - -#[test] -fn remove_radio_station_removes_the_saved_copy_and_reports_it() { - // Unseeded, the persist would write the developer's real state.yml. - let dir = tempfile::tempdir().unwrap(); - let (mut app, _rx) = app_with_channel(); - app.state_path = Some(dir.path().join("state.yml")); - app.runtime_state.radio_stations = vec![RadioStationConfig { - name: "Groove Salad".to_string(), - url: "https://ice1.somafm.com/groovesalad-128-mp3".to_string(), - }]; - app.radio_stations = vec![radio_station_row( - "Groove Salad", - "https://ice1.somafm.com/groovesalad-128-mp3", - )]; - - app.apply(Action::RemoveRadioStation( - "radio:https://ice1.somafm.com/groovesalad-128-mp3".to_string(), - )); - - assert!(app.runtime_state.radio_stations.is_empty()); - assert!(app.radio_stations.is_empty(), "the sidebar row goes too"); - assert_eq!( - app.status_message(), - Some("Removed saved radio station: Groove Salad") - ); -} - -#[test] -fn remove_radio_station_reports_a_config_owned_station_without_removing() { - let dir = tempfile::tempdir().unwrap(); - let (mut app, _rx) = app_with_channel(); - app.state_path = Some(dir.path().join("state.yml")); - app.user_config.behavior.radio_stations = vec![RadioStationConfig { - name: "Configured Groove".to_string(), - url: "https://ice1.somafm.com/groovesalad-128-mp3".to_string(), - }]; - app.radio_stations = vec![radio_station_row( - "Configured Groove", - "https://ice1.somafm.com/groovesalad-128-mp3", - )]; - - app.apply(Action::RemoveRadioStation( - "radio:https://ice1.somafm.com/groovesalad-128-mp3".to_string(), - )); - - assert_eq!(app.radio_stations.len(), 1); - assert_eq!( - app.status_message(), - Some("Radio station is configured in config.yml: Configured Groove") - ); -} - -#[test] -fn remove_radio_station_removes_only_the_saved_copy_of_a_configured_station() { - let dir = tempfile::tempdir().unwrap(); - let (mut app, _rx) = app_with_channel(); - app.state_path = Some(dir.path().join("state.yml")); - app.user_config.behavior.radio_stations = vec![RadioStationConfig { - name: "Configured Groove".to_string(), - url: "https://ice1.somafm.com/groovesalad-128-mp3".to_string(), - }]; - app.runtime_state.radio_stations = vec![RadioStationConfig { - name: "Runtime Duplicate".to_string(), - url: "https://ice1.somafm.com/groovesalad-128-mp3".to_string(), - }]; - app.radio_stations = vec![radio_station_row( - "Configured Groove", - "https://ice1.somafm.com/groovesalad-128-mp3", - )]; - - app.apply(Action::RemoveRadioStation( - "radio:https://ice1.somafm.com/groovesalad-128-mp3".to_string(), - )); - - assert!(app.runtime_state.radio_stations.is_empty()); - // Config still supplies the row, so the sidebar keeps it. - assert_eq!(app.radio_stations.len(), 1); - assert_eq!( - app.status_message(), - Some("Removed saved radio station: Runtime Duplicate") - ); -} - -#[test] -fn remove_radio_station_reports_an_unfavorited_station() { - let dir = tempfile::tempdir().unwrap(); - let (mut app, _rx) = app_with_channel(); - app.state_path = Some(dir.path().join("state.yml")); - app.radio_stations = vec![radio_station_row( - "Groove Salad", - "https://ice1.somafm.com/groovesalad-128-mp3", - )]; - - app.apply(Action::RemoveRadioStation( - "radio:https://ice1.somafm.com/groovesalad-128-mp3".to_string(), - )); - - assert_eq!(app.radio_stations.len(), 1); - assert_eq!( - app.status_message(), - Some("Radio station is not favorited: Groove Salad") - ); -} - -#[test] -fn remove_radio_station_without_a_stream_url_reports_it() { - let dir = tempfile::tempdir().unwrap(); - let (mut app, _rx) = app_with_channel(); - app.state_path = Some(dir.path().join("state.yml")); - app.radio_stations = vec![radio_station_row( - "Groove Salad", - "https://ice1.somafm.com/groovesalad-128-mp3", - )]; - - app.apply(Action::RemoveRadioStation("not-a-radio-uri".to_string())); - - assert_eq!(app.radio_stations.len(), 1); - assert_eq!( - app.status_message(), - Some("Radio station has no stream URL") - ); -} - -#[test] -fn favorite_radio_station_persists_it_and_lists_it_in_the_sidebar() { - // Unseeded, the persist would write the developer's real state.yml. - let dir = tempfile::tempdir().unwrap(); - let (mut app, _rx) = app_with_channel(); - app.state_path = Some(dir.path().join("state.yml")); - - app.apply(Action::FavoriteRadioStation(radio_station_row( - "Groove Salad", - "https://ice1.somafm.com/groovesalad-128-mp3", - ))); - - assert_eq!(app.runtime_state.radio_stations.len(), 1); - assert_eq!( - app.runtime_state.radio_stations[0].url, - "https://ice1.somafm.com/groovesalad-128-mp3" - ); - assert_eq!(app.radio_stations.len(), 1); - assert_eq!( - app.status_message(), - Some("Favorited radio station: Groove Salad") - ); -} - -#[test] -fn favorite_radio_station_without_a_stream_url_reports_it() { - let (mut app, _rx) = app_with_channel(); - let mut station = radio_station_row( - "Groove Salad", - "https://ice1.somafm.com/groovesalad-128-mp3", - ); - station.uri = None; - - app.apply(Action::FavoriteRadioStation(station)); - - assert!(app.runtime_state.radio_stations.is_empty()); - assert!(app.radio_stations.is_empty(), "no sidebar row is added"); - assert_eq!( - app.status_message(), - Some("Radio station has no stream URL") - ); -} - -// --- podcasts, episodes, the native queue and the Discover rows --- - -use super::DiscoverTarget; -use crate::core::app::{ - DiscoverTimeRange, EpisodeTableContext, SelectedFullShow, SelectedShow, TrackTableContext, -}; -use crate::core::plugin_api::{EpisodeInfo, ShowInfo}; - -fn show_info(id: Option<&str>, name: &str) -> ShowInfo { - ShowInfo { - id: id.map(|id| id.to_string()), - name: name.to_string(), - ..Default::default() - } -} - -fn shows_page(offset: u32, limit: u32, names: &[&str]) -> Paged { - Paged { - items: names - .iter() - .map(|name| show_info(Some("3aNsrV6lkzmcU1w8u8kA7N"), name)) - .collect(), - offset, - limit, - total: 40, - next: None, - previous: None, - } -} - -fn episodes_page(offset: u32, limit: u32, names: &[&str]) -> Paged { - Paged { - items: names - .iter() - .map(|name| EpisodeInfo { - id: Some((*name).to_string()), - uri: Some(format!("spotify:episode:{name}")), - name: (*name).to_string(), - duration_ms: 1_000, - show_name: String::new(), - description: String::new(), - release_date: String::new(), - is_playable: true, - resume_point: None, - image_url: None, - }) - .collect(), - offset, - limit, - total: 40, - next: None, - previous: None, - } -} - -#[test] -fn load_more_saved_shows_flips_to_a_cached_page() { - let (mut app, rx) = app_with_channel(); - app.library.saved_shows.add_pages(shows_page(0, 20, &["A"])); - app - .library - .saved_shows - .add_pages(shows_page(20, 20, &["B"])); - // `add_pages` leaves the visible index on the tail; start from the first. - app.library.saved_shows.index = 0; - - app.apply(Action::LoadMore(ListTarget::SavedShows)); - - assert_eq!(app.library.saved_shows.index, 1); - assert!(rx.try_recv().is_err(), "a cached page needs no fetch"); -} - -#[test] -fn load_more_saved_shows_fetches_the_next_offset() { - let (mut app, rx) = app_with_channel(); - app.library.saved_shows.add_pages(shows_page(0, 20, &["A"])); - - app.apply(Action::LoadMore(ListTarget::SavedShows)); - - assert!(matches!( - rx.try_recv(), - Ok(IoEvent::GetCurrentUserSavedShows(Some(20))) - )); -} - -#[test] -fn load_more_saved_shows_with_an_empty_cache_is_a_noop() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::LoadMore(ListTarget::SavedShows)); - - assert!(rx.try_recv().is_err(), "expected no IoEvent dispatched"); -} - -#[test] -fn load_more_show_episodes_flips_to_a_cached_page() { - let (mut app, rx) = app_with_channel(); - app.selected_show_simplified = Some(SelectedShow { - show: show_info(Some("3aNsrV6lkzmcU1w8u8kA7N"), "A Podcast"), - }); - app.episode_table_context = EpisodeTableContext::Simplified; - app - .library - .show_episodes - .add_pages(episodes_page(0, 20, &["A"])); - app - .library - .show_episodes - .add_pages(episodes_page(20, 20, &["B"])); - app.library.show_episodes.index = 0; - - app.apply(Action::LoadMore(ListTarget::ShowEpisodes)); - - assert_eq!(app.library.show_episodes.index, 1); - assert!(rx.try_recv().is_err(), "a cached page needs no fetch"); -} - -#[test] -fn load_more_show_episodes_fetches_the_next_offset() { - let (mut app, rx) = app_with_channel(); - app.selected_show_simplified = Some(SelectedShow { - show: show_info(Some("3aNsrV6lkzmcU1w8u8kA7N"), "A Podcast"), - }); - app.episode_table_context = EpisodeTableContext::Simplified; - app - .library - .show_episodes - .add_pages(episodes_page(0, 20, &["A"])); - - app.apply(Action::LoadMore(ListTarget::ShowEpisodes)); - - match rx.try_recv() { - Ok(IoEvent::GetCurrentShowEpisodes(show_id, offset)) => { - assert_eq!(show_id, "3aNsrV6lkzmcU1w8u8kA7N"); - assert_eq!(offset, Some(20)); - } - _other => panic!("expected GetCurrentShowEpisodes (IoEvent is not Debug)"), - } -} - -#[test] -fn load_more_show_episodes_reads_the_full_show_context() { - let (mut app, rx) = app_with_channel(); - // Both snapshots are set; the context decides which id rides along. - app.selected_show_simplified = Some(SelectedShow { - show: show_info(Some("simplified-show"), "Simplified"), - }); - app.selected_show_full = Some(SelectedFullShow { - show: show_info(Some("full-show"), "Full"), - }); - app.episode_table_context = EpisodeTableContext::Full; - app - .library - .show_episodes - .add_pages(episodes_page(0, 20, &["A"])); - - app.apply(Action::LoadMore(ListTarget::ShowEpisodes)); - - match rx.try_recv() { - Ok(IoEvent::GetCurrentShowEpisodes(show_id, _offset)) => assert_eq!(show_id, "full-show"), - _other => panic!("expected GetCurrentShowEpisodes (IoEvent is not Debug)"), - } -} - -#[test] -fn load_more_show_episodes_without_a_selected_show_is_a_noop() { - let (mut app, rx) = app_with_channel(); - app - .library - .show_episodes - .add_pages(episodes_page(0, 20, &["A"])); - - app.apply(Action::LoadMore(ListTarget::ShowEpisodes)); - - assert!(rx.try_recv().is_err(), "no open show means no fetch"); -} - -#[test] -fn queue_track_pushes_onto_the_native_queue() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::QueueTrack(track("0000000000000000000001", "One"))); - - assert_eq!(app.native_queue.len(), 1); - assert_eq!(app.native_queue[0].name, "One"); - assert!(rx.try_recv().is_err(), "nothing goes to the Web API queue"); -} - -#[test] -fn queue_track_on_an_external_spotify_device_falls_back_to_the_web_api() { - let (mut app, rx) = app_with_channel(); - // A Spotify context with no native streaming device reads as external. - app.current_playback_context = Some(playback_context(true, false)); - - app.apply(Action::QueueTrack(track("0000000000000000000001", "One"))); - - assert!(app.native_queue.is_empty()); - match rx.try_recv() { - Ok(IoEvent::AddItemToQueue(uri)) => { - assert_eq!(uri, "spotify:track:0000000000000000000001"); - } - _other => panic!("expected AddItemToQueue (IoEvent is not Debug)"), - } -} - -#[test] -fn queue_track_without_a_uri_reports_it() { - let (mut app, rx) = app_with_channel(); - let mut uri_less = track("0000000000000000000001", "One"); - uri_less.uri = None; - - app.apply(Action::QueueTrack(uri_less)); - - assert!(app.native_queue.is_empty()); - assert_eq!(app.status_message(), Some("Cannot queue: track has no URI")); - assert!(rx.try_recv().is_err(), "expected no IoEvent dispatched"); -} - -#[test] -fn queue_track_rejects_a_radio_stream() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::QueueTrack(radio_station_row( - "Groove Salad", - "https://ice1.somafm.com/groovesalad-128-mp3", - ))); - - assert!(app.native_queue.is_empty()); - assert_eq!(app.status_message(), Some("Radio stations can't be queued")); - assert!(rx.try_recv().is_err(), "expected no IoEvent dispatched"); -} - -#[test] -fn open_discover_artists_mix_fetches_when_the_cache_is_empty() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::OpenDiscover(DiscoverTarget::ArtistsMix)); - - assert!(matches!(rx.try_recv(), Ok(IoEvent::GetTopArtistsMix))); - assert_eq!( - app.get_current_route().id, - RouteId::Home, - "the fetch pushes no route" - ); -} - -#[test] -fn open_discover_artists_mix_shows_the_cache_when_loaded() { - let (mut app, rx) = app_with_channel(); - app.discover_artists_mix = vec![track("0000000000000000000001", "One")]; - // A stale in-range cursor must be reset to the top, not clamped. - app.select_track_row(4); - - app.apply(Action::OpenDiscover(DiscoverTarget::ArtistsMix)); - - assert_eq!(app.track_table.tracks.len(), 1); - assert_eq!( - app.track_table.context, - Some(TrackTableContext::DiscoverPlaylist) - ); - assert_eq!(app.view.track_table_index, 0); - assert_eq!(app.get_current_route().id, RouteId::TrackTable); - assert!(rx.try_recv().is_err(), "a cached mix needs no fetch"); -} - -#[test] -fn open_discover_top_tracks_carries_the_time_range() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::OpenDiscover(DiscoverTarget::TopTracks( - DiscoverTimeRange::Long, - ))); - - assert!(matches!( - rx.try_recv(), - Ok(IoEvent::GetUserTopTracks(DiscoverTimeRange::Long)) - )); -} - -#[test] -fn open_discover_top_tracks_shows_the_cache_when_loaded() { - let (mut app, rx) = app_with_channel(); - app.discover_top_tracks = vec![track("0000000000000000000001", "One")]; - app.select_track_row(4); - - app.apply(Action::OpenDiscover(DiscoverTarget::TopTracks( - DiscoverTimeRange::Short, - ))); - - assert_eq!(app.track_table.tracks.len(), 1); - assert_eq!( - app.track_table.context, - Some(TrackTableContext::DiscoverPlaylist) - ); - assert_eq!(app.view.track_table_index, 0); - assert_eq!(app.get_current_route().id, RouteId::TrackTable); - assert!(rx.try_recv().is_err(), "a cached page needs no fetch"); -} - -#[test] -fn open_discover_while_loading_is_a_noop() { - for target in [ - DiscoverTarget::ArtistsMix, - DiscoverTarget::TopTracks(DiscoverTimeRange::Medium), - ] { - let (mut app, rx) = app_with_channel(); - app.discover_loading = true; - - app.apply(Action::OpenDiscover(target)); - - assert!(rx.try_recv().is_err(), "expected no IoEvent dispatched"); - assert_eq!(app.get_current_route().id, RouteId::Home); - assert_eq!(app.track_table.context, None); - } -} - -// --- the create-playlist form and the friends social graph --- - -use crate::core::app::FriendSearchResult; - -#[test] -fn create_youtube_playlist_dispatches_the_local_create() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::CreateYouTubePlaylist("Focus".to_string())); - - match rx.try_recv() { - Ok(IoEvent::CreateYouTubePlaylist(name)) => assert_eq!(name, "Focus"), - _other => panic!("expected CreateYouTubePlaylist (IoEvent is not Debug)"), - } -} - -#[test] -fn search_tracks_for_playlist_dispatches_the_query_untrimmed() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::SearchTracksForPlaylist("daft punk ".to_string())); - - match rx.try_recv() { - Ok(IoEvent::SearchTracksForPlaylist(query)) => { - assert_eq!(query, "daft punk ", "the arm must not trim"); - } - _other => panic!("expected SearchTracksForPlaylist (IoEvent is not Debug)"), - } -} - -#[test] -fn add_friend_by_code_dispatches() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::AddFriendByCode("JAY123".to_string())); - - match rx.try_recv() { - Ok(IoEvent::AddFriendByCode(code)) => assert_eq!(code, "JAY123"), - _other => panic!("expected AddFriendByCode (IoEvent is not Debug)"), - } -} - -#[test] -fn add_friend_by_user_id_dispatches() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::AddFriendById("user-1".to_string())); - - match rx.try_recv() { - Ok(IoEvent::AddFriendById(user_id)) => assert_eq!(user_id, "user-1"), - _other => panic!("expected AddFriendById (IoEvent is not Debug)"), - } -} - -#[test] -fn unfollow_friend_dispatches() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::UnfollowFriend("user-1".to_string())); - - match rx.try_recv() { - Ok(IoEvent::UnfollowFriend(user_id)) => assert_eq!(user_id, "user-1"), - _other => panic!("expected UnfollowFriend (IoEvent is not Debug)"), - } -} - -#[test] -fn search_friend_users_asks_from_two_bytes_up() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::SearchFriendUsers("ab".to_string())); - - match rx.try_recv() { - Ok(IoEvent::SearchFriendUsers(query)) => assert_eq!(query, "ab"), - _other => panic!("expected SearchFriendUsers (IoEvent is not Debug)"), - } -} - -#[test] -fn search_friend_users_below_the_threshold_clears_stale_results() { - let (mut app, rx) = app_with_channel(); - app.friend_user_search_results = vec![FriendSearchResult { - id: "user-1".to_string(), - name: "Jay".to_string(), - is_following: false, - }]; - - app.apply(Action::SearchFriendUsers("a".to_string())); - - assert!(rx.try_recv().is_err(), "expected no IoEvent dispatched"); - assert!( - app.friend_user_search_results.is_empty(), - "a below-threshold query drops the stale results" - ); -} - -// --- stats, settings, sort and the native queue --- - -use super::ActionOutcome; -use crate::core::plugin_api::ArtistInfo; -use crate::core::sort::{SortContext, SortField, SortOrder}; - -#[test] -fn cycle_stats_period_walks_the_period_ring_and_reloads() { - let (mut app, rx) = app_with_channel(); - let start = app.stats_period; - - app.apply(Action::CycleStatsPeriod { forward: true }); - - assert_eq!(app.stats_period, start.next()); - assert!(app.stats_loading); - assert!(app.stats_data.is_none()); - match rx.try_recv() { - Ok(IoEvent::LoadListeningStats(period)) => assert_eq!(period, start.next()), - _other => panic!("expected LoadListeningStats (IoEvent is not Debug)"), - } - - app.apply(Action::CycleStatsPeriod { forward: false }); - - assert_eq!(app.stats_period, start); - match rx.try_recv() { - Ok(IoEvent::LoadListeningStats(period)) => assert_eq!(period, start), - _other => panic!("expected LoadListeningStats (IoEvent is not Debug)"), - } -} - -#[test] -fn save_settings_writes_the_config_and_reports_success() { - use crate::core::user_config::UserConfigPaths; - - let dir = tempfile::tempdir().unwrap(); - let (mut app, _rx) = app_with_channel(); - app.user_config.path_to_config = Some(UserConfigPaths { - config_file_path: dir.path().join("config.yml"), - }); - app.load_settings_for_category(); - - let outcome = app.apply(Action::SaveSettings); - - assert_eq!(outcome, ActionOutcome::SettingsSaved { saved: true }); - assert!(!app.has_unsaved_settings_changes()); - assert!(dir.path().join("config.yml").exists()); -} - -#[test] -fn save_settings_without_a_config_path_reports_failure_and_raises_the_error_frame() { - let (mut app, _rx) = app_with_channel(); - - let outcome = app.apply(Action::SaveSettings); - - assert_eq!(outcome, ActionOutcome::SettingsSaved { saved: false }); - assert!(!app.api_error().is_empty()); - assert_eq!(app.get_current_route().id, RouteId::Error); -} - -#[test] -fn cycle_visualizer_style_steps_the_configured_style() { - let (mut app, _rx) = app_with_channel(); - let before = app.user_config.behavior.visualizer_style; - - app.apply(Action::CycleVisualizerStyle); - - assert_eq!(app.user_config.behavior.visualizer_style, before.next()); -} - -fn artist(name: &str) -> ArtistInfo { - ArtistInfo { - id: None, - uri: None, - name: name.to_string(), - image_url: None, - } -} - -fn artist_names(app: &App) -> Vec<&str> { - app.artists.iter().map(|a| a.name.as_str()).collect() -} - -#[test] -fn sort_saved_artists_reorders_the_cached_rows_in_place() { - let (mut app, rx) = app_with_channel(); - app.artists = vec![artist("Zed"), artist("Alpha"), artist("Mid")]; - - app.apply(Action::Sort { - context: SortContext::SavedArtists, - field: SortField::Name, - }); - - assert_eq!(artist_names(&app), vec!["Alpha", "Mid", "Zed"]); - assert_eq!(app.artist_sort.field, SortField::Name); - assert_eq!(app.artist_sort.order, SortOrder::Ascending); - assert!( - rx.try_recv().is_err(), - "an in-place sort dispatches nothing" - ); -} - -#[test] -fn sorting_by_the_field_in_effect_flips_the_direction_and_resorts() { - let (mut app, _rx) = app_with_channel(); - app.artists = vec![artist("Zed"), artist("Alpha")]; - app.apply(Action::Sort { - context: SortContext::SavedArtists, - field: SortField::Name, - }); - - app.apply(Action::Sort { - context: SortContext::SavedArtists, - field: SortField::Name, - }); - - assert_eq!(app.artist_sort.order, SortOrder::Descending); - assert_eq!(artist_names(&app), vec!["Zed", "Alpha"]); -} - -#[test] -fn toggle_sort_order_flips_the_recorded_direction_without_resorting() { - let (mut app, _rx) = app_with_channel(); - app.artists = vec![artist("Zed"), artist("Alpha")]; - app.apply(Action::Sort { - context: SortContext::SavedArtists, - field: SortField::Name, - }); - - app.apply(Action::ToggleSortOrder(SortContext::SavedArtists)); - - assert_eq!(app.artist_sort.order, SortOrder::Descending); - assert_eq!( - artist_names(&app), - vec!["Alpha", "Zed"], - "the rows keep the order that was applied" - ); -} - -fn queued(uri: &str, name: &str) -> TrackInfo { - TrackInfo { - uri: Some(uri.to_string()), - name: name.to_string(), - artists: vec![], - album: String::new(), - duration_ms: 0, - id: None, - album_id: None, - artist_refs: vec![], - is_playable: true, - is_local: false, - track_number: 0, - explicit: false, - image_url: None, - } -} - -fn queue_names(app: &App) -> Vec<&str> { - app.native_queue.iter().map(|t| t.name.as_str()).collect() -} - -fn app_with_three_queued() -> (App, Receiver) { - let (mut app, rx) = app_with_channel(); - app.native_queue = vec![ - queued("spotify:track:a", "A"), - queued("spotify:track:b", "B"), - queued("spotify:track:c", "C"), - ]; - (app, rx) -} - -#[test] -fn play_queue_item_drops_the_earlier_items_and_advances() { - let (mut app, rx) = app_with_three_queued(); - - app.apply(Action::PlayQueueItem { - uri: "spotify:track:c".to_string(), - position: 2, - }); - - assert_eq!(queue_names(&app), vec!["C"]); - assert!(matches!(rx.try_recv(), Ok(IoEvent::AdvanceNativeQueue))); -} - -#[test] -fn play_queue_item_falls_back_to_the_uri_when_the_position_is_stale() { - let (mut app, rx) = app_with_three_queued(); - - app.apply(Action::PlayQueueItem { - uri: "spotify:track:c".to_string(), - position: 0, - }); - - assert_eq!(queue_names(&app), vec!["C"]); - assert!(matches!(rx.try_recv(), Ok(IoEvent::AdvanceNativeQueue))); -} - -#[test] -fn queue_actions_on_an_unknown_uri_are_noops() { - let (mut app, rx) = app_with_three_queued(); - let unknown = "spotify:track:zzz".to_string(); - - app.apply(Action::PlayQueueItem { - uri: unknown.clone(), - position: 0, - }); - app.apply(Action::RemoveFromQueue { - uri: unknown.clone(), - position: 1, - }); - app.apply(Action::MoveQueueItem { - uri: unknown, - from: 0, - to: 2, - }); - - assert_eq!(queue_names(&app), vec!["A", "B", "C"]); - assert!(rx.try_recv().is_err(), "nothing may be dispatched"); -} - -#[test] -fn remove_from_queue_and_move_queue_item_edit_the_queue() { - let (mut app, rx) = app_with_three_queued(); - - app.apply(Action::RemoveFromQueue { - uri: "spotify:track:b".to_string(), - position: 1, - }); - assert_eq!(queue_names(&app), vec!["A", "C"]); - - app.apply(Action::MoveQueueItem { - uri: "spotify:track:c".to_string(), - from: 1, - to: 0, - }); - assert_eq!(queue_names(&app), vec!["C", "A"]); - - // An out-of-range destination changes nothing. - app.apply(Action::MoveQueueItem { - uri: "spotify:track:c".to_string(), - from: 0, - to: 5, - }); - assert_eq!(queue_names(&app), vec!["C", "A"]); - assert!(rx.try_recv().is_err(), "queue edits dispatch nothing"); -} - -// --- the DJ screen's own actions (no-ops without ai-dj) --- - -#[cfg(feature = "ai-dj")] -mod dj_screen_actions { - use super::*; - use crate::infra::dj::{DjLine, DjSpeaker, TurnKind}; - - #[test] - fn ask_dj_starts_one_turn_and_bumps_the_generation_once() { - let (mut app, rx) = app_with_channel(); - let before = app.dj.generation; - - app.apply(Action::AskDj(" something chill ".to_string())); - - assert!(app.dj.thinking, "the turn is in flight"); - assert_eq!(app.dj.generation, before + 1); - assert_eq!(app.dj.transcript.len(), 1); - assert_eq!(app.dj.transcript[0], DjLine::user("something chill")); - assert!( - !app.is_loading, - "a brain call must not pin the global spinner" - ); - match rx.try_recv() { - Ok(IoEvent::AskDj(request)) => { - assert!(request.extra_instruction.is_none()); - assert_eq!(request.generation, app.dj.generation); - assert!(!request.must_act); - assert_eq!(request.vibe_on_success.as_deref(), Some("something chill")); - assert_eq!(request.turn_seq, app.dj.turn_seq); - } - _other => panic!("expected AskDj (IoEvent is not Debug)"), - } - } - - #[test] - fn ask_dj_while_a_turn_is_in_flight_is_refused_and_says_so() { - let (mut app, rx) = app_with_channel(); - app.dj.begin_turn(TurnKind::Ask); - let generation = app.dj.generation; - - // Whitespace on purpose: the busy message has to win over the blank check. - app.apply(Action::AskDj(" ".to_string())); - - assert!(rx.try_recv().is_err(), "nothing may be dispatched"); - assert_eq!(app.dj.generation, generation); - assert!(app.dj.transcript.is_empty()); - assert_eq!( - app.status_message(), - Some("The DJ is still working on the last request") - ); - } - - #[test] - fn ask_dj_with_a_blank_prompt_dispatches_nothing() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::AskDj(" ".to_string())); - - assert!(rx.try_recv().is_err()); - assert!(!app.dj.thinking); - assert!(app.dj.transcript.is_empty()); - } - - #[test] - fn a_vibe_shift_asks_again_with_a_steer_that_must_act() { - let (mut app, rx) = app_with_channel(); - let before = app.dj.generation; - - app.apply(Action::DjVibeShift); - - assert_eq!(app.dj.generation, before + 1); - assert!(app.dj.thinking); - assert!(!app.is_loading); - match rx.try_recv() { - Ok(IoEvent::AskDj(request)) => { - assert!(request - .extra_instruction - .as_deref() - .is_some_and(|steer| steer.contains("Change direction"))); - assert!(request.must_act); - assert!(request.vibe_on_success.is_none()); - } - _other => panic!("expected AskDj (IoEvent is not Debug)"), - } - let last = app.dj.transcript.last().unwrap(); - assert!(last.text.contains("vibe")); - assert_eq!(last.speaker, DjSpeaker::System); - } - - #[test] - fn a_vibe_shift_while_thinking_is_refused() { - let (mut app, rx) = app_with_channel(); - app.dj.begin_turn(TurnKind::Ask); - - app.apply(Action::DjVibeShift); - - assert!(rx.try_recv().is_err()); - assert_eq!( - app.status_message(), - Some("The DJ is already working on something") - ); - } - - #[test] - fn toggling_dj_auto_queue_on_with_a_short_queue_asks_for_a_refill() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::ToggleDjAutoQueue); - - assert!(app.dj.auto_queue); - assert!(!app.is_loading); - match rx.try_recv() { - Ok(IoEvent::DjTopUp(generation, turn_seq)) => { - assert_eq!(generation, app.dj.generation); - assert_eq!(turn_seq, app.dj.turn_seq); - } - _other => panic!("expected DjTopUp (IoEvent is not Debug)"), - } - } - - #[test] - fn toggling_dj_auto_queue_off_does_not_abandon_a_question_in_flight() { - let (mut app, rx) = app_with_channel(); - app.apply(Action::AskDj("something warm".to_string())); - assert!(matches!(rx.try_recv(), Ok(IoEvent::AskDj(_)))); - let generation = app.dj.generation; - app.dj.auto_queue = true; - - app.apply(Action::ToggleDjAutoQueue); - - assert!(!app.dj.auto_queue); - assert!(app.dj.thinking, "the question is still being answered"); - assert_eq!(app.dj.generation, generation); - } - - #[test] - fn toggling_dj_auto_queue_off_still_abandons_a_refill() { - let (mut app, _rx) = app_with_channel(); - app.dj.auto_queue = true; - app.dj.begin_turn(TurnKind::Refill); - app.dj.step = Some((2, 4)); - let generation = app.dj.generation; - - app.apply(Action::ToggleDjAutoQueue); - - assert!(!app.dj.thinking, "nobody is waiting on a refill"); - assert!(app.dj.step.is_none()); - assert_ne!(app.dj.generation, generation); - } - - #[test] - fn toggling_dj_fresh_only_starts_the_crawl_once() { - let (mut app, rx) = app_with_channel(); - assert!(!app.dj.avoid_library); - - app.apply(Action::ToggleDjFreshOnly); - assert!(app.dj.avoid_library); - assert!(matches!(rx.try_recv(), Ok(IoEvent::DjIndexLibrary))); - - // Off, then on again with the index already cached: no second crawl. - app.apply(Action::ToggleDjFreshOnly); - assert!(!app.dj.avoid_library); - app.dj.library = Some(crate::infra::dj::DjLibrary::default()); - app.apply(Action::ToggleDjFreshOnly); - assert!(app.dj.avoid_library); - assert!(rx.try_recv().is_err()); - } - - #[test] - fn open_dj_setup_opens_the_screen_and_the_picker_without_stacking_routes() { - let (mut app, rx) = app_with_channel(); - - app.apply(Action::OpenDjSetup); - - assert_eq!(app.get_current_route().id, RouteId::AiDj); - assert_eq!(app.get_current_route().active_block, ActiveBlock::AiDj); - assert!(app.dj.setup.is_some()); - assert!(rx.try_recv().is_err(), "the filter is off, so no crawl"); - - app.apply(Action::OpenDjSetup); - app.pop_navigation_stack(); - assert_ne!(app.get_current_route().id, RouteId::AiDj); - } - - #[test] - fn open_dj_setup_warms_the_library_index_when_the_filter_is_on() { - let (mut app, rx) = app_with_channel(); - app.apply(Action::OpenDjSetup); - assert!(rx.try_recv().is_err()); - app.dj.avoid_library = true; - - // Already on the DJ screen: the picker key still warms the index. - app.apply(Action::OpenDjSetup); - - assert!(matches!(rx.try_recv(), Ok(IoEvent::DjIndexLibrary))); - assert_eq!(app.get_current_route().id, RouteId::AiDj); - } -} diff --git a/src/core/app/album_theme.rs b/src/core/app/album_theme.rs deleted file mode 100644 index 1cecdd5f..00000000 --- a/src/core/app/album_theme.rs +++ /dev/null @@ -1,383 +0,0 @@ -use super::*; - -impl App { - /// The user's own theme: the restore target while an album-derived theme is - /// applied (or fading out), otherwise the live theme. Settings rows must be - /// built from this, never from the live theme, or album accents would leak - /// into `custom_theme` and persist on the next settings save. - #[cfg(feature = "art-decode")] - pub fn user_theme(&self) -> crate::core::user_config::Theme { - use crate::core::cover_theme::CoverThemeState; - match self.cover_theme_state { - CoverThemeState::Active { base } | CoverThemeState::Restoring { base } => base, - CoverThemeState::Inactive => self.user_config.theme, - } - } - - #[cfg(not(feature = "art-decode"))] - pub fn user_theme(&self) -> crate::core::user_config::Theme { - self.user_config.theme - } - - /// Whether an adaptive-theme fade is animating (drives the fast tick rate). - #[cfg(feature = "art-decode")] - pub fn theme_fade_active(&self) -> bool { - self.theme_transition.is_some() - } - - #[cfg(not(feature = "art-decode"))] - pub fn theme_fade_active(&self) -> bool { - false - } - - /// Store freshly decoded cover art together with the palette extracted from - /// it. The single entry point that keeps the adaptive theme in sync with the - /// art: `None` for the palette fades back to the user's own theme. - #[cfg(feature = "art-decode")] - pub fn store_cover_art( - &mut self, - key: String, - img: image::DynamicImage, - palette: Option, - ) { - self.cover_art.store_decoded(key, img); - match palette { - Some(palette) => self.set_cover_art_palette(palette), - None => self.clear_cover_art_palette(), - } - } - - /// Drop the stored cover art together with its palette; the adaptive theme - /// follows the art and fades back to the user's own theme. - #[cfg(feature = "art-decode")] - pub fn clear_cover_art(&mut self) { - self.cover_art.clear(); - self.clear_cover_art_palette(); - } - - /// Store the palette extracted from freshly loaded cover art and, when - /// Adaptive Theme is on, fade the UI accents toward it. - #[cfg(feature = "art-decode")] - fn set_cover_art_palette(&mut self, palette: crate::core::cover_theme::AlbumPalette) { - self.cover_art_palette = Some(palette); - self.apply_cover_theme(); - } - - /// Drop the stored palette and fade back to the user's own theme. A cheap - /// no-op when nothing is applied, so it is safe on every tick of the - /// "no art" path. - #[cfg(feature = "art-decode")] - fn clear_cover_art_palette(&mut self) { - self.cover_art_palette = None; - self.restore_cover_theme(); - } - - /// Fade toward the theme derived from the stored palette, capturing the - /// user's theme as the restore base on first application. - #[cfg(feature = "art-decode")] - fn apply_cover_theme(&mut self) { - use crate::core::cover_theme::{derive_theme, CoverThemeState}; - if !self.user_config.behavior.cover_art_theme { - return; - } - let Some(palette) = self.cover_art_palette else { - return; - }; - // Re-applying while active or mid-restore keeps the original base; only - // from Inactive is the live theme really the user's own (a fade-out in - // flight leaves a blend in `user_config.theme` that must not be captured). - let base = self.user_theme(); - self.cover_theme_state = CoverThemeState::Active { base }; - let target = derive_theme(&base, &palette); - self.begin_theme_transition(target); - } - - /// Fade back to the user's own theme, if an album theme is applied. - #[cfg(feature = "art-decode")] - fn restore_cover_theme(&mut self) { - use crate::core::cover_theme::CoverThemeState; - if let CoverThemeState::Active { base } = self.cover_theme_state { - self.cover_theme_state = if self.begin_theme_transition(base) { - CoverThemeState::Restoring { base } - } else { - CoverThemeState::Inactive - }; - } - } - - /// Start fading the live theme toward `target`. Returns false when the - /// theme is already there (nothing to animate). A transition already headed - /// to the same target is left to finish rather than restarted. - #[cfg(feature = "art-decode")] - fn begin_theme_transition(&mut self, target: crate::core::user_config::Theme) -> bool { - use crate::core::cover_theme::ThemeTransition; - if let Some(transition) = &self.theme_transition { - if transition.target() == target { - return true; - } - } - if self.user_config.theme == target { - self.theme_transition = None; - return false; - } - self.theme_transition = Some(ThemeTransition::new(self.user_config.theme, target)); - true - } - - /// Reconcile the adaptive theme after `apply_settings_changes` rewrote - /// `user_config.theme` from the settings rows (which every save does, for - /// every row). The rows carry the user's own colors, so that rewrite is the - /// new base; what was actually on screen (`live_before`) is put back so the - /// fade continues from there. `enabled_before` is the Adaptive Theme flag - /// before the save, so a flip re-applies or restores. - #[cfg(feature = "art-decode")] - pub(super) fn reconcile_cover_theme_after_settings( - &mut self, - live_before: crate::core::user_config::Theme, - enabled_before: bool, - ) { - use crate::core::cover_theme::CoverThemeState; - match self.cover_theme_state { - CoverThemeState::Inactive => {} - previous => { - let base = self.user_config.theme; - self.cover_theme_state = CoverThemeState::Active { base }; - self.user_config.theme = live_before; - self.theme_transition = None; - if matches!(previous, CoverThemeState::Restoring { .. }) { - self.restore_cover_theme(); - } else { - self.apply_cover_theme(); - } - } - } - if self.user_config.behavior.cover_art_theme != enabled_before { - if self.user_config.behavior.cover_art_theme { - self.apply_cover_theme(); - } else { - self.restore_cover_theme(); - } - } - } -} - -#[cfg(all(test, feature = "art-decode"))] -mod tests { - use super::*; - - #[cfg(feature = "art-decode")] - mod cover_theme_tests { - use super::*; - use crate::core::cover_theme::{derive_theme, AlbumPalette, CoverThemeState}; - use crate::core::user_config::ThemePreset; - - const PALETTE: AlbumPalette = AlbumPalette { - primary: (200, 30, 40), - secondary: (30, 60, 220), - }; - - fn app_with_adaptive_on() -> App { - let mut app = App::default(); - app.user_config.behavior.cover_art_theme = true; - app - } - - #[test] - fn palette_application_fades_in_and_clear_restores_the_user_theme() { - let mut app = app_with_adaptive_on(); - let user = app.user_config.theme; - - app.set_cover_art_palette(PALETTE); - assert!(matches!( - app.cover_theme_state, - CoverThemeState::Active { .. } - )); - assert!(app.theme_transition.is_some()); - - // A tick longer than the fade completes it: accents changed, the - // structural colors kept. - app.update_on_tick(Duration::from_secs(2)); - assert!(app.theme_transition.is_none()); - assert_ne!(app.user_config.theme.active, user.active); - assert_eq!(app.user_config.theme.text, user.text); - assert_eq!(app.user_config.theme.background, user.background); - - app.clear_cover_art_palette(); - assert!(matches!( - app.cover_theme_state, - CoverThemeState::Restoring { .. } - )); - app.update_on_tick(Duration::from_secs(2)); - assert_eq!(app.user_config.theme, user); - assert_eq!(app.cover_theme_state, CoverThemeState::Inactive); - } - - #[test] - fn palette_is_stored_but_not_applied_while_disabled() { - let mut app = App::default(); - assert!(!app.user_config.behavior.cover_art_theme); - let user = app.user_config.theme; - - app.set_cover_art_palette(PALETTE); - - assert_eq!(app.cover_theme_state, CoverThemeState::Inactive); - assert!(app.theme_transition.is_none()); - assert_eq!(app.user_config.theme, user); - assert_eq!(app.cover_art_palette, Some(PALETTE)); - } - - #[test] - fn user_theme_reports_the_base_while_an_album_theme_is_applied() { - let mut app = app_with_adaptive_on(); - let user = app.user_config.theme; - app.set_cover_art_palette(PALETTE); - app.update_on_tick(Duration::from_secs(2)); - - assert_ne!(app.user_config.theme, user); - assert_eq!(app.user_theme(), user); - } - - #[test] - fn settings_rewrite_becomes_the_new_base_and_rederives() { - let mut app = app_with_adaptive_on(); - app.set_cover_art_palette(PALETTE); - app.update_on_tick(Duration::from_secs(2)); - let displayed = app.user_config.theme; - - // What apply_settings_changes does on save: the arms rewrite the live - // theme with the user's own colors (here: a new preset), then the - // reconciliation runs. - let new_user = ThemePreset::Dracula.to_theme(); - app.user_config.theme = new_user; - app.reconcile_cover_theme_after_settings(displayed, true); - - assert!( - matches!(app.cover_theme_state, CoverThemeState::Active { base } if base == new_user) - ); - app.update_on_tick(Duration::from_secs(2)); - assert_eq!(app.user_config.theme, derive_theme(&new_user, &PALETTE)); - assert_eq!(app.user_theme(), new_user); - } - - #[test] - fn toggle_off_restores_and_toggle_on_reapplies_the_stored_palette() { - let mut app = app_with_adaptive_on(); - let user = app.user_config.theme; - app.set_cover_art_palette(PALETTE); - app.update_on_tick(Duration::from_secs(2)); - - // Toggle off the way a settings save does: through - // apply_settings_changes, which resets the live theme to the base - // before the arms run. - app.settings_items = vec![SettingItem { - id: "behavior.cover_art_theme".to_string(), - name: "Adaptive Theme".to_string(), - description: String::new(), - value: SettingValue::Bool(false), - }]; - app.apply_settings_changes(); - app.update_on_tick(Duration::from_secs(2)); - assert_eq!(app.user_config.theme, user); - assert_eq!(app.cover_theme_state, CoverThemeState::Inactive); - - // Toggling back on recolors immediately from the stored palette. - app.settings_items[0].value = SettingValue::Bool(true); - app.apply_settings_changes(); - assert!(matches!( - app.cover_theme_state, - CoverThemeState::Active { .. } - )); - assert!(app.theme_transition.is_some()); - } - - #[test] - fn save_from_another_category_keeps_the_base_theme() { - let mut app = app_with_adaptive_on(); - let user = app.user_config.theme; - app.set_cover_art_palette(PALETTE); - app.update_on_tick(Duration::from_secs(2)); - assert_ne!(app.user_config.theme, user); - - // A save from the Behavior category: settings_items holds only that - // category's rows, so no theme.* arm rewrites `user_config.theme` with - // the user's own colors. The base must not drift to the blend. - app.settings_items = vec![SettingItem { - id: "behavior.seek_milliseconds".to_string(), - name: "Seek Duration (ms)".to_string(), - description: String::new(), - value: SettingValue::Number(app.user_config.behavior.seek_milliseconds as i64), - }]; - app.apply_settings_changes(); - - assert_eq!(app.user_theme(), user, "base drifted to the blended theme"); - - app.clear_cover_art_palette(); - app.update_on_tick(Duration::from_secs(2)); - assert_eq!( - app.user_config.theme, user, - "did not restore the user theme" - ); - } - - #[test] - fn track_change_during_restore_keeps_the_original_base() { - let mut app = app_with_adaptive_on(); - let user = app.user_config.theme; - app.set_cover_art_palette(PALETTE); - app.update_on_tick(Duration::from_secs(2)); - - // Art clears (fade-out starts) and new art arrives mid-fade: the base - // captured must be the user's theme, not the half-restored blend. - app.clear_cover_art_palette(); - app.update_on_tick(Duration::from_millis(100)); - assert!(matches!( - app.cover_theme_state, - CoverThemeState::Restoring { .. } - )); - app.set_cover_art_palette(AlbumPalette { - primary: (30, 200, 90), - secondary: (200, 30, 40), - }); - match app.cover_theme_state { - CoverThemeState::Active { base } => assert_eq!(base, user), - other => panic!("expected Active, got {other:?}"), - } - } - - #[test] - fn custom_theme_save_does_not_leak_album_analysis_bar_into_base() { - let mut app = app_with_adaptive_on(); - app.user_config.current_preset = ThemePreset::Custom; - app.user_config.custom_theme = app.user_config.theme; - let user = app.user_config.theme; - - app.set_cover_art_palette(PALETTE); - app.update_on_tick(Duration::from_secs(2)); - // analysis_bar is album-colored while applied, and has no settings row - // in any category, so a save must not treat the live value as the - // user's choice. - assert_ne!(app.user_config.theme.analysis_bar, user.analysis_bar); - - // Settings -> Theme, save without changing anything. - app.view.settings_category = crate::core::app::SettingsCategory::Theme; - app.load_settings_for_category(); - app.apply_settings_changes(); - assert_eq!(app.user_theme().analysis_bar, user.analysis_bar); - - // Turning adaptive theming off (a real settings save) restores the - // user's analysis_bar. - app.settings_items = vec![SettingItem { - id: "behavior.cover_art_theme".to_string(), - name: "Adaptive Theme".to_string(), - description: String::new(), - value: SettingValue::Bool(false), - }]; - app.apply_settings_changes(); - app.update_on_tick(Duration::from_secs(2)); - assert_eq!( - app.user_config.theme.analysis_bar, user.analysis_bar, - "album accent stuck in analysis_bar after adaptive theme off" - ); - } - } -} diff --git a/src/core/app/construction.rs b/src/core/app/construction.rs deleted file mode 100644 index dbfa3cf3..00000000 --- a/src/core/app/construction.rs +++ /dev/null @@ -1,344 +0,0 @@ -use super::*; - -impl Default for App { - fn default() -> Self { - App { - spectrum_data: None, - audio_capture_active: false, - view: ViewState { - search_hovered_block: SearchResultBlock::SongSearch, - ..ViewState::default() - }, - album_table_context: AlbumTableContext::Full, - discover_top_tracks: vec![], - discover_artists_mix: vec![], - discover_loading: false, - stats_period: RecapPeriod::ThirtyDays, - stats_loading: false, - stats_data: None, - listening_streaks: None, - recap_prompt: None, - community_pin_item: PlaylistFolderItem::CommunityPin, - local_playlists: Vec::new(), - subsonic_playlists: Vec::new(), - qobuz_playlists: Vec::new(), - radio_stations: Vec::new(), - youtube_playlists: Vec::new(), - youtube_open_playlist: None, - active_source: Source::default(), - artists: vec![], - artist: None, - user_config: UserConfig::new(), - runtime_state: RuntimeState::default(), - state_path: None, - recently_played: None, - selected_album_simplified: None, - selected_album_full: None, - library: Library { - saved_tracks: ScrollableResultPages::new(), - saved_albums: ScrollableResultPages::new(), - saved_shows: ScrollableResultPages::new(), - saved_artists: ScrollableResultPages::new(), - show_episodes: ScrollableResultPages::new(), - }, - liked_song_ids_set: HashSet::new(), - liked_lookup_pending: HashSet::new(), - liked_lookup_worker_running: false, - liked_state_epoch: 0, - followed_artist_ids_set: HashSet::new(), - saved_album_ids_set: HashSet::new(), - saved_show_ids_set: HashSet::new(), - navigation_stack: vec![DEFAULT_ROUTE], - api_error: String::new(), - api_error_expires_at: None, - current_playback_context: None, - last_track_id: None, - pending_stop_after_track: false, - devices: None, - queue: None, - native_queue: Vec::new(), - queue_suspended: None, - #[cfg(feature = "queue")] - queue_now: None, - #[cfg(feature = "streaming")] - spotify_queue_guard_reloads: 0, - #[cfg(feature = "queue")] - queue_slot_desired_playing: true, - playlist_offset: 0, - playlist_tracks: None, - playlist_track_pages: ScrollableResultPages::new(), - playlist_track_table_id: None, - active_playlist_track_filter: None, - pending_playlist_track_search: None, - playlists: None, - recommendations_context: None, - recommendations_seed: "".to_string(), - search_results: SearchResult::default(), - song_progress_ms: 0, - last_tick_at: Instant::now(), - seek_ms: None, - #[cfg(feature = "streaming")] - last_native_seek: None, - #[cfg(feature = "streaming")] - pending_native_seek: None, - last_api_seek: None, - pending_api_seek: None, - last_source_seek: None, - pending_source_seek: None, - track_table: Default::default(), - episode_table_context: EpisodeTableContext::Full, - selected_show_simplified: None, - selected_show_full: None, - user: None, - instant_since_last_current_playback_poll: Instant::now(), - clipboard: Clipboard::new().ok(), - is_loading: false, - io_tx: None, - is_fetching_current_playback: false, - spotify_token_expiry: None, - spotify_connected: false, - auth_refresh_in_progress: false, - spotify_refresh_retry_at: None, - pending_keybinding_persist: None, - keybinding_runtime: KeybindingRuntimeState::default(), - - active_announcement: None, - pending_announcements: Vec::new(), - lyrics: None, - lyrics_status: LyricsStatus::default(), - desired_lyrics_identity: None, - lyrics_synced: false, - global_song_count: None, - global_song_count_failed: false, - // Settings defaults - settings_items: Vec::new(), - settings_saved_items: Vec::new(), - native_track_info: None, - is_streaming_active: false, - native_device_id: None, - pending_play_file: None, - native_is_playing: None, - native_playback_origin: None, - #[cfg(feature = "streaming")] - native_spotify_shuffle: None, - #[cfg(feature = "streaming")] - native_shuffle_generation: 0, - keepawake: None, - last_device_activation: None, - native_activation_pending: false, - // Sort menu defaults - playlist_sort: SortState::new(), - album_sort: SortState::new(), - artist_sort: SortState::new(), - recently_played_sort: SortState::new(), - last_party_sync_at: Instant::now(), - status_message: None, - status_message_expires_at: None, - status_message_is_error: false, - party_status: PartyStatus::default(), - party_session: None, - pending_track_table_selection: None, - playlist_track_positions: None, - pending_playlist_track_add: None, - pending_playlist_track_removal: None, - all_playlists: Vec::new(), - _playlist_folder_nodes: None, - playlist_folder_items: Vec::new(), - current_playlist_folder_id: 0, - playlist_refresh_generation: 0, - saved_tracks_prefetch_generation: 0, - saved_tracks_prefetch_in_flight: HashSet::new(), - playlist_tracks_prefetch_generation: 0, - pending_playlist_open: None, - playlist_tracks_prefetch_in_flight: HashSet::new(), - playlist_sort_fetch_in_flight: HashSet::new(), - is_volume_change_in_flight: false, - state_save_due: None, - pending_state_save_patch: PersistedRuntimeState::default(), - state_save_error_reported: false, - pending_volume: None, - last_dispatched_volume: None, - #[cfg(feature = "streaming")] - streaming_player: None, - decoded_repeat: RepeatMode::Off, - decoded_shuffle: false, - #[cfg(feature = "local-files")] - local_playback: None, - #[cfg(feature = "subsonic")] - subsonic_playback: None, - #[cfg(feature = "qobuz")] - qobuz_playback: None, - #[cfg(feature = "internet-radio")] - radio_playback: None, - #[cfg(feature = "youtube")] - youtube_playback: None, - #[cfg(feature = "audio-decode")] - decoded_sink_claim: None, - #[cfg(feature = "streaming")] - streaming_recovery_tx: None, - #[cfg(feature = "streaming")] - pending_start_playback: None, - #[cfg(feature = "streaming")] - native_backend_pending: false, - #[cfg(feature = "streaming")] - native_load_watchdog: None, - #[cfg(feature = "streaming")] - native_playback_recovery: None, - #[cfg(feature = "streaming")] - native_restore_pending: None, - #[cfg(feature = "streaming")] - native_playback_generation: 0, - #[cfg(all(feature = "mpris", target_os = "linux"))] - mpris_manager: None, - #[cfg(feature = "art-decode")] - cover_art: crate::core::art::CoverArtStore::default(), - #[cfg(feature = "art-decode")] - desired_cover_art_key: None, - #[cfg(feature = "art-decode")] - cover_art_palette: None, - #[cfg(feature = "art-decode")] - cover_theme_state: crate::core::cover_theme::CoverThemeState::default(), - #[cfg(feature = "art-decode")] - theme_transition: None, - #[cfg(feature = "dj-core")] - dj: crate::infra::dj::DjState::default(), - friends: Vec::new(), - friends_loading: false, - friend_code: None, - friend_user_search_results: Vec::new(), - last_friends_refresh_at: Instant::now(), - create_playlist_tracks: Vec::new(), - create_playlist_search_results: Vec::new(), - pending_plugin_commands: Vec::new(), - plugin_data_generations: PluginDataGenerations::default(), - plugin_screens: std::collections::BTreeMap::new(), - pending_plugin_screen_keys: Vec::new(), - plugin_playbar_segments: std::collections::BTreeMap::new(), - plugin_popup: None, - log_path: crate::core::paths::app_log_path().display().to_string(), - } - } -} - -impl App { - /// `App::default()` with a Spotify session, for tests that need one without - /// an `IoEvent` channel. - #[cfg(test)] - pub(crate) fn default_connected() -> App { - App { - spotify_connected: true, - ..App::default() - } - } - - /// This app under a browse scope, for tests. - #[cfg(all(test, feature = "tui"))] - pub(crate) fn under_source(mut self, source: Source) -> App { - self.active_source = source; - self - } - - /// This app with a Spotify playback context, for tests. - #[cfg(all(test, feature = "tui"))] - pub(crate) fn with_playback(mut self, context: CurrentPlaybackContext) -> App { - self.current_playback_context = Some(context); - self - } - - #[cfg(test)] - pub fn new( - io_tx: Sender, - user_config: UserConfig, - spotify_token_expiry: Option, - ) -> App { - Self::new_with_state( - io_tx, - user_config, - RuntimeState::default(), - None, - spotify_token_expiry, - ) - } - - pub fn new_with_state( - io_tx: Sender, - user_config: UserConfig, - runtime_state: RuntimeState, - state_path: Option, - spotify_token_expiry: Option, - ) -> App { - // Read the persisted active source before moving runtime_state into the struct, - // so the restored value overrides the Source::default() set by App::default(). - let active_source = runtime_state.active_source; - // Same reason: read before the move. The config only seeds the DJ's filter; - // the toggle owns it from then on. - #[cfg(feature = "ai-dj")] - let dj_avoid_library = user_config.behavior.dj_avoid_library; - // Resolve configurable per-context default sort states. Config validation - // already rejected invalid specs at load time, so parse failure here is a - // defensive fallback to the built-in default sort. - let parse_sort = |spec: &str, ctx: SortContext| -> SortState { - SortState::parse(spec, ctx).unwrap_or_default() - }; - let playlist_sort = parse_sort( - &user_config.behavior.default_sort_playlist_tracks, - SortContext::PlaylistTracks, - ); - let album_sort = parse_sort( - &user_config.behavior.default_sort_saved_albums, - SortContext::SavedAlbums, - ); - let artist_sort = parse_sort( - &user_config.behavior.default_sort_saved_artists, - SortContext::SavedArtists, - ); - let recently_played_sort = parse_sort( - &user_config.behavior.default_sort_recently_played, - SortContext::RecentlyPlayed, - ); - // Resolve the configurable startup route. Unknown / non-context-free values - // degrade to Home + warn (precedent: StartupBehavior::from_name). - let startup_route_id = match RouteId::from_config_str(&user_config.behavior.startup_route) { - Some(id) => id, - None => { - log::warn!( - "[config] startup_route '{}' is not a valid context-free route (valid: {}); using Home", - user_config.behavior.startup_route, - RouteId::STARTUP_OPTIONS - .iter() - .map(|r| r.to_config_str()) - .collect::>() - .join(", ") - ); - RouteId::Home - } - }; - let startup_route = Route { - id: startup_route_id, - active_block: ActiveBlock::Empty, - hovered_block: ActiveBlock::Library, - }; - App { - io_tx: Some(io_tx), - user_config, - runtime_state, - state_path, - // A token expiry means a Spotify session loaded at startup; a free-source - // launch with no cached token passes `None`. In-TUI login flips both fields. - spotify_connected: spotify_token_expiry.is_some(), - spotify_token_expiry, - active_source, - navigation_stack: vec![startup_route], - playlist_sort, - album_sort, - artist_sort, - recently_played_sort, - #[cfg(feature = "ai-dj")] - dj: crate::infra::dj::DjState { - avoid_library: dj_avoid_library, - ..crate::infra::dj::DjState::default() - }, - ..App::default() - } - } -} diff --git a/src/core/app/discover.rs b/src/core/app/discover.rs deleted file mode 100644 index 063e20c8..00000000 --- a/src/core/app/discover.rs +++ /dev/null @@ -1,128 +0,0 @@ -use super::*; - -/// Time range for Top Tracks/Artists in Discover feature -#[derive(Clone, PartialEq, Eq, Debug, Copy, Default, serde::Serialize, serde::Deserialize)] -pub enum DiscoverTimeRange { - /// Last 4 weeks - Short, - /// Last 6 months (default) - #[default] - Medium, - /// All time - Long, -} - -impl DiscoverTimeRange { - pub fn label(&self) -> &'static str { - match self { - DiscoverTimeRange::Short => "4 weeks", - DiscoverTimeRange::Medium => "6 months", - DiscoverTimeRange::Long => "All time", - } - } - - pub fn next(&self) -> Self { - match self { - DiscoverTimeRange::Short => DiscoverTimeRange::Medium, - DiscoverTimeRange::Medium => DiscoverTimeRange::Long, - DiscoverTimeRange::Long => DiscoverTimeRange::Short, - } - } - - pub fn prev(&self) -> Self { - match self { - DiscoverTimeRange::Short => DiscoverTimeRange::Long, - DiscoverTimeRange::Medium => DiscoverTimeRange::Short, - DiscoverTimeRange::Long => DiscoverTimeRange::Medium, - } - } -} - -#[derive(Clone, PartialEq, Debug)] -pub enum RecommendationsContext { - Artist, - Song, -} - -impl App { - pub fn get_recommendations_for_seed( - &mut self, - seed_artists: Option>, - seed_tracks: Option>, - first_track: Option, - ) { - let user_country = self.get_user_country(); - self.dispatch(IoEvent::GetRecommendationsForSeed( - seed_artists, - seed_tracks, - Box::new(first_track), - user_country, - )); - } - - pub fn get_recommendations_for_track_id(&mut self, id: String) { - let user_country = self.get_user_country(); - self.dispatch(IoEvent::GetRecommendationsForTrackId(id, user_country)); - } - - /// Seed the track-radio recommendations flow from one track: set the Song - /// context, record the seed label, and fetch recommendations for it. - /// - /// NOTE: preserves a pre-existing bug. The historic handler fed the - /// track's full URI ("spotify:track:...") as the seed, which - /// `TrackId::from_id` rejects, so the whole seed_tracks list collapses to - /// `None` and the recommendation request goes out unseeded. - /// `TrackInfo::uri` reproduces that exact URI string; switching to - /// `track.id` (base62) would change behavior. Fix the seeding separately - /// with its own verification. - pub fn load_recommendations_for_track(&mut self, track: TrackInfo) { - let seed_tracks = track.uri.clone().map(|uri| vec![uri]); - self.recommendations_context = Some(RecommendationsContext::Song); - self.recommendations_seed = track.name.clone(); - self.get_recommendations_for_seed(None, seed_tracks, Some(track)); - } - - pub fn load_recommendations_for_artist(&mut self, id: String, name: String) { - self.recommendations_context = Some(RecommendationsContext::Artist); - self.recommendations_seed = name; - self.get_recommendations_for_seed(Some(vec![id]), None, None); - } - - /// Unlike [`Self::load_recommendations_for_track`], no context row is - /// prepended to the results table. - pub fn load_recommendations_for_track_id(&mut self, id: String, name: String) { - self.recommendations_context = Some(RecommendationsContext::Song); - self.recommendations_seed = name; - self.get_recommendations_for_track_id(id); - } - - /// Show the cached mix, or fetch it; a no-op while a fetch is in flight. - pub(crate) fn open_discover_mix(&mut self, target: crate::core::action::DiscoverTarget) { - use crate::core::action::DiscoverTarget; - if self.discover_loading { - return; - } - let (cached, event) = match target { - DiscoverTarget::ArtistsMix => (&self.discover_artists_mix, IoEvent::GetTopArtistsMix), - DiscoverTarget::TopTracks(range) => { - (&self.discover_top_tracks, IoEvent::GetUserTopTracks(range)) - } - }; - if cached.is_empty() { - self.dispatch(event); - } else { - let tracks = cached.clone(); - self.show_tracks_in_table(tracks, TrackTableContext::DiscoverPlaylist); - } - } - - /// Open the shared track table on `tracks` with the cursor on the top row. - pub(super) fn show_tracks_in_table( - &mut self, - tracks: Vec, - context: TrackTableContext, - ) { - self.set_track_table(tracks, context); - self.push_navigation_stack(RouteId::TrackTable, ActiveBlock::TrackTable); - } -} diff --git a/src/core/app/dj.rs b/src/core/app/dj.rs deleted file mode 100644 index d29806c1..00000000 --- a/src/core/app/dj.rs +++ /dev/null @@ -1,397 +0,0 @@ -use super::*; - -impl App { - /// Dedupe keys for everything the DJ should not queue again: what is already - /// waiting in the native queue, plus the track playing right now. - /// - /// The recently-played window is added by the caller from the taste brief; - /// this covers only what `App` itself knows. - #[cfg(feature = "dj-core")] - #[cfg_attr(not(any(feature = "mcp-server", feature = "ai-dj")), allow(dead_code))] - pub fn dj_skip_keys(&self) -> std::collections::HashSet { - use crate::infra::dj::dedupe_key; - let mut keys: std::collections::HashSet = self - .native_queue - .iter() - .map(|track| dedupe_key(&track.name, &track.artists.join(", "))) - .collect(); - if let Some(snapshot) = crate::infra::media_metadata::current_playback_snapshot(self) { - keys.insert(dedupe_key( - &snapshot.metadata.title, - &snapshot.primary_artist(), - )); - } - keys - } - - /// Queue a batch of DJ-chosen tracks, returning how many were accepted. - /// - /// Routes every track through [`Self::add_track_to_native_queue`] rather than - /// pushing into `native_queue` directly, so the no-URI guard, the radio - /// rejection, and the external-Connect-device fallback to the Spotify Web API - /// queue all still apply. That fallback is also why callers cap the batch: - /// on an external device each track costs its own API round trip. - /// - /// The per-track status messages the single-track path emits are harmless - /// here: they are set and replaced within one lock, before any draw, and the - /// caller overwrites them with a single aggregate message afterwards. - #[cfg(feature = "dj-core")] - #[cfg_attr(not(any(feature = "mcp-server", feature = "ai-dj")), allow(dead_code))] - pub fn extend_native_queue_from_dj(&mut self, tracks: Vec) -> usize { - let mut accepted = 0usize; - for track in tracks { - // Mirror `add_track_to_native_queue`'s rejections so the count we report - // is honest, rather than inferring them from a length change. - let Some(uri) = track.uri.clone() else { - continue; - }; - if uri.starts_with("radio:") { - continue; - } - let before = self.native_queue.len(); - self.add_track_to_native_queue(track); - accepted += 1; - // A Spotify track on an external Connect device is dispatched to the Web - // API queue instead of pushed locally, so only remember what a vibe shift - // would actually be able to drop again. Remembered by URI, not position: - // the queue can be reordered, appended to, or pruned by hand before the - // shift happens. - if self.native_queue.len() > before { - self.dj.queued_uris.insert(uri); - } - } - accepted - } - - /// Drop the DJ's own contributions from the queue. - /// - /// Used by a vibe shift: a new direction that only takes effect after the - /// already-queued tracks have played reads as broken, so the DJ's own picks go - /// and anything the user queued by hand stays — wherever it sits. Matching is - /// by URI, so a track the user queued by hand *and* the DJ also picked goes - /// too; that is the DJ's pick as much as theirs. - #[cfg(feature = "dj-core")] - #[cfg_attr(not(feature = "ai-dj"), allow(dead_code))] - pub fn drop_dj_queued_tracks(&mut self) -> usize { - let dj_uris = std::mem::take(&mut self.dj.queued_uris); - if dj_uris.is_empty() { - return 0; - } - let before = self.native_queue.len(); - self - .native_queue - .retain(|track| !track.uri.as_ref().is_some_and(|uri| dj_uris.contains(uri))); - before - self.native_queue.len() - } - - /// Open the DJ screen, warming the library index if the filter is already - /// on. - /// - /// Every entry point goes through here so the crawl is never left to the - /// resolve step. `behavior.dj_avoid_library: true` seeds the toggle without - /// going through `toggle_dj_fresh_only`, so without this the first turn of such - /// a session would crawl inline on the serial IoEvent lane — head-of-line - /// blocking every other event behind a few seconds of pagination. - /// Moved verbatim from the terminal DJ handler; reached through - /// `Action::OpenLibrary(LibraryTarget::AiDj)`. - #[cfg(feature = "ai-dj")] - pub(super) fn open_ai_dj_screen(&mut self) { - self.focus_dj_screen(); - // Asked once, on the first visit, and only here because `open` is the single - // funnel every entry point goes through. Not in `first_run.rs`: that runs before - // the TUI, gated on the absence of client.yml, and would interrogate every user - // about coding agents even if they never open the DJ. - // - // An already-open picker is rebuilt rather than resumed. That state means the user - // left the screen by a route the picker's own Esc never saw (a mouse click on the - // sidebar), so its rows were detected against a session that has moved on. - if self.dj.setup.is_some() || !self.user_config.behavior.dj_is_configured() { - use crate::infra::dj::setup::DjSetup; - self.dj.setup = Some(DjSetup::new(&self.user_config.behavior)); - } - } - - /// Make the DJ screen current and focused, then start the library crawl. - #[cfg(feature = "ai-dj")] - fn focus_dj_screen(&mut self) { - // Pushed only when the DJ is not already the current route: a second - // `RouteId::AiDj` on the stack turns the Esc that should leave the DJ into - // one that lands back on it. Reachable with the DJ already open because - // focus can be elsewhere — Left to the sidebar, then the open key or the - // sidebar's own "AI DJ" row — which is also why focus is restored rather - // than left where it was. - if self.get_current_route().id == RouteId::AiDj { - self.set_current_route_state(Some(ActiveBlock::AiDj), Some(ActiveBlock::AiDj)); - } else { - self.push_navigation_stack(RouteId::AiDj, ActiveBlock::AiDj); - } - self.request_dj_library_index(); - } - - /// Kick off the library crawl unless it has already run or is running. - /// Shared by the screen opener and the "only tracks I do not already have" - /// toggle in the terminal handler. - #[cfg(feature = "ai-dj")] - pub(crate) fn request_dj_library_index(&mut self) { - if self.dj.avoid_library && self.dj.library.is_none() && !self.dj.library_indexing { - self.dispatch(IoEvent::DjIndexLibrary); - } - } - - /// Ask the DJ for something, in the listener's own words. The input box is - /// cleared only when the turn starts. - #[cfg(feature = "ai-dj")] - pub(crate) fn ask_dj(&mut self, text: String) { - use crate::infra::dj::{AskDjRequest, DjLine, TurnKind}; - // Checked before the trim so a blank ask during a turn still reports busy. - if self.dj.thinking { - self.set_status_message("The DJ is still working on the last request", 3); - return; - } - let text = text.trim(); - if text.is_empty() { - return; - } - let text = text.to_string(); - self.dj.clear_input(); - - // Jumping back to the newest line: the user just spoke, so that is what they - // want to see. - self.dj.scroll = 0; - // A new instruction supersedes any refill already in flight. - let generation = self.dj.bump_generation(); - let turn_seq = self.dj.begin_turn(TurnKind::Ask); - self.dj.push_line(DjLine::user(text.clone())); - - // No `extra_instruction`: the line above *is* the instruction, and the brain - // reads it from the transcript. Passing it here as well is what made the DJ see - // the same request two and three times over. - // - // `dispatch_without_spinner` throughout the DJ actions: a brain call runs for - // up to `dj_agent_timeout_secs`, and plain `dispatch` would pin the global - // `is_loading` spinner for all of it. `dj.thinking` is the progress surface. - self.dispatch_without_spinner(IoEvent::AskDj(Box::new(AskDjRequest { - extra_instruction: None, - generation, - // The listener is right here, so the DJ may ask them something instead of - // guessing. - must_act: false, - // Only if the turn queues: a turn spent answering "what have I been - // listening to?" must not become the standing direction for every refill. - vibe_on_success: Some(text), - turn_seq, - }))); - } - - /// Toggle continuous auto-queue, refilling at once if the queue is short. - #[cfg(feature = "ai-dj")] - pub(crate) fn toggle_dj_auto_queue(&mut self) { - use crate::infra::dj::TurnKind; - self.dj.auto_queue = !self.dj.auto_queue; - // Either direction invalidates a *refill* in flight: switching off should drop - // a pending one, and switching on starts a fresh one. - // - // A question the listener typed is not this toggle's to abandon. Bumping the - // generation would make its answer land and be discarded, and clearing - // `thinking` would let a new ask start a second brain call alongside the - // first. Nothing is left unguarded by skipping both: no refill can be in - // flight while an ask is (`wants_top_up` gates on the same flag), and later - // refills are already stopped by `auto_queue` itself. - let asked_turn_in_flight = self.dj.asked_turn_in_flight(); - let generation = if asked_turn_in_flight { - self.dj.generation - } else { - self.dj.bump_generation() - }; - if self.dj.auto_queue { - // Same rule the driver tick applies, so the immediate refill and the ongoing - // ones cannot disagree — on an external Connect device the native queue never - // fills, and refilling on its length would queue a batch per brain call - // forever. Said out loud, or auto-queue just looks broken on that device. - let external = self.spotify_external_device_active(); - if external { - self.set_status_message( - "DJ auto-queue on — waits while another Spotify device is playing", - 6, - ); - } else { - self.set_status_message("DJ auto-queue on", 4); - } - if crate::infra::network::dj::wants_top_up(self.native_queue.len(), &self.dj, external) { - let turn_seq = self.dj.begin_turn(TurnKind::Refill); - self.dispatch_without_spinner(IoEvent::DjTopUp(generation, turn_seq)); - } - } else { - if !asked_turn_in_flight { - // `finish_turn`, not a bare flag clear: it drops the step counter with the - // flag. Left behind, that counter belongs to a turn nobody is waiting on, - // and the next refill would paint it as its own progress until its first - // step lands. - self.dj.finish_turn(self.dj.turn_seq); - } - self.set_status_message("DJ auto-queue off", 4); - } - } - - /// Toggle "only tracks I do not already have", warming the index when on. - #[cfg(feature = "ai-dj")] - pub(crate) fn toggle_dj_fresh_only(&mut self) { - self.dj.avoid_library = !self.dj.avoid_library; - if self.dj.avoid_library { - self.set_status_message("DJ: only tracks you don't already have", 4); - self.request_dj_library_index(); - } else { - self.set_status_message("DJ: recommending from everything", 4); - } - } - - /// Re-roll the direction: drop the DJ's own queued tracks and ask again. - #[cfg(feature = "ai-dj")] - pub(crate) fn dj_vibe_shift(&mut self) { - use crate::infra::dj::{AskDjRequest, DjLine, TurnKind, MAX_BATCH}; - if self.dj.thinking { - self.set_status_message("The DJ is already working on something", 3); - return; - } - let generation = self.dj.bump_generation(); - let dropped = self.drop_dj_queued_tracks(); - let turn_seq = self.dj.begin_turn(TurnKind::Ask); - self.dj.scroll = 0; - self.dj.push_line(DjLine::system(if dropped > 0 { - format!("Shifting the vibe (dropped {dropped} queued track(s))") - } else { - "Shifting the vibe".to_string() - })); - // A steer rather than a transcript line: the listener never typed this, and the - // system line above already tells them what happened. - self.dispatch_without_spinner(IoEvent::AskDj(Box::new(AskDjRequest { - extra_instruction: Some(format!( - "Change direction. Queue {MAX_BATCH} tracks that go somewhere different from what has been \ - playing, while still being something this listener would enjoy." - )), - generation, - // A vibe shift has just emptied the queue, so it has to refill it rather than - // stopping to ask what "different" means. - must_act: true, - vibe_on_success: None, - turn_seq, - }))); - } - - /// Show the DJ's backend/model picker, opening the DJ screen first when it - /// is not current. Does not consult `dj_is_configured()`: the key press is - /// the request. - #[cfg(feature = "ai-dj")] - pub(crate) fn open_dj_setup(&mut self) { - use crate::infra::dj::setup::DjSetup; - self.focus_dj_screen(); - self.dj.setup = Some(DjSetup::new(&self.user_config.behavior)); - } - - /// Close the picker without changing the brain, and record that the question - /// was answered: `open_ai_dj_screen` re-checks `dj_is_configured()` on every - /// entry, so a picker that recorded nothing on Esc would reappear every time. - #[cfg(feature = "ai-dj")] - pub(crate) fn dismiss_dj_setup(&mut self) { - self.dj.close_setup(); - self.user_config.behavior.dj_configured = Some(true); - self.persist_dj_setup("keeping the current DJ brain"); - } - - /// Apply the finished picker to the config, close it, and persist the choice. - #[cfg(feature = "ai-dj")] - pub(crate) fn commit_dj_setup(&mut self) { - use crate::infra::dj::setup::{apply_choice, DjSetup}; - use crate::infra::dj::DjLine; - let Some(choice) = self.dj.setup.as_ref().and_then(DjSetup::choice) else { - return; - }; - apply_choice(&mut self.user_config.behavior, &choice); - self.user_config.behavior.dj_configured = Some(true); - self.dj.close_setup(); - // A batch already in flight came from the backend the user just replaced. - self.dj.bump_generation(); - self - .dj - .push_line(DjLine::system(format!("DJ brain: {}", choice.describe()))); - // The picker is already closed by the time this shows, so the "not ready" - // case repeats what to do about it rather than pointing at a hidden row. - let message = if choice.ready { - format!("DJ brain: {}", choice.describe()) - } else { - format!( - "DJ brain: {} (not usable yet: install it, or set its API key)", - choice.describe() - ) - }; - self.persist_dj_setup(&message); - } - - /// Write the config and say so. Surfaced rather than logged: a picker whose - /// job is to stop re-prompting has to say when the marker did not reach disk. - /// Not `handle_error`, which would push a modal error page over the DJ. - #[cfg(feature = "ai-dj")] - fn persist_dj_setup(&mut self, ok_message: &str) { - if let Err(e) = self.user_config.save_config() { - self.set_error_status_message(format!("DJ: could not save that choice: {e}"), 8); - return; - } - self.set_status_message(ok_message.to_string(), 5); - } -} - -#[cfg(all(test, feature = "dj-core"))] -mod tests { - use super::*; - use crate::core::app::test_support::*; - - /// A vibe shift drops the DJ's picks by identity, not by truncating a tail - /// count: the DJ's tracks stop being a contiguous tail the moment the user - /// queues by hand after a batch lands (or deletes a DJ pick from the queue - /// screen), and the old count-based truncate took the user's track instead. - #[cfg(feature = "dj-core")] - #[test] - fn a_vibe_shift_drops_the_djs_picks_and_keeps_what_the_user_queued_after_them() { - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - - app.extend_native_queue_from_dj(vec![ - queue_track(Some("subsonic:track:dj1"), "DJ Pick 1"), - queue_track(Some("subsonic:track:dj2"), "DJ Pick 2"), - ]); - // The desync case: the user queues by hand *after* the DJ's batch, so the - // DJ's picks are no longer the queue's tail. - app.add_track_to_native_queue(queue_track(Some("subsonic:track:mine"), "My Track")); - - assert_eq!(app.drop_dj_queued_tracks(), 2); - assert_eq!(app.native_queue.len(), 1); - assert_eq!( - app.native_queue[0].name, "My Track", - "the user's own pick must survive the shift" - ); - - // The set was consumed: a second shift with nothing new queued drops nothing. - assert_eq!(app.drop_dj_queued_tracks(), 0); - } - - /// Deleting a DJ pick from the queue screen must not make a later vibe shift - /// overreach — the count-based version truncated by the stale count. - #[cfg(feature = "dj-core")] - #[test] - fn a_dj_pick_removed_by_hand_does_not_widen_the_vibe_shift() { - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - - app.add_track_to_native_queue(queue_track(Some("subsonic:track:mine"), "My Track")); - app.extend_native_queue_from_dj(vec![ - queue_track(Some("subsonic:track:dj1"), "DJ Pick 1"), - queue_track(Some("subsonic:track:dj2"), "DJ Pick 2"), - ]); - // The user deletes one DJ pick by hand (queue-screen removal). - app.native_queue.retain(|track| track.name != "DJ Pick 2"); - - assert_eq!(app.drop_dj_queued_tracks(), 1, "only the remaining DJ pick"); - assert_eq!(app.native_queue.len(), 1); - assert_eq!(app.native_queue[0].name, "My Track"); - } -} diff --git a/src/core/app/friends.rs b/src/core/app/friends.rs deleted file mode 100644 index 4b8c18f9..00000000 --- a/src/core/app/friends.rs +++ /dev/null @@ -1,110 +0,0 @@ -use super::*; - -// ── Friends feature ─────────────────────────────────────────────────────────── - -#[derive(Clone, Debug)] -pub struct FriendEntry { - pub id: String, - pub name: String, - /// Lowercased `name`, precomputed once so the per-frame search filter - /// doesn't allocate per friend per frame. - pub name_lower: String, - pub is_online: bool, - pub now_playing: Option, - /// Total listening time in milliseconds (from spotatui.com) - #[allow(dead_code)] - pub listening_ms: u64, - /// Total number of listens tracked on spotatui.com - #[allow(dead_code)] - pub total_listens: u64, -} - -#[derive(Clone, Debug)] -pub struct FriendNowPlaying { - pub title: String, - pub artists: String, -} - -/// A user returned from the username/code search. -#[derive(Clone, Debug)] -pub struct FriendSearchResult { - pub id: String, - pub name: String, - pub is_following: bool, -} - -#[derive(Clone, Copy, PartialEq, Debug, Default)] -pub enum FriendFilter { - #[default] - All, - Online, -} - -/// Which tab is active in the "Add Friend" dialog. -#[derive(Clone, Copy, PartialEq, Debug, Default)] -pub enum FriendAddMode { - #[default] - Code, - Search, -} - -impl App { - pub fn clear_friend_add_dialog_state(&mut self) { - self.view.friend_add_dialog_visible = false; - self.view.friend_add_mode = FriendAddMode::Code; - self.view.friend_add_input.clear(); - self.view.friend_user_search_input.clear(); - self.friend_user_search_results.clear(); - self.view.friend_user_search_selected = 0; - } - - pub fn open_friend_add_dialog(&mut self) { - self.clear_friend_add_dialog_state(); - self.view.friend_add_dialog_visible = true; - } - - pub fn copy_friend_code(&mut self) { - let Some(code) = self.friend_code.clone() else { - self.set_status_message("Friend code not loaded yet", 3); - return; - }; - - let Some(clipboard) = &mut self.clipboard else { - self.set_status_message("Clipboard not available", 3); - return; - }; - - if clipboard.set_text(code.clone()).is_ok() { - self.set_status_message(format!("Copied friend code: {}", code), 3); - } else { - self.set_status_message("Failed to copy to clipboard", 3); - } - } - - /// A query under the server's two-byte minimum clears the stale results - /// instead of asking. - pub(crate) fn search_friend_users(&mut self, query: String) { - if query.len() >= 2 { - self.dispatch(IoEvent::SearchFriendUsers(query)); - } else { - self.friend_user_search_results.clear(); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn copy_friend_code_without_a_code_reports_not_loaded() { - let mut app = App::default(); - - app.copy_friend_code(); - - assert_eq!( - app.status_message.as_deref(), - Some("Friend code not loaded yet") - ); - } -} diff --git a/src/core/app/help.rs b/src/core/app/help.rs deleted file mode 100644 index 93413a4d..00000000 --- a/src/core/app/help.rs +++ /dev/null @@ -1,32 +0,0 @@ -use super::*; - -/// Formatted Help-menu rows, static between terminal-width, keybinding, or -/// filter changes. Rebuilt outside the draw path (see -/// `tui::ui::popups::ensure_help_menu_model`) so Help rendering reads immutable -/// `App` state instead of rebuilding ~80 owned Strings on every redraw. -pub struct HelpMenuModel { - pub width: usize, - pub keys: crate::core::user_config::KeyBindings, - pub source: Source, - pub spotify_connected: bool, - pub filter: String, - pub header: String, - pub rows: Vec, - /// Per-row filter-match byte ranges, parallel to `rows`. Precomputed here so - /// the render loop does not lowercase every visible row on every frame. - pub match_ranges: Vec>, -} - -impl App { - pub fn calculate_help_menu_offset(&mut self) { - if self.view.help_menu_max_lines == 0 || self.view.help_docs_size == 0 { - self.view.help_menu_page = 0; - self.view.help_menu_offset = 0; - return; - } - - let last_page = self.view.help_docs_size.saturating_sub(1) / self.view.help_menu_max_lines; - self.view.help_menu_page = self.view.help_menu_page.min(last_page); - self.view.help_menu_offset = self.view.help_menu_page * self.view.help_menu_max_lines; - } -} diff --git a/src/core/app/keybindings.rs b/src/core/app/keybindings.rs deleted file mode 100644 index 845a9922..00000000 --- a/src/core/app/keybindings.rs +++ /dev/null @@ -1,139 +0,0 @@ -use super::*; - -#[cfg_attr(not(target_os = "macos"), allow(dead_code))] -#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] -pub enum CapabilityState { - #[default] - Unknown, - Yes, - No, -} - -#[derive(Clone, Copy, Debug, Default)] -pub struct TerminalInputCapabilities { - pub keyboard_enhancement_supported: bool, - pub keyboard_enhancement_enabled: bool, - pub ctrl_punct_reliable: CapabilityState, -} - -#[cfg_attr(not(target_os = "macos"), allow(dead_code))] -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum KeyFallbackReason { - CtrlCommaNotReported, -} - -#[derive(Clone, Copy, Debug, Default)] -pub struct KeybindingRuntimeState { - pub effective_open_settings: Option, - pub fallback_reason: Option, - #[cfg_attr(not(target_os = "macos"), allow(dead_code))] - pub fallback_notice_shown: bool, - #[cfg_attr(not(target_os = "macos"), allow(dead_code))] - pub persist_prompt_shown: bool, -} - -#[derive(Clone, Copy, Debug)] -pub struct PendingKeybindingPersist { - pub open_settings_key: Key, -} - -impl App { - pub fn effective_open_settings_key(&self) -> Key { - self - .keybinding_runtime - .effective_open_settings - .unwrap_or(self.user_config.keys.open_settings) - } - - pub fn effective_save_settings_key(&self) -> Key { - self.user_config.keys.save_settings - } - - /// The Open Settings key that waits for the persist prompt's answer. - pub fn pending_keybinding_persist_key(&self) -> Option { - self - .pending_keybinding_persist - .map(|persist| persist.open_settings_key) - } - - #[cfg(target_os = "macos")] - fn allow_plain_comma_open_settings_fallback(&self) -> bool { - !matches!( - self.get_current_route().active_block, - ActiveBlock::Input - | ActiveBlock::TrackTable - | ActiveBlock::AlbumList - | ActiveBlock::Artists - | ActiveBlock::SortMenu - | ActiveBlock::Settings - | ActiveBlock::Dialog(_) - ) - } - - #[cfg(target_os = "macos")] - pub fn maybe_activate_open_settings_fallback(&mut self, key: Key) -> bool { - if self.user_config.keys.open_settings != Key::Ctrl(',') { - return false; - } - - if key == Key::Ctrl(',') { - self.view.terminal_input_caps.ctrl_punct_reliable = CapabilityState::Yes; - self.keybinding_runtime.effective_open_settings = None; - self.keybinding_runtime.fallback_reason = None; - return false; - } - - if key == Key::Char(',') && self.allow_plain_comma_open_settings_fallback() { - self.view.terminal_input_caps.ctrl_punct_reliable = CapabilityState::No; - self.keybinding_runtime.effective_open_settings = Some(Key::Alt(',')); - self.keybinding_runtime.fallback_reason = Some(KeyFallbackReason::CtrlCommaNotReported); - - if !self.keybinding_runtime.fallback_notice_shown { - self.set_status_message( - "Ctrl+, not detected in this terminal; using Alt+, for this session", - 5, - ); - self.keybinding_runtime.fallback_notice_shown = true; - } - - if !self.keybinding_runtime.persist_prompt_shown { - self.keybinding_runtime.persist_prompt_shown = true; - self.pending_keybinding_persist = Some(PendingKeybindingPersist { - open_settings_key: Key::Alt(','), - }); - self.view.confirm = false; - } - - return true; - } - - false - } - - #[cfg(not(target_os = "macos"))] - pub fn maybe_activate_open_settings_fallback(&mut self, _key: Key) -> bool { - false - } - - pub fn persist_open_settings_fallback(&mut self) { - let Some(persist) = self.pending_keybinding_persist else { - return; - }; - - self.user_config.keys.open_settings = persist.open_settings_key; - if let Err(e) = self.user_config.save_config() { - self.handle_error(anyhow!("Failed to save keybinding fallback: {}", e)); - return; - } - - self.keybinding_runtime.effective_open_settings = None; - self.keybinding_runtime.fallback_reason = None; - self.set_status_message( - format!( - "Saved open settings shortcut as {}", - persist.open_settings_key - ), - 4, - ); - } -} diff --git a/src/core/app/layout.rs b/src/core/app/layout.rs deleted file mode 100644 index ff07228b..00000000 --- a/src/core/app/layout.rs +++ /dev/null @@ -1,45 +0,0 @@ -use super::*; - -impl App { - /// Set the sidebar width, clamped to 100%, and schedule the state save. - pub fn set_sidebar_width_percent(&mut self, percent: u8) { - self.runtime_state.sidebar_width_percent = percent.min(100); - self.schedule_state_save(PersistedRuntimeState::sidebar_width_percent( - self.runtime_state.sidebar_width_percent, - )); - } - - /// Set the playbar height, clamped to `MAX_PLAYBAR_ROWS`, and schedule the save. - pub fn set_playbar_height_rows(&mut self, rows: u16) { - self.runtime_state.playbar_height_rows = rows.min(crate::core::limits::MAX_PLAYBAR_ROWS); - self.schedule_state_save(PersistedRuntimeState::playbar_height_rows( - self.runtime_state.playbar_height_rows, - )); - } - - /// Set the library split height, clamped to 100%, and schedule the save. - pub fn set_library_height_percent(&mut self, percent: u8) { - self.runtime_state.library_height_percent = percent.min(100); - self.schedule_state_save(PersistedRuntimeState::library_height_percent( - self.runtime_state.library_height_percent, - )); - } - - /// Every pane back to its configured default, or the runtime default. - pub fn reset_layout(&mut self) { - let defaults = RuntimeState::default(); - let behavior = &self.user_config.behavior; - let sidebar = behavior - .sidebar_width_percent - .unwrap_or(defaults.sidebar_width_percent); - let playbar = behavior - .playbar_height_rows - .unwrap_or(defaults.playbar_height_rows); - let library = behavior - .library_height_percent - .unwrap_or(defaults.library_height_percent); - self.set_sidebar_width_percent(sidebar); - self.set_playbar_height_rows(playbar); - self.set_library_height_percent(library); - } -} diff --git a/src/core/app/library.rs b/src/core/app/library.rs deleted file mode 100644 index 61b86bba..00000000 --- a/src/core/app/library.rs +++ /dev/null @@ -1,786 +0,0 @@ -use super::*; - -/// Every sidebar row with what it needs; `App::library_rows` is the filtered list. -pub(crate) fn library_row_requirements() -> &'static [(LibraryTarget, Requirement)] { - &[ - ( - LibraryTarget::Discover, - Requirement::Source(Source::Spotify), - ), - ( - LibraryTarget::RecentlyPlayed, - Requirement::Source(Source::Spotify), - ), - (LibraryTarget::Friends, Requirement::None), - (LibraryTarget::Stats, Requirement::None), - ( - LibraryTarget::LikedSongs, - Requirement::Source(Source::Spotify), - ), - (LibraryTarget::Albums, Requirement::Source(Source::Spotify)), - (LibraryTarget::Artists, Requirement::Source(Source::Spotify)), - ( - LibraryTarget::Podcasts, - Requirement::Source(Source::Spotify), - ), - #[cfg(feature = "local-files")] - (LibraryTarget::LocalFiles, Requirement::None), - #[cfg(feature = "ai-dj")] - (LibraryTarget::AiDj, Requirement::None), - ] -} - -impl App { - /// The sidebar rows the active source and the session can serve. - pub(crate) fn library_rows(&self) -> Vec { - library_row_requirements() - .iter() - .filter(|(_, needs)| self.availability(*needs).is_available()) - .map(|(target, _)| *target) - .collect() - } - - /// Where the selected row sits among the rows offered now; the top row when it is hidden. - pub(crate) fn library_cursor(&self) -> usize { - self - .library_rows() - .iter() - .position(|row| *row == self.view.library_selected) - .unwrap_or(0) - } -} - -#[derive(Clone)] -pub struct Library { - pub saved_tracks: ScrollableResultPages>, - pub saved_albums: ScrollableResultPages>, - pub saved_shows: ScrollableResultPages>, - pub saved_artists: ScrollableResultPages>, - pub show_episodes: ScrollableResultPages>, -} - -// Is it possible to compose enums? -#[derive(Clone, PartialEq, Debug, Copy)] -pub enum AlbumTableContext { - Simplified, - Full, -} - -#[derive(Clone, PartialEq, Debug, Copy)] -pub enum EpisodeTableContext { - Simplified, - Full, -} - -#[derive(Clone)] -pub struct SelectedShow { - pub show: ShowInfo, -} - -#[derive(Clone)] -pub struct SelectedFullShow { - pub show: ShowInfo, -} - -#[derive(Clone)] -pub struct SelectedAlbum { - pub album: crate::core::plugin_api::AlbumInfo, - pub tracks: crate::core::pagination::Paged, - pub selected_index: usize, -} - -#[derive(Clone)] -#[allow(dead_code)] -pub struct SelectedFullAlbum { - pub album: crate::core::plugin_api::AlbumInfo, - pub selected_index: usize, -} - -#[derive(Clone)] -#[allow(dead_code)] -pub struct Artist { - pub artist_id: String, - pub artist_name: String, - pub albums: crate::core::pagination::Paged, - pub related_artists: Vec, - pub top_tracks: Vec, - pub selected_album_index: usize, - pub selected_related_artist_index: usize, - pub selected_top_track_index: usize, - pub artist_hovered_block: ArtistBlock, - pub artist_selected_block: ArtistBlock, -} - -impl App { - /// Sort the recently-played track list in place per `recently_played_sort`. - /// `Default` keeps the API's play order (a re-fetch restores it). - pub fn sort_recently_played_items(&mut self) { - let sort_state = self.recently_played_sort; - if sort_state.field == SortField::Default { - return; - } - if let Some(page) = self.recently_played.as_mut() { - use crate::core::sort::sort_by_key_with_order; - match sort_state.field { - SortField::Name => { - sort_by_key_with_order(&mut page.items, sort_state.order, |t| t.name.to_lowercase()) - } - SortField::Artist => sort_by_key_with_order(&mut page.items, sort_state.order, |t| { - t.artists.first().map(|s| s.to_lowercase()) - }), - SortField::Album => sort_by_key_with_order(&mut page.items, sort_state.order, |t| { - t.album.to_lowercase() - }), - _ => {} - } - } - } - - /// Sort one list surface by a field, like the sort menu's Enter. - pub(crate) fn apply_sort_field(&mut self, context: SortContext, field: SortField) { - self.sort_state_mut(context).apply_field(field); - - // Actually sort the data - match context { - SortContext::PlaylistTracks => { - if let Some(playlist_id) = self.current_playlist_track_table_id() { - self.dispatch(IoEvent::FetchAllPlaylistTracksAndSort( - playlist_id.id().to_string(), - )); - } - } - SortContext::SavedAlbums => self.sort_saved_albums(), - SortContext::SavedArtists => self.sort_saved_artists(), - SortContext::RecentlyPlayed => self.sort_recently_played_items(), - } - } - - /// Flip the recorded direction without re-sorting loaded rows (the sort - /// menu's uppercase shortcut, quirk kept). - pub(crate) fn toggle_sort_order(&mut self, context: SortContext) { - let sort_state = self.sort_state_mut(context); - sort_state.order = sort_state.order.toggle(); - } - - pub(crate) fn sort_state(&self, context: SortContext) -> &SortState { - match context { - SortContext::PlaylistTracks => &self.playlist_sort, - SortContext::SavedAlbums => &self.album_sort, - SortContext::SavedArtists => &self.artist_sort, - SortContext::RecentlyPlayed => &self.recently_played_sort, - } - } - - fn sort_state_mut(&mut self, context: SortContext) -> &mut SortState { - match context { - SortContext::PlaylistTracks => &mut self.playlist_sort, - SortContext::SavedAlbums => &mut self.album_sort, - SortContext::SavedArtists => &mut self.artist_sort, - SortContext::RecentlyPlayed => &mut self.recently_played_sort, - } - } - - fn sort_saved_albums(&mut self) { - use crate::core::sort::sort_by_key_with_order; - - let sort_state = self.album_sort; - - // Sort library.saved_albums pages - for page in &mut self.library.saved_albums.pages { - match sort_state.field { - SortField::Name => sort_by_key_with_order(&mut page.items, sort_state.order, |a| { - a.album.name.to_lowercase() - }), - SortField::Artist => sort_by_key_with_order(&mut page.items, sort_state.order, |a| { - a.album - .artists - .first() - .map(|artist| artist.name.to_lowercase()) - .unwrap_or_default() - }), - SortField::DateAdded => { - sort_by_key_with_order(&mut page.items, sort_state.order, |a| a.added_at.clone()) - } - _ => {} - } - } - } - - fn sort_saved_artists(&mut self) { - use crate::core::sort::sort_by_key_with_order; - - let sort_state = self.artist_sort; - if sort_state.field != SortField::Name { - return; - } - - // Sort library.saved_artists pages - for page in &mut self.library.saved_artists.pages { - sort_by_key_with_order(&mut page.items, sort_state.order, |a| a.name.to_lowercase()); - } - - // Also sort the app.artists vec - sort_by_key_with_order(&mut self.artists, sort_state.order, |a| { - a.name.to_lowercase() - }); - } - - pub(crate) fn reload_stats(&mut self) { - self.stats_loading = true; - self.dispatch(IoEvent::LoadListeningStats(self.stats_period)); - } - - /// The period is written before the fetch: the result handler only accepts - /// data for the current period. - pub(crate) fn set_stats_period(&mut self, period: RecapPeriod) { - self.stats_period = period; - self.stats_data = None; - self.reload_stats(); - } - - pub(crate) fn cycle_stats_period(&mut self, forward: bool) { - let period = if forward { - self.stats_period.next() - } else { - self.stats_period.prev() - }; - self.set_stats_period(period); - } - - pub fn set_saved_tracks_to_table_continuous(&mut self) { - let mut tracks = Vec::new(); - let mut expected_offset = 0; - let mut seen_offsets = HashSet::new(); - let mut active_index = 0; - - for (page_index, page) in self.library.saved_tracks.pages.iter().enumerate() { - if page.offset != expected_offset || !seen_offsets.insert(page.offset) { - break; - } - - tracks.extend(page.items.iter().cloned()); - expected_offset = expected_offset.saturating_add(page.limit); - active_index = page_index; - - if page.next.is_none() { - break; - } - } - - self.library.saved_tracks.index = active_index; - self.replace_track_table_tracks(tracks); - self.track_table.context = Some(TrackTableContext::SavedTracks); - } - - pub fn reset_saved_tracks_view(&mut self) { - self.saved_tracks_prefetch_generation = self.saved_tracks_prefetch_generation.wrapping_add(1); - self.saved_tracks_prefetch_in_flight.clear(); - self.library.saved_tracks.clear(); - self.pending_track_table_selection = None; - self.view.track_table_index = 0; - self.track_table.tracks.clear(); - self.track_table.context = Some(TrackTableContext::SavedTracks); - } - - /// Open a library sidebar section: the whole Enter consequence of a - /// library row (fetches, route pushes, bookkeeping), moved verbatim from - /// the library handler so every frontend fires the same sequence through - /// `Action::OpenLibrary`. - pub fn open_library_section(&mut self, target: LibraryTarget) { - match target { - LibraryTarget::Discover => { - self.push_navigation_stack(RouteId::Discover, ActiveBlock::Discover); - } - // The row pushes the route immediately; only the global navigate - // binding defers the push to the network result. - LibraryTarget::RecentlyPlayed => { - self.dispatch(IoEvent::GetRecentlyPlayed); - self.push_navigation_stack(RouteId::RecentlyPlayed, ActiveBlock::RecentlyPlayed); - } - LibraryTarget::Friends => { - self.push_navigation_stack(RouteId::Friends, ActiveBlock::Friends); - // Load friend code + friends list on first open (or if empty) - if self.friend_code.is_none() { - self.dispatch(IoEvent::GetFriendCode); - } - if self.friends.is_empty() && !self.friends_loading { - self.dispatch(IoEvent::GetFriends); - } - self.last_friends_refresh_at = std::time::Instant::now(); - } - LibraryTarget::Stats => { - self.reload_stats(); - self.push_navigation_stack(RouteId::Stats, ActiveBlock::Stats); - } - LibraryTarget::LikedSongs => { - self.reset_saved_tracks_view(); - self.dispatch(IoEvent::GetCurrentSavedTracks(None)); - self.push_navigation_stack(RouteId::TrackTable, ActiveBlock::TrackTable); - } - LibraryTarget::Albums => { - self.dispatch(IoEvent::GetCurrentUserSavedAlbums(None)); - self.push_navigation_stack(RouteId::AlbumList, ActiveBlock::AlbumList); - } - LibraryTarget::Artists => { - self.dispatch(IoEvent::GetFollowedArtists(None)); - self.push_navigation_stack(RouteId::Artists, ActiveBlock::Artists); - } - LibraryTarget::Podcasts => { - self.dispatch(IoEvent::GetCurrentUserSavedShows(None)); - self.push_navigation_stack(RouteId::Podcasts, ActiveBlock::Podcasts); - } - // The row cannot be selected in builds without `local-files`, so the - // arm is a no-op there. - LibraryTarget::LocalFiles => { - #[cfg(feature = "local-files")] - self.push_navigation_stack(RouteId::LocalBrowser, ActiveBlock::LocalBrowser); - } - // The row cannot be selected in builds without `ai-dj`, so the arm is - // a no-op there. - LibraryTarget::AiDj => { - #[cfg(feature = "ai-dj")] - self.open_ai_dj_screen(); - } - } - } - - pub fn next_missing_saved_tracks_offset(&self, page_index: usize) -> Option { - let saved_tracks_page = self.library.saved_tracks.get_results(Some(page_index))?; - saved_tracks_page.next.as_ref()?; - - let next_offset = saved_tracks_page.offset + saved_tracks_page.limit; - self - .library - .saved_tracks - .page_index_for_offset(next_offset) - .is_none() - .then_some(next_offset) - } - - pub fn next_missing_saved_tracks_offset_continuous(&self) -> Option { - let saved_tracks_page = self - .library - .saved_tracks - .get_results(Some(self.library.saved_tracks.index))?; - saved_tracks_page.next.as_ref()?; - Some(saved_tracks_page.offset + saved_tracks_page.limit) - } - - pub fn current_saved_tracks_has_more_tracks(&self) -> bool { - self - .library - .saved_tracks - .get_results(Some(self.library.saved_tracks.index)) - .is_some_and(|saved_tracks| saved_tracks.next.is_some()) - } - - pub fn set_saved_artists_to_table(&mut self, saved_artists_page: &CursorPaged) { - self.artists = saved_artists_page.items.clone(); - } - - pub fn get_current_user_saved_artists_next(&mut self) { - match self - .library - .saved_artists - .get_results(Some(self.library.saved_artists.index + 1)) - .cloned() - { - Some(saved_artists) => { - self.set_saved_artists_to_table(&saved_artists); - self.library.saved_artists.index += 1 - } - None => { - if let Some(saved_artists) = &self.library.saved_artists.clone().get_results(None) { - if let Some(last_artist) = saved_artists.items.last() { - if let Some(after) = last_artist.id.as_deref() { - self.dispatch(IoEvent::GetFollowedArtists(Some(after.to_string()))); - } - } - } - } - } - } - - pub fn get_current_user_saved_artists_previous(&mut self) { - if self.library.saved_artists.index > 0 { - self.library.saved_artists.index -= 1; - } - - if let Some(saved_artists) = &self.library.saved_artists.get_results(None).cloned() { - self.set_saved_artists_to_table(saved_artists); - } - } - - pub fn get_current_user_saved_tracks_next(&mut self) { - if !self.current_saved_tracks_has_more_tracks() { - return; - } - - if let Some(next_offset) = self.next_missing_saved_tracks_offset_continuous() { - if self - .library - .saved_tracks - .page_index_for_offset(next_offset) - .is_some() - { - self.set_saved_tracks_to_table_continuous(); - } else if !self.saved_tracks_prefetch_in_flight.contains(&next_offset) { - self.saved_tracks_prefetch_in_flight.insert(next_offset); - self.dispatch(IoEvent::GetCurrentSavedTracks(Some(next_offset))); - } - } - } - - pub fn get_current_user_saved_albums_next(&mut self) { - match self - .library - .saved_albums - .get_results(Some(self.library.saved_albums.index + 1)) - .cloned() - { - Some(_) => self.library.saved_albums.index += 1, - None => { - if let Some(saved_albums) = &self.library.saved_albums.get_results(None) { - let offset = Some(saved_albums.offset + saved_albums.limit); - self.dispatch(IoEvent::GetCurrentUserSavedAlbums(offset)); - } - } - } - } - - pub fn get_current_user_saved_albums_previous(&mut self) { - if self.library.saved_albums.index > 0 { - self.library.saved_albums.index -= 1; - } - } - - /// Open a saved album's track list from the visible saved-albums page. - pub fn open_saved_album(&mut self, album_id: String) { - let Some(album) = self - .library - .saved_albums - .get_results(None) - .and_then(|albums| { - albums - .items - .iter() - .find(|saved| saved.album.id.as_deref() == Some(album_id.as_str())) - }) - .map(|saved| &saved.album) - else { - return; - }; - // The library cache embeds only the first page of each album's - // tracklist (50 tracks max); refetch longer albums in full. - let cached_is_complete = album - .total_tracks - .is_none_or(|total| album.tracks.len() as u32 >= total); - if !cached_is_complete { - // GetAlbum sets the Full context and pushes AlbumTracks itself. - self.dispatch(IoEvent::GetAlbum(album_id)); - return; - } - self.selected_album_full = Some(SelectedFullAlbum { - album: album.clone(), - selected_index: 0, - }); - self.album_table_context = AlbumTableContext::Full; - self.push_navigation_stack(RouteId::AlbumTracks, ActiveBlock::AlbumTracks); - } - - /// Save or unsave the item playing right now, through the ownership order. - pub fn toggle_save_current_item(&mut self) { - const REFUSED: &str = "The current playback source cannot be liked"; - let uri = match self.playing_item() { - PlayingItem::Spotify(PlayableItem::Track(track)) => Ok(track.id.as_ref().map(|id| id.uri())), - PlayingItem::Spotify(PlayableItem::Episode(episode)) => Ok(Some(episode.id.uri())), - PlayingItem::Spotify(_) | PlayingItem::Nothing => Ok(None), - PlayingItem::QueuedSpotify(track) => track.uri.clone().ok_or(REFUSED).map(Some), - PlayingItem::NotSpotify => Err(REFUSED), - }; - match uri { - Ok(Some(uri)) => self.dispatch(IoEvent::ToggleSaveTrack(uri)), - Ok(None) => {} - Err(status) => self.set_status_message(status, 4), - } - } - - pub fn get_current_user_saved_shows_next(&mut self) { - match self - .library - .saved_shows - .get_results(Some(self.library.saved_shows.index + 1)) - .cloned() - { - Some(_) => self.library.saved_shows.index += 1, - None => { - if let Some(saved_shows) = &self.library.saved_shows.get_results(None) { - let offset = Some(saved_shows.offset + saved_shows.limit); - self.dispatch(IoEvent::GetCurrentUserSavedShows(offset)); - } - } - } - } - - pub fn get_current_user_saved_shows_previous(&mut self) { - if self.library.saved_shows.index > 0 { - self.library.saved_shows.index -= 1; - } - } - - /// Next page of the open show's episodes; a no-op when no show is open. - pub(crate) fn get_episode_table_next(&mut self) { - let Some(show_id) = self.selected_episode_show_id() else { - return; - }; - match self - .library - .show_episodes - .get_results(Some(self.library.show_episodes.index + 1)) - .cloned() - { - Some(_) => self.library.show_episodes.index += 1, - None => { - if let Some(show_episodes) = &self.library.show_episodes.get_results(None) { - let offset = Some(show_episodes.offset + show_episodes.limit); - self.dispatch(IoEvent::GetCurrentShowEpisodes(show_id, offset)); - } - } - } - } - - pub fn get_episode_table_previous(&mut self) { - if self.library.show_episodes.index > 0 { - self.library.show_episodes.index -= 1; - } - } - - /// Resolve the currently selected show's id/URI (from the episode-table - /// context). Returns `None` if the stored domain show has no id. - pub(crate) fn selected_episode_show_id(&self) -> Option { - match self.episode_table_context { - EpisodeTableContext::Full => self - .selected_show_full - .as_ref() - .and_then(|s| s.show.id.clone()), - EpisodeTableContext::Simplified => self - .selected_show_simplified - .as_ref() - .and_then(|s| s.show.id.clone()), - } - } - - pub fn get_artist(&mut self, artist_id: String, input_artist_name: String) { - let user_country = self.get_user_country(); - self.dispatch(IoEvent::GetArtist( - artist_id, - input_artist_name, - user_country, - )); - } - - pub fn get_user_country(&self) -> Option { - // `country` is stored as its ISO 3166-1 alpha-2 string (the multi-source - // domain holds no rspotify types); re-derive the rspotify `Country` here at - // the boundary, the same way IDs are re-parsed when dispatching IoEvents. - let code = self - .user - .as_ref() - .and_then(|user| user.country.as_deref())?; - serde_json::from_value(serde_json::Value::String(code.to_string())).ok() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::app::test_support::*; - - #[test] - fn default_sort_recently_played_seeds_state_and_sorts_items() { - let (tx, _rx) = channel(); - let mut config = UserConfig::new(); - config.behavior.default_sort_recently_played = "name:desc".to_string(); - let mut app = App::new(tx, config, Some(SystemTime::now())); - assert_eq!(app.recently_played_sort.field, SortField::Name); - assert_eq!(app.recently_played_sort.order, SortOrder::Descending); - - app.recently_played = Some(crate::core::pagination::CursorPaged { - items: vec![ - queue_track(None, "Alpha"), - queue_track(None, "Charlie"), - queue_track(None, "Bravo"), - ], - limit: 3, - next: None, - cursor_after: None, - total: None, - }); - - app.sort_recently_played_items(); - - let names: Vec<_> = app - .recently_played - .as_ref() - .unwrap() - .items - .iter() - .map(|t| t.name.as_str()) - .collect(); - assert_eq!(names, vec!["Charlie", "Bravo", "Alpha"]); - } - - #[test] - fn reset_saved_tracks_view_clears_cached_pages_and_bumps_generation() { - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.saved_tracks_prefetch_generation = 7; - let saved_tracks_domain_page = crate::infra::network::mapping::map_page( - &saved_tracks_page( - 0, - 2, - &["0000000000000000000001", "0000000000000000000002"], - false, - ), - |st| TrackInfo::from(&st.track), - ); - app.library.saved_tracks.add_pages(saved_tracks_domain_page); - app.track_table.tracks = vec![ - TrackInfo::from(&full_track("0000000000000000000001", "Track 1")), - TrackInfo::from(&full_track("0000000000000000000002", "Track 2")), - ]; - app.view.track_table_index = 1; - - app.reset_saved_tracks_view(); - - assert_eq!(app.saved_tracks_prefetch_generation, 8); - assert!(app.library.saved_tracks.pages.is_empty()); - assert!(app.track_table.tracks.is_empty()); - assert_eq!(app.view.track_table_index, 0); - assert_eq!( - app.track_table.context, - Some(TrackTableContext::SavedTracks) - ); - } - - #[cfg(feature = "streaming")] - mod queued_spotify_like { - use super::*; - use crate::infra::queue::QueueNowPlaying; - use std::sync::mpsc::Receiver; - - /// An app whose cached playback context still names the suspended context's - /// last Spotify track — the regression target: Like must never save it - /// while the queue slot owns playback. - #[allow(deprecated)] - fn app_with_stale_context() -> (App, Receiver) { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.current_playback_context = Some(CurrentPlaybackContext { - device: Device { - id: Some("native-device".to_string()), - is_active: true, - is_private_session: false, - is_restricted: false, - name: "spotatui".to_string(), - _type: DeviceType::Computer, - volume_percent: Some(50), - }, - repeat_state: RepeatState::Off, - shuffle_state: false, - context: None, - timestamp: Utc::now(), - progress: None, - is_playing: true, - item: Some(PlayableItem::Track(FullTrack { - album: SimplifiedAlbum::default(), - artists: vec![SimplifiedArtist::default()], - available_markets: Vec::new(), - disc_number: 1, - duration: chrono::Duration::milliseconds(180_000), - explicit: false, - external_ids: Default::default(), - external_urls: Default::default(), - href: None, - id: Some( - TrackId::from_id("0000000000000000000009") - .unwrap() - .into_static(), - ), - is_local: false, - is_playable: Some(true), - linked_from: None, - restrictions: None, - name: "Cached".to_string(), - popularity: 50, - preview_url: None, - track_number: 1, - r#type: rspotify::model::Type::Track, - })), - currently_playing_type: CurrentlyPlayingType::Track, - actions: Actions::default(), - }); - (app, rx) - } - - #[test] - fn like_saves_the_queued_spotify_track_not_the_cached_context_item() { - let (mut app, rx) = app_with_stale_context(); - app.queue_now = Some(QueueNowPlaying::Spotify { - track: queue_track(Some("spotify:track:queued"), "Queued"), - }); - - app.toggle_save_current_item(); - - assert!( - matches!(rx.try_recv(), Ok(IoEvent::ToggleSaveTrack(uri)) if uri == "spotify:track:queued"), - "expected the queue slot's own track to be liked" - ); - assert!(app.status_message.is_none()); - } - - #[test] - fn like_for_uri_less_queued_track_never_falls_back_to_cached_context() { - let (mut app, rx) = app_with_stale_context(); - app.queue_now = Some(QueueNowPlaying::Spotify { - track: queue_track(None, "Queued"), - }); - - app.toggle_save_current_item(); - - assert!(rx.try_recv().is_err(), "nothing may be dispatched"); - assert_eq!( - app.status_message.as_deref(), - Some("The current playback source cannot be liked") - ); - } - } - - #[test] - fn playlist_sort_dispatches_for_current_playlist_table_id() { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - let sidebar_playlist = playlist_info( - "37i9dQZF1DXcBWIGoYBM5M", - "Sidebar Playlist", - "spotatui-test-user", - false, - ); - let search_playlist_id = PlaylistId::from_id("37i9dQZF1DX4WYpdgoIcn6") - .unwrap() - .into_static(); - app.all_playlists = vec![sidebar_playlist]; - app.view.active_playlist_index = Some(0); - app.track_table.context = Some(TrackTableContext::PlaylistSearch); - app.playlist_track_table_id = Some(search_playlist_id.clone()); - - app.apply_sort_field(SortContext::PlaylistTracks, SortField::Name); - - match rx.recv().unwrap() { - IoEvent::FetchAllPlaylistTracksAndSort(playlist_id) => { - assert_eq!(playlist_id, search_playlist_id.id()); - } - _ => panic!("expected playlist sort fetch"), - } - } -} diff --git a/src/core/app/lyrics.rs b/src/core/app/lyrics.rs deleted file mode 100644 index 738152b3..00000000 --- a/src/core/app/lyrics.rs +++ /dev/null @@ -1,199 +0,0 @@ -use super::*; - -#[derive(Clone, PartialEq, Debug, Default)] -pub enum LyricsStatus { - #[default] - NotStarted, - Loading, - Found, - NotFound, -} - -/// UI state for the lyrics view: the eased scroll position plus the manual -/// browsing mode. The lyric data itself lives in `App::lyrics`. -#[derive(Default)] -pub struct LyricsViewState { - /// Animated scroll position as a fractional lyric-line index. - pub scroll_pos: f64, - /// `Some(i)` while the user browses manually; auto-follow is paused and the - /// view centers on line `i` until follow resumes. - pub manual_index: Option, - /// When the last manual scroll input happened, for the auto-resnap timeout. - pub last_manual_input: Option, - /// User nudge applied to the playback position when matching lyric - /// timestamps, for correcting misaligned LRC files. Positive shows lyrics - /// earlier. Reset on track change. - pub timing_offset_ms: i64, - /// Position the current glide started from. - anim_from: f64, - /// Line index the current glide is heading to. - anim_target: f64, - /// Progress through the current glide, 0.0..=1.0. - anim_progress: f64, -} - -/// How long one scroll glide takes. Row steps quantize evenly across this -/// window thanks to the ease-in-out curve. -const LYRICS_SCROLL_ANIM: Duration = Duration::from_millis(300); - -/// Symmetric acceleration/deceleration so the whole-row steps a terminal can -/// show land evenly spaced in time (a first-frame jump followed by a slow -/// tail reads as stutter, not smoothness). -pub(crate) fn ease_in_out_cubic(t: f64) -> f64 { - if t < 0.5 { - 4.0 * t * t * t - } else { - 1.0 - (-2.0 * t + 2.0).powi(3) / 2.0 - } -} - -impl LyricsViewState { - pub fn reset(&mut self) { - *self = Self::default(); - } - - /// Glide the scroll position toward `target` over a fixed duration. A new - /// target mid-glide restarts the curve from the current position, so - /// motion stays continuous under rapid manual scrolling. - pub fn ease_toward(&mut self, target: f64, elapsed: Duration) { - if target != self.anim_target { - self.anim_from = self.scroll_pos; - self.anim_target = target; - self.anim_progress = 0.0; - } - if self.anim_progress >= 1.0 { - return; - } - self.anim_progress = - (self.anim_progress + elapsed.as_secs_f64() / LYRICS_SCROLL_ANIM.as_secs_f64()).min(1.0); - let eased = ease_in_out_cubic(self.anim_progress); - self.scroll_pos = self.anim_from + (self.anim_target - self.anim_from) * eased; - } - - /// Jump straight to `target` with no animation (used while the view is not - /// visible so it opens already in position). - pub fn snap_to(&mut self, target: f64) { - self.scroll_pos = target; - self.anim_from = target; - self.anim_target = target; - self.anim_progress = 1.0; - } -} - -/// Index of the lyric line currently playing: the last line whose timestamp is -/// at or before `progress_ms`. -pub fn active_lyric_index(lyrics: &[(u128, String)], progress_ms: u128) -> usize { - let mut active_idx = 0; - for (i, (time, _)) in lyrics.iter().enumerate() { - if *time <= progress_ms { - active_idx = i; - } else { - break; - } - } - active_idx -} - -impl App { - /// Playback progress adjusted by the user's lyric timing nudge, for - /// matching against lyric timestamps. - pub fn lyric_progress_ms(&self) -> u128 { - let adjusted = - self.song_progress_ms as i128 + i128::from(self.view.lyrics_view.timing_offset_ms); - adjusted.max(0) as u128 - } - - /// Advance the lyrics view scroll animation: glide toward the focused line - /// while the view is open, snap when it is not, and drop back to - /// auto-follow after the browsing timeout. - pub(super) fn advance_lyrics_scroll(&mut self, elapsed: Duration) { - const BROWSE_RESNAP_AFTER: Duration = Duration::from_secs(8); - let Some(lyrics) = &self.lyrics else { - return; - }; - if lyrics.is_empty() { - return; - } - if self.view.lyrics_view.manual_index.is_some() - && self - .view - .lyrics_view - .last_manual_input - .is_some_and(|at| at.elapsed() >= BROWSE_RESNAP_AFTER) - { - self.view.lyrics_view.manual_index = None; - } - let active = active_lyric_index(lyrics, self.lyric_progress_ms()); - let target = self.view.lyrics_view.manual_index.unwrap_or(active) as f64; - if self.get_current_route().id == RouteId::LyricsView { - self.view.lyrics_view.ease_toward(target, elapsed); - } else { - self.view.lyrics_view.snap_to(target); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn active_lyric_index_picks_last_started_line() { - let lyrics = vec![ - (1_000u128, "a".to_string()), - (5_000, "b".to_string()), - (10_000, "c".to_string()), - ]; - assert_eq!(active_lyric_index(&lyrics, 0), 0); - assert_eq!(active_lyric_index(&lyrics, 4_999), 0); - assert_eq!(active_lyric_index(&lyrics, 5_000), 1); - assert_eq!(active_lyric_index(&lyrics, 99_000), 2); - } - - #[test] - fn lyrics_scroll_eases_monotonically_and_snaps_on_target() { - let mut state = LyricsViewState::default(); - let mut last = 0.0; - for _ in 0..200 { - state.ease_toward(10.0, Duration::from_millis(16)); - assert!(state.scroll_pos > last && state.scroll_pos <= 10.0); - last = state.scroll_pos; - if state.scroll_pos == 10.0 { - break; - } - } - assert_eq!(state.scroll_pos, 10.0, "easing never converged"); - } - - #[test] - fn browsing_resnaps_to_follow_after_timeout() { - let mut app = App::default(); - app.lyrics = Some(vec![(0, "a".to_string()), (5_000, "b".to_string())]); - app.lyrics_status = LyricsStatus::Found; - app.view.lyrics_view.manual_index = Some(1); - app.view.lyrics_view.last_manual_input = Instant::now().checked_sub(Duration::from_secs(9)); - app.advance_lyrics_scroll(Duration::from_millis(16)); - assert_eq!(app.view.lyrics_view.manual_index, None); - } - - #[test] - fn lyric_progress_applies_offset_and_clamps_at_zero() { - let mut app = App::default(); - app.song_progress_ms = 10_000; - app.view.lyrics_view.timing_offset_ms = 2_500; - assert_eq!(app.lyric_progress_ms(), 12_500); - app.view.lyrics_view.timing_offset_ms = -15_000; - assert_eq!(app.lyric_progress_ms(), 0); - } - - #[test] - fn browsing_persists_while_input_is_recent() { - let mut app = App::default(); - app.lyrics = Some(vec![(0, "a".to_string()), (5_000, "b".to_string())]); - app.lyrics_status = LyricsStatus::Found; - app.view.lyrics_view.manual_index = Some(1); - app.view.lyrics_view.last_manual_input = Some(Instant::now()); - app.advance_lyrics_scroll(Duration::from_millis(16)); - assert_eq!(app.view.lyrics_view.manual_index, Some(1)); - } -} diff --git a/src/core/app/mod.rs b/src/core/app/mod.rs deleted file mode 100644 index 72d0c113..00000000 --- a/src/core/app/mod.rs +++ /dev/null @@ -1,625 +0,0 @@ -use crate::core::action::LibraryTarget; -use crate::core::geometry::Viewport; -use crate::core::input::Key; -use crate::core::pagination::{CursorPaged, Paged}; -use crate::core::plugin_api::{ - ArtistInfo, EpisodeInfo, PlayableInfo, PlaylistInfo, SavedAlbumInfo, ShowInfo, TrackInfo, -}; -use crate::core::requirement::{availability, Availability, Capability, Requirement}; -use crate::core::sort::{SortContext, SortField, SortOrder, SortState}; -use crate::core::source::Source; -use crate::core::state::{ - PersistedRuntimeState, RadioStationAddOutcome, RadioStationConfig, RuntimeState, -}; -use crate::core::user_config::{color_to_string, normalize_tick_rate_milliseconds, UserConfig}; -use crate::infra::history::{RecapPeriod, StatsData, StreakSummary}; -use crate::infra::network::sync::{ControlMode, PartySession, PartyStatus}; -use crate::infra::network::IoEvent; -#[cfg(feature = "queue")] -use crate::infra::queue::QueueNowPlaying; -use anyhow::anyhow; -use rspotify::{ - model::enums::Country, - model::{ - context::CurrentPlaybackContext, device::DevicePayload, idtypes::PlaylistId, track::FullTrack, - PlayableItem, - }, - prelude::*, // Adds Id trait for .id() method -}; -use std::cell::Cell; -use std::path::PathBuf; -use std::sync::mpsc::Sender; -// Bare `Arc` here is only ever named by the streaming player, the MPRIS manager, -// and the decoded queue-slot accessors below (whose gate is the queueable -// sources, not `audio-decode` — a radio-only build has no queue slot). -#[cfg(any(feature = "queue", all(feature = "mpris", target_os = "linux")))] -use std::sync::Arc; -use std::{ - cmp::{max, min}, - collections::HashSet, - time::{Duration, Instant, SystemTime}, -}; -use unicode_width::UnicodeWidthStr; - -use arboard::Clipboard; -#[cfg(any(test, feature = "streaming"))] -use chrono::Utc; -use log::info; -#[cfg(feature = "streaming")] -use rspotify::model::{ - context::Actions, - device::Device, - enums::{CurrentlyPlayingType, RepeatState}, - DeviceType, -}; - -use crate::infra::queue::RepeatMode; - -#[cfg(test)] -use crate::core::test_helpers::{playlist_info, user_info}; -#[cfg(test)] -use chrono::Duration as ChronoDuration; -#[cfg(test)] -use rspotify::model::{ - artist::SimplifiedArtist, idtypes::TrackId, page::Page, track::SavedTrack, SimplifiedAlbum, -}; -#[cfg(test)] -use std::collections::HashMap; -#[cfg(test)] -use std::sync::mpsc::channel; - -mod album_theme; -mod construction; -mod discover; -mod dj; -mod friends; -mod help; -mod keybindings; -mod layout; -mod library; -mod lyrics; -mod models; -mod native_backend; -mod native_recovery; -mod native_shuffle; -mod party; -mod persistence; -mod playback_routing; -pub(crate) use playback_routing::{PlaybackOwner, NOTHING_PLAYING_STATUS}; -mod playlist_folders; -mod playlist_pages; -mod playlists; -mod plugins; -mod queue; -mod queue_suspend; -mod recap; -mod route; -mod scrollable_pages; -mod seek; -mod settings_apply; -mod settings_schema; -mod shuffle_repeat; -mod status; -mod tick; -mod transport; -mod view; -mod volume; - -#[cfg(test)] -mod test_support; - -pub use discover::*; -pub use friends::*; -pub use help::*; -pub use keybindings::*; -pub use library::*; -pub use lyrics::*; -pub use models::*; -pub use native_backend::*; -#[cfg(feature = "streaming")] -pub use native_recovery::*; -#[cfg(feature = "streaming")] -pub(crate) use native_shuffle::*; -pub use playback_routing::*; -pub use playlist_folders::*; -pub use playlists::*; -pub use plugins::*; -pub use queue::*; -pub use route::*; -pub use scrollable_pages::*; -pub use seek::*; -pub use settings_schema::*; -pub use status::*; -pub use view::*; - -pub struct App { - /// What the user actually wants the volume to be. We keep this around until - /// Spotify's API comes back with the same value — otherwise a slow poll - /// response can flash the old volume back on screen. - pub pending_volume: Option, - /// The last value we actually sent to the API. Lets us skip redundant - /// dispatches while we're just waiting for confirmation. - pub last_dispatched_volume: Option, - pub instant_since_last_current_playback_poll: Instant, - navigation_stack: Vec, - pub spectrum_data: Option, - pub audio_capture_active: bool, - /// Presentation state of the terminal frontend (cursors, scroll offsets, edit - /// buffers, focus, popups, terminal geometry). See [`ViewState`]: handlers and - /// draw functions write it freely; nothing outside `tui/` and these `App` - /// methods should. - pub view: ViewState, - pub user_config: UserConfig, - pub runtime_state: RuntimeState, - pub state_path: Option, - pub artists: Vec, - pub artist: Option, - pub album_table_context: AlbumTableContext, - /// The live error recorded by [`App::handle_error`], or empty when there is - /// none. The `RouteId::Error` frame raised alongside it is a presentation - /// *hint*, not the state: a frontend may draw it full-screen, render this - /// string as a toast, or ignore it. Dismiss via [`App::clear_api_error`]. - api_error: String, - /// When the live `api_error` stops being current. Stamped by `handle_error`, - /// consumed by `App::expire_api_error` on the tick. `None` means none is live. - api_error_expires_at: Option, - pub current_playback_context: Option, - pub last_track_id: Option, - /// Set to true when a track ends naturally and stop_after_current_track is enabled. - /// The next Playing event will see this flag and immediately pause. - #[allow(dead_code)] - pub pending_stop_after_track: bool, - pub devices: Option, - pub queue: Option, - /// The native cross-source playback queue (FIFO). Unlike [`Self::queue`] - /// (a read-only mirror of Spotify's Web-API queue), this is owned by the app - /// and holds tracks from any source. - pub native_queue: Vec, - /// How to resume the underlying per-source context after the native queue - /// drains. Populated when a track is queued over an active context. - pub queue_suspended: Option, - /// What the native queue's playback slot is currently playing, if anything. - /// Overlays the per-source `*_playback` contexts without mutating them (those - /// are the context to resume). Gated to builds that can actually play a queued - /// track — i.e. native streaming or a source with a finite track list, which - /// excludes internet radio — and every read goes through the unconditional - /// [`Self::queue_owns_playback`] accessor. - #[cfg(feature = "queue")] - pub queue_now: Option, - /// Bounded retry guard for the native-Spotify queue slot. When a queued - /// Spotify track is playing via a direct `player.load` (no Spirc context) and - /// Spirc self-advances to a different track, the player-event handler reissues - /// the queued track and increments this. Reset to 0 each time a new Spotify - /// queue slot is published; capped so a genuinely-gone track can't loop. - #[cfg(feature = "streaming")] - pub spotify_queue_guard_reloads: u8, - /// Whether the queue slot should be playing. Set on a Spotify slot's publish - /// and on a user skip, flipped by the slot's pause/resume transport arms, - /// cleared by a device removal; read by the paths that stage the next - /// decoded item and by the suspended context's resume. Unlike - /// `native_is_playing` this survives a backend teardown, so a recovery - /// replay of the slot can honor a user's pause. - #[cfg(feature = "queue")] - pub queue_slot_desired_playing: bool, - /// Decoded cover art for the current track plus its load status. The TUI - /// renderer (`tui::cover_art`) caches its terminal protocols on this - /// store's key. - #[cfg(feature = "art-decode")] - pub cover_art: crate::core::art::CoverArtStore, - /// Image key currently desired by the UI. Detached decode results for older - /// keys are discarded. - #[cfg(feature = "art-decode")] - pub desired_cover_art_key: Option, - /// Accent colors extracted from the current cover art. Stored even while - /// Adaptive Theme is off, so toggling it on recolors immediately without a - /// refetch. Cleared together with the art itself. - #[cfg(feature = "art-decode")] - cover_art_palette: Option, - /// Whether an album-derived theme is applied, and the user theme to restore. - #[cfg(feature = "art-decode")] - cover_theme_state: crate::core::cover_theme::CoverThemeState, - /// In-flight fade of the live theme, advanced by `update_on_tick`. - #[cfg(feature = "art-decode")] - theme_transition: Option, - /// AI DJ session state: transcript, auto-queue toggle, vibe, and the - /// generation counter that invalidates in-flight background work. - #[cfg(feature = "dj-core")] - pub dj: crate::infra::dj::DjState, - pub liked_song_ids_set: HashSet, - /// Liked-state lookups pending for the detached contains worker, as bare - /// base62 track ids (deduped). The `CurrentUserSavedTracksContains` handler - /// enqueues here instead of resolving on the serial IoEvent pump. - pub liked_lookup_pending: HashSet, - /// Whether the single detached liked-lookup worker is currently draining - /// [`Self::liked_lookup_pending`]. - pub liked_lookup_worker_running: bool, - /// Bumped on every local like/unlike so a detached liked-state read that - /// started before the mutation is re-read instead of clobbering it. - pub liked_state_epoch: u64, - pub followed_artist_ids_set: HashSet, - pub saved_album_ids_set: HashSet, - pub saved_show_ids_set: HashSet, - pub library: Library, - pub playlist_offset: u32, - // Each item carries its absolute playlist position (`page.offset + raw slot - // index`) alongside the playable. The position is computed in the mapping - // layer before unparseable/local slots are dropped, so removal-by-position and - // play-from-here offsets stay correct (see `playlist_items_page`). - pub playlist_tracks: Option>, - pub playlist_track_pages: ScrollableResultPages>, - pub playlist_track_table_id: Option>, - pub active_playlist_track_filter: Option, - pub pending_playlist_track_search: Option, - pub playlists: Option>, - /// The Recently Played page. Its cursor is `view.recently_played_index`. - pub recently_played: - Option>, - pub recommendations_seed: String, - pub recommendations_context: Option, - pub search_results: SearchResult, - pub selected_album_simplified: Option, - pub selected_album_full: Option, - pub song_progress_ms: u128, - /// When `update_on_tick` last ran. Every frontend must drive the tick (see - /// `core::driver`); `playback_position_ms` reports stale past 2s so one - /// that stops ticking shows a loud error instead of a frozen playbar. - last_tick_at: Instant, - pub seek_ms: Option, - /// Last time a native seek was actually sent to the player (for throttling) - #[cfg(feature = "streaming")] - pub last_native_seek: Option, - /// Pending seek position to send to player (throttled to avoid overwhelming librespot) - #[cfg(feature = "streaming")] - pending_native_seek: Option, - /// Last time an API seek was sent (for throttling external device control) - last_api_seek: Option, - /// Pending seek position for API (throttled to avoid overwhelming Spotify API) - pending_api_seek: Option, - /// Last time a decoded-source seek was dispatched (for throttling drags) - last_source_seek: Option, - /// Pending decoded-source seek position (throttled; drags coalesce to the last target) - pending_source_seek: Option, - pub track_table: TrackTable, - pub episode_table_context: EpisodeTableContext, - pub selected_show_simplified: Option, - pub selected_show_full: Option, - pub user: Option, - /// Folders under the configured music dir that hold audio, shown by the - /// Local Files browser. Its cursor is `view.local_playlists_index`. - pub local_playlists: Vec, - /// The user's Subsonic server playlists shown by the Subsonic browser. - /// Populated by `GetSubsonicPlaylists` dispatch. - pub subsonic_playlists: Vec, - /// The Qobuz sidebar rows (favorites, playlists, albums) shown by the Qobuz - /// browser. Populated by `GetQobuzPlaylists` dispatch. - pub qobuz_playlists: Vec, - /// The user's configured internet-radio stations (as playable rows, uri - /// `radio:`) shown by the sidebar when the Radio source is active. - /// Populated by `GetRadioStations` dispatch. - /// Unconditional (domain type) because the sidebar match arms key on the - /// unconditional `Source::Radio` variant even in the slim build. - pub radio_stations: Vec, - /// The user's local YouTube playlists (from `youtube_playlists.yml`), shown - /// by the sidebar when the YouTube source is active. Unconditional for the - /// same slim-build reason as [`radio_stations`](Self::radio_stations). - pub youtube_playlists: Vec, - /// The `youtube:playlist:` URI currently open in the shared track table, so - /// the remove-track flow knows which playlist to edit. - pub youtube_open_playlist: Option, - /// The source the UI is currently scoped to (sidebar, search, capability - /// gating). Browse-scope only — never changes playback routing. - pub active_source: Source, - pub clipboard: Option, - pub is_loading: bool, - io_tx: Option>, - pub is_fetching_current_playback: bool, - /// Expiry of the current Spotify access token, or `None` when there is no - /// Spotify session (launched against a free source). The token-refresh poll - /// in the driver skips refreshing while this is `None`. - pub spotify_token_expiry: Option, - /// Whether a Spotify session is available (token loaded at startup or added - /// via in-TUI login). Gates the Spotify-only startup dispatches so a - /// free-source launch doesn't spam "connect Spotify" messages. - pub spotify_connected: bool, - pub auth_refresh_in_progress: bool, - /// Earliest instant the timed token refresh may run again after a failure, - /// so a failing refresh backs off instead of re-arming on every tick. - spotify_refresh_retry_at: Option, - pending_keybinding_persist: Option, - pub keybinding_runtime: KeybindingRuntimeState, - - pub active_announcement: Option, - pending_announcements: Vec, - pub lyrics: Option>, - pub lyrics_status: LyricsStatus, - /// Title/artist pair whose lyrics response is currently desired. Detached - /// service responses must match this before mutating visible state. - pub desired_lyrics_identity: Option<(String, String)>, - /// Whether the current `lyrics` carry real LRC timestamps rather than - /// synthesized evenly-spaced ones derived from plain lyrics. - pub lyrics_synced: bool, - pub global_song_count: Option, - pub global_song_count_failed: bool, - // Settings screen state - pub settings_items: Vec, - settings_saved_items: Vec, - /// Immediate track info from native player for instant UI updates - pub native_track_info: Option, - /// Whether native streaming is active (disables API-based progress calculation) - pub is_streaming_active: bool, - /// Device id for the native streaming device when known - #[allow(dead_code)] - pub native_device_id: Option, - /// A `file://` URI to start playing once the UI is up (set from `--play-file`). - /// Consumed and cleared on first render. - pub pending_play_file: Option, - /// Native playback state - updated by player events, used when streaming is active - /// This is more reliable than current_playback_context.is_playing during native streaming - pub native_is_playing: Option, - /// Tracks whether the current native playback was started from a Spotify context - /// or from a raw URI-list/native-only route. - pub native_playback_origin: Option, - /// The app-owned native-Spotify shuffle session, when client-side shuffle - /// owns the current native playback (see [`NativeSpotifyShuffleSession`]). - #[cfg(feature = "streaming")] - pub native_spotify_shuffle: Option, - /// Monotonic generation for [`Self::native_spotify_shuffle`]; bumped on every - /// session create/clear so stale background fetches are discarded. - #[cfg(feature = "streaming")] - native_shuffle_generation: u64, - /// Prevent idle/sleep during playback - keepawake: Option, - /// Timestamp of the last native device activation - #[allow(dead_code)] - pub last_device_activation: Option, - /// Whether a native device activation is still in progress - #[allow(dead_code)] - pub native_activation_pending: bool, - /// Top tracks from the user for Discover feature - pub discover_top_tracks: Vec, - /// Top Artists Mix tracks for Discover feature - pub discover_artists_mix: Vec, - /// Whether we're currently loading discover data - pub discover_loading: bool, - /// Period shown on the Stats screen - pub stats_period: RecapPeriod, - /// Whether we're currently loading stats data - pub stats_loading: bool, - /// Aggregated listening stats for the Stats screen - pub stats_data: Option, - /// Cached listening streak summary (Home strip + Stats screen) - pub listening_streaks: Option, - /// Pending monthly recap popup (path + listen count) - recap_prompt: Option, - /// Current sort state per context - pub playlist_sort: SortState, - album_sort: SortState, - pub artist_sort: SortState, - recently_played_sort: SortState, - /// Last time the listening party host broadcast playback state. - last_party_sync_at: Instant, - /// Ephemeral status message shown in the playbar. Read through - /// [`App::status_message`]; every writer is a setter in `status.rs`. - status_message: Option, - /// When to clear the status message - status_message_expires_at: Option, - /// True when the current status message is an error (blocks normal message overwrites) - status_message_is_error: bool, - /// Listening party status - pub party_status: PartyStatus, - /// Active listening party session data - pub party_session: Option, - /// Pending track table selection to apply when new page loads - pending_track_table_selection: Option, - /// Maps visible track table rows to source playlist item positions. - /// Used to remove a single selected playlist occurrence safely. - pub playlist_track_positions: Option>, - /// Pending track to add in add-to-playlist dialog flow - pub pending_playlist_track_add: Option, - /// Pending track removal info in remove-from-playlist confirmation flow - pub pending_playlist_track_removal: Option, - /// Full flat list of all user playlists (all pages combined) - pub all_playlists: Vec, - /// Folder tree from rootlist (None if not fetched or streaming disabled) - pub _playlist_folder_nodes: Option>, - /// Flattened folder+playlist items for display navigation - pub playlist_folder_items: Vec, - /// Backing storage for the injected community-playlist pin so display methods - /// can hand out a `&PlaylistFolderItem`. Never stored in - /// `playlist_folder_items`. - community_pin_item: PlaylistFolderItem, - /// Current folder ID being viewed (0 = root) - pub current_playlist_folder_id: usize, - /// Incremented every time playlists are refreshed to guard stale background tasks - pub playlist_refresh_generation: u64, - /// Incremented every time the saved tracks view is reloaded to guard stale prefetch tasks - pub saved_tracks_prefetch_generation: u64, - pub saved_tracks_prefetch_in_flight: HashSet, - /// Incremented every time the playlist track table is reloaded to guard stale prefetch tasks - pub playlist_tracks_prefetch_generation: u64, - pub playlist_tracks_prefetch_in_flight: HashSet, - /// Playlist ids whose full track list is being fetched for sorting. Further - /// sort changes reuse the active fetch and apply the newest sort at completion. - pub playlist_sort_fetch_in_flight: HashSet, - /// Playlist id whose page-1 open fetch is in flight, so spamming Enter on a - /// playlist doesn't queue duplicate full fetches. Cleared when the response - /// (or its error) lands. - pub pending_playlist_open: Option, - /// Tracks whether a ChangeVolume request is on its way to Spotify. - /// When true, we hold off on sending another one — rapid key presses - /// just update `pending_volume` and the latest value wins. - pub is_volume_change_in_flight: bool, - /// Deadline for a debounced state save scheduled by auto-repeating runtime - /// state changes such as volume and shuffle. - state_save_due: Option, - /// Runtime-state patch waiting for the next debounced save. - pending_state_save_patch: PersistedRuntimeState, - /// Whether the current run of state-save failures has already been reported. - /// A failed flush re-arms its own deadline, so the tick retries twice a - /// second; without this latch the report would re-fire at that rate and pin - /// the status bar into error mode, where `set_status_message` refuses to - /// overwrite it and every ordinary message is dropped for the rest of the run. - state_save_error_reported: bool, - /// Reference to the native streaming player for direct control (bypasses event channel) - #[cfg(feature = "streaming")] - pub streaming_player: Option>, - /// Player-global repeat mode for the decoded (non-Spotify) sources - /// (Local / Subsonic / YouTube). Decoded sources have no Spotify - /// `current_playback_context`, so their repeat lives here instead of in - /// `current_playback_context.repeat_state`. Consulted by the auto-advance / - /// skip logic and rendered in the source playbar. Radio (an infinite stream) - /// and the native queue ignore it. - pub decoded_repeat: RepeatMode, - /// Player-global shuffle flag for the decoded (non-Spotify) sources. When set, - /// the owning source's own queue is reordered in place (see `ShuffleBackup`), - /// keeping the current track playing. - pub decoded_shuffle: bool, - /// The active local-file playback session (multi-source Phase 3), or `None` - /// when Spotify owns playback. Decoupled from Spotify/librespot state: the - /// local playbar reads progress and pause state live from the player here, so - /// librespot events and polls never desync it. `Some` exactly while a local - /// file is playing; dropping it releases the audio output device. - #[cfg(feature = "local-files")] - pub local_playback: Option, - /// The active Subsonic playback session (multi-source Phase 4), or `None` when - /// another backend owns playback. Same decoupling contract as - /// [`local_playback`](Self::local_playback): the playbar reads progress/pause - /// live from the player here, never touching Spotify/librespot fields. - #[cfg(feature = "subsonic")] - pub subsonic_playback: Option, - /// The active Qobuz playback session, or `None` when another backend owns - /// playback. Same decoupling contract as [`local_playback`](Self::local_playback). - #[cfg(feature = "qobuz")] - pub qobuz_playback: Option, - /// The active internet-radio playback session (multi-source Phase 5), or - /// `None` when another backend owns playback. Same decoupling contract as - /// [`local_playback`](Self::local_playback); unlike it there is no queue — - /// a station is one infinite stream. - #[cfg(feature = "internet-radio")] - pub radio_playback: Option, - /// The active YouTube playback session (multi-source, yt-dlp backed), or - /// `None` when another backend owns playback. Same decoupling contract as - /// [`subsonic_playback`](Self::subsonic_playback). - #[cfg(feature = "youtube")] - pub youtube_playback: Option, - /// The decoded source that claimed the audio sink: set when its start path - /// pauses librespot, kept when the session dies with nothing to replace it - /// (a failed start, a lost output device), cleared when an explicit Spotify - /// start takes the sink. Covers the window in which every `*_playback` field - /// is `None` for a source the user asked for. - #[cfg(feature = "audio-decode")] - decoded_sink_claim: Option, - /// Sender used to recover native streaming when a stale/disconnected player is detected. - #[cfg(feature = "streaming")] - pub streaming_recovery_tx: - Option>, - /// A StartPlayback request parked while no usable backend exists (native - /// session recovering, or startup init still materializing one). Replayed - /// once the backend is ready so the user's press isn't silently dropped. - #[cfg(feature = "streaming")] - pub pending_start_playback: Option, - /// True while a native backend may materialize soon (recovery in flight, or - /// the deferred startup init still running). While set, a playback request - /// that finds no active device parks itself for replay instead of routing - /// to the full-screen error. - #[cfg(feature = "streaming")] - pub native_backend_pending: bool, - /// Armed when a native load is issued; a Playing/TrackChanged event disarms - /// it. If it fires, the session is a zombie (passes `is_connected` but - /// silently drops Spirc commands) and recovery is forced. - #[cfg(feature = "streaming")] - pub native_load_watchdog: Option, - /// Durable native playback intent, independent of the current librespot - /// Session/Spirc instance. Used to restore the exact track or in-flight - /// transition after a transport-level recovery. - #[cfg(feature = "streaming")] - pub native_playback_recovery: Option, - /// A restore command has been issued for this generation and is waiting for - /// a matching Playing/Paused event from the replacement player. - #[cfg(feature = "streaming")] - pub native_restore_pending: Option, - #[cfg(feature = "streaming")] - native_playback_generation: u64, - /// Reference to MPRIS manager for emitting Seeked signals after native seeks - #[cfg(all(feature = "mpris", target_os = "linux"))] - pub mpris_manager: Option>, - - // Friends screen state - /// All friends fetched from spotatui.com (follows list) - pub friends: Vec, - /// Whether friends are currently loading from the API - pub friends_loading: bool, - /// Own friend code fetched from spotatui.com - pub friend_code: Option, - /// Results from searching users by name - pub friend_user_search_results: Vec, - /// Timestamp of the last time friends were refreshed (for periodic polling) - last_friends_refresh_at: Instant, - - // Create Playlist form state - pub create_playlist_tracks: Vec, - pub create_playlist_search_results: Vec, - /// Commands queued by keybindings for the scripting engine to run. - pub pending_plugin_commands: Vec, - /// Per-domain write counters driving async plugin data reads (see - /// [`PluginDataKind`]). Ungated: the network layer bumps them in every build; - /// only the scripting engine reads them. - pub plugin_data_generations: PluginDataGenerations, - /// Retained content of plugin-registered custom screens, keyed by screen - /// name. Written by script effects; read by the draw loop. - pub plugin_screens: - std::collections::BTreeMap, - /// Keys pressed while a plugin screen was focused: `(screen_name, key_string)`, - /// drained by the script engine after each key event. - pub pending_plugin_screen_keys: Vec<(String, String)>, - /// Per-plugin playbar segments, keyed by plugin name (BTreeMap for deterministic order). - pub plugin_playbar_segments: std::collections::BTreeMap, - /// Currently displayed plugin popup, if any. - pub plugin_popup: Option, - /// Where this run's log file is being written, resolved once here so draw - /// code can show it without doing the environment lookup every frame. - pub log_path: String, -} - -impl App { - // Send a network event to the network thread - /// Clone the IoEvent sender so a spawned task (e.g. the in-TUI Spotify login - /// callback server) can dispatch events back into the pump without holding the - /// `App` lock. `None` before the sender is wired up or after teardown. - pub fn io_tx_clone(&self) -> Option> { - self.io_tx.clone() - } - - pub fn dispatch(&mut self, action: IoEvent) { - // `is_loading` will be set to false again after the async action has finished in network.rs - self.is_loading = true; - if let Some(io_tx) = &self.io_tx { - if let Err(e) = io_tx.send(action) { - self.is_loading = false; - log::error!("Error from dispatch {}", e); - // TODO: handle error - }; - } - } - - /// [`Self::dispatch`] without setting `is_loading`. - /// - /// For work with its own progress surface. A DJ brain call runs for up to - /// `dj_agent_timeout_secs` (minutes), and `dispatch` would pin the global - /// spinner until the service-lane task finishes — the exact UX bug - /// `DjState::thinking` exists to avoid, and the reason the MCP executor sends - /// straight down the channel instead of dispatching. - #[cfg(feature = "ai-dj")] - pub fn dispatch_without_spinner(&self, action: IoEvent) { - if let Some(io_tx) = &self.io_tx { - if let Err(e) = io_tx.send(action) { - log::warn!("dispatch_without_spinner failed (shutting down?): {e}"); - } - } - } - - // Close the IO channel to allow the network thread to exit gracefully - pub fn close_io_channel(&mut self) { - self.io_tx = None; - } -} diff --git a/src/core/app/models.rs b/src/core/app/models.rs deleted file mode 100644 index 3ad20fbf..00000000 --- a/src/core/app/models.rs +++ /dev/null @@ -1,202 +0,0 @@ -use super::*; - -/// Minimal source-agnostic snapshot of the signed-in user, used for playlist -/// ownership checks and market/country resolution. Holds only string fields so -/// no `rspotify::model` type leaks into [`App`] state. -#[derive(Debug, Clone, PartialEq, Default)] -pub struct UserInfo { - pub id: String, - pub display_name: Option, - /// ISO 3166-1 alpha-2 country code (e.g. `"US"`), when known. - pub country: Option, -} - -/// State backing the monthly recap popup. -#[derive(Clone, Debug)] -pub struct RecapPromptState { - pub path: PathBuf, - pub listens: usize, -} - -// Is it possible to compose enums? -#[derive(PartialEq, Debug)] -pub enum TrackTableContext { - MyPlaylists, - AlbumSearch, - PlaylistSearch, - SavedTracks, - RecommendedTracks, - DiscoverPlaylist, - LocalPlaylist, - SubsonicPlaylist, - YouTubePlaylist, - QobuzPlaylist, -} - -/// The five search result pages. Their cursors and focus are `App.view`'s -/// `search_*` fields. -#[derive(Default)] -pub struct SearchResult { - pub albums: Option>, - pub artists: Option>, - pub playlists: Option>, - pub tracks: Option>, - pub shows: Option>, -} - -/// The shared track table's rows and what they are. Its cursor is -/// `App.view.track_table_index`, its scroll offset `view.track_table_scroll_offset`. -#[derive(Default)] -pub struct TrackTable { - pub tracks: Vec, - pub context: Option, -} - -#[derive(Clone, Copy, PartialEq, Debug)] -pub enum PendingTrackSelection { - Index(usize), -} - -impl App { - pub fn replace_track_table_tracks(&mut self, tracks: Vec) { - self.playlist_track_positions = None; - - let track_count = tracks.len(); - self.track_table.tracks = tracks; - if track_count > 0 { - if let Some(pending) = self.pending_track_table_selection.take() { - self.view.track_table_index = match pending { - PendingTrackSelection::Index(index) => index.min(track_count.saturating_sub(1)), - }; - } else { - self.clamp_track_table_cursor(); - } - } else { - self.view.track_table_index = 0; - } - } - - /// Show `tracks` in the shared table with the cursor on the top row. - pub(crate) fn set_track_table(&mut self, tracks: Vec, context: TrackTableContext) { - self.track_table.tracks = tracks; - self.track_table.context = Some(context); - self.view.track_table_index = 0; - } - - /// Keep the track table cursor inside the rows the table holds now. - pub(crate) fn clamp_track_table_cursor(&mut self) { - let max_index = self.track_table.tracks.len().saturating_sub(1); - if self.view.track_table_index > max_index { - self.view.track_table_index = max_index; - } - } - - /// Keep every search cursor inside its page after the pages were replaced: - /// a shorter page must never leave a cursor past its end. - pub(crate) fn clamp_search_cursors(&mut self) { - let pages = &self.search_results; - clamp_cursor( - &mut self.view.search_selected_tracks_index, - page_len(&pages.tracks), - ); - clamp_cursor( - &mut self.view.search_selected_artists_index, - page_len(&pages.artists), - ); - clamp_cursor( - &mut self.view.search_selected_album_index, - page_len(&pages.albums), - ); - clamp_cursor( - &mut self.view.search_selected_playlists_index, - page_len(&pages.playlists), - ); - clamp_cursor( - &mut self.view.search_selected_shows_index, - page_len(&pages.shows), - ); - } - - /// Test seed: a `view` write in a test outside `tui/` is counted by `view_writes_outside_tui`. - #[cfg(test)] - pub(crate) fn select_track_row(&mut self, index: usize) { - self.view.track_table_index = index; - } - - /// Park a row index for the page a `LoadMore` is about to fetch; the next - /// `replace_track_table_tracks` moves the cursor there. - pub fn select_row_when_next_page_lands(&mut self, index: usize) { - self.pending_track_table_selection = Some(PendingTrackSelection::Index(index)); - } - - #[cfg(test)] - pub(crate) fn pending_track_table_selection(&self) -> Option { - self.pending_track_table_selection - } -} - -fn page_len(page: &Option>) -> usize { - page.as_ref().map(|p| p.items.len()).unwrap_or(0) -} - -fn clamp_cursor(index: &mut Option, len: usize) { - *index = match (*index, len) { - (_, 0) => None, - (Some(i), len) => Some(i.min(len - 1)), - (None, _) => None, - }; -} - -#[cfg(test)] -mod tests { - use super::*; - - fn page_of(len: usize) -> Option> { - Some(Paged { - items: (0..len as u32).collect(), - total: len as u32, - ..Default::default() - }) - } - - /// Replacing a long page (e.g. 21 playlists, selected index 20) with a - /// shorter one (e.g. 2 playlists) must clamp the selected index into range - /// instead of leaving it pointing past the end — this is the root cause of - /// panic-1 (unchecked `.items[selected_index]` indexing downstream). - #[test] - fn clamp_selects_last_valid_index_when_new_page_is_shorter() { - let mut index = Some(20); - let new_page = page_of(2); - clamp_cursor(&mut index, page_len(&new_page)); - assert_eq!(index, Some(1)); - } - - #[test] - fn clamp_resets_to_none_when_new_page_is_empty() { - let mut index = Some(20); - let empty_page: Option> = page_of(0); - clamp_cursor(&mut index, page_len(&empty_page)); - assert_eq!(index, None); - - let mut index = Some(0); - let none_page: Option> = None; - clamp_cursor(&mut index, page_len(&none_page)); - assert_eq!(index, None); - } - - #[test] - fn clamp_leaves_in_range_index_untouched() { - let mut index = Some(1); - let new_page = page_of(5); - clamp_cursor(&mut index, page_len(&new_page)); - assert_eq!(index, Some(1)); - } - - #[test] - fn clamp_leaves_none_as_none_when_page_is_nonempty() { - let mut index = None; - let new_page = page_of(5); - clamp_cursor(&mut index, page_len(&new_page)); - assert_eq!(index, None); - } -} diff --git a/src/core/app/native_backend.rs b/src/core/app/native_backend.rs deleted file mode 100644 index 49e108cb..00000000 --- a/src/core/app/native_backend.rs +++ /dev/null @@ -1,352 +0,0 @@ -use super::*; - -#[cfg(feature = "streaming")] -const FRESH_NATIVE_ACTIVITY_WINDOW: Duration = Duration::from_secs(5); - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum NativePlaybackOrigin { - Context, - #[default] - RawList, -} - -#[allow(dead_code)] -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum NativeTrackKind { - #[default] - Track, - Episode, -} - -/// Immediate track info from native player for instant UI updates -/// Used to display track info immediately when skipping, before API responds -#[derive(Clone, Debug, Default)] -pub struct NativeTrackInfo { - pub name: String, - /// Individual credited artist names, in order. Kept structured (not a - /// pre-joined display string) so the LRCLIB lookup can fall back to the - /// primary artist alone for collaborations (#410). Join with `", "` for - /// display. - pub artists: Vec, - #[allow(dead_code)] - pub album: String, // Reserved for future use (e.g., displaying album in playbar) - pub duration_ms: u32, - pub kind: NativeTrackKind, - /// Album art URL carried by librespot's own `TrackChanged` payload, so cover - /// art follows the track librespot is actually decoding. The polled Spotify - /// context is not a usable source here: it lags by seconds after a skip, and - /// for a natively queued track (played via a direct `player.load`, which Spirc - /// never reports) it stays on the *previous* track for the whole song. (#402) - pub image_url: Option, -} - -#[cfg(feature = "streaming")] -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PendingStartPlayback { - pub context_uri: Option, - pub uris: Option>, - pub offset: Option, - pub parked_at: Instant, - pub recovery_attempts: u8, -} - -impl App { - #[cfg(feature = "streaming")] - pub fn request_native_streaming_recovery_if_disconnected( - &mut self, - reselect_device: bool, - ) -> bool { - let Some(player) = self.streaming_player.as_ref() else { - return false; - }; - - if player.is_available() { - return false; - } - - self.force_native_streaming_recovery(reselect_device); - true - } - - /// Tear down the current native session — even one that still passes - /// `is_connected` — and request recovery. Called by the disconnect check - /// above and by the load watchdog, which catches zombie sessions (half-open - /// TCP: `is_connected` true, Spirc commands silently dropped). - #[cfg(feature = "streaming")] - pub fn force_native_streaming_recovery(&mut self, reselect_device: bool) { - let position_ms = u32::try_from(self.song_progress_ms).unwrap_or(u32::MAX); - let is_playing = self.native_is_playing.unwrap_or(false); - self.prepare_native_playback_recovery(position_ms, is_playing); - if let Some(player) = self.streaming_player.take() { - // Stop the old spirc before dropping our reference so the dead session - // doesn't linger as a ghost Connect device (#297). - player.shutdown(); - } - // Unlike disconnect recovery, the shuffle session (and any shuffled queue - // suspension bound to its generation) is deliberately kept: the session is - // app-owned and player-independent, so a session-driven load resumes it on - // the replacement player. - self.is_streaming_active = false; - self.native_activation_pending = false; - self.native_device_id = None; - self.native_is_playing = Some(false); - self.native_track_info = None; - self.native_playback_origin = None; - self.song_progress_ms = 0; - self.last_track_id = None; - self.last_device_activation = None; - self.seek_ms = None; - self.native_load_watchdog = None; - // Playback requests park for replay until recovery resolves (see - // `replay_pending_start_playback`). Only enter the pending state when the - // recovery request was actually accepted. - if reselect_device { - self.current_playback_context = None; - } - - self.set_status_message("Native streaming disconnected; attempting recovery.", 8); - if let Some(tx) = &self.streaming_recovery_tx { - self.native_backend_pending = tx - .send(crate::infra::player::StreamingRecoveryRequest { - reselect_device, - restore_playback: true, - continue_after_track: None, - }) - .is_ok(); - } else { - self.native_backend_pending = false; - } - self.dispatch(IoEvent::GetCurrentPlayback); - } - - /// Park a StartPlayback request (string form, as `IoEvent::StartPlayback` - /// carries it) for replay once a usable backend exists. A newer request - /// replaces an older one — the user's latest intent wins. - #[cfg(feature = "streaming")] - pub fn park_start_playback( - &mut self, - context_uri: Option, - uris: Option>, - offset: Option, - ) { - let same_request = self.pending_start_playback.as_ref().is_some_and(|pending| { - pending.context_uri == context_uri && pending.uris == uris && pending.offset == offset - }); - if !same_request { - self.pending_start_playback = Some(PendingStartPlayback { - context_uri, - uris, - offset, - parked_at: Instant::now(), - recovery_attempts: 0, - }); - } - } - - /// Replay a parked StartPlayback through the normal dispatch path. No-op - /// when nothing is parked. - #[cfg(feature = "streaming")] - pub fn replay_pending_start_playback(&mut self) { - const MAX_PARKED_AGE: Duration = Duration::from_secs(30); - let Some(pending) = self.pending_start_playback.clone() else { - return; - }; - if pending.parked_at.elapsed() > MAX_PARKED_AGE { - self.pending_start_playback = None; - self.set_status_message("Playback request expired during native recovery.", 6); - return; - } - self.set_status_message("Resuming playback request…", 4); - self.dispatch(IoEvent::StartPlayback( - pending.context_uri, - pending.uris, - pending.offset, - )); - } - - #[cfg(feature = "streaming")] - pub fn mark_native_streaming_device_available( - &mut self, - device_id: String, - device_name: String, - volume_percent: u8, - ) { - self.native_device_id = Some(device_id.clone()); - self.is_streaming_active = true; - self.native_activation_pending = false; - // Only the polled "no active playback" answer reaches this, and that answer - // is structurally wrong while a queued Spotify track plays: the queue starts - // it with a direct `player.load`, which never enters Spirc's Connect state, - // so the Web API reports the device idle over audible playback. Clearing the - // play state here drew a paused playbar over that audio, and re-cleared it - // on every poll after the user pressed play. The player's own events own - // this field while the queue slot holds the sink. - if !self.queue_now_is_spotify() { - self.native_is_playing = Some(false); - } - - if self - .current_playback_context - .as_ref() - .and_then(|ctx| ctx.item.as_ref()) - .is_some() - { - return; - } - - self.current_playback_context = Some(CurrentPlaybackContext { - device: Device { - id: Some(device_id), - is_active: true, - is_private_session: false, - is_restricted: false, - name: device_name, - _type: DeviceType::Computer, - volume_percent: Some(u32::from(volume_percent)), - }, - repeat_state: RepeatState::Off, - shuffle_state: self.runtime_state.shuffle_enabled, - context: None, - timestamp: Utc::now(), - progress: None, - is_playing: false, - item: None, - currently_playing_type: CurrentlyPlayingType::Unknown, - actions: Actions::default(), - }); - } - - #[cfg(feature = "streaming")] - pub fn has_fresh_native_activity(&self) -> bool { - self.native_track_info.is_some() - || self.native_is_playing == Some(true) - || self - .last_device_activation - .is_some_and(|instant| instant.elapsed() < FRESH_NATIVE_ACTIVITY_WINDOW) - } -} - -#[cfg(all(test, feature = "streaming"))] -mod tests { - use super::*; - - #[cfg(feature = "streaming")] - #[test] - fn parked_playback_retries_are_keyed_to_the_request() { - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.park_start_playback( - Some("spotify:playlist:first".to_string()), - Some(vec!["spotify:track:first".to_string()]), - Some(1), - ); - app - .pending_start_playback - .as_mut() - .unwrap() - .recovery_attempts = 2; - - app.park_start_playback( - Some("spotify:playlist:first".to_string()), - Some(vec!["spotify:track:first".to_string()]), - Some(1), - ); - assert_eq!( - app - .pending_start_playback - .as_ref() - .unwrap() - .recovery_attempts, - 2 - ); - - app.park_start_playback(Some("spotify:playlist:second".to_string()), None, None); - assert_eq!( - app - .pending_start_playback - .as_ref() - .unwrap() - .recovery_attempts, - 0 - ); - } - - #[cfg(feature = "streaming")] - #[test] - fn fresh_native_activity_is_true_when_native_metadata_exists() { - let mut app = App { - native_track_info: Some(NativeTrackInfo::default()), - ..Default::default() - }; - - assert!(app.has_fresh_native_activity()); - - app.native_track_info = None; - assert!(!app.has_fresh_native_activity()); - } - - #[cfg(feature = "streaming")] - #[test] - fn fresh_native_activity_is_true_when_native_is_playing() { - let app = App { - native_is_playing: Some(true), - ..Default::default() - }; - - assert!(app.has_fresh_native_activity()); - } - - #[cfg(feature = "streaming")] - #[test] - fn fresh_native_activity_uses_recent_activation_window() { - let mut app = App { - last_device_activation: Some(Instant::now()), - ..Default::default() - }; - - assert!(app.has_fresh_native_activity()); - - app.last_device_activation = Some(Instant::now() - Duration::from_secs(6)); - - assert!(!app.has_fresh_native_activity()); - } - - /// The polled "device is idle" answer is the only caller, and it cannot see a - /// queued Spotify track: the queue starts it with a direct `player.load`, so - /// the Web API reports the device idle over audible playback. Believing it - /// drew a paused playbar over that audio, and re-drew it every poll after the - /// user pressed play. - #[cfg(feature = "streaming")] - #[test] - fn marking_the_device_available_keeps_the_queue_slots_play_state() { - use crate::core::app::test_support::queue_track; - use crate::infra::queue::QueueNowPlaying; - - let mut app = App { - native_is_playing: Some(true), - queue_now: Some(QueueNowPlaying::Spotify { - track: queue_track(Some("spotify:track:queued"), "Queued"), - }), - ..Default::default() - }; - - app.mark_native_streaming_device_available("device".to_string(), "spotatui".to_string(), 70); - - assert_eq!(app.native_is_playing, Some(true)); - assert!(app.is_streaming_active); - } - - /// With no queue slot the API's idle answer is trustworthy again. - #[cfg(feature = "streaming")] - #[test] - fn marking_the_device_available_clears_the_play_state_without_a_queue_slot() { - let mut app = App { - native_is_playing: Some(true), - ..Default::default() - }; - - app.mark_native_streaming_device_available("device".to_string(), "spotatui".to_string(), 70); - - assert_eq!(app.native_is_playing, Some(false)); - } -} diff --git a/src/core/app/native_recovery.rs b/src/core/app/native_recovery.rs deleted file mode 100644 index 62d3732d..00000000 --- a/src/core/app/native_recovery.rs +++ /dev/null @@ -1,733 +0,0 @@ -use super::*; - -/// Track identity for recovery matching: the bare id for `spotify::` -/// URIs, the full string otherwise. Delegates to [`base62_id_of`] so -/// `spotify:local:` URIs keep their full identity (their last segment is a -/// duration shared across unrelated local tracks). -#[cfg(feature = "streaming")] -fn spotify_item_identity(value: &str) -> &str { - base62_id_of(value) -} - -#[cfg(feature = "streaming")] -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct NativePlaybackRecoverySnapshot { - pub generation: u64, - pub context_uri: Option, - pub uris: Option>, - pub offset: Option, - pub current_track_uri: Option, - pub loading_track_uri: Option, - pub track_duration_ms: Option, - pub position_ms: u32, - pub desired_playing: bool, - pub shuffle: bool, - pub repeat: RepeatState, - pub recovery_attempts: u8, -} - -#[cfg(feature = "streaming")] -impl NativePlaybackRecoverySnapshot { - pub fn expected_track_uri(&self) -> Option<&str> { - self - .loading_track_uri - .as_deref() - .or(self.current_track_uri.as_deref()) - .or_else(|| { - let uris = self.uris.as_ref()?; - if self.context_uri.is_some() { - uris.first().map(String::as_str) - } else { - uris - .get(self.offset.unwrap_or(0)) - .or_else(|| uris.first()) - .map(String::as_str) - } - }) - } - - pub fn restore_position_ms(&self) -> u32 { - if self.loading_track_uri.is_some() { - 0 - } else { - self - .track_duration_ms - .map_or(self.position_ms, |duration_ms| { - if duration_ms == 0 { - self.position_ms - } else { - self.position_ms.min(duration_ms - 1) - } - }) - } - } - - pub fn completed_track_uri(&self) -> Option<&str> { - let duration_ms = self.track_duration_ms?; - if self.desired_playing - && self.loading_track_uri.is_none() - && duration_ms > 0 - && self.position_ms >= duration_ms - { - self.current_track_uri.as_deref() - } else { - None - } - } - - fn canonical_track_uri(&self, observed_track_id: &str, kind: Option) -> String { - if observed_track_id.starts_with("spotify:") { - return observed_track_id.to_string(); - } - if let Some(uri) = self - .loading_track_uri - .iter() - .chain(self.current_track_uri.iter()) - .chain(self.uris.iter().flatten()) - .find(|uri| spotify_item_identity(uri) == spotify_item_identity(observed_track_id)) - { - return uri.clone(); - } - - let kind = kind.unwrap_or_else(|| { - if self - .current_track_uri - .as_deref() - .is_some_and(|uri| uri.starts_with("spotify:episode:")) - { - NativeTrackKind::Episode - } else { - NativeTrackKind::Track - } - }); - let item_type = match kind { - NativeTrackKind::Track => "track", - NativeTrackKind::Episode => "episode", - }; - format!("spotify:{item_type}:{observed_track_id}") - } - - fn update_offset_for_track(&mut self, track_uri: &str) { - let Some(uris) = self.uris.as_ref() else { - return; - }; - if let Some(index) = uris - .iter() - .position(|uri| spotify_item_identity(uri) == spotify_item_identity(track_uri)) - { - self.offset = Some(index); - } - } - - pub(super) fn next_raw_list_request( - &self, - previous_track_uri: &str, - ) -> Option { - if self.context_uri.is_some() { - return None; - } - let uris = self.uris.as_ref()?; - if uris.is_empty() { - return None; - } - - let current_index = uris - .iter() - .position(|uri| spotify_item_identity(uri) == spotify_item_identity(previous_track_uri)) - .or(self.offset) - .unwrap_or(0) - .min(uris.len() - 1); - let next_index = match self.repeat { - RepeatState::Track => current_index, - RepeatState::Context if current_index + 1 >= uris.len() => 0, - _ if current_index + 1 < uris.len() => current_index + 1, - _ => return None, - }; - - Some(PendingStartPlayback { - context_uri: None, - uris: Some(uris.clone()), - offset: Some(next_index), - parked_at: Instant::now(), - recovery_attempts: 0, - }) - } -} - -#[cfg(feature = "streaming")] -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct NativePlaybackRestoreAttempt { - pub generation: u64, - pub expected_track_uri: Option, - pub desired_playing: bool, -} - -impl App { - #[cfg(feature = "streaming")] - fn next_native_playback_generation(&mut self) -> u64 { - self.native_playback_generation = self.native_playback_generation.wrapping_add(1); - self.native_playback_generation - } - - #[cfg(feature = "streaming")] - pub(crate) fn record_native_playback_request( - &mut self, - context_uri: Option, - uris: Option>, - offset: Option, - desired_playing: bool, - shuffle: bool, - repeat: RepeatState, - ) -> u64 { - let generation = self.next_native_playback_generation(); - let current_track_uri = uris.as_ref().and_then(|items| { - if context_uri.is_some() { - items.first().cloned() - } else { - items - .get(offset.unwrap_or(0)) - .or_else(|| items.first()) - .cloned() - } - }); - self.native_playback_recovery = Some(NativePlaybackRecoverySnapshot { - generation, - context_uri, - uris, - offset, - current_track_uri, - loading_track_uri: None, - track_duration_ms: None, - position_ms: 0, - desired_playing, - shuffle, - repeat, - recovery_attempts: 0, - }); - self.native_restore_pending = None; - self.native_load_watchdog = None; - generation - } - - #[cfg(feature = "streaming")] - pub(crate) fn set_native_playback_intent(&mut self, desired_playing: bool) { - if self.native_playback_recovery.is_none() { - self.native_restore_pending = None; - return; - } - let generation = self.next_native_playback_generation(); - if let Some(snapshot) = self.native_playback_recovery.as_mut() { - snapshot.generation = generation; - snapshot.desired_playing = desired_playing; - snapshot.recovery_attempts = 0; - } - self.native_restore_pending = None; - self.native_load_watchdog = None; - } - - #[cfg(feature = "streaming")] - pub(crate) fn set_native_recovery_position(&mut self, position_ms: u32) { - if let Some(snapshot) = self.native_playback_recovery.as_mut() { - snapshot.position_ms = position_ms; - } - } - - #[cfg(feature = "streaming")] - pub(crate) fn set_native_recovery_shuffle(&mut self, shuffle: bool) { - if let Some(snapshot) = self.native_playback_recovery.as_mut() { - snapshot.shuffle = shuffle; - } - } - - #[cfg(feature = "streaming")] - pub(crate) fn set_native_recovery_repeat(&mut self, repeat: RepeatState) { - if let Some(snapshot) = self.native_playback_recovery.as_mut() { - snapshot.repeat = repeat; - } - } - - #[cfg(feature = "streaming")] - pub(crate) fn prepare_native_playback_recovery( - &mut self, - position_ms: u32, - is_playing: bool, - ) -> Option { - if self.native_playback_recovery.is_none() { - let context_uri = self - .current_playback_context - .as_ref() - .and_then(|ctx| ctx.context.as_ref()) - .map(|ctx| ctx.uri.clone()); - let current_track_uri = self - .current_playback_context - .as_ref() - .and_then(|ctx| ctx.item.as_ref()) - .and_then(|item| match item { - PlayableItem::Track(track) => track.id.as_ref().map(|id| id.uri()), - PlayableItem::Episode(episode) => Some(episode.id.uri()), - PlayableItem::Unknown(_) => None, - }) - .or_else(|| { - self.last_track_id.as_ref().map(|id| { - if id.starts_with("spotify:") { - id.clone() - } else { - let item_type = match self.native_track_info.as_ref().map(|info| info.kind) { - Some(NativeTrackKind::Episode) => "episode", - _ => "track", - }; - format!("spotify:{item_type}:{id}") - } - }) - }); - if context_uri.is_some() || current_track_uri.is_some() { - let uris = context_uri - .is_none() - .then(|| current_track_uri.clone().into_iter().collect::>()); - let generation = self.next_native_playback_generation(); - self.native_playback_recovery = Some(NativePlaybackRecoverySnapshot { - generation, - context_uri, - uris, - offset: Some(0), - current_track_uri, - loading_track_uri: None, - track_duration_ms: self.native_track_info.as_ref().map(|info| info.duration_ms), - position_ms, - desired_playing: is_playing, - shuffle: self.runtime_state.shuffle_enabled, - repeat: self - .current_playback_context - .as_ref() - .map_or(RepeatState::Off, |ctx| ctx.repeat_state), - recovery_attempts: 0, - }); - } - } - - let track_duration_ms = self.native_track_info.as_ref().map(|info| info.duration_ms); - let snapshot = self.native_playback_recovery.as_mut()?; - snapshot.position_ms = position_ms; - if snapshot.loading_track_uri.is_none() && snapshot.track_duration_ms.is_none() { - snapshot.track_duration_ms = track_duration_ms; - } - Some(snapshot.generation) - } - - #[cfg(feature = "streaming")] - pub(crate) fn begin_native_playback_restore( - &mut self, - generation: u64, - ) -> Option { - let snapshot = self.native_playback_recovery.as_mut()?; - if snapshot.generation != generation { - return None; - } - snapshot.recovery_attempts = snapshot.recovery_attempts.saturating_add(1); - let attempt = NativePlaybackRestoreAttempt { - generation, - expected_track_uri: snapshot.expected_track_uri().map(str::to_string), - desired_playing: snapshot.desired_playing, - }; - self.native_restore_pending = Some(attempt); - Some(snapshot.clone()) - } - - #[cfg(feature = "streaming")] - pub(crate) fn native_playback_restore_generation(&self) -> Option { - self - .native_playback_recovery - .as_ref() - .map(|snapshot| snapshot.generation) - } - - #[cfg(feature = "streaming")] - fn native_restore_event_matches(&self, track_uri: &str) -> bool { - self - .native_restore_pending - .as_ref() - .and_then(|attempt| attempt.expected_track_uri.as_deref()) - .is_none_or(|expected| spotify_item_identity(expected) == spotify_item_identity(track_uri)) - } - - #[cfg(feature = "streaming")] - pub(crate) fn observe_native_loading(&mut self, track_uri: String, position_ms: u32) { - if self.native_restore_pending.is_some() && !self.native_restore_event_matches(&track_uri) { - log::warn!( - "ignoring stale native Loading event during restore: expected {:?}, got {}", - self - .native_restore_pending - .as_ref() - .and_then(|attempt| attempt.expected_track_uri.as_deref()), - track_uri - ); - return; - } - let canonical_track_uri = self - .native_playback_recovery - .as_ref() - .map_or(track_uri.clone(), |snapshot| { - snapshot.canonical_track_uri(&track_uri, None) - }); - if let Some(snapshot) = self.native_playback_recovery.as_mut() { - snapshot.loading_track_uri = Some(canonical_track_uri); - snapshot.track_duration_ms = None; - snapshot.position_ms = position_ms; - } - } - - #[cfg(feature = "streaming")] - pub(crate) fn observe_native_track_changed( - &mut self, - track_uri: String, - kind: NativeTrackKind, - duration_ms: u32, - ) { - if self.native_restore_pending.is_some() && !self.native_restore_event_matches(&track_uri) { - log::warn!( - "ignoring stale native TrackChanged event during restore: expected {:?}, got {}", - self - .native_restore_pending - .as_ref() - .and_then(|attempt| attempt.expected_track_uri.as_deref()), - track_uri - ); - return; - } - let canonical_track_uri = self - .native_playback_recovery - .as_ref() - .map_or(track_uri.clone(), |snapshot| { - snapshot.canonical_track_uri(&track_uri, Some(kind)) - }); - if let Some(snapshot) = self.native_playback_recovery.as_mut() { - snapshot.current_track_uri = Some(canonical_track_uri.clone()); - snapshot.loading_track_uri = None; - snapshot.track_duration_ms = Some(duration_ms); - snapshot.position_ms = 0; - snapshot.update_offset_for_track(&canonical_track_uri); - } - } - - #[cfg(feature = "streaming")] - pub(crate) fn observe_native_playback_state( - &mut self, - track_uri: String, - position_ms: u32, - is_playing: bool, - ) -> bool { - let restore_matches = self.native_restore_event_matches(&track_uri); - let restore_confirmed = self.native_restore_pending.as_ref().is_some_and(|attempt| { - attempt.generation - == self - .native_playback_recovery - .as_ref() - .map_or(u64::MAX, |snapshot| snapshot.generation) - && restore_matches - && attempt.desired_playing == is_playing - }); - let canonical_track_uri = self - .native_playback_recovery - .as_ref() - .map_or(track_uri.clone(), |snapshot| { - snapshot.canonical_track_uri(&track_uri, None) - }); - - if self.native_restore_pending.is_none() || restore_matches { - if let Some(snapshot) = self.native_playback_recovery.as_mut() { - snapshot.current_track_uri = Some(canonical_track_uri.clone()); - snapshot.loading_track_uri = None; - snapshot.position_ms = position_ms; - snapshot.update_offset_for_track(&canonical_track_uri); - // A transport failure can emit Paused immediately before the event - // stream closes. Keep desired intent independent from observed state: - // explicit pause controls update it before issuing the command, while - // a successful Playing event may safely confirm play intent. - if self.native_restore_pending.is_none() && is_playing { - snapshot.desired_playing = true; - } - if restore_confirmed { - snapshot.recovery_attempts = 0; - } - } - } - - if restore_confirmed { - self.native_restore_pending = None; - self.native_load_watchdog = None; - } - restore_confirmed - } - - #[cfg(feature = "streaming")] - pub(crate) fn native_raw_list_next_request( - &self, - previous_track_uri: &str, - ) -> Option { - let snapshot = self.native_playback_recovery.as_ref()?; - if snapshot - .loading_track_uri - .as_deref() - .is_some_and(|loading| { - spotify_item_identity(loading) != spotify_item_identity(previous_track_uri) - }) - || snapshot - .current_track_uri - .as_deref() - .is_some_and(|current| { - spotify_item_identity(current) != spotify_item_identity(previous_track_uri) - }) - { - return None; - } - snapshot.next_raw_list_request(previous_track_uri) - } - - /// True when native playback is a raw URI list (no context) that has no track - /// after `previous_track_uri`: the list ran out with repeat off. Ending here - /// is a legitimate stop, not a stall. - #[cfg(feature = "streaming")] - pub(crate) fn native_raw_list_playback_exhausted(&self, previous_track_uri: &str) -> bool { - let Some(snapshot) = self.native_playback_recovery.as_ref() else { - return false; - }; - snapshot.context_uri.is_none() - && snapshot.uris.as_ref().is_some_and(|uris| !uris.is_empty()) - && !self.native_transition_has_advanced(previous_track_uri) - && self - .native_raw_list_next_request(previous_track_uri) - .is_none() - } - - #[cfg(feature = "streaming")] - pub(crate) fn native_transition_has_advanced(&self, previous_track_uri: &str) -> bool { - let Some(snapshot) = self.native_playback_recovery.as_ref() else { - return false; - }; - snapshot - .loading_track_uri - .as_deref() - .is_some_and(|loading| { - spotify_item_identity(loading) != spotify_item_identity(previous_track_uri) - }) - || snapshot - .current_track_uri - .as_deref() - .is_some_and(|current| { - spotify_item_identity(current) != spotify_item_identity(previous_track_uri) - }) - } - - #[cfg(feature = "streaming")] - pub(crate) fn clear_native_playback_recovery(&mut self) { - self.native_playback_recovery = None; - self.native_restore_pending = None; - self.native_load_watchdog = None; - } -} - -#[cfg(all(test, feature = "streaming"))] -mod tests { - use super::*; - - #[cfg(feature = "streaming")] - #[test] - fn native_recovery_prefers_in_flight_loading_track() { - let mut app = App::default(); - let generation = app.record_native_playback_request( - None, - Some(vec![ - "spotify:track:current".to_string(), - "spotify:track:next".to_string(), - ]), - Some(0), - true, - false, - RepeatState::Off, - ); - app.observe_native_playback_state("current".to_string(), 175_000, true); - app.observe_native_loading("next".to_string(), 0); - - let snapshot = app.begin_native_playback_restore(generation).unwrap(); - - assert_eq!(snapshot.expected_track_uri(), Some("spotify:track:next")); - assert_eq!(snapshot.restore_position_ms(), 0); - assert!(snapshot.desired_playing); - } - - #[cfg(feature = "streaming")] - #[test] - fn stale_native_event_does_not_confirm_restore_generation() { - let mut app = App::default(); - let generation = app.record_native_playback_request( - None, - Some(vec!["spotify:track:expected".to_string()]), - Some(0), - true, - false, - RepeatState::Off, - ); - app.begin_native_playback_restore(generation).unwrap(); - - assert!(!app.observe_native_playback_state("spotify:track:stale".to_string(), 0, true)); - assert!(app.native_restore_pending.is_some()); - - assert!(app.observe_native_playback_state("expected".to_string(), 12_000, true)); - assert!(app.native_restore_pending.is_none()); - assert_eq!( - app.native_playback_recovery.as_ref().unwrap().position_ms, - 12_000 - ); - } - - #[cfg(feature = "streaming")] - #[test] - fn transport_pause_does_not_overwrite_explicit_play_intent() { - let mut app = App::default(); - app.record_native_playback_request( - None, - Some(vec!["spotify:track:current".to_string()]), - Some(0), - true, - false, - RepeatState::Off, - ); - - app.observe_native_playback_state("current".to_string(), 20_000, false); - app.prepare_native_playback_recovery(20_000, false); - - assert!( - app - .native_playback_recovery - .as_ref() - .unwrap() - .desired_playing - ); - - app.set_native_playback_intent(false); - assert!( - !app - .native_playback_recovery - .as_ref() - .unwrap() - .desired_playing - ); - } - - #[cfg(feature = "streaming")] - #[test] - fn completed_track_during_transport_loss_is_detected_for_recovery() { - let mut app = App::default(); - app.record_native_playback_request( - None, - Some(vec![ - "spotify:track:finished".to_string(), - "spotify:track:next".to_string(), - ]), - Some(0), - true, - false, - RepeatState::Off, - ); - app.observe_native_track_changed("finished".to_string(), NativeTrackKind::Track, 389_094); - - app.prepare_native_playback_recovery(395_562, false); - - assert_eq!( - app - .native_playback_recovery - .as_ref() - .and_then(NativePlaybackRecoverySnapshot::completed_track_uri), - Some("spotify:track:finished") - ); - } - - #[cfg(feature = "streaming")] - #[test] - fn native_raw_list_next_request_matches_uri_and_base62_forms() { - let mut app = App::default(); - app.record_native_playback_request( - None, - Some(vec![ - "spotify:track:first".to_string(), - "spotify:track:second".to_string(), - ]), - Some(0), - true, - false, - RepeatState::Off, - ); - - let request = app.native_raw_list_next_request("first").unwrap(); - - assert_eq!(request.offset, Some(1)); - assert_eq!( - request.uris.as_deref(), - Some( - [ - "spotify:track:first".to_string(), - "spotify:track:second".to_string(), - ] - .as_slice() - ) - ); - } - - #[cfg(feature = "streaming")] - fn raw_list_app(offset: usize, repeat: RepeatState) -> App { - let mut app = App::default(); - app.record_native_playback_request( - None, - Some(vec![ - "spotify:track:first".to_string(), - "spotify:track:second".to_string(), - ]), - Some(offset), - true, - false, - repeat, - ); - app - } - - #[cfg(feature = "streaming")] - #[test] - fn raw_list_is_exhausted_after_the_last_track_with_repeat_off() { - let app = raw_list_app(1, RepeatState::Off); - assert!(app.native_raw_list_playback_exhausted("second")); - } - - #[cfg(feature = "streaming")] - #[test] - fn raw_list_is_not_exhausted_mid_list_or_while_repeating() { - let app = raw_list_app(0, RepeatState::Off); - assert!(!app.native_raw_list_playback_exhausted("first")); - - let app = raw_list_app(1, RepeatState::Context); - assert!(!app.native_raw_list_playback_exhausted("second")); - - let app = raw_list_app(1, RepeatState::Track); - assert!(!app.native_raw_list_playback_exhausted("second")); - } - - #[cfg(feature = "streaming")] - #[test] - fn raw_list_is_not_exhausted_for_context_playback_or_advanced_transitions() { - let mut app = App::default(); - app.record_native_playback_request( - Some("spotify:playlist:ctx".to_string()), - Some(vec!["spotify:track:first".to_string()]), - Some(0), - true, - false, - RepeatState::Off, - ); - assert!(!app.native_raw_list_playback_exhausted("first")); - - // A transition onto another track means playback advanced; the list is in - // use, not exhausted. - let mut app = raw_list_app(1, RepeatState::Off); - app.observe_native_loading("spotify:track:other".to_string(), 0); - assert!(!app.native_raw_list_playback_exhausted("second")); - } -} diff --git a/src/core/app/native_shuffle.rs b/src/core/app/native_shuffle.rs deleted file mode 100644 index 284b1b6c..00000000 --- a/src/core/app/native_shuffle.rs +++ /dev/null @@ -1,375 +0,0 @@ -use super::*; - -/// App-owned play order for native-Spotify shuffle. When shuffle is on and a -/// Spotify context (playlist/album/Liked Songs) starts on the native streaming -/// device, the app builds this session and loads a flat, pre-shuffled URI list -/// into Spirc (`LoadRequest::from_tracks`) instead of delegating shuffle to -/// Spirc/Spotify — so every track plays exactly once per pass and a queue -/// suspend/resume never reshuffles the remaining order. -#[cfg(feature = "streaming")] -pub struct NativeSpotifyShuffleSession { - /// The URI list currently loaded into Spirc, in play order (shuffled while - /// [`Self::shuffled`] is true). - pub order: Vec, - /// The context's original order — grows to the full context when the - /// background fetch completes. Restored on shuffle-off. - pub original: Vec, - /// Index into `order` of the currently-playing track (synced from - /// `PlayerEvent::TrackChanged`). - pub index: usize, - /// Whether `order` is currently shuffled. - pub shuffled: bool, - /// False while the background full-context fetch is still running. - pub fetch_complete: bool, - /// The background full-context fetch failed for good (retries exhausted): - /// `order` never grew past its seed and the queue-suspend path must fall - /// back to the context route instead of a shuffled resume. - pub fetch_failed: bool, - /// Stamp guarding stale background fetches from writing into a newer session. - pub generation: u64, - /// Set to the index we just loaded into Spirc whenever the session issues a - /// `from_tracks` reload; consumed by the next `TrackChanged` so a reload is - /// confirmed in place rather than mistaken for a forward advance (which would - /// mis-map a duplicate track id onto a later occurrence). - pub pending_reload_index: Option, - /// Set when the user issues a manual skip so the next `TrackChanged` is read - /// as an explicit advance in that direction (`Some(true)` = Next, `Some(false)` - /// = Previous) rather than a repeat-one auto replay, which stays put. - /// Distinguishes an explicit skip to a duplicate track id from a replay of it - /// and resolves the duplicate in the skipped direction. - pub pending_manual_skip: Option, -} - -/// The bare base62 id of a `spotify::` URI. librespot 0.8's -/// `SpotifyUri` Display prints the full URI while every app-side comparison -/// uses the bare id, so player-event handlers normalize through this once at -/// the event boundary. Anything else passes through unchanged: a bare id, and -/// notably `spotify:local::::<duration>` URIs, whose -/// only unique identity is the full string (the last segment is a duration -/// shared across unrelated local tracks). -#[cfg_attr(not(feature = "streaming"), allow(dead_code))] -pub(crate) fn base62_id_of(uri_or_id: &str) -> &str { - let mut parts = uri_or_id.split(':'); - match (parts.next(), parts.next(), parts.next(), parts.next()) { - (Some("spotify"), Some(kind), Some(id), None) if kind != "local" => id, - _ => uri_or_id, - } -} - -/// Whether a `spotify:track:<id>` URI refers to the given bare base62 track id. -#[cfg_attr(not(feature = "streaming"), allow(dead_code))] -pub(crate) fn uri_matches_base62_id(uri: &str, base62_id: &str) -> bool { - base62_id_of(uri) == base62_id -} - -/// How playback moved to the track a `TrackChanged` reports, so the wrap-search -/// resolves a duplicate id to the right occurrence. -#[cfg_attr(not(feature = "streaming"), allow(dead_code))] -#[derive(Clone, Copy, PartialEq, Eq)] -pub(crate) enum ShuffleStep { - /// Sequential forward move (auto-advance or a manual Next): the next - /// occurrence at or after `prev + 1`. - Forward, - /// A manual Previous: the previous occurrence at or before `prev - 1`. - Backward, - /// A repeat-one auto replay: stay on the current occurrence (`prev` first). - Stay, -} - -/// Resolve which index of `order` a `TrackChanged` for `playing_base62_id` -/// refers to, given the previously-known index `prev` and how playback moved -/// ([`ShuffleStep`]). -/// -/// `prev` is always searched last, so a duplicate track resolves to the -/// upcoming (`Forward`) or previous (`Backward`) copy, while a unique-track -/// re-fire still lands on `prev`. `Stay` checks `prev` first, so a repeat-one -/// replay keeps its index. Returns `None` when the id is not in `order`. -#[cfg_attr(not(feature = "streaming"), allow(dead_code))] -pub(crate) fn shuffle_advance_index( - order: &[String], - prev: usize, - playing_base62_id: &str, - step: ShuffleStep, -) -> Option<usize> { - let len = order.len(); - if len == 0 { - return None; - } - let prev = prev.min(len - 1); - (0..len).find_map(|k| { - let i = match step { - ShuffleStep::Stay => (prev + k) % len, - ShuffleStep::Forward => (prev + 1 + k) % len, - ShuffleStep::Backward => (prev + len - 1 - k) % len, - }; - uri_matches_base62_id(&order[i], playing_base62_id).then_some(i) - }) -} - -impl App { - /// Drop the native-Spotify shuffle session and invalidate any in-flight - /// background fetch for it. - #[cfg(feature = "streaming")] - pub(crate) fn clear_native_shuffle_session(&mut self) { - self.native_shuffle_generation = self.native_shuffle_generation.wrapping_add(1); - self.native_spotify_shuffle = None; - } - - /// The generation stamp for a new shuffle session (also invalidates fetches - /// for any previous session). - #[cfg(feature = "streaming")] - pub(crate) fn next_native_shuffle_generation(&mut self) -> u64 { - self.native_shuffle_generation = self.native_shuffle_generation.wrapping_add(1); - self.native_shuffle_generation - } - - /// The current repeat state as the source-neutral [`RepeatMode`] used by the - /// queue engine's index math. - #[cfg(feature = "streaming")] - pub(crate) fn native_shuffle_repeat_mode(&self) -> crate::infra::queue::RepeatMode { - match self - .current_playback_context - .as_ref() - .map(|ctx| ctx.repeat_state) - { - Some(RepeatState::Context) => crate::infra::queue::RepeatMode::Context, - Some(RepeatState::Track) => crate::infra::queue::RepeatMode::Track, - _ => crate::infra::queue::RepeatMode::Off, - } - } - - /// Whether native playback is currently in the playing (not paused) state. - /// A client-side shuffle reload (`from_tracks`) must preserve this instead of - /// unconditionally starting playback, so toggling shuffle while paused does - /// not resume it. `native_is_playing` is the authoritative native state - /// (updated by player events); the context flag is only a fallback, matching - /// how the media-metadata snapshot reads it. - #[cfg(feature = "streaming")] - pub(crate) fn native_shuffle_is_playing(&self) -> bool { - self - .native_is_playing - .or_else(|| { - self - .current_playback_context - .as_ref() - .map(|ctx| ctx.is_playing) - }) - .unwrap_or(true) - } - - /// Flag the client-side shuffle session so the next `TrackChanged` is read as - /// an explicit skip (`forward` = Next, else Previous) rather than a repeat-one - /// auto replay. Called from the native Next/Previous transport paths before - /// driving Spirc, so a duplicate track resolves in the skipped direction. - #[cfg(feature = "streaming")] - pub(crate) fn mark_native_shuffle_manual_skip(&mut self, forward: bool) { - if let Some(session) = self.native_spotify_shuffle.as_mut() { - session.pending_manual_skip = Some(forward); - } - } - - /// Keep the shuffle session's index in step with what Spirc is actually - /// playing (called from `PlayerEvent::TrackChanged` with librespot's base62 - /// track id). Also detects a completed repeat-all lap (last track wrapping - /// back to the first) and dispatches a fresh lap reshuffle, matching - /// Spotify's own per-lap reshuffle behavior. - #[cfg(feature = "streaming")] - pub(crate) fn sync_native_shuffle_index(&mut self, playing_base62_id: &str) { - // While a queued track owns playback the session is suspended; a queued - // track that also appears in the playlist must not move the session index. - if self.queue_owns_playback() { - return; - } - // Repeat-one replays the current track in place (no reload), so an *auto* - // TrackChanged for it must keep the index on the current occurrence — but a - // *manual* skip (below) advances even under repeat-one. - let repeat_one = self.native_shuffle_repeat_mode() == crate::infra::queue::RepeatMode::Track; - let lap_wrapped = { - let Some(session) = self.native_spotify_shuffle.as_mut() else { - return; - }; - let len = session.order.len(); - if len == 0 { - return; - } - let prev = session.index.min(len - 1); - // A reload we just issued (`playing_track = Index(k)`) makes Spirc emit a - // TrackChanged for `order[k]`; confirm that index rather than treating it - // as a forward advance (which would mis-map a duplicate id to a later - // copy). A reload is never a lap wrap. This is checked before the skip - // logic even when a manual skip is also pending: Spirc emits the reload's - // event before the skip's, so confirming it here and leaving the skip flag - // for the skip's own (next) event keeps both in order. - // - // Residual, intentionally unhandled: a reload's confirmation event and a - // manual-skip event to an *adjacent duplicate* of the reloaded track are - // indistinguishable by track id, and whether Spirc emits one event or two - // is an internal timing race it does not expose. The mis-mapping is a - // wrong occurrence of the *same* track, negligible in practice (a human - // skip lands well after the reload event) and self-correcting on the next - // non-duplicate transition — so it is not worth sub-event bookkeeping. - if let Some(k) = session.pending_reload_index.take() { - if k < len && uri_matches_base62_id(&session.order[k], playing_base62_id) { - session.index = k; - return; - } - } - // A manual skip carries its direction and always advances (even under - // repeat-one); otherwise an auto replay stays put under repeat-one and a - // normal auto-advance moves forward. - let step = match session.pending_manual_skip.take() { - Some(true) => ShuffleStep::Forward, - Some(false) => ShuffleStep::Backward, - None if repeat_one => ShuffleStep::Stay, - None => ShuffleStep::Forward, - }; - let Some(new_index) = shuffle_advance_index(&session.order, prev, playing_base62_id, step) - else { - return; - }; - session.index = new_index; - session.shuffled && len > 1 && prev == len - 1 && new_index == 0 - }; - if lap_wrapped && self.native_shuffle_repeat_mode() == crate::infra::queue::RepeatMode::Context - { - self.dispatch(IoEvent::ReshuffleNativeShuffleLap); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn track_uris(ids: &[&str]) -> Vec<String> { - ids.iter().map(|id| format!("spotify:track:{id}")).collect() - } - - #[test] - fn shuffle_advance_moves_onto_the_next_track_including_a_duplicate() { - let order = track_uris(&["a", "b", "b", "c"]); - // From "a" (0) the next "b" is the occurrence at index 1. - assert_eq!( - shuffle_advance_index(&order, 0, "b", ShuffleStep::Forward), - Some(1) - ); - // Playing the *next* "b" (a sequential advance from index 1) must resolve - // to index 2, not stick on the current occurrence at index 1. - assert_eq!( - shuffle_advance_index(&order, 1, "b", ShuffleStep::Forward), - Some(2) - ); - assert_eq!( - shuffle_advance_index(&order, 2, "c", ShuffleStep::Forward), - Some(3) - ); - } - - #[test] - fn shuffle_advance_wraps_and_reports_missing_ids() { - let order = track_uris(&["a", "b", "c"]); - // Last -> first wrap (repeat-all lap). - assert_eq!( - shuffle_advance_index(&order, 2, "a", ShuffleStep::Forward), - Some(0) - ); - assert_eq!( - shuffle_advance_index(&order, 1, "z", ShuffleStep::Forward), - None - ); - assert_eq!( - shuffle_advance_index(&[], 0, "a", ShuffleStep::Forward), - None - ); - } - - #[test] - fn shuffle_advance_stays_on_current_for_repeat_one() { - let order = track_uris(&["a", "b", "a", "c"]); - // Repeat-one replays index 0's "a" in place; it must not jump to the - // duplicate "a" at index 2. - assert_eq!( - shuffle_advance_index(&order, 0, "a", ShuffleStep::Stay), - Some(0) - ); - // A unique track under repeat-one likewise keeps its index. - assert_eq!( - shuffle_advance_index(&order, 1, "b", ShuffleStep::Stay), - Some(1) - ); - } - - #[test] - fn shuffle_advance_backward_resolves_the_previous_duplicate() { - // Manual Previous through duplicates: from index 3 ("b"), Previous plays - // "a" and must resolve to index 2 (the previous occurrence), not index 0. - let order = track_uris(&["a", "b", "a", "b"]); - assert_eq!( - shuffle_advance_index(&order, 3, "a", ShuffleStep::Backward), - Some(2) - ); - // Forward from the same spot would instead find the earlier copy. - assert_eq!( - shuffle_advance_index(&order, 3, "a", ShuffleStep::Forward), - Some(0) - ); - // First -> last wrap going backward. - assert_eq!( - shuffle_advance_index(&track_uris(&["a", "b", "c"]), 0, "c", ShuffleStep::Backward), - Some(2) - ); - } - - #[test] - fn base62_id_of_strips_the_uri_prefix() { - assert_eq!( - base62_id_of("spotify:track:6q02DsdnysAHuwmxFuI20c"), - "6q02DsdnysAHuwmxFuI20c" - ); - assert_eq!(base62_id_of("spotify:episode:abc"), "abc"); - // A bare id passes through unchanged. - assert_eq!( - base62_id_of("6q02DsdnysAHuwmxFuI20c"), - "6q02DsdnysAHuwmxFuI20c" - ); - // A Spotify local-track URI's only unique identity is the full string; - // stripping to the last segment would collide tracks by duration. - assert_eq!( - base62_id_of("spotify:local:Artist:Album:Title:213"), - "spotify:local:Artist:Album:Title:213" - ); - } - - /// Regression for the frozen shuffle index: librespot 0.8's `TrackChanged` - /// reports the full `spotify:track:<id>` URI, and feeding that through - /// unnormalized made every order lookup miss, so `session.index` stayed at 0 - /// for the whole session. The event boundary now normalizes to the bare id; - /// an auto advance must move the index to the playing track. - #[cfg(feature = "streaming")] - #[test] - fn sync_native_shuffle_index_advances_on_auto_track_changed() { - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.native_spotify_shuffle = Some(NativeSpotifyShuffleSession { - order: vec![ - "spotify:track:a".to_string(), - "spotify:track:b".to_string(), - "spotify:track:c".to_string(), - ], - original: Vec::new(), - index: 0, - shuffled: true, - fetch_complete: true, - fetch_failed: false, - generation: 1, - pending_reload_index: None, - pending_manual_skip: None, - }); - - // What the event boundary hands over for librespot's full-URI form. - let playing_uri = "spotify:track:b".to_string(); - app.sync_native_shuffle_index(base62_id_of(&playing_uri)); - - assert_eq!(app.native_spotify_shuffle.as_ref().unwrap().index, 1); - } -} diff --git a/src/core/app/party.rs b/src/core/app/party.rs deleted file mode 100644 index 0439af33..00000000 --- a/src/core/app/party.rs +++ /dev/null @@ -1,97 +0,0 @@ -use super::*; - -const PARTY_NEEDS_SPOTIFY: &str = - "Listening Party needs Spotify. Press `d` and pick Spotify to log in."; - -impl App { - /// Host a party; needs a Spotify session, the relay drives Spotify playback. - pub(crate) fn start_party(&mut self) { - if !self.spotify_connected { - self.set_status_message(PARTY_NEEDS_SPOTIFY, 6); - return; - } - self.dispatch(IoEvent::StartParty(ControlMode::HostOnly)); - } - - /// Join a party; same session requirement as `start_party`. - pub(crate) fn join_party(&mut self, code: String, name: String) { - if !self.spotify_connected { - self.set_status_message(PARTY_NEEDS_SPOTIFY, 6); - return; - } - self.dispatch(IoEvent::JoinParty { code, name }); - // The typed code and name are consumed only by a join that went out. - self.view.party_input.clear(); - self.view.party_input_idx = 0; - self.view.party_join_name.clear(); - } - - /// The local write is optimistic on purpose: the relay handler never writes - /// the session back, so the popup's "Control" label renders from it. - pub(crate) fn toggle_party_control_mode(&mut self) { - let Some(session) = &mut self.party_session else { - return; - }; - let updated_mode = match session.control_mode { - ControlMode::HostOnly => ControlMode::SharedControl, - ControlMode::SharedControl => ControlMode::HostOnly, - }; - session.control_mode = updated_mode.clone(); - self.dispatch(IoEvent::SetPartyControlMode(updated_mode)); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::action::Action; - use crate::core::app::test_support::*; - - #[test] - fn start_party_without_a_session_dispatches_nothing_and_says_why() { - let (mut app, rx) = session_free_app(); - - app.apply(Action::StartParty); - - assert!(rx.try_recv().is_err()); - assert_eq!(app.status_message.as_deref(), Some(PARTY_NEEDS_SPOTIFY)); - } - - #[test] - fn join_party_without_a_session_dispatches_nothing_and_keeps_the_input() { - let (mut app, rx) = session_free_app(); - app.view.party_input = "ABC123".chars().collect(); - app.view.party_input_idx = 6; - app.view.party_join_name = "Guest".chars().collect(); - - app.apply(Action::JoinParty { - code: "ABC123".to_string(), - name: "Guest".to_string(), - }); - - assert!(rx.try_recv().is_err()); - assert_eq!(app.status_message.as_deref(), Some(PARTY_NEEDS_SPOTIFY)); - assert_eq!(app.view.party_input.iter().collect::<String>(), "ABC123"); - assert_eq!(app.view.party_input_idx, 6); - assert_eq!(app.view.party_join_name.iter().collect::<String>(), "Guest"); - } - - #[test] - fn join_party_with_a_session_dispatches_and_clears_the_input() { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.view.party_input = "ABC123".chars().collect(); - app.view.party_input_idx = 6; - app.view.party_join_name = "Guest".chars().collect(); - - app.apply(Action::JoinParty { - code: "ABC123".to_string(), - name: "Guest".to_string(), - }); - - assert!(matches!(rx.try_recv(), Ok(IoEvent::JoinParty { .. }))); - assert!(app.view.party_input.is_empty()); - assert_eq!(app.view.party_input_idx, 0); - assert!(app.view.party_join_name.is_empty()); - } -} diff --git a/src/core/app/persistence.rs b/src/core/app/persistence.rs deleted file mode 100644 index 7aa7e85c..00000000 --- a/src/core/app/persistence.rs +++ /dev/null @@ -1,565 +0,0 @@ -use super::*; - -/// Debounce window for coalescing hot-path runtime-state saves (volume, -/// shuffle) into one disk write, and the retry delay after a failed save. -const STATE_SAVE_DEBOUNCE_MS: u64 = 500; - -/// Ungated copy of the `radio:` scheme: favoriting persists stations in -/// builds without the radio player too. -const RADIO_URI_PREFIX: &str = "radio:"; - -/// The trimmed stream URL behind a `radio:` URI. -fn radio_stream_url(uri: &str) -> Option<&str> { - uri - .strip_prefix(RADIO_URI_PREFIX) - .map(str::trim) - .filter(|url| !url.is_empty()) -} - -impl App { - /// Snapshot the currently-playing non-Spotify session for persistence, or - /// `None` when Spotify (or nothing) owns playback. Reads the live position and - /// pause state straight from whichever source's player is active, mirroring - /// the source-ownership order the runner tick uses. Spotify playback is not - /// persisted here — its resume is handled by `startup_behavior` device logic. - /// The resume point (`resume_index`, `resume_position_ms`) recorded when the - /// active decoded context was suspended under the native queue, or `None` when - /// no decoded context is suspended. Only one context is ever active, so this - /// unambiguously describes it. - #[cfg(feature = "audio-decode-queue")] - fn suspended_resume(&self) -> Option<(Option<usize>, u64)> { - match self.queue_suspended.as_ref()? { - #[cfg(feature = "local-files")] - crate::core::queue::SuspendedContext::Local { - resume_index, - resume_position_ms, - } => Some((*resume_index, *resume_position_ms)), - #[cfg(feature = "subsonic")] - crate::core::queue::SuspendedContext::Subsonic { - resume_index, - resume_position_ms, - } => Some((*resume_index, *resume_position_ms)), - #[cfg(feature = "qobuz")] - crate::core::queue::SuspendedContext::Qobuz { - resume_index, - resume_position_ms, - } => Some((*resume_index, *resume_position_ms)), - #[cfg(feature = "youtube")] - crate::core::queue::SuspendedContext::YouTube { - resume_index, - resume_position_ms, - } => Some((*resume_index, *resume_position_ms)), - #[allow(unreachable_patterns)] - _ => None, - } - } - - pub fn current_persisted_playback( - &self, - ) -> Option<crate::core::persisted_playback::PersistedPlayback> { - if !self.has_persistable_playback() { - return None; - } - #[cfg(feature = "audio-decode")] - use crate::core::persisted_playback::PersistedPlayback; - #[cfg(feature = "youtube")] - if let Some(s) = self.youtube_playback.as_ref() { - // While suspended under the queue, the context's player is playing a - // *queued* track, so read the resume point from `queue_suspended` instead - // of the (repurposed) live player. A `None` resume_index means the context - // was exhausted — don't persist it (the queue itself still persists). - match self.suspended_resume() { - Some((Some(index), position_ms)) => { - return Some(PersistedPlayback::YouTube { - tracks: s.tracks.clone(), - index, - position_ms, - paused: false, - repeat: self.decoded_repeat, - shuffle_on: self.decoded_shuffle, - shuffle: s.shuffle_backup.clone(), - }); - } - Some((None, _)) => {} - None => { - return Some(PersistedPlayback::YouTube { - tracks: s.tracks.clone(), - index: s.index, - position_ms: s.player.position().as_millis() as u64, - paused: s.player.is_paused(), - repeat: self.decoded_repeat, - shuffle_on: self.decoded_shuffle, - shuffle: s.shuffle_backup.clone(), - }); - } - } - } - #[cfg(feature = "subsonic")] - if let Some(s) = self.subsonic_playback.as_ref() { - match self.suspended_resume() { - Some((Some(index), position_ms)) => { - return Some(PersistedPlayback::Subsonic { - tracks: s.tracks.clone(), - index, - position_ms, - paused: false, - repeat: self.decoded_repeat, - shuffle_on: self.decoded_shuffle, - shuffle: s.shuffle_backup.clone(), - }); - } - Some((None, _)) => {} - None => { - return Some(PersistedPlayback::Subsonic { - tracks: s.tracks.clone(), - index: s.index, - position_ms: s.player.position().as_millis() as u64, - paused: s.player.is_paused(), - repeat: self.decoded_repeat, - shuffle_on: self.decoded_shuffle, - shuffle: s.shuffle_backup.clone(), - }); - } - } - } - #[cfg(feature = "qobuz")] - if let Some(s) = self.qobuz_playback.as_ref() { - match self.suspended_resume() { - Some((Some(index), position_ms)) => { - return Some(PersistedPlayback::Qobuz { - tracks: s.tracks.clone(), - index, - position_ms, - paused: false, - repeat: self.decoded_repeat, - shuffle_on: self.decoded_shuffle, - shuffle: s.shuffle_backup.clone(), - }); - } - Some((None, _)) => {} - None => { - return Some(PersistedPlayback::Qobuz { - tracks: s.tracks.clone(), - index: s.index, - position_ms: s.player.position().as_millis() as u64, - paused: s.player.is_paused(), - repeat: self.decoded_repeat, - shuffle_on: self.decoded_shuffle, - shuffle: s.shuffle_backup.clone(), - }); - } - } - } - #[cfg(feature = "local-files")] - if let Some(s) = self.local_playback.as_ref() { - match self.suspended_resume() { - Some((Some(index), position_ms)) => { - return Some(PersistedPlayback::Local { - queue: s.queue.clone(), - index, - position_ms, - paused: false, - repeat: self.decoded_repeat, - shuffle_on: self.decoded_shuffle, - shuffle: s.shuffle_backup.clone(), - }); - } - Some((None, _)) => {} - None => { - return Some(PersistedPlayback::Local { - queue: s.queue.clone(), - index: s.index, - position_ms: s.player.position().as_millis() as u64, - paused: s.player.is_paused(), - repeat: self.decoded_repeat, - shuffle_on: self.decoded_shuffle, - shuffle: s.shuffle_backup.clone(), - }); - } - } - } - #[cfg(feature = "internet-radio")] - if let Some(s) = self.radio_playback.as_ref() { - return Some(PersistedPlayback::Radio { - station: s.station.clone(), - paused: s.player.is_paused(), - }); - } - None - } - - /// Snapshot the full session to persist: the active non-Spotify playback (if - /// any) plus the native queue. Returns `None` only when there is nothing to - /// save (no active source *and* an empty queue), so the caller clears the - /// session file — preserving the existing Some→None clear semantics. - pub fn current_persisted_session( - &self, - ) -> Option<crate::core::persisted_playback::PersistedSession> { - let playback = self.current_persisted_playback(); - let queue_now_track = self.queue_now_track(); - if playback.is_none() && self.native_queue.is_empty() && queue_now_track.is_none() { - return None; - } - // A track playing through the queue slot has been popped off `native_queue`; - // prepend it so a mid-queue quit resumes it (it re-enters the queue on the - // next launch). - let mut queue = self.native_queue.clone(); - if let Some(track) = queue_now_track { - queue.insert(0, track.clone()); - } - Some(crate::core::persisted_playback::PersistedSession { playback, queue }) - } - - /// Cheap presence check for `current_persisted_session`: same decision - /// logic, no snapshot allocation. Lets the tick loop skip building the - /// snapshot (which clones the whole native queue) unless a save is due. - pub fn has_persistable_session(&self) -> bool { - self.has_persistable_playback() - || !self.native_queue.is_empty() - || self.queue_now_track().is_some() - } - - fn has_persistable_playback(&self) -> bool { - // Mirrors `current_persisted_playback`: a decoded context suspended past - // its end (resume_index == None) does not persist. - #[cfg(feature = "audio-decode-queue")] - { - let context_resumable = !matches!(self.suspended_resume(), Some((None, _))); - #[cfg(feature = "youtube")] - if self.youtube_playback.is_some() && context_resumable { - return true; - } - #[cfg(feature = "subsonic")] - if self.subsonic_playback.is_some() && context_resumable { - return true; - } - #[cfg(feature = "qobuz")] - if self.qobuz_playback.is_some() && context_resumable { - return true; - } - #[cfg(feature = "local-files")] - if self.local_playback.is_some() && context_resumable { - return true; - } - } - #[cfg(feature = "internet-radio")] - if self.radio_playback.is_some() { - return true; - } - false - } - - /// Schedule a debounced state save. Hot paths (volume, shuffle) call this - /// instead of `save_runtime_state()` so a held key doesn't pay - /// disk + YAML work on every auto-repeat; the save lands once, shortly - /// after the last change. - pub fn schedule_state_save(&mut self, patch: PersistedRuntimeState) { - if patch.is_empty() { - return; - } - self.pending_state_save_patch.merge_patch(&patch); - self.state_save_due = Some(Instant::now() + Duration::from_millis(STATE_SAVE_DEBOUNCE_MS)); - } - - pub fn save_runtime_state(&self, patch: &PersistedRuntimeState) -> anyhow::Result<()> { - if patch.is_empty() { - return Ok(()); - } - let path = match &self.state_path { - Some(path) => path.clone(), - None => crate::core::state::default_state_path()?, - }; - crate::core::state::save(&path, patch) - } - - fn save_removed_radio_station(&self, url: &str) -> anyhow::Result<()> { - let path = match &self.state_path { - Some(path) => path.clone(), - None => crate::core::state::default_state_path()?, - }; - crate::core::state::save_removing_radio_station(&path, url) - } - - pub fn add_radio_station( - &mut self, - name: impl AsRef<str>, - url: impl AsRef<str>, - ) -> anyhow::Result<RadioStationAddOutcome> { - if self.is_configured_radio_station_url(url.as_ref()) { - return Ok(RadioStationAddOutcome::AlreadyExists); - } - let before_len = self.runtime_state.radio_stations.len(); - let outcome = self.runtime_state.add_radio_station(name, url)?; - if outcome == RadioStationAddOutcome::Added { - let Some(station) = self.runtime_state.radio_stations.get(before_len).cloned() else { - // `Added` promises the station was appended at `before_len`; if it is - // not there, don't report success for a station that was never saved. - self.runtime_state.radio_stations.truncate(before_len); - return Err(anyhow!( - "added radio station was not found at the expected position; not persisted" - )); - }; - if let Err(error) = self.save_runtime_state(&PersistedRuntimeState::radio_station(station)) { - self.runtime_state.radio_stations.truncate(before_len); - return Err(error); - } - } - Ok(outcome) - } - - pub fn is_configured_radio_station_url(&self, url: &str) -> bool { - let url = url.trim(); - self.is_config_owned_radio_station_url(url) && !self.is_state_owned_radio_station_url(url) - } - - pub fn is_config_owned_radio_station_url(&self, url: &str) -> bool { - let url = url.trim(); - !url.is_empty() - && self - .user_config - .behavior - .radio_stations - .iter() - .any(|station| station.url.trim() == url) - } - - pub fn is_state_owned_radio_station_url(&self, url: &str) -> bool { - let url = url.trim(); - !url.is_empty() - && self - .runtime_state - .radio_stations - .iter() - .any(|station| station.url.trim() == url) - } - - pub fn remove_radio_station_by_url( - &mut self, - url: impl AsRef<str>, - ) -> anyhow::Result<Option<RadioStationConfig>> { - let url = url.as_ref().trim().to_string(); - let Some(index) = self - .runtime_state - .radio_stations - .iter() - .position(|station| station.url.trim() == url) - else { - return Ok(None); - }; - let removed = self.runtime_state.remove_radio_station_by_url(&url)?; - if let Err(error) = self.save_removed_radio_station(&url) { - if let Some(removed) = removed { - self.runtime_state.radio_stations.insert(index, removed); - } - return Err(error); - } - Ok(removed) - } - - fn add_station_to_sidebar(&mut self, mut station: TrackInfo, name: String, url: &str) { - let uri = format!("{}{url}", RADIO_URI_PREFIX); - if self - .radio_stations - .iter() - .any(|existing| existing.uri.as_deref() == Some(uri.as_str())) - { - return; - } - - station.name = name; - station.uri = Some(uri); - self.radio_stations.push(station); - if self.view.selected_playlist_index.is_none() { - self.view.selected_playlist_index = Some(0); - } - } - - /// Favorite one radio station: persist it and mirror it into the sidebar. - pub(crate) fn favorite_radio_station(&mut self, station: TrackInfo) { - let Some(url) = station.uri.as_deref().and_then(radio_stream_url) else { - self.set_status_message("Radio station has no stream URL".to_string(), 4); - return; - }; - - let trimmed = station.name.trim(); - let name = if trimmed.is_empty() { url } else { trimmed }.to_string(); - let url = url.to_string(); - - let message = match self.add_radio_station(&name, &url) { - Ok(RadioStationAddOutcome::Added) => format!("Favorited radio station: {name}"), - Ok(RadioStationAddOutcome::AlreadyExists) => { - format!("Radio station already favorited: {name}") - } - Err(error) => { - self.set_error_status_message(format!("Could not favorite radio station: {error}"), 6); - return; - } - }; - - self.add_station_to_sidebar(station, name, &url); - self.set_status_message(message, 4); - } - - /// Remove a saved radio station by `radio:` URI; a config.yml station is - /// reported, not removed. - pub(crate) fn remove_saved_radio_station(&mut self, uri: String) { - let Some(url) = radio_stream_url(&uri) else { - self.set_status_message("Radio station has no stream URL".to_string(), 4); - return; - }; - let station_name = |app: &Self| { - app - .radio_stations - .iter() - .find(|station| station.uri.as_deref() == Some(uri.as_str())) - .map(|station| station.name.clone()) - .unwrap_or_default() - }; - let config_owned = self.is_config_owned_radio_station_url(url); - if config_owned && !self.is_state_owned_radio_station_url(url) { - let name = station_name(self); - self.set_status_message( - format!("Radio station is configured in config.yml: {name}"), - 4, - ); - return; - } - - match self.remove_radio_station_by_url(url) { - Ok(Some(removed)) => { - if !config_owned { - self - .radio_stations - .retain(|station| station.uri.as_deref() != Some(uri.as_str())); - } - // The saved copy's name, not the sidebar row's: a station configured in - // config.yml keeps its configured name while its saved duplicate goes. - self.set_status_message(format!("Removed saved radio station: {}", removed.name), 4); - } - Ok(None) => { - let name = station_name(self); - self.set_status_message(format!("Radio station is not favorited: {name}"), 4); - } - Err(error) => { - self.set_error_status_message(format!("Could not remove radio station: {error}"), 6); - } - } - } - - /// Flush a scheduled state save once its debounce window has passed, or - /// immediately when `force` is set (shutdown). - pub fn flush_state_save(&mut self, force: bool) { - let Some(due) = self.state_save_due else { - return; - }; - if force || Instant::now() >= due { - let patch = std::mem::take(&mut self.pending_state_save_patch); - self.state_save_due = None; - match self.save_runtime_state(&patch) { - Ok(()) => self.state_save_error_reported = false, - Err(e) => { - self.pending_state_save_patch.merge_patch(&patch); - // Keep the save scheduled: with the deadline cleared the retained patch - // would never be retried (this fn returns early on a `None` deadline), - // and the forced shutdown flush would silently drop it. - self.state_save_due = - Some(Instant::now() + Duration::from_millis(STATE_SAVE_DEBOUNCE_MS)); - // Reported to the status bar, not as an error page: the tick calls - // this every frame and the retry above re-arms twice a second, so as - // a modal an unwritable state dir is an unusable app, and as an - // `api_error` it is one no frontend can clear because every retry - // restamps the lifetime. Latched to one report per failure run, or - // the same rate would pin the status bar into error mode and drop - // every ordinary message for the rest of the session. - if !self.state_save_error_reported { - self.state_save_error_reported = true; - self.set_error_status_message(format!("Failed to save state: {}", e), 8); - } - } - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::app::test_support::*; - - /// An `App` whose state saves always fail: the save path's parent is a - /// regular file, so creating the directory for it cannot succeed on any - /// platform. - fn app_with_unwritable_state_path() -> (App, tempfile::TempDir) { - let dir = tempfile::tempdir().unwrap(); - let blocker = dir.path().join("blocker"); - std::fs::write(&blocker, b"not a directory").unwrap(); - - let mut app = make_app_simple(); - app.state_path = Some(blocker.join("state.yml")); - (app, dir) - } - - fn schedule_a_save(app: &mut App) { - app.pending_state_save_patch = PersistedRuntimeState { - volume_percent: Some(42), - ..PersistedRuntimeState::default() - }; - app.state_save_due = Some(Instant::now()); - } - - #[test] - fn a_failed_state_save_reports_to_the_status_bar_instead_of_the_error_page() { - let (mut app, _dir) = app_with_unwritable_state_path(); - schedule_a_save(&mut app); - - app.flush_state_save(false); - - assert!(app - .status_message - .as_deref() - .is_some_and(|message| message.starts_with("Failed to save state"))); - assert!(app.status_message_is_error); - // Not the modal: the tick retries twice a second, so an error page here - // is an app the user cannot escape. - assert!(app.api_error.is_empty()); - assert_ne!(app.get_current_route().id, RouteId::Error); - } - - // The regression the latch exists to prevent: an unlatched report re-fires - // at the tick's retry rate, and a live error message holds - // `status_message_is_error`, which makes `set_status_message` drop every - // ordinary message for the rest of the session. - // - // Asserted through a sentinel rather than by expiring the message and - // watching the bar recover: backdating `status_message_expires_at` opens the - // priority guard whether or not the latch is there, so that version of this - // test passes with the latch deleted. A second report would replace the - // sentinel, because an error always overwrites a live error. - #[test] - fn repeated_state_save_failures_report_only_once() { - let (mut app, _dir) = app_with_unwritable_state_path(); - schedule_a_save(&mut app); - app.flush_state_save(false); - app.set_error_status_message("sentinel", 8); - - // The retry the failed flush re-armed, as the tick would drive it. - app.state_save_due = Some(Instant::now()); - app.flush_state_save(false); - - assert_eq!(app.status_message.as_deref(), Some("sentinel")); - } - - #[test] - fn a_successful_state_save_re_arms_the_failure_report() { - let (mut app, dir) = app_with_unwritable_state_path(); - schedule_a_save(&mut app); - app.flush_state_save(false); - assert!(app.state_save_error_reported); - - app.state_path = Some(dir.path().join("state.yml")); - app.state_save_due = Some(Instant::now()); - app.flush_state_save(false); - - assert!(!app.state_save_error_reported); - } -} diff --git a/src/core/app/playback_routing.rs b/src/core/app/playback_routing.rs deleted file mode 100644 index 692e6965..00000000 --- a/src/core/app/playback_routing.rs +++ /dev/null @@ -1,540 +0,0 @@ -use super::*; - -pub(crate) const NOTHING_PLAYING_STATUS: &str = "Nothing is playing"; - -/// The status shown when a Spotify-bound request finds no session. -pub(crate) const SPOTIFY_NOT_CONNECTED_STATUS: &str = - "Spotify not connected. Press `d` and pick Spotify to log in."; - -/// Who owns the audio output, in the order the transport chains check. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum PlaybackOwner { - /// The native queue slot (a decoded track or a queued Spotify track). - Queue, - /// A decoded source's sink (Local, Subsonic, Qobuz, Radio, YouTube). - Decoded, - /// librespot as the active Connect device. - #[cfg_attr(not(feature = "streaming"), allow(dead_code))] - NativeSpotify, - /// A Spotify session with no local player: an external device or idle. - Spotify, - /// No player and no session. - None, -} - -/// The item a track-level action on "what is playing now" can act on. -pub(super) enum PlayingItem<'a> { - /// Spotify owns playback and the cached context names the item. Under - /// native streaming it lags the player after a skip until the next poll. - Spotify(&'a PlayableItem), - /// A Spotify track plays through the native queue slot; the cached context - /// names the suspended context's track, so the slot's own track is the item. - QueuedSpotify(&'a TrackInfo), - /// A decoded source or a decoded queue item owns the sink. - NotSpotify, - /// No owner, or a Spotify owner with no item. - Nothing, -} - -/// The pure half of [`App::playing_item`], so every owner is testable without -/// an audio device. -fn resolve_playing_item<'a>( - owner: PlaybackOwner, - slot_track: Option<&'a TrackInfo>, - slot_is_spotify: bool, - cached_item: Option<&'a PlayableItem>, -) -> PlayingItem<'a> { - match owner { - PlaybackOwner::Queue => match slot_track { - Some(track) if slot_is_spotify => PlayingItem::QueuedSpotify(track), - _ => PlayingItem::NotSpotify, - }, - PlaybackOwner::Decoded => PlayingItem::NotSpotify, - PlaybackOwner::NativeSpotify | PlaybackOwner::Spotify => { - cached_item.map_or(PlayingItem::Nothing, PlayingItem::Spotify) - } - PlaybackOwner::None => PlayingItem::Nothing, - } -} - -impl App { - /// Resolve the item playing now through the ownership order. - pub(super) fn playing_item(&self) -> PlayingItem<'_> { - resolve_playing_item( - self.playback_owner(), - self.queue_now_track(), - self.queue_now_is_spotify(), - self - .current_playback_context - .as_ref() - .and_then(|context| context.item.as_ref()), - ) - } - - pub(crate) fn playback_owner(&self) -> PlaybackOwner { - if self.queue_owns_playback() { - return PlaybackOwner::Queue; - } - if self.active_decoded_source() { - return PlaybackOwner::Decoded; - } - #[cfg(feature = "streaming")] - if self.is_native_streaming_active_for_playback() { - return PlaybackOwner::NativeSpotify; - } - if self.spotify_connected { - return PlaybackOwner::Spotify; - } - PlaybackOwner::None - } - - /// Record that `source` took the audio sink; its start path calls this - /// before it pauses librespot. - #[cfg(feature = "audio-decode")] - pub(crate) fn claim_decoded_sink(&mut self, source: Source) { - self.decoded_sink_claim = Some(source); - } - - /// Spotify takes the sink back: an explicit Spotify start reached the - /// network layer. - #[cfg(feature = "audio-decode")] - pub(crate) fn release_decoded_sink_claim(&mut self) { - self.decoded_sink_claim = None; - } - - /// Whether a decoded source holds the sink claim, session or not. - pub(crate) fn decoded_sink_claimed(&self) -> bool { - #[cfg(feature = "audio-decode")] - { - self.decoded_sink_claim.is_some() - } - #[cfg(not(feature = "audio-decode"))] - { - false - } - } - - /// The last arm of a transport chain: the Web API when a session exists, - /// otherwise a source-neutral status instead of the "not connected" nag. - pub(crate) fn dispatch_spotify_fallback(&mut self, event: IoEvent) { - if self.playback_owner() == PlaybackOwner::None { - self.set_status_message(NOTHING_PLAYING_STATUS, 4); - return; - } - self.dispatch(event); - } - - /// `Some(true)` when a decoded source owns the sink and plays, `Some(false)` - /// when it owns the sink and is paused, `None` when none owns it. - pub(crate) fn decoded_playing_state(&self) -> Option<bool> { - #[cfg(feature = "audio-decode")] - { - self.active_decoded_player().map(|p| !p.is_paused()) - } - #[cfg(not(feature = "audio-decode"))] - { - None - } - } - - /// Check if native streaming is the active playback device - /// Returns true while the player is connected or reconnecting and it is the - /// currently active device. - #[cfg(feature = "streaming")] - pub(super) fn is_native_streaming_active_for_playback(&self) -> bool { - // Keep routing controls to the native backend during its bounded in-place - // reconnect; StreamingPlayer queues Spirc-dependent commands in that window. - let player_available = self - .streaming_player - .as_ref() - .is_some_and(|p| p.is_available()); - - if !player_available { - return false; - } - - // Get native device name from player - let native_device_name = self - .streaming_player - .as_ref() - .map(|p| p.device_name().to_lowercase()); - - // If no context yet (e.g., at startup), use the app state flag which is - // set when the native streaming device is activated/selected. - let Some(ref ctx) = self.current_playback_context else { - return self.is_streaming_active; - }; - - // First, check if the current playback device matches the native streaming device ID - if let (Some(current_id), Some(native_id)) = - (ctx.device.id.as_ref(), self.native_device_id.as_ref()) - { - if current_id == native_id { - return true; - } - } - - // Fallback: strict name match (case-insensitive), but only while we have - // fresh native activity or a recent explicit activation. After a recovery, - // Spotify can keep returning the old "spotatui" device while the new native - // player is connected but stopped/not active. - if let Some(native_name) = native_device_name.as_ref() { - let current_device_name = ctx.device.name.to_lowercase(); - if current_device_name == native_name.as_str() && self.has_fresh_native_activity() { - return true; - } - } - - // No match - not the active device - false - } - - /// Whether Spotify playback is happening on an *external* Connect device - /// (i.e. a Spotify context exists and it is not our own native streaming - /// device). When true, `z` on a Spotify track keeps today's Web-API - /// `AddItemToQueue` behavior instead of routing to the native queue. Under a - /// build without native streaming, any Spotify context is external by - /// definition. - pub fn spotify_external_device_active(&self) -> bool { - #[cfg(feature = "streaming")] - { - self.current_playback_context.is_some() && !self.is_native_streaming_active_for_playback() - } - #[cfg(not(feature = "streaming"))] - { - self.current_playback_context.is_some() - } - } - - /// Whether any decoded-audio source (local file, Subsonic, internet radio, or - /// YouTube) currently owns the playback session. - /// - /// Starting a non-Spotify source only *pauses* librespot; it never clears - /// `is_streaming_active` / `current_playback_context`, so - /// [`is_native_streaming_active_for_playback`](Self::is_native_streaming_active_for_playback) - /// stays true while a decoded source owns the rodio sink. The direct-control - /// transport methods (next/prev/volume) use this guard to route to the active - /// source via `IoEvent` dispatch instead of driving the paused librespot. - /// - /// Radio is included: routing Next/volume to radio's dispatcher (which no-ops - /// or handles it) is still correct — we must never drive librespot while a - /// source is playing. In a build with all source features off this reduces to - /// `false`. - pub(crate) fn active_decoded_source(&self) -> bool { - // The native queue slot playing a decoded track owns the sink even when no - // per-source `*_playback` context is set (e.g. queueing from an idle app). - #[cfg(feature = "audio-decode-queue")] - if self.queue_now_decoded_player().is_some() { - return true; - } - // A queued Spotify track owns the sink via librespot; any remaining - // `*_playback` below is a suspended context, not the active source. - if self.queue_now_is_spotify() { - return false; - } - // A decoded start in flight, or a source whose session died with nothing to - // replace it, still owns the sink: librespot is paused underneath. - #[cfg(feature = "audio-decode")] - if self.decoded_sink_claim.is_some() { - return true; - } - #[cfg(feature = "local-files")] - if self.local_playback.is_some() { - return true; - } - #[cfg(feature = "subsonic")] - if self.subsonic_playback.is_some() { - return true; - } - #[cfg(feature = "qobuz")] - if self.qobuz_playback.is_some() { - return true; - } - #[cfg(feature = "internet-radio")] - if self.radio_playback.is_some() { - return true; - } - #[cfg(feature = "youtube")] - if self.youtube_playback.is_some() { - return true; - } - false - } - - /// Whether a *queueable* decoded source (Local / Subsonic / YouTube) — one with - /// its own track queue — currently owns playback. Unlike - /// [`active_decoded_source`](Self::active_decoded_source) this **excludes** - /// internet radio (an infinite stream with no queue) and the native queue slot - /// (a suspended context is not the active source). This is the gate for the - /// decoded repeat/shuffle controls, which only make sense over a real queue. - /// Also gates which playbar buttons are drawn and clickable (see - /// `playbar_supported_controls`). - pub(crate) fn active_queueable_decoded_source(&self) -> bool { - // The native queue owning the sink is out of scope for repeat/shuffle; any - // per-source `*_playback` below is then a suspended context, not active. - if self.queue_owns_playback() { - return false; - } - #[cfg(feature = "local-files")] - if self.local_playback.is_some() { - return true; - } - #[cfg(feature = "subsonic")] - if self.subsonic_playback.is_some() { - return true; - } - #[cfg(feature = "qobuz")] - if self.qobuz_playback.is_some() { - return true; - } - #[cfg(feature = "youtube")] - if self.youtube_playback.is_some() { - return true; - } - false - } - - /// Take every decoded session except `keep`'s, so one backend can own the - /// output device, and return their players. Stop them off the `App` lock: a - /// sink clear waits for the audio thread. - #[cfg(feature = "audio-decode")] - pub(crate) fn take_decoded_sessions_except( - &mut self, - keep: crate::core::source::Source, - ) -> Vec<std::sync::Arc<crate::infra::audio::LocalPlayer>> { - use crate::core::source::Source; - use std::sync::Arc; - let mut players = Vec::new(); - #[cfg(feature = "local-files")] - if keep != Source::Local { - players.extend(self.local_playback.take().map(|s| Arc::clone(&s.player))); - } - #[cfg(feature = "subsonic")] - if keep != Source::Subsonic { - players.extend(self.subsonic_playback.take().map(|s| Arc::clone(&s.player))); - } - #[cfg(feature = "qobuz")] - if keep != Source::Qobuz { - players.extend(self.qobuz_playback.take().map(|s| Arc::clone(&s.player))); - } - #[cfg(feature = "internet-radio")] - if keep != Source::Radio { - players.extend(self.radio_playback.take().map(|s| Arc::clone(&s.player))); - } - #[cfg(feature = "youtube")] - if keep != Source::YouTube { - players.extend(self.youtube_playback.take().map(|s| Arc::clone(&s.player))); - } - players - } - - /// The player of whichever decoded source (local file, Subsonic, Qobuz, - /// internet radio, or YouTube) currently owns the session, or `None` when - /// Spotify (or nothing) owns it. All five decode through the same `LocalPlayer` - /// sink, so a single accessor covers transport/seek routing for every one. - /// Ordering mirrors [`Self::active_decoded_source`]. - #[cfg(feature = "audio-decode")] - pub fn active_decoded_player(&self) -> Option<&std::sync::Arc<crate::infra::audio::LocalPlayer>> { - #[cfg(feature = "audio-decode-queue")] - if let Some(p) = self.queue_now_decoded_player() { - return Some(p); - } - // A queued Spotify track owns the sink via librespot; any remaining - // `*_playback` below is a suspended context, not the active source. - if self.queue_now_is_spotify() { - return None; - } - #[cfg(feature = "local-files")] - if let Some(s) = &self.local_playback { - return Some(&s.player); - } - #[cfg(feature = "subsonic")] - if let Some(s) = &self.subsonic_playback { - return Some(&s.player); - } - #[cfg(feature = "qobuz")] - if let Some(s) = &self.qobuz_playback { - return Some(&s.player); - } - #[cfg(feature = "internet-radio")] - if let Some(s) = &self.radio_playback { - return Some(&s.player); - } - #[cfg(feature = "youtube")] - if let Some(s) = &self.youtube_playback { - return Some(&s.player); - } - None - } - - /// The current playback position, in milliseconds, of the active *seekable* - /// decoded source (local file, Subsonic, or YouTube). - /// - /// Read live from the source player's sink. Internet radio is intentionally - /// **excluded** — a live stream is not seekable — so radio returns `None` here - /// and seek keys become correct no-ops for radio. In a build with all seekable - /// source features off this reduces to `None`. - pub(super) fn active_source_position_ms(&self) -> Option<u128> { - #[cfg(feature = "audio-decode-queue")] - if let Some(p) = self.queue_now_decoded_player() { - return Some(p.position().as_millis()); - } - // A queued Spotify track owns the sink; librespot events drive progress and - // any remaining `*_playback` below is a suspended context. - if self.queue_now_is_spotify() { - return None; - } - #[cfg(feature = "local-files")] - if let Some(local) = &self.local_playback { - return Some(local.player.position().as_millis()); - } - #[cfg(feature = "subsonic")] - if let Some(subsonic) = &self.subsonic_playback { - return Some(subsonic.player.position().as_millis()); - } - #[cfg(feature = "qobuz")] - if let Some(qobuz) = &self.qobuz_playback { - return Some(qobuz.player.position().as_millis()); - } - #[cfg(feature = "youtube")] - if let Some(youtube) = &self.youtube_playback { - return Some(youtube.player.position().as_millis()); - } - None - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::app::test_support::*; - - #[cfg(feature = "streaming")] - #[test] - fn spotify_queue_slot_shadows_decoded_activity_checks() { - use crate::infra::queue::QueueNowPlaying; - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.queue_now = Some(QueueNowPlaying::Spotify { - track: queue_track(Some("spotify:track:queued"), "Queued"), - }); - - assert!(!app.active_decoded_source()); - assert!(app.active_source_position_ms().is_none()); - assert_eq!(app.playback_owner(), PlaybackOwner::Queue); - } - - #[cfg(all(feature = "streaming", feature = "audio-decode"))] - #[test] - fn spotify_queue_slot_shadows_decoded_player_lookup() { - use crate::infra::queue::QueueNowPlaying; - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.queue_now = Some(QueueNowPlaying::Spotify { - track: queue_track(Some("spotify:track:queued"), "Queued"), - }); - - assert!(app.active_decoded_player().is_none()); - } - - #[test] - fn playing_item_follows_the_owner() { - let slot = queue_track(Some("spotify:track:queued"), "Queued"); - let cached = PlayableItem::Track(full_track("0000000000000000000001", "Cached")); - - assert!(matches!( - resolve_playing_item(PlaybackOwner::Decoded, None, false, Some(&cached)), - PlayingItem::NotSpotify - )); - assert!(matches!( - resolve_playing_item(PlaybackOwner::Queue, Some(&slot), false, Some(&cached)), - PlayingItem::NotSpotify - )); - assert!(matches!( - resolve_playing_item(PlaybackOwner::Queue, Some(&slot), true, Some(&cached)), - PlayingItem::QueuedSpotify(track) if track.name == "Queued" - )); - assert!(matches!( - resolve_playing_item(PlaybackOwner::Spotify, None, false, Some(&cached)), - PlayingItem::Spotify(PlayableItem::Track(track)) if track.name == "Cached" - )); - assert!(matches!( - resolve_playing_item(PlaybackOwner::NativeSpotify, None, false, None), - PlayingItem::Nothing - )); - assert!(matches!( - resolve_playing_item(PlaybackOwner::None, None, false, Some(&cached)), - PlayingItem::Nothing - )); - } - - #[test] - fn playback_owner_is_none_without_a_session() { - let (app, _rx) = session_free_app(); - - assert_eq!(app.playback_owner(), PlaybackOwner::None); - } - - #[test] - fn playback_owner_is_spotify_with_a_session_and_no_player() { - assert_eq!(make_app_simple().playback_owner(), PlaybackOwner::Spotify); - } - - #[test] - fn dispatch_spotify_fallback_reports_nothing_playing_without_a_session() { - let (mut app, rx) = session_free_app(); - - app.dispatch_spotify_fallback(IoEvent::NextTrack); - - assert!(rx.try_recv().is_err()); - assert_eq!(app.status_message.as_deref(), Some(NOTHING_PLAYING_STATUS)); - } - - #[test] - fn dispatch_spotify_fallback_dispatches_with_a_session() { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - - app.dispatch_spotify_fallback(IoEvent::NextTrack); - - assert!(matches!(rx.try_recv(), Ok(IoEvent::NextTrack))); - } - - #[cfg(feature = "youtube")] - #[test] - fn a_claimed_decoded_sink_owns_playback_without_a_session() { - let mut app = make_app_simple(); - app.claim_decoded_sink(Source::YouTube); - - assert!(app.active_decoded_source()); - assert!(app.decoded_sink_claimed()); - assert_eq!(app.playback_owner(), PlaybackOwner::Decoded); - assert!(!app.active_queueable_decoded_source()); - assert!(app.active_source_position_ms().is_none()); - } - - #[cfg(feature = "youtube")] - #[test] - fn releasing_the_claim_hands_the_sink_back_to_spotify() { - let mut app = make_app_simple(); - app.claim_decoded_sink(Source::YouTube); - app.release_decoded_sink_claim(); - - assert!(!app.active_decoded_source()); - assert_eq!(app.playback_owner(), PlaybackOwner::Spotify); - } - - #[cfg(all(feature = "streaming", feature = "youtube"))] - #[test] - fn a_spotify_queue_slot_shadows_the_claim() { - use crate::infra::queue::QueueNowPlaying; - let mut app = make_app_simple(); - app.claim_decoded_sink(Source::YouTube); - app.queue_now = Some(QueueNowPlaying::Spotify { - track: queue_track(Some("spotify:track:queued"), "Queued"), - }); - - assert!(!app.active_decoded_source()); - assert_eq!(app.playback_owner(), PlaybackOwner::Queue); - } -} diff --git a/src/core/app/playlist_folders.rs b/src/core/app/playlist_folders.rs deleted file mode 100644 index 8eb749f5..00000000 --- a/src/core/app/playlist_folders.rs +++ /dev/null @@ -1,321 +0,0 @@ -use super::*; - -/// The public "spotatui community" playlist pinned to the top of the Spotify -/// playlists sidebar. -pub const COMMUNITY_PLAYLIST_ID: &str = "0tjRxKAUgoz95pWeW17wYx"; - -/// A node in the playlist folder hierarchy from Spotify's rootlist -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -#[allow(dead_code)] -pub enum PlaylistFolderNodeType { - Folder, - Playlist, -} - -/// A node in the playlist folder hierarchy from Spotify's rootlist -#[derive(Clone, Debug)] -#[allow(dead_code)] -pub struct PlaylistFolderNode { - pub name: Option<String>, - pub node_type: PlaylistFolderNodeType, - pub uri: String, - pub children: Vec<PlaylistFolderNode>, -} - -/// A folder entry for navigation in the playlist panel -#[derive(Clone, Debug)] -pub struct PlaylistFolder { - pub name: String, - /// Folder ID this item is visible in (which folder "page" it appears on) - pub current_id: usize, - /// Folder ID this item navigates to when selected - pub target_id: usize, -} - -/// A flattened item for display in the playlist panel -#[derive(Clone, Debug)] -#[allow(dead_code)] -pub enum PlaylistFolderItem { - Folder(PlaylistFolder), - Playlist { - /// Index into app.all_playlists - index: usize, - /// Folder ID this playlist is visible in - current_id: usize, - }, - /// The pinned "spotatui community" playlist. Injected at display time only - /// (never stored in `playlist_folder_items`); opens the community playlist - /// when selected. - CommunityPin, -} - -impl App { - pub fn is_playlist_item_visible_in_current_folder(&self, item: &PlaylistFolderItem) -> bool { - match item { - PlaylistFolderItem::Folder(f) => f.current_id == self.current_playlist_folder_id, - PlaylistFolderItem::Playlist { current_id, .. } => { - *current_id == self.current_playlist_folder_id - } - PlaylistFolderItem::CommunityPin => false, - } - } - - /// Whether the user already follows the community playlist (its id is present - /// in the loaded playlists), in which case the pin is suppressed. - pub fn follows_community_playlist(&self) -> bool { - self - .all_playlists - .iter() - .any(|p| p.id.as_deref() == Some(COMMUNITY_PLAYLIST_ID)) - } - - /// Whether the community-playlist pin should be shown as row 0 of the Spotify - /// playlists sidebar. - pub fn community_pin_visible(&self) -> bool { - self.active_source == Source::Spotify - && self.user_config.behavior.pin_community_playlist - && self.current_playlist_folder_id == 0 - && !self.follows_community_playlist() - } - - /// Record in `state.yml` that the pin prompt was shown, so it never asks again. - pub(crate) fn mark_community_pin_prompt_shown(&mut self) { - self.runtime_state.community_pin_prompt_shown = true; - if let Err(e) = - self.save_runtime_state(&PersistedRuntimeState::community_pin_prompt_shown(true)) - { - log::warn!("failed to persist community pin prompt state: {}", e); - } - } - - /// Answer the pin prompt: hide the pin (persisted) or keep it, then close it. - pub(crate) fn answer_community_pin_prompt(&mut self, keep: bool) { - if !keep { - self.user_config.behavior.pin_community_playlist = false; - if let Err(e) = self.user_config.save_config() { - log::warn!("failed to persist community playlist pin setting: {}", e); - } - self.set_status_message( - "Community playlist pin hidden (re-enable in Settings)".to_string(), - 6, - ); - } - self.mark_community_pin_prompt_shown(); - self.pop_navigation_stack(); - } - - /// Get the number of items visible in the current folder level. - pub fn get_playlist_display_count(&self) -> usize { - self.get_playlist_display_items().len() - } - - /// Get a visible item by display index in the current folder. - pub fn get_playlist_display_item_at(&self, display_index: usize) -> Option<&PlaylistFolderItem> { - self - .get_playlist_display_items() - .into_iter() - .nth(display_index) - } - - /// Get visible playlist items in the current folder (used by UI rendering). - /// - /// Single source of truth for the visible order: rendering and index-based - /// selection (keyboard + mouse) both go through it, so they can never - /// disagree. When `group_folders_first` is set, folders are hoisted to the - /// top via a stable sort, preserving each group's relative order. - pub fn get_playlist_display_items(&self) -> Vec<&PlaylistFolderItem> { - let mut items: Vec<&PlaylistFolderItem> = self - .playlist_folder_items - .iter() - .filter(|item| self.is_playlist_item_visible_in_current_folder(item)) - .collect(); - if self.user_config.behavior.group_folders_first { - items.sort_by_key(|item| !matches!(item, PlaylistFolderItem::Folder(_))); - } - // Injected after sorting so the pin is always row 0, regardless of grouping. - if self.community_pin_visible() { - items.insert(0, &self.community_pin_item); - } - items - } - - /// Get the playlist for a PlaylistFolderItem::Playlist variant - #[allow(dead_code)] - pub fn get_playlist_for_item(&self, item: &PlaylistFolderItem) -> Option<&PlaylistInfo> { - match item { - PlaylistFolderItem::Playlist { index, .. } => self.all_playlists.get(*index), - PlaylistFolderItem::Folder(_) | PlaylistFolderItem::CommunityPin => None, - } - } - - /// Get the currently selected playlist id in the visible playlist list. - #[allow(dead_code)] - pub fn get_selected_playlist_id(&self) -> Option<String> { - let selected_index = self.view.selected_playlist_index?; - // Row 0 is the "+ Add Playlist" entry; display items start at row 1. - let display_index = selected_index.checked_sub(1)?; - match self.get_playlist_display_item_at(display_index) { - Some(PlaylistFolderItem::Playlist { index, .. }) => { - return self.all_playlists.get(*index).and_then(|p| p.id.clone()); - } - // The pin is not a stored playlist; don't let the raw-page fallback below - // return an unrelated playlist's id for it. - Some(PlaylistFolderItem::CommunityPin) => return None, - Some(PlaylistFolderItem::Folder(_)) | None => {} - } - - // In the raw-page fallback the pin is also rendered (row 1, above the pages), - // so a row past it maps to `items[display_index - 1]`. The CommunityPin guard - // above means this only runs at display_index >= 1 when the pin is visible. - let raw_index = if self.community_pin_visible() { - display_index.checked_sub(1)? - } else { - display_index - }; - self - .playlists - .as_ref() - .and_then(|playlists| playlists.items.get(raw_index)) - .and_then(|playlist| playlist.id.clone()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn group_folders_first_hoists_folders_stably_and_only_when_enabled() { - fn folder(name: &str) -> PlaylistFolderItem { - PlaylistFolderItem::Folder(PlaylistFolder { - name: name.to_string(), - current_id: 0, - target_id: 1, - }) - } - fn playlist(index: usize) -> PlaylistFolderItem { - PlaylistFolderItem::Playlist { - index, - current_id: 0, - } - } - // Interleaved: playlist, folder A, playlist, folder B (all at root level). - let mut app = App { - playlist_folder_items: vec![playlist(0), folder("A"), playlist(1), folder("B")], - ..Default::default() - }; - // Keep this test focused on folder hoisting; the community pin is exercised - // separately. - app.user_config.behavior.pin_community_playlist = false; - - // Off (default): order is untouched. - app.user_config.behavior.group_folders_first = false; - let names: Vec<&str> = app - .get_playlist_display_items() - .iter() - .map(|i| match i { - PlaylistFolderItem::Folder(f) => f.name.as_str(), - PlaylistFolderItem::Playlist { .. } => "P", - PlaylistFolderItem::CommunityPin => "C", - }) - .collect(); - assert_eq!(names, vec!["P", "A", "P", "B"]); - - // On: folders float to the top; both groups keep their relative order. - app.user_config.behavior.group_folders_first = true; - let names: Vec<&str> = app - .get_playlist_display_items() - .iter() - .map(|i| match i { - PlaylistFolderItem::Folder(f) => f.name.as_str(), - PlaylistFolderItem::Playlist { .. } => "P", - PlaylistFolderItem::CommunityPin => "C", - }) - .collect(); - assert_eq!(names, vec!["A", "B", "P", "P"]); - // Selection index resolves against the same reordered list. - assert!(matches!( - app.get_playlist_display_item_at(0), - Some(PlaylistFolderItem::Folder(_)) - )); - } - - #[test] - fn community_pin_is_first_display_item_when_visible() { - let app = App::default(); - // Default Spotify source + default-on toggle + root folder + not following. - // The pin is the first *display* item (sidebar row 1, below "+ Add Playlist"). - assert!(app.community_pin_visible()); - assert!(matches!( - app.get_playlist_display_item_at(0), - Some(PlaylistFolderItem::CommunityPin) - )); - assert_eq!(app.get_playlist_display_count(), 1); - } - - #[test] - fn community_pin_hidden_outside_root_folder() { - let mut app = App::default(); - assert!(app.community_pin_visible()); - app.current_playlist_folder_id = 3; - assert!(!app.community_pin_visible()); - } - - #[test] - fn community_pin_hidden_when_toggle_off() { - let mut app = App::default(); - app.user_config.behavior.pin_community_playlist = false; - assert!(!app.community_pin_visible()); - } - - #[test] - fn community_pin_hidden_when_already_following() { - let mut app = App::default(); - assert!(app.community_pin_visible()); - app.all_playlists = vec![playlist_info( - COMMUNITY_PLAYLIST_ID, - "spotatui community", - "spotatui", - false, - )]; - assert!(app.follows_community_playlist()); - assert!(!app.community_pin_visible()); - } - - #[test] - fn community_pin_hidden_under_non_spotify_source() { - let mut app = App::default(); - app.active_source = Source::Local; - assert!(!app.community_pin_visible()); - } - - #[test] - fn selected_playlist_id_offsets_past_pin_in_raw_fallback() { - // Folder-aware items not yet initialized, so the sidebar falls back to the - // raw playlist pages. Rendered rows: [+ Add Playlist, pin, First, Second]. - let mut app = App::default(); - assert!(app.community_pin_visible()); - assert!(app.playlist_folder_items.is_empty()); - app.playlists = Some(Paged { - items: vec![ - playlist_info("00000000000000000000a0", "First", "me", false), - playlist_info("00000000000000000000a1", "Second", "me", false), - ], - total: 2, - ..Default::default() - }); - - // Row 3 is the second raw page item, not the off-by-one neighbor. - app.view.selected_playlist_index = Some(3); - assert_eq!( - app.get_selected_playlist_id().as_deref(), - Some("00000000000000000000a1") - ); - // Row 2 is the first raw page item. - app.view.selected_playlist_index = Some(2); - assert_eq!( - app.get_selected_playlist_id().as_deref(), - Some("00000000000000000000a0") - ); - } -} diff --git a/src/core/app/playlist_pages.rs b/src/core/app/playlist_pages.rs deleted file mode 100644 index 62f90467..00000000 --- a/src/core/app/playlist_pages.rs +++ /dev/null @@ -1,697 +0,0 @@ -use super::*; - -fn sort_playlist_track_matches(matches: &mut [(FullTrack, usize)], sort_state: SortState) { - if sort_state.field == SortField::Default { - return; - } - - matches.sort_by(|(track_a, position_a), (track_b, position_b)| { - let order = match sort_state.field { - SortField::Name => track_a.name.cmp(&track_b.name), - SortField::Duration => track_a.duration.cmp(&track_b.duration), - SortField::Artist => { - let empty_string = String::new(); - let artist_a = track_a - .artists - .first() - .map(|artist| &artist.name) - .unwrap_or(&empty_string); - let artist_b = track_b - .artists - .first() - .map(|artist| &artist.name) - .unwrap_or(&empty_string); - artist_a.cmp(artist_b) - } - SortField::Album => track_a.album.name.cmp(&track_b.album.name), - SortField::DateAdded => position_a.cmp(position_b), - SortField::Default => std::cmp::Ordering::Equal, - }; - - if sort_state.order == SortOrder::Descending { - order.reverse() - } else { - order - } - }); -} - -impl App { - pub fn set_playlist_tracks_to_table_continuous(&mut self) { - let mut tracks: Vec<TrackInfo> = Vec::new(); - let mut track_ids: Vec<String> = Vec::new(); - let mut positions: Vec<usize> = Vec::new(); - let mut expected_offset = 0; - let mut seen_offsets = HashSet::new(); - let mut active_index = 0; - let mut active_page = None; - - for (page_index, page) in self.playlist_track_pages.pages.iter().enumerate() { - if page.offset != expected_offset || !seen_offsets.insert(page.offset) { - break; - } - - for (position, item) in page.items.iter() { - if let PlayableInfo::Track(track) = item { - if let Some(id) = track.id.as_ref() { - track_ids.push(id.clone()); - } - tracks.push(track.clone()); - positions.push(*position as usize); - } - } - - expected_offset = expected_offset.saturating_add(page.limit); - active_index = page_index; - active_page = Some(page.clone()); - - if page.next.is_none() { - break; - } - } - - self.playlist_track_pages.index = active_index; - self.playlist_tracks = active_page; - self.playlist_offset = 0; - self.replace_track_table_tracks(tracks); - self.playlist_track_positions = Some(positions); - self.dispatch(IoEvent::CurrentUserSavedTracksContains(track_ids)); - } - - /// Open a playlist's track table: navigate immediately (the cleared table is - /// the loading state) and fetch page 1. The fetch response used to be what - /// pushed the route, so a queued or slow fetch made Enter look dead and - /// trained users to spam it — each press queuing another full fetch. Now - /// the screen opens on the first press, and a repeat press while the same - /// open is still in flight only re-ensures navigation instead of dispatching - /// a duplicate fetch. - pub fn open_playlist_tracks( - &mut self, - playlist_id: PlaylistId<'static>, - context: TrackTableContext, - ) { - let id_str = playlist_id.id().to_string(); - if self.pending_playlist_open.as_deref() == Some(id_str.as_str()) { - // Same open already in flight: just make sure the screen is shown. - if self.get_current_route().id != RouteId::TrackTable { - self.push_navigation_stack(RouteId::TrackTable, ActiveBlock::TrackTable); - } - return; - } - self.pending_playlist_open = Some(id_str.clone()); - self.reset_playlist_tracks_view(playlist_id, context); - self.push_navigation_stack(RouteId::TrackTable, ActiveBlock::TrackTable); - self.set_status_message("Loading playlist…", 3); - self.dispatch(IoEvent::GetPlaylistItems(id_str, self.playlist_offset)); - } - - /// Open a decoded source's playlist or folder in the shared track table, - /// routed by URI scheme. Leaves the Spotify page cache alone. - pub(crate) fn open_source_playlist_tracks(&mut self, uri: String) { - let (context, event) = if uri.starts_with("file:") { - ( - TrackTableContext::LocalPlaylist, - IoEvent::GetLocalTracks(uri), - ) - } else if uri.starts_with("subsonic:") { - ( - TrackTableContext::SubsonicPlaylist, - IoEvent::GetSubsonicTracks(uri), - ) - } else if uri.starts_with(YOUTUBE_PLAYLIST_PREFIX) { - ( - TrackTableContext::YouTubePlaylist, - IoEvent::GetYouTubeTracks(uri), - ) - } else if uri.starts_with("qobuz:") { - ( - TrackTableContext::QobuzPlaylist, - IoEvent::GetQobuzTracks(uri), - ) - } else { - // Unknown scheme: silent no-op, like the other opening paths. - return; - }; - self.dispatch(event); - self.show_tracks_in_table(Vec::new(), context); - } - - /// Show a decoded source's search hits as a songs-only result set with the - /// songs block focused. The album/artist blocks stay empty: their Enter - /// paths dispatch Spotify-bound events. - #[cfg(any( - feature = "subsonic", - feature = "qobuz", - feature = "internet-radio", - feature = "youtube" - ))] - pub(crate) fn show_source_search_tracks(&mut self, tracks: Vec<TrackInfo>) { - let total = tracks.len() as u32; - self.search_results.tracks = Some(Paged { - items: tracks, - total, - ..Default::default() - }); - self.search_results.albums = None; - self.search_results.artists = None; - self.search_results.playlists = None; - self.search_results.shows = None; - self.view.search_selected_tracks_index = Some(0); - self.view.search_hovered_block = SearchResultBlock::SongSearch; - self.view.search_selected_block = SearchResultBlock::Empty; - } - - pub fn reset_playlist_tracks_view( - &mut self, - playlist_id: PlaylistId<'static>, - context: TrackTableContext, - ) { - self.playlist_tracks_prefetch_generation = - self.playlist_tracks_prefetch_generation.wrapping_add(1); - self.playlist_tracks_prefetch_in_flight.clear(); - self.playlist_track_table_id = Some(playlist_id); - self.active_playlist_track_filter = None; - self.pending_playlist_track_search = None; - self.playlist_track_pages.clear(); - self.playlist_tracks = None; - self.playlist_offset = 0; - self.pending_track_table_selection = None; - self.view.track_table_index = 0; - self.track_table.tracks.clear(); - self.track_table.context = Some(context); - self.playlist_track_positions = None; - } - - pub fn is_playlist_track_filter_active(&self) -> bool { - self.active_playlist_track_filter.is_some() - } - - pub fn clear_playlist_track_filter(&mut self) { - self.active_playlist_track_filter = None; - self.pending_playlist_track_search = None; - self.view.input_context = InputContext::GlobalSearch; - if self.playlist_track_pages.pages.is_empty() { - self.track_table.tracks.clear(); - self.view.track_table_index = 0; - self.playlist_track_positions = None; - return; - } - self.set_playlist_tracks_to_table_continuous(); - } - - pub fn apply_playlist_track_search_results( - &mut self, - playlist_id: &PlaylistId<'_>, - query: String, - mut matches: Vec<(FullTrack, usize)>, - ) -> bool { - if !self.is_playlist_track_table_active_for(playlist_id) { - return false; - } - - sort_playlist_track_matches(&mut matches, self.playlist_sort); - - let track_ids = matches - .iter() - .filter_map(|(track, _)| track.id.as_ref().map(|id| id.id().to_string())) - .collect(); - let tracks: Vec<TrackInfo> = matches - .iter() - .map(|(track, _)| TrackInfo::from(track)) - .collect(); - let positions: Vec<usize> = matches.into_iter().map(|(_, position)| position).collect(); - - self.active_playlist_track_filter = Some(query); - self.pending_playlist_track_search = None; - self.view.track_table_index = 0; - self.track_table.tracks = tracks; - self.playlist_track_positions = Some(positions); - self.dispatch(IoEvent::CurrentUserSavedTracksContains(track_ids)); - true - } - - pub fn is_playlist_track_table_context(&self) -> bool { - matches!( - self.track_table.context, - Some(TrackTableContext::MyPlaylists) | Some(TrackTableContext::PlaylistSearch) - ) - } - - pub fn current_playlist_track_table_id(&self) -> Option<PlaylistId<'static>> { - self - .is_playlist_track_table_context() - .then_some(self.playlist_track_table_id.clone()) - .flatten() - } - - pub fn current_playlist_track_total(&self) -> Option<u32> { - self.current_playlist_track_table_id()?; - self - .playlist_tracks - .as_ref() - .map(|playlist_tracks| playlist_tracks.total) - .or_else(|| { - self - .playlist_track_pages - .pages - .first() - .map(|page| page.total) - }) - } - - pub fn is_playlist_track_table_active_for(&self, playlist_id: &PlaylistId<'_>) -> bool { - self - .current_playlist_track_table_id() - .as_ref() - .is_some_and(|current_playlist_id| current_playlist_id.id() == playlist_id.id()) - } - - pub fn is_current_route_playlist_track_table_for(&self, playlist_id: &PlaylistId<'_>) -> bool { - self.get_current_route().id == RouteId::TrackTable - && self.is_playlist_track_table_active_for(playlist_id) - } - - pub fn next_missing_playlist_tracks_offset(&self, page_index: usize) -> Option<u32> { - let playlist_tracks_page = self.playlist_track_pages.get_results(Some(page_index))?; - playlist_tracks_page.next.as_ref()?; - - let next_offset = playlist_tracks_page.offset + playlist_tracks_page.limit; - self - .playlist_track_pages - .page_index_for_offset(next_offset) - .is_none() - .then_some(next_offset) - } - - pub fn next_missing_playlist_tracks_offset_continuous(&self) -> Option<u32> { - let playlist_tracks_page = self - .playlist_track_pages - .get_results(Some(self.playlist_track_pages.index))?; - playlist_tracks_page.next.as_ref()?; - Some(playlist_tracks_page.offset + playlist_tracks_page.limit) - } - - pub fn current_playlist_has_more_tracks(&self) -> bool { - if self.is_playlist_track_filter_active() { - return false; - } - - self - .playlist_tracks - .as_ref() - .is_some_and(|playlist_tracks| playlist_tracks.next.is_some()) - } - - pub fn selected_playlist_track_position(&self) -> Option<usize> { - self - .playlist_track_positions - .as_ref() - .and_then(|positions| positions.get(self.view.track_table_index)) - .copied() - } - - pub fn get_playlist_tracks_next(&mut self) { - if self.is_playlist_track_filter_active() { - return; - } - - let Some(playlist_id) = self.current_playlist_track_table_id() else { - return; - }; - if !self.current_playlist_has_more_tracks() { - return; - } - - if let Some(next_offset) = self.next_missing_playlist_tracks_offset_continuous() { - if self - .playlist_track_pages - .page_index_for_offset(next_offset) - .is_some() - { - self.set_playlist_tracks_to_table_continuous(); - } else if !self - .playlist_tracks_prefetch_in_flight - .contains(&next_offset) - { - self.playlist_tracks_prefetch_in_flight.insert(next_offset); - self.dispatch(IoEvent::GetPlaylistItems( - playlist_id.id().to_string(), - next_offset, - )); - } - } - } - - pub fn apply_sorted_playlist_tracks_if_current( - &mut self, - playlist_id: &PlaylistId<'_>, - tracks: Vec<FullTrack>, - ) -> bool { - if !self.is_playlist_track_table_active_for(playlist_id) { - return false; - } - - let tracks = tracks.iter().map(TrackInfo::from).collect(); - self.replace_track_table_tracks(tracks); - self.view.track_table_index = 0; - true - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::app::test_support::*; - - fn empty_playlist_page( - offset: u32, - total: u32, - limit: u32, - has_next: bool, - ) -> Paged<(u32, PlayableInfo)> { - Paged { - items: vec![], - limit, - next: has_next.then(|| "https://example.com/playlists/test/items?next".to_string()), - offset, - previous: None, - total, - } - } - - fn playlist_page( - offset: u32, - total: u32, - ids: &[&str], - has_next: bool, - ) -> Paged<(u32, PlayableInfo)> { - Paged { - items: ids - .iter() - .enumerate() - .map(|(index, id)| { - let position = offset + index as u32; - let track = PlayableInfo::Track(TrackInfo::from(&full_track( - id, - &format!("Track {offset}-{index}"), - ))); - (position, track) - }) - .collect(), - limit: ids.len() as u32, - next: has_next.then(|| "https://example.com/playlists/test/items?next".to_string()), - offset, - previous: None, - total, - } - } - - fn playlist_id(id: &str) -> PlaylistId<'static> { - PlaylistId::from_id(id).unwrap().into_static() - } - - #[test] - fn reset_playlist_tracks_view_clears_cached_pages_and_bumps_generation() { - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - let playlist_id = PlaylistId::from_id("37i9dQZF1DXcBWIGoYBM5M") - .unwrap() - .into_static(); - app.playlist_tracks_prefetch_generation = 4; - app.playlist_track_table_id = Some(playlist_id.clone()); - app - .playlist_track_pages - .upsert_page_by_offset(empty_playlist_page(0, 40, 20, true)); - app.playlist_tracks = Some(empty_playlist_page(0, 40, 20, true)); - app.playlist_offset = 20; - app.view.track_table_index = 1; - app.track_table.tracks = vec![ - TrackInfo::from(&full_track("0000000000000000000001", "Track 1")), - TrackInfo::from(&full_track("0000000000000000000002", "Track 2")), - ]; - - app.reset_playlist_tracks_view(playlist_id.clone(), TrackTableContext::MyPlaylists); - - assert_eq!(app.playlist_tracks_prefetch_generation, 5); - assert_eq!(app.playlist_track_table_id, Some(playlist_id)); - assert!(app.playlist_track_pages.pages.is_empty()); - assert!(app.playlist_tracks.is_none()); - assert_eq!(app.playlist_offset, 0); - assert!(app.track_table.tracks.is_empty()); - assert_eq!(app.view.track_table_index, 0); - assert_eq!( - app.track_table.context, - Some(TrackTableContext::MyPlaylists) - ); - } - - #[test] - fn playlist_next_requests_adjacent_offset_when_cache_is_sparse() { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - let playlist_id = playlist_id("37i9dQZF1DXcBWIGoYBM5M"); - let first_page = empty_playlist_page(0, 100, 20, true); - let last_page = empty_playlist_page(80, 100, 20, false); - - app.track_table.context = Some(TrackTableContext::MyPlaylists); - app.playlist_track_table_id = Some(playlist_id.clone()); - app - .playlist_track_pages - .upsert_page_by_offset(first_page.clone()); - app.playlist_track_pages.upsert_page_by_offset(last_page); - app.playlist_tracks = Some(first_page); - app.playlist_offset = 0; - - app.get_playlist_tracks_next(); - - match rx.recv().unwrap() { - IoEvent::GetPlaylistItems(id, offset) => { - assert_eq!(id, playlist_id.id()); - assert_eq!(offset, 20); - } - _ => panic!("unexpected event"), - } - } - - #[test] - fn playlist_next_uses_cached_adjacent_page_before_fetching() { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - let playlist_id = playlist_id("37i9dQZF1DX4WYpdgoIcn6"); - let first_page = empty_playlist_page(0, 60, 20, true); - let second_page = empty_playlist_page(20, 60, 20, true); - - app.track_table.context = Some(TrackTableContext::MyPlaylists); - app.playlist_track_table_id = Some(playlist_id.clone()); - app - .playlist_track_pages - .upsert_page_by_offset(first_page.clone()); - app - .playlist_track_pages - .upsert_page_by_offset(second_page.clone()); - app.playlist_tracks = Some(first_page); - app.playlist_offset = 0; - - app.get_playlist_tracks_next(); - - assert_eq!(app.playlist_offset, 0); - assert_eq!( - app.playlist_tracks.as_ref().map(|page| page.offset), - Some(20) - ); - match rx.recv().unwrap() { - IoEvent::CurrentUserSavedTracksContains(track_ids) => { - assert!(track_ids.is_empty()); - } - _ => panic!("unexpected event"), - } - assert!(rx.try_recv().is_err()); - } - - #[test] - fn playlist_continuous_table_stops_at_sparse_cache_gap() { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - let playlist_id = playlist_id("37i9dQZF1DX4WYpdgoIcn6"); - let first_page = playlist_page( - 0, - 6, - &["0000000000000000000001", "0000000000000000000002"], - true, - ); - let sparse_page = playlist_page( - 4, - 6, - &["0000000000000000000005", "0000000000000000000006"], - false, - ); - - app.track_table.context = Some(TrackTableContext::MyPlaylists); - app.playlist_track_table_id = Some(playlist_id); - app.playlist_track_pages.upsert_page_by_offset(first_page); - app.playlist_track_pages.upsert_page_by_offset(sparse_page); - - app.set_playlist_tracks_to_table_continuous(); - - assert_eq!(app.track_table.tracks.len(), 2); - assert_eq!(app.playlist_track_positions, Some(vec![0, 1])); - match rx.recv().unwrap() { - IoEvent::CurrentUserSavedTracksContains(track_ids) => { - assert_eq!(track_ids.len(), 2); - } - _ => panic!("unexpected event"), - } - } - - #[test] - fn playlist_next_cached_page_applies_pending_continuous_index() { - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - let playlist_id = playlist_id("37i9dQZF1DX4WYpdgoIcn6"); - let first_page = playlist_page( - 0, - 4, - &["0000000000000000000001", "0000000000000000000002"], - true, - ); - let second_page = playlist_page( - 2, - 4, - &["0000000000000000000003", "0000000000000000000004"], - false, - ); - - app.track_table.context = Some(TrackTableContext::MyPlaylists); - app.playlist_track_table_id = Some(playlist_id); - app - .playlist_track_pages - .upsert_page_by_offset(first_page.clone()); - app.playlist_track_pages.upsert_page_by_offset(second_page); - app.playlist_tracks = Some(first_page); - app.track_table.tracks = vec![ - TrackInfo::from(&full_track("0000000000000000000001", "Track 1")), - TrackInfo::from(&full_track("0000000000000000000002", "Track 2")), - ]; - app.view.track_table_index = 1; - app.pending_track_table_selection = Some(PendingTrackSelection::Index(2)); - - app.get_playlist_tracks_next(); - - assert_eq!(app.track_table.tracks.len(), 4); - assert_eq!(app.view.track_table_index, 2); - assert_eq!(app.playlist_track_positions, Some(vec![0, 1, 2, 3])); - } - - #[test] - fn playlist_search_results_preserve_source_positions_and_handle_no_matches() { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - let playlist_id = playlist_id("37i9dQZF1DX4WYpdgoIcn6"); - - app.track_table.context = Some(TrackTableContext::MyPlaylists); - app.playlist_track_table_id = Some(playlist_id.clone()); - app.pending_playlist_track_search = Some("track".to_string()); - - assert!(app.apply_playlist_track_search_results( - &playlist_id, - "track".to_string(), - vec![ - (full_track("0000000000000000000002", "Second"), 8), - (full_track("0000000000000000000004", "Fourth"), 11), - ], - )); - - assert_eq!(app.active_playlist_track_filter, Some("track".to_string())); - assert!(app.pending_playlist_track_search.is_none()); - assert_eq!(app.track_table.tracks.len(), 2); - assert_eq!(app.playlist_track_positions, Some(vec![8, 11])); - match rx.recv().unwrap() { - IoEvent::CurrentUserSavedTracksContains(track_ids) => { - assert_eq!(track_ids.len(), 2); - } - _ => panic!("unexpected event"), - } - - assert!(app.apply_playlist_track_search_results(&playlist_id, "none".to_string(), vec![])); - assert!(app.track_table.tracks.is_empty()); - assert_eq!(app.playlist_track_positions, Some(vec![])); - } - - #[test] - fn clearing_playlist_search_restores_cached_continuous_view() { - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - let playlist_id = playlist_id("37i9dQZF1DX4WYpdgoIcn6"); - let page = playlist_page( - 0, - 2, - &["0000000000000000000001", "0000000000000000000002"], - false, - ); - - app.track_table.context = Some(TrackTableContext::MyPlaylists); - app.playlist_track_table_id = Some(playlist_id); - app.playlist_track_pages.upsert_page_by_offset(page); - app.active_playlist_track_filter = Some("second".to_string()); - app.track_table.tracks = vec![TrackInfo::from(&full_track( - "0000000000000000000002", - "Second", - ))]; - app.playlist_track_positions = Some(vec![1]); - - app.clear_playlist_track_filter(); - - assert!(app.active_playlist_track_filter.is_none()); - assert_eq!(app.track_table.tracks.len(), 2); - assert_eq!(app.playlist_track_positions, Some(vec![0, 1])); - } - - #[test] - fn apply_sorted_playlist_tracks_if_current_requires_matching_playlist_identity_and_context() { - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - let sidebar_playlist_id = playlist_id("37i9dQZF1DXcBWIGoYBM5M"); - let active_playlist_id = playlist_id("37i9dQZF1DX4WYpdgoIcn6"); - let original_track = full_track("0000000000000000000001", "Original"); - - app.track_table.tracks = vec![TrackInfo::from(&original_track)]; - app.track_table.context = Some(TrackTableContext::PlaylistSearch); - app.playlist_track_table_id = Some(active_playlist_id.clone()); - - assert!(!app.apply_sorted_playlist_tracks_if_current( - &sidebar_playlist_id, - vec![full_track("0000000000000000000002", "Wrong Playlist")], - )); - assert_eq!( - app.track_table.tracks[0].id.as_deref(), - original_track.id.as_ref().map(|id| id.id()) - ); - - app.track_table.context = Some(TrackTableContext::SavedTracks); - assert!(!app.apply_sorted_playlist_tracks_if_current( - &active_playlist_id, - vec![full_track("0000000000000000000003", "Wrong Context")], - )); - assert_eq!( - app.track_table.tracks[0].id.as_deref(), - original_track.id.as_ref().map(|id| id.id()) - ); - } - - #[test] - fn current_route_playlist_track_table_requires_track_table_route() { - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - let playlist_id = playlist_id("37i9dQZF1DXcBWIGoYBM5M"); - - app.track_table.context = Some(TrackTableContext::MyPlaylists); - app.playlist_track_table_id = Some(playlist_id.clone()); - app.push_navigation_stack(RouteId::Search, ActiveBlock::SearchResultBlock); - - assert!(app.is_playlist_track_table_active_for(&playlist_id)); - assert!(!app.is_current_route_playlist_track_table_for(&playlist_id)); - - app.push_navigation_stack(RouteId::TrackTable, ActiveBlock::TrackTable); - assert!(app.is_current_route_playlist_track_table_for(&playlist_id)); - } -} diff --git a/src/core/app/playlists.rs b/src/core/app/playlists.rs deleted file mode 100644 index 54971aca..00000000 --- a/src/core/app/playlists.rs +++ /dev/null @@ -1,569 +0,0 @@ -use super::*; - -/// Playlist URIs with this scheme address the local YouTube playlists file. -pub(crate) const YOUTUBE_PLAYLIST_PREFIX: &str = "youtube:playlist:"; - -#[derive(Clone)] -pub struct PendingPlaylistTrackAdd { - /// Track id/URI passed through to `AddTrackToPlaylist`. - pub track_id: String, - pub track_name: String, -} - -#[derive(Clone)] -pub struct PendingPlaylistTrackRemoval { - /// Playlist id/URI passed through to `RemoveTrackFromPlaylistAtPosition`. - pub playlist_id: String, - pub playlist_name: String, - /// Track id/URI passed through to `RemoveTrackFromPlaylistAtPosition`. - pub track_id: String, - pub track_name: String, - pub position: usize, -} - -/// A row in the add-to-playlist picker dialog: a navigable folder or an -/// editable destination playlist. -#[derive(Debug)] -pub enum PlaylistPickerRow<'a> { - Folder(&'a PlaylistFolder), - Playlist(&'a PlaylistInfo), -} - -/// Which stage of the "Create Playlist" form we are on -#[derive(Clone, Copy, PartialEq, Debug, Default)] -pub enum CreatePlaylistStage { - #[default] - Name, - AddTracks, -} - -/// Which panel inside the AddTracks stage has focus -#[derive(Clone, Copy, PartialEq, Debug, Default)] -pub enum CreatePlaylistFocus { - #[default] - SearchInput, - SearchResults, - AddedTracks, -} - -impl App { - pub fn clear_playlist_track_dialog_state(&mut self) { - self.pending_playlist_track_add = None; - self.pending_playlist_track_removal = None; - self.view.playlist_picker_selected_index = 0; - self.view.playlist_picker_folder_id = 0; - } - - pub fn clear_dialog_state(&mut self) { - self.view.dialog = None; - self.view.confirm = false; - self.pending_keybinding_persist = None; - self.clear_playlist_track_dialog_state(); - } - - /// Reset the create-playlist form; the caller pops the frame. - pub fn clear_create_playlist_form_state(&mut self) { - self.view.create_playlist_name = Vec::new(); - self.view.create_playlist_name_idx = 0; - self.view.create_playlist_name_cursor = 0; - self.view.create_playlist_stage = CreatePlaylistStage::Name; - self.create_playlist_tracks = Vec::new(); - self.create_playlist_search_results = Vec::new(); - self.view.create_playlist_search_input = Vec::new(); - self.view.create_playlist_search_idx = 0; - self.view.create_playlist_search_cursor = 0; - self.view.create_playlist_selected_result = 0; - self.view.create_playlist_focus = CreatePlaylistFocus::SearchInput; - } - - pub fn playlist_is_editable(&self, playlist: &PlaylistInfo) -> bool { - let Some(user) = &self.user else { - return false; - }; - - playlist.owner_id.as_deref() == Some(user.id.as_str()) || playlist.collaborative - } - - pub fn editable_playlists(&self) -> Vec<&PlaylistInfo> { - self - .all_playlists - .iter() - .filter(|playlist| self.playlist_is_editable(playlist)) - .collect() - } - - /// The rows offered by the add-track picker dialog for the active source: - /// local YouTube playlists under YouTube (flat), otherwise the user's - /// editable Spotify playlists plus folder rows scoped to - /// `playlist_picker_folder_id`, mirroring the sidebar's folder navigation. - pub fn playlist_picker_items(&self) -> Vec<PlaylistPickerRow<'_>> { - if self.active_source == Source::YouTube { - return self - .youtube_playlists - .iter() - .map(PlaylistPickerRow::Playlist) - .collect(); - } - - // Fallback: folder items never built (rootlist fetch failed, streaming - // disabled, …) — flat editable list, same as the pre-folder behavior. - if self.playlist_folder_items.is_empty() { - return self - .editable_playlists() - .into_iter() - .map(PlaylistPickerRow::Playlist) - .collect(); - } - - let mut rows: Vec<PlaylistPickerRow> = self - .playlist_folder_items - .iter() - .filter_map(|item| match item { - PlaylistFolderItem::Folder(f) if f.current_id == self.view.playlist_picker_folder_id => { - Some(PlaylistPickerRow::Folder(f)) - } - PlaylistFolderItem::Playlist { index, current_id } - if *current_id == self.view.playlist_picker_folder_id => - { - self - .all_playlists - .get(*index) - .filter(|playlist| self.playlist_is_editable(playlist)) - .map(PlaylistPickerRow::Playlist) - } - _ => None, - }) - .collect(); - if self.user_config.behavior.group_folders_first { - rows.sort_by_key(|row| !matches!(row, PlaylistPickerRow::Folder(_))); - } - rows - } - - pub fn begin_add_track_to_playlist_flow(&mut self, track_id: Option<String>, track_name: String) { - let Some(track_id) = track_id else { - self.set_status_message("Track cannot be added to playlist".to_string(), 4); - return; - }; - - // Under the YouTube source the destinations are the *local* playlists - // (youtube_playlists.yml), not the Spotify ones — no user/playlist - // fetches apply. The picker's Enter routes by source too. - if self.active_source == Source::YouTube { - if self.youtube_playlists.is_empty() { - // Kick a (re)load in case the file changed on disk; if it is - // genuinely empty the user needs to create a playlist first. - self.dispatch(IoEvent::GetYouTubePlaylists); - self.set_status_message( - "No YouTube playlists yet — create one from the sidebar".to_string(), - 4, - ); - return; - } - self.clear_dialog_state(); - self.pending_playlist_track_add = Some(PendingPlaylistTrackAdd { - track_id, - track_name, - }); - self.push_navigation_stack( - RouteId::Dialog, - ActiveBlock::Dialog(DialogContext::AddTrackToPlaylistPicker), - ); - return; - } - - let mut requested_data = false; - if self.user.is_none() { - self.dispatch(IoEvent::GetUser); - requested_data = true; - } - if self.playlists.is_none() { - self.dispatch(IoEvent::GetPlaylists); - requested_data = true; - } - if requested_data { - self.set_status_message("Playlist destinations loading, try again".to_string(), 4); - return; - } - - if self.editable_playlists().is_empty() { - self.set_status_message("No editable playlists available".to_string(), 4); - return; - } - - self.clear_dialog_state(); - self.pending_playlist_track_add = Some(PendingPlaylistTrackAdd { - track_id, - track_name, - }); - self.push_navigation_stack( - RouteId::Dialog, - ActiveBlock::Dialog(DialogContext::AddTrackToPlaylistPicker), - ); - } - - /// Resolve the track table's current selection and open the - /// add-to-playlist picker for it. Silent no-op when no row is selected, - /// exactly like the `w` key that has always driven it. - /// Add a track to a playlist, routed by the playlist URI's scheme. - pub(crate) fn add_track_to_playlist(&mut self, playlist: String, track: String) { - if playlist.starts_with(YOUTUBE_PLAYLIST_PREFIX) { - self.dispatch(IoEvent::AddTrackToYouTubePlaylist(playlist, track)); - } else { - self.dispatch(IoEvent::AddTrackToPlaylist(playlist, track)); - } - } - - /// Remove a track from a playlist, routed by the playlist URI's scheme. - pub(crate) fn remove_track_from_playlist( - &mut self, - playlist: String, - track: String, - position: usize, - ) { - if playlist.starts_with(YOUTUBE_PLAYLIST_PREFIX) { - self.dispatch(IoEvent::RemoveTrackFromYouTubePlaylist(playlist, track)); - } else { - self.dispatch(IoEvent::RemoveTrackFromPlaylistAtPosition( - playlist, track, position, - )); - } - } - - pub fn begin_add_track_to_playlist_flow_from_selection(&mut self) { - let Some(track) = self.track_table.tracks.get(self.view.track_table_index) else { - return; - }; - let track_id = track.id.clone(); - let track_name = track.name.clone(); - self.begin_add_track_to_playlist_flow(track_id, track_name); - } - - /// Open the add-to-playlist picker for the item playing now, through the - /// ownership order. A queued Spotify track stages the slot's own track. - pub fn begin_add_playing_track_to_playlist_flow(&mut self) { - match self.playing_item() { - PlayingItem::Spotify(PlayableItem::Track(track)) => { - let track_id = track.id.as_ref().map(|id| id.uri()); - let name = track.name.clone(); - self.begin_add_track_to_playlist_flow(track_id, name); - } - PlayingItem::Spotify(PlayableItem::Episode(_)) => { - self.set_status_message("Only tracks can be added to playlists".to_string(), 4); - } - PlayingItem::Spotify(_) => {} - PlayingItem::QueuedSpotify(track) => { - let track_id = track.uri.clone(); - let name = track.name.clone(); - self.begin_add_track_to_playlist_flow(track_id, name); - } - PlayingItem::NotSpotify => { - self.set_status_message("Add to playlist needs a Spotify track playing", 4); - } - PlayingItem::Nothing => { - self.set_status_message("No track currently playing".to_string(), 4); - } - } - } - - /// Stage a remove-track-from-playlist confirmation for the track table's - /// current selection and push the confirm dialog. Body moved verbatim - /// from the track-table handler's `open_remove_from_playlist_dialog`. - pub fn begin_remove_track_from_playlist_flow(&mut self) { - // Local YouTube playlist: same confirm dialog, routed (by the - // `youtube:playlist:` prefix in the pending target) to the local file edit - // instead of the Spotify API. No snapshot position — removal is by video id. - if self.track_table.context == Some(TrackTableContext::YouTubePlaylist) { - let Some(playlist_uri) = self.youtube_open_playlist.clone() else { - self.set_status_message("No YouTube playlist is open".to_string(), 4); - return; - }; - let playlist_name = self - .youtube_playlists - .iter() - .find(|p| p.uri == playlist_uri) - .map(|p| p.name.clone()) - .unwrap_or_else(|| "YouTube playlist".to_string()); - let Some(track) = self.track_table.tracks.get(self.view.track_table_index) else { - return; - }; - let Some(track_id) = track.id.clone() else { - self.set_status_message("Track cannot be edited in playlist".to_string(), 4); - return; - }; - let track_name = track.name.clone(); - self.clear_dialog_state(); - self.pending_playlist_track_removal = Some(PendingPlaylistTrackRemoval { - playlist_id: playlist_uri, - playlist_name, - track_id, - track_name, - position: 0, // unused for local YouTube playlists - }); - self.push_navigation_stack( - RouteId::Dialog, - ActiveBlock::Dialog(DialogContext::RemoveTrackFromPlaylistConfirm), - ); - return; - } - - let playlist_context = match self.current_playlist_removal_target() { - Some(context) => context, - None => { - self.set_status_message( - "Remove only works in selected playlist views".to_string(), - 4, - ); - return; - } - }; - - let track = match self.track_table.tracks.get(self.view.track_table_index) { - Some(track) => track, - None => return, - }; - - let track_id = match track.id.clone() { - Some(id) => id, - None => { - self.set_status_message("Track cannot be edited in playlist".to_string(), 4); - return; - } - }; - let track_name = track.name.clone(); - - let position = match self - .playlist_track_positions - .as_ref() - .and_then(|positions| positions.get(self.view.track_table_index)) - .copied() - { - Some(position) => position, - None => { - self.set_status_message("Cannot resolve track position for removal".to_string(), 4); - return; - } - }; - - self.clear_dialog_state(); - self.pending_playlist_track_removal = Some(PendingPlaylistTrackRemoval { - playlist_id: playlist_context.0, - playlist_name: playlist_context.1, - track_id, - track_name, - position, - }); - self.push_navigation_stack( - RouteId::Dialog, - ActiveBlock::Dialog(DialogContext::RemoveTrackFromPlaylistConfirm), - ); - } - - /// The active playlist's (base62 id, name) pair for the removal flow. - fn current_playlist_removal_target(&self) -> Option<(String, String)> { - let playlist_id = self.current_playlist_track_table_id()?; - let playlist_id = playlist_id.id().to_string(); - let playlist_name = self - .all_playlists - .iter() - .find(|playlist| playlist.id.as_deref() == Some(playlist_id.as_str())) - .map(|playlist| playlist.name.clone()) - .or_else(|| { - self - .search_results - .playlists - .as_ref() - .and_then(|playlists| { - playlists - .items - .iter() - .find(|playlist| playlist.id.as_deref() == Some(playlist_id.as_str())) - }) - .map(|playlist| playlist.name.clone()) - })?; - Some((playlist_id, playlist_name)) - } - - /// The sidebar-selected playlist's id for the unfollow confirm; `None` on a - /// folder or pin row. - pub fn selected_sidebar_playlist_id(&self) -> Option<String> { - let selected_index = self.view.selected_playlist_index?; - // Row 0 is the "+ Add Playlist" entry; display items start at row 1. - let display_index = selected_index.checked_sub(1)?; - let index = match self.get_playlist_display_item_at(display_index)? { - PlaylistFolderItem::Playlist { index, .. } => *index, - // A folder row or the community pin is not an unfollowable playlist. - PlaylistFolderItem::Folder(_) | PlaylistFolderItem::CommunityPin => return None, - }; - self - .all_playlists - .get(index) - .and_then(|playlist| playlist.id.clone()) - } - - /// The highlighted search-result playlist's Spotify id. - pub fn selected_search_result_playlist_id(&self) -> Option<String> { - let playlists = self.search_results.playlists.as_ref()?; - let selected_index = self.view.search_selected_playlists_index?; - playlists.items.get(selected_index)?.id.clone() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[cfg(feature = "streaming")] - #[test] - fn add_playing_track_stages_the_queued_track_not_the_suspended_one() { - use crate::core::app::test_support::*; - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.active_source = Source::YouTube; - app.youtube_playlists = vec![PlaylistInfo { - uri: "youtube:playlist:y1".to_string(), - ..playlist_info("y1", "Local List", "owner", false) - }]; - app.current_playback_context = Some(playing_track_context(full_track( - "0000000000000000000001", - "Suspended", - ))); - app.queue_now = Some(crate::infra::queue::QueueNowPlaying::Spotify { - track: queue_track(Some("spotify:track:queued"), "Queued"), - }); - - app.begin_add_playing_track_to_playlist_flow(); - - let pending = app - .pending_playlist_track_add - .as_ref() - .expect("staged for the picker"); - assert_eq!(pending.track_id, "spotify:track:queued"); - assert_eq!(pending.track_name, "Queued"); - assert!(rx.try_recv().is_err()); - } - - #[test] - fn editable_playlists_include_owned_and_collaborative_only() { - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.user = Some(user_info("spotatui-owner")); - app.all_playlists = vec![ - playlist_info("37i9dQZF1DXcBWIGoYBM5M", "Owned", "spotatui-owner", false), - playlist_info( - "37i9dQZF1DX4WYpdgoIcn6", - "Collaborative", - "friend-owner", - true, - ), - playlist_info("37i9dQZF1DWZqd5JICZI0u", "Followed", "friend-owner", false), - ]; - - let editable_names = app - .editable_playlists() - .into_iter() - .map(|playlist| playlist.name.clone()) - .collect::<Vec<_>>(); - - assert_eq!(editable_names, vec!["Owned", "Collaborative"]); - } - - #[test] - fn begin_add_track_to_playlist_flow_requires_editable_playlist() { - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.user = Some(user_info("spotatui-owner")); - app.playlists = Some(Paged { - total: 1, - ..Default::default() - }); - app.all_playlists = vec![playlist_info( - "37i9dQZF1DWZqd5JICZI0u", - "Followed", - "friend-owner", - false, - )]; - - app.begin_add_track_to_playlist_flow( - Some("0000000000000000000001".to_string()), - "Track".to_string(), - ); - - assert_eq!( - app.status_message.as_deref(), - Some("No editable playlists available") - ); - assert!(app.pending_playlist_track_add.is_none()); - } - - /// A sidebar with the "+ Add Playlist" row, then one folder, then one - /// playlist row (both visible at the root level). - fn app_with_sidebar_playlist() -> App { - // Read-only resolvers: no channel needed, nothing dispatches. - let mut app = App::default(); - app.user_config.behavior.pin_community_playlist = false; - app.all_playlists = vec![playlist_info( - "37i9dQZF1DXcBWIGoYBM5M", - "Owned", - "spotatui-owner", - false, - )]; - app.playlist_folder_items = vec![ - PlaylistFolderItem::Folder(PlaylistFolder { - name: "Mixes".to_string(), - current_id: 0, - target_id: 1, - }), - PlaylistFolderItem::Playlist { - index: 0, - current_id: 0, - }, - ]; - app - } - - #[test] - fn selected_sidebar_playlist_id_resolves_the_row_below_add_playlist() { - let mut app = app_with_sidebar_playlist(); - app.user = Some(user_info("spotatui-owner")); - // Row 0 is "+ Add Playlist", row 1 the folder, row 2 the playlist. - app.view.selected_playlist_index = Some(2); - - assert_eq!( - app.selected_sidebar_playlist_id().as_deref(), - Some("37i9dQZF1DXcBWIGoYBM5M") - ); - } - - #[test] - fn selected_sidebar_playlist_id_skips_rows_that_are_not_playlists() { - let mut app = app_with_sidebar_playlist(); - app.user = Some(user_info("spotatui-owner")); - - app.view.selected_playlist_index = Some(0); - assert_eq!(app.selected_sidebar_playlist_id(), None, "+ Add Playlist"); - app.view.selected_playlist_index = Some(1); - assert_eq!(app.selected_sidebar_playlist_id(), None, "a folder row"); - app.view.selected_playlist_index = None; - assert_eq!(app.selected_sidebar_playlist_id(), None, "no selection"); - } - - #[test] - fn selected_search_result_playlist_id_resolves_the_selection() { - let mut app = App::default(); - app.search_results.playlists = Some(Paged { - items: vec![ - playlist_info("37i9dQZF1DWZqd5JICZI0u", "First", "friend-owner", false), - playlist_info("37i9dQZF1DXcBWIGoYBM5M", "Second", "friend-owner", false), - ], - total: 2, - ..Default::default() - }); - app.view.search_selected_playlists_index = Some(1); - - assert_eq!( - app.selected_search_result_playlist_id().as_deref(), - Some("37i9dQZF1DXcBWIGoYBM5M") - ); - } -} diff --git a/src/core/app/plugins.rs b/src/core/app/plugins.rs deleted file mode 100644 index 83c61df3..00000000 --- a/src/core/app/plugins.rs +++ /dev/null @@ -1,147 +0,0 @@ -use super::*; - -/// Data domains plugins can request through the scripting API. Each domain has -/// a generation counter in [`PluginDataGenerations`] that the network layer -/// bumps whenever it writes that domain to `App`, so the script engine can tell -/// "the data a plugin asked for has arrived" without a per-request completion -/// signal. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PluginDataKind { - Playlists, - Queue, - Search, - SavedTracks, - SavedAlbums, - SavedShows, - RecentlyPlayed, - Devices, - Lyrics, -} - -impl PluginDataKind { - pub const COUNT: usize = 9; - - pub fn index(self) -> usize { - self as usize - } -} - -/// Per-domain write counters for plugin data requests. See [`PluginDataKind`]. -#[derive(Debug, Default)] -pub struct PluginDataGenerations { - counters: [u64; PluginDataKind::COUNT], -} - -impl PluginDataGenerations { - pub fn bump(&mut self, kind: PluginDataKind) { - let slot = &mut self.counters[kind.index()]; - *slot = slot.wrapping_add(1); - } - - // Only the scripting engine reads generations; the network layer just bumps. - #[cfg_attr(not(feature = "scripting"), allow(dead_code))] - pub fn get(&self, kind: PluginDataKind) -> u64 { - self.counters[kind.index()] - } -} - -impl App { - /// Queue a plugin command name to be executed by the scripting engine. - #[cfg_attr(not(feature = "scripting"), allow(dead_code))] - pub fn queue_plugin_command(&mut self, name: String) { - self.pending_plugin_commands.push(name); - } - - /// Show a plugin popup, resetting its scroll to the top. - #[cfg_attr(not(feature = "scripting"), allow(dead_code))] - pub(crate) fn show_plugin_popup(&mut self, popup: crate::core::plugin_api::PluginPopup) { - self.plugin_popup = Some(popup); - self.view.plugin_popup_scroll = 0; - } - - /// Dismiss the plugin popup and reset its scroll. - pub(crate) fn close_plugin_popup(&mut self) { - self.plugin_popup = None; - self.view.plugin_popup_scroll = 0; - } - - /// Navigate to a registered plugin screen, resetting its scroll. A no-op - /// push when the screen is already the current route (the scroll still - /// resets, matching the historic behavior). - #[cfg_attr(not(feature = "scripting"), allow(dead_code))] - pub(crate) fn open_plugin_screen(&mut self, name: String) { - if self.get_current_route().id != RouteId::PluginScreen(name.clone()) { - self.push_navigation_stack(RouteId::PluginScreen(name), ActiveBlock::PluginScreen); - } - self.view.plugin_screen_scroll = 0; - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // These live here rather than in `core/action/tests.rs` because they must - // seed the scroll fields to a nonzero value first, and `core/app/` is the - // one place the `view_writes_outside_tui` gate exempts. - - #[test] - fn show_plugin_popup_resets_the_scroll() { - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.view.plugin_popup_scroll = 5; - - app.show_plugin_popup(crate::core::plugin_api::PluginPopup { - title: "Hi".to_string(), - lines: Vec::new(), - }); - - assert_eq!( - app.plugin_popup.as_ref().map(|p| p.title.as_str()), - Some("Hi") - ); - assert_eq!(app.view.plugin_popup_scroll, 0); - } - - #[test] - fn close_plugin_popup_clears_the_popup_and_resets_the_scroll() { - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.show_plugin_popup(crate::core::plugin_api::PluginPopup { - title: "Hi".to_string(), - lines: Vec::new(), - }); - app.view.plugin_popup_scroll = 5; - - app.close_plugin_popup(); - - assert!(app.plugin_popup.is_none()); - assert_eq!(app.view.plugin_popup_scroll, 0); - } - - #[test] - fn open_plugin_screen_resets_the_scroll_even_when_already_current() { - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - - app.view.plugin_screen_scroll = 4; - app.open_plugin_screen("stats".to_string()); - assert_eq!( - app.get_current_route().id, - RouteId::PluginScreen("stats".to_string()) - ); - assert_eq!(app.view.plugin_screen_scroll, 0, "reset on the first open"); - - // Re-opening the already-current screen must not stack a second frame, - // but the scroll still resets (the historic behavior). - app.view.plugin_screen_scroll = 4; - app.open_plugin_screen("stats".to_string()); - assert_eq!(app.view.plugin_screen_scroll, 0, "reset on a re-open too"); - app.pop_navigation_stack(); - assert_eq!( - app.get_current_route().id, - RouteId::Home, - "one pop suffices, so the re-open pushed no second frame" - ); - } -} diff --git a/src/core/app/queue.rs b/src/core/app/queue.rs deleted file mode 100644 index 9e04d0f7..00000000 --- a/src/core/app/queue.rs +++ /dev/null @@ -1,418 +0,0 @@ -use super::*; - -/// Domain-level representation of the playback queue. -/// Replaces `rspotify::model::CurrentUserQueue` in `App` state. -#[derive(Debug, Clone)] -pub struct QueueState { - pub currently_playing: Option<PlayableInfo>, - pub queue: Vec<PlayableInfo>, -} - -impl App { - /// Add a track to the native cross-source queue. - /// - /// Rejects tracks with no URI and radio streams (a live stream is not a finite - /// track). Spotify tracks on an external Connect device keep today's Web-API - /// queue behavior (there is no native sink to play them through); everything - /// else is pushed onto [`Self::native_queue`]. - pub fn add_track_to_native_queue(&mut self, track: TrackInfo) { - let Some(uri) = track.uri.clone() else { - self.set_status_message("Cannot queue: track has no URI", 3); - return; - }; - if uri.starts_with("radio:") { - self.set_status_message("Radio stations can't be queued", 3); - return; - } - // A Spotify track controlled on an external device has no native sink to - // play through, so fall back to the Spotify Web-API queue. - if matches!( - crate::core::queue::queue_item_source(&uri), - crate::core::queue::QueueItemSource::Spotify - ) && self.spotify_external_device_active() - { - self.dispatch(IoEvent::AddItemToQueue(uri)); - return; - } - let name = track.name.clone(); - self.native_queue.push(track); - self.set_status_message(format!("Queued: {name}"), 3); - // Keep the Spotify mirror queue ([`Self::queue`]) current while a native - // Spotify context is playing: it is the snapshot source for the resume - // target when this newly-queued item later suspends the context. - #[cfg(feature = "streaming")] - if self.is_native_streaming_active_for_playback() && !self.queue_owns_playback() { - self.dispatch(IoEvent::GetQueue); - } - } - - /// The position while its URI still matches, else the first item with that - /// URI (the queue can hold duplicates). - fn queue_position_for(&self, uri: &str, position: usize) -> Option<usize> { - if self - .native_queue - .get(position) - .and_then(|t| t.uri.as_deref()) - == Some(uri) - { - return Some(position); - } - self - .native_queue - .iter() - .position(|t| t.uri.as_deref() == Some(uri)) - } - - /// Jump to a queued item: drop everything before it and hand the sink to - /// the queue, suspending a playing context mid-track first. - pub(crate) fn play_queue_item(&mut self, uri: &str, position: usize) { - let Some(skip) = self.queue_position_for(uri, position) else { - return; - }; - // Drop the items before the selected one so it becomes the queue head. - self.native_queue.drain(..skip); - if !self.queue_owns_playback() { - self.suspend_active_decoded_context_mid_track(); - // No decoded context recorded a suspension: a native-Spotify context may be - // playing instead — suspend it so the queue hands back to it on drain. - #[cfg(feature = "streaming")] - if self.queue_suspended.is_none() { - self.suspend_native_spotify_context_mid_track(); - } - } - self.dispatch(IoEvent::AdvanceNativeQueue); - } - - /// Remove one native-queue item; a no-op when it is not in the queue. - pub(crate) fn remove_queue_item(&mut self, uri: &str, position: usize) { - if let Some(idx) = self.queue_position_for(uri, position) { - self.native_queue.remove(idx); - } - } - - /// Move one native-queue item to index `to`; a no-op when out of range. - pub(crate) fn move_queue_item(&mut self, uri: &str, from: usize, to: usize) { - let Some(from) = self.queue_position_for(uri, from) else { - return; - }; - if to >= self.native_queue.len() { - return; - } - let item = self.native_queue.remove(from); - self.native_queue.insert(to, item); - } - - /// Whether the native queue's playback slot currently owns the output (either a - /// decoded queued track or a native-streamed Spotify one). The single gated - /// entry point for every queue-ownership check; reduces to `false` in a build - /// where the slot cannot exist (slim, or one whose only decoded source is - /// internet radio, which is never queueable). - pub fn queue_owns_playback(&self) -> bool { - #[cfg(feature = "queue")] - { - self.queue_now.is_some() - } - #[cfg(not(feature = "queue"))] - { - false - } - } - - /// Whether the queue slot is playing a Spotify track via native streaming. - /// While true, librespot owns the sink and any still-`Some` decoded - /// `*_playback` struct is a *suspended* context that must stay invisible to - /// the decoded-ownership predicates (`active_decoded_source`, - /// `active_decoded_player`, transport routing) — otherwise a space-bar toggle - /// or media key would resume the suspended player on top of librespot. - pub(crate) fn queue_now_is_spotify(&self) -> bool { - #[cfg(feature = "streaming")] - { - matches!( - self.queue_now.as_ref(), - Some(QueueNowPlaying::Spotify { .. }) - ) - } - #[cfg(not(feature = "streaming"))] - { - false - } - } - - /// The queue slot's player when it is playing a *decoded* queued track (local / - /// Subsonic / Qobuz / YouTube). `None` for a Spotify slot or an empty slot. Gated - /// on exactly those four sources: they are the decoded ones a queue item can - /// name, so a build whose only decoded source is internet radio has no - /// decoded slot to look up. - #[cfg(feature = "audio-decode-queue")] - pub fn queue_now_decoded_player(&self) -> Option<&Arc<crate::infra::audio::LocalPlayer>> { - match self.queue_now.as_ref()? { - QueueNowPlaying::Decoded(d) => Some(&d.player), - #[cfg(feature = "streaming")] - QueueNowPlaying::Spotify { .. } => None, - } - } - - /// Take the queue slot, returning its player when it was a decoded track (so - /// the caller can stop it). Clears [`Self::queue_now`] either way. - #[cfg(feature = "audio-decode-queue")] - pub fn take_queue_now_decoded_player(&mut self) -> Option<Arc<crate::infra::audio::LocalPlayer>> { - match self.queue_now.take() { - Some(QueueNowPlaying::Decoded(d)) => Some(d.player), - _ => None, - } - } - - /// The track currently playing through the queue slot, if any. Used by - /// persistence to prepend it back onto the saved queue so a mid-queue quit - /// doesn't lose the in-flight track. - pub(super) fn queue_now_track(&self) -> Option<&TrackInfo> { - #[cfg(feature = "queue")] - { - match self.queue_now.as_ref()? { - #[cfg(feature = "audio-decode-queue")] - QueueNowPlaying::Decoded(d) => Some(&d.track), - #[cfg(feature = "streaming")] - QueueNowPlaying::Spotify { track } => Some(track), - } - } - #[cfg(not(feature = "queue"))] - { - None - } - } - - /// A `"{name} - {artists}"` label for the track playing through the queue slot, - /// for the Queue screen's "Now playing" row. `None` when the slot is empty. - pub fn queue_now_display(&self) -> Option<String> { - let track = self.queue_now_track()?; - Some(format!("{} - {}", track.name, track.artists.join(", "))) - } - - /// Handle a native-streaming `EndOfTrack` while the native queue is in play. - /// - /// Returns `true` when the queue took over (an `AdvanceNativeQueue` was - /// dispatched, so the caller must **not** fall back to - /// `EnsurePlaybackContinues`), `false` to let the normal continue-playback path - /// run. Three cases: - /// - /// - **A queued Spotify track just ended** (`queue_now_is_spotify`): clear the - /// slot *now* — before the advance is processed — so the Spirc self-advance - /// guard can't see the stale slot on the next `TrackChanged` and reissue the - /// finished track over the next item's download window. Pause librespot - /// (Spirc may already be loading its own next track) and advance the queue. - /// - **A stray librespot `EndOfTrack` while a decoded queued track owns the - /// sink** (`queue_owns_playback` without a Spotify slot): consume it without - /// touching the queue — advancing would skip the audible decoded track, and - /// `EnsurePlaybackContinues` would resume Spotify over it. - /// - **A context track ended with items waiting** (queue idle, non-empty): - /// snapshot the Spotify context for resume, `pause()` the streaming player to - /// preempt Spirc's own auto-advance, then advance the queue. - #[cfg(feature = "streaming")] - pub(crate) fn handle_native_spotify_track_end(&mut self) -> bool { - if self.queue_now_is_spotify() { - self.queue_now = None; - self.spotify_queue_guard_reloads = 0; - if let Some(player) = self.streaming_player.as_ref() { - player.pause(); - } - self.song_progress_ms = 0; - self.dispatch(IoEvent::AdvanceNativeQueue); - return true; - } - if self.queue_owns_playback() { - return true; - } - if !self.native_queue.is_empty() { - self.suspend_native_spotify_context_for_queue(crate::infra::queue::SuspendCause::AutoAdvance); - // Preempt Spirc: after a direct `player.load`, Spirc may try to advance to - // the next context track on its own. Pausing first stops that before the - // queue slot takes the sink. - if let Some(player) = self.streaming_player.as_ref() { - player.pause(); - } - self.song_progress_ms = 0; - self.dispatch(IoEvent::AdvanceNativeQueue); - return true; - } - false - } - - /// Spirc self-advance guard for the native-Spotify queue slot. - /// - /// A queued Spotify track plays via a direct `player.load` (no Spirc context), - /// so Spirc may try to advance to the next context track on its own when the - /// queued track ends. Given the base62 id of the track librespot just switched - /// to, this returns `Some(uri)` to reissue the queued track (Spirc fought - /// back), or `None` to leave playback alone. Bounded by - /// [`Self::spotify_queue_guard_reloads`] so a genuinely-gone track can't wedge - /// a reload loop; the budget resets whenever the queued track is confirmed - /// playing. See Risk #1 in the plan — the mitigation is pending a live - /// experiment and cannot be verified without a real Spotify session. - #[cfg(feature = "streaming")] - pub(crate) fn spotify_queue_guard_reload_uri( - &mut self, - playing_base62_id: &str, - ) -> Option<String> { - let queued_uri = match self.queue_now.as_ref()? { - #[cfg(feature = "audio-decode-queue")] - QueueNowPlaying::Decoded(_) => return None, - QueueNowPlaying::Spotify { track } => track.uri.clone(), - }?; - let queued_id = base62_id_of(&queued_uri); - if queued_id == playing_base62_id { - // The queued track is (re)confirmed playing: clear the retry budget. - self.spotify_queue_guard_reloads = 0; - return None; - } - const MAX_RELOADS: u8 = 2; - if self.spotify_queue_guard_reloads >= MAX_RELOADS { - return None; - } - self.spotify_queue_guard_reloads += 1; - Some(queued_uri) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::app::test_support::*; - - #[test] - fn add_track_to_native_queue_pushes_normal_track() { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.add_track_to_native_queue(queue_track(Some("subsonic:track:1"), "Song")); - assert_eq!(app.native_queue.len(), 1); - assert_eq!(app.native_queue[0].name, "Song"); - // No Web-API dispatch for a non-Spotify item. - assert!(rx.try_recv().is_err()); - } - - #[test] - fn add_track_to_native_queue_rejects_missing_uri() { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.add_track_to_native_queue(queue_track(None, "No URI")); - assert!(app.native_queue.is_empty()); - assert!(rx.try_recv().is_err()); - } - - #[test] - fn add_track_to_native_queue_rejects_radio() { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.add_track_to_native_queue(queue_track(Some("radio:https://example.com/live"), "Live")); - assert!(app.native_queue.is_empty()); - assert!(rx.try_recv().is_err()); - } - - #[test] - fn add_track_to_native_queue_spotify_no_context_pushes_natively() { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - // No playback context => not external => queue natively (Spotify tracks play - // via native streaming). - app.add_track_to_native_queue(queue_track(Some("spotify:track:abc"), "Spotify Song")); - assert_eq!(app.native_queue.len(), 1); - assert!(rx.try_recv().is_err()); - } - - #[test] - fn add_track_to_native_queue_spotify_external_device_dispatches_web_api() { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - // A Spotify context with no native streaming device reads as external. - app.current_playback_context = Some(make_external_context()); - app.add_track_to_native_queue(queue_track(Some("spotify:track:abc"), "Spotify Song")); - // Routed to the Web-API queue; nothing pushed to the native queue. - assert!(app.native_queue.is_empty()); - match rx.recv().unwrap() { - IoEvent::AddItemToQueue(uri) => assert_eq!(uri, "spotify:track:abc"), - _ => panic!("expected AddItemToQueue dispatch"), - } - } - - /// The Spirc self-advance guard compares bare ids: the queued track's own - /// `TrackChanged` (normalized at the event boundary) reads as confirmation, - /// not as Spirc fighting back. (Comparing against the full URI reissued every - /// queued track up to the retry budget, restarting it from position 0.) - #[cfg(feature = "streaming")] - #[test] - fn queue_guard_confirms_queued_track_from_normalized_event_id() { - use crate::infra::queue::QueueNowPlaying; - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.queue_now = Some(QueueNowPlaying::Spotify { - track: queue_track(Some("spotify:track:queued"), "Queued"), - }); - - let playing_uri = "spotify:track:queued".to_string(); - assert_eq!( - app.spotify_queue_guard_reload_uri(base62_id_of(&playing_uri)), - None, - "the queued track's own TrackChanged must not trigger a reload" - ); - } - - /// When the queued Spotify track ends, the slot must be cleared *before* the - /// advance is dispatched — a stale slot lets the Spirc self-advance guard - /// reissue the finished track over the next item's download window (heard as - /// "the Spotify song keeps playing while the YouTube track downloads"). - #[cfg(feature = "streaming")] - #[test] - fn spotify_slot_end_clears_slot_before_advancing() { - use crate::infra::queue::QueueNowPlaying; - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.queue_now = Some(QueueNowPlaying::Spotify { - track: queue_track(Some("spotify:track:queued"), "Queued"), - }); - app.spotify_queue_guard_reloads = 1; - - assert!(app.handle_native_spotify_track_end()); - - assert!(!app.queue_owns_playback(), "the ended slot is cleared"); - assert_eq!(app.spotify_queue_guard_reloads, 0); - assert!( - app - .spotify_queue_guard_reload_uri("some-other-track-id") - .is_none(), - "an empty slot must never reissue the finished track" - ); - assert!(matches!(rx.recv().unwrap(), IoEvent::AdvanceNativeQueue)); - } - - /// The Spirc self-advance guard reissues the queued track only when Spirc has - /// switched away from it, and only within its bounded retry budget. - #[cfg(feature = "streaming")] - #[test] - fn spotify_queue_guard_reissues_only_on_mismatch_and_within_budget() { - use crate::infra::queue::QueueNowPlaying; - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.queue_now = Some(QueueNowPlaying::Spotify { - track: queue_track(Some("spotify:track:queued"), "Queued"), - }); - - // Same track (base62 id): no reissue, budget stays clear. - assert_eq!(app.spotify_queue_guard_reload_uri("queued"), None); - assert_eq!(app.spotify_queue_guard_reloads, 0); - - // Spirc switched away: reissue our track, up to the cap, then stop. - assert_eq!( - app.spotify_queue_guard_reload_uri("other").as_deref(), - Some("spotify:track:queued") - ); - assert_eq!( - app.spotify_queue_guard_reload_uri("other").as_deref(), - Some("spotify:track:queued") - ); - assert_eq!(app.spotify_queue_guard_reload_uri("other"), None); - assert_eq!(app.spotify_queue_guard_reloads, 2); - - // The queued track being confirmed playing again resets the budget. - assert_eq!(app.spotify_queue_guard_reload_uri("queued"), None); - assert_eq!(app.spotify_queue_guard_reloads, 0); - } -} diff --git a/src/core/app/queue_suspend.rs b/src/core/app/queue_suspend.rs deleted file mode 100644 index fe2a1cd5..00000000 --- a/src/core/app/queue_suspend.rs +++ /dev/null @@ -1,705 +0,0 @@ -use super::*; - -impl App { - /// Suspend the active decoded context with **skip** semantics (resume at the - /// context's *next* track, position 0) and latch its `advancing` guard so the - /// runner tick leaves it alone. Radio is torn down (a live stream can't share - /// the sink) and its station stashed for reconnect. A no-op when no decoded - /// context is active. Called before handing the sink to the native queue. - /// - /// `cause` decides where the context resumes under Repeat One: an auto-advance - /// handoff replays the repeated track, a manual Next advances past it. See - /// [`crate::infra::queue::resume_index_after_queue`]. - pub(crate) fn suspend_active_decoded_context_for_skip( - &mut self, - #[cfg_attr(not(feature = "audio-decode-queue"), allow(unused_variables))] - cause: crate::infra::queue::SuspendCause, - ) { - // `resume_index_after_queue` applies the per-mode wrap/clamp: Repeat All - // wraps at the end (last -> first) so a skip at the final track resumes the - // first one rather than reading as exhausted (`None`); Repeat One resumes - // the *same* track on an auto-advance (a queued song must not consume the - // repeat) but advances on a manual skip; Off clamps to `None` at the boundary. - #[cfg_attr(not(feature = "audio-decode-queue"), allow(unused_variables))] - let repeat = self.decoded_repeat; - #[cfg(feature = "local-files")] - #[allow(clippy::needless_return)] - if let Some(local) = self.local_playback.as_mut() { - let resume_index = crate::infra::queue::resume_index_after_queue( - local.index, - local.queue.len(), - repeat, - cause, - ); - local.advancing = true; - self.queue_suspended = Some(crate::core::queue::SuspendedContext::Local { - resume_index, - resume_position_ms: 0, - }); - return; - } - #[cfg(feature = "subsonic")] - #[allow(clippy::needless_return)] - if let Some(s) = self.subsonic_playback.as_mut() { - let resume_index = - crate::infra::queue::resume_index_after_queue(s.index, s.tracks.len(), repeat, cause); - s.advancing = true; - self.queue_suspended = Some(crate::core::queue::SuspendedContext::Subsonic { - resume_index, - resume_position_ms: 0, - }); - return; - } - #[cfg(feature = "youtube")] - #[allow(clippy::needless_return)] - if let Some(s) = self.youtube_playback.as_mut() { - let resume_index = - crate::infra::queue::resume_index_after_queue(s.index, s.tracks.len(), repeat, cause); - s.advancing = true; - self.queue_suspended = Some(crate::core::queue::SuspendedContext::YouTube { - resume_index, - resume_position_ms: 0, - }); - return; - } - #[cfg(feature = "qobuz")] - #[allow(clippy::needless_return)] - if let Some(s) = self.qobuz_playback.as_mut() { - let resume_index = - crate::infra::queue::resume_index_after_queue(s.index, s.tracks.len(), repeat, cause); - // The queue takes the sink: a download in flight must not commit onto it. - if let Some(fetch) = s.fetch.take() { - fetch.abort(); - } - s.advancing = true; - self.queue_suspended = Some(crate::core::queue::SuspendedContext::Qobuz { - resume_index, - resume_position_ms: 0, - }); - return; - } - #[cfg(feature = "internet-radio")] - #[allow(clippy::needless_return)] - if let Some(radio) = self.radio_playback.take() { - radio.player.stop(); - self.queue_suspended = Some(crate::core::queue::SuspendedContext::Radio { - station: radio.station, - }); - } - } - - /// Suspend the active decoded context with **mid-track** semantics (resume the - /// *same* track at its live position) — the Enter-jump path. Radio has no - /// seekable position, so it is stashed for reconnect like the skip path. - pub(crate) fn suspend_active_decoded_context_mid_track(&mut self) { - #[cfg(feature = "local-files")] - #[allow(clippy::needless_return)] - if let Some(local) = self.local_playback.as_mut() { - let position_ms = local.player.position().as_millis() as u64; - let index = local.index; - local.advancing = true; - self.queue_suspended = Some(crate::core::queue::SuspendedContext::Local { - resume_index: Some(index), - resume_position_ms: position_ms, - }); - return; - } - #[cfg(feature = "subsonic")] - #[allow(clippy::needless_return)] - if let Some(s) = self.subsonic_playback.as_mut() { - let position_ms = s.player.position().as_millis() as u64; - let index = s.index; - s.advancing = true; - self.queue_suspended = Some(crate::core::queue::SuspendedContext::Subsonic { - resume_index: Some(index), - resume_position_ms: position_ms, - }); - return; - } - #[cfg(feature = "youtube")] - #[allow(clippy::needless_return)] - if let Some(s) = self.youtube_playback.as_mut() { - let position_ms = s.player.position().as_millis() as u64; - let index = s.index; - s.advancing = true; - self.queue_suspended = Some(crate::core::queue::SuspendedContext::YouTube { - resume_index: Some(index), - resume_position_ms: position_ms, - }); - return; - } - #[cfg(feature = "qobuz")] - #[allow(clippy::needless_return)] - if let Some(s) = self.qobuz_playback.as_mut() { - let position_ms = s.player.position().as_millis() as u64; - let index = s.index; - // The queue takes the sink: a download in flight must not commit onto it. - if let Some(fetch) = s.fetch.take() { - fetch.abort(); - } - s.advancing = true; - self.queue_suspended = Some(crate::core::queue::SuspendedContext::Qobuz { - resume_index: Some(index), - resume_position_ms: position_ms, - }); - return; - } - #[cfg(feature = "internet-radio")] - if let Some(radio) = self.radio_playback.take() { - radio.player.stop(); - self.queue_suspended = Some(crate::core::queue::SuspendedContext::Radio { - station: radio.station, - }); - } - } - - /// Snapshot how to resume the underlying native-Spotify context once the - /// native queue drains, and record it in [`Self::queue_suspended`]. Skip - /// semantics: `resume_track_uri` is the head of the Spotify mirror queue - /// ([`Self::queue`]) — i.e. the *next* track Spirc would have played — so the - /// context resumes at its next track, matching Spotify's own queue behavior. - /// Either field is `None` when the corresponding state is unknown; the resume - /// handler degrades gracefully (context-only, or track-only, or "finished"). - #[cfg(feature = "streaming")] - pub(crate) fn suspend_native_spotify_context_for_queue( - &mut self, - cause: crate::infra::queue::SuspendCause, - ) { - // A client-side shuffle session resumes by index into its app-owned order - // (no context reload, no reshuffle) — the whole point of the session is - // that a queue interruption cannot regenerate the remaining shuffle order. - if let Some(session) = self.native_spotify_shuffle.as_ref() { - // A lazily-seeded session whose context fetch failed for good has only - // its seed track — a shuffled resume would dead-end in "Queue finished", - // so fall through and snapshot the Spotify context instead. - if !(session.fetch_failed && session.order.len() <= 1) { - let generation = session.generation; - let resume_index = crate::infra::queue::resume_index_after_queue( - session.index, - session.order.len(), - self.native_shuffle_repeat_mode(), - cause, - ); - let (context_uri, resume_track_uri) = self.spotify_context_snapshot_parts(); - self.queue_suspended = Some(crate::core::queue::SuspendedContext::SpotifyShuffled { - resume_index, - generation, - context_uri, - resume_track_uri, - }); - return; - } - } - self.queue_suspended = Some(self.spotify_context_suspend_snapshot()); - } - - /// Snapshot the current Spotify context as a queue suspension: the resume - /// target is the mirror queue's head (the context's next track). Used by the - /// suspend fall-through above. - #[cfg(feature = "streaming")] - pub(crate) fn spotify_context_suspend_snapshot(&self) -> crate::core::queue::SuspendedContext { - let (context_uri, resume_track_uri) = self.spotify_context_snapshot_parts(); - crate::core::queue::SuspendedContext::Spotify { - context_uri, - resume_track_uri, - } - } - - /// The live context snapshot pieces: the playing context's uri and the - /// Spotify mirror queue's head (the context's next track). - #[cfg(feature = "streaming")] - fn spotify_context_snapshot_parts(&self) -> (Option<String>, Option<String>) { - let context_uri = self - .current_playback_context - .as_ref() - .and_then(|ctx| ctx.context.as_ref()) - .map(|c| c.uri.clone()); - let resume_track_uri = self - .queue - .as_ref() - .and_then(|q| q.queue.first()) - .and_then(|item| match item { - crate::core::plugin_api::PlayableInfo::Track(t) => t.uri.clone(), - crate::core::plugin_api::PlayableInfo::Episode(e) => e.uri.clone(), - }); - if context_uri.is_some() || resume_track_uri.is_some() { - return (context_uri, resume_track_uri); - } - // Restored/direct-loaded playback has no server-side context and often an - // empty mirror queue, which used to snapshot `(None, None)` and dead-end - // the queue drain in "Queue finished" (no active playback after a queued - // song). The recovery snapshot still knows what was playing; consult it - // here, synchronously at suspend time, because the queued track's own - // direct load moves the recovery observer afterwards. - let Some(snapshot) = self.native_playback_recovery.as_ref() else { - return (None, None); - }; - let next_track_uri = snapshot - .current_track_uri - .as_deref() - .and_then(|current| snapshot.next_raw_list_request(current)) - .and_then(|request| { - let index = request.offset?; - request.uris?.into_iter().nth(index) - }); - (snapshot.context_uri.clone(), next_track_uri) - } - - /// Convert a shuffled queue suspension into a context snapshot. Called when - /// the shuffle session it indexes into is invalidated while suspended - /// (disconnect recovery, failed context fetch): the shuffled resume would be - /// a silent no-op and the queued tracks would drain into nothing. Prefers - /// the context captured at suspension time — by conversion time - /// `current_playback_context` may describe the queued track, not the - /// suspended context — and falls back to the live snapshot per field. - /// `only_generation` restricts the conversion to a suspension bound to that - /// session generation. - #[cfg(feature = "streaming")] - pub(crate) fn convert_shuffled_suspension_to_context(&mut self, only_generation: Option<u64>) { - let Some(crate::core::queue::SuspendedContext::SpotifyShuffled { - generation, - context_uri, - resume_track_uri, - .. - }) = &self.queue_suspended - else { - return; - }; - if only_generation.is_some_and(|g| *generation != g) { - return; - } - let stored = (context_uri.clone(), resume_track_uri.clone()); - let live = self.spotify_context_snapshot_parts(); - self.queue_suspended = Some(crate::core::queue::SuspendedContext::Spotify { - context_uri: stored.0.or(live.0), - resume_track_uri: stored.1.or(live.1), - }); - } - - /// Suspend the native-Spotify context with **mid-track** semantics (the - /// Enter-jump path): resume at the track that was playing when the user - /// jumped, not the context's next one. Position is not preserved — the - /// Spotify resume path restarts the track. Pauses the streaming player so the - /// queued track doesn't play over it. A no-op unless native streaming is the - /// active playback device. - #[cfg(feature = "streaming")] - pub(crate) fn suspend_native_spotify_context_mid_track(&mut self) { - if !self.is_native_streaming_active_for_playback() { - return; - } - // Mid-track semantics for a client-side shuffle session: resume the track - // that was playing, at its position in the app-owned order. - if let Some(session) = self.native_spotify_shuffle.as_ref() { - // Same stranded-session fallback as the queue suspend: a seed-only - // session with a failed fetch would replay the seed and then dead-end, - // so fall through and resume through the context route instead. - if !(session.fetch_failed && session.order.len() <= 1) { - let generation = session.generation; - let resume_index = if session.index < session.order.len() { - Some(session.index) - } else { - None - }; - let (context_uri, resume_track_uri) = self.spotify_context_snapshot_parts(); - self.queue_suspended = Some(crate::core::queue::SuspendedContext::SpotifyShuffled { - resume_index, - generation, - context_uri, - resume_track_uri, - }); - if let Some(player) = self.streaming_player.as_ref() { - player.pause(); - } - return; - } - } - let context_uri = self - .current_playback_context - .as_ref() - .and_then(|ctx| ctx.context.as_ref()) - .map(|c| c.uri.clone()); - // Resume target: the *current* item. Fall back to the mirror queue's head - // (the context's next track) when the current item is unknown. - let resume_track_uri = self - .current_playback_context - .as_ref() - .and_then(|ctx| ctx.item.as_ref()) - .and_then(|item| match item { - PlayableItem::Track(t) => t.id.as_ref().map(|id| id.uri()), - PlayableItem::Episode(e) => Some(e.id.uri()), - _ => None, - }) - .or_else(|| { - self - .queue - .as_ref() - .and_then(|q| q.queue.first()) - .and_then(|item| match item { - crate::core::plugin_api::PlayableInfo::Track(t) => t.uri.clone(), - crate::core::plugin_api::PlayableInfo::Episode(e) => e.uri.clone(), - }) - }); - self.queue_suspended = Some(crate::core::queue::SuspendedContext::Spotify { - context_uri, - resume_track_uri, - }); - if let Some(player) = self.streaming_player.as_ref() { - player.pause(); - } - } -} - -#[cfg(all(test, feature = "streaming"))] -mod tests { - use super::*; - use crate::core::app::test_support::*; - - #[cfg(feature = "streaming")] - #[allow(deprecated)] - fn context_playing(context_uri: &str) -> CurrentPlaybackContext { - use rspotify::model::{context::Context, Type}; - let mut ctx = make_external_context(); - ctx.context = Some(Context { - uri: context_uri.to_string(), - href: String::new(), - external_urls: HashMap::new(), - _type: Type::Playlist, - }); - ctx - } - - /// The suspension snapshot records the context's uri and, as the resume target, - /// the head of the Spotify mirror queue (the *next* track Spirc would play). - #[cfg(feature = "streaming")] - #[test] - fn suspend_native_spotify_context_snapshots_context_and_next_track() { - use crate::core::plugin_api::PlayableInfo; - use crate::core::queue::SuspendedContext; - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.current_playback_context = Some(context_playing("spotify:playlist:ctx123")); - app.queue = Some(QueueState { - currently_playing: Some(PlayableInfo::Track(queue_track( - Some("spotify:track:current"), - "Current", - ))), - queue: vec![ - PlayableInfo::Track(queue_track(Some("spotify:track:next1"), "Next One")), - PlayableInfo::Track(queue_track(Some("spotify:track:next2"), "Next Two")), - ], - }); - - app.suspend_native_spotify_context_for_queue(crate::infra::queue::SuspendCause::AutoAdvance); - - match app.queue_suspended { - Some(SuspendedContext::Spotify { - context_uri, - resume_track_uri, - }) => { - assert_eq!(context_uri.as_deref(), Some("spotify:playlist:ctx123")); - assert_eq!(resume_track_uri.as_deref(), Some("spotify:track:next1")); - } - other => panic!("expected a Spotify suspension, got {other:?}"), - } - } - - /// With no mirror queue or context, the snapshot degrades to all-None (the - /// resume handler then finishes the queue rather than panicking). - #[cfg(feature = "streaming")] - #[test] - fn suspend_native_spotify_context_degrades_to_none_without_state() { - use crate::core::queue::SuspendedContext; - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - - app.suspend_native_spotify_context_for_queue(crate::infra::queue::SuspendCause::AutoAdvance); - - match app.queue_suspended { - Some(SuspendedContext::Spotify { - context_uri, - resume_track_uri, - }) => { - assert!(context_uri.is_none()); - assert!(resume_track_uri.is_none()); - } - other => panic!("expected a Spotify suspension, got {other:?}"), - } - } - - /// A suspended shuffle resume is bound to its session's generation, and the - /// suspend cause is honored under repeat-one: an auto advance replays the - /// current track (keeps the repeat alive across the queue) while a manual Next - /// advances past it. - #[cfg(feature = "streaming")] - #[test] - fn suspend_native_shuffle_binds_generation_and_honors_cause() { - use crate::core::queue::SuspendedContext; - use crate::infra::queue::SuspendCause; - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - // Repeat-one is the only mode where AutoAdvance and ManualSkip diverge. - let mut ctx = context_playing("spotify:playlist:ctx"); - ctx.repeat_state = RepeatState::Track; - app.current_playback_context = Some(ctx); - let session = || NativeSpotifyShuffleSession { - order: vec![ - "spotify:track:a".to_string(), - "spotify:track:b".to_string(), - "spotify:track:c".to_string(), - ], - original: Vec::new(), - index: 1, - shuffled: true, - fetch_complete: true, - fetch_failed: false, - generation: 42, - pending_reload_index: None, - pending_manual_skip: None, - }; - - // Auto advance under repeat-one replays the current index in place. - app.native_spotify_shuffle = Some(session()); - app.suspend_native_spotify_context_for_queue(SuspendCause::AutoAdvance); - match app.queue_suspended { - Some(SuspendedContext::SpotifyShuffled { - resume_index, - generation, - .. - }) => { - assert_eq!( - resume_index, - Some(1), - "auto advance replays the current track" - ); - assert_eq!(generation, 42, "resume is bound to the session generation"); - } - other => panic!("expected a shuffled suspension, got {other:?}"), - } - - // A manual Next advances past the current track even under repeat-one. - app.native_spotify_shuffle = Some(session()); - app.suspend_native_spotify_context_for_queue(SuspendCause::ManualSkip); - match app.queue_suspended { - Some(SuspendedContext::SpotifyShuffled { resume_index, .. }) => { - assert_eq!( - resume_index, - Some(2), - "manual skip advances past the current track" - ); - } - other => panic!("expected a shuffled suspension, got {other:?}"), - } - } - - /// Queue resume targets the track *after* the one that was playing: with the - /// index synced to 2, an auto-advance suspend under repeat Off stores resume - /// index 3. (The frozen index always produced 1, replaying or rewinding to - /// the second shuffled track after every queue drain.) - #[cfg(feature = "streaming")] - #[test] - fn queue_resume_targets_the_track_after_the_synced_index() { - use crate::core::queue::SuspendedContext; - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.native_spotify_shuffle = Some(NativeSpotifyShuffleSession { - order: vec![ - "spotify:track:a".to_string(), - "spotify:track:b".to_string(), - "spotify:track:c".to_string(), - "spotify:track:d".to_string(), - ], - original: Vec::new(), - index: 0, - shuffled: true, - fetch_complete: true, - fetch_failed: false, - generation: 7, - pending_reload_index: None, - pending_manual_skip: None, - }); - - let playing_uri = "spotify:track:c".to_string(); - app.sync_native_shuffle_index(base62_id_of(&playing_uri)); - app.suspend_native_spotify_context_for_queue(crate::infra::queue::SuspendCause::AutoAdvance); - - match app.queue_suspended { - Some(SuspendedContext::SpotifyShuffled { resume_index, .. }) => { - assert_eq!(resume_index, Some(3)); - } - other => panic!("expected a shuffled suspension, got {other:?}"), - } - } - - /// A playlist shuffle session whose background context fetch failed for good - /// is stuck on its seed track. Suspending for the queue must snapshot the - /// Spotify context (resumable through the context route) instead of storing - /// a shuffled resume that dead-ends in "Queue finished". - #[cfg(feature = "streaming")] - #[test] - fn stranded_seed_only_shuffle_session_suspends_to_the_context() { - use crate::core::queue::SuspendedContext; - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.current_playback_context = Some(context_playing("spotify:playlist:ctx")); - app.native_spotify_shuffle = Some(NativeSpotifyShuffleSession { - order: vec!["spotify:track:seed".to_string()], - original: vec!["spotify:track:seed".to_string()], - index: 0, - shuffled: true, - fetch_complete: true, - fetch_failed: true, - generation: 1, - pending_reload_index: None, - pending_manual_skip: None, - }); - - app.suspend_native_spotify_context_for_queue(crate::infra::queue::SuspendCause::AutoAdvance); - - match app.queue_suspended { - Some(SuspendedContext::Spotify { context_uri, .. }) => { - assert_eq!(context_uri.as_deref(), Some("spotify:playlist:ctx")); - } - other => panic!("expected a context suspension, got {other:?}"), - } - } - - /// A genuinely one-track context (fetch succeeded, nothing more to play) - /// still suspends as a shuffled session: its `None` resume correctly means - /// "context exhausted" rather than a stranded fetch. - #[cfg(feature = "streaming")] - #[test] - fn exhausted_one_track_shuffle_session_still_suspends_shuffled() { - use crate::core::queue::SuspendedContext; - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.native_spotify_shuffle = Some(NativeSpotifyShuffleSession { - order: vec!["spotify:track:only".to_string()], - original: vec!["spotify:track:only".to_string()], - index: 0, - shuffled: true, - fetch_complete: true, - fetch_failed: false, - generation: 2, - pending_reload_index: None, - pending_manual_skip: None, - }); - - app.suspend_native_spotify_context_for_queue(crate::infra::queue::SuspendCause::AutoAdvance); - - match app.queue_suspended { - Some(SuspendedContext::SpotifyShuffled { resume_index, .. }) => { - assert_eq!(resume_index, None, "one-track context is exhausted"); - } - other => panic!("expected a shuffled suspension, got {other:?}"), - } - } - - /// Disconnect recovery clears the shuffle session, which orphans a shuffled - /// queue suspension (the generation check turns the resume into a silent - /// no-op and the queued tracks vanish). Converting it to a context snapshot - /// first keeps the drain resumable through the context route. - #[cfg(feature = "streaming")] - #[test] - fn shuffled_suspension_converts_to_context_when_session_is_cleared() { - use crate::core::queue::SuspendedContext; - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.current_playback_context = Some(context_playing("spotify:playlist:ctx")); - app.native_spotify_shuffle = Some(NativeSpotifyShuffleSession { - order: vec!["spotify:track:a".to_string(), "spotify:track:b".to_string()], - original: Vec::new(), - index: 0, - shuffled: true, - fetch_complete: true, - fetch_failed: false, - generation: 5, - pending_reload_index: None, - pending_manual_skip: None, - }); - // The suspension carries the context captured when it was created; by - // disconnect time the live context may describe the queued track instead. - app.queue_suspended = Some(SuspendedContext::SpotifyShuffled { - resume_index: Some(1), - generation: 5, - context_uri: Some("spotify:playlist:captured".to_string()), - resume_track_uri: None, - }); - - // What disconnect recovery does, in order. - app.convert_shuffled_suspension_to_context(None); - app.clear_native_shuffle_session(); - - match app.queue_suspended.take() { - Some(SuspendedContext::Spotify { context_uri, .. }) => { - assert_eq!( - context_uri.as_deref(), - Some("spotify:playlist:captured"), - "the suspension-time context wins over the live one" - ); - } - other => panic!("expected a context suspension, got {other:?}"), - } - - // Without a captured context the conversion falls back to the live one. - app.queue_suspended = Some(SuspendedContext::SpotifyShuffled { - resume_index: Some(1), - generation: 6, - context_uri: None, - resume_track_uri: None, - }); - app.convert_shuffled_suspension_to_context(None); - match app.queue_suspended { - Some(SuspendedContext::Spotify { context_uri, .. }) => { - assert_eq!(context_uri.as_deref(), Some("spotify:playlist:ctx")); - } - other => panic!("expected a context suspension, got {other:?}"), - } - } - - /// Restored/direct-loaded playback has no live context and no mirror queue; - /// the suspend fallback consults the recovery snapshot so the queue drain - /// resumes the next raw-list track instead of dead-ending in "Queue - /// finished" (observed as "No active playback" after a queued song). - #[cfg(feature = "streaming")] - #[test] - fn empty_live_state_suspension_falls_back_to_the_recovery_snapshot() { - use crate::core::queue::SuspendedContext; - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.native_playback_recovery = Some(NativePlaybackRecoverySnapshot { - generation: 1, - context_uri: None, - uris: Some(vec![ - "spotify:track:a".to_string(), - "spotify:track:b".to_string(), - "spotify:track:c".to_string(), - ]), - offset: Some(0), - current_track_uri: Some("spotify:track:a".to_string()), - loading_track_uri: None, - track_duration_ms: None, - position_ms: 0, - desired_playing: true, - shuffle: false, - repeat: RepeatState::Off, - recovery_attempts: 0, - }); - - app.suspend_native_spotify_context_for_queue(crate::infra::queue::SuspendCause::AutoAdvance); - - match app.queue_suspended { - Some(SuspendedContext::Spotify { - context_uri, - resume_track_uri, - }) => { - assert_eq!(context_uri, None); - assert_eq!( - resume_track_uri.as_deref(), - Some("spotify:track:b"), - "the drain must resume the track after the one that was playing" - ); - } - other => panic!("expected a context suspension, got {other:?}"), - } - } -} diff --git a/src/core/app/recap.rs b/src/core/app/recap.rs deleted file mode 100644 index 1b5dc01a..00000000 --- a/src/core/app/recap.rs +++ /dev/null @@ -1,51 +0,0 @@ -use super::*; - -impl App { - /// The monthly recap popup that waits for an answer, if one is open. - pub fn recap_prompt(&self) -> Option<&RecapPromptState> { - self.recap_prompt.as_ref() - } - - /// Raise the monthly recap popup for a recap that was just written. - pub(crate) fn show_recap_prompt(&mut self, prompt: RecapPromptState) { - self.recap_prompt = Some(prompt); - self.push_navigation_stack(RouteId::RecapPrompt, ActiveBlock::RecapPrompt); - } - - /// Open the recap in the browser and close the popup. - pub(crate) fn open_recap(&mut self) { - if let Some(prompt) = self.recap_prompt.take() { - if let Err(e) = open::that_detached(&prompt.path) { - log::warn!("failed to open recap in browser: {}", e); - self.set_status_message( - format!( - "Recap saved at {} (couldn't open browser)", - prompt.path.display() - ), - 8, - ); - } - } - self.pop_navigation_stack(); - } - - /// Close the popup without opening the recap ("[ESC] Later"). - pub(crate) fn dismiss_recap_prompt(&mut self) { - self.recap_prompt = None; - self.pop_navigation_stack(); - } - - /// Close the popup and switch the monthly prompt off in `config.yml`. - pub(crate) fn disable_recap_prompt(&mut self) { - self.recap_prompt = None; - self.user_config.behavior.enable_monthly_recap_prompt = false; - if let Err(e) = self.user_config.save_config() { - log::warn!("failed to persist monthly recap prompt setting: {}", e); - } - self.set_status_message( - "Monthly recap prompt disabled (re-enable in Settings)".to_string(), - 6, - ); - self.pop_navigation_stack(); - } -} diff --git a/src/core/app/route.rs b/src/core/app/route.rs deleted file mode 100644 index a435428f..00000000 --- a/src/core/app/route.rs +++ /dev/null @@ -1,631 +0,0 @@ -use super::*; - -pub(super) const DEFAULT_ROUTE: Route = Route { - id: RouteId::Home, - active_block: ActiveBlock::Empty, - hovered_block: ActiveBlock::Library, -}; - -#[derive(PartialEq, Debug, Default)] -pub enum SearchResultBlock { - AlbumSearch, - SongSearch, - ArtistSearch, - PlaylistSearch, - ShowSearch, - #[default] - Empty, -} - -#[derive(PartialEq, Debug, Clone)] -pub enum ArtistBlock { - TopTracks, - Albums, - RelatedArtists, - Empty, -} - -#[derive(Clone, Copy, PartialEq, Debug)] -pub enum DialogContext { - PlaylistWindow, - PlaylistSearch, - AddTrackToPlaylistPicker, - RemoveTrackFromPlaylistConfirm, - PersistKeybindingFallback, - /// Confirm deleting a local YouTube playlist (sidebar `D` under the - /// YouTube source). - YouTubePlaylistWindow, -} - -#[derive(Clone, Copy, PartialEq, Debug)] -pub enum ActiveBlock { - Analysis, - PlayBar, - AlbumTracks, - AlbumList, - ArtistBlock, - Empty, - Error, - HelpMenu, - Home, - Input, - Library, - MyPlaylists, - Podcasts, - EpisodeTable, - RecentlyPlayed, - SearchResultBlock, - SelectDevice, - TrackTable, - Discover, - Artists, - LyricsView, - CoverArtView, - MiniPlayer, - Dialog(DialogContext), - - AnnouncementPrompt, - RecapPrompt, - CommunityPinPrompt, - ExitPrompt, - Settings, - SortMenu, - Queue, - Party, - CreatePlaylistForm, - Friends, - LocalBrowser, - Stats, - /// The AI DJ screen's prompt + transcript. - #[cfg(feature = "ai-dj")] - AiDj, - /// A plugin-registered custom screen (the screen name lives in - /// [`RouteId::PluginScreen`]; `ActiveBlock` is `Copy` and can't carry it). - /// Only script effects construct it. - #[cfg_attr(not(feature = "scripting"), allow(dead_code))] - PluginScreen, -} - -#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] -pub enum InputContext { - #[default] - GlobalSearch, - PlaylistTrackSearch, -} - -#[derive(Clone, PartialEq, Debug)] -pub enum RouteId { - Analysis, - AlbumTracks, - AlbumList, - Artist, - LyricsView, - CoverArtView, - MiniPlayer, - Error, - Home, - RecentlyPlayed, - Search, - SelectedDevice, - TrackTable, - Discover, - Artists, - Podcasts, - PodcastEpisodes, - Recommendations, - Dialog, - - AnnouncementPrompt, - RecapPrompt, - CommunityPinPrompt, - ExitPrompt, - Settings, - HelpMenu, - Queue, - Party, - CreatePlaylist, - Friends, - /// Only reachable when `local-files` is built in (the sidebar row that opens it - /// is gated on that feature). - #[cfg_attr(not(feature = "local-files"), allow(dead_code))] - LocalBrowser, - Stats, - #[cfg(feature = "ai-dj")] - AiDj, - /// A plugin-registered custom screen, keyed by its registered name. - /// Only script effects construct it. - #[cfg_attr(not(feature = "scripting"), allow(dead_code))] - PluginScreen(String), -} - -impl RouteId { - /// Routes that can be shown at startup with no extra context (no album/artist - /// id, search query, etc.). These are the only routes `startup_route` may - /// select. - pub const STARTUP_OPTIONS: &'static [RouteId] = &[ - RouteId::Home, - RouteId::RecentlyPlayed, - RouteId::Podcasts, - RouteId::Discover, - RouteId::Artists, - RouteId::AlbumList, - RouteId::Stats, - ]; - - /// What a startup route needs before its screen opens populated. - pub(crate) fn startup_requirement(&self) -> Requirement { - match self { - RouteId::Home | RouteId::Stats => Requirement::None, - _ => Requirement::SpotifySession, - } - } - - /// Parse a `startup_route` config token. Unknown / non-context-free strings - /// return `None` (the caller logs a warning and falls back to Home). - pub fn from_config_str(s: &str) -> Option<RouteId> { - match s.trim().to_ascii_lowercase().as_str() { - "home" => Some(RouteId::Home), - "recently_played" | "recent" => Some(RouteId::RecentlyPlayed), - "podcasts" => Some(RouteId::Podcasts), - "discover" => Some(RouteId::Discover), - "artists" | "library" => Some(RouteId::Artists), - "album_list" | "albums" => Some(RouteId::AlbumList), - "stats" => Some(RouteId::Stats), - _ => None, - } - } - - /// The config-file token for this route (inverse of `from_config_str`). - pub fn to_config_str(&self) -> &'static str { - match self { - RouteId::Home => "home", - RouteId::RecentlyPlayed => "recently_played", - RouteId::Podcasts => "podcasts", - RouteId::Discover => "discover", - RouteId::Artists => "artists", - RouteId::AlbumList => "album_list", - RouteId::Stats => "stats", - _ => "home", - } - } -} - -#[derive(Debug)] -pub struct Route { - pub id: RouteId, - pub active_block: ActiveBlock, - pub hovered_block: ActiveBlock, -} - -/// Which panel of the combined Source & Device picker (the `d` screen) has -/// keyboard focus. -#[derive(Clone, Copy, PartialEq, Debug, Default)] -pub enum SourceFocus { - Source, - #[default] - Devices, -} - -impl App { - /// Open the combined Source & Device picker. The picker opens immediately so - /// it is reachable offline or when Local is the active source. Initial focus - /// is the Source panel unless Spotify is active (devices are Spotify Connect - /// only), and only Spotify needs a `me/player/devices` fetch: an - /// unauthenticated or offline session must not surface a spurious error. - pub(crate) fn open_source_device_picker(&mut self) { - self.view.source_list_index = Source::ALL - .iter() - .position(|s| *s == self.active_source) - .unwrap_or(0); - self.view.source_device_focus = if self.active_source == Source::Spotify { - SourceFocus::Devices - } else { - SourceFocus::Source - }; - self.push_navigation_stack(RouteId::SelectedDevice, ActiveBlock::SelectDevice); - if self.active_source == Source::Spotify { - self.dispatch(IoEvent::GetDevices); - } - } - - /// Switch the active browse source and mirror + persist the choice so the - /// selection survives restarts. Browse scope only: never interrupts - /// playback (routing goes by URI scheme). Sidebar data loading and any - /// view resets stay with the caller. - pub fn set_active_source(&mut self, source: Source) { - self.active_source = source; - self.runtime_state.active_source = source; - // The Spotify scope reaches disk when the login succeeds (see - // `persist_active_source`), so a cancelled login never forces a browser at - // the next boot. - if source == Source::Spotify && !self.spotify_connected { - return; - } - self.persist_active_source(); - } - - /// Whether the active browse scope and the session can serve a row or key. - pub(crate) fn availability(&self, requirement: Requirement) -> Availability { - availability(requirement, self.active_source, self.spotify_connected) - } - - /// Write the current browse scope to `state.yml`. - pub(crate) fn persist_active_source(&mut self) { - if let Err(e) = self.save_runtime_state( - &crate::core::state::PersistedRuntimeState::active_source(self.runtime_state.active_source), - ) { - log::warn!("[source] failed to persist active_source: {e}"); - } - } - - /// Fetch the sidebar data a browse source needs; Spotify's playlists arrive - /// with the session. - pub(crate) fn load_source_sidebar(&mut self, source: Source) { - let event = match source { - Source::Local => IoEvent::GetLocalPlaylists, - Source::Subsonic => IoEvent::GetSubsonicPlaylists, - Source::Radio => IoEvent::GetRadioStations, - Source::YouTube => IoEvent::GetYouTubePlaylists, - Source::Qobuz => IoEvent::GetQobuzPlaylists, - Source::Spotify => return, - }; - self.dispatch(event); - } - - // The navigation_stack actually only controls the large block to the right of `library` and - // `playlists` - pub fn push_navigation_stack(&mut self, next_route_id: RouteId, next_active_block: ActiveBlock) { - info!("navigating to {:?}", next_route_id); - if !self - .navigation_stack - .last() - .map(|last_route| last_route.id == next_route_id) - .unwrap_or(false) - { - self.navigation_stack.push(Route { - id: next_route_id, - active_block: next_active_block, - hovered_block: next_active_block, - }); - } - } - - pub fn pop_navigation_stack(&mut self) -> Option<Route> { - info!("navigating back"); - if self.navigation_stack.len() == 1 { - None - } else { - let popped = self.navigation_stack.pop(); - // Leaving the error screen dismisses the error. Done here rather than in - // a key handler so every back path clears it (the escape key, the - // configurable back key, a script's `Back`), and so a frontend with no - // back key at all is not the odd one out. Keyed on the frame that was - // POPPED, not on the new top: pressing `d` from the error page stacks the - // device picker over it, and coming back must still show the message. - if popped - .as_ref() - .is_some_and(|route| route.id == RouteId::Error) - { - self.clear_api_error(); - } - popped - } - } - - /// Remove every frame still serving as the error screen. - /// - /// Matched on the id AND the block: a producer that rewrites the current - /// frame in place could leave a `{ Error, Input }` frame holding text-input - /// focus, and an id-only match would drop its keystrokes into the global - /// bindings. No such producer exists today; such a frame no longer draws - /// the error screen, so leaving it is harmless. - /// - /// Both `push_navigation_stack` and the frames below the top are covered: - /// pushes dedupe only against the top frame, so navigating away and failing - /// again buries a second error frame that would otherwise resurface later - /// rendering an unrelated message, or an empty one. - /// - /// Cannot empty the stack: the bottom frame is `DEFAULT_ROUTE` (Home) or a - /// `RouteId::STARTUP_OPTIONS` route, and `Error` is in neither. - pub(super) fn drop_error_routes(&mut self) { - self - .navigation_stack - .retain(|route| !(route.id == RouteId::Error && route.active_block == ActiveBlock::Error)); - } - - pub fn get_current_route(&self) -> &Route { - // if for some reason there is no route return the default - self.navigation_stack.last().unwrap_or(&DEFAULT_ROUTE) - } - - fn get_current_route_mut(&mut self) -> &mut Route { - self.navigation_stack.last_mut().unwrap() - } - - pub fn set_current_route_state( - &mut self, - active_block: Option<ActiveBlock>, - hovered_block: Option<ActiveBlock>, - ) { - let current_route = self.get_current_route_mut(); - if let Some(active_block) = active_block { - current_route.active_block = active_block; - } - if let Some(hovered_block) = hovered_block { - current_route.hovered_block = hovered_block; - } - } - - /// Toggle the audio analysis visualization view - /// This now uses local FFT analysis instead of the deprecated Spotify API - pub fn get_audio_analysis(&mut self) { - info!("entering audio analysis view"); - if self.get_current_route().id != RouteId::Analysis { - // Enter visualization mode - self.push_navigation_stack(RouteId::Analysis, ActiveBlock::Analysis); - } - // Spectrum data will be updated by the audio capture system on each tick - } - - /// Open the album page of the item playing now, through the ownership - /// order (episodes open their show). A queued Spotify track resolves through - /// the slot; any other owner answers with a status message. - pub(crate) fn jump_to_album(&mut self) { - // Build the event under the borrow so only the payload is cloned, never - // the whole playback context. - let event = match self.playing_item() { - PlayingItem::Spotify(PlayableItem::Track(track)) => { - Ok(IoEvent::GetAlbumTracks(Box::new(track.album.clone()))) - } - PlayingItem::Spotify(PlayableItem::Episode(episode)) => Ok(IoEvent::GetShowEpisodes( - Box::new(crate::core::plugin_api::ShowInfo::from(&episode.show)), - )), - PlayingItem::Spotify(_) => return, - // A track listed from an album page carries no album id of its own; the - // track lookup finds the album from the track instead. - PlayingItem::QueuedSpotify(track) => { - match (&track.album_id, track.id.as_ref().or(track.uri.as_ref())) { - (Some(album_id), _) => Ok(IoEvent::GetAlbum(album_id.clone())), - (None, Some(track_id)) => Ok(IoEvent::GetAlbumForTrack(track_id.clone())), - (None, None) => Err("The queued track has no album id"), - } - } - PlayingItem::NotSpotify => Err("Jump to album needs a Spotify track playing"), - PlayingItem::Nothing => Err(NOTHING_PLAYING_STATUS), - }; - match event { - Ok(event) => self.dispatch(event), - Err(status) => self.set_status_message(status, 4), - } - } - - /// Open the album list of the first artist of the track playing now, - /// through the ownership order. Episodes have no artist page. - pub(crate) fn jump_to_artist_album(&mut self) { - let artist = match self.playing_item() { - PlayingItem::Spotify(PlayableItem::Track(track)) => track - .artists - .first() - .and_then(|artist| { - artist - .id - .as_ref() - .map(|id| (id.id().to_string(), artist.name.clone())) - }) - .ok_or("The playing track has no artist id"), - PlayingItem::Spotify(_) => return, - PlayingItem::QueuedSpotify(track) => track - .artist_refs - .first() - .and_then(|artist| artist.id.clone().map(|id| (id, artist.name.clone()))) - .ok_or("The queued track has no artist id"), - PlayingItem::NotSpotify => Err("Jump to artist needs a Spotify track playing"), - PlayingItem::Nothing => Err(NOTHING_PLAYING_STATUS), - }; - match artist { - Ok((artist_id, artist_name)) => self.get_artist(artist_id, artist_name), - Err(status) => self.set_status_message(status, 4), - } - } - - /// Open the context (album/artist/playlist) that playback runs in, through - /// the ownership order. The queue slot plays outside any context, so a - /// queued track answers with a status message. A context with no item (an - /// ad plays) still opens. - pub(crate) fn jump_to_context(&mut self) { - match self.playing_item() { - PlayingItem::Spotify(_) | PlayingItem::Nothing => {} - PlayingItem::QueuedSpotify(_) => { - self.set_status_message("The queue slot has no play context", 4); - return; - } - PlayingItem::NotSpotify => { - self.set_status_message("Jump to context needs a Spotify track playing", 4); - return; - } - } - let context = self - .current_playback_context - .as_ref() - .and_then(|playback| playback.context.as_ref()) - .map(|context| { - ( - context._type.clone(), - crate::infra::network::ids::playlist_id(&context.uri), - ) - }); - let Some((context_type, playlist)) = context else { - self.set_status_message(NOTHING_PLAYING_STATUS, 4); - return; - }; - match context_type { - rspotify::model::enums::Type::Album => self.jump_to_album(), - rspotify::model::enums::Type::Artist => self.jump_to_artist_album(), - rspotify::model::enums::Type::Playlist => { - if let Some(playlist_id) = playlist { - self.open_playlist_tracks(playlist_id, TrackTableContext::MyPlaylists); - } - } - _ => {} - } - } - - /// Generate a listening recap: the selected period on the Stats screen - /// when that screen is current, 30 days anywhere else. - pub(crate) fn generate_recap(&mut self) { - let period = if self.get_current_route().active_block == ActiveBlock::Stats { - self.stats_period - } else { - RecapPeriod::ThirtyDays - }; - self.dispatch(IoEvent::GenerateRecap(period)); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::app::test_support::*; - - /// A Spotify session whose cached context names a track with an artist id. - #[cfg(feature = "streaming")] - fn app_with_suspended_context() -> (App, std::sync::mpsc::Receiver<IoEvent>) { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - let mut track = full_track("0000000000000000000001", "Suspended"); - track.artists[0].id = Some( - rspotify::model::idtypes::ArtistId::from_id("0OdUWJ0sBjDrqHygGUXeCF") - .unwrap() - .into_static(), - ); - app.current_playback_context = Some(playing_track_context(track)); - (app, rx) - } - - #[cfg(feature = "streaming")] - fn queue_spotify_track(app: &mut App, track: TrackInfo) { - app.queue_now = Some(crate::infra::queue::QueueNowPlaying::Spotify { track }); - } - - #[cfg(feature = "streaming")] - #[test] - fn jump_to_album_opens_the_queued_tracks_album_not_the_suspended_one() { - let (mut app, rx) = app_with_suspended_context(); - let mut track = queue_track(Some("spotify:track:queued"), "Queued"); - track.album_id = Some("queuedalbum".to_string()); - queue_spotify_track(&mut app, track); - - app.jump_to_album(); - - assert!(matches!(rx.try_recv(), Ok(IoEvent::GetAlbum(id)) if id == "queuedalbum")); - } - - #[cfg(feature = "streaming")] - #[test] - fn jump_to_album_reports_a_queued_track_without_an_album_id() { - let (mut app, rx) = app_with_suspended_context(); - queue_spotify_track(&mut app, queue_track(None, "Queued")); - - app.jump_to_album(); - - assert!(rx.try_recv().is_err()); - assert_eq!( - app.status_message(), - Some("The queued track has no album id") - ); - } - - #[cfg(feature = "streaming")] - #[test] - fn jump_to_artist_album_opens_the_queued_tracks_artist() { - let (mut app, rx) = app_with_suspended_context(); - let mut track = queue_track(Some("spotify:track:queued"), "Queued"); - track.artist_refs = vec![crate::core::plugin_api::ArtistRef { - id: Some("queuedartist".to_string()), - name: "Queued Artist".to_string(), - }]; - queue_spotify_track(&mut app, track); - - app.jump_to_artist_album(); - - assert!(matches!( - rx.try_recv(), - Ok(IoEvent::GetArtist(id, name, _)) if id == "queuedartist" && name == "Queued Artist" - )); - } - - #[cfg(feature = "streaming")] - #[test] - fn jump_to_context_refuses_the_queue_slot() { - let (mut app, rx) = app_with_suspended_context(); - queue_spotify_track( - &mut app, - queue_track(Some("spotify:track:queued"), "Queued"), - ); - - app.jump_to_context(); - - assert!(rx.try_recv().is_err()); - assert_eq!( - app.status_message(), - Some("The queue slot has no play context") - ); - } - - #[test] - fn jump_to_album_reports_nothing_playing_without_a_session() { - let (mut app, rx) = session_free_app(); - - app.jump_to_album(); - - assert!(rx.try_recv().is_err()); - assert_eq!(app.status_message(), Some(NOTHING_PLAYING_STATUS)); - } - - #[cfg(feature = "streaming")] - #[test] - fn jump_to_album_looks_the_album_up_from_a_queued_track_without_an_album_id() { - let (mut app, rx) = app_with_suspended_context(); - let mut track = queue_track(Some("spotify:track:queued"), "Queued"); - track.id = Some("queuedtrack".to_string()); - queue_spotify_track(&mut app, track); - - app.jump_to_album(); - - assert!(matches!(rx.try_recv(), Ok(IoEvent::GetAlbumForTrack(id)) if id == "queuedtrack")); - } - - #[test] - fn jump_to_context_reports_nothing_playing_without_a_context() { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.current_playback_context = Some(make_external_context()); - - app.jump_to_context(); - - assert!(rx.try_recv().is_err()); - assert_eq!(app.status_message(), Some(NOTHING_PLAYING_STATUS)); - } - - #[test] - fn jump_to_context_opens_the_context_when_no_item_plays() { - use rspotify::model::{context::Context, enums::Type}; - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - let mut context = make_external_context(); - context.context = Some(Context { - uri: "spotify:playlist:37i9dQZF1DX4WYpdgoIcn6".to_string(), - href: String::new(), - external_urls: std::collections::HashMap::new(), - _type: Type::Playlist, - }); - app.current_playback_context = Some(context); - - app.jump_to_context(); - - assert!(matches!( - rx.try_recv(), - Ok(IoEvent::GetPlaylistItems(id, _)) if id == "37i9dQZF1DX4WYpdgoIcn6" - )); - } -} diff --git a/src/core/app/scrollable_pages.rs b/src/core/app/scrollable_pages.rs deleted file mode 100644 index b59a79de..00000000 --- a/src/core/app/scrollable_pages.rs +++ /dev/null @@ -1,216 +0,0 @@ -use super::*; - -#[derive(Clone)] -pub struct ScrollableResultPages<T> { - pub index: usize, - pub pages: Vec<T>, -} - -impl<T> ScrollableResultPages<T> { - pub fn new() -> ScrollableResultPages<T> { - ScrollableResultPages { - index: 0, - pages: vec![], - } - } - - pub fn get_results(&self, at_index: Option<usize>) -> Option<&T> { - self.pages.get(at_index.unwrap_or(self.index)) - } - - pub fn get_mut_results(&mut self, at_index: Option<usize>) -> Option<&mut T> { - self.pages.get_mut(at_index.unwrap_or(self.index)) - } - - pub fn clear(&mut self) { - self.index = 0; - self.pages.clear(); - } - - /// Append a page and jump the visible index to it. - /// - /// Index-ordered caches only (saved albums/shows/artists, show episodes). - /// The **offset-keyed** caches (saved tracks, playlist tracks) must insert via - /// [`Self::upsert_page_by_offset`] instead: their lookups binary-search on - /// `Paged::offset`, and one out-of-order `add_pages` breaks the sorted - /// invariant they depend on — and this method also repoints the visible index - /// to the tail, clobbering the page the user is looking at. - pub fn add_pages(&mut self, new_pages: T) { - self.pages.push(new_pages); - // Whenever a new page is added, set the active index to the end of the vector - self.index = self.pages.len() - 1; - } -} - -// Offset-keyed page caches are always kept sorted by `Paged::offset`, but the cache -// can be sparse, so visible-page identity is derived from the offset, never raw -// cache adjacency. There is no `DeserializeOwned` bound because `Paged` carries -// already-mapped domain items. -impl<T> ScrollableResultPages<Paged<T>> { - pub fn page_index_for_offset(&self, offset: u32) -> Option<usize> { - self - .pages - .binary_search_by_key(&offset, |page| page.offset) - .ok() - } - - pub fn upsert_page_by_offset(&mut self, new_page: Paged<T>) -> usize { - let active_page_offset = self.pages.get(self.index).map(|page| page.offset); - let new_page_offset = new_page.offset; - - match self - .pages - .binary_search_by_key(&new_page.offset, |page| page.offset) - { - Ok(index) => { - self.pages[index] = new_page; - } - Err(index) => { - self.pages.insert(index, new_page); - } - }; - - if let Some(active_page_offset) = active_page_offset { - if let Some(active_page_index) = self.page_index_for_offset(active_page_offset) { - self.index = active_page_index; - } - } else if !self.pages.is_empty() { - self.index = 0; - } - - self - .page_index_for_offset(new_page_offset) - .expect("upserted page offset must exist in cache") - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::app::test_support::*; - - fn saved_tracks_domain_page( - offset: u32, - total: u32, - ids: &[&str], - has_next: bool, - ) -> Paged<TrackInfo> { - crate::infra::network::mapping::map_page( - &saved_tracks_page(offset, total, ids, has_next), - |st| TrackInfo::from(&st.track), - ) - } - - #[test] - fn upsert_page_by_offset_preserves_active_index() { - let mut pages = ScrollableResultPages::new(); - pages.upsert_page_by_offset(saved_tracks_domain_page( - 0, - 4, - &["0000000000000000000001", "0000000000000000000002"], - true, - )); - - let inserted_index = pages.upsert_page_by_offset(saved_tracks_domain_page( - 2, - 4, - &["0000000000000000000003", "0000000000000000000004"], - false, - )); - - assert_eq!(inserted_index, 1); - assert_eq!(pages.index, 0); - assert_eq!(pages.pages.len(), 2); - } - - #[test] - fn upsert_page_by_offset_replaces_duplicate_page() { - let mut pages = ScrollableResultPages::new(); - pages.upsert_page_by_offset(saved_tracks_domain_page( - 0, - 2, - &["0000000000000000000001", "0000000000000000000002"], - false, - )); - - let replaced_index = pages.upsert_page_by_offset(saved_tracks_domain_page( - 0, - 2, - &["0000000000000000000003", "0000000000000000000004"], - false, - )); - - assert_eq!(replaced_index, 0); - assert_eq!(pages.pages.len(), 1); - assert_eq!( - pages.pages[0].items[0].id.as_deref().unwrap(), - "0000000000000000000003" - ); - } - - #[test] - fn upsert_page_by_offset_keeps_active_page_when_inserting_before_it() { - let mut pages = ScrollableResultPages::new(); - pages.upsert_page_by_offset(saved_tracks_domain_page( - 0, - 6, - &["0000000000000000000001", "0000000000000000000002"], - true, - )); - pages.upsert_page_by_offset(saved_tracks_domain_page( - 4, - 6, - &["0000000000000000000005", "0000000000000000000006"], - false, - )); - pages.index = 1; - - let inserted_index = pages.upsert_page_by_offset(saved_tracks_domain_page( - 2, - 6, - &["0000000000000000000003", "0000000000000000000004"], - true, - )); - - assert_eq!(inserted_index, 1); - assert_eq!(pages.index, 2); - assert_eq!(pages.pages[pages.index].offset, 4); - } - - #[test] - fn out_of_order_upserts_keep_offset_lookups_correct() { - let mut pages = ScrollableResultPages::new(); - pages.upsert_page_by_offset(saved_tracks_domain_page( - 4, - 6, - &["0000000000000000000005", "0000000000000000000006"], - false, - )); - pages.upsert_page_by_offset(saved_tracks_domain_page( - 0, - 6, - &["0000000000000000000001", "0000000000000000000002"], - true, - )); - pages.upsert_page_by_offset(saved_tracks_domain_page( - 2, - 6, - &["0000000000000000000003", "0000000000000000000004"], - true, - )); - - // The cache is stored sorted by offset regardless of insertion order, so - // every offset lookup binary-searches to the right page. - assert!(pages.pages.windows(2).all(|w| w[0].offset < w[1].offset)); - assert_eq!(pages.page_index_for_offset(0), Some(0)); - assert_eq!(pages.page_index_for_offset(2), Some(1)); - assert_eq!(pages.page_index_for_offset(4), Some(2)); - assert_eq!(pages.page_index_for_offset(3), None); - assert_eq!( - pages.pages[pages.page_index_for_offset(2).unwrap()].items[0] - .id - .as_deref(), - Some("0000000000000000000003") - ); - } -} diff --git a/src/core/app/seek.rs b/src/core/app/seek.rs deleted file mode 100644 index 459a1da7..00000000 --- a/src/core/app/seek.rs +++ /dev/null @@ -1,409 +0,0 @@ -use super::*; - -/// How long to ignore position updates after a seek (ms) -/// This prevents the UI from jumping back to old positions while the seek completes -pub const SEEK_POSITION_IGNORE_MS: u128 = 500; - -impl App { - pub(super) fn apply_seek(&mut self, seek_ms: u32) { - // The audible track's duration decides between a seek and a skip; a - // decoded owner seeks through its own latch and gets nothing here. - let duration_ms = match self.playing_item() { - PlayingItem::Spotify(PlayableItem::Track(track)) => track.duration.num_milliseconds() as u32, - PlayingItem::Spotify(PlayableItem::Episode(episode)) => { - episode.duration.num_milliseconds() as u32 - } - PlayingItem::QueuedSpotify(track) => u32::try_from(track.duration_ms).unwrap_or(u32::MAX), - PlayingItem::Spotify(_) | PlayingItem::NotSpotify | PlayingItem::Nothing => return, - }; - - let event = if seek_ms < duration_ms { - IoEvent::Seek(seek_ms) - } else { - IoEvent::NextTrack - }; - - self.dispatch(event); - } - - pub fn seek_forwards(&mut self) { - info!( - "seeking forwards by {} ms", - self.user_config.behavior.seek_milliseconds - ); - // A decoded source owns the session: seek relative to *its* live position, - // never from the stale/foreign Spotify progress. The source player clamps - // to the track duration internally, so no upper clamp is needed (and we - // must not read the stale Spotify context duration). Radio has no position - // (not seekable): stop there instead of repositioning the paused Spotify - // player underneath it. - if self.active_decoded_source() { - if let Some(pos) = self.active_source_position_ms() { - let new_progress = (pos as u32).saturating_add(self.user_config.behavior.seek_milliseconds); - self.song_progress_ms = new_progress as u128; - self.seek_ms = None; - self.dispatch(IoEvent::Seek(new_progress)); - } - return; - } - if let Some(CurrentPlaybackContext { - item: Some(item), .. - }) = &self.current_playback_context - { - let duration_ms = match item { - PlayableItem::Track(track) => track.duration.num_milliseconds() as u32, - PlayableItem::Episode(episode) => episode.duration.num_milliseconds() as u32, - _ => return, - }; - - let old_progress = match self.seek_ms { - Some(seek_ms) => seek_ms, - None => self.song_progress_ms, - }; - - let new_progress = min( - (old_progress as u32).saturating_add(self.user_config.behavior.seek_milliseconds), - duration_ms, - ); - - self.seek_ms = Some(new_progress as u128); - - // Use native streaming player for instant control (bypasses event channel latency) - #[cfg(feature = "streaming")] - if self.is_native_streaming_active_for_playback() && self.streaming_player.is_some() { - // Always update UI immediately - self.song_progress_ms = new_progress as u128; - self.seek_ms = None; - - // Throttle actual seeks to avoid overwhelming librespot (max ~20/sec) - const SEEK_THROTTLE_MS: u128 = 50; - let should_seek_now = self - .last_native_seek - .is_none_or(|t| t.elapsed().as_millis() >= SEEK_THROTTLE_MS); - - if should_seek_now { - self.execute_native_seek(new_progress); - } else { - // Queue the seek - will be flushed by tick loop or next seek - self.pending_native_seek = Some(new_progress); - } - return; - } - - // Fallback: API-based seek for external devices (with throttling) - self.queue_api_seek(new_progress); - } - } - - pub fn seek_backwards(&mut self) { - info!( - "seeking backwards by {} ms", - self.user_config.behavior.seek_milliseconds - ); - // A decoded source owns the session: seek relative to *its* live position, - // never from the stale/foreign Spotify progress. Radio has no position (not - // seekable): stop there instead of repositioning the paused Spotify player - // underneath it. - if self.active_decoded_source() { - if let Some(pos) = self.active_source_position_ms() { - let new_progress = (pos as u32).saturating_sub(self.user_config.behavior.seek_milliseconds); - self.song_progress_ms = new_progress as u128; - self.seek_ms = None; - self.dispatch(IoEvent::Seek(new_progress)); - } - return; - } - let old_progress = match self.seek_ms { - Some(seek_ms) => seek_ms, - None => self.song_progress_ms, - }; - let new_progress = - (old_progress as u32).saturating_sub(self.user_config.behavior.seek_milliseconds); - self.seek_ms = Some(new_progress as u128); - - // Use native streaming player for instant control (bypasses event channel latency) - #[cfg(feature = "streaming")] - if self.is_native_streaming_active_for_playback() && self.streaming_player.is_some() { - // Always update UI immediately - self.song_progress_ms = new_progress as u128; - self.seek_ms = None; - - // Throttle actual seeks to avoid overwhelming librespot (max ~20/sec) - const SEEK_THROTTLE_MS: u128 = 50; - let should_seek_now = self - .last_native_seek - .is_none_or(|t| t.elapsed().as_millis() >= SEEK_THROTTLE_MS); - - if should_seek_now { - self.execute_native_seek(new_progress); - } else { - // Queue the seek - will be flushed by tick loop or next seek - self.pending_native_seek = Some(new_progress); - } - return; - } - - // Fallback: API-based seek for external devices (with throttling) - self.queue_api_seek(new_progress); - } - - /// Seek to an absolute position within the current track (e.g. from clicking or - /// dragging on the playbar progress line). The target is clamped to the track - /// duration. Mirrors the dispatch logic of [`Self::seek_forwards`]. - pub fn seek_to(&mut self, position_ms: u32) { - // A decoded source owns the session: seek it to the absolute target directly - // (the source player clamps to the track duration internally). Never read - // the stale Spotify context duration for a source. Radio has no position - // (not seekable): stop there instead of repositioning the paused Spotify - // player underneath it. - if self.active_decoded_source() { - if self.active_source_position_ms().is_some() { - // Decoded `.seek()` can re-decode forward for many codecs, so a mouse - // drag must not dispatch one seek per drag event; coalesce to the last - // target with the same throttle-and-flush pattern as the other backends. - self.queue_source_seek(position_ms); - } - return; - } - if let Some(CurrentPlaybackContext { - item: Some(item), .. - }) = &self.current_playback_context - { - let duration_ms = match item { - PlayableItem::Track(track) => track.duration.num_milliseconds() as u32, - PlayableItem::Episode(episode) => episode.duration.num_milliseconds() as u32, - _ => return, - }; - - let new_progress = position_ms.min(duration_ms); - self.seek_ms = Some(new_progress as u128); - - // Use native streaming player for instant control (bypasses event channel latency) - #[cfg(feature = "streaming")] - if self.is_native_streaming_active_for_playback() && self.streaming_player.is_some() { - // Always update UI immediately - self.song_progress_ms = new_progress as u128; - self.seek_ms = None; - - // Throttle actual seeks to avoid overwhelming librespot (max ~20/sec) - const SEEK_THROTTLE_MS: u128 = 50; - let should_seek_now = self - .last_native_seek - .is_none_or(|t| t.elapsed().as_millis() >= SEEK_THROTTLE_MS); - - if should_seek_now { - self.execute_native_seek(new_progress); - } else { - // Queue the seek - will be flushed by tick loop or next seek - self.pending_native_seek = Some(new_progress); - } - return; - } - - // Fallback: API-based seek for external devices (with throttling) - self.queue_api_seek(new_progress); - } - } - - /// Queue a decoded-source (local/Subsonic/YouTube) seek with throttling - fn queue_source_seek(&mut self, position_ms: u32) { - // Always update UI immediately - self.song_progress_ms = position_ms as u128; - self.seek_ms = None; - - const SOURCE_SEEK_THROTTLE_MS: u128 = 50; - let should_seek_now = self - .last_source_seek - .is_none_or(|t| t.elapsed().as_millis() >= SOURCE_SEEK_THROTTLE_MS); - - if should_seek_now { - self.execute_source_seek(position_ms); - } else { - // Queue the seek - will be flushed by tick loop or next seek - self.pending_source_seek = Some(position_ms); - } - } - - /// Execute a decoded-source seek and update tracking state - fn execute_source_seek(&mut self, position_ms: u32) { - self.pending_source_seek = None; - self.last_source_seek = Some(Instant::now()); - self.dispatch(IoEvent::Seek(position_ms)); - } - - /// Flush any pending decoded-source seek (called from tick loop) - pub fn flush_pending_source_seek(&mut self) { - // Queued for a decoded owner; once that owner is gone the value would - // reach whoever holds the sink now. - if self.pending_source_seek.is_some() && !self.active_decoded_source() { - self.pending_source_seek = None; - return; - } - if let Some(position) = self.pending_source_seek { - const SOURCE_SEEK_THROTTLE_MS: u128 = 50; - let should_flush = self - .last_source_seek - .is_none_or(|t| t.elapsed().as_millis() >= SOURCE_SEEK_THROTTLE_MS); - - if should_flush { - self.execute_source_seek(position); - } - } - } - - /// Queue an API-based seek with throttling (for external device control) - fn queue_api_seek(&mut self, position_ms: u32) { - // Always update UI immediately - self.song_progress_ms = position_ms as u128; - self.seek_ms = None; - - // Start the ignore window immediately when the user requests a seek - // This prevents position updates from overwriting our target while waiting - let now = Instant::now(); - - // Mark poll data as stale so resync won't happen after ignore window - self.instant_since_last_current_playback_poll = now; - - // Throttle API calls (max ~5/sec to respect rate limits) - const API_SEEK_THROTTLE_MS: u128 = 200; - let should_seek_now = self - .last_api_seek - .is_none_or(|t| t.elapsed().as_millis() >= API_SEEK_THROTTLE_MS); - - // Update last_api_seek for BOTH the ignore window AND throttling - // This ensures the ignore window starts immediately on any seek request - self.last_api_seek = Some(now); - - if should_seek_now { - self.execute_api_seek(position_ms); - } else { - // Queue the seek - will be flushed by tick loop - self.pending_api_seek = Some(position_ms); - } - } - - /// Execute an API-based seek - fn execute_api_seek(&mut self, position_ms: u32) { - self.pending_api_seek = None; - self.apply_seek(position_ms); - } - - /// Flush any pending API seek (called from tick loop) - pub fn flush_pending_api_seek(&mut self) { - // Queued for an external Connect device; under any other owner the Web - // API seek would land on a device that is not the audible one. - if self.pending_api_seek.is_some() && self.playback_owner() != PlaybackOwner::Spotify { - self.pending_api_seek = None; - return; - } - if let Some(position) = self.pending_api_seek { - const API_SEEK_THROTTLE_MS: u128 = 200; - let should_flush = self - .last_api_seek - .is_none_or(|t| t.elapsed().as_millis() >= API_SEEK_THROTTLE_MS); - - if should_flush { - self.execute_api_seek(position); - } - } - } - - /// Execute a native seek and update tracking state - #[cfg(feature = "streaming")] - fn execute_native_seek(&mut self, position_ms: u32) { - if let Some(player) = self.streaming_player.clone() { - player.seek(position_ms); - self.last_native_seek = Some(Instant::now()); - self.pending_native_seek = None; - self.set_native_recovery_position(position_ms); - - // Notify MPRIS clients that position jumped - #[cfg(all(feature = "mpris", target_os = "linux"))] - if let Some(ref mpris) = self.mpris_manager { - mpris.emit_seeked(position_ms as u64); - } - } - } - - /// Flush any pending native seek (called from tick loop) - #[cfg(feature = "streaming")] - pub fn flush_pending_native_seek(&mut self) { - // Queued for librespot; a decoded source that took the sink since leaves - // librespot paused, and a seek would move the wrong player. - if self.pending_native_seek.is_some() && self.active_decoded_source() { - self.pending_native_seek = None; - return; - } - if let Some(position) = self.pending_native_seek { - // Only flush if enough time has passed since last seek - const SEEK_THROTTLE_MS: u128 = 50; - let should_flush = self - .last_native_seek - .is_none_or(|t| t.elapsed().as_millis() >= SEEK_THROTTLE_MS); - - if should_flush { - self.execute_native_seek(position); - } - } - } -} - -#[cfg(all(test, feature = "streaming"))] -mod tests { - use super::*; - use crate::core::app::test_support::*; - use crate::infra::queue::QueueNowPlaying; - - fn app_with_spotify_queue_slot() -> (App, std::sync::mpsc::Receiver<IoEvent>) { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.queue_now = Some(QueueNowPlaying::Spotify { - track: queue_track(Some("spotify:track:queued"), "Queued"), - }); - (app, rx) - } - - #[test] - fn an_api_seek_queued_under_spotify_is_dropped_once_the_queue_slot_owns_playback() { - let (mut app, rx) = app_with_spotify_queue_slot(); - app.pending_api_seek = Some(5_000); - - app.flush_pending_api_seek(); - - assert!(app.pending_api_seek.is_none()); - assert!(rx.try_recv().is_err()); - } - - #[test] - fn a_source_seek_is_dropped_without_a_decoded_owner() { - let (mut app, rx) = app_with_spotify_queue_slot(); - app.pending_source_seek = Some(5_000); - - app.flush_pending_source_seek(); - - assert!(app.pending_source_seek.is_none()); - assert!(rx.try_recv().is_err()); - } - - #[test] - fn a_native_seek_survives_under_a_spotify_queue_slot() { - let (mut app, _rx) = app_with_spotify_queue_slot(); - app.pending_native_seek = Some(5_000); - - app.flush_pending_native_seek(); - - // No player is attached, so the value waits; the point is that it is kept. - assert_eq!(app.pending_native_seek, Some(5_000)); - } - - #[test] - fn apply_seek_under_a_spotify_queue_slot_uses_the_slot_track_duration() { - let (mut app, rx) = app_with_spotify_queue_slot(); - app.current_playback_context = Some(playing_track_context(full_track("ctx", "Context"))); - - // Past the slot track's 1000 ms but inside the suspended context's track. - app.apply_seek(2_000); - - assert!(matches!(rx.try_recv(), Ok(IoEvent::NextTrack))); - } -} diff --git a/src/core/app/settings_apply.rs b/src/core/app/settings_apply.rs deleted file mode 100644 index fcefc0f5..00000000 --- a/src/core/app/settings_apply.rs +++ /dev/null @@ -1,833 +0,0 @@ -use super::*; - -impl App { - /// Commit `settings_items` and write `config.yml`; `false` when the write - /// failed (the error frame is on top, do not close the screen over it). - pub(crate) fn save_settings_from_items(&mut self) -> bool { - #[cfg(feature = "telemetry")] - let global_count_was_enabled = self.user_config.behavior.enable_global_song_count; - // Apply settings to user_config and save to file - self.apply_settings_changes(); - - // If the user just turned the global counter on, fetch the current count now so - // the home banner reflects it without waiting for the next launch. - #[cfg(feature = "telemetry")] - if !global_count_was_enabled && self.user_config.behavior.enable_global_song_count { - self.dispatch(IoEvent::FetchGlobalSongCount); - } - #[cfg(target_os = "macos")] - if self.user_config.keys.open_settings != Key::Ctrl(',') { - self.keybinding_runtime.effective_open_settings = None; - self.keybinding_runtime.fallback_reason = None; - } - if let Err(e) = self.user_config.save_config() { - self.handle_error(anyhow::anyhow!("Failed to save settings: {}", e)); - return false; - } - - self.settings_saved_items = self.settings_items.clone(); - true - } - - pub(crate) fn cycle_visualizer_style(&mut self) { - self.user_config.behavior.visualizer_style = self.user_config.behavior.visualizer_style.next(); - // Save the config so the preference persists - let _ = self.user_config.save_config(); - } - - // Apply changes from settings_items back to user_config - pub fn apply_settings_changes(&mut self) { - use crate::core::user_config::{parse_theme_item, ThemePreset}; - - let mut settings_error: Option<String> = None; - // What is actually on screen right now, put back by the reconciliation at - // the end so an adaptive-theme fade continues from it. - #[cfg(feature = "art-decode")] - let cover_theme_live_before = self.user_config.theme; - #[cfg(feature = "art-decode")] - let cover_art_theme_before = self.user_config.behavior.cover_art_theme; - // Run the arms against the user's own colors rather than the live blend: - // settings_items holds only the current category's rows, so a save from - // another category rewrites no theme field at all, and analysis_bar has no - // row in any category. Whatever survives the arms IS the new base theme. - #[cfg(feature = "art-decode")] - { - self.user_config.theme = self.user_theme(); - } - for setting in &self.settings_items { - match setting.id.as_str() { - // Behavior settings - "behavior.seek_milliseconds" => { - if let SettingValue::Number(v) = &setting.value { - self.user_config.behavior.seek_milliseconds = *v as u32; - } - } - "behavior.volume_increment" => { - if let SettingValue::Number(v) = &setting.value { - self.user_config.behavior.volume_increment = (*v).clamp(0, 100) as u8; - } - } - "behavior.tick_rate_milliseconds" => { - if let SettingValue::Number(v) = &setting.value { - self.user_config.behavior.tick_rate_milliseconds = normalize_tick_rate_milliseconds(*v); - } - } - "behavior.animation_tick_rate_milliseconds" => { - if let SettingValue::Number(v) = &setting.value { - self.user_config.behavior.animation_tick_rate_milliseconds = - normalize_tick_rate_milliseconds(*v); - } - } - "behavior.status_message_ttl_percent" => { - if let SettingValue::Number(v) = &setting.value { - self.user_config.behavior.status_message_ttl_percent = (*v).clamp(10, 1000) as u16; - } - } - #[cfg(feature = "qobuz")] - "behavior.qobuz_quality" => { - if let SettingValue::Cycle(v, _) = &setting.value { - self.user_config.behavior.qobuz_quality = - crate::core::user_config::qobuz_quality_from_label(v); - } - } - "behavior.playback_poll_seconds" => { - if let SettingValue::Number(v) = &setting.value { - self.user_config.behavior.playback_poll_seconds = (*v).max(1) as u64; - } - } - "behavior.table_scroll_padding" => { - if let SettingValue::Number(v) = &setting.value { - self.user_config.behavior.table_scroll_padding = (*v).max(0) as u16; - } - } - "behavior.like_animation_frames" => { - if let SettingValue::Number(v) = &setting.value { - self.user_config.behavior.like_animation_frames = (*v).max(1) as u8; - } - } - "behavior.enable_text_emphasis" => { - if let SettingValue::Bool(v) = &setting.value { - self.user_config.behavior.enable_text_emphasis = *v; - } - } - "behavior.banner_gradient" => { - if let SettingValue::Bool(v) = &setting.value { - self.user_config.behavior.banner_gradient = *v; - } - } - "behavior.show_loading_indicator" => { - if let SettingValue::Bool(v) = &setting.value { - self.user_config.behavior.show_loading_indicator = *v; - } - } - "behavior.enforce_wide_search_bar" => { - if let SettingValue::Bool(v) = &setting.value { - self.user_config.behavior.enforce_wide_search_bar = *v; - } - } - "behavior.group_folders_first" => { - if let SettingValue::Bool(v) = &setting.value { - self.user_config.behavior.group_folders_first = *v; - } - } - "behavior.disable_mouse_inputs" => { - if let SettingValue::Bool(v) = &setting.value { - self.user_config.behavior.disable_mouse_inputs = *v; - } - } - "behavior.set_window_title" => { - if let SettingValue::Bool(v) = &setting.value { - self.user_config.behavior.set_window_title = *v; - } - } - "behavior.enable_discord_rpc" => { - if let SettingValue::Bool(v) = &setting.value { - self.user_config.behavior.enable_discord_rpc = *v; - } - } - "behavior.stop_after_current_track" => { - if let SettingValue::Bool(v) = &setting.value { - self.user_config.behavior.stop_after_current_track = *v; - } - } - "behavior.startup_behavior" => { - if let SettingValue::Cycle(v, _) = &setting.value { - self.user_config.behavior.startup_behavior = - crate::core::user_config::StartupBehavior::from_name(v); - } - } - "behavior.startup_route" => { - if let SettingValue::Cycle(v, _) = &setting.value { - self.user_config.behavior.startup_route = v.clone(); - } - } - "behavior.default_sort_playlist_tracks" => { - if let SettingValue::Cycle(v, _) = &setting.value { - self.user_config.behavior.default_sort_playlist_tracks = v.clone(); - } - } - "behavior.default_sort_saved_albums" => { - if let SettingValue::Cycle(v, _) = &setting.value { - self.user_config.behavior.default_sort_saved_albums = v.clone(); - } - } - "behavior.default_sort_saved_artists" => { - if let SettingValue::Cycle(v, _) = &setting.value { - self.user_config.behavior.default_sort_saved_artists = v.clone(); - } - } - "behavior.default_sort_recently_played" => { - if let SettingValue::Cycle(v, _) = &setting.value { - self.user_config.behavior.default_sort_recently_played = v.clone(); - } - } - "behavior.sidebar_position" => { - if let SettingValue::Cycle(v, _) = &setting.value { - self.user_config.behavior.sidebar_position = v.clone(); - } - } - "behavior.playbar_position" => { - if let SettingValue::Cycle(v, _) = &setting.value { - self.user_config.behavior.playbar_position = v.clone(); - } - } - "behavior.small_terminal_width" => { - if let SettingValue::Number(v) = &setting.value { - self.user_config.behavior.small_terminal_width = (*v).max(1) as u16; - } - } - "behavior.small_terminal_height" => { - if let SettingValue::Number(v) = &setting.value { - self.user_config.behavior.small_terminal_height = (*v).max(1) as u16; - } - } - "behavior.keepawake_enabled" => { - if let SettingValue::Bool(v) = &setting.value { - self.user_config.behavior.keepawake_enabled = *v; - } - } - "behavior.enable_media_keys" => { - if let SettingValue::Bool(v) = &setting.value { - self.user_config.behavior.enable_media_keys = *v; - } - } - "behavior.enable_announcements" => { - if let SettingValue::Bool(v) = &setting.value { - self.user_config.behavior.enable_announcements = *v; - } - } - "behavior.enable_monthly_recap_prompt" => { - if let SettingValue::Bool(v) = &setting.value { - self.user_config.behavior.enable_monthly_recap_prompt = *v; - } - } - "behavior.pin_community_playlist" => { - if let SettingValue::Bool(v) = &setting.value { - self.user_config.behavior.pin_community_playlist = *v; - } - } - #[cfg(feature = "telemetry")] - "behavior.enable_global_song_count" => { - if let SettingValue::Bool(v) = &setting.value { - self.user_config.behavior.enable_global_song_count = *v; - } - } - #[cfg(feature = "self-update")] - "behavior.disable_auto_update" => { - if let SettingValue::Bool(v) = &setting.value { - self.user_config.behavior.disable_auto_update = *v; - } - } - #[cfg(feature = "self-update")] - "behavior.auto_update_delay" => { - if let SettingValue::String(v) = &setting.value { - self.user_config.behavior.auto_update_delay = v.clone(); - } - } - "behavior.announcement_feed_url" => { - if let SettingValue::String(v) = &setting.value { - let trimmed = v.trim(); - self.user_config.behavior.announcement_feed_url = if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - }; - } - } - "behavior.sync_token" => { - if let SettingValue::String(v) = &setting.value { - let trimmed = v.trim(); - self.user_config.behavior.sync_token = if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - }; - } - } - "behavior.liked_icon" => { - if let SettingValue::String(v) = &setting.value { - self.user_config.behavior.liked_icon = v.clone(); - } - } - "behavior.shuffle_icon" => { - if let SettingValue::String(v) = &setting.value { - self.user_config.behavior.shuffle_icon = v.clone(); - } - } - "behavior.playing_icon" => { - if let SettingValue::String(v) = &setting.value { - self.user_config.behavior.playing_icon = v.clone(); - } - } - "behavior.paused_icon" => { - if let SettingValue::String(v) = &setting.value { - self.user_config.behavior.paused_icon = v.clone(); - } - } - "behavior.gauge_filled_icon" - | "behavior.gauge_unfilled_icon" - | "behavior.episode_played_icon" - | "behavior.sort_ascending_icon" - | "behavior.sort_descending_icon" => { - if let SettingValue::String(v) = &setting.value { - if UnicodeWidthStr::width(v.as_str()) == 1 { - match setting.id.as_str() { - "behavior.gauge_filled_icon" => { - self.user_config.behavior.gauge_filled_icon = v.clone() - } - "behavior.gauge_unfilled_icon" => { - self.user_config.behavior.gauge_unfilled_icon = v.clone() - } - "behavior.episode_played_icon" => { - self.user_config.behavior.episode_played_icon = v.clone() - } - "behavior.sort_ascending_icon" => { - self.user_config.behavior.sort_ascending_icon = v.clone() - } - "behavior.sort_descending_icon" => { - self.user_config.behavior.sort_descending_icon = v.clone() - } - _ => {} - } - } else { - settings_error = Some(format!( - "{} must be exactly one terminal cell wide", - setting.name - )); - } - } - } - "behavior.active_source_icon" => { - if let SettingValue::String(v) = &setting.value { - self.user_config.behavior.active_source_icon = v.clone(); - } - } - "behavior.list_highlight_icon" => { - if let SettingValue::String(v) = &setting.value { - self.user_config.behavior.list_highlight_icon = v.clone(); - } - } - #[cfg(feature = "cover-art")] - "behavior.draw_cover_art" => { - if let SettingValue::Bool(v) = setting.value { - self.user_config.behavior.draw_cover_art = v; - } - } - #[cfg(feature = "art-decode")] - "behavior.cover_art_theme" => { - if let SettingValue::Bool(v) = setting.value { - self.user_config.behavior.cover_art_theme = v; - } - } - #[cfg(feature = "cover-art")] - "behavior.draw_cover_art_forced" => { - if let SettingValue::Bool(v) = setting.value { - self.user_config.behavior.draw_cover_art_forced = v; - } - } - #[cfg(feature = "cover-art")] - "behavior.playbar_cover_art_size_percent" => { - if let SettingValue::Number(v) = setting.value { - self.user_config.behavior.playbar_cover_art_size_percent = - crate::core::user_config::normalize_playbar_cover_art_size_percent(v); - } - } - #[cfg(feature = "cover-art")] - "behavior.cover_art_dither" => { - if let SettingValue::Bool(v) = setting.value { - self.user_config.behavior.cover_art_dither = v; - } - } - #[cfg(feature = "cover-art")] - "behavior.cover_art_dither_algorithm" => { - if let SettingValue::Cycle(v, _) = &setting.value { - self.user_config.behavior.cover_art_dither_algorithm = - crate::core::user_config::normalize_cover_art_dither_algorithm(v).to_string(); - } - } - #[cfg(feature = "cover-art")] - "behavior.cover_art_dither_pixel_scale" => { - if let SettingValue::Number(v) = setting.value { - self.user_config.behavior.cover_art_dither_pixel_scale = - crate::core::user_config::normalize_cover_art_dither_pixel_scale(v); - } - } - // Keybindings - "keys.back" => { - if let SettingValue::Key(v) = &setting.value { - if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) { - self.user_config.keys.back = key; - } - } - } - "keys.move_up" => { - if let SettingValue::Key(v) = &setting.value { - if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) { - self.user_config.keys.move_up = key; - } - } - } - "keys.move_down" => { - if let SettingValue::Key(v) = &setting.value { - if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) { - self.user_config.keys.move_down = key; - } - } - } - "keys.move_left" => { - if let SettingValue::Key(v) = &setting.value { - if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) { - self.user_config.keys.move_left = key; - } - } - } - "keys.move_right" => { - if let SettingValue::Key(v) = &setting.value { - if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) { - self.user_config.keys.move_right = key; - } - } - } - "keys.next_page" => { - if let SettingValue::Key(v) = &setting.value { - if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) { - self.user_config.keys.next_page = key; - } - } - } - "keys.previous_page" => { - if let SettingValue::Key(v) = &setting.value { - if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) { - self.user_config.keys.previous_page = key; - } - } - } - "keys.toggle_playback" => { - if let SettingValue::Key(v) = &setting.value { - if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) { - self.user_config.keys.toggle_playback = key; - } - } - } - "keys.seek_backwards" => { - if let SettingValue::Key(v) = &setting.value { - if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) { - self.user_config.keys.seek_backwards = key; - } - } - } - "keys.seek_forwards" => { - if let SettingValue::Key(v) = &setting.value { - if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) { - self.user_config.keys.seek_forwards = key; - } - } - } - "keys.next_track" => { - if let SettingValue::Key(v) = &setting.value { - if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) { - self.user_config.keys.next_track = key; - } - } - } - "keys.previous_track" => { - if let SettingValue::Key(v) = &setting.value { - if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) { - self.user_config.keys.previous_track = key; - } - } - } - "keys.force_previous_track" => { - if let SettingValue::Key(v) = &setting.value { - if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) { - self.user_config.keys.force_previous_track = key; - } - } - } - "keys.shuffle" => { - if let SettingValue::Key(v) = &setting.value { - if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) { - self.user_config.keys.shuffle = key; - } - } - } - "keys.repeat" => { - if let SettingValue::Key(v) = &setting.value { - if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) { - self.user_config.keys.repeat = key; - } - } - } - "keys.search" => { - if let SettingValue::Key(v) = &setting.value { - if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) { - self.user_config.keys.search = key; - } - } - } - "keys.help" => { - if let SettingValue::Key(v) = &setting.value { - if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) { - self.user_config.keys.help = key; - } - } - } - "keys.open_settings" => { - if let SettingValue::Key(v) = &setting.value { - if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) { - self.user_config.keys.open_settings = key; - } - } - } - "keys.save_settings" => { - if let SettingValue::Key(v) = &setting.value { - if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) { - self.user_config.keys.save_settings = key; - } - } - } - "keys.jump_to_album" => { - if let SettingValue::Key(v) = &setting.value { - if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) { - self.user_config.keys.jump_to_album = key; - } - } - } - "keys.jump_to_artist_album" => { - if let SettingValue::Key(v) = &setting.value { - if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) { - self.user_config.keys.jump_to_artist_album = key; - } - } - } - "keys.jump_to_context" => { - if let SettingValue::Key(v) = &setting.value { - if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) { - self.user_config.keys.jump_to_context = key; - } - } - } - "keys.manage_devices" => { - if let SettingValue::Key(v) = &setting.value { - if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) { - self.user_config.keys.manage_devices = key; - } - } - } - "keys.decrease_volume" => { - if let SettingValue::Key(v) = &setting.value { - if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) { - self.user_config.keys.decrease_volume = key; - } - } - } - "keys.increase_volume" => { - if let SettingValue::Key(v) = &setting.value { - if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) { - self.user_config.keys.increase_volume = key; - } - } - } - "keys.add_item_to_queue" => { - if let SettingValue::Key(v) = &setting.value { - if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) { - self.user_config.keys.add_item_to_queue = key; - } - } - } - "keys.show_queue" => { - if let SettingValue::Key(v) = &setting.value { - if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) { - self.user_config.keys.show_queue = key; - } - } - } - "keys.remove_from_queue" => { - if let SettingValue::Key(v) = &setting.value { - if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) { - self.user_config.keys.remove_from_queue = key; - } - } - } - "keys.like_track" => { - if let SettingValue::Key(v) = &setting.value { - if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) { - self.user_config.keys.like_track = key; - } - } - } - "keys.generate_recap" => { - if let SettingValue::Key(v) = &setting.value { - if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) { - self.user_config.keys.generate_recap = key; - } - } - } - "keys.copy_song_url" => { - if let SettingValue::Key(v) = &setting.value { - if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) { - self.user_config.keys.copy_song_url = key; - } - } - } - "keys.copy_album_url" => { - if let SettingValue::Key(v) = &setting.value { - if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) { - self.user_config.keys.copy_album_url = key; - } - } - } - "keys.audio_analysis" => { - if let SettingValue::Key(v) = &setting.value { - if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) { - self.user_config.keys.audio_analysis = key; - } - } - } - "keys.lyrics_view" => { - if let SettingValue::Key(v) = &setting.value { - if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) { - self.user_config.keys.lyrics_view = key; - } - } - } - "keys.miniplayer_view" => { - if let SettingValue::Key(v) = &setting.value { - if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) { - self.user_config.keys.miniplayer_view = key; - } - } - } - #[cfg(feature = "cover-art")] - "keys.cover_art_view" => { - if let SettingValue::Key(v) = &setting.value { - if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) { - self.user_config.keys.cover_art_view = key; - } - } - } - // Decides whether the per-color changes following will apply. - // A named preset takes priority; the user's custom_theme is preserved - // so they can return to it later by selecting Custom. - "theme.preset" => { - if let SettingValue::Preset(name) = &setting.value { - let preset = ThemePreset::from_name(name); - self.user_config.current_preset = preset; - if preset != ThemePreset::Custom { - self.user_config.theme = preset.to_theme(); - } - } - } - // Individual theme color overrides only apply when on Custom; they - // update both the active theme and the persisted custom_theme. - "theme.active" if self.user_config.current_preset == ThemePreset::Custom => { - if let SettingValue::Color(v) = &setting.value { - if let Ok(c) = parse_theme_item(v) { - self.user_config.theme.active = c; - self.user_config.custom_theme.active = c; - } - } - } - "theme.banner" if self.user_config.current_preset == ThemePreset::Custom => { - if let SettingValue::Color(v) = &setting.value { - if let Ok(c) = parse_theme_item(v) { - self.user_config.theme.banner = c; - self.user_config.custom_theme.banner = c; - } - } - } - "theme.hint" if self.user_config.current_preset == ThemePreset::Custom => { - if let SettingValue::Color(v) = &setting.value { - if let Ok(c) = parse_theme_item(v) { - self.user_config.theme.hint = c; - self.user_config.custom_theme.hint = c; - } - } - } - "theme.hovered" if self.user_config.current_preset == ThemePreset::Custom => { - if let SettingValue::Color(v) = &setting.value { - if let Ok(c) = parse_theme_item(v) { - self.user_config.theme.hovered = c; - self.user_config.custom_theme.hovered = c; - } - } - } - "theme.selected" if self.user_config.current_preset == ThemePreset::Custom => { - if let SettingValue::Color(v) = &setting.value { - if let Ok(c) = parse_theme_item(v) { - self.user_config.theme.selected = c; - self.user_config.custom_theme.selected = c; - } - } - } - "theme.inactive" if self.user_config.current_preset == ThemePreset::Custom => { - if let SettingValue::Color(v) = &setting.value { - if let Ok(c) = parse_theme_item(v) { - self.user_config.theme.inactive = c; - self.user_config.custom_theme.inactive = c; - } - } - } - "theme.text" if self.user_config.current_preset == ThemePreset::Custom => { - if let SettingValue::Color(v) = &setting.value { - if let Ok(c) = parse_theme_item(v) { - self.user_config.theme.text = c; - self.user_config.custom_theme.text = c; - } - } - } - "theme.error_text" if self.user_config.current_preset == ThemePreset::Custom => { - if let SettingValue::Color(v) = &setting.value { - if let Ok(c) = parse_theme_item(v) { - self.user_config.theme.error_text = c; - self.user_config.custom_theme.error_text = c; - } - } - } - "theme.error_border" if self.user_config.current_preset == ThemePreset::Custom => { - if let SettingValue::Color(v) = &setting.value { - if let Ok(c) = parse_theme_item(v) { - self.user_config.theme.error_border = c; - self.user_config.custom_theme.error_border = c; - } - } - } - "theme.playbar_background" if self.user_config.current_preset == ThemePreset::Custom => { - if let SettingValue::Color(v) = &setting.value { - if let Ok(c) = parse_theme_item(v) { - self.user_config.theme.playbar_background = c; - self.user_config.custom_theme.playbar_background = c; - } - } - } - "theme.playbar_progress" if self.user_config.current_preset == ThemePreset::Custom => { - if let SettingValue::Color(v) = &setting.value { - if let Ok(c) = parse_theme_item(v) { - self.user_config.theme.playbar_progress = c; - self.user_config.custom_theme.playbar_progress = c; - } - } - } - #[cfg(feature = "cover-art")] - "theme.cover_art_dither_color" => { - if let SettingValue::Color(v) = &setting.value { - if v.trim().eq_ignore_ascii_case("auto") || v.trim().is_empty() { - self.user_config.cover_art_dither_color = None; - } else if let Ok(c) = parse_theme_item(v) { - self.user_config.cover_art_dither_color = Some(c); - } - } - } - "theme.playbar_progress_text" if self.user_config.current_preset == ThemePreset::Custom => { - if let SettingValue::Color(v) = &setting.value { - if let Ok(c) = parse_theme_item(v) { - self.user_config.theme.playbar_progress_text = c; - self.user_config.custom_theme.playbar_progress_text = c; - } - } - } - "theme.playbar_text" if self.user_config.current_preset == ThemePreset::Custom => { - if let SettingValue::Color(v) = &setting.value { - if let Ok(c) = parse_theme_item(v) { - self.user_config.theme.playbar_text = c; - self.user_config.custom_theme.playbar_text = c; - } - } - } - "theme.highlighted_lyrics" if self.user_config.current_preset == ThemePreset::Custom => { - if let SettingValue::Color(v) = &setting.value { - if let Ok(c) = parse_theme_item(v) { - self.user_config.theme.highlighted_lyrics = c; - self.user_config.custom_theme.highlighted_lyrics = c; - } - } - } - "theme.background" if self.user_config.current_preset == ThemePreset::Custom => { - if let SettingValue::Color(v) = &setting.value { - if let Ok(c) = parse_theme_item(v) { - self.user_config.theme.background = c; - self.user_config.custom_theme.background = c; - } - } - } - "theme.header" if self.user_config.current_preset == ThemePreset::Custom => { - if let SettingValue::Color(v) = &setting.value { - if let Ok(c) = parse_theme_item(v) { - self.user_config.theme.header = c; - self.user_config.custom_theme.header = c; - } - } - } - _ => {} - } - } - #[cfg(feature = "art-decode")] - self.reconcile_cover_theme_after_settings(cover_theme_live_before, cover_art_theme_before); - if let Some(message) = settings_error { - self.set_status_message(message, 4); - } - } - - /// Updates the colour RGB entries when switching through the presets in themes - pub fn sync_theme_color_settings(&mut self, theme: &crate::core::user_config::Theme) { - let mappings: [(&str, crate::core::theme::Color); 16] = [ - ("theme.active", theme.active), - ("theme.banner", theme.banner), - ("theme.hint", theme.hint), - ("theme.hovered", theme.hovered), - ("theme.selected", theme.selected), - ("theme.inactive", theme.inactive), - ("theme.text", theme.text), - ("theme.error_text", theme.error_text), - ("theme.error_border", theme.error_border), - ("theme.playbar_background", theme.playbar_background), - ("theme.playbar_progress", theme.playbar_progress), - ("theme.playbar_progress_text", theme.playbar_progress_text), - ("theme.playbar_text", theme.playbar_text), - ("theme.highlighted_lyrics", theme.highlighted_lyrics), - ("theme.background", theme.background), - ("theme.header", theme.header), - ]; - for setting in &mut self.settings_items { - if let Some((_, color)) = mappings.iter().find(|(id, _)| *id == setting.id) { - setting.value = SettingValue::Color(color_to_string(*color)); - } - } - } - - /// Sync the Banner Gradient toggle to a newly selected preset's default - /// (Terminal defaults to off so the banner follows the terminal palette). - /// Custom keeps whatever the user chose. - pub fn sync_banner_gradient_setting(&mut self, preset: crate::core::user_config::ThemePreset) { - if preset == crate::core::user_config::ThemePreset::Custom { - return; - } - if let Some(setting) = self - .settings_items - .iter_mut() - .find(|s| s.id == "behavior.banner_gradient") - { - setting.value = SettingValue::Bool(preset.default_banner_gradient()); - } - } -} diff --git a/src/core/app/settings_schema.rs b/src/core/app/settings_schema.rs deleted file mode 100644 index 39f4daf2..00000000 --- a/src/core/app/settings_schema.rs +++ /dev/null @@ -1,1087 +0,0 @@ -use super::*; - -/// Settings screen category tabs -#[derive(Clone, Copy, PartialEq, Debug, Default)] -pub enum SettingsCategory { - #[default] - Behavior, - Icons, - Keybindings, - Theme, -} - -impl SettingsCategory { - pub fn all() -> &'static [SettingsCategory] { - &[ - SettingsCategory::Behavior, - SettingsCategory::Icons, - SettingsCategory::Keybindings, - SettingsCategory::Theme, - ] - } - - pub fn name(&self) -> &'static str { - match self { - SettingsCategory::Behavior => "Behavior", - SettingsCategory::Icons => "Icons", - SettingsCategory::Keybindings => "Keybindings", - SettingsCategory::Theme => "Theme", - } - } - - pub fn index(&self) -> usize { - match self { - SettingsCategory::Behavior => 0, - SettingsCategory::Icons => 1, - SettingsCategory::Keybindings => 2, - SettingsCategory::Theme => 3, - } - } - - pub fn from_index(index: usize) -> Self { - match index { - 0 => SettingsCategory::Behavior, - 1 => SettingsCategory::Icons, - 2 => SettingsCategory::Keybindings, - 3 => SettingsCategory::Theme, - _ => SettingsCategory::Behavior, - } - } -} - -/// Represents a setting's value type -#[derive(Clone, PartialEq, Debug)] -pub enum SettingValue { - Bool(bool), - Number(i64), - String(String), - Color(String), // Stored as "R,G,B" or color name - Key(String), // Key representation like "ctrl-s" or "a" - Preset(String), // Theme preset name - cycles through available presets - /// A value cycling through a fixed list of options: (current, all options). - Cycle(String, &'static [&'static str]), -} - -const STARTUP_ROUTE_SETTING_OPTIONS: &[&str] = &[ - "home", - "recently_played", - "podcasts", - "discover", - "artists", - "album_list", - "stats", -]; - -/// The startup routes that open populated without a Spotify session. -const STARTUP_ROUTE_SESSION_FREE_OPTIONS: &[&str] = &["home", "stats"]; - -/// What a settings row needs; an unmet key row stays with a hint, any other unmet row is hidden. -pub(crate) fn setting_requirement(id: &str) -> Requirement { - match id { - "behavior.playback_poll_seconds" - | "behavior.default_sort_saved_albums" - | "behavior.default_sort_saved_artists" - | "behavior.default_sort_recently_played" - | "behavior.pin_community_playlist" - | "behavior.liked_icon" - | "behavior.episode_played_icon" - | "keys.jump_to_album" - | "keys.jump_to_artist_album" - | "keys.jump_to_context" - | "keys.copy_song_url" - | "keys.copy_album_url" => Requirement::SpotifySession, - // The like key favorites the station under Radio. - "keys.like_track" => Requirement::AnyOf(&[ - Requirement::Capability(Capability::Like), - Requirement::Source(Source::Radio), - ]), - "keys.search" => Requirement::Capability(Capability::Search), - _ => Requirement::None, - } -} - -const PLAYLIST_TRACK_SORT_SETTING_OPTIONS: &[&str] = &[ - "default", - "name", - "name:desc", - "date_added", - "date_added:desc", - "artist", - "artist:desc", - "album", - "album:desc", - "duration", - "duration:desc", -]; - -const SAVED_ALBUM_SORT_SETTING_OPTIONS: &[&str] = &[ - "default", - "name", - "name:desc", - "date_added", - "date_added:desc", - "artist", - "artist:desc", -]; - -const SAVED_ARTIST_SORT_SETTING_OPTIONS: &[&str] = &["default", "name", "name:desc"]; - -const RECENTLY_PLAYED_SORT_SETTING_OPTIONS: &[&str] = &[ - "default", - "name", - "name:desc", - "artist", - "artist:desc", - "album", - "album:desc", -]; - -const SIDEBAR_POSITION_SETTING_OPTIONS: &[&str] = &["left", "right", "hidden"]; - -const PLAYBAR_POSITION_SETTING_OPTIONS: &[&str] = &["bottom", "top"]; - -impl SettingValue { - #[allow(dead_code)] - pub fn display(&self) -> String { - match self { - SettingValue::Bool(v) => if *v { "On" } else { "Off" }.to_string(), - SettingValue::Number(v) => v.to_string(), - SettingValue::String(v) => v.clone(), - SettingValue::Color(v) => v.clone(), - SettingValue::Key(v) => v.clone(), - SettingValue::Preset(v) => v.clone(), - SettingValue::Cycle(v, _) => v.clone(), - } - } -} - -/// Represents a single configurable setting -#[derive(Clone, Debug, PartialEq)] -pub struct SettingItem { - pub id: String, // e.g., "behavior.seek_milliseconds" - pub name: String, // e.g., "Seek Duration" - #[allow(dead_code)] - pub description: String, // e.g., "Milliseconds to skip when seeking" (for future tooltip) - pub value: SettingValue, -} - -impl App { - /// Load settings for the current category into settings_items - pub fn load_settings_for_category(&mut self) { - // Helper to convert Key to displayable string - fn key_to_string(key: &Key) -> String { - match key { - Key::Char(c) => c.to_string(), - Key::Ctrl(c) => format!("ctrl-{}", c), - Key::Alt(c) => format!("alt-{}", c), - Key::Enter => "enter".to_string(), - Key::Esc => "esc".to_string(), - Key::Backspace => "backspace".to_string(), - Key::Delete => "del".to_string(), - Key::Left => "left".to_string(), - Key::Right => "right".to_string(), - Key::Up => "up".to_string(), - Key::Down => "down".to_string(), - Key::PageUp => "pageup".to_string(), - Key::PageDown => "pagedown".to_string(), - _ => "unknown".to_string(), - } - } - - self.settings_items = match self.view.settings_category { - SettingsCategory::Behavior => vec![ - SettingItem { - id: "behavior.seek_milliseconds".to_string(), - name: "Seek Duration (ms)".to_string(), - description: "Milliseconds to skip when seeking".to_string(), - value: SettingValue::Number(self.user_config.behavior.seek_milliseconds as i64), - }, - SettingItem { - id: "behavior.volume_increment".to_string(), - name: "Volume Increment".to_string(), - description: "Volume change per keypress (0-100)".to_string(), - value: SettingValue::Number(self.user_config.behavior.volume_increment as i64), - }, - SettingItem { - id: "behavior.tick_rate_milliseconds".to_string(), - name: "Tick Rate (ms)".to_string(), - description: "UI refresh rate in milliseconds".to_string(), - value: SettingValue::Number(self.user_config.behavior.tick_rate_milliseconds as i64), - }, - SettingItem { - id: "behavior.animation_tick_rate_milliseconds".to_string(), - name: "Animation Tick Rate (ms)".to_string(), - description: "Refresh rate for animation-heavy views".to_string(), - value: SettingValue::Number( - self.user_config.behavior.animation_tick_rate_milliseconds as i64, - ), - }, - SettingItem { - id: "behavior.status_message_ttl_percent".to_string(), - name: "Status TTL Percent".to_string(), - description: "Scale status message duration from 10% to 1000%".to_string(), - value: SettingValue::Number(self.user_config.behavior.status_message_ttl_percent as i64), - }, - #[cfg(feature = "qobuz")] - SettingItem { - id: "behavior.qobuz_quality".to_string(), - name: "Qobuz Quality".to_string(), - description: "Stream quality for Qobuz tracks (hi-res files are large)".to_string(), - value: SettingValue::Cycle( - crate::core::user_config::qobuz_quality_label(self.user_config.behavior.qobuz_quality) - .to_string(), - crate::core::user_config::QOBUZ_QUALITY_LABELS, - ), - }, - SettingItem { - id: "behavior.playback_poll_seconds".to_string(), - name: "Playback Poll Seconds".to_string(), - description: "Seconds between regular playback refreshes".to_string(), - value: SettingValue::Number(self.user_config.behavior.playback_poll_seconds as i64), - }, - SettingItem { - id: "behavior.table_scroll_padding".to_string(), - name: "Table Scroll Padding".to_string(), - description: "Rows reserved while scrolling tables".to_string(), - value: SettingValue::Number(self.user_config.behavior.table_scroll_padding as i64), - }, - SettingItem { - id: "behavior.like_animation_frames".to_string(), - name: "Like Animation Frames".to_string(), - description: "Frames used by the playbar like animation".to_string(), - value: SettingValue::Number(self.user_config.behavior.like_animation_frames as i64), - }, - SettingItem { - id: "behavior.enable_text_emphasis".to_string(), - name: "Text Emphasis".to_string(), - description: "Enable bold/italic text styling".to_string(), - value: SettingValue::Bool(self.user_config.behavior.enable_text_emphasis), - }, - SettingItem { - id: "behavior.show_loading_indicator".to_string(), - name: "Loading Indicator".to_string(), - description: "Show loading status in UI".to_string(), - value: SettingValue::Bool(self.user_config.behavior.show_loading_indicator), - }, - SettingItem { - id: "behavior.enforce_wide_search_bar".to_string(), - name: "Wide Search Bar".to_string(), - description: "Force search bar to take full width".to_string(), - value: SettingValue::Bool(self.user_config.behavior.enforce_wide_search_bar), - }, - SettingItem { - id: "behavior.group_folders_first".to_string(), - name: "Playlist Folders First".to_string(), - description: "List folders at the top of the Playlists tab".to_string(), - value: SettingValue::Bool(self.user_config.behavior.group_folders_first), - }, - SettingItem { - id: "behavior.disable_mouse_inputs".to_string(), - name: "Disable Mouse Inputs".to_string(), - description: "Disable mouse inputs for keyboard-only navigation".to_string(), - value: SettingValue::Bool(self.user_config.behavior.disable_mouse_inputs), - }, - SettingItem { - id: "behavior.set_window_title".to_string(), - name: "Set Window Title".to_string(), - description: "Update terminal window title with track info".to_string(), - value: SettingValue::Bool(self.user_config.behavior.set_window_title), - }, - SettingItem { - id: "behavior.enable_discord_rpc".to_string(), - name: "Discord Rich Presence".to_string(), - description: "Show your current track in Discord".to_string(), - value: SettingValue::Bool(self.user_config.behavior.enable_discord_rpc), - }, - SettingItem { - id: "behavior.stop_after_current_track".to_string(), - name: "Stop After Current Track".to_string(), - description: "Pause playback when the current track finishes".to_string(), - value: SettingValue::Bool(self.user_config.behavior.stop_after_current_track), - }, - SettingItem { - id: "behavior.keepawake_enabled".to_string(), - name: "Keep System Awake".to_string(), - description: "Prevent the system from sleeping while music is playing".to_string(), - value: SettingValue::Bool(self.user_config.behavior.keepawake_enabled), - }, - SettingItem { - id: "behavior.enable_media_keys".to_string(), - name: "Media Key Controls".to_string(), - description: - "Let OS media keys, headphone buttons, and remote controls (playerctl, Now Playing) control playback" - .to_string(), - value: SettingValue::Bool(self.user_config.behavior.enable_media_keys), - }, - SettingItem { - id: "behavior.startup_behavior".to_string(), - name: "Startup Behavior".to_string(), - description: "Playback state when spotatui starts. Continue resumes your last session (including a saved non-Spotify track) exactly as it was; Play always starts; Pause always pauses.".to_string(), - value: SettingValue::Cycle( - self - .user_config - .behavior - .startup_behavior - .name() - .to_string(), - crate::core::user_config::StartupBehavior::options(), - ), - }, - SettingItem { - id: "behavior.startup_route".to_string(), - name: "Startup Route".to_string(), - description: "Screen shown when spotatui starts".to_string(), - value: SettingValue::Cycle( - self.user_config.behavior.startup_route.clone(), - self.startup_route_options(), - ), - }, - SettingItem { - id: "behavior.default_sort_playlist_tracks".to_string(), - name: "Playlist Track Sort".to_string(), - description: "Default sort for playlist track tables".to_string(), - value: SettingValue::Cycle( - self.user_config.behavior.default_sort_playlist_tracks.clone(), - PLAYLIST_TRACK_SORT_SETTING_OPTIONS, - ), - }, - SettingItem { - id: "behavior.default_sort_saved_albums".to_string(), - name: "Saved Album Sort".to_string(), - description: "Default sort for saved albums".to_string(), - value: SettingValue::Cycle( - self.user_config.behavior.default_sort_saved_albums.clone(), - SAVED_ALBUM_SORT_SETTING_OPTIONS, - ), - }, - SettingItem { - id: "behavior.default_sort_saved_artists".to_string(), - name: "Saved Artist Sort".to_string(), - description: "Default sort for saved artists".to_string(), - value: SettingValue::Cycle( - self.user_config.behavior.default_sort_saved_artists.clone(), - SAVED_ARTIST_SORT_SETTING_OPTIONS, - ), - }, - SettingItem { - id: "behavior.default_sort_recently_played".to_string(), - name: "Recently Played Sort".to_string(), - description: "Default sort for recently played tracks".to_string(), - value: SettingValue::Cycle( - self.user_config.behavior.default_sort_recently_played.clone(), - RECENTLY_PLAYED_SORT_SETTING_OPTIONS, - ), - }, - SettingItem { - id: "behavior.sidebar_position".to_string(), - name: "Sidebar Position".to_string(), - description: "Place the sidebar left, right, or hide it".to_string(), - value: SettingValue::Cycle( - self.user_config.behavior.sidebar_position.clone(), - SIDEBAR_POSITION_SETTING_OPTIONS, - ), - }, - SettingItem { - id: "behavior.playbar_position".to_string(), - name: "Playbar Position".to_string(), - description: "Place the playbar at the bottom or top".to_string(), - value: SettingValue::Cycle( - self.user_config.behavior.playbar_position.clone(), - PLAYBAR_POSITION_SETTING_OPTIONS, - ), - }, - SettingItem { - id: "behavior.small_terminal_width".to_string(), - name: "Small Terminal Width".to_string(), - description: "Width below which compact layout is used".to_string(), - value: SettingValue::Number(self.user_config.behavior.small_terminal_width as i64), - }, - SettingItem { - id: "behavior.small_terminal_height".to_string(), - name: "Small Terminal Height".to_string(), - description: "Height below which compact margins are used".to_string(), - value: SettingValue::Number(self.user_config.behavior.small_terminal_height as i64), - }, - SettingItem { - id: "behavior.enable_announcements".to_string(), - name: "Remote Announcements".to_string(), - description: "Show one-time announcements from remote JSON feed".to_string(), - value: SettingValue::Bool(self.user_config.behavior.enable_announcements), - }, - SettingItem { - id: "behavior.enable_monthly_recap_prompt".to_string(), - name: "Monthly Recap Prompt".to_string(), - description: "Show a popup once a month when your listening recap is ready".to_string(), - value: SettingValue::Bool(self.user_config.behavior.enable_monthly_recap_prompt), - }, - SettingItem { - id: "behavior.pin_community_playlist".to_string(), - name: "Community Playlist Pin".to_string(), - description: "Pin the spotatui community playlist to the top of your Spotify playlists" - .to_string(), - value: SettingValue::Bool(self.user_config.behavior.pin_community_playlist), - }, - #[cfg(feature = "telemetry")] - SettingItem { - id: "behavior.enable_global_song_count".to_string(), - name: "Global Song Counter".to_string(), - description: "Contribute to the anonymous worldwide song counter. No personal info, song names, or history are sent; only a simple increment when a new song starts.".to_string(), - value: SettingValue::Bool(self.user_config.behavior.enable_global_song_count), - }, - #[cfg(feature = "self-update")] - SettingItem { - id: "behavior.disable_auto_update".to_string(), - name: "Disable Auto-Update".to_string(), - description: "Skip the automatic update check on startup. Use the 'spotatui update' command to update manually.".to_string(), - value: SettingValue::Bool(self.user_config.behavior.disable_auto_update), - }, - #[cfg(feature = "self-update")] - SettingItem { - id: "behavior.auto_update_delay".to_string(), - name: "Auto-Update Delay".to_string(), - description: "How long to wait before installing an available update. Use '0' for immediate, or e.g. '10m', '2h', '7d'. Only applies when auto-update is enabled.".to_string(), - value: SettingValue::String(self.user_config.behavior.auto_update_delay.clone()), - }, - SettingItem { - id: "behavior.announcement_feed_url".to_string(), - name: "Announcements Feed URL".to_string(), - description: "Remote JSON feed URL (HTTPS)".to_string(), - value: SettingValue::String( - self - .user_config - .behavior - .announcement_feed_url - .clone() - .unwrap_or_default(), - ), - }, - SettingItem { - id: "behavior.sync_token".to_string(), - name: "Sync Token".to_string(), - description: "API token from spotatui.com to sync listening history".to_string(), - value: SettingValue::String( - self - .user_config - .behavior - .sync_token - .clone() - .unwrap_or_default(), - ), - }, - #[cfg(feature = "cover-art")] - SettingItem { - id: "behavior.draw_cover_art".to_string(), - name: "Draw Cover Art".to_string(), - description: "Enable rendering song/episode cover art".to_string(), - value: SettingValue::Bool(self.user_config.behavior.draw_cover_art), - }, - #[cfg(feature = "cover-art")] - SettingItem { - id: "behavior.draw_cover_art_forced".to_string(), - name: "Force Draw Cover Art".to_string(), - description: "Force rendering of cover art despite terminal support".to_string(), - value: SettingValue::Bool(self.user_config.behavior.draw_cover_art_forced), - }, - #[cfg(feature = "cover-art")] - SettingItem { - id: "behavior.playbar_cover_art_size_percent".to_string(), - name: "Cover Art Size".to_string(), - description: "Playbar cover art size as a percentage (25-200)".to_string(), - value: SettingValue::Number( - self.user_config.behavior.playbar_cover_art_size_percent as i64, - ), - }, - #[cfg(feature = "cover-art")] - SettingItem { - id: "behavior.cover_art_dither".to_string(), - name: "Dither Cover Art".to_string(), - description: "Dither all cover-art sizes in two theme-aware colors".to_string(), - value: SettingValue::Bool(self.user_config.behavior.cover_art_dither), - }, - #[cfg(feature = "cover-art")] - SettingItem { - id: "behavior.cover_art_dither_algorithm".to_string(), - name: "Cover Art Dither Algorithm".to_string(), - description: "Stucki, ordered Bayer, or Atkinson dithering".to_string(), - value: SettingValue::Cycle( - self.user_config.behavior.cover_art_dither_algorithm.clone(), - &["stucki", "bayer8x8", "atkinson"], - ), - }, - #[cfg(feature = "cover-art")] - SettingItem { - id: "behavior.cover_art_dither_pixel_scale".to_string(), - name: "Cover Art Dither Pixel Scale".to_string(), - description: "Visual pixel size (1-3)".to_string(), - value: SettingValue::Number(self.user_config.behavior.cover_art_dither_pixel_scale as i64), - }, - ], - SettingsCategory::Icons => vec![ - SettingItem { - id: "behavior.liked_icon".to_string(), - name: "Liked Icon".to_string(), - description: "Icon for liked songs".to_string(), - value: SettingValue::String(self.user_config.behavior.liked_icon.clone()), - }, - SettingItem { - id: "behavior.shuffle_icon".to_string(), - name: "Shuffle Icon".to_string(), - description: "Icon for shuffle mode".to_string(), - value: SettingValue::String(self.user_config.behavior.shuffle_icon.clone()), - }, - SettingItem { - id: "behavior.playing_icon".to_string(), - name: "Playing Icon".to_string(), - description: "Icon for playing state".to_string(), - value: SettingValue::String(self.user_config.behavior.playing_icon.clone()), - }, - SettingItem { - id: "behavior.paused_icon".to_string(), - name: "Paused Icon".to_string(), - description: "Icon for paused state".to_string(), - value: SettingValue::String(self.user_config.behavior.paused_icon.clone()), - }, - SettingItem { - id: "behavior.gauge_filled_icon".to_string(), - name: "Gauge Filled Icon".to_string(), - description: "Single-cell icon for filled gauge segments".to_string(), - value: SettingValue::String(self.user_config.behavior.gauge_filled_icon.clone()), - }, - SettingItem { - id: "behavior.gauge_unfilled_icon".to_string(), - name: "Gauge Empty Icon".to_string(), - description: "Single-cell icon for empty gauge segments".to_string(), - value: SettingValue::String(self.user_config.behavior.gauge_unfilled_icon.clone()), - }, - SettingItem { - id: "behavior.active_source_icon".to_string(), - name: "Active Source Icon".to_string(), - description: "Icon for the active playback source".to_string(), - value: SettingValue::String(self.user_config.behavior.active_source_icon.clone()), - }, - SettingItem { - id: "behavior.episode_played_icon".to_string(), - name: "Episode Played Icon".to_string(), - description: "Single-cell icon for fully played episodes".to_string(), - value: SettingValue::String(self.user_config.behavior.episode_played_icon.clone()), - }, - SettingItem { - id: "behavior.sort_ascending_icon".to_string(), - name: "Sort Ascending Icon".to_string(), - description: "Single-cell icon for ascending sort".to_string(), - value: SettingValue::String(self.user_config.behavior.sort_ascending_icon.clone()), - }, - SettingItem { - id: "behavior.sort_descending_icon".to_string(), - name: "Sort Descending Icon".to_string(), - description: "Single-cell icon for descending sort".to_string(), - value: SettingValue::String(self.user_config.behavior.sort_descending_icon.clone()), - }, - SettingItem { - id: "behavior.list_highlight_icon".to_string(), - name: "List Highlight Icon".to_string(), - description: "Icon shown next to highlighted list rows".to_string(), - value: SettingValue::String(self.user_config.behavior.list_highlight_icon.clone()), - }, - ], - SettingsCategory::Keybindings => vec![ - SettingItem { - id: "keys.back".to_string(), - name: "Back".to_string(), - description: "Go back / quit".to_string(), - value: SettingValue::Key(key_to_string(&self.user_config.keys.back)), - }, - SettingItem { - id: "keys.move_up".to_string(), - name: "Move Up".to_string(), - description: "Move selection up".to_string(), - value: SettingValue::Key(key_to_string(&self.user_config.keys.move_up)), - }, - SettingItem { - id: "keys.move_down".to_string(), - name: "Move Down".to_string(), - description: "Move selection down".to_string(), - value: SettingValue::Key(key_to_string(&self.user_config.keys.move_down)), - }, - SettingItem { - id: "keys.move_left".to_string(), - name: "Move Left".to_string(), - description: "Move selection left".to_string(), - value: SettingValue::Key(key_to_string(&self.user_config.keys.move_left)), - }, - SettingItem { - id: "keys.move_right".to_string(), - name: "Move Right".to_string(), - description: "Move selection right".to_string(), - value: SettingValue::Key(key_to_string(&self.user_config.keys.move_right)), - }, - SettingItem { - id: "keys.next_page".to_string(), - name: "Next Page".to_string(), - description: "Navigate to next page".to_string(), - value: SettingValue::Key(key_to_string(&self.user_config.keys.next_page)), - }, - SettingItem { - id: "keys.previous_page".to_string(), - name: "Previous Page".to_string(), - description: "Navigate to previous page".to_string(), - value: SettingValue::Key(key_to_string(&self.user_config.keys.previous_page)), - }, - SettingItem { - id: "keys.toggle_playback".to_string(), - name: "Toggle Playback".to_string(), - description: "Play/pause".to_string(), - value: SettingValue::Key(key_to_string(&self.user_config.keys.toggle_playback)), - }, - SettingItem { - id: "keys.seek_backwards".to_string(), - name: "Seek Backwards".to_string(), - description: "Seek backwards in track".to_string(), - value: SettingValue::Key(key_to_string(&self.user_config.keys.seek_backwards)), - }, - SettingItem { - id: "keys.seek_forwards".to_string(), - name: "Seek Forwards".to_string(), - description: "Seek forwards in track".to_string(), - value: SettingValue::Key(key_to_string(&self.user_config.keys.seek_forwards)), - }, - SettingItem { - id: "keys.next_track".to_string(), - name: "Next Track".to_string(), - description: "Skip to next track".to_string(), - value: SettingValue::Key(key_to_string(&self.user_config.keys.next_track)), - }, - SettingItem { - id: "keys.previous_track".to_string(), - name: "Previous Track".to_string(), - description: "Go to previous track".to_string(), - value: SettingValue::Key(key_to_string(&self.user_config.keys.previous_track)), - }, - SettingItem { - id: "keys.force_previous_track".to_string(), - name: "Force Previous Track".to_string(), - description: "Always skip to the previous track (ignoring playback position)".to_string(), - value: SettingValue::Key(key_to_string(&self.user_config.keys.force_previous_track)), - }, - SettingItem { - id: "keys.shuffle".to_string(), - name: "Shuffle".to_string(), - description: "Toggle shuffle mode".to_string(), - value: SettingValue::Key(key_to_string(&self.user_config.keys.shuffle)), - }, - SettingItem { - id: "keys.repeat".to_string(), - name: "Repeat".to_string(), - description: "Cycle repeat mode".to_string(), - value: SettingValue::Key(key_to_string(&self.user_config.keys.repeat)), - }, - SettingItem { - id: "keys.search".to_string(), - name: "Search".to_string(), - description: "Open search".to_string(), - value: SettingValue::Key(key_to_string(&self.user_config.keys.search)), - }, - SettingItem { - id: "keys.help".to_string(), - name: "Help".to_string(), - description: "Show help menu".to_string(), - value: SettingValue::Key(key_to_string(&self.user_config.keys.help)), - }, - SettingItem { - id: "keys.open_settings".to_string(), - name: "Open Settings".to_string(), - description: "Open settings menu".to_string(), - value: SettingValue::Key(key_to_string(&self.user_config.keys.open_settings)), - }, - SettingItem { - id: "keys.save_settings".to_string(), - name: "Save Settings".to_string(), - description: "Save settings to file".to_string(), - value: SettingValue::Key(key_to_string(&self.user_config.keys.save_settings)), - }, - SettingItem { - id: "keys.jump_to_album".to_string(), - name: "Jump to Album".to_string(), - description: "Jump to currently playing album".to_string(), - value: SettingValue::Key(key_to_string(&self.user_config.keys.jump_to_album)), - }, - SettingItem { - id: "keys.jump_to_artist_album".to_string(), - name: "Jump to Artist".to_string(), - description: "Jump to artist's albums".to_string(), - value: SettingValue::Key(key_to_string(&self.user_config.keys.jump_to_artist_album)), - }, - SettingItem { - id: "keys.jump_to_context".to_string(), - name: "Jump to Context".to_string(), - description: "Jump to current playback context".to_string(), - value: SettingValue::Key(key_to_string(&self.user_config.keys.jump_to_context)), - }, - SettingItem { - id: "keys.manage_devices".to_string(), - name: "Switch Music Source".to_string(), - description: "Open the source and device picker".to_string(), - value: SettingValue::Key(key_to_string(&self.user_config.keys.manage_devices)), - }, - SettingItem { - id: "keys.decrease_volume".to_string(), - name: "Decrease Volume".to_string(), - description: "Decrease playback volume".to_string(), - value: SettingValue::Key(key_to_string(&self.user_config.keys.decrease_volume)), - }, - SettingItem { - id: "keys.increase_volume".to_string(), - name: "Increase Volume".to_string(), - description: "Increase playback volume".to_string(), - value: SettingValue::Key(key_to_string(&self.user_config.keys.increase_volume)), - }, - SettingItem { - id: "keys.add_item_to_queue".to_string(), - name: "Add to Queue".to_string(), - description: "Add selected item to queue".to_string(), - value: SettingValue::Key(key_to_string(&self.user_config.keys.add_item_to_queue)), - }, - SettingItem { - id: "keys.show_queue".to_string(), - name: "Show Queue".to_string(), - description: "Show playback queue".to_string(), - value: SettingValue::Key(key_to_string(&self.user_config.keys.show_queue)), - }, - SettingItem { - id: "keys.remove_from_queue".to_string(), - name: "Remove from Queue".to_string(), - description: "Remove the selected track from the queue".to_string(), - value: SettingValue::Key(key_to_string(&self.user_config.keys.remove_from_queue)), - }, - SettingItem { - id: "keys.like_track".to_string(), - name: "Like Track".to_string(), - description: "Toggle saved state for the currently playing track or episode" - .to_string(), - value: SettingValue::Key(key_to_string(&self.user_config.keys.like_track)), - }, - SettingItem { - id: "keys.generate_recap".to_string(), - name: "Generate Listening Recap".to_string(), - description: - "Generate and open the listening recap HTML card (uses the selected period on the Stats screen, 30 days elsewhere)" - .to_string(), - value: SettingValue::Key(key_to_string(&self.user_config.keys.generate_recap)), - }, - SettingItem { - id: "keys.copy_song_url".to_string(), - name: "Copy Song URL".to_string(), - description: "Copy current song URL to clipboard".to_string(), - value: SettingValue::Key(key_to_string(&self.user_config.keys.copy_song_url)), - }, - SettingItem { - id: "keys.copy_album_url".to_string(), - name: "Copy Album URL".to_string(), - description: "Copy current album URL to clipboard".to_string(), - value: SettingValue::Key(key_to_string(&self.user_config.keys.copy_album_url)), - }, - SettingItem { - id: "keys.audio_analysis".to_string(), - name: "Audio Analysis".to_string(), - description: "Open audio analysis view".to_string(), - value: SettingValue::Key(key_to_string(&self.user_config.keys.audio_analysis)), - }, - SettingItem { - id: "keys.lyrics_view".to_string(), - name: "Lyrics View".to_string(), - description: "Open lyrics view".to_string(), - value: SettingValue::Key(key_to_string(&self.user_config.keys.lyrics_view)), - }, - SettingItem { - id: "keys.miniplayer_view".to_string(), - name: "Miniplayer View".to_string(), - description: "Toggle full-screen playbar view".to_string(), - value: SettingValue::Key(key_to_string(&self.user_config.keys.miniplayer_view)), - }, - #[cfg(feature = "cover-art")] - SettingItem { - id: "keys.cover_art_view".to_string(), - name: "Cover Art View".to_string(), - description: "Open full-screen cover art view".to_string(), - value: SettingValue::Key(key_to_string(&self.user_config.keys.cover_art_view)), - }, - ], - SettingsCategory::Theme => { - // The user's own colors, not the live theme: while an album-derived - // theme is applied the live theme is a blend, and rows built from it - // would be written back into custom_theme on save. - let user_theme = self.user_theme(); - vec![ - SettingItem { - id: "theme.preset".to_string(), - name: "Theme Preset".to_string(), - description: "Choose a preset theme or customize below".to_string(), - value: SettingValue::Preset(self.user_config.current_preset.name().to_string()), - }, - SettingItem { - id: "theme.active".to_string(), - name: "Active Color".to_string(), - description: "Color for active elements".to_string(), - value: SettingValue::Color(color_to_string(user_theme.active)), - }, - SettingItem { - id: "theme.banner".to_string(), - name: "Banner Color".to_string(), - description: "Color for banner text".to_string(), - value: SettingValue::Color(color_to_string(user_theme.banner)), - }, - SettingItem { - id: "behavior.banner_gradient".to_string(), - name: "Banner Gradient".to_string(), - description: "Animated RGB gradient on the home banner; off uses the Banner Color" - .to_string(), - value: SettingValue::Bool(self.user_config.behavior.banner_gradient), - }, - #[cfg(feature = "art-decode")] - SettingItem { - id: "behavior.cover_art_theme".to_string(), - name: "Adaptive Theme".to_string(), - description: "Recolor UI accents from the current cover art, fading on track change" - .to_string(), - value: SettingValue::Bool(self.user_config.behavior.cover_art_theme), - }, - SettingItem { - id: "theme.hint".to_string(), - name: "Hint Color".to_string(), - description: "Color for hints".to_string(), - value: SettingValue::Color(color_to_string(user_theme.hint)), - }, - SettingItem { - id: "theme.hovered".to_string(), - name: "Hovered Color".to_string(), - description: "Color for hovered elements".to_string(), - value: SettingValue::Color(color_to_string(user_theme.hovered)), - }, - SettingItem { - id: "theme.selected".to_string(), - name: "Selected Color".to_string(), - description: "Color for selected items".to_string(), - value: SettingValue::Color(color_to_string(user_theme.selected)), - }, - SettingItem { - id: "theme.inactive".to_string(), - name: "Inactive Color".to_string(), - description: "Color for inactive elements".to_string(), - value: SettingValue::Color(color_to_string(user_theme.inactive)), - }, - SettingItem { - id: "theme.text".to_string(), - name: "Text Color".to_string(), - description: "Default text color".to_string(), - value: SettingValue::Color(color_to_string(user_theme.text)), - }, - SettingItem { - id: "theme.error_text".to_string(), - name: "Error Text Color".to_string(), - description: "Color for error messages".to_string(), - value: SettingValue::Color(color_to_string(user_theme.error_text)), - }, - SettingItem { - id: "theme.error_border".to_string(), - name: "Error Border Color".to_string(), - description: "Border color for error messages".to_string(), - value: SettingValue::Color(color_to_string(user_theme.error_border)), - }, - SettingItem { - id: "theme.playbar_background".to_string(), - name: "Playbar Background".to_string(), - description: "Background color for playbar".to_string(), - value: SettingValue::Color(color_to_string(user_theme.playbar_background)), - }, - SettingItem { - id: "theme.playbar_progress".to_string(), - name: "Playbar Progress".to_string(), - description: "Color for playbar progress".to_string(), - value: SettingValue::Color(color_to_string(user_theme.playbar_progress)), - }, - #[cfg(feature = "cover-art")] - SettingItem { - id: "theme.cover_art_dither_color".to_string(), - name: "Cover Art Dither Color".to_string(), - description: "Accent color for dithered art; auto follows live playbar progress".to_string(), - value: SettingValue::Color( - self.user_config.cover_art_dither_color.map(color_to_string).unwrap_or_else(|| "auto".to_string()), - ), - }, - SettingItem { - id: "theme.playbar_progress_text".to_string(), - name: "Playbar Progress Text".to_string(), - description: "Color for playbar progress text".to_string(), - value: SettingValue::Color(color_to_string(user_theme.playbar_progress_text)), - }, - SettingItem { - id: "theme.playbar_text".to_string(), - name: "Playbar Text".to_string(), - description: "Color for playbar text".to_string(), - value: SettingValue::Color(color_to_string(user_theme.playbar_text)), - }, - SettingItem { - id: "theme.highlighted_lyrics".to_string(), - name: "Lyrics Highlight".to_string(), - description: "Color for current lyrics line".to_string(), - value: SettingValue::Color(color_to_string(user_theme.highlighted_lyrics)), - }, - SettingItem { - id: "theme.background".to_string(), - name: "Background".to_string(), - description: "Color for the background".to_string(), - value: SettingValue::Color(color_to_string(user_theme.background)), - }, - SettingItem { - id: "theme.header".to_string(), - name: "Header".to_string(), - description: "Color for the header".to_string(), - value: SettingValue::Color(color_to_string(user_theme.header)), - }, - ] - } - }; - self.view.settings_selected_index = self - .settings_items - .iter() - .position(|item| self.setting_offered(item)) - .unwrap_or(0); - self.settings_saved_items = self.settings_items.clone(); - self.view.settings_unsaved_prompt_visible = false; - self.view.settings_unsaved_prompt_save_selected = true; - } - - /// Whether a row is drawn now: key rows always, other rows only when their requirement is met. - pub(crate) fn setting_offered(&self, item: &SettingItem) -> bool { - matches!(item.value, SettingValue::Key(_)) - || self - .availability(setting_requirement(&item.id)) - .is_available() - } - - /// The startup routes the cycle offers; a stored Spotify route stays in it so the cycle cannot strand it. - fn startup_route_options(&self) -> &'static [&'static str] { - let current = self.user_config.behavior.startup_route.as_str(); - if self.spotify_connected || !STARTUP_ROUTE_SESSION_FREE_OPTIONS.contains(¤t) { - STARTUP_ROUTE_SETTING_OPTIONS - } else { - STARTUP_ROUTE_SESSION_FREE_OPTIONS - } - } - - /// Enter the Settings screen on a fresh, unfiltered view of the current - /// category. Every front door (keybinding, sidebar click, Lua `navigate`) - /// goes through here so none of them can carry a stale filter in. - pub fn open_settings_screen(&mut self) { - self.clear_settings_filter(); - self.load_settings_for_category(); - self.push_navigation_stack(RouteId::Settings, ActiveBlock::Settings); - } - - /// Whether the Settings screen holds staged edits that were not saved. - pub fn has_unsaved_settings_changes(&self) -> bool { - self.settings_items != self.settings_saved_items - } - - /// Open the settings row filter on a fresh, empty query. - pub fn begin_settings_filter(&mut self) { - self.view.settings_filter.clear(); - self.view.settings_filter_editing = true; - } - - /// Stop typing into the settings row filter, leaving it applied. - pub fn apply_settings_filter(&mut self) { - self.view.settings_filter_editing = false; - } - - /// Drop the settings row filter entirely. - pub fn clear_settings_filter(&mut self) { - self.view.settings_filter.clear(); - self.view.settings_filter_editing = false; - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn startup_screen_setting_cycle_offers_every_startup_route() { - // The Settings cycle list and RouteId::STARTUP_OPTIONS are coupled only - // by convention; a route added to one but not the other silently becomes - // unselectable in-app (#443, which is how Stats went missing). - let expected: Vec<&str> = RouteId::STARTUP_OPTIONS - .iter() - .map(|route| route.to_config_str()) - .collect(); - assert_eq!(STARTUP_ROUTE_SETTING_OPTIONS, expected.as_slice()); - } - - #[test] - fn session_free_startup_routes_are_the_routes_with_no_startup_requirement() { - let expected: Vec<&str> = RouteId::STARTUP_OPTIONS - .iter() - .filter(|route| route.startup_requirement() == Requirement::None) - .map(|route| route.to_config_str()) - .collect(); - assert_eq!(STARTUP_ROUTE_SESSION_FREE_OPTIONS, expected.as_slice()); - } - - #[test] - fn the_startup_cycle_narrows_without_a_session_unless_the_stored_route_needs_one() { - let mut app = App::default(); - assert_eq!( - app.startup_route_options(), - STARTUP_ROUTE_SESSION_FREE_OPTIONS - ); - app.user_config.behavior.startup_route = "discover".to_string(); - assert_eq!(app.startup_route_options(), STARTUP_ROUTE_SETTING_OPTIONS); - app.spotify_connected = true; - app.user_config.behavior.startup_route = "home".to_string(); - assert_eq!(app.startup_route_options(), STARTUP_ROUTE_SETTING_OPTIONS); - } - - fn offered(app: &App, id: &str) -> bool { - let item = app - .settings_items - .iter() - .find(|item| item.id == id) - .unwrap_or_else(|| panic!("no row {id}")); - app.setting_offered(item) - } - - #[test] - fn without_a_session_the_spotify_rows_are_hidden_and_the_key_rows_stay() { - let mut app = App::default(); - app.load_settings_for_category(); - assert!(!offered(&app, "behavior.default_sort_saved_albums")); - assert!(offered(&app, "behavior.default_sort_playlist_tracks")); - assert!(app.setting_offered(&app.settings_items[app.view.settings_selected_index])); - - app.view.settings_category = SettingsCategory::Keybindings; - app.load_settings_for_category(); - assert!(offered(&app, "keys.like_track")); - - // The rows follow the session live, without a reload. - app.view.settings_category = SettingsCategory::Behavior; - app.load_settings_for_category(); - app.spotify_connected = true; - assert!(offered(&app, "behavior.default_sort_saved_albums")); - } - - #[test] - fn the_like_key_row_is_offered_under_radio() { - let mut app = App::default_connected(); - app.view.settings_category = SettingsCategory::Keybindings; - app.load_settings_for_category(); - app.active_source = Source::Radio; - assert!(offered(&app, "keys.like_track")); - assert!(app - .availability(setting_requirement("keys.like_track")) - .is_available()); - app.active_source = Source::Local; - assert!(!app - .availability(setting_requirement("keys.like_track")) - .is_available()); - } -} diff --git a/src/core/app/shuffle_repeat.rs b/src/core/app/shuffle_repeat.rs deleted file mode 100644 index 4fc5b1e4..00000000 --- a/src/core/app/shuffle_repeat.rs +++ /dev/null @@ -1,288 +0,0 @@ -use super::*; - -impl App { - /// Apply the current [`decoded_shuffle`](Self::decoded_shuffle) flag to whatever - /// queueable decoded source owns playback: reorder its queue when turning on, - /// or restore the original order when turning off. The currently-playing track - /// is unaffected (it stays at the front), so audio continues uninterrupted. - /// Call only when [`active_queueable_decoded_source`](Self::active_queueable_decoded_source) - /// holds, so exactly one branch applies. - #[cfg_attr(not(feature = "audio-decode-queue"), allow(unused_variables))] - fn apply_decoded_shuffle(&mut self) { - let on = self.decoded_shuffle; - // While a track change is in flight (`advancing`), an async `play_index` - // has captured a numeric target it will commit later; reordering the queue - // now would make that target identify a different track. Defer the reorder — - // [`reconcile_decoded_shuffle`](Self::reconcile_decoded_shuffle), driven by - // the runner tick, applies it once the advance commits. - #[cfg(feature = "local-files")] - #[allow(clippy::needless_return)] - if let Some(s) = self.local_playback.as_mut() { - if !s.advancing { - s.set_shuffle(on); - } - return; - } - #[cfg(feature = "subsonic")] - #[allow(clippy::needless_return)] - if let Some(s) = self.subsonic_playback.as_mut() { - if !s.advancing { - s.set_shuffle(on); - } - return; - } - #[cfg(feature = "youtube")] - #[allow(clippy::needless_return)] - if let Some(s) = self.youtube_playback.as_mut() { - if !s.advancing { - s.set_shuffle(on); - } - } - #[cfg(feature = "qobuz")] - if let Some(s) = self.qobuz_playback.as_mut() { - if !s.advancing { - s.set_shuffle(on); - } - } - } - - /// Re-sync the active queueable source's queue order to `decoded_shuffle` when - /// a shuffle toggle was deferred by [`apply_decoded_shuffle`](Self::apply_decoded_shuffle) - /// because a track change was in flight. Driven by the runner tick, so it lands - /// as soon as the advance commits (`advancing` clears). A no-op whenever the - /// order already matches `decoded_shuffle`, so it is cheap to call every tick. - #[cfg(feature = "audio-decode-queue")] - pub(crate) fn reconcile_decoded_shuffle(&mut self) { - // The native queue owns the sink: any per-source struct is a suspended - // context whose order is reconciled when it resumes, not now. - if self.queue_owns_playback() { - return; - } - let on = self.decoded_shuffle; - #[cfg(feature = "local-files")] - #[allow(clippy::needless_return)] - if let Some(s) = self.local_playback.as_mut() { - if !s.advancing && s.shuffle_backup.is_some() != on { - s.set_shuffle(on); - } - return; - } - #[cfg(feature = "subsonic")] - #[allow(clippy::needless_return)] - if let Some(s) = self.subsonic_playback.as_mut() { - if !s.advancing && s.shuffle_backup.is_some() != on { - s.set_shuffle(on); - } - return; - } - #[cfg(feature = "youtube")] - #[allow(clippy::needless_return)] - if let Some(s) = self.youtube_playback.as_mut() { - if !s.advancing && s.shuffle_backup.is_some() != on { - s.set_shuffle(on); - } - } - #[cfg(feature = "qobuz")] - if let Some(s) = self.qobuz_playback.as_mut() { - if !s.advancing && s.shuffle_backup.is_some() != on { - s.set_shuffle(on); - } - } - } - - /// Store a Spotify `RepeatState` as the decoded repeat mode. Used by the - /// source routers to honor an explicit `IoEvent::Repeat` (e.g. a plugin's - /// `set_repeat`) while a queueable decoded source owns playback, so it updates - /// `decoded_repeat` instead of falling through to the Spotify context. The - /// keyboard / MPRIS / `cycle_repeat` paths already update it directly. - /// - /// Returns whether it consumed the event: `false` (leaving it to the Spotify - /// handler) when the native queue owns playback or no queueable decoded source - /// is active — the same ownership gate as the MPRIS mode setters, so a - /// suspended context under the queue is never touched. - #[cfg(feature = "audio-decode-queue")] - pub(crate) fn set_decoded_repeat_from_state( - &mut self, - state: rspotify::model::enums::RepeatState, - ) -> bool { - // `RepeatState` is only imported under `streaming`, but this method compiles - // for decoded-only builds too, so refer to it via a local alias. - use rspotify::model::enums::RepeatState as Rs; - if !self.active_queueable_decoded_source() { - return false; - } - self.decoded_repeat = match state { - Rs::Off => RepeatMode::Off, - Rs::Context => RepeatMode::Context, - Rs::Track => RepeatMode::Track, - }; - true - } - - /// Set the decoded shuffle to an explicit value from an external media - /// controller (MPRIS), reordering the active source's queue. Returns whether a - /// queueable decoded source consumed it. A `false` return does **not** mean - /// "hand this to Spotify": the caller rejects the request and corrects the - /// client's property instead, because reaching this method at all means a - /// decoded source (radio, or the queue slot) owns playback and the Spotify - /// context is not what the user is listening to. - #[cfg(all(feature = "mpris", target_os = "linux", feature = "audio-decode",))] - pub fn set_decoded_shuffle(&mut self, on: bool) -> bool { - if !self.active_queueable_decoded_source() { - return false; - } - if self.decoded_shuffle != on { - self.decoded_shuffle = on; - self.apply_decoded_shuffle(); - } - true - } - - /// Set the decoded repeat mode to an explicit value from an external media - /// controller (MPRIS). Returns whether a queueable decoded source consumed it - /// (see [`set_decoded_shuffle`](Self::set_decoded_shuffle)). - #[cfg(all(feature = "mpris", target_os = "linux", feature = "audio-decode"))] - pub fn set_decoded_repeat(&mut self, mode: RepeatMode) -> bool { - if !self.active_queueable_decoded_source() { - return false; - } - self.decoded_repeat = mode; - true - } - - pub fn shuffle(&mut self) { - // Decoded (non-Spotify) sources: toggle the player-global decoded shuffle and - // reorder the owning source's queue in place (the current track stays at the - // front, so playback continues uninterrupted). - if self.active_queueable_decoded_source() { - self.decoded_shuffle = !self.decoded_shuffle; - self.apply_decoded_shuffle(); - let label = if self.decoded_shuffle { - "Shuffle: On" - } else { - "Shuffle: Off" - }; - self.set_status_message(label, 2); - return; - } - - // A decoded source owns playback but has no queue to shuffle: internet radio - // is an endless stream, and the native queue slot plays an explicit list over - // a suspended context. Both hide the shuffle button and blank shuffle in the - // MPRIS snapshot, so the key must no-op to match. Falling through would flip - // shuffle on the user's real Spotify device for a source they are not - // listening to, invisibly: the playbar on screen is the decoded one, so - // nothing would reflect the change. - if self.active_decoded_source() || self.queue_owns_playback() { - self.set_status_message("Shuffle does not apply to this source", 2); - return; - } - - if let Some(shuffle_state) = self - .current_playback_context - .as_ref() - .map(|context| context.shuffle_state) - { - let new_shuffle_state = !shuffle_state; - info!("toggling shuffle: {}", new_shuffle_state); - - // Native streaming: the network handler reorders the client-side shuffle - // session and reloads it (building one mid-playback when it can); it - // falls back to Spirc shuffle for contexts the session doesn't cover. - #[cfg(feature = "streaming")] - if self.is_native_streaming_active_for_playback() && self.streaming_player.is_some() { - // Remember the desired state for a seamless-recovery replay, then let - // the network handler reorder/reload the client-side session. - self.set_native_recovery_shuffle(new_shuffle_state); - self.dispatch(IoEvent::ToggleNativeShuffleSession(new_shuffle_state)); - - // Update UI state immediately - if let Some(ctx) = &mut self.current_playback_context { - ctx.shuffle_state = new_shuffle_state; - } - self.runtime_state.shuffle_enabled = new_shuffle_state; - self.schedule_state_save(PersistedRuntimeState::shuffle_enabled(new_shuffle_state)); - - // Notify MPRIS clients of the change - #[cfg(all(feature = "mpris", target_os = "linux"))] - if let Some(ref mpris) = self.mpris_manager { - mpris.set_shuffle(new_shuffle_state); - } - return; - } - - // Fallback to API-based shuffle for external devices - self.dispatch_spotify_fallback(IoEvent::Shuffle(new_shuffle_state)); - }; - } - - pub fn repeat(&mut self) { - // Decoded (non-Spotify) sources have no `current_playback_context`; cycle the - // player-global decoded repeat instead, mirroring Spotify's - // Off -> Repeat All -> Repeat One -> Off. - if self.active_queueable_decoded_source() { - self.decoded_repeat = self.decoded_repeat.next(); - let label = match self.decoded_repeat { - RepeatMode::Off => "Repeat: Off", - RepeatMode::Context => "Repeat: All", - RepeatMode::Track => "Repeat: One", - }; - self.set_status_message(label, 2); - return; - } - - // See `shuffle`: radio and the queue slot have no repeat of their own, and - // falling through would cycle repeat on the user's real Spotify device with - // nothing on screen to show for it. - if self.active_decoded_source() || self.queue_owns_playback() { - self.set_status_message("Repeat does not apply to this source", 2); - return; - } - - if let Some(current_repeat_state) = self - .current_playback_context - .as_ref() - .map(|context| context.repeat_state) - { - info!("toggling repeat mode: {:?}", current_repeat_state); - - // Use native streaming player for instant control (bypasses event channel latency) - #[cfg(feature = "streaming")] - if self.is_native_streaming_active_for_playback() { - if let Some(player) = self.streaming_player.clone() { - // Try to set repeat on the native player (pass current state, not next) - let _ = player.set_repeat(current_repeat_state); - - // Calculate next state for UI update - let next_repeat_state = match current_repeat_state { - RepeatState::Off => RepeatState::Context, - RepeatState::Context => RepeatState::Track, - RepeatState::Track => RepeatState::Off, - }; - self.set_native_recovery_repeat(next_repeat_state); - - // Update UI state immediately - if let Some(ctx) = &mut self.current_playback_context { - ctx.repeat_state = next_repeat_state; - } - - // Notify MPRIS clients of the change - #[cfg(all(feature = "mpris", target_os = "linux"))] - if let Some(ref mpris) = self.mpris_manager { - use crate::infra::mpris::LoopStatusEvent; - let loop_status = match next_repeat_state { - RepeatState::Off => LoopStatusEvent::None, - RepeatState::Context => LoopStatusEvent::Playlist, - RepeatState::Track => LoopStatusEvent::Track, - }; - mpris.set_loop_status(loop_status); - } - return; - } - } - - // Fallback to API-based repeat for external devices - self.dispatch_spotify_fallback(IoEvent::Repeat(current_repeat_state)); - } - } -} diff --git a/src/core/app/status.rs b/src/core/app/status.rs deleted file mode 100644 index bce5bd46..00000000 --- a/src/core/app/status.rs +++ /dev/null @@ -1,309 +0,0 @@ -use super::*; - -#[derive(Clone, Copy, PartialEq, Debug)] -pub enum AnnouncementLevel { - Info, - Warning, - Critical, -} - -#[derive(Clone, PartialEq, Debug)] -pub struct Announcement { - pub id: String, - pub title: String, - pub body: String, - pub level: AnnouncementLevel, - pub url: Option<String>, - pub received_at: Instant, -} - -/// How long a recorded error stays current before the tick retires it. Long -/// enough not to snatch the page away from someone reading it; short enough -/// that a frontend with no dismissal gesture does not latch the first failure -/// for the life of the process. -const API_ERROR_TTL: Duration = Duration::from_secs(60); - -/// How long the retired message lingers in the status bar afterwards, before -/// `status_message_ttl_percent` scaling. -const API_ERROR_HANDOFF_TTL_SECS: u64 = 8; - -impl App { - #[allow(dead_code)] - pub fn enqueue_announcements(&mut self, announcements: Vec<Announcement>) { - if announcements.is_empty() { - return; - } - - let mut existing_ids: HashSet<String> = self - .pending_announcements - .iter() - .map(|announcement| announcement.id.clone()) - .collect(); - - if let Some(active) = &self.active_announcement { - existing_ids.insert(active.id.clone()); - } - - let mut incoming = announcements - .into_iter() - .filter(|announcement| existing_ids.insert(announcement.id.clone())) - .collect::<Vec<Announcement>>(); - - if self.active_announcement.is_none() { - if let Some(first) = incoming.first().cloned() { - self.active_announcement = Some(first); - incoming.remove(0); - } - } - - self.pending_announcements.extend(incoming); - } - - pub fn dismiss_active_announcement(&mut self) -> Option<String> { - let dismissed_id = self - .active_announcement - .take() - .map(|announcement| announcement.id); - - if let Some(next_announcement) = self.pending_announcements.first().cloned() { - self.active_announcement = Some(next_announcement); - self.pending_announcements.remove(0); - } - - dismissed_id - } - - /// The status-bar message that is live now, if any. - pub fn status_message(&self) -> Option<&str> { - self.status_message.as_deref() - } - - /// Whether the live status message is an error. - pub fn status_message_is_error(&self) -> bool { - self.status_message_is_error - } - - /// The live error text, or empty when none is live. The CLI's exit gate. - pub fn api_error(&self) -> &str { - &self.api_error - } - - /// Drop the live status message and its error priority. - pub(crate) fn clear_status_message(&mut self) { - self.status_message = None; - self.status_message_expires_at = None; - self.status_message_is_error = false; - } - - pub fn set_status_message(&mut self, message: impl Into<String>, ttl_secs: u64) { - // A live error message blocks normal messages from overwriting it. - if self.status_message_is_error { - if let (Some(_), Some(expires_at)) = (&self.status_message, self.status_message_expires_at) { - if Instant::now() < expires_at { - return; - } - } - } - self.status_message = Some(message.into()); - let ttl = self.scaled_status_ttl(ttl_secs); - self.status_message_expires_at = Some(Instant::now() + Duration::from_secs(ttl)); - self.status_message_is_error = false; - } - - /// Set an error status message. Errors always replace whatever is currently shown - /// (including a previous error) and are styled distinctly in the UI. - #[cfg_attr(not(feature = "scripting"), allow(dead_code))] - pub fn set_error_status_message(&mut self, message: impl Into<String>, ttl_secs: u64) { - self.status_message = Some(message.into()); - let ttl = self.scaled_status_ttl(ttl_secs); - self.status_message_expires_at = Some(Instant::now() + Duration::from_secs(ttl)); - self.status_message_is_error = true; - } - - /// Scale a status-message TTL by `status_message_ttl_percent` (default 100 - /// == 1.0×). Applied here at the single sink so the ~66 call sites keep - /// their relative per-severity TTLs. - fn scaled_status_ttl(&self, ttl_secs: u64) -> u64 { - let pct = self.user_config.behavior.status_message_ttl_percent as u64; - // round to nearest, never zero. - ((ttl_secs * pct + 50) / 100).max(1) - } - - /// Record an error and raise the `RouteId::Error` frame the terminal - /// frontend draws full-screen. That frame is a presentation hint, not the - /// state: another frontend may render [`Self::api_error`] however it likes. - /// Every frontend dismisses through [`Self::clear_api_error`], and one that - /// never dismisses gets the [`API_ERROR_TTL`] backstop on the tick. - pub fn handle_error(&mut self, e: anyhow::Error) { - info!("error occurred: {}", e); - self.push_navigation_stack(RouteId::Error, ActiveBlock::Error); - self.api_error = e.to_string(); - self.api_error_expires_at = Some(Instant::now() + API_ERROR_TTL); - } - - /// Dismiss the live error: the message, its lifetime, and every navigation - /// frame still showing it. The single dismissal primitive, so a frontend - /// with no route stack has one call to make. Dropping the frames is what - /// stops a cleared message from leaving an error page with nothing on it. - pub fn clear_api_error(&mut self) { - self.api_error.clear(); - self.api_error_expires_at = None; - self.drop_error_routes(); - } - - /// Retire an error whose lifetime has passed. When the error frame is the - /// screen the user is on, the text moves to the status bar on the way out so - /// the page does not vanish leaving nothing behind. A frame that expired - /// buried under something else goes quietly: a toast about a minute-old - /// failure the user already navigated away from is noise. - pub(super) fn expire_api_error(&mut self) { - let Some(expires_at) = self.api_error_expires_at else { - return; - }; - if Instant::now() < expires_at { - return; - } - let showing = self.get_current_route().active_block == ActiveBlock::Error; - let message = std::mem::take(&mut self.api_error); - self.clear_api_error(); - if showing && !message.is_empty() { - self.set_error_status_message(message, API_ERROR_HANDOFF_TTL_SECS); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::app::test_support::*; - - // --- status message priority tests --- - - #[test] - fn normal_message_does_not_overwrite_live_error() { - let mut app = make_app_simple(); - app.set_error_status_message("plugin error", 6); - assert!(app.status_message_is_error); - - app.set_status_message("now playing", 4); - - assert_eq!(app.status_message.as_deref(), Some("plugin error")); - assert!(app.status_message_is_error); - } - - #[test] - fn error_overwrites_normal_message() { - let mut app = make_app_simple(); - app.set_status_message("now playing", 4); - assert!(!app.status_message_is_error); - - app.set_error_status_message("plugin error", 6); - - assert_eq!(app.status_message.as_deref(), Some("plugin error")); - assert!(app.status_message_is_error); - } - - #[test] - fn error_overwrites_previous_error() { - let mut app = make_app_simple(); - app.set_error_status_message("first error", 6); - app.set_error_status_message("second error", 6); - - assert_eq!(app.status_message.as_deref(), Some("second error")); - assert!(app.status_message_is_error); - } - - // --- error lifecycle tests --- - - #[test] - fn raising_an_error_stamps_a_lifetime_on_it() { - let mut app = make_app_simple(); - - app.handle_error(anyhow!("boom")); - - assert_eq!(app.api_error, "boom"); - assert!(app.api_error_expires_at.is_some()); - assert_eq!(app.get_current_route().id, RouteId::Error); - assert_eq!(app.get_current_route().active_block, ActiveBlock::Error); - } - - #[test] - fn dismissing_the_error_route_clears_the_message() { - let mut app = make_app_simple(); - app.handle_error(anyhow!("boom")); - - app.pop_navigation_stack(); - - assert!(app.api_error.is_empty()); - assert!(app.api_error_expires_at.is_none()); - assert_ne!(app.get_current_route().id, RouteId::Error); - } - - // The error page's own hint tells the user to press `d`. Coming back from - // the device picker must still show the message, which is why the clear is - // keyed on the frame that was popped rather than on the new top. - #[test] - fn popping_a_screen_opened_from_the_error_route_keeps_the_message() { - let mut app = make_app_simple(); - app.handle_error(anyhow!("boom")); - app.push_navigation_stack(RouteId::SelectedDevice, ActiveBlock::SelectDevice); - - app.pop_navigation_stack(); - - assert_eq!(app.api_error, "boom"); - assert_eq!(app.get_current_route().id, RouteId::Error); - } - - #[test] - fn dismissing_an_error_drops_a_duplicate_error_frame_left_lower_in_the_stack() { - let mut app = make_app_simple(); - app.handle_error(anyhow!("first")); - app.push_navigation_stack(RouteId::SelectedDevice, ActiveBlock::SelectDevice); - // Pushes a SECOND error frame: pushes dedupe only against the top frame. - app.handle_error(anyhow!("second")); - - app.pop_navigation_stack(); - - assert!(app.api_error.is_empty()); - assert_eq!(app.get_current_route().id, RouteId::SelectedDevice); - } - - #[test] - fn dismissing_an_error_never_empties_the_navigation_stack() { - let mut app = make_app_simple(); - app.handle_error(anyhow!("boom")); - - app.pop_navigation_stack(); - - assert_eq!(app.get_current_route().id, RouteId::Home); - } - - // A producer that rewrites the error frame in place leaves it holding - // text-input focus; deleting it would drop keystrokes into the global - // bindings. No such producer exists today; this pins the invariant. - #[test] - fn clearing_an_error_leaves_a_frame_repurposed_as_a_search_input_alone() { - let mut app = make_app_simple(); - app.handle_error(anyhow!("boom")); - app.set_current_route_state(Some(ActiveBlock::Input), Some(ActiveBlock::Input)); - - app.clear_api_error(); - - assert!(app.api_error.is_empty()); - assert_eq!(app.get_current_route().active_block, ActiveBlock::Input); - } - - #[test] - fn normal_message_accepted_after_error_expires() { - let mut app = make_app_simple(); - app.set_error_status_message("plugin error", 6); - - // Simulate expiry by backdating the timestamp. - app.status_message_expires_at = Some(Instant::now() - Duration::from_secs(1)); - - app.set_status_message("now playing", 4); - - assert_eq!(app.status_message.as_deref(), Some("now playing")); - assert!(!app.status_message_is_error); - } -} diff --git a/src/core/app/test_support.rs b/src/core/app/test_support.rs deleted file mode 100644 index 52c12d6c..00000000 --- a/src/core/app/test_support.rs +++ /dev/null @@ -1,124 +0,0 @@ -use super::*; - -#[allow(deprecated)] -pub(super) fn full_track(id: &str, name: &str) -> FullTrack { - FullTrack { - album: SimplifiedAlbum { - name: format!("{name} Album"), - ..Default::default() - }, - artists: vec![SimplifiedArtist { - name: "Artist".to_string(), - ..Default::default() - }], - available_markets: Vec::new(), - disc_number: 1, - duration: ChronoDuration::milliseconds(180_000), - explicit: false, - external_ids: HashMap::new(), - external_urls: HashMap::new(), - href: None, - id: Some(TrackId::from_id(id).unwrap().into_static()), - is_local: false, - is_playable: Some(true), - linked_from: None, - restrictions: None, - name: name.to_string(), - popularity: 50, - preview_url: None, - track_number: 1, - r#type: rspotify::model::Type::Track, - } -} - -pub(super) fn queue_track(uri: Option<&str>, name: &str) -> TrackInfo { - TrackInfo { - uri: uri.map(|u| u.to_string()), - name: name.to_string(), - artists: vec!["Artist".to_string()], - album: "Album".to_string(), - duration_ms: 1000, - id: None, - album_id: None, - artist_refs: vec![], - is_playable: true, - is_local: false, - track_number: 0, - explicit: false, - image_url: None, - } -} - -#[allow(deprecated)] -pub(super) fn make_external_context() -> CurrentPlaybackContext { - use rspotify::model::{context::Actions, CurrentlyPlayingType, Device, DeviceType, RepeatState}; - CurrentPlaybackContext { - device: Device { - id: Some("external".to_string()), - is_active: true, - is_private_session: false, - is_restricted: false, - name: "Phone".to_string(), - _type: DeviceType::Smartphone, - volume_percent: Some(50), - }, - repeat_state: RepeatState::Off, - shuffle_state: false, - context: None, - timestamp: Utc::now(), - progress: None, - is_playing: true, - item: None, - currently_playing_type: CurrentlyPlayingType::Track, - actions: Actions::default(), - } -} - -pub(super) fn saved_track(id: &str, name: &str) -> SavedTrack { - SavedTrack { - added_at: Utc::now(), - track: full_track(id, name), - } -} - -pub(super) fn saved_tracks_page( - offset: u32, - total: u32, - ids: &[&str], - has_next: bool, -) -> Page<SavedTrack> { - Page { - href: "https://example.com/me/tracks".to_string(), - items: ids - .iter() - .enumerate() - .map(|(index, id)| saved_track(id, &format!("Track {offset}-{index}"))) - .collect(), - limit: ids.len() as u32, - next: has_next.then(|| "https://example.com/me/tracks?next".to_string()), - offset, - previous: None, - total, - } -} - -pub(super) fn make_app_simple() -> App { - let (tx, _rx) = channel(); - App::new(tx, UserConfig::new(), Some(SystemTime::now())) -} - -/// An `App` with no Spotify session; the receiver keeps `dispatch` observable. -pub(super) fn session_free_app() -> (App, std::sync::mpsc::Receiver<IoEvent>) { - let (tx, rx) = channel(); - (App::new(tx, UserConfig::new(), None), rx) -} - -/// A playback context whose item is `track`: the suspended Spotify track the -/// queue-slot tests seed under the slot. -#[cfg(feature = "streaming")] -pub(super) fn playing_track_context(track: FullTrack) -> CurrentPlaybackContext { - CurrentPlaybackContext { - item: Some(PlayableItem::Track(track)), - ..make_external_context() - } -} diff --git a/src/core/app/tick.rs b/src/core/app/tick.rs deleted file mode 100644 index b34f732a..00000000 --- a/src/core/app/tick.rs +++ /dev/null @@ -1,438 +0,0 @@ -use super::*; - -/// Grace window after the last tick before playback-position reads go stale. -const STALE_TICK_AFTER: Duration = Duration::from_secs(2); - -/// How long the timed token refresh waits after a failed attempt. A token -/// inside the 60 s refresh margin gets a few tries, then a steady retry. -const SPOTIFY_REFRESH_RETRY_BACKOFF: Duration = Duration::from_secs(15); - -// The window must cover at least two of the slowest allowed ticks, so one -// missed frame can never read as stale. The tick rate is bounded on every -// path (config load rejects out-of-range values, the settings screen clamps), -// and this assertion keeps the two constants coupled if either ever moves. -const _: () = assert!( - STALE_TICK_AFTER.as_millis() >= 2 * crate::core::user_config::MAX_TICK_RATE_MILLISECONDS as u128 -); - -/// Whether the machine must stay awake. The audible player answers first: a -/// decoded source that owns the sink overrides the suspended librespot flag and -/// the stale Spotify context it left behind. -fn playing_for_keepawake( - decoded: Option<bool>, - native: Option<bool>, - spotify: Option<bool>, -) -> bool { - decoded.or(native).or(spotify).unwrap_or(false) -} - -impl App { - /// A token refresh failed: hold the timed refresh back for the backoff. - pub(crate) fn note_spotify_refresh_failed(&mut self) { - self.spotify_refresh_retry_at = Some(Instant::now() + SPOTIFY_REFRESH_RETRY_BACKOFF); - } - - /// A token refresh succeeded: the timer runs on the expiry again. - pub(crate) fn note_spotify_refresh_succeeded(&mut self) { - self.spotify_refresh_retry_at = None; - } - - /// Earliest instant the timed refresh may run again, if one failed. - pub(crate) fn spotify_refresh_retry_at(&self) -> Option<Instant> { - self.spotify_refresh_retry_at - } - - /// Milliseconds into the current track, or `None` when no tick has run for - /// over [`STALE_TICK_AFTER`]. A frontend that stops driving - /// `core::driver::Driver::tick` (or never starts) would otherwise show a - /// silently frozen playbar; `None` is the loud version of that failure, - /// rendered as an explicit stall by the playbar. - pub fn playback_position_ms(&self) -> Option<u128> { - (self.last_tick_at.elapsed() <= STALE_TICK_AFTER).then_some(self.song_progress_ms) - } - - fn poll_current_playback(&mut self) { - // No Spotify session (free-source launch): the poll would hit the auth gate - // and re-flash a "connect Spotify" status message every interval. Free - // sources drive their own playback state, so skip the Spotify poll entirely. - // A decoded source that owns the sink has paused Spotify: the poll would - // only spend rate limit and pump time on a context nothing renders. - if !self.spotify_connected || self.active_decoded_source() { - return; - } - - // Poll interval depends on playback mode: - // - Native streaming: configurable (default 5s; real-time events provide - // updates between polls). - // - External players (spotifyd, etc.): 1 second (no events, need faster - // polling for smooth playbar) — stays hardcoded, not a preference. - let poll_interval_ms: u128 = if self.is_streaming_active { - self.user_config.behavior.playback_poll_seconds as u128 * 1000 - } else { - 1_000 - }; - - let elapsed = self - .instant_since_last_current_playback_poll - .elapsed() - .as_millis(); - - if !self.is_fetching_current_playback && elapsed >= poll_interval_ms { - self.is_fetching_current_playback = true; - // Trigger the seek if the user has set a new position - match self.seek_ms { - Some(seek_ms) => self.apply_seek(seek_ms as u32), - None => self.dispatch(IoEvent::GetCurrentPlayback), - } - } - } - - pub fn update_on_tick(&mut self, elapsed: Duration) { - self.last_tick_at = Instant::now(); - - // Increment global animation tick (wraps after ~9.4 quintillion ticks, effectively never) - self.view.animation_tick = self.view.animation_tick.wrapping_add(1); - - // Advance an adaptive-theme fade. Real elapsed time, not tick count, so - // the fade speed is independent of the configured tick rates. - #[cfg(feature = "art-decode")] - if let Some(transition) = self.theme_transition.as_mut() { - transition.advance(elapsed); - self.user_config.theme = transition.current(); - if transition.is_complete() { - self.theme_transition = None; - // A finished fade-out means the user's own theme is back in place. - if let crate::core::cover_theme::CoverThemeState::Restoring { .. } = self.cover_theme_state - { - self.cover_theme_state = crate::core::cover_theme::CoverThemeState::Inactive; - } - } - } - - // Periodic party sync: host broadcasts state about every 2 seconds. - // Keep this before early-return paths so sync still happens during native-streaming fast paths. - if self.party_status == PartyStatus::Hosting - && self.last_party_sync_at.elapsed() >= Duration::from_secs(2) - { - self.last_party_sync_at = Instant::now(); - self.dispatch(IoEvent::SyncPlayback); - } - - // Periodic friends refresh: re-fetch when the Friends screen is active, every 30 seconds. - if self.get_current_route().id == RouteId::Friends - && self.last_friends_refresh_at.elapsed() >= Duration::from_secs(30) - && !self.friends_loading - && self.user_config.behavior.sync_token.is_some() - { - self.last_friends_refresh_at = Instant::now(); - self.dispatch(IoEvent::GetFriends); - } - - if let Some(expires_at) = self.status_message_expires_at { - if Instant::now() >= expires_at { - self.clear_status_message(); - } - } - - // Must stay above the early returns further down this fn, or the backstop - // would silently stop running during playback. - self.expire_api_error(); - - if let Some(frame) = self.view.liked_song_animation_frame { - if frame > 0 { - self.view.liked_song_animation_frame = Some(frame - 1); - } else { - self.view.liked_song_animation_frame = None; - } - } - - self.advance_lyrics_scroll(elapsed); - - // Load watchdog: a native load that produces no Playing/TrackChanged - // event within the window means the session is a zombie (passes - // `is_connected`, drops Spirc commands) — force recovery; the parked - // request replays once the new session is up. - #[cfg(feature = "streaming")] - { - const NATIVE_LOAD_WATCHDOG: Duration = Duration::from_secs(5); - let restore_attempts = self - .native_restore_pending - .as_ref() - .and_then(|attempt| { - self - .native_playback_recovery - .as_ref() - .filter(|snapshot| snapshot.generation == attempt.generation) - }) - .map_or(0, |snapshot| snapshot.recovery_attempts.saturating_sub(1)); - let watchdog_window = NATIVE_LOAD_WATCHDOG.saturating_mul( - u32::from( - self - .pending_start_playback - .as_ref() - .map_or(restore_attempts, |pending| pending.recovery_attempts), - ) + 1, - ); - let native_reconnecting = self - .streaming_player - .as_ref() - .is_some_and(|player| player.is_recovering()); - if native_reconnecting { - // A command accepted during the bounded fast-reconnect window is - // intentionally deferred by StreamingPlayer. Start its response window - // after the replacement Spirc exists, not while it is still connecting. - if let Some(watchdog) = self.native_load_watchdog.as_mut() { - *watchdog = Instant::now(); - } - } else if self - .native_load_watchdog - .is_some_and(|armed| armed.elapsed() >= watchdog_window) - { - self.native_load_watchdog = None; - const MAX_RECOVERY_ATTEMPTS: u8 = 2; - if let Some(pending) = self.pending_start_playback.as_mut() { - if pending.recovery_attempts >= MAX_RECOVERY_ATTEMPTS { - self.pending_start_playback = None; - self.set_status_message( - "Native playback did not respond after recovery; request dropped.", - 8, - ); - } else { - pending.recovery_attempts += 1; - log::warn!( - "no player event within {}s of native load; forcing recovery attempt {}", - NATIVE_LOAD_WATCHDOG.as_secs(), - pending.recovery_attempts - ); - self.force_native_streaming_recovery(true); - } - } else if let Some(attempt) = self.native_restore_pending.clone() { - let recovery_attempts = self - .native_playback_recovery - .as_ref() - .filter(|snapshot| snapshot.generation == attempt.generation) - .map_or(0, |snapshot| snapshot.recovery_attempts); - if recovery_attempts >= MAX_RECOVERY_ATTEMPTS { - self.native_restore_pending = None; - self.set_status_message( - "Native connection recovered, but playback could not be restored.", - 8, - ); - } else { - log::warn!( - "native restore generation {} produced no matching player event; forcing recovery attempt {}", - attempt.generation, - recovery_attempts + 1 - ); - self.force_native_streaming_recovery(true); - } - } - } - } - - self.poll_current_playback(); - let playing_now = self.user_config.behavior.keepawake_enabled - && playing_for_keepawake( - self.decoded_playing_state(), - self.native_is_playing, - self.current_playback_context.as_ref().map(|c| c.is_playing), - ); - match (playing_now, self.keepawake.is_some()) { - (true, false) => { - self.keepawake = keepawake::Builder::default() - .idle(true) - .sleep(true) - .display(true) - .reason("Playing music") - .app_name("spotatui") - .create() - .ok(); - } - (false, true) => self.keepawake = None, - _ => {} - } - - if let Some(CurrentPlaybackContext { - item: Some(item), - progress, - is_playing, - .. - }) = &self.current_playback_context - { - // When native streaming is active, skip API-based progress calculation - // The native player's PositionChanged events update song_progress_ms directly - if self.is_streaming_active { - let ms_since_poll = self - .instant_since_last_current_playback_poll - .elapsed() - .as_millis(); - if ms_since_poll < 2000 { - return; // Recent native update - don't overwrite - } - // No recent native update - fall through to API-based calculation as fallback - } - - let ms_since_poll = self - .instant_since_last_current_playback_poll - .elapsed() - .as_millis(); - - // Skip position updates if we recently seeked (let UI show our target position) - let recently_seeked = self - .last_api_seek - .is_some_and(|t| t.elapsed().as_millis() < SEEK_POSITION_IGNORE_MS); - - if recently_seeked { - return; // Don't overwrite our seek target - } - - // Resync from fresh API data (within 300ms of poll) to correct drift - if ms_since_poll < 300 { - self.song_progress_ms = progress - .as_ref() - .map(|p| p.num_milliseconds() as u128) - .unwrap_or(0); - } else if *is_playing { - // Smooth incremental updates between API polls - let elapsed_ms = elapsed.as_millis(); - let duration_ms = match item { - PlayableItem::Track(track) => track.duration.num_milliseconds() as u128, - PlayableItem::Episode(episode) => episode.duration.num_milliseconds() as u128, - _ => return, - }; - - self.song_progress_ms = (self.song_progress_ms + elapsed_ms).min(duration_ms); - } - // When paused, keep song_progress_ms unchanged - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn poll_current_playback_skips_when_spotify_disconnected() { - let (tx, rx) = channel(); - // No Spotify session (free-source launch): spotify_connected == false. - let mut app = App::new(tx, UserConfig::new(), None); - assert!(!app.spotify_connected); - // Force the poll interval to have elapsed so only the connection gate matters. - app.instant_since_last_current_playback_poll = Instant::now() - Duration::from_secs(10); - - app.poll_current_playback(); - - // Nothing dispatched: no per-tick "connect Spotify" auth-spam for free sources. - assert!(rx.try_recv().is_err()); - assert!(!app.is_fetching_current_playback); - } - - #[test] - fn playback_position_goes_stale_without_ticks_and_recovers_on_one() { - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), None); - app.song_progress_ms = 1234; - assert_eq!(app.playback_position_ms(), Some(1234)); - - // A frontend that stops ticking: the position must read as stale rather - // than freeze at its last value. - app.last_tick_at = Instant::now() - Duration::from_secs(3); - assert_eq!(app.playback_position_ms(), None); - - app.update_on_tick(Duration::from_millis(500)); - assert!(app.playback_position_ms().is_some()); - } - - #[test] - fn a_live_error_survives_a_tick() { - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), None); - app.handle_error(anyhow!("boom")); - - app.update_on_tick(Duration::from_millis(500)); - - assert_eq!(app.api_error, "boom"); - assert_eq!(app.get_current_route().id, RouteId::Error); - } - - #[test] - fn an_expired_error_is_retired_by_the_tick_and_handed_to_the_status_bar() { - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), None); - app.handle_error(anyhow!("boom")); - app.api_error_expires_at = Some(Instant::now() - Duration::from_secs(1)); - - app.update_on_tick(Duration::from_millis(500)); - - assert!(app.api_error.is_empty()); - assert!(app.api_error_expires_at.is_none()); - assert_ne!(app.get_current_route().id, RouteId::Error); - assert_eq!(app.status_message.as_deref(), Some("boom")); - assert!(app.status_message_is_error); - } - - // The load-bearing case for a frontend that never dismisses: a buried error - // frame must not survive the expiry, or it resurfaces later rendering an - // error page with nothing on it. It expires silently, because a toast about - // a minute-old failure on a screen the user already left is noise. - #[test] - fn expiring_an_error_removes_an_error_route_left_under_another_screen() { - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), None); - app.handle_error(anyhow!("boom")); - app.push_navigation_stack(RouteId::SelectedDevice, ActiveBlock::SelectDevice); - app.api_error_expires_at = Some(Instant::now() - Duration::from_secs(1)); - - app.update_on_tick(Duration::from_millis(500)); - - assert!(app.api_error.is_empty()); - assert_eq!(app.get_current_route().id, RouteId::SelectedDevice); - assert!(app.status_message.is_none()); - - app.pop_navigation_stack(); - assert_eq!(app.get_current_route().id, RouteId::Home); - } - - #[test] - fn poll_current_playback_dispatches_when_spotify_connected() { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - assert!(app.spotify_connected); - app.instant_since_last_current_playback_poll = Instant::now() - Duration::from_secs(10); - - app.poll_current_playback(); - - assert!(matches!(rx.try_recv(), Ok(IoEvent::GetCurrentPlayback))); - assert!(app.is_fetching_current_playback); - } - - #[test] - fn a_paused_decoded_source_overrides_the_suspended_spotify_state() { - // The Spotify-to-decoded handoff only pauses librespot, so both the native - // flag and the context it left behind can still read as playing. - assert!(!playing_for_keepawake(Some(false), Some(true), Some(true))); - } - - #[test] - fn a_playing_decoded_source_keeps_the_machine_awake() { - assert!(playing_for_keepawake(Some(true), Some(false), Some(false))); - } - - #[test] - fn without_a_decoded_owner_the_native_flag_decides() { - assert!(playing_for_keepawake(None, Some(true), Some(false))); - assert!(!playing_for_keepawake(None, Some(false), Some(true))); - } - - #[test] - fn without_a_decoded_owner_or_a_native_flag_spotify_decides() { - assert!(playing_for_keepawake(None, None, Some(true))); - assert!(!playing_for_keepawake(None, None, Some(false))); - } - - #[test] - fn nothing_playing_lets_the_machine_sleep() { - assert!(!playing_for_keepawake(None, None, None)); - } -} diff --git a/src/core/app/transport.rs b/src/core/app/transport.rs deleted file mode 100644 index f32ca431..00000000 --- a/src/core/app/transport.rs +++ /dev/null @@ -1,562 +0,0 @@ -use super::*; - -impl App { - /// Pause native streaming playback with the full bookkeeping the pause - /// branch of [`toggle_playback`](Self::toggle_playback) does: clear any - /// parked StartPlayback and the load watchdog (either would resume or force - /// recovery against a backend we just gave up on), mark the playback intent - /// as paused (so a recovery snapshot doesn't resume into that backend - /// either), pause the player, and flip the UI playing state. - #[cfg(feature = "streaming")] - pub fn pause_native_playback(&mut self) { - self.pending_start_playback = None; - self.native_load_watchdog = None; - self.set_native_playback_intent(false); - if let Some(player) = &self.streaming_player { - player.pause(); - } - if let Some(ctx) = &mut self.current_playback_context { - ctx.is_playing = false; - } - self.native_is_playing = Some(false); - } - - pub fn toggle_playback(&mut self) { - // The native queue slot owns the sink: toggle its player directly (covers the - // idle-app case where no per-source context is set). - #[cfg(feature = "audio-decode-queue")] - if let Some(player) = self.queue_now_decoded_player() { - if player.is_paused() { - player.resume(); - } else { - player.pause(); - } - return; - } - - // Spotify queue playback has no item in `current_playback_context`; route - // the intended transport action using the librespot state instead. Flip the - // state optimistically so rapid toggles alternate instead of both reading - // the stale pre-router value and dispatching the same action twice. - if self.queue_now_is_spotify() { - let is_playing = self.native_is_playing.unwrap_or(false); - self.native_is_playing = Some(!is_playing); - if is_playing { - self.dispatch(IoEvent::PausePlayback); - } else { - self.dispatch(IoEvent::StartPlayback(None, None, None)); - } - return; - } - - // Local-file playback owns the session: toggle the local sink directly. The - // playbar reads pause state live from the player, so nothing else to update. - #[cfg(feature = "local-files")] - if let Some(local) = &self.local_playback { - if local.player.is_paused() { - local.player.resume(); - } else { - local.player.pause(); - } - return; - } - - // Subsonic playback owns the session the same way: toggle its sink directly. - #[cfg(feature = "subsonic")] - if let Some(subsonic) = &self.subsonic_playback { - if subsonic.player.is_paused() { - subsonic.player.resume(); - } else { - subsonic.player.pause(); - } - return; - } - - // Qobuz playback owns the session the same way: toggle its sink directly. - #[cfg(feature = "qobuz")] - if let Some(qobuz) = &self.qobuz_playback { - if qobuz.player.is_paused() { - qobuz.player.resume(); - } else { - qobuz.player.pause(); - } - return; - } - - // YouTube playback owns the session the same way: toggle its sink directly. - #[cfg(feature = "youtube")] - if let Some(youtube) = &self.youtube_playback { - if youtube.player.is_paused() { - youtube.player.resume(); - } else { - youtube.player.pause(); - } - return; - } - - // Internet-radio playback owns the session the same way: toggle its sink - // directly. Without this branch radio falls through to the streaming path, - // which only ever emits a bare resume — so Play/Pause could resume radio but - // never pause it. - #[cfg(feature = "internet-radio")] - if let Some(radio) = &self.radio_playback { - if radio.player.is_paused() { - radio.player.resume(); - } else { - radio.player.pause(); - } - return; - } - - // A decoded start in flight, or a source that lost its sink: nothing to - // toggle, and the paused librespot underneath is not the player the user - // means. - if self.decoded_sink_claimed() { - self.set_status_message(NOTHING_PLAYING_STATUS, 4); - return; - } - - // Use native streaming player for instant control (bypasses event channel latency) - #[cfg(feature = "streaming")] - if self.is_native_streaming_active_for_playback() { - if self - .current_playback_context - .as_ref() - .and_then(|ctx| ctx.item.as_ref()) - .is_none() - { - self.dispatch(IoEvent::StartPlayback(None, None, None)); - return; - } - - if let Some(player) = self.streaming_player.clone() { - let is_playing = self - .native_is_playing - .or_else(|| self.current_playback_context.as_ref().map(|c| c.is_playing)) - .unwrap_or(false); - info!( - "toggling playback: {}", - if is_playing { "paused" } else { "playing" } - ); - if is_playing { - self.pause_native_playback(); - } else { - self.set_native_playback_intent(true); - player.play(); - // Update UI state immediately - if let Some(ctx) = &mut self.current_playback_context { - ctx.is_playing = true; - } - self.native_is_playing = Some(true); - } - return; - } - } - - // Fallback to API-based playback control for external devices - let is_playing = if self.is_streaming_active { - self - .native_is_playing - .or_else(|| self.current_playback_context.as_ref().map(|c| c.is_playing)) - .unwrap_or(false) - } else { - self - .current_playback_context - .as_ref() - .map(|c| c.is_playing) - .unwrap_or(false) - }; - - if is_playing { - self.dispatch_spotify_fallback(IoEvent::PausePlayback); - } else { - // When no offset or uris are passed, spotify will resume current playback - self.dispatch_spotify_fallback(IoEvent::StartPlayback(None, None, None)); - } - } - - pub fn previous_track(&mut self) { - info!("playing previous track or restarting current track"); - #[cfg(feature = "streaming")] - { - self.pending_start_playback = None; - self.native_load_watchdog = None; - } - // The native queue owns playback: a forward-only queue has no "previous", - // so restart the current queued track. The queue router intercepts the - // dispatched event for both decoded and Spotify queue slots. - if self.queue_owns_playback() { - self.song_progress_ms = 0; - self.dispatch(IoEvent::PreviousTrack); - return; - } - // A decoded source owns the session: route to its dispatcher, never to the - // paused librespot. Preserve the ">= 3s restarts current, else previous" - // semantics (radio no-ops both Seek and PreviousTrack). - if self.active_decoded_source() { - if self.song_progress_ms >= 3_000 { - self.dispatch(IoEvent::Seek(0)); - } else { - self.dispatch(IoEvent::PreviousTrack); - } - self.song_progress_ms = 0; - return; - } - if self.song_progress_ms >= 3_000 { - // If more than 3 seconds into the song, restart from beginning - #[cfg(feature = "streaming")] - if self.is_native_streaming_active_for_playback() { - if let Some(player) = self.streaming_player.clone() { - player.seek(0); - self.song_progress_ms = 0; - self.seek_ms = None; - self.set_native_recovery_position(0); - return; - } - } - - // Fallback for external devices - self.dispatch_spotify_fallback(IoEvent::Seek(0)); - } else { - // If less than 3 seconds in, go to previous track - #[cfg(feature = "streaming")] - if self.is_native_streaming_active_for_playback() { - // A manual Previous advances the shuffle session even under repeat-one. - self.mark_native_shuffle_manual_skip(false); - if let Some(ref player) = self.streaming_player { - player.activate(); - player.prev(); - // Reset progress immediately for UI feedback - self.song_progress_ms = 0; - // librespot can occasionally land in a paused state after a skip. - // Schedule a short delayed resume to avoid racing the track transition. - let player = std::sync::Arc::clone(player); - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(300)); - player.activate(); - player.play(); - }); - return; - } - } - - // Fallback for external devices - self.dispatch_spotify_fallback(IoEvent::PreviousTrack); - } - } - - pub fn force_previous_track(&mut self) { - info!("force skipping to previous track"); - #[cfg(feature = "streaming")] - { - self.pending_start_playback = None; - self.native_load_watchdog = None; - } - // The native queue owns playback: restart the current queued track (the - // queue router intercepts the event for both slot kinds). - if self.queue_owns_playback() { - self.song_progress_ms = 0; - self.dispatch(IoEvent::ForcePreviousTrack); - return; - } - // A decoded source owns the session: route to its dispatcher, never to the - // paused librespot. The source handles or no-ops ForcePreviousTrack. - if self.active_decoded_source() { - self.song_progress_ms = 0; - self.dispatch(IoEvent::ForcePreviousTrack); - return; - } - #[cfg(feature = "streaming")] - if self.is_native_streaming_active_for_playback() { - // A manual Previous advances the shuffle session even under repeat-one. - self.mark_native_shuffle_manual_skip(false); - if let Some(ref player) = self.streaming_player { - player.activate(); - // First prev() restarts the current track (if past Spotify's ~3s threshold). - // After a short delay the second prev() actually skips to the previous track, - // since the position is now back at 0. - player.prev(); - self.song_progress_ms = 0; - let player = std::sync::Arc::clone(player); - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(500)); - player.prev(); - std::thread::sleep(std::time::Duration::from_millis(300)); - player.activate(); - player.play(); - }); - return; - } - } - - self.song_progress_ms = 0; - self.dispatch_spotify_fallback(IoEvent::ForcePreviousTrack); - } - - pub fn next_track(&mut self) { - info!("skipping to next track"); - #[cfg(feature = "streaming")] - { - self.pending_start_playback = None; - self.native_load_watchdog = None; - } - // The native queue owns playback: skip to the next queued item (or resume - // the suspended context when the queue drains). - if self.queue_owns_playback() { - self.song_progress_ms = 0; - self.dispatch(IoEvent::AdvanceNativeQueue); - return; - } - // A decoded context is playing with items waiting in the queue: suspend it - // (skip semantics — resume at the context's next track) and start the queue. - // An explicit Next advances the context even under Repeat One, matching the - // per-source skip paths: repeat-one only replays on *auto* advance. - if self.active_decoded_source() && !self.native_queue.is_empty() { - self.suspend_active_decoded_context_for_skip(crate::infra::queue::SuspendCause::ManualSkip); - self.song_progress_ms = 0; - self.dispatch(IoEvent::AdvanceNativeQueue); - return; - } - // A decoded source (local/subsonic/radio/youtube) owns the session: route to - // its dispatcher, never to the paused librespot. The source handles or - // no-ops NextTrack (radio has no queue). - if self.active_decoded_source() { - self.song_progress_ms = 0; - self.dispatch(IoEvent::NextTrack); - return; - } - // Use native streaming player for instant control (bypasses event channel latency) - #[cfg(feature = "streaming")] - if self.is_native_streaming_active_for_playback() { - // A native-Spotify context is playing with items waiting in the queue: - // suspend it (skip semantics) and hand the sink to the queue instead of - // Spirc-advancing the context. (`queue_owns_playback` is already handled - // above, so here the context, not a queued track, is playing.) - if !self.native_queue.is_empty() { - self - .suspend_native_spotify_context_for_queue(crate::infra::queue::SuspendCause::ManualSkip); - if let Some(player) = self.streaming_player.as_ref() { - player.pause(); - } - self.song_progress_ms = 0; - self.dispatch(IoEvent::AdvanceNativeQueue); - return; - } - // A manual Next advances the shuffle session even under repeat-one. - self.mark_native_shuffle_manual_skip(true); - if let Some(ref player) = self.streaming_player { - player.activate(); - player.next(); - // Reset progress immediately for UI feedback - self.song_progress_ms = 0; - // librespot can occasionally land in a paused state after a skip. - // Schedule a short delayed resume to avoid racing the track transition. - let player = std::sync::Arc::clone(player); - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(300)); - player.activate(); - player.play(); - }); - return; - } - } - - // Fallback for external devices - self.dispatch_spotify_fallback(IoEvent::NextTrack); - } - - /// Start playback of an explicit list of playable URIs, optionally from an - /// offset into that list. The single place the shared action vocabulary - /// builds a URI-list `StartPlayback`; ownership routing happens in the - /// pump's source routers, exactly as for the equivalent keybinding. - #[cfg_attr(not(feature = "scripting"), allow(dead_code))] - pub(crate) fn start_playback_uris(&mut self, uris: Vec<String>, offset: Option<usize>) { - // No URIs: nothing to start, and an empty start would only tear the current player down. - if uris.is_empty() { - return; - } - self.dispatch(IoEvent::StartPlayback(None, Some(uris), offset)); - } - - /// Start playback of a Spotify context URI, optionally from a 0-based track - /// offset. The context twin of [`Self::start_playback_uris`]. - #[cfg_attr(not(feature = "scripting"), allow(dead_code))] - pub(crate) fn start_playback_context(&mut self, context_uri: String, offset: Option<usize>) { - self.dispatch(IoEvent::StartPlayback(Some(context_uri), None, offset)); - } - - /// Start playback of one track URI as the first track inside a Spotify - /// context URI. The third start shape beside the two twins above: the - /// network layer deliberately does not trim the uri list when a context is - /// present, which is what keeps the selected track first under shuffle. - #[cfg_attr(not(feature = "scripting"), allow(dead_code))] - pub(crate) fn start_playback_track_in_context(&mut self, context_uri: String, track_uri: String) { - self.dispatch(IoEvent::StartPlayback( - Some(context_uri), - Some(vec![track_uri]), - Some(0), - )); - } - - pub fn copy_song_url(&mut self) { - info!("copying song url to clipboard"); - let clipboard = match &mut self.clipboard { - Some(ctx) => ctx, - None => return, - }; - - if let Some(CurrentPlaybackContext { - item: Some(item), .. - }) = &self.current_playback_context - { - match item { - PlayableItem::Track(track) => { - let track_id = track.id.as_ref().map(|id| id.id().to_string()); - - match track_id { - Some(id) if !id.is_empty() => { - if let Err(e) = clipboard.set_text(format!("https://open.spotify.com/track/{}", id)) { - self.handle_error(anyhow!("failed to set clipboard content: {}", e)); - } - } - _ => { - self.handle_error(anyhow!("Track has no ID")); - } - } - } - PlayableItem::Episode(episode) => { - let episode_id = episode.id.id().to_string(); - if let Err(e) = - clipboard.set_text(format!("https://open.spotify.com/episode/{}", episode_id)) - { - self.handle_error(anyhow!("failed to set clipboard content: {}", e)); - } - } - _ => {} - } - } - } - - pub fn copy_album_url(&mut self) { - info!("copying album url to clipboard"); - let clipboard = match &mut self.clipboard { - Some(ctx) => ctx, - None => return, - }; - - if let Some(CurrentPlaybackContext { - item: Some(item), .. - }) = &self.current_playback_context - { - match item { - PlayableItem::Track(track) => { - let album_id = track.album.id.as_ref().map(|id| id.id().to_string()); - - match album_id { - Some(id) if !id.is_empty() => { - if let Err(e) = clipboard.set_text(format!("https://open.spotify.com/album/{}", id)) { - self.handle_error(anyhow!("failed to set clipboard content: {}", e)); - } - } - _ => { - self.handle_error(anyhow!("Album has no ID")); - } - } - } - PlayableItem::Episode(episode) => { - let show_id = episode.show.id.id().to_string(); - if let Err(e) = clipboard.set_text(format!("https://open.spotify.com/show/{}", show_id)) { - self.handle_error(anyhow!("failed to set clipboard content: {}", e)); - } - } - _ => {} - } - } - } -} - -#[cfg(all(test, feature = "streaming"))] -mod tests { - use super::*; - use crate::core::app::test_support::*; - - /// When the native queue slot owns playback, `next_track` advances the queue - /// instead of driving the streaming player's own `next`. - #[cfg(feature = "streaming")] - #[test] - fn next_track_advances_native_queue_when_queue_owns_playback() { - use crate::infra::queue::QueueNowPlaying; - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.queue_now = Some(QueueNowPlaying::Spotify { - track: queue_track(Some("spotify:track:queued"), "Queued"), - }); - - app.next_track(); - - // The first dispatched event is the queue advance, not a Spotify NextTrack. - assert!( - matches!(rx.recv().unwrap(), IoEvent::AdvanceNativeQueue), - "expected AdvanceNativeQueue to be dispatched first" - ); - } - - #[cfg(feature = "streaming")] - #[test] - fn toggle_playback_with_spotify_queue_slot_does_not_panic() { - use crate::infra::queue::QueueNowPlaying; - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.queue_now = Some(QueueNowPlaying::Spotify { - track: queue_track(Some("spotify:track:queued"), "Queued"), - }); - app.native_is_playing = Some(true); - - app.toggle_playback(); - - assert!(app.queue_now_is_spotify()); - assert!(matches!(rx.recv().unwrap(), IoEvent::PausePlayback)); - assert_eq!(app.native_is_playing, Some(false)); - - // A second toggle before the router echoes back the new state must read - // the optimistically flipped value and dispatch the opposite action. - app.toggle_playback(); - - assert!(matches!( - rx.recv().unwrap(), - IoEvent::StartPlayback(None, None, None) - )); - assert_eq!(app.native_is_playing, Some(true)); - } - - #[cfg(feature = "streaming")] - #[test] - fn previous_track_restarts_native_queue_when_queue_owns_playback() { - use crate::infra::queue::QueueNowPlaying; - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.queue_now = Some(QueueNowPlaying::Spotify { - track: queue_track(Some("spotify:track:queued"), "Queued"), - }); - - app.previous_track(); - - assert!( - matches!(rx.recv().unwrap(), IoEvent::PreviousTrack), - "expected PreviousTrack to be dispatched for the queue router" - ); - } - - #[cfg(feature = "youtube")] - #[test] - fn toggle_under_a_claim_with_no_session_dispatches_nothing() { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.claim_decoded_sink(Source::YouTube); - - app.toggle_playback(); - - assert!(rx.try_recv().is_err()); - assert_eq!(app.status_message.as_deref(), Some(NOTHING_PLAYING_STATUS)); - } -} diff --git a/src/core/app/view.rs b/src/core/app/view.rs deleted file mode 100644 index fe924c25..00000000 --- a/src/core/app/view.rs +++ /dev/null @@ -1,191 +0,0 @@ -use super::*; - -/// Presentation state of the terminal frontend, grouped on [`App::view`]. -/// -/// Everything in here is a cursor, a scroll offset, an edit buffer, a focus -/// marker, a popup flag or the terminal geometry: state that tells a frontend -/// *where the user is looking*, not what the player or the library holds. It -/// lives on `App` today so the existing handlers and draw functions keep one -/// argument, but nothing outside the TUI should depend on it. Producers that -/// replace a list (the network layer, a source dispatcher, a script effect) -/// still reset or clamp a few of these cursors directly; the -/// `view_writes_outside_tui` ratchet in `src/gates.rs` counts those writes and -/// exists to burn them down to zero. The table cursors (`track_table_index`, the -/// `search_*` fields, `recently_played_index`, `library_selected`) are reset -/// through `App` methods such as `set_track_table` and `clamp_search_cursors`, -/// so a producer never writes them. -/// -/// Add a field here only when it is presentation state. A pending operation or -/// a value another frontend would also need belongs on `App` itself. -#[derive(Default)] -pub struct ViewState { - /// Terminal geometry as of the last frame. The small/large search limits - /// derive from it. - pub size: Viewport, - /// What the terminal can report about key presses, probed at startup and - /// consulted when the rebind flow validates a capture. - pub terminal_input_caps: TerminalInputCapabilities, - - // Inputs: - // input is the string for input; - // input_idx is the index of the cursor in terms of character; - // input_cursor_position is the sum of the width of characters preceding the cursor. - // Reason for this complication is due to non-ASCII characters, they may - // take more than 1 bytes to store and more than 1 character width to display. - pub input: Vec<char>, - pub input_idx: usize, - pub input_cursor_position: u16, - pub input_context: InputContext, - /// Horizontal scroll offset for the input box, computed during rendering. - pub input_scroll_offset: Cell<u16>, - - // Sidebar, library and table cursors - pub home_scroll: u16, - pub selected_playlist_index: Option<usize>, - pub active_playlist_index: Option<usize>, - pub saved_album_tracks_index: usize, - pub album_list_index: usize, - pub artists_list_index: usize, - pub shows_list_index: usize, - pub episode_list_index: usize, - pub queue_selected_index: usize, - /// Cursor in the Local Files folder list. - pub local_playlists_index: usize, - - // Table cursors. A producer that replaces a list resets or clamps these - // through an `App` method (`set_track_table`, `clamp_search_cursors`, ...), - // never directly. - /// The row under the cursor in the shared track table. - pub track_table_index: usize, - /// First row the track table shows. Persisted across frames so the cursor - /// can move inside the visible window without dragging the view; updated - /// during draw, hence `Cell`. - pub track_table_scroll_offset: Cell<usize>, - /// The sidebar row the cursor is on, by identity. - pub library_selected: LibraryTarget, - /// The row under the cursor in the Recently Played table. - pub recently_played_index: usize, - // The search screen: one cursor per result block, plus its focus. - pub search_selected_album_index: Option<usize>, - pub search_selected_artists_index: Option<usize>, - pub search_selected_playlists_index: Option<usize>, - pub search_selected_tracks_index: Option<usize>, - pub search_selected_shows_index: Option<usize>, - pub search_hovered_block: SearchResultBlock, - pub search_selected_block: SearchResultBlock, - - // The `d` device/source picker - pub selected_device_index: Option<usize>, - /// Cursor within the Source panel of the `d` picker (index into [`Source::ALL`]). - pub source_list_index: usize, - /// Which panel of the `d` picker currently has focus. - pub source_device_focus: SourceFocus, - - // Help menu - pub help_docs_size: u32, - pub help_menu_page: u32, - pub help_menu_max_lines: u32, - pub help_menu_offset: u32, - /// Text filter applied to the rows in the Help menu. - pub help_filter: String, - /// Whether typed keys are currently editing [`Self::help_filter`]. - pub help_filter_editing: bool, - /// Formatted Help rows for the current width/keys/filter; `None` until the - /// Help menu is first prepared for rendering. - pub help_menu_model: Option<HelpMenuModel>, - - // Modal dialogs - /// Which modal dialog is open, if any. - pub dialog: Option<String>, - /// The yes/no cursor of the open dialog. - pub confirm: bool, - - /// Scroll/browse state for the lyrics view. - pub lyrics_view: LyricsViewState, - - // Settings screen state - pub settings_category: SettingsCategory, - pub settings_selected_index: usize, - pub settings_edit_mode: bool, - pub settings_edit_buffer: String, - /// Fuzzy filter over the current category's rows. Survives a tab switch, so - /// one query can be walked across categories. - pub settings_filter: String, - /// Whether typed keys are currently editing [`Self::settings_filter`]. - pub settings_filter_editing: bool, - pub settings_unsaved_prompt_visible: bool, - /// Yes/no cursor of the unsaved-changes prompt. Set to `true` whenever the - /// prompt opens; only read while the prompt is visible. - pub settings_unsaved_prompt_save_selected: bool, - - // Discover and Stats screens - /// Selected index in the Discover view - pub discover_selected_index: usize, - /// Time range for Top Tracks - pub discover_time_range: DiscoverTimeRange, - /// Selected index in the Stats screen's Top Tracks list - pub stats_selected_track: usize, - - // Sort menu state - /// Whether the sort menu popup is visible - pub sort_menu_visible: bool, - /// Currently selected sort option in the menu - pub sort_menu_selected: usize, - /// Current sort context (what we're sorting) - pub sort_context: Option<SortContext>, - - // Animation - /// Animation frame counter for the "Liked" heart flash effect (0-10) - pub liked_song_animation_frame: Option<u8>, - /// Global animation tick counter, incremented every tick. - pub animation_tick: u64, - - // Listening party inputs - /// Input buffer for the party join code - pub party_input: Vec<char>, - /// Cursor position in party code input - pub party_input_idx: usize, - /// Input buffer for the required party guest name - pub party_join_name: Vec<char>, - - // Add-to-playlist picker dialog - /// Selected playlist index in the add-to-playlist picker dialog - pub playlist_picker_selected_index: usize, - /// Folder ID the add-to-playlist picker dialog is viewing (0 = root) - pub playlist_picker_folder_id: usize, - - // Friends screen state - /// Cursor position in the friends list - pub friend_selected_index: usize, - /// Active filter (All / Online) - pub friend_filter: FriendFilter, - /// Inline search / filter input on the Friends screen - pub friend_search_input: Vec<char>, - /// Whether the "Add Friend" overlay dialog is open - pub friend_add_dialog_visible: bool, - /// Which tab is active inside the add-friend dialog - pub friend_add_mode: FriendAddMode, - /// Input buffer for the "add by friend code" text field - pub friend_add_input: Vec<char>, - /// Input buffer for the "search by username" text field in the add dialog - pub friend_user_search_input: Vec<char>, - /// Selected row in the user-search results list - pub friend_user_search_selected: usize, - - // Create Playlist form state - pub create_playlist_name: Vec<char>, - pub create_playlist_name_idx: usize, - pub create_playlist_name_cursor: u16, - pub create_playlist_stage: CreatePlaylistStage, - pub create_playlist_search_input: Vec<char>, - pub create_playlist_search_idx: usize, - pub create_playlist_search_cursor: u16, - pub create_playlist_selected_result: usize, - pub create_playlist_focus: CreatePlaylistFocus, - - // Plugin surfaces - /// Vertical scroll for the focused plugin screen. - pub plugin_screen_scroll: u16, - /// Scroll offset for the plugin popup. - pub plugin_popup_scroll: u16, -} diff --git a/src/core/app/volume.rs b/src/core/app/volume.rs deleted file mode 100644 index 6846ae92..00000000 --- a/src/core/app/volume.rs +++ /dev/null @@ -1,276 +0,0 @@ -use super::*; - -impl App { - /// Coalesced Web API volume for an external device. - fn queue_api_volume(&mut self, volume: u8) { - if self.playback_owner() == PlaybackOwner::None { - self.set_status_message(NOTHING_PLAYING_STATUS, 4); - return; - } - self.pending_volume = Some(volume); - if !self.is_volume_change_in_flight { - self.is_volume_change_in_flight = true; - self.dispatch(IoEvent::ChangeVolume(volume)); - } - } - - /// A volume change never reached Spotify (a failed request, a replay the - /// owner change dropped): release the latches so the next one can go. - pub(crate) fn cancel_volume_change(&mut self) { - self.is_volume_change_in_flight = false; - self.pending_volume = None; - self.last_dispatched_volume = None; - } - - pub fn flush_pending_volume(&mut self) { - if self.pending_volume.is_some() && self.playback_owner() == PlaybackOwner::None { - self.pending_volume = None; - return; - } - // A decoded source took the value in its own branch; a Web API call here - // would only latch `is_volume_change_in_flight` with no Spotify reply to - // clear it. Drop the latch so the value does not replay at Spotify when - // the sink changes hands; `desired_volume` reads the decoded volume from - // the runtime state meanwhile. - if self.active_decoded_source() { - self.pending_volume = None; - self.last_dispatched_volume = None; - return; - } - if self.is_volume_change_in_flight { - return; // previous request still processing - } - if let Some(volume) = self.pending_volume { - if self.last_dispatched_volume == Some(volume) { - return; // already dispatched this value, waiting for API to confirm - } - self.is_volume_change_in_flight = true; - self.last_dispatched_volume = Some(volume); - self.dispatch(IoEvent::ChangeVolume(volume)); - } - } - - /// Returns the volume the UI should show and volume-up/down should use as a base. - /// - /// If the user just pressed a volume key, we show their input (not what the API - /// says) because Spotify can be slow to reflect the change. Without this, you'd - /// see the percentage jump back to the old value for a split second before - /// correcting — especially noticeable when spamming volume up/down. - pub fn desired_volume(&self) -> u32 { - // A decoded owner's volume is the one its setters wrote; the pending value - // and the Spotify device volume both describe the other player. - if self.active_decoded_source() { - return self.runtime_state.volume_percent as u32; - } - if let Some(pending) = self.pending_volume { - return pending as u32; - } - self - .current_playback_context - .as_ref() - .and_then(|c| c.device.volume_percent) - // No Spotify device volume (e.g. a decoded source is playing, or the slim - // build has no context): fall back to the configured volume, not 0, so the - // playbar and volume-up/down base math stay correct for every source. - .unwrap_or(self.runtime_state.volume_percent as u32) - } - - /// Set volume to an absolute percentage (0-100). Routes through the same - /// native-streaming fast path and API coalescing logic as the keyboard - /// volume keys, so Lua actions behave identically to keypresses. - #[cfg_attr(not(feature = "scripting"), allow(dead_code))] - pub fn set_volume_percent(&mut self, volume: u8) { - let next_volume = volume.min(100); - let current_volume = self.desired_volume() as u8; - - if next_volume != current_volume { - info!("setting volume to {}", next_volume); - // A decoded source owns the sink: route the volume change to its - // dispatcher (which sets the rodio sink's gain), never to the paused - // librespot. The dispatcher converts the u8 percentage to a float. - if self.active_decoded_source() { - self.dispatch(IoEvent::ChangeVolume(next_volume)); - self.runtime_state.volume_percent = next_volume; - self.schedule_state_save(PersistedRuntimeState::volume_percent(next_volume)); - self.pending_volume = Some(next_volume); - return; - } - // Use native streaming player for instant control (bypasses event channel latency) - #[cfg(feature = "streaming")] - if self.is_native_streaming_active_for_playback() { - if let Some(ref player) = self.streaming_player { - player.set_volume(next_volume); - - // Update UI state immediately - if let Some(ctx) = &mut self.current_playback_context { - ctx.device.volume_percent = Some(next_volume.into()); - } - self.runtime_state.volume_percent = next_volume; - self.schedule_state_save(PersistedRuntimeState::volume_percent(next_volume)); - self.pending_volume = Some(next_volume); - - // Notify MPRIS clients of the change (VolumeChanged is never emitted by - // librespot for local mixer changes, so this is the only way the - // Volume D-Bus property stays in sync) - #[cfg(all(feature = "mpris", target_os = "linux"))] - if let Some(ref mpris) = self.mpris_manager { - mpris.set_volume(next_volume); - } - return; - } - } - - self.queue_api_volume(next_volume); - } - } - - /// Bump volume up. Uses `desired_volume()` as the base so rapid presses - /// don't accidentally calculate from a stale API value. - pub fn increase_volume(&mut self) { - let current_volume = self.desired_volume() as u8; - let next_volume = min( - current_volume + self.user_config.behavior.volume_increment, - 100, - ); - - if next_volume != current_volume { - info!("increasing volume: {} -> {}", current_volume, next_volume); - // A decoded source owns the sink: route the volume change to its - // dispatcher (which sets the rodio sink's gain), never to the paused - // librespot. The dispatcher converts the u8 percentage to a float. - if self.active_decoded_source() { - self.dispatch(IoEvent::ChangeVolume(next_volume)); - self.runtime_state.volume_percent = next_volume; - self.schedule_state_save(PersistedRuntimeState::volume_percent(next_volume)); - self.pending_volume = Some(next_volume); - return; - } - // Use native streaming player for instant control (bypasses event channel latency) - #[cfg(feature = "streaming")] - if self.is_native_streaming_active_for_playback() { - if let Some(ref player) = self.streaming_player { - player.set_volume(next_volume); - - // Update UI state immediately - if let Some(ctx) = &mut self.current_playback_context { - ctx.device.volume_percent = Some(next_volume.into()); - } - self.runtime_state.volume_percent = next_volume; - self.schedule_state_save(PersistedRuntimeState::volume_percent(next_volume)); - self.pending_volume = Some(next_volume); - - // Notify MPRIS clients of the change (VolumeChanged is never emitted by - // librespot for local mixer changes, so this is the only way the - // Volume D-Bus property stays in sync) - #[cfg(all(feature = "mpris", target_os = "linux"))] - if let Some(ref mpris) = self.mpris_manager { - mpris.set_volume(next_volume); - } - return; - } - } - - self.queue_api_volume(next_volume); - } - } - - /// Bump volume down. Uses `desired_volume()` as the base so rapid presses - /// don't accidentally calculate from a stale API value. - pub fn decrease_volume(&mut self) { - let current_volume = self.desired_volume() as i8; - let next_volume = max( - current_volume - self.user_config.behavior.volume_increment as i8, - 0, - ); - - if next_volume != current_volume { - let next_volume_u8 = next_volume as u8; - info!( - "decreasing volume: {} -> {}", - current_volume, next_volume_u8 - ); - - // A decoded source owns the sink: route the volume change to its - // dispatcher (which sets the rodio sink's gain), never to the paused - // librespot. The dispatcher converts the u8 percentage to a float. - if self.active_decoded_source() { - self.dispatch(IoEvent::ChangeVolume(next_volume_u8)); - self.runtime_state.volume_percent = next_volume_u8; - self.schedule_state_save(PersistedRuntimeState::volume_percent(next_volume_u8)); - self.pending_volume = Some(next_volume_u8); - return; - } - // Use native streaming player for instant control (bypasses event channel latency) - #[cfg(feature = "streaming")] - if self.is_native_streaming_active_for_playback() { - if let Some(ref player) = self.streaming_player { - player.set_volume(next_volume_u8); - - // Update UI state immediately - if let Some(ctx) = &mut self.current_playback_context { - ctx.device.volume_percent = Some(next_volume_u8.into()); - } - self.runtime_state.volume_percent = next_volume_u8; - self.schedule_state_save(PersistedRuntimeState::volume_percent(next_volume_u8)); - self.pending_volume = Some(next_volume_u8); - - // Notify MPRIS clients of the change (VolumeChanged is never emitted by - // librespot for local mixer changes, so this is the only way the - // Volume D-Bus property stays in sync) - #[cfg(all(feature = "mpris", target_os = "linux"))] - if let Some(ref mpris) = self.mpris_manager { - mpris.set_volume(next_volume_u8); - } - return; - } - } - - self.queue_api_volume(next_volume_u8); - } - } -} - -#[cfg(test)] -mod tests { - use crate::core::app::test_support::*; - - // Regression for transport-4: with no Spotify device volume and no pending - // volume (the state while a decoded source plays, and the whole slim build), - // `desired_volume` must fall back to the configured volume, not 0. The old - // `.unwrap_or(0)` made volume-down a dead no-op and the first volume-up snap - // to the increment. This is the only hardware-free guard for that fix — a - // source-active transport test needs a real audio device (see report). - #[test] - fn desired_volume_falls_back_to_config_when_no_context() { - let mut app = make_app_simple(); - app.current_playback_context = None; - app.pending_volume = None; - app.runtime_state.volume_percent = 42; - - assert_eq!( - app.desired_volume(), - 42, - "with no device volume and no pending volume, base volume must come from config, not 0" - ); - } - - #[cfg(feature = "youtube")] - #[test] - fn a_volume_set_under_a_decoded_owner_does_not_replay_at_spotify() { - use super::*; - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.claim_decoded_sink(Source::YouTube); - app.current_playback_context = Some(make_external_context()); - app.runtime_state.volume_percent = 60; - app.pending_volume = Some(60); - - app.flush_pending_volume(); - assert_eq!(app.desired_volume(), 60); - assert!(app.pending_volume.is_none()); - - app.release_decoded_sink_claim(); - app.flush_pending_volume(); - assert!(rx.try_recv().is_err()); - } -} diff --git a/src/core/art.rs b/src/core/art.rs deleted file mode 100644 index db2fefe9..00000000 --- a/src/core/art.rs +++ /dev/null @@ -1,313 +0,0 @@ -//! Frontend-agnostic cover art: what to fetch ([`CoverArtRequest`]), the -//! fetch/decode itself ([`fetch_and_decode`]), and the decoded image plus its -//! load status ([`CoverArtStore`]) that frontends render from. Turning the -//! decoded image into a terminal graphics protocol is the TUI's job -//! (`tui::cover_art`); nothing in here may depend on ratatui. - -use anyhow::anyhow; -use log::{debug, info}; -use std::sync::{Arc, OnceLock}; - -/// Cap on a cover-art response body, checked against both the declared and -/// the actual size. Real covers are a few hundred KiB; the cap only exists so -/// a misbehaving server cannot make the app buffer an unbounded body (the -/// decode needs it all in memory). -const MAX_COVER_ART_BYTES: u64 = 16 * 1024 * 1024; - -/// Cap on either decoded dimension. [`MAX_COVER_ART_BYTES`] bounds only the -/// compressed body, and a tiny file can still declare enormous dimensions -/// (`image`'s default `max_alloc` is best-effort, not strict), so the decoder -/// gets strict width/height limits too. 4096 px is far beyond what any -/// terminal cell grid can show. -const MAX_COVER_ART_DIMENSION: u32 = 4096; - -static CLIENT: OnceLock<reqwest::Client> = OnceLock::new(); - -/// The process-wide cover-art HTTP client, so keep-alive connections to the -/// art CDN are reused across track changes. Built fallibly *outside* -/// `get_or_init` so a TLS setup failure surfaces as a fetch error instead of -/// a panic; a lost build race just discards the extra client. -fn client() -> anyhow::Result<&'static reqwest::Client> { - if let Some(client) = CLIENT.get() { - return Ok(client); - } - let client = reqwest::Client::builder() - .connect_timeout(std::time::Duration::from_secs(10)) - .timeout(std::time::Duration::from_secs(30)) - .build() - .map_err(|e| anyhow!(e))?; - Ok(CLIENT.get_or_init(|| client)) -} - -/// What to fetch for the current track's cover art. Built once per track change -/// by the shared detector and handled off the `App` lock in the network layer. -#[derive(Clone, Debug)] -pub enum CoverArtRequest { - /// Download and decode an image from a URL (Spotify album art, YouTube - /// thumbnail, Subsonic getCoverArt, Qobuz album art). - Url(String), - /// Read the embedded cover picture out of a local audio file. `key` is the - /// track's `file://` URI (used as the cache identity); `path` is the resolved - /// filesystem path handed to the blocking tag reader. - #[cfg(feature = "local-files")] - LocalFile { - key: String, - path: std::path::PathBuf, - }, -} - -impl CoverArtRequest { - /// The cache-identity key for this request: the image URL, or the file URI. - /// Used to skip re-fetching art already held for the same track. - pub fn key(&self) -> &str { - match self { - CoverArtRequest::Url(url) => url, - #[cfg(feature = "local-files")] - CoverArtRequest::LocalFile { key, .. } => key, - } - } -} - -/// Status of the currently-playing track's cover art, mirroring `LyricsStatus`. -/// Drives the placeholder message shown when art can't be displayed, so a -/// missing image reads as an explicit state rather than silently showing -/// nothing (or, worse, the previous track's art). -#[derive(Clone, Copy, PartialEq, Debug, Default)] -pub enum CoverArtStatus { - /// Nothing is playing / no art has been requested yet. - #[default] - NotStarted, - /// A fetch/decode for the current track is in flight. - Loading, - /// Art for the current track is loaded and rendering. - Loaded, - /// The current source has no cover art to show (e.g. internet radio, or a - /// local file with no embedded picture). - Unavailable, - /// A fetch/decode was attempted for the current track but failed. - Failed, -} - -/// Downloads and decodes the cover-art image at `url`, off any external lock. -/// -/// This is a **free function** on purpose: it borrows no `App` state, so its -/// `.await` points can be reached with the `App` mutex fully dropped. The -/// network fetch and the (synchronous, CPU-bound) image decode are the -/// expensive parts and must never run while the `App` guard is held, or the -/// render loop — which locks the same mutex every frame — freezes for the -/// whole CDN round-trip (#142). -/// -/// The shared client carries explicit timeouts so a hung CDN cannot stall the -/// fetch forever even off-lock (`reqwest::get` uses a default client with -/// none), and the body is read through a bounded loop: `content_length()` is -/// only a hint and `bytes()` would buffer an arbitrarily large body, so the -/// [`MAX_COVER_ART_BYTES`] cap is enforced on the declared size first and -/// again while accumulating the actual bytes. -pub async fn fetch_and_decode(url: &str) -> anyhow::Result<image::DynamicImage> { - info!("getting new cover art image..."); - - let res = client()? - .get(url) - .send() - .await - .and_then(|r| r.error_for_status()); - let mut res = match res { - Ok(res) => res, - Err(e) => return Err(anyhow!(e)), - }; - - if let Some(declared) = res.content_length() { - if declared > MAX_COVER_ART_BYTES { - return Err(anyhow!( - "cover art response declares {declared} bytes (limit {MAX_COVER_ART_BYTES})" - )); - } - } - - // The declared size only pre-allocates; the cap above bounds it. - let mut file = Vec::with_capacity(res.content_length().unwrap_or(0) as usize); - while let Some(chunk) = res.chunk().await.map_err(|e| anyhow!(e))? { - if (file.len() + chunk.len()) as u64 > MAX_COVER_ART_BYTES { - return Err(anyhow!( - "cover art response exceeded the {MAX_COVER_ART_BYTES}-byte limit" - )); - } - file.extend_from_slice(&chunk); - } - debug!("finished reading response: {} bytes", file.len()); - - decode_bounded(&file) -} - -/// Decode a downloaded cover-art body with strict dimension limits, so a -/// small compressed file cannot expand into an oversized decoded allocation. -fn decode_bounded(bytes: &[u8]) -> anyhow::Result<image::DynamicImage> { - let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)) - .with_guessed_format() - .map_err(|e| anyhow!(e))?; - let mut limits = image::Limits::default(); - limits.max_image_width = Some(MAX_COVER_ART_DIMENSION); - limits.max_image_height = Some(MAX_COVER_ART_DIMENSION); - reader.limits(limits); - reader.decode().map_err(|e| anyhow!(e)) -} - -/// The decoded cover art for the current track, keyed by the request that -/// produced it, plus its load status. Lives on `App` so any frontend can read -/// it; the TUI caches its terminal protocols on [`Self::key`] and rebuilds -/// them only when the key changes. -#[derive(Default)] -pub struct CoverArtStore { - art: Option<(String, Arc<image::DynamicImage>)>, - /// Status of the current track's cover art, driving the placeholder message. - pub status: CoverArtStatus, -} - -impl CoverArtStore { - /// The cache-identity key of the stored image (the URL or file URI it was - /// decoded from), if any art is loaded. - pub fn key(&self) -> Option<&str> { - self.art.as_ref().map(|(key, _)| key.as_str()) - } - - /// The decoded image, if any art is loaded. Read by whichever frontend - /// renders it; a decode-only build (`art-decode` without `cover-art`) - /// fetches art solely for the adaptive theme and has no reader. - #[cfg_attr(not(feature = "cover-art"), allow(dead_code))] - pub fn image(&self) -> Option<&image::DynamicImage> { - self.art.as_ref().map(|(_, image)| image.as_ref()) - } - - #[cfg(feature = "cover-art")] - pub fn image_shared(&self) -> Option<Arc<image::DynamicImage>> { - self.art.as_ref().map(|(_, image)| Arc::clone(image)) - } - - pub fn available(&self) -> bool { - self.art.is_some() - } - - /// Store an already-decoded image under its cache key, replacing whatever - /// was held. Cheap: the slow fetch/decode happens in [`fetch_and_decode`], - /// off the `App` lock. - pub fn store_decoded(&mut self, key: String, image: image::DynamicImage) { - info!("got new cover art: {key}"); - self.art = Some((key, Arc::new(image))); - } - - /// Drop any stored cover art so the pane renders nothing. Used when - /// switching to a track/source with no art, or after a failed fetch, so - /// stale art from the previous track can never linger on screen. - pub fn clear(&mut self) { - self.art = None; - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::{Read, Write}; - - /// Serve one hand-written HTTP response on a real loopback listener, so the - /// fetch path is exercised without any mock layer (house HTTP-test pattern). - fn one_shot_server( - response: impl FnOnce(&mut std::net::TcpStream) + Send + 'static, - ) -> (std::net::SocketAddr, std::thread::JoinHandle<()>) { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let addr = listener.local_addr().unwrap(); - let handle = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().unwrap(); - // Read (some of) the request so the client sees a well-formed exchange. - let mut buf = [0u8; 1024]; - let _ = stream.read(&mut buf); - response(&mut stream); - }); - (addr, handle) - } - - #[tokio::test] - async fn oversized_declared_cover_art_body_is_rejected() { - let (addr, server) = one_shot_server(|stream| { - let _ = stream.write_all( - b"HTTP/1.1 200 OK\r\nContent-Length: 999999999\r\nContent-Type: image/jpeg\r\n\r\n", - ); - }); - - let err = fetch_and_decode(&format!("http://{addr}/art.jpg")) - .await - .unwrap_err(); - assert!(err.to_string().contains("declares"), "got: {err}"); - server.join().unwrap(); - } - - #[tokio::test] - async fn cover_art_body_exceeding_the_cap_is_rejected_mid_read() { - let (addr, server) = one_shot_server(|stream| { - // No Content-Length, close-delimited body: the declared-size check - // cannot fire, so the accumulation cap has to. Write errors are - // expected once the client bails mid-body. - let _ = stream - .write_all(b"HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Type: image/jpeg\r\n\r\n"); - let chunk = vec![0u8; 1024 * 1024]; - for _ in 0..17 { - if stream.write_all(&chunk).is_err() { - break; - } - } - }); - - let err = fetch_and_decode(&format!("http://{addr}/art.jpg")) - .await - .unwrap_err(); - assert!(err.to_string().contains("exceeded"), "got: {err}"); - let _ = server.join(); - } - - #[test] - fn cover_art_dimensions_beyond_the_limit_are_rejected() { - // A blank one-row PNG wider than the cap compresses to a tiny body, so it - // passes the byte cap and must be stopped by the decoder's limits. - let mut png = Vec::new(); - image::DynamicImage::new_rgb8(MAX_COVER_ART_DIMENSION + 1, 1) - .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) - .unwrap(); - assert!((png.len() as u64) < MAX_COVER_ART_BYTES); - - assert!(decode_bounded(&png).is_err()); - // The same pipeline still decodes an in-bounds image. - let mut ok_png = Vec::new(); - image::DynamicImage::new_rgb8(2, 2) - .write_to( - &mut std::io::Cursor::new(&mut ok_png), - image::ImageFormat::Png, - ) - .unwrap(); - assert!(decode_bounded(&ok_png).is_ok()); - } - - #[test] - fn request_key_is_the_url() { - let request = CoverArtRequest::Url("https://img.example/cover.jpg".to_string()); - assert_eq!(request.key(), "https://img.example/cover.jpg"); - } - - #[test] - fn store_replaces_and_clear_drops_the_held_art() { - let mut store = CoverArtStore::default(); - assert!(!store.available()); - assert_eq!(store.key(), None); - - store.store_decoded("a".to_string(), image::DynamicImage::new_rgb8(1, 1)); - assert!(store.available()); - assert_eq!(store.key(), Some("a")); - assert!(store.image().is_some()); - - store.store_decoded("b".to_string(), image::DynamicImage::new_rgb8(2, 2)); - assert_eq!(store.key(), Some("b")); - - store.clear(); - assert!(!store.available()); - assert_eq!(store.key(), None); - assert!(store.image().is_none()); - } -} diff --git a/src/core/auth.rs b/src/core/auth.rs deleted file mode 100644 index 83f93f7e..00000000 --- a/src/core/auth.rs +++ /dev/null @@ -1,1151 +0,0 @@ -use crate::core::config::{ClientConfig, ConfigPaths, NCSPOT_CLIENT_ID}; -use crate::core::onboarding::Onboarding; -use crate::infra::network::requests; -use crate::infra::redirect_uri::{bind_callback_listener, serve_spotify_callback}; -use anyhow::{anyhow, Result}; -use log::{info, warn}; -use rspotify::{ - prelude::*, - {AuthCodePkceSpotify, Config, Credentials, OAuth, Token, TokenCallback}, -}; -use std::{ - fs, - path::{Path, PathBuf}, - sync::{Arc, OnceLock}, - time::{Duration, SystemTime}, -}; -use tokio::sync::Mutex; - -pub const TOKEN_REFRESH_MARGIN: Duration = Duration::from_secs(60); -static TOKEN_REFRESH_LOCK: OnceLock<Mutex<()>> = OnceLock::new(); - -const SCOPES: [&str; 16] = [ - "playlist-read-collaborative", - "playlist-read-private", - "playlist-modify-private", - "playlist-modify-public", - "user-follow-read", - "user-follow-modify", - "user-library-modify", - "user-library-read", - "user-modify-playback-state", - "user-read-currently-playing", - "user-read-playback-state", - "user-read-playback-position", - "user-read-private", - "user-read-recently-played", - "user-top-read", - "streaming", -]; - -pub struct AuthenticatedClient { - pub spotify: AuthCodePkceSpotify, - pub token_cache_path: PathBuf, - #[cfg(feature = "streaming")] - pub redirect_uri: String, - /// The `/me` response captured while validating the cached token, so later - /// startup steps (the native-streaming account probe) can reuse it instead - /// of paying a second round trip. `None` when validation didn't run (fresh - /// interactive login). - #[cfg_attr(not(feature = "streaming"), allow(dead_code))] - pub me: Option<rspotify::model::PrivateUser>, - /// What to tell the user about which Spotify app this session authenticated - /// as, when that is not what they would assume. `None` when there is nothing - /// surprising to report. - pub client_id_notice: Option<ClientIdNotice>, -} - -/// Which Spotify app a session ended up on, when the user would likely assume -/// otherwise. An issue #395 reporter believed they were running their own app -/// ("Option 2") while actually on the shared ncspot one, which cost real -/// diagnostic time — and nothing in the app ever said so. -#[derive(Debug, Clone)] -pub enum ClientIdNotice { - /// An earlier candidate had no usable session, so authentication moved on to - /// the next one. - FellBack { - from_client_id: String, - to_client_id: String, - }, -} - -// Manual token cache helpers since rspotify's built-in caching isn't working. -fn preserve_refresh_token_from_file(token: &mut Token, path: &Path) { - if token.refresh_token.is_none() && path.exists() { - if let Ok(old_json) = fs::read_to_string(path) { - if let Ok(old_token) = serde_json::from_str::<Token>(&old_json) { - token.refresh_token = old_token.refresh_token; - } - } - } -} - -/// Write `contents` to `path`, restricting permissions to owner-only where -/// the platform supports it (0o600 on Unix). Shared by any caller that -/// persists sensitive data to disk (the Spotify token cache, and — via -/// `user_config::save_config` — the app config file, which holds the -/// Subsonic password and party sync token in cleartext). -#[cfg(unix)] -pub(crate) fn write_private_file(path: &Path, contents: &[u8]) -> std::io::Result<()> { - use std::io::Write; - use std::os::unix::fs::OpenOptionsExt; - std::fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .mode(0o600) - .open(path)? - .write_all(contents) -} - -#[cfg(not(unix))] -pub(crate) fn write_private_file(path: &Path, contents: &[u8]) -> std::io::Result<()> { - std::fs::write(path, contents) -} - -/// Atomically replace `path` with `contents`, written privately (0o600 on Unix -/// via [`write_private_file`]). -/// -/// The bytes go to a process-unique temporary sibling of the resolved target -/// first, then a rename swaps it over that target, so a symlinked `path` keeps -/// its link. The unique suffix (pid plus a per-process counter) keeps two -/// concurrent writers from sharing one temp file and renaming each other's -/// half-written bytes into place. On a failed write or rename the temp file is -/// removed best-effort so aborted writes do not accumulate. -pub(crate) fn write_private_file_atomic(path: &Path, contents: &[u8]) -> Result<()> { - // Follow the link chain by hand: `canonicalize` fails on a dangling link and - // would let the rename replace the link itself. The bound stops a link loop. - // `join` keeps an absolute link and resolves a relative one against the - // link's directory. - let mut target = path.to_path_buf(); - for _ in 0..40 { - let Ok(link) = fs::read_link(&target) else { - break; - }; - target = target.parent().unwrap_or(Path::new("")).join(link); - } - if fs::read_link(&target).is_ok() { - return Err(anyhow!( - "{} is a symlink loop or chain too long", - path.display() - )); - } - let tmp = unique_temp_path(&target); - // Clean up on either failure (write or rename). Each attempt uses a fresh - // unique name, so a leaked temp from a partial write would otherwise persist. - if let Err(error) = create_private_file_exclusive(&tmp) - .and_then(|mut file| std::io::Write::write_all(&mut file, contents)) - .and_then(|()| std::fs::rename(&tmp, &target)) - { - let _ = std::fs::remove_file(&tmp); - return Err(error.into()); - } - Ok(()) -} - -/// Create a new private file; an existing path, a symlink included, is refused. -fn create_private_file_exclusive(path: &Path) -> std::io::Result<std::fs::File> { - let mut options = std::fs::OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - options.open(path) -} - -/// A temporary sibling of `path` that no other process (or concurrent save in -/// this one) will pick, so atomic writes never collide on a shared temp file. -fn unique_temp_path(path: &Path) -> std::path::PathBuf { - use std::sync::atomic::{AtomicU64, Ordering}; - static COUNTER: AtomicU64 = AtomicU64::new(0); - let seq = COUNTER.fetch_add(1, Ordering::Relaxed); - let mut name = path - .file_name() - .map(|name| name.to_os_string()) - .unwrap_or_default(); - name.push(format!(".{}.{}.tmp", std::process::id(), seq)); - path.with_file_name(name) -} - -fn persist_token_to_file(mut token: Token, path: &Path) -> Result<()> { - preserve_refresh_token_from_file(&mut token, path); - let token_json = serde_json::to_string_pretty(&token)?; - write_private_file(path, token_json.as_bytes()).map_err(|e| anyhow!(e))?; - info!("token cached to {}", path.display()); - Ok(()) -} - -pub async fn save_token_to_file(spotify: &AuthCodePkceSpotify, path: &Path) -> Result<()> { - let mut token_lock = spotify.token.lock().await.expect("Failed to lock token"); - if let Some(ref mut token) = *token_lock { - preserve_refresh_token_from_file(token, path); - persist_token_to_file(token.clone(), path)?; - } - Ok(()) -} - -async fn restore_refresh_token_from_file(spotify: &AuthCodePkceSpotify, path: &Path) { - let mut token_lock = spotify.token.lock().await.expect("Failed to lock token"); - if let Some(ref mut token) = *token_lock { - preserve_refresh_token_from_file(token, path); - } -} - -pub async fn load_token_from_file(spotify: &AuthCodePkceSpotify, path: &PathBuf) -> Result<bool> { - if !path.exists() { - return Ok(false); - } - - let token_json = fs::read_to_string(path)?; - let token: Token = serde_json::from_str(&token_json)?; - - let mut token_lock = spotify.token.lock().await.expect("Failed to lock token"); - *token_lock = Some(token); - drop(token_lock); - - info!("authentication token loaded from cache"); - Ok(true) -} - -pub fn token_cache_path_for_client(base_path: &Path, client_id: &str) -> PathBuf { - let suffix = &client_id[..8.min(client_id.len())]; - let stem = base_path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("spotify_token_cache"); - let file_name = format!("{}_{}.json", stem, suffix); - base_path.with_file_name(file_name) -} - -fn redirect_uri_for_client(client_config: &ClientConfig, client_id: &str) -> String { - if client_id == NCSPOT_CLIENT_ID { - "http://127.0.0.1:8989/login".to_string() - } else { - client_config.get_redirect_uri() - } -} - -fn auth_port_from_redirect_uri(redirect_uri: &str) -> u16 { - redirect_uri - .split(':') - .nth(2) - .and_then(|v| v.split('/').next()) - .and_then(|v| v.parse::<u16>().ok()) - .unwrap_or(8888) -} - -/// Whether a failed `/me` means Spotify rejected the token itself, so the -/// cache is stale and the user has to log in again. Anything else (a rate -/// limit, an outage, no network) says nothing about the token. -fn spotify_rejected_token(e: &anyhow::Error) -> bool { - if let Some(api) = e.downcast_ref::<requests::SpotifyApiError>() { - return matches!(api.status.as_u16(), 400 | 401); - } - // No response to classify: rspotify's own wording for a refused refresh. - let text = e.to_string().to_lowercase(); - text.contains("401") - || text.contains("unauthorized") - || text.contains("status code 400") - || text.contains("invalid_grant") - || text.contains("token expired") -} - -fn build_pkce_spotify_client( - client_id: &str, - redirect_uri: String, - cache_path: PathBuf, -) -> AuthCodePkceSpotify { - let creds = Credentials::new_pkce(client_id); - let oauth = OAuth { - redirect_uri, - scopes: SCOPES.iter().map(|s| s.to_string()).collect(), - ..Default::default() - }; - let token_callback_path = cache_path.clone(); - let token_callback = TokenCallback(Box::new(move |token| { - if let Err(e) = persist_token_to_file(token, &token_callback_path) { - warn!( - "failed to persist refreshed token to {}: {}", - token_callback_path.display(), - e - ); - } - Ok(()) - })); - let config = Config { - cache_path, - token_refreshing: false, - token_callback_fn: Arc::new(Some(token_callback)), - ..Default::default() - }; - AuthCodePkceSpotify::with_config(creds, oauth, config) -} - -pub fn should_refresh_token_at(expiry: SystemTime, now: SystemTime) -> bool { - now - .checked_add(TOKEN_REFRESH_MARGIN) - .map(|refresh_deadline| refresh_deadline >= expiry) - .unwrap_or(true) -} - -fn expiry_from_token(token: &Token) -> SystemTime { - if let Some(expires_at) = token.expires_at { - let unix_secs = expires_at.timestamp().max(0) as u64; - SystemTime::UNIX_EPOCH + Duration::from_secs(unix_secs) - } else { - let expires_in_secs = token.expires_in.num_seconds().max(0) as u64; - SystemTime::now() - .checked_add(Duration::from_secs(expires_in_secs)) - .unwrap_or_else(SystemTime::now) - } -} - -async fn token_state(spotify: &AuthCodePkceSpotify) -> Result<(SystemTime, bool)> { - let token_lock = spotify.token.lock().await.expect("Failed to lock token"); - let token = token_lock - .as_ref() - .ok_or_else(|| anyhow!("Authentication failed: no valid token available"))?; - - Ok((expiry_from_token(token), token.refresh_token.is_some())) -} - -pub async fn token_needs_refresh(spotify: &AuthCodePkceSpotify) -> Result<bool> { - let (expiry, has_refresh_token) = token_state(spotify).await?; - Ok(has_refresh_token && should_refresh_token_at(expiry, SystemTime::now())) -} - -pub async fn refresh_token_and_cache( - spotify: &AuthCodePkceSpotify, - token_cache_path: &Path, - force: bool, -) -> Result<SystemTime> { - let refresh_lock = TOKEN_REFRESH_LOCK.get_or_init(|| Mutex::new(())); - let _guard = refresh_lock.lock().await; - - restore_refresh_token_from_file(spotify, token_cache_path).await; - let (current_expiry, has_refresh_token) = token_state(spotify).await?; - if !force && !should_refresh_token_at(current_expiry, SystemTime::now()) { - return Ok(current_expiry); - } - - if !has_refresh_token { - return Err(anyhow!( - "Authentication refresh failed: no refresh token available" - )); - } - - spotify.refresh_token().await?; - restore_refresh_token_from_file(spotify, token_cache_path).await; - if spotify.config.token_callback_fn.as_ref().is_none() { - save_token_to_file(spotify, token_cache_path).await?; - } - let expiry = token_expiry(spotify).await?; - info!("refreshed token cached to {}", token_cache_path.display()); - Ok(expiry) -} - -/// Returns the `/me` response when the cached token was validated with one, -/// so callers can reuse it instead of re-fetching (`None` after a fresh -/// interactive login, which skips the validation call). -async fn ensure_auth_token( - spotify: &mut AuthCodePkceSpotify, - token_cache_path: &PathBuf, - auth_port: u16, - interactive: bool, - onboarding: &dyn Onboarding, -) -> Result<Option<rspotify::model::PrivateUser>> { - let mut needs_auth = match load_token_from_file(spotify, token_cache_path).await { - Ok(true) => false, - Ok(false) => { - info!("no cached token found, authentication required"); - true - } - Err(e) => { - info!("failed to read token cache: {}", e); - true - } - }; - - if !needs_auth && token_needs_refresh(spotify).await.unwrap_or(false) { - match refresh_token_and_cache(spotify, token_cache_path, false).await { - Ok(_) => {} - Err(e) => { - info!("cached authentication token refresh failed: {}", e); - if token_cache_path.exists() { - if let Err(remove_err) = fs::remove_file(token_cache_path) { - info!( - "failed to remove stale token cache {}: {}", - token_cache_path.display(), - remove_err - ); - } - } - needs_auth = true; - } - } - } - - let mut validated_me = None; - if !needs_auth { - match requests::spotify_get_typed_before_app::<rspotify::model::PrivateUser>( - spotify, - "me", - token_cache_path, - ) - .await - { - Ok(user) => validated_me = Some(user), - Err(e) if spotify_rejected_token(&e) => { - info!("cached authentication token is invalid, re-authentication required"); - if token_cache_path.exists() { - if let Err(remove_err) = fs::remove_file(token_cache_path) { - info!( - "failed to remove stale token cache {}: {}", - token_cache_path.display(), - remove_err - ); - } - } - needs_auth = true; - } - Err(e) => { - // A rate limit that outlasted the retries, an outage, or no network: - // none of these rejected the token, so the session keeps it (#504). - warn!("could not verify the cached token against /me, keeping it: {e:#}"); - } - } - } - - if needs_auth { - // Silent load (free-source launch, or a returning user whose cached token is - // present but stale): never open a browser or block on the callback server. - // Interactive Spotify login is either the pre-TUI wizard path or the in-TUI - // `BeginSpotifyLogin` flow. - if !interactive { - return Err(anyhow!( - "Spotify authentication required but no valid cached token is available" - )); - } - - info!("starting spotify authentication flow on port {}", auth_port); - let auth_url = spotify.get_authorize_url(None)?; - - // Async server, same as the in-TUI login path: the blocking variant used - // to park a tokio worker thread in a std accept() loop with no timeout, - // which could hang the whole login on startup (#364). Bound before the - // browser opens, see `bind_callback_listener`; a failed bind goes straight - // to manual authentication without opening one. - let callback_url = match bind_callback_listener(auth_port).await { - Ok(listener) => { - onboarding.info("\nAttempting to open this URL in your browser:"); - onboarding.info(&format!("{}\n", auth_url)); - if let Err(e) = open::that_detached(&auth_url) { - onboarding.info(&format!("Failed to open browser automatically: {}", e)); - onboarding.info("Please manually open the URL above in your browser."); - } - onboarding.info(&format!( - "Waiting for authorization callback on http://127.0.0.1:{}...\n", - auth_port - )); - // Bounded like the in-TUI login: a Redirect URI missing from the - // app's dashboard entry means the callback never comes. - match tokio::time::timeout( - std::time::Duration::from_secs(180), - serve_spotify_callback(listener), - ) - .await - { - Ok(result) => result, - Err(_) => { - onboarding.info( - "No authorization callback within 3 minutes. Check the Redirect URI in the app's Spotify dashboard entry.", - ); - Err(()) - } - } - } - Err(()) => Err(()), - }; - - match callback_url { - Ok(url) => { - if let Some(code) = spotify.parse_response_code(&url) { - info!("authorization code received, requesting access token"); - spotify.request_token(&code).await?; - save_token_to_file(spotify, token_cache_path).await?; - info!("successfully authenticated with spotify"); - } else { - return Err(anyhow!( - "Failed to parse authorization code from callback URL" - )); - } - } - Err(()) => { - info!("redirect uri web server failed, using manual authentication"); - onboarding.info("Starting webserver failed. Continuing with manual authentication"); - onboarding.info(&format!( - "Please open this URL in your browser: {}", - auth_url - )); - let input = onboarding.prompt_line("Enter the URL you were redirected to: \n")?; - if let Some(code) = spotify.parse_response_code(&input) { - info!("authorization code received from manual input, requesting access token"); - spotify.request_token(&code).await?; - save_token_to_file(spotify, token_cache_path).await?; - info!("successfully authenticated with spotify"); - } else { - return Err(anyhow!("Failed to parse authorization code from input URL")); - } - } - } - } - - Ok(validated_me) -} - -/// Authenticate the Spotify client, trying the primary client id then the -/// optional fallback. When `interactive` is false, candidates that lack a valid -/// cached token fail fast (no browser, no blocking callback server) so a -/// free-source launch or a returning user with a stale token degrades to "no -/// Spotify session" instead of forcing an OAuth flow. -pub async fn authenticate_with_fallback( - client_config: &mut ClientConfig, - config_paths: &ConfigPaths, - onboarding: &dyn Onboarding, -) -> Result<AuthenticatedClient> { - authenticate_candidates(client_config, config_paths, true, onboarding).await -} - -/// Load a cached token (with refresh); fails instead of opening a browser. -pub async fn authenticate_cached( - client_config: &mut ClientConfig, - config_paths: &ConfigPaths, - onboarding: &dyn Onboarding, -) -> Result<AuthenticatedClient> { - authenticate_candidates(client_config, config_paths, false, onboarding).await -} - -/// Best-effort silent Spotify load for a free-source launch: returns `Some` only -/// when a cached token is present and usable, `None` otherwise (never prompts). -pub async fn try_load_spotify_silently( - client_config: &mut ClientConfig, - config_paths: &ConfigPaths, - onboarding: &dyn Onboarding, -) -> Option<AuthenticatedClient> { - authenticate_cached(client_config, config_paths, onboarding) - .await - .ok() -} - -/// Decide what (if anything) to tell the user about the app they just -/// authenticated as. The primary `client_id` is what the user configured -/// (`ClientConfig::prefer_own_app` puts their own app first), so only a -/// fall-through to a later candidate is worth a word. -fn client_id_notice_for( - index: usize, - requested_client_id: &str, - winning_client_id: &str, -) -> Option<ClientIdNotice> { - (index > 0).then(|| ClientIdNotice::FellBack { - from_client_id: requested_client_id.to_string(), - to_client_id: winning_client_id.to_string(), - }) -} - -async fn authenticate_candidates( - client_config: &mut ClientConfig, - config_paths: &ConfigPaths, - interactive: bool, - onboarding: &dyn Onboarding, -) -> Result<AuthenticatedClient> { - let mut client_candidates = vec![client_config.client_id.clone()]; - if let Some(fallback_id) = client_config.fallback_client_id.clone() { - if fallback_id != client_config.client_id { - client_candidates.push(fallback_id); - } - } - - // Captured up front: the loop below overwrites `client_config.client_id` with - // whichever candidate won, so this is the only record of what was requested. - let requested_client_id = client_config.client_id.clone(); - - let mut spotify = None; - #[cfg(feature = "streaming")] - let mut selected_redirect_uri = client_config.get_redirect_uri(); - let mut last_auth_error = None; - let mut validated_me = None; - let mut client_id_notice = None; - - for (index, client_id) in client_candidates.iter().enumerate() { - let token_cache_path = token_cache_path_for_client(&config_paths.token_cache_path, client_id); - let redirect_uri = redirect_uri_for_client(client_config, client_id); - let auth_port = auth_port_from_redirect_uri(&redirect_uri); - let mut candidate = - build_pkce_spotify_client(client_id, redirect_uri.clone(), token_cache_path.clone()); - - let auth_result = ensure_auth_token( - &mut candidate, - &token_cache_path, - auth_port, - interactive, - onboarding, - ) - .await; - - match auth_result { - Ok(me) => { - validated_me = me; - client_id_notice = client_id_notice_for(index, &requested_client_id, client_id); - if *client_id == NCSPOT_CLIENT_ID { - info!( - "Using the shared ncspot client ID; its Spotify rate limit is shared by every user. Run `spotatui --reconfigure-auth` and choose 2 to use your own app." - ); - } else { - info!("Using client ID {} (your own app)", client_id); - } - client_config.client_id = client_id.clone(); - #[cfg(feature = "streaming")] - { - selected_redirect_uri = redirect_uri; - } - spotify = Some(candidate); - break; - } - Err(e) => { - last_auth_error = Some(e); - if index + 1 < client_candidates.len() { - info!( - "Authentication with client {} failed, trying fallback client...", - client_id - ); - continue; - } - } - } - } - - let spotify = - spotify.ok_or_else(|| last_auth_error.unwrap_or_else(|| anyhow!("Authentication failed")))?; - let token_cache_path = - token_cache_path_for_client(&config_paths.token_cache_path, &client_config.client_id); - - Ok(AuthenticatedClient { - spotify, - token_cache_path, - #[cfg(feature = "streaming")] - redirect_uri: selected_redirect_uri, - me: validated_me, - client_id_notice, - }) -} - -/// Prepare an in-TUI Spotify login: build a fresh PKCE client and return it with -/// the authorize URL to open, the callback port to listen on, and the token cache -/// path to persist to. PKCE stores the `code_verifier` inside this client -/// instance, so the caller MUST keep this exact client and later run -/// `request_token` on it — rebuilding the client would break the exchange with -/// `invalid_grant`. -pub fn prepare_interactive_login( - client_config: &ClientConfig, - config_paths: &ConfigPaths, -) -> Result<(AuthCodePkceSpotify, String, u16, PathBuf)> { - let client_id = client_config.client_id.clone(); - let token_cache_path = token_cache_path_for_client(&config_paths.token_cache_path, &client_id); - let redirect_uri = redirect_uri_for_client(client_config, &client_id); - let auth_port = auth_port_from_redirect_uri(&redirect_uri); - let mut spotify = build_pkce_spotify_client(&client_id, redirect_uri, token_cache_path.clone()); - let authorize_url = spotify.get_authorize_url(None)?; - Ok((spotify, authorize_url, auth_port, token_cache_path)) -} - -pub async fn token_expiry(spotify: &AuthCodePkceSpotify) -> Result<SystemTime> { - let token_lock = spotify.token.lock().await.expect("Failed to lock token"); - let token_expiry = if let Some(ref token) = *token_lock { - expiry_from_token(token) - } else { - return Err(anyhow!("Authentication failed: no valid token available")); - }; - - Ok(token_expiry) -} - -#[cfg(test)] -mod tests { - use super::*; - use chrono::{TimeDelta, Utc}; - #[cfg(unix)] - use std::os::unix::fs::symlink as symlink_file; - #[cfg(windows)] - use std::os::windows::fs::symlink_file; - - const PERSONAL_CLIENT_ID: &str = "0123456789abcdef0123456789abcdef"; - - /// Option 1: the user only ever asked for the shared app. Nothing surprising. - #[test] - fn shared_client_id_alone_is_not_reported() { - assert!(client_id_notice_for(0, NCSPOT_CLIENT_ID, NCSPOT_CLIENT_ID).is_none()); - } - - /// A personal id as primary with its own session: the user is on exactly - /// the app they configured. - #[test] - fn personal_client_id_as_primary_is_not_reported() { - assert!(client_id_notice_for(0, PERSONAL_CLIENT_ID, PERSONAL_CLIENT_ID).is_none()); - } - - /// The user's own app had no session yet, so the shared app took over. - /// Reported as a fallback, naming both apps. - #[test] - fn falling_through_to_the_next_candidate_is_reported_as_a_fallback() { - let notice = client_id_notice_for(1, PERSONAL_CLIENT_ID, NCSPOT_CLIENT_ID); - assert!(matches!( - notice, - Some(ClientIdNotice::FellBack { from_client_id, to_client_id }) - if from_client_id == PERSONAL_CLIENT_ID && to_client_id == NCSPOT_CLIENT_ID - )); - } - - fn create_test_token(refresh_token: Option<String>) -> Token { - Token { - access_token: "test_access_token".to_string(), - refresh_token, - expires_in: TimeDelta::seconds(3600), - expires_at: Some(Utc::now() + TimeDelta::seconds(3600)), - scopes: Default::default(), - } - } - - async fn create_test_spotify(token: Token) -> AuthCodePkceSpotify { - let creds = Credentials::new("test_client_id", "test_client_secret"); - let oauth = OAuth { - redirect_uri: "http://localhost:8888/callback".to_string(), - scopes: Default::default(), - ..Default::default() - }; - let config = Config::default(); - let spotify = AuthCodePkceSpotify::with_config(creds, oauth, config); - - let mut token_lock = spotify.token.lock().await.expect("Failed to lock token"); - *token_lock = Some(token); - drop(token_lock); - - spotify - } - - fn create_temp_path() -> PathBuf { - let mut path = std::env::temp_dir(); - path.push(format!( - "spotatui_test_token_{}.json", - rand::random::<u32>() - )); - path - } - - #[tokio::test] - async fn test_save_token_preserves_refresh_token_when_missing() { - let path = create_temp_path(); - - let initial_token = create_test_token(Some("initial_refresh_token".to_string())); - let spotify1 = create_test_spotify(initial_token).await; - save_token_to_file(&spotify1, &path).await.unwrap(); - - let refreshed_token = create_test_token(None); - let spotify2 = create_test_spotify(refreshed_token).await; - save_token_to_file(&spotify2, &path).await.unwrap(); - - let saved_json = fs::read_to_string(&path).unwrap(); - let saved_token: Token = serde_json::from_str(&saved_json).unwrap(); - assert_eq!( - saved_token.refresh_token, - Some("initial_refresh_token".to_string()) - ); - assert_eq!(saved_token.access_token, "test_access_token"); - - let _ = fs::remove_file(&path); - } - - #[tokio::test] - async fn test_save_token_uses_new_refresh_token_when_present() { - let path = create_temp_path(); - - let initial_token = create_test_token(Some("initial_refresh_token".to_string())); - let spotify1 = create_test_spotify(initial_token).await; - save_token_to_file(&spotify1, &path).await.unwrap(); - - let new_token = create_test_token(Some("new_refresh_token".to_string())); - let spotify2 = create_test_spotify(new_token).await; - save_token_to_file(&spotify2, &path).await.unwrap(); - - let saved_json = fs::read_to_string(&path).unwrap(); - let saved_token: Token = serde_json::from_str(&saved_json).unwrap(); - assert_eq!( - saved_token.refresh_token, - Some("new_refresh_token".to_string()) - ); - - let _ = fs::remove_file(&path); - } - - #[tokio::test] - async fn test_save_token_works_without_existing_file() { - let path = create_temp_path(); - - let token = create_test_token(None); - let spotify = create_test_spotify(token).await; - save_token_to_file(&spotify, &path).await.unwrap(); - - let saved_json = fs::read_to_string(&path).unwrap(); - let saved_token: Token = serde_json::from_str(&saved_json).unwrap(); - assert_eq!(saved_token.refresh_token, None); - assert_eq!(saved_token.access_token, "test_access_token"); - - let _ = fs::remove_file(&path); - } - - #[tokio::test] - async fn test_expired_token_detection_with_refresh_token() { - let expired_token = Token { - access_token: "expired_access_token".to_string(), - refresh_token: Some("valid_refresh_token".to_string()), - expires_in: TimeDelta::seconds(3600), - expires_at: Some(Utc::now() - TimeDelta::seconds(3600)), - scopes: Default::default(), - }; - - let spotify = create_test_spotify(expired_token).await; - - let should_refresh = { - let token_lock = spotify.token.lock().await.expect("Failed to lock token"); - if let Some(ref token) = *token_lock { - token - .expires_at - .map(|exp| exp < Utc::now()) - .unwrap_or(false) - && token.refresh_token.is_some() - } else { - false - } - }; - - assert!( - should_refresh, - "Expired token with refresh_token should be detected as needing refresh" - ); - } - - #[tokio::test] - async fn test_expired_token_without_refresh_token_not_refreshable() { - let expired_token = Token { - access_token: "expired_access_token".to_string(), - refresh_token: None, - expires_in: TimeDelta::seconds(3600), - expires_at: Some(Utc::now() - TimeDelta::seconds(3600)), - scopes: Default::default(), - }; - - let spotify = create_test_spotify(expired_token).await; - - let should_refresh = { - let token_lock = spotify.token.lock().await.expect("Failed to lock token"); - if let Some(ref token) = *token_lock { - token - .expires_at - .map(|exp| exp < Utc::now()) - .unwrap_or(false) - && token.refresh_token.is_some() - } else { - false - } - }; - - assert!( - !should_refresh, - "Expired token without refresh_token should NOT be refreshable" - ); - } - - #[tokio::test] - async fn test_valid_token_does_not_need_refresh() { - let valid_token = Token { - access_token: "valid_access_token".to_string(), - refresh_token: Some("refresh_token".to_string()), - expires_in: TimeDelta::seconds(3600), - expires_at: Some(Utc::now() + TimeDelta::seconds(3600)), - scopes: Default::default(), - }; - - let spotify = create_test_spotify(valid_token).await; - - let should_refresh = { - let token_lock = spotify.token.lock().await.expect("Failed to lock token"); - if let Some(ref token) = *token_lock { - token - .expires_at - .map(|exp| exp < Utc::now()) - .unwrap_or(false) - && token.refresh_token.is_some() - } else { - false - } - }; - - assert!( - !should_refresh, - "Valid non-expired token should not need refresh" - ); - } - - #[test] - fn test_token_refresh_deadline_uses_safety_margin() { - let now = SystemTime::now(); - let outside_margin = now + TOKEN_REFRESH_MARGIN + Duration::from_secs(30); - let inside_margin = now + TOKEN_REFRESH_MARGIN - Duration::from_secs(1); - - assert!(!should_refresh_token_at(outside_margin, now)); - assert!(should_refresh_token_at(inside_margin, now)); - } - - #[tokio::test] - async fn test_token_needs_refresh_inside_margin() { - let expiring_token = Token { - access_token: "expiring_access_token".to_string(), - refresh_token: Some("refresh_token".to_string()), - expires_in: TimeDelta::seconds(30), - expires_at: Some(Utc::now() + TimeDelta::seconds(30)), - scopes: Default::default(), - }; - - let spotify = create_test_spotify(expiring_token).await; - - assert!( - token_needs_refresh(&spotify).await.unwrap(), - "Token inside the refresh margin should refresh before it expires" - ); - } - - #[test] - fn test_pkce_client_disables_rspotify_auto_refresh_with_cache_callback() { - let spotify = build_pkce_spotify_client( - "test_client_id", - "http://localhost:8888/callback".to_string(), - create_temp_path(), - ); - - assert!( - !spotify.config.token_refreshing, - "authenticated requests should use spotatui's shared refresh-and-retry path" - ); - assert!( - spotify.config.token_callback_fn.as_ref().is_some(), - "rspotify-driven refreshes must still persist through spotatui's cache path" - ); - } - - #[test] - fn test_rspotify_token_callback_preserves_cached_refresh_token() { - let path = create_temp_path(); - let old_token = create_test_token(Some("cached_refresh_token".to_string())); - fs::write(&path, serde_json::to_string_pretty(&old_token).unwrap()).unwrap(); - - let spotify = build_pkce_spotify_client( - "test_client_id", - "http://localhost:8888/callback".to_string(), - path.clone(), - ); - let callback = spotify.config.token_callback_fn.as_ref().as_ref().unwrap(); - let mut refreshed_token = create_test_token(None); - refreshed_token.access_token = "callback_access_token".to_string(); - - callback.0(refreshed_token).unwrap(); - - let saved_json = fs::read_to_string(&path).unwrap(); - let saved_token: Token = serde_json::from_str(&saved_json).unwrap(); - assert_eq!(saved_token.access_token, "callback_access_token"); - assert_eq!( - saved_token.refresh_token, - Some("cached_refresh_token".to_string()) - ); - - let _ = fs::remove_file(&path); - } - - #[tokio::test] - async fn test_token_without_expires_at_does_not_need_refresh() { - let token = Token { - access_token: "access_token".to_string(), - refresh_token: Some("refresh_token".to_string()), - expires_in: TimeDelta::seconds(3600), - expires_at: None, - scopes: Default::default(), - }; - - let spotify = create_test_spotify(token).await; - - let should_refresh = { - let token_lock = spotify.token.lock().await.expect("Failed to lock token"); - if let Some(ref token) = *token_lock { - token - .expires_at - .map(|exp| exp < Utc::now()) - .unwrap_or(false) - && token.refresh_token.is_some() - } else { - false - } - }; - - assert!( - !should_refresh, - "Token without expires_at should not trigger refresh" - ); - } - - fn api_error(status: u16, body: &str, detail: Option<&str>) -> anyhow::Error { - requests::SpotifyApiError { - status: reqwest::StatusCode::from_u16(status).unwrap(), - body: body.to_string(), - detail: detail.map(str::to_string), - } - .into() - } - - /// The reporter's exact line, and the paced helper's shape of the same 429 - /// (#504). A 5xx whose body happens to say "unauthorized" is an outage, not - /// a rejected token. - #[test] - fn a_failed_request_does_not_mean_the_token_was_rejected() { - assert!(!spotify_rejected_token(&anyhow!( - "http error: status code 429 Too Many Requests" - ))); - assert!(!spotify_rejected_token(&api_error( - 429, - "{\"error\":{\"status\":429,\"message\":\"API rate limit exceeded\"}}", - None - ))); - assert!(!spotify_rejected_token(&api_error( - 503, - "upstream unauthorized: token expired at the gateway", - None - ))); - assert!(!spotify_rejected_token(&anyhow!( - "Spotify API request failed: error sending request for url (https://api.spotify.com/v1/me)" - ))); - } - - #[test] - fn a_401_or_a_refused_refresh_means_log_in_again() { - assert!(spotify_rejected_token(&api_error( - 401, - "{\"error\":{\"status\":401,\"message\":\"The access token expired\"}}", - None - ))); - assert!(spotify_rejected_token(&api_error( - 401, - "{}", - Some("token refresh failed: invalid_grant") - ))); - assert!(spotify_rejected_token(&anyhow!( - "http error: status code 400 Bad Request" - ))); - } - - /// A `config.yml` link in one directory to a target in another (the dotfiles - /// shape); `None` when the platform refuses to create symlinks. - fn linked_config(seed: Option<&[u8]>) -> Option<(tempfile::TempDir, PathBuf, PathBuf)> { - let root = tempfile::tempdir().unwrap(); - let target = root.path().join("store").join("config.yml"); - let link = root.path().join("config").join("config.yml"); - fs::create_dir(target.parent().unwrap()).unwrap(); - fs::create_dir(link.parent().unwrap()).unwrap(); - if let Some(bytes) = seed { - fs::write(&target, bytes).unwrap(); - } - symlink_file(&target, &link).ok()?; - Some((root, target, link)) - } - - fn is_symlink(path: &Path) -> bool { - fs::symlink_metadata(path).unwrap().file_type().is_symlink() - } - - #[test] - fn atomic_write_through_a_dangling_symlink_keeps_the_link() { - let Some((_root, target, link)) = linked_config(None) else { - return; - }; - - write_private_file_atomic(&link, b"new").unwrap(); - - assert!(is_symlink(&link)); - assert_eq!(fs::read(&target).unwrap(), b"new"); - } - - #[test] - fn atomic_write_through_a_symlink_keeps_the_link() { - let Some((_root, target, link)) = linked_config(Some(b"old")) else { - return; - }; - - write_private_file_atomic(&link, b"new").unwrap(); - - assert!(is_symlink(&link)); - assert_eq!(fs::read(&target).unwrap(), b"new"); - } - - #[test] - fn atomic_write_onto_a_read_only_target_keeps_the_link() { - let Some((_root, target, link)) = linked_config(Some(b"old")) else { - return; - }; - let store = target.parent().unwrap(); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(store, fs::Permissions::from_mode(0o500)).unwrap(); - } - #[cfg(windows)] - { - let mut permissions = fs::metadata(&target).unwrap().permissions(); - permissions.set_readonly(true); - fs::set_permissions(&target, permissions).unwrap(); - } - - let _ = write_private_file_atomic(&link, b"new"); - - assert!(is_symlink(&link)); - // No temp sibling is left behind, on a failed write or on a root-run success. - assert_eq!(fs::read_dir(store).unwrap().count(), 1); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(store, fs::Permissions::from_mode(0o700)).unwrap(); - } - } - - #[test] - fn atomic_write_into_a_symlink_loop_fails_and_keeps_both_links() { - let Some((_root, target, link)) = linked_config(None) else { - return; - }; - symlink_file(&link, &target).unwrap(); - - assert!(write_private_file_atomic(&link, b"new").is_err()); - - assert!(is_symlink(&link)); - assert!(is_symlink(&target)); - } - - #[test] - fn an_exclusive_create_refuses_a_planted_symlink() { - let Some((_root, target, link)) = linked_config(None) else { - return; - }; - - let error = create_private_file_exclusive(&link).unwrap_err(); - - assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists); - assert!(!target.exists()); - } -} diff --git a/src/core/banner.rs b/src/core/banner.rs deleted file mode 100644 index 92b58afe..00000000 --- a/src/core/banner.rs +++ /dev/null @@ -1,8 +0,0 @@ -pub const BANNER: &str = r#" - ███████╗██████╗ ██████╗ ████████╗ █████╗ ████████╗██╗ ██╗██╗ - ██╔════╝██╔══██╗██╔═══██╗╚══██╔══╝██╔══██╗╚══██╔══╝██║ ██║██║ - ███████╗██████╔╝██║ ██║ ██║ ███████║ ██║ ██║ ██║██║ - ╚════██║██╔═══╝ ██║ ██║ ██║ ██╔══██║ ██║ ██║ ██║██║ - ███████║██║ ╚██████╔╝ ██║ ██║ ██║ ██║ ╚██████╔╝██║ - ╚══════╝╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ -"#; diff --git a/src/core/config.rs b/src/core/config.rs deleted file mode 100644 index ba7a8257..00000000 --- a/src/core/config.rs +++ /dev/null @@ -1,502 +0,0 @@ -use crate::core::banner::BANNER; -use crate::core::onboarding::Onboarding; -use anyhow::{anyhow, Context, Error, Result}; -use serde::{Deserialize, Serialize}; -use std::{ - fs, - io::Write, - path::{Path, PathBuf}, -}; - -const DEFAULT_PORT: u16 = 8888; -const FILE_NAME: &str = "client.yml"; -const TOKEN_CACHE_FILE: &str = ".spotify_token_cache.json"; -const GITIGNORE_FILE: &str = ".gitignore"; -pub const NCSPOT_CLIENT_ID: &str = "d420a117a32841c2b3474932e49fb54b"; -const AUTH_SETUP_VERSION: u8 = 2; - -#[derive(Default, Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct ClientConfig { - pub client_id: String, - #[serde(default)] - pub fallback_client_id: Option<String>, - #[serde(default)] - pub client_secret: String, - #[serde(default)] - pub setup_version: u8, - pub device_id: Option<String>, - // FIXME: port should be defined in `user_config` not in here - pub port: Option<u16>, - // Streaming configuration - #[serde(default = "default_streaming_enabled")] - pub enable_streaming: bool, - #[serde(default = "default_device_name")] - pub streaming_device_name: String, - #[serde(default = "default_bitrate")] - pub streaming_bitrate: u16, - #[serde(default)] - pub streaming_audio_cache: bool, -} - -fn default_streaming_enabled() -> bool { - cfg!(feature = "streaming") -} - -fn default_device_name() -> String { - "spotatui".to_string() -} - -fn default_bitrate() -> u16 { - 320 -} - -pub struct ConfigPaths { - pub config_file_path: PathBuf, - pub token_cache_path: PathBuf, -} - -impl ClientConfig { - pub fn new() -> ClientConfig { - ClientConfig { - client_id: "".to_string(), - fallback_client_id: None, - client_secret: "".to_string(), - setup_version: 0, - device_id: None, - port: None, - enable_streaming: default_streaming_enabled(), - streaming_device_name: default_device_name(), - streaming_bitrate: default_bitrate(), - streaming_audio_cache: false, - } - } - - pub fn get_redirect_uri(&self) -> String { - format!("http://127.0.0.1:{}/callback", self.get_port()) - } - - pub fn get_port(&self) -> u16 { - self.port.unwrap_or(DEFAULT_PORT) - } - - pub fn get_or_build_paths(&self) -> Result<ConfigPaths> { - match ( - crate::core::paths::app_config_dir(), - crate::core::paths::app_state_dir(), - ) { - (Some(app_config_dir), Some(app_state_dir)) => { - fs::create_dir_all(&app_config_dir)?; - crate::core::paths::ensure_private_dir(&app_state_dir)?; - - // Create .gitignore to protect sensitive config from being committed. - let gitignore_path = app_config_dir.join(GITIGNORE_FILE); - if !gitignore_path.exists() { - let gitignore_content = "\ - # Protect sensitive files from being committed - # This file is auto-generated by spotatui - - # Client credentials (client_id, client_secret) - client.yml - "; - fs::write(&gitignore_path, gitignore_content)?; - } - - let config_file_path = &app_config_dir.join(FILE_NAME); - let token_cache_path = &app_state_dir.join(TOKEN_CACHE_FILE); - if let Err(error) = - migrate_legacy_token_caches_if_unclaimed(&app_config_dir, &app_state_dir) - { - log::warn!( - "failed to migrate legacy Spotify token cache from {} to {}: {error}", - app_config_dir.display(), - app_state_dir.display() - ); - } - - let paths = ConfigPaths { - config_file_path: config_file_path.to_path_buf(), - token_cache_path: token_cache_path.to_path_buf(), - }; - - Ok(paths) - } - _ => Err(anyhow!("No $HOME directory found for client config/state")), - } - } - - pub fn set_device_id(&mut self, device_id: String) -> Result<()> { - let paths = self.get_or_build_paths()?; - let config_string = fs::read_to_string(&paths.config_file_path)?; - let mut config_yml: ClientConfig = serde_yaml::from_str(&config_string)?; - - self.device_id = Some(device_id.clone()); - config_yml.device_id = Some(device_id); - - let new_config = serde_yaml::to_string(&config_yml)?; - let mut config_file = fs::File::create(&paths.config_file_path)?; - write!(config_file, "{}", new_config)?; - Ok(()) - } - - /// Load `client.yml`, or run the auth wizard when the file is absent. - /// Returns `true` when the wizard ran. - pub fn load_config(&mut self, onboarding: &dyn Onboarding) -> Result<bool> { - let paths = self.get_or_build_paths()?; - if paths.config_file_path.exists() { - let config_string = fs::read_to_string(&paths.config_file_path)?; - let config_yml: ClientConfig = serde_yaml::from_str(&config_string)?; - - self.client_id = config_yml.client_id; - self.fallback_client_id = config_yml.fallback_client_id; - self.client_secret = config_yml.client_secret; - self.setup_version = config_yml.setup_version; - self.device_id = config_yml.device_id; - self.port = config_yml.port; - self.enable_streaming = config_yml.enable_streaming; - self.streaming_device_name = config_yml.streaming_device_name; - self.streaming_bitrate = config_yml.streaming_bitrate; - self.streaming_audio_cache = config_yml.streaming_audio_cache; - self.prefer_own_app(); - - Ok(false) - } else { - onboarding.info(BANNER); - - onboarding.info(&format!( - "Config will be saved to {}", - paths.config_file_path.display() - )); - - self.run_auth_setup_wizard(onboarding)?; - Ok(true) - } - } - - pub fn needs_auth_setup_migration(&self) -> bool { - self.setup_version < AUTH_SETUP_VERSION - } - - /// An older wizard wrote the shared id as primary and the user's own app as - /// the fallback; the own app leads, since its rate limit is the reason to - /// have one, and the shared id becomes the fallback. - fn prefer_own_app(&mut self) { - if self.client_id != NCSPOT_CLIENT_ID { - return; - } - if let Some(own_app) = self.fallback_client_id.take_if(|fallback| { - fallback != NCSPOT_CLIENT_ID && ClientConfig::validate_client_key(fallback).is_ok() - }) { - self.fallback_client_id = Some(std::mem::replace(&mut self.client_id, own_app)); - } - } - - pub fn reconfigure_auth(&mut self, onboarding: &dyn Onboarding) -> Result<()> { - self.run_auth_setup_wizard(onboarding) - } - - /// Write `client.yml` with ncspot defaults but WITHOUT running the OAuth flow. - /// Used when a first-run user picks a free source: it seeds a `client_id`/port - /// so a later in-TUI Spotify login can build the authorize URL, while doing no - /// browser flow now. `load_config` then finds the file and skips the wizard. - pub fn init_default_spotify_config(&mut self) -> Result<()> { - self.client_id = NCSPOT_CLIENT_ID.to_string(); - self.fallback_client_id = None; - self.client_secret = String::new(); - self.port = Some(8989); - self.setup_version = AUTH_SETUP_VERSION; - self.save_config_file() - } - - pub fn mark_auth_setup_migrated(&mut self) -> Result<()> { - self.setup_version = AUTH_SETUP_VERSION; - self.save_config_file() - } - - fn run_auth_setup_wizard(&mut self, onboarding: &dyn Onboarding) -> Result<()> { - onboarding.info("\nClient setup options:\n"); - onboarding.info( - " 1) Use the shared ncspot client ID (quick start; its Spotify rate limit is shared by every ncspot and spotatui user)", - ); - onboarding.info( - " 2) Use your own Spotify app, with the shared ID as a fallback (recommended: a rate limit of your own)", - ); - - let setup_option = ClientConfig::get_setup_option(onboarding)?; - - let (client_id, fallback_client_id, port) = match setup_option { - 1 => { - onboarding.info("\nUsing ncspot redirect URI: http://127.0.0.1:8989/login"); - (NCSPOT_CLIENT_ID.to_string(), None, 8989) - } - 2 => { - // The port comes first so the Redirect URI below names the real one. - let port = onboarding.prompt_line(&format!( - "\nEnter port of your app's redirect uri (default {}): \n", - DEFAULT_PORT - ))?; - let port = port.trim().parse::<u16>().unwrap_or(DEFAULT_PORT); - onboarding.info("\nCreate your Spotify app:\n"); - let instructions = [ - "Go to https://developer.spotify.com/dashboard/applications", - "Click `Create app` and add your own name and description", - &format!("Add `http://127.0.0.1:{port}/callback` to Redirect URIs"), - ]; - - for (number, item) in instructions.iter().enumerate() { - onboarding.info(&format!(" {}. {}", number + 1, item)); - } - - let own_app = ClientConfig::get_client_key_from_input("Client ID", onboarding)?; - (own_app, Some(NCSPOT_CLIENT_ID.to_string()), port) - } - _ => unreachable!(), - }; - - self.client_id = client_id; - self.fallback_client_id = fallback_client_id; - self.client_secret = String::new(); - self.port = Some(port); - self.setup_version = AUTH_SETUP_VERSION; - - self.save_config_file() - } - - fn save_config_file(&self) -> Result<()> { - let paths = self.get_or_build_paths()?; - let content_yml = serde_yaml::to_string(self)?; - - let mut config_file = fs::File::create(&paths.config_file_path)?; - write!(config_file, "{}", content_yml)?; - Ok(()) - } - - fn get_client_key_from_input( - type_label: &'static str, - onboarding: &dyn Onboarding, - ) -> Result<String> { - const MAX_RETRIES: u8 = 5; - let mut num_retries = 0; - loop { - let input = onboarding.prompt_line(&format!("\nEnter your {}: \n", type_label))?; - let client_key = input.trim().to_string(); - match ClientConfig::validate_client_key(&client_key) { - Ok(_) => return Ok(client_key), - Err(error_string) => { - onboarding.info(&error_string.to_string()); - num_retries += 1; - if num_retries == MAX_RETRIES { - return Err(Error::from(std::io::Error::other(format!( - "Maximum retries ({}) exceeded.", - MAX_RETRIES - )))); - } - } - }; - } - } - - fn get_setup_option(onboarding: &dyn Onboarding) -> Result<u8> { - const MAX_RETRIES: u8 = 5; - let mut retries = 0; - - loop { - let input = onboarding.prompt_line("\nChoose option (1 or 2): \n")?; - let trimmed = input.trim(); - - match trimmed.parse::<u8>() { - Ok(1) | Ok(2) => return Ok(trimmed.parse::<u8>()?), - _ => { - onboarding.info("Invalid choice. Please enter 1 or 2."); - retries += 1; - if retries >= MAX_RETRIES { - return Err(Error::from(std::io::Error::other(format!( - "Maximum retries ({}) exceeded.", - MAX_RETRIES - )))); - } - } - } - } - } - - fn validate_client_key(key: &str) -> Result<()> { - const EXPECTED_LEN: usize = 32; - if key.len() != EXPECTED_LEN { - Err(Error::from(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("invalid length: {} (must be {})", key.len(), EXPECTED_LEN,), - ))) - } else if !key.chars().all(|c| c.is_ascii_hexdigit()) { - Err(Error::from(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "invalid character found (must be hex digits)", - ))) - } else { - Ok(()) - } - } -} - -fn migrate_legacy_token_caches_if_unclaimed( - app_config_dir: &Path, - app_state_dir: &Path, -) -> Result<()> { - // OAuth tokens used to live next to client.yml; preserve them when moving - // app-managed state out of the config directory. - crate::core::migrations::migrate_legacy_path_if_unclaimed( - &app_config_dir.join(TOKEN_CACHE_FILE), - &app_state_dir.join(TOKEN_CACHE_FILE), - )?; - - for entry in - fs::read_dir(app_config_dir).with_context(|| format!("reading {}", app_config_dir.display()))? - { - let entry = entry?; - let file_name = entry.file_name(); - let Some(file_name) = file_name.to_str() else { - continue; - }; - - if file_name == TOKEN_CACHE_FILE || !is_client_token_cache_file(file_name) { - continue; - } - - crate::core::migrations::migrate_legacy_path_if_unclaimed( - &entry.path(), - &app_state_dir.join(file_name), - )?; - } - - Ok(()) -} - -fn is_client_token_cache_file(file_name: &str) -> bool { - file_name.starts_with(".spotify_token_cache_") && file_name.ends_with(".json") -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::test_helpers::ScriptedOnboarding; - - #[test] - fn prefer_own_app_swaps_a_shared_primary_with_a_personal_fallback() { - let own = "0123456789abcdef0123456789abcdef"; - let mut config = ClientConfig::new(); - config.client_id = NCSPOT_CLIENT_ID.to_string(); - config.fallback_client_id = Some(own.to_string()); - - config.prefer_own_app(); - - assert_eq!(config.client_id, own); - assert_eq!(config.fallback_client_id.as_deref(), Some(NCSPOT_CLIENT_ID)); - } - - #[test] - fn prefer_own_app_leaves_other_layouts_alone() { - let own = "0123456789abcdef0123456789abcdef"; - for (primary, fallback) in [ - (NCSPOT_CLIENT_ID, None), - (NCSPOT_CLIENT_ID, Some(NCSPOT_CLIENT_ID)), - (NCSPOT_CLIENT_ID, Some("not-a-client-id")), - (own, Some(NCSPOT_CLIENT_ID)), - (own, None), - ] { - let mut config = ClientConfig::new(); - config.client_id = primary.to_string(); - config.fallback_client_id = fallback.map(str::to_string); - - config.prefer_own_app(); - - assert_eq!(config.client_id, primary); - assert_eq!(config.fallback_client_id.as_deref(), fallback); - } - } - - #[test] - fn setup_option_accepts_a_valid_choice_after_garbage() { - let onboarding = ScriptedOnboarding::with_answers(&["x", "2"]); - assert_eq!(ClientConfig::get_setup_option(&onboarding).unwrap(), 2); - assert!(onboarding.saw("Invalid choice. Please enter 1 or 2.")); - } - - #[test] - fn setup_option_errors_after_max_retries() { - let onboarding = ScriptedOnboarding::with_answers(&["x", "0", "3", "", "yes"]); - assert!(ClientConfig::get_setup_option(&onboarding).is_err()); - } - - #[test] - fn client_key_prompt_rejects_bad_keys_until_a_valid_one() { - let valid = "0123456789abcdef0123456789abcdef"; - let onboarding = ScriptedOnboarding::with_answers(&["tooshort", valid]); - assert_eq!( - ClientConfig::get_client_key_from_input("Client ID", &onboarding).unwrap(), - valid - ); - assert!(onboarding.saw("\nEnter your Client ID: \n")); - } - - #[test] - fn legacy_token_cache_migration_moves_base_and_client_token_files() { - let dir = tempfile::tempdir().unwrap(); - let config_dir = dir.path().join("config"); - let state_dir = dir.path().join("state"); - std::fs::create_dir_all(&config_dir).unwrap(); - - std::fs::write(config_dir.join(TOKEN_CACHE_FILE), "base token").unwrap(); - std::fs::write( - config_dir.join(".spotify_token_cache_d420a117.json"), - "client token", - ) - .unwrap(); - std::fs::write(config_dir.join("client.yml"), "client_id: abc").unwrap(); - - migrate_legacy_token_caches_if_unclaimed(&config_dir, &state_dir).unwrap(); - - assert!(!config_dir.join(TOKEN_CACHE_FILE).exists()); - assert!(!config_dir - .join(".spotify_token_cache_d420a117.json") - .exists()); - assert_eq!( - std::fs::read_to_string(state_dir.join(TOKEN_CACHE_FILE)).unwrap(), - "base token" - ); - assert_eq!( - std::fs::read_to_string(state_dir.join(".spotify_token_cache_d420a117.json")).unwrap(), - "client token" - ); - assert!(config_dir.join("client.yml").exists()); - } - - #[test] - fn legacy_token_cache_migration_does_not_overwrite_existing_state_token() { - let dir = tempfile::tempdir().unwrap(); - let config_dir = dir.path().join("config"); - let state_dir = dir.path().join("state"); - std::fs::create_dir_all(&config_dir).unwrap(); - std::fs::create_dir_all(&state_dir).unwrap(); - - std::fs::write( - config_dir.join(".spotify_token_cache_d420a117.json"), - "legacy token", - ) - .unwrap(); - std::fs::write( - state_dir.join(".spotify_token_cache_d420a117.json"), - "state token", - ) - .unwrap(); - - migrate_legacy_token_caches_if_unclaimed(&config_dir, &state_dir).unwrap(); - - assert_eq!( - std::fs::read_to_string(config_dir.join(".spotify_token_cache_d420a117.json")).unwrap(), - "legacy token" - ); - assert_eq!( - std::fs::read_to_string(state_dir.join(".spotify_token_cache_d420a117.json")).unwrap(), - "state token" - ); - } -} diff --git a/src/core/cover_theme.rs b/src/core/cover_theme.rs deleted file mode 100644 index b02fffdd..00000000 --- a/src/core/cover_theme.rs +++ /dev/null @@ -1,488 +0,0 @@ -//! Adaptive theming from album art: extract the dominant colors of the -//! decoded cover image, derive a UI theme from them, and fade the live theme -//! between targets on track change. -//! -//! Everything here is pure pixel/color math on data the cover-art pipeline -//! already decodes, so it behaves identically on every platform and terminal. - -use crate::core::theme::Color; -use std::time::Duration; - -use crate::core::app::ease_in_out_cubic; -use crate::core::user_config::Theme; - -/// How long a theme fade takes. Long enough to read as a transition, short -/// enough that the theme settles before the track's intro is over. -const THEME_FADE_SECS: f32 = 0.8; - -/// Cover art is downsampled so no side exceeds this before analysis; one pass -/// over at most 64x64 pixels keeps extraction well under a millisecond. -const SAMPLE_DIM: u32 = 64; - -/// Minimum share of sampled pixels (1/50 = 2%) a color region needs before it -/// can be picked as the secondary accent, so single stray pixels never drive -/// the theme. -const SECONDARY_MIN_SHARE: u32 = 50; - -/// Accent colors extracted from one cover image, before any legibility -/// adjustment (that happens in [`derive_theme`], which knows the base theme's -/// background). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct AlbumPalette { - pub primary: (u8, u8, u8), - pub secondary: (u8, u8, u8), -} - -/// Whether an album-derived theme is applied. `base` is the user's own theme, -/// kept while album accents are on screen (or fading out) so it can be -/// restored, and so settings changes know what the user's real colors are. -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub enum CoverThemeState { - #[default] - Inactive, - Active { - base: Theme, - }, - /// Fading back to `base`; flips to `Inactive` when the fade completes. - Restoring { - base: Theme, - }, -} - -/// An in-flight fade of the live theme. Driven by real elapsed time rather -/// than tick count, so its speed is independent of the configured tick rates. -#[derive(Clone, Copy, Debug)] -pub struct ThemeTransition { - from: Theme, - to: Theme, - progress: f32, -} - -impl ThemeTransition { - pub fn new(from: Theme, to: Theme) -> Self { - Self { - from, - to, - progress: 0.0, - } - } - - pub fn advance(&mut self, elapsed: Duration) { - self.progress = (self.progress + elapsed.as_secs_f32() / THEME_FADE_SECS).min(1.0); - } - - pub fn current(&self) -> Theme { - let eased = ease_in_out_cubic(f64::from(self.progress)) as f32; - lerp_theme(&self.from, &self.to, eased) - } - - pub fn is_complete(&self) -> bool { - self.progress >= 1.0 - } - - pub fn target(&self) -> Theme { - self.to - } -} - -#[derive(Clone, Default)] -struct Bin { - count: u32, - r: u64, - g: u64, - b: u64, -} - -struct Candidate { - rgb: (u8, u8, u8), - hsv: (f32, f32, f32), - count: u32, - score: f32, -} - -/// Extract the two accent colors that best represent `img`. -/// -/// Pixels are pooled into 4-bits-per-channel buckets and each bucket is scored -/// by `count * (0.15 + saturation) * mid-brightness preference`: a colorful -/// region beats a larger near-black or near-white one (borders, text, vinyl -/// grooves), which is what makes the result feel like "the album's color" -/// rather than its literal average. Returns `None` when the image has no -/// opaque pixels. -pub fn extract_palette(img: &image::DynamicImage) -> Option<AlbumPalette> { - let thumb = img.thumbnail(SAMPLE_DIM, SAMPLE_DIM).into_rgba8(); - - // 4 bits per channel: keys are a dense 0..4096 index, so a flat array beats - // hashing per pixel. - let mut bins = vec![Bin::default(); 4096]; - let mut total: u32 = 0; - for px in thumb.pixels() { - if px[3] < 128 { - continue; - } - let (r, g, b) = (px[0], px[1], px[2]); - let key = (usize::from(r >> 4) << 8) | (usize::from(g >> 4) << 4) | usize::from(b >> 4); - let bin = &mut bins[key]; - bin.count += 1; - bin.r += u64::from(r); - bin.g += u64::from(g); - bin.b += u64::from(b); - total += 1; - } - if total == 0 { - return None; - } - - let mut candidates: Vec<Candidate> = bins - .iter() - .filter(|bin| bin.count > 0) - .map(|bin| { - let n = u64::from(bin.count); - let rgb = ((bin.r / n) as u8, (bin.g / n) as u8, (bin.b / n) as u8); - let hsv = rgb_to_hsv(rgb); - let brightness_weight = (1.0 - (hsv.2 - 0.62).abs()).max(0.15); - let score = bin.count as f32 * (0.15 + hsv.1) * brightness_weight; - Candidate { - rgb, - hsv, - count: bin.count, - score, - } - }) - .collect(); - // Tie-break on the color value so equal scores (common in synthetic images) - // resolve deterministically under the unstable sort. - candidates.sort_unstable_by(|a, b| b.score.total_cmp(&a.score).then_with(|| a.rgb.cmp(&b.rgb))); - - let primary = &candidates[0]; - - // Secondary: a hue clearly different from the primary, falling back to a - // brightness contrast (covers monochrome art), falling back to the primary - // itself ([`derive_theme`] keeps the on-screen accents distinct). - let eligible: Vec<&Candidate> = candidates - .iter() - .skip(1) - .filter(|c| c.count * SECONDARY_MIN_SHARE >= total) - .collect(); - let secondary_rgb = eligible - .iter() - .find(|c| c.hsv.1 >= 0.15 && hue_distance(c.hsv.0, primary.hsv.0) >= 60.0) - .or_else(|| { - eligible - .iter() - .find(|c| (c.hsv.2 - primary.hsv.2).abs() >= 0.2) - }) - .map(|c| c.rgb) - .unwrap_or(primary.rgb); - - Some(AlbumPalette { - primary: primary.rgb, - secondary: secondary_rgb, - }) -} - -/// Build the theme shown while `palette`'s album is playing: `base` with only -/// the accent fields replaced. Text, background, borders and error colors stay -/// the user's own, so legibility never depends on what the art happens to be. -pub fn derive_theme(base: &Theme, palette: &AlbumPalette) -> Theme { - let dark_bg = background_is_dark(base); - let primary = accent_color(palette.primary, dark_bg); - let mut secondary = accent_color(palette.secondary, dark_bg); - // Monochrome art repeats the primary, and the brightness clamp can land two - // distinct accents on the same color; keep hovered visually distinct from - // selected either way. - if secondary == primary { - secondary = contrast_variant(primary, dark_bg); - } - Theme { - active: primary, - banner: primary, - hovered: secondary, - selected: primary, - playbar_progress: primary, - highlighted_lyrics: primary, - analysis_bar: primary, - ..*base - } -} - -fn background_is_dark(base: &Theme) -> bool { - match base.background { - Color::Rgb(r, g, b) => luma((r, g, b)) < 0.5, - // Reset / named backgrounds follow the terminal palette, which we can't - // read; assume dark, the overwhelmingly common case for TUIs. - _ => true, - } -} - -/// Clamp an extracted color's brightness so it stays readable against the -/// base background: lifted on dark backgrounds, dimmed on light ones. -fn accent_color(rgb: (u8, u8, u8), dark_bg: bool) -> Color { - let (h, s, v) = rgb_to_hsv(rgb); - let v = if dark_bg { v.max(0.55) } else { v.min(0.55) }; - let (r, g, b) = hsv_to_rgb((h, s, v)); - Color::Rgb(r, g, b) -} - -/// A brightness variant of an already-clamped accent that stays on the legal -/// side of the clamp, so it cannot collapse back into the original. -fn contrast_variant(color: Color, dark_bg: bool) -> Color { - let Color::Rgb(r, g, b) = color else { - return color; - }; - let (h, s, v) = rgb_to_hsv((r, g, b)); - let v = if dark_bg { - // Legal range [0.55, 1.0]. - if v > 0.775 { - v - 0.225 - } else { - v + 0.225 - } - } else { - // Legal range [0.0, 0.55]. - if v < 0.325 { - v + 0.225 - } else { - v - 0.225 - } - }; - let (r, g, b) = hsv_to_rgb((h, s, v.clamp(0.0, 1.0))); - Color::Rgb(r, g, b) -} - -fn luma((r, g, b): (u8, u8, u8)) -> f32 { - (0.2126 * f32::from(r) + 0.7152 * f32::from(g) + 0.0722 * f32::from(b)) / 255.0 -} - -fn hue_distance(a: f32, b: f32) -> f32 { - let d = (a - b).abs() % 360.0; - d.min(360.0 - d) -} - -/// Thin adapters over `csscolorparser` (already linked via `colorgrad`), so -/// this module carries no color-space math of its own. Same conventions: -/// h in [0..360], s/v in [0..1]. -fn rgb_to_hsv((r, g, b): (u8, u8, u8)) -> (f32, f32, f32) { - let [h, s, v, _] = colorgrad::Color::from_rgba8(r, g, b, 255).to_hsva(); - (h, s, v) -} - -fn hsv_to_rgb((h, s, v): (f32, f32, f32)) -> (u8, u8, u8) { - let [r, g, b, _] = colorgrad::Color::from_hsva(h, s, v, 1.0).to_rgba8(); - (r, g, b) -} - -/// Interpolate between two colors. Only RGB pairs can blend; when either end -/// is a named ANSI color or `Reset` (whose actual RGB the terminal owns), the -/// color snaps at the halfway point instead. -fn lerp_color(from: Color, to: Color, t: f32) -> Color { - match (from, to) { - (Color::Rgb(r1, g1, b1), Color::Rgb(r2, g2, b2)) => { - let [r, g, b, _] = colorgrad::Color::from_rgba8(r1, g1, b1, 255) - .interpolate_rgb(&colorgrad::Color::from_rgba8(r2, g2, b2, 255), t) - .to_rgba8(); - Color::Rgb(r, g, b) - } - _ => { - if t < 0.5 { - from - } else { - to - } - } - } -} - -fn lerp_theme(from: &Theme, to: &Theme, t: f32) -> Theme { - Theme { - analysis_bar: lerp_color(from.analysis_bar, to.analysis_bar, t), - analysis_bar_text: lerp_color(from.analysis_bar_text, to.analysis_bar_text, t), - active: lerp_color(from.active, to.active, t), - banner: lerp_color(from.banner, to.banner, t), - error_border: lerp_color(from.error_border, to.error_border, t), - error_text: lerp_color(from.error_text, to.error_text, t), - hint: lerp_color(from.hint, to.hint, t), - hovered: lerp_color(from.hovered, to.hovered, t), - inactive: lerp_color(from.inactive, to.inactive, t), - playbar_background: lerp_color(from.playbar_background, to.playbar_background, t), - playbar_progress: lerp_color(from.playbar_progress, to.playbar_progress, t), - playbar_progress_text: lerp_color(from.playbar_progress_text, to.playbar_progress_text, t), - playbar_text: lerp_color(from.playbar_text, to.playbar_text, t), - selected: lerp_color(from.selected, to.selected, t), - text: lerp_color(from.text, to.text, t), - background: lerp_color(from.background, to.background, t), - header: lerp_color(from.header, to.header, t), - highlighted_lyrics: lerp_color(from.highlighted_lyrics, to.highlighted_lyrics, t), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use image::{DynamicImage, Rgba, RgbaImage}; - - fn solid(r: u8, g: u8, b: u8) -> DynamicImage { - DynamicImage::ImageRgba8(RgbaImage::from_pixel(32, 32, Rgba([r, g, b, 255]))) - } - - #[test] - fn solid_image_yields_that_color_as_primary() { - let palette = extract_palette(&solid(200, 30, 40)).unwrap(); - assert_eq!(palette.primary, (200, 30, 40)); - } - - #[test] - fn two_tone_image_yields_hue_distinct_secondary() { - let img = DynamicImage::ImageRgba8(RgbaImage::from_fn(32, 32, |x, _| { - if x < 16 { - Rgba([200, 30, 40, 255]) - } else { - Rgba([30, 60, 220, 255]) - } - })); - let palette = extract_palette(&img).unwrap(); - let (pr, _, pb) = palette.primary; - let (sr, _, sb) = palette.secondary; - // One accent red-dominant, the other blue-dominant, in either order. - assert!( - (pr > pb && sb > sr) || (pb > pr && sr > sb), - "expected one red and one blue accent, got {palette:?}" - ); - } - - #[test] - fn grayscale_image_still_yields_a_palette() { - let palette = extract_palette(&solid(128, 128, 128)).unwrap(); - assert_eq!(palette.primary, (128, 128, 128)); - // With no second color to offer, the palette repeats the primary; - // derive_theme keeps the on-screen accents distinct. - assert_eq!(palette.secondary, palette.primary); - let derived = derive_theme(&Theme::default(), &palette); - assert_ne!(derived.hovered, derived.selected); - } - - #[test] - fn fully_transparent_image_yields_none() { - let img = DynamicImage::ImageRgba8(RgbaImage::from_pixel(16, 16, Rgba([10, 10, 10, 0]))); - assert!(extract_palette(&img).is_none()); - } - - #[test] - fn colorful_region_beats_larger_dark_region() { - // 75% near-black border, 25% saturated orange: the orange should win. - let img = DynamicImage::ImageRgba8(RgbaImage::from_fn(32, 32, |x, _| { - if x < 8 { - Rgba([240, 140, 20, 255]) - } else { - Rgba([12, 12, 12, 255]) - } - })); - let palette = extract_palette(&img).unwrap(); - assert_eq!(palette.primary, (240, 140, 20)); - } - - #[test] - fn derive_replaces_accents_and_keeps_base_text_and_background() { - let base = Theme::default(); - let palette = AlbumPalette { - primary: (200, 30, 40), - secondary: (30, 60, 220), - }; - let derived = derive_theme(&base, &palette); - assert_ne!(derived.active, base.active); - assert_eq!(derived.text, base.text); - assert_eq!(derived.background, base.background); - assert_eq!(derived.error_border, base.error_border); - assert_eq!(derived.inactive, base.inactive); - } - - #[test] - fn derive_lifts_dark_accents_on_dark_background() { - let base = Theme::default(); // background: Reset -> assumed dark - let palette = AlbumPalette { - primary: (10, 10, 60), // very dark navy - secondary: (10, 10, 60), - }; - let derived = derive_theme(&base, &palette); - match derived.active { - Color::Rgb(r, g, b) => { - let v = f32::from(r.max(g).max(b)) / 255.0; - assert!(v >= 0.54, "accent stayed too dark: {r},{g},{b}"); - } - other => panic!("expected RGB accent, got {other:?}"), - } - } - - #[test] - fn derive_dims_bright_accents_on_light_background() { - let base = Theme { - background: Color::Rgb(250, 250, 250), - ..Theme::default() - }; - let palette = AlbumPalette { - primary: (255, 240, 100), // bright yellow - secondary: (255, 240, 100), - }; - let derived = derive_theme(&base, &palette); - match derived.active { - Color::Rgb(r, g, b) => { - let v = f32::from(r.max(g).max(b)) / 255.0; - assert!(v <= 0.56, "accent stayed too bright: {r},{g},{b}"); - } - other => panic!("expected RGB accent, got {other:?}"), - } - } - - #[test] - fn derived_accents_stay_distinct_after_clamping() { - let base = Theme::default(); - // Monochrome palette: both accents clamp to the same brightness floor. - let palette = AlbumPalette { - primary: (128, 128, 128), - secondary: (52, 52, 52), - }; - let derived = derive_theme(&base, &palette); - assert_ne!(derived.hovered, derived.selected); - } - - #[test] - fn lerp_color_blends_rgb_and_snaps_named_at_half() { - let from = Color::Rgb(0, 0, 0); - let to = Color::Rgb(255, 255, 255); - assert_eq!(lerp_color(from, to, 0.0), from); - assert_eq!(lerp_color(from, to, 1.0), to); - match lerp_color(from, to, 0.5) { - Color::Rgb(r, g, b) => { - assert!((127..=128).contains(&r)); - assert_eq!(r, g); - assert_eq!(g, b); - } - other => panic!("expected RGB midpoint, got {other:?}"), - } - assert_eq!(lerp_color(Color::Cyan, to, 0.4), Color::Cyan); - assert_eq!(lerp_color(Color::Cyan, to, 0.6), to); - } - - #[test] - fn transition_advances_with_elapsed_time_and_completes() { - let from = Theme::default(); - let to = derive_theme( - &from, - &AlbumPalette { - primary: (200, 30, 40), - secondary: (30, 60, 220), - }, - ); - let mut transition = ThemeTransition::new(from, to); - assert_eq!(transition.current(), from); - - transition.advance(Duration::from_millis(400)); - assert!(!transition.is_complete()); - let mid = transition.current(); - assert_ne!(mid, from); - assert_ne!(mid, to); - - transition.advance(Duration::from_millis(500)); - assert!(transition.is_complete()); - assert_eq!(transition.current(), to); - } -} diff --git a/src/core/driver/mod.rs b/src/core/driver/mod.rs deleted file mode 100644 index fe636038..00000000 --- a/src/core/driver/mod.rs +++ /dev/null @@ -1,974 +0,0 @@ -//! The frontend-neutral scheduler ("the driver"): everything the app must do -//! on a timer, extracted from the terminal event loop so any frontend can run -//! playback correctly by calling [`Driver::tick`] at its tick rate. -//! -//! One tick advances playback state (`App::update_on_tick` plus the debounced -//! seek/volume/state flushes), refreshes the OAuth token, syncs OS presence -//! (Discord, MPRIS, window title), detects track changes (lyrics + cover -//! art), auto-advances the native queue and the decoded sources, persists the -//! non-Spotify session, and feeds the audio visualizer. -//! -//! What stays with the frontend, injected through [`TickEnv`] or kept at the -//! call site: the visualizer bar count (a function of frontend geometry), the -//! terminal image-protocol probe behind `TickEnv::cover_art_full_image_support`, -//! and the macOS `NSRunLoop` pump (the frontend owns the main thread). -//! -//! A frontend that stops ticking is a bug this module makes loud: -//! `App::playback_position_ms` reports stale once no tick has run for two -//! seconds, instead of leaving a silently frozen playbar. - -mod plan; -mod presence; - -use crate::core::app::{App, RouteId}; -#[cfg(any(feature = "audio-viz", feature = "audio-viz-cpal"))] -use crate::infra::audio; -#[cfg(feature = "discord-rpc")] -use crate::infra::discord_rpc; -#[cfg(all(feature = "mpris", target_os = "linux"))] -use crate::infra::mpris; -use crate::infra::network::IoEvent; -#[cfg(feature = "scripting")] -use crate::infra::scripting::ScriptEngine; -#[cfg(feature = "scripting")] -use log::info; -use std::sync::{atomic::AtomicU64, Arc}; -use std::time::{Duration, Instant, SystemTime}; - -#[cfg(feature = "discord-rpc")] -pub type DiscordRpcHandle = Option<discord_rpc::DiscordRpcManager>; -#[cfg(not(feature = "discord-rpc"))] -pub type DiscordRpcHandle = Option<()>; - -#[cfg(all(feature = "mpris", target_os = "linux"))] -pub type MprisHandle = Option<Arc<mpris::MprisManager>>; -#[cfg(not(all(feature = "mpris", target_os = "linux")))] -pub type MprisHandle = Option<()>; - -/// Identity of the currently-playing track, used by the shared track-change -/// detector to fire lyrics + cover-art fetches exactly once per track (rather -/// than every tick). Title + artists + album + duration distinguishes tracks -/// across every source without depending on a source-specific id. -type TrackIdentity = (String, Vec<String>, String, u32); - -fn track_identity(snapshot: &crate::infra::media_metadata::PlaybackSnapshot) -> TrackIdentity { - ( - snapshot.metadata.title.clone(), - snapshot.metadata.artists.clone(), - snapshot.metadata.album.clone(), - snapshot.metadata.duration_ms, - ) -} - -/// Resolve what cover art to fetch for the track described by `snapshot`. -/// -/// Local files carry embedded artwork read straight from the file (no URL), so -/// they take a dedicated `LocalFile` request. Every other source that can supply -/// art (Spotify album art, YouTube thumbnail, Subsonic getCoverArt) surfaces it -/// as `snapshot.metadata.image_url`. `None` means the current track has no art -/// to show (e.g. internet radio, or a Spotify item without images). -#[cfg(feature = "art-decode")] -fn cover_art_request_for( - app: &App, - snapshot: &crate::infra::media_metadata::PlaybackSnapshot, -) -> Option<crate::core::art::CoverArtRequest> { - use crate::core::art::CoverArtRequest; - - #[cfg(feature = "local-files")] - if let Some(local) = app.local_playback.as_ref() { - let uri = local.queue.get(local.index)?; - let path = crate::infra::local::file_uri_to_path(uri).ok()?; - return Some(CoverArtRequest::LocalFile { - key: uri.clone(), - path, - }); - } - #[cfg(not(feature = "local-files"))] - let _ = app; - - snapshot - .metadata - .image_url - .clone() - .map(CoverArtRequest::Url) -} - -/// Per-tick inputs only the frontend can supply. -pub struct TickEnv { - /// Wall-clock instant of this tick; injected so the driver's throttling - /// decisions stay deterministic under test. - pub now: Instant, - /// Visualizer bar count resolved from the frontend's geometry, or `None` - /// while the analysis view is not showing (which releases the capture). - #[cfg(any(feature = "audio-viz", feature = "audio-viz-cpal"))] - pub viz_bars: Option<usize>, - /// Whether the frontend renders real pixels for cover art (the terminal - /// image-protocol probe); false in a decode-only build, where art is - /// fetched solely for the adaptive theme. - #[cfg(feature = "art-decode")] - pub cover_art_full_image_support: bool, -} - -/// The scheduler's own state: everything that used to live as locals of the -/// terminal event loop, one instance per frontend session. -pub struct Driver { - /// Real-time position updates from the native player's event handler, - /// read lock-free instead of waiting on the polled Spotify context. - shared_position: Option<Arc<AtomicU64>>, - #[cfg(all(feature = "mpris", target_os = "linux"))] - mpris_manager: MprisHandle, - #[cfg(feature = "discord-rpc")] - discord_rpc_manager: DiscordRpcHandle, - #[cfg(feature = "scripting")] - script_engine: Option<ScriptEngine>, - #[cfg(any(feature = "audio-viz", feature = "audio-viz-cpal"))] - audio_capture: Option<audio::AudioCaptureManager>, - /// Previous tick's `is_streaming_active`, so a native session that ends can - /// push a final stopped state to MPRIS clients. - #[cfg(all(feature = "mpris", target_os = "linux"))] - prev_is_streaming_active: bool, - #[cfg(feature = "discord-rpc")] - discord_presence: presence::DiscordPresenceState, - #[cfg(all(feature = "mpris", target_os = "linux"))] - mpris_state: presence::MprisState, - window_title: presence::WindowTitleState, - /// Last track the shared detector fired lyrics for, so the lookup re-fires - /// only on an actual track change rather than every tick. - last_track_identity: Option<TrackIdentity>, - /// Cache key (URL / file URI) of the cover art last requested, so the - /// per-tick cover-art evaluation dispatches a fetch only when the resolved - /// art changes. - #[cfg(feature = "art-decode")] - last_cover_art_key: Option<String>, - /// Throttle state for `last_session.yml` (see `plan::session_persist_action`): - /// `last_session_save` spaces the periodic writes; `session_was_present` - /// lets a Some -> None transition (queue ended, switched to Spotify) clear - /// the file so a stale session is never resurrected. - last_session_save: Option<Instant>, - session_was_present: bool, - /// Whether the active internet-radio stream has produced audio yet, so the - /// tick can tell "stream just started, sink not filled" apart from "stream - /// died / drained" — both of which report `is_finished()` (empty sink). - #[cfg(feature = "internet-radio")] - radio_stream_started: bool, -} - -/// The startup route's data fetch, gated on what the route needs. -fn fetch_startup_route(app: &mut App) { - let route = app.get_current_route().id.clone(); - if !app.availability(route.startup_requirement()).is_available() { - return; - } - match route { - RouteId::RecentlyPlayed => app.dispatch(IoEvent::GetRecentlyPlayed), - RouteId::AlbumList => app.dispatch(IoEvent::GetCurrentUserSavedAlbums(None)), - RouteId::Artists => app.dispatch(IoEvent::GetFollowedArtists(None)), - RouteId::Podcasts => app.dispatch(IoEvent::GetCurrentUserSavedShows(None)), - RouteId::Stats => app.reload_stats(), - _ => {} - } -} - -impl Driver { - pub fn new( - shared_position: Option<Arc<AtomicU64>>, - mpris_manager: MprisHandle, - discord_rpc_manager: DiscordRpcHandle, - ) -> Self { - #[cfg(not(all(feature = "mpris", target_os = "linux")))] - let _ = mpris_manager; - #[cfg(not(feature = "discord-rpc"))] - let _ = discord_rpc_manager; - - // The Lua VM (plus its HTTP client) is only constructed when the user - // actually has script files; a zero-plugin install skips the engine — and - // its per-tick `on_tick` dispatch — entirely. - #[cfg(feature = "scripting")] - let script_engine: Option<ScriptEngine> = { - let config_dir = crate::core::user_config::default_app_config_dir(); - match config_dir { - Some(config_dir) if ScriptEngine::has_user_scripts(&config_dir) => { - match ScriptEngine::new() { - Ok(mut engine) => { - let loaded = engine.load_user_scripts(&config_dir); - info!("loaded {loaded} lua plugin file(s)"); - Some(engine) - } - Err(e) => { - log::error!("failed to initialize lua scripting engine: {e}"); - None - } - } - } - _ => { - info!("no lua plugin files found; scripting engine not started"); - None - } - } - }; - - Driver { - shared_position, - #[cfg(all(feature = "mpris", target_os = "linux"))] - mpris_manager, - #[cfg(feature = "discord-rpc")] - discord_rpc_manager, - #[cfg(feature = "scripting")] - script_engine, - #[cfg(any(feature = "audio-viz", feature = "audio-viz-cpal"))] - audio_capture: None, - #[cfg(all(feature = "mpris", target_os = "linux"))] - prev_is_streaming_active: false, - #[cfg(feature = "discord-rpc")] - discord_presence: presence::DiscordPresenceState::default(), - #[cfg(all(feature = "mpris", target_os = "linux"))] - mpris_state: presence::MprisState::default(), - window_title: presence::WindowTitleState::default(), - last_track_identity: None, - #[cfg(feature = "art-decode")] - last_cover_art_key: None, - last_session_save: None, - session_was_present: false, - #[cfg(feature = "internet-radio")] - radio_stream_started: false, - } - } - - /// One tick. The frontend calls this at its tick rate with the `App` lock - /// held; everything slow (file I/O, network) is dispatched off-lock. Must - /// run inside a Tokio runtime: session persistence goes through - /// `tokio::task::spawn_blocking`, which panics without one. - pub fn tick(&mut self, app: &mut App, elapsed: Duration, env: TickEnv) { - #[cfg(feature = "streaming")] - if let Some(player) = app.streaming_player.clone() { - if let Some(error) = player.take_audio_backend_error() { - app.pause_native_playback(); - app.set_status_message(format!("Audio backend failed: {error}"), 15); - } - } - - #[cfg(all(feature = "mpris", target_os = "linux"))] - { - let current_is_streaming_active = app.is_streaming_active; - if self.prev_is_streaming_active && !current_is_streaming_active { - if let Some(ref mpris) = self.mpris_manager { - mpris.set_stopped(); - } - } - self.prev_is_streaming_active = current_is_streaming_active; - } - - // Only refresh when a Spotify session exists; a free-source launch has no - // token expiry and must not schedule refreshes. This lives on the tick so - // every frontend gets it: one that skipped it would work for exactly one - // token lifetime and then 401 forever. - if plan::oauth_refresh_due( - app.spotify_token_expiry, - SystemTime::now(), - app.auth_refresh_in_progress, - app.active_decoded_source(), - app.spotify_refresh_retry_at(), - env.now, - ) { - app.auth_refresh_in_progress = true; - app.dispatch(IoEvent::RefreshAuthentication); - } - - app.update_on_tick(elapsed); - - #[cfg(feature = "streaming")] - app.flush_pending_native_seek(); - app.flush_pending_api_seek(); - app.flush_pending_source_seek(); - app.flush_pending_volume(); - app.flush_state_save(false); - - #[cfg(feature = "scripting")] - if let Some(engine) = self.script_engine.as_mut() { - engine.on_tick(app); - } - - #[cfg(feature = "discord-rpc")] - if let Some(ref manager) = self.discord_rpc_manager { - presence::update_discord_presence(manager, &mut self.discord_presence, app); - } - - #[cfg(all(feature = "mpris", target_os = "linux"))] - if let Some(ref mpris) = self.mpris_manager { - presence::update_mpris_state(mpris, &mut self.mpris_state, app); - } - - // Shared track-change detector. One place decides "the playing track - // changed" off the source-agnostic snapshot, then drives BOTH lyrics - // (every source) and cover art (cover-art feature) — so both light up - // for Spotify, local files, Subsonic, radio and YouTube through a single - // path. - { - let snapshot = crate::infra::media_metadata::current_playback_snapshot(app); - - // Lyrics fire once per track (identity latch): their inputs — title, - // artist, duration — ARE the identity, so they are correct at the - // instant the identity changes. - let identity = snapshot.as_ref().map(track_identity); - if identity != self.last_track_identity { - self.last_track_identity = identity; - app.view.lyrics_view.reset(); - match snapshot.as_ref() { - Some(snapshot) => { - use crate::infra::media_metadata::PlaybackItemKind; - // LRCLIB lookup by title + artist + duration. Source agnostic; - // radio (duration 0) simply resolves to "not found". Podcast - // episodes have no lyrics, so skip the lookup and show the - // not-found message rather than stale lyrics. - if snapshot.item_kind == PlaybackItemKind::Track { - let title = snapshot.metadata.title.clone(); - // The identity latch keys on the joined display credit, but the - // LRCLIB lookup needs the structured artist list so it can fall - // back to the primary artist alone for collaborations (#410). - let artists = snapshot.metadata.artists.clone(); - app.desired_lyrics_identity = Some((title.clone(), artists.join(", "))); - app.dispatch(IoEvent::GetLyrics( - title, - artists, - snapshot.metadata.duration_ms as f64 / 1000.0, - )); - } else { - app.desired_lyrics_identity = None; - app.lyrics = None; - app.lyrics_status = crate::core::app::LyricsStatus::NotFound; - app - .plugin_data_generations - .bump(crate::core::app::PluginDataKind::Lyrics); - } - } - None => { - app.desired_lyrics_identity = None; - // Nothing is playing: reset so no stale lyrics linger. - app.lyrics = None; - app.lyrics_status = crate::core::app::LyricsStatus::NotStarted; - app - .plugin_data_generations - .bump(crate::core::app::PluginDataKind::Lyrics); - } - } - } - - // Cover art is re-evaluated EVERY tick against the desired image key, - // NOT latched to the identity change. With native streaming the - // snapshot's `image_url` comes from the polled Spotify context, which - // catches up seconds *after* `native_track_info` flips the identity — - // an identity-latched fetch would fire once with the previous track's - // URL (or none at startup) and never see the real one, leaving the art - // stuck or missing until restart. Comparing against - // `last_cover_art_key` keeps this a no-op on quiet ticks and fires - // exactly once whenever the resolved art actually changes. - #[cfg(feature = "art-decode")] - { - use crate::core::art::CoverArtStatus; - let enabled = app - .user_config - .needs_cover_art(env.cover_art_full_image_support); - let desired = if enabled { - snapshot - .as_ref() - .and_then(|snapshot| cover_art_request_for(app, snapshot)) - } else { - None - }; - match plan::cover_art_action( - desired.as_ref().map(|request| request.key()), - self.last_cover_art_key.as_deref(), - app.cover_art.available(), - enabled, - snapshot.is_some(), - ) { - plan::CoverArtAction::Fetch => { - let request = desired.expect("Fetch is only planned with a desired request"); - app.desired_cover_art_key = Some(request.key().to_string()); - self.last_cover_art_key = Some(request.key().to_string()); - // Keep the previous image on screen until the new one - // resolves (smooth swap); the fetch runs off-lock. - app.cover_art.status = CoverArtStatus::Loading; - app.dispatch(IoEvent::FetchCoverArt(request)); - } - plan::CoverArtAction::Keep => { - app.desired_cover_art_key = desired.map(|request| request.key().to_string()); - } - plan::CoverArtAction::Drop { clear, status } => { - app.desired_cover_art_key = None; - self.last_cover_art_key = None; - // No art to show (radio, art disabled, nothing playing): drop - // any stale image once, so the pane shows the placeholder. - if clear { - app.clear_cover_art(); - } - app.cover_art.status = status; - } - } - } - } - - // Output device lost mid-track (Bluetooth headphones disconnected, a USB - // DAC unplugged). cpal pauses the stream and reports it, after which no - // audio callback runs again: `LocalPlayer` then refuses the transport calls - // that would wait on one, so nothing hangs, but the session sits silent and - // still looks like it is playing (`is_finished` stays false — the source is - // queued on a sink that will never drain it). That is why this runs before - // every advance block below: a dead device must not read as end-of-track. - // - // Rebuild the output on the new default device (with retries: a Bluetooth - // output takes seconds to come back) and restage the track there, at the - // position it stopped. Whether it stays paused depends on which of the - // two shapes this was: cpal reporting the device *removed* (headphones - // out) pauses, like macOS itself does — nobody wants the music jumping to - // the laptop speakers. A default that merely moved (headphones *in*) keeps - // whatever play/pause state the session had. The seek and the pause ride - // on the session as `resume_at`, applied by the path that stages the - // track, never as events queued behind it. - #[cfg(feature = "audio-decode-queue")] - macro_rules! decoded_device_recovery { - ($app:ident, $playback:ident) => { - // Not while the queue owns the sink: the session below is suspended, and - // its player is the *same* handle the queue slot is using (see - // `suspended_context_player`). The queue branch further down recovers it. - if !$app.queue_owns_playback() - && $app - .$playback - .as_ref() - .is_some_and(|s| s.player.device_lost()) - { - use crate::infra::audio::Reopen; - // Read before the reopen: it swaps in a fresh sink that is paused, - // empty, and reports no removal. A change already in flight stages a - // different track, so its position is the top. - let (position_ms, paused) = $app.$playback.as_ref().map_or((0, false), |s| { - ( - if s.advancing { - 0 - } else { - s.player.position().as_millis() as u64 - }, - s.player.device_removed() || s.player.is_paused(), - ) - }); - match $app.$playback.as_ref().map(|s| s.player.recover_device()) { - Some(Reopen::Reopened) => { - // The fresh sink is empty, so `is_finished()` is true from here - // on: hold the advance latch or the block below would read that - // as end-of-track and skip a song while the replay is in flight. - if let Some(s) = $app.$playback.as_mut() { - s.advancing = true; - s.resume_at = Some(crate::infra::queue::ResumePoint { - position_ms, - paused, - }); - } - $app.dispatch(IoEvent::ReplayCurrentTrack); - $app.set_status_message( - if paused { - "Audio device changed - playback paused here." - } else { - "Audio device changed - playback moved to it." - }, - 8, - ); - } - Some(Reopen::Retrying) => { - $app.set_status_message("Audio output device lost - waiting for a new one.", 8); - } - Some(Reopen::GaveUp) => { - $app.$playback = None; - $app.set_status_message("Audio output device disconnected.", 8); - } - Some(Reopen::Waiting) | None => {} - } - } - }; - } - #[cfg(feature = "local-files")] - decoded_device_recovery!(app, local_playback); - #[cfg(feature = "subsonic")] - decoded_device_recovery!(app, subsonic_playback); - #[cfg(feature = "qobuz")] - decoded_device_recovery!(app, qobuz_playback); - #[cfg(feature = "youtube")] - decoded_device_recovery!(app, youtube_playback); - - // Radio has no track to restage: a station is an infinite stream with no - // position, and pausing one only stalls its ring buffer. Drop the session - // and say so, rather than leave a dead player behind the playbar. - #[cfg(feature = "internet-radio")] - if app - .radio_playback - .as_ref() - .is_some_and(|s| s.player.device_lost()) - { - app.radio_playback = None; - app.set_status_message("Audio output device disconnected - radio stopped.", 8); - } - - // The native queue slot's own sink. There is no "replay this queued item" - // event, so recovery here reopens the device and lets the advance block - // below take it from the top of the next item: the interrupted queued track - // is skipped rather than restaged. Same split as the context path above: - // a removal clears the slot's desired play state, which every path that - // stages the next item (and the suspended context's resume) reads, so the - // queue does not play out loud on a device the user just left. - #[cfg(feature = "audio-decode-queue")] - { - use crate::infra::audio::Reopen; - use crate::infra::queue::QueueNowPlaying; - let lost = match app.queue_now.as_ref() { - Some(QueueNowPlaying::Decoded(d)) if d.player.device_lost() => Some(( - Arc::clone(&d.player), - d.player.device_removed() || d.player.is_paused(), - )), - _ => None, - }; - if let Some((player, paused)) = lost { - match player.recover_device() { - Reopen::Reopened => { - if paused { - app.queue_slot_desired_playing = false; - } - app.set_status_message( - if paused { - "Audio device changed - playback paused here." - } else { - "Audio device changed - playback moved to it." - }, - 8, - ); - } - Reopen::Retrying => { - app.set_status_message("Audio output device lost - waiting for a new one.", 8); - } - // No device to play on. Hand the slot to the same teardown a drained - // queue uses: clearing `queue_now` here would strand the suspended - // context (latched `advancing`) and the remaining queued items. - Reopen::GaveUp => { - if paused { - app.queue_slot_desired_playing = false; - } - app.dispatch(IoEvent::FinishNativeQueue); - app.set_status_message("Audio output device disconnected.", 8); - } - Reopen::Waiting => {} - } - } - } - - // Native queue slot: when the queued track finishes, advance the queue - // (play the next queued item, or resume the suspended context). Runs - // before the per-source blocks so it takes precedence over them. - #[cfg(feature = "audio-decode-queue")] - { - use crate::infra::queue::QueueNowPlaying; - let advance = match app.queue_now.as_mut() { - Some(QueueNowPlaying::Decoded(d)) - if plan::native_queue_advance_due(d.player.is_finished(), d.advancing) => - { - d.advancing = true; // atomic check-and-set: one dispatch only - true - } - _ => false, - }; - if advance { - app.dispatch(IoEvent::AdvanceNativeQueue); - } - } - - // Auto-queue refill. Dispatch only: this path holds `&mut App`, so the - // brain call itself must happen on the detached service lane. The - // generation goes along for the ride so a refill the user has since - // abandoned (DJ off, vibe shift, source change) can be dropped when it - // lands instead of queueing tracks for a session that is gone. - #[cfg(feature = "ai-dj")] - { - if crate::infra::network::dj::wants_top_up( - app.native_queue.len(), - &app.dj, - app.spotify_external_device_active(), - ) { - let turn_seq = app.dj.begin_turn(crate::infra::dj::TurnKind::Refill); - let generation = app.dj.generation; - // Not `dispatch`: that pins the global `is_loading` spinner until the - // service-lane task finishes, which for a brain call is minutes. The - // DJ's own `thinking` flag is the progress surface here. - app.dispatch_without_spinner(IoEvent::DjTopUp(generation, turn_seq)); - } - } - - // Decoded-source auto-advance, one macro invocation per source (the - // blocks are identical except for which `*_playback` session and queue - // field they read). Each session reads its progress live from the - // player at render time; the only state self-managed here is - // end-of-track. When the sink drains and no track change is already in - // flight (`!advancing`), `plan::decoded_advance` picks the move: advance / - // replay (repeat-one) / suspend to the native queue / tear down. - // - // Decide under one borrow, then act after the borrow ends. `advancing` - // is set *synchronously here* (atomic check-and-set, before - // dispatching) because the sink stays empty for the whole decode — or, - // for Subsonic/YouTube, multi-second download — window; without it the - // next tick would re-dispatch and skip several tracks per advance. - #[cfg(feature = "audio-decode-queue")] - macro_rules! decoded_auto_advance { - ($app:ident, $playback:ident, $queue:ident) => { - if !$app.queue_owns_playback() { - use crate::infra::queue::next_index; - let queue_len = $app.native_queue.len(); - let repeat = $app.decoded_repeat; - let advance = $app.$playback.as_ref().map(|s| { - plan::decoded_advance( - s.player.is_finished(), - s.advancing, - next_index(s.index, s.$queue.len()).is_some(), - queue_len, - repeat, - ) - }); - match advance { - Some(plan::DecodedAdvance::Dispatch { replay }) => { - if let Some(s) = $app.$playback.as_mut() { - s.advancing = true; // atomic check-and-set: one dispatch only - } - $app.dispatch(if replay { - IoEvent::ReplayCurrentTrack - } else { - IoEvent::NextTrack - }); - } - Some(plan::DecodedAdvance::SuspendToQueue) => { - // End-of-track handoff: under Repeat One the context resumes - // the same track, so a queued song can't consume the repeat. - $app.suspend_active_decoded_context_for_skip( - crate::infra::queue::SuspendCause::AutoAdvance, - ); - $app.dispatch(IoEvent::AdvanceNativeQueue); - } - Some(plan::DecodedAdvance::Teardown) => { - $app.$playback = None; - $app.release_decoded_sink_claim(); - } - Some(plan::DecodedAdvance::None) | None => {} - } - } - }; - } - #[cfg(feature = "local-files")] - decoded_auto_advance!(app, local_playback, queue); - #[cfg(feature = "subsonic")] - decoded_auto_advance!(app, subsonic_playback, tracks); - #[cfg(feature = "qobuz")] - decoded_auto_advance!(app, qobuz_playback, tracks); - #[cfg(feature = "youtube")] - decoded_auto_advance!(app, youtube_playback, tracks); - - // Apply any shuffle toggle that was deferred while a track change was in - // flight, now that the advance may have committed (a cheap no-op when the - // queue order already matches the decoded shuffle state). - #[cfg(feature = "audio-decode-queue")] - app.reconcile_decoded_shuffle(); - - // Internet radio has no queue to auto-advance; instead the tick watches - // for a live stream that dies (server disconnect or the ring buffer - // draining to EOF), which leaves `is_finished()` (empty sink) true while - // the session was never paused. `is_finished()` is also true during the - // brief pre-playback window before the first bytes arrive, so only tear - // down once the stream has actually started producing audio. - #[cfg(feature = "internet-radio")] - match app.radio_playback.as_ref() { - Some(radio) => { - if !radio.player.is_finished() { - self.radio_stream_started = true; - } else if self.radio_stream_started { - app.radio_playback = None; - app.release_decoded_sink_claim(); - self.radio_stream_started = false; - app.set_status_message("Radio stream ended", 4); - } - } - None => self.radio_stream_started = false, - } - - // A decoded non-Spotify source owns the sink while its `*_playback` is - // `Some`. Drive `song_progress_ms` from its live player, and do NOT let - // the (paused) librespot position below clobber it. - #[allow(unused_mut)] - let mut source_owns_playback = false; - // The native queue slot owns the sink when playing a decoded track; read - // progress from its player first (it may share the suspended context's - // player, in which case a per-source block below reads the same value). - #[cfg(feature = "audio-decode-queue")] - if let Some(crate::infra::queue::QueueNowPlaying::Decoded(d)) = app.queue_now.as_ref() { - source_owns_playback = true; - app.song_progress_ms = d.player.position().as_millis(); - } - #[allow(unused_variables)] - let spotify_queue_slot = app.queue_now_is_spotify(); - #[cfg(feature = "local-files")] - if !spotify_queue_slot { - if let Some(local) = app.local_playback.as_ref() { - source_owns_playback = true; - let position_ms = local.player.position().as_millis(); - app.song_progress_ms = position_ms; - } - } - #[cfg(feature = "subsonic")] - if !spotify_queue_slot { - if let Some(subsonic) = app.subsonic_playback.as_ref() { - source_owns_playback = true; - let position_ms = subsonic.player.position().as_millis(); - app.song_progress_ms = position_ms; - } - } - #[cfg(feature = "qobuz")] - if !spotify_queue_slot { - if let Some(qobuz) = app.qobuz_playback.as_ref() { - source_owns_playback = true; - app.song_progress_ms = qobuz.player.position().as_millis(); - } - } - #[cfg(feature = "internet-radio")] - if !spotify_queue_slot { - if let Some(radio) = app.radio_playback.as_ref() { - source_owns_playback = true; - let position_ms = radio.player.position().as_millis(); - app.song_progress_ms = position_ms; - } - } - #[cfg(feature = "youtube")] - if !spotify_queue_slot { - if let Some(youtube) = app.youtube_playback.as_ref() { - source_owns_playback = true; - let position_ms = youtube.player.position().as_millis(); - app.song_progress_ms = position_ms; - } - } - - // Persist the active non-Spotify session so it resumes on next launch. - // Throttled to avoid churning the file every tick; a Some -> None - // transition (queue ended, or switched to Spotify) clears it instead. - let has_session = app.has_persistable_session(); - match plan::session_persist_action( - has_session, - self.session_was_present, - self.last_session_save, - env.now, - ) { - plan::SessionPersist::Save => { - self.last_session_save = Some(env.now); - // Snapshot (clones the queue) only when a save is actually due, - // not on every tick. - if let Some(session) = app.current_persisted_session() { - // Fire-and-forget on the blocking pool: file I/O never blocks the - // UI tick, and a dropped handle still runs to completion. - tokio::task::spawn_blocking(move || { - if let Ok(path) = crate::core::persisted_playback::default_session_path() { - if let Err(e) = crate::core::persisted_playback::save(&path, &session) { - log::warn!("[session] failed to persist playback session: {e}"); - } - } - }); - } - } - plan::SessionPersist::Clear => { - self.last_session_save = None; - tokio::task::spawn_blocking(|| { - if let Ok(path) = crate::core::persisted_playback::default_session_path() { - if let Err(e) = crate::core::persisted_playback::clear(&path) { - log::warn!("[session] failed to clear playback session: {e}"); - } - } - }); - } - plan::SessionPersist::None => {} - } - self.session_was_present = has_session; - - #[cfg(feature = "streaming")] - if !source_owns_playback { - if let Some(ref pos) = self.shared_position { - if app.is_streaming_active { - let recently_seeked = app - .last_native_seek - .is_some_and(|t| t.elapsed().as_millis() < crate::core::app::SEEK_POSITION_IGNORE_MS); - - if !recently_seeked { - let position_ms = pos.load(std::sync::atomic::Ordering::Relaxed); - if position_ms > 0 { - app.song_progress_ms = position_ms as u128; - } - } - } - } - } - #[cfg(not(feature = "streaming"))] - if !source_owns_playback { - if let Some(ref pos) = self.shared_position { - if app.is_streaming_active { - let position_ms = pos.load(std::sync::atomic::Ordering::Relaxed); - if position_ms > 0 { - app.song_progress_ms = position_ms as u128; - } - } - } - } - - #[cfg(any(feature = "audio-viz", feature = "audio-viz-cpal"))] - match env.viz_bars { - Some(desired_bars) => { - if self.audio_capture.is_none() { - // Built at the count we are about to ask for, so the first frame - // does not immediately throw the fresh cavacore plan away. - self.audio_capture = audio::AudioCaptureManager::new(desired_bars); - app.audio_capture_active = self.audio_capture.is_some(); - } - - if let Some(ref capture) = self.audio_capture { - if let Some(spectrum) = capture.get_spectrum(desired_bars) { - app.spectrum_data = Some(spectrum); - } - // Kept outside the spectrum arm: a dead stream must drop the - // "Capturing audio" status instead of freezing it on. - app.audio_capture_active = capture.is_active(); - } - } - None => { - if self.audio_capture.is_some() { - self.audio_capture = None; - app.audio_capture_active = false; - app.spectrum_data = None; - } - } - } - } - - /// The one-shot startup dispatch, fired by the frontend right after its - /// first frame: initial Spotify fetches, the startup route's data, the - /// persisted active source's sidebar data, and `--play-file`. - pub fn dispatch_startup(&mut self, app: &mut App) { - // Spotify-only startup fetches: skip them entirely when launched against a - // free source with no Spotify session, so the network layer doesn't reject - // three events with "connect Spotify" status flashes on every launch. - if app.spotify_connected { - app.dispatch(IoEvent::GetCurrentPlayback); - app.dispatch(IoEvent::GetPlaylists); - app.dispatch(IoEvent::GetUser); - } - // startup_route seeds the nav stack directly (App::new), bypassing the - // handlers that normally fetch a screen's data on navigation — kick - // off that fetch here or the screen renders empty until re-entered. - // (Home needs nothing extra; Discover fetches from within its menu.) - fetch_startup_route(app); - // A persisted non-Spotify active source needs its sidebar data loaded - // too (all of these are inert no-ops when the feature is off). - app.load_source_sidebar(app.active_source); - if app.user_config.behavior.enable_global_song_count { - app.dispatch(IoEvent::FetchGlobalSongCount); - } - app.dispatch(IoEvent::FetchAnnouncements); - - // `--play-file`: kick off local playback now that dispatch is wired. - if let Some(uri) = app.pending_play_file.take() { - app.dispatch(IoEvent::StartPlayback(Some(uri), None, None)); - } - - #[cfg(feature = "scripting")] - if let Some(engine) = self.script_engine.as_mut() { - engine.on_start(app); - } - } - - /// Run any plugin commands a keypress queued; the frontend calls this after - /// each key event. No-op without the `scripting` feature. - pub fn run_pending_script_commands(&mut self, app: &mut App) { - #[cfg(feature = "scripting")] - if let Some(engine) = self.script_engine.as_mut() { - engine.run_pending_commands(app); - } - #[cfg(not(feature = "scripting"))] - let _ = app; - } - - /// The window title the frontend should apply now, or `None` when it is - /// unchanged. Called once per frame (cheaper than a tick, and a title - /// change should not wait for one). - pub fn next_window_title(&mut self, app: &App) -> Option<String> { - presence::next_window_title(&mut self.window_title, app) - } - - /// On teardown: the default title to restore if this session changed it. - pub fn window_title_reset(&mut self) -> Option<&'static str> { - presence::window_title_reset(&mut self.window_title) - } - - /// The scripting quit hook. No-op without the `scripting` feature. - pub fn on_quit(&mut self, app: &mut App) { - #[cfg(feature = "scripting")] - if let Some(engine) = self.script_engine.as_mut() { - engine.on_quit(app); - } - #[cfg(not(feature = "scripting"))] - let _ = app; - } - - /// Clear any published rich presence on the way out. - pub fn clear_presence(&self) { - #[cfg(feature = "discord-rpc")] - if let Some(ref manager) = self.discord_rpc_manager { - manager.clear(); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::app::ActiveBlock; - use crate::core::user_config::UserConfig; - use std::sync::mpsc::{channel, Receiver}; - use std::time::SystemTime; - - fn app_on(route: RouteId, block: ActiveBlock, connected: bool) -> (App, Receiver<IoEvent>) { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), connected.then(SystemTime::now)); - app.push_navigation_stack(route, block); - (app, rx) - } - - #[test] - fn a_spotify_startup_route_fetches_nothing_without_a_session() { - let (mut app, rx) = app_on(RouteId::AlbumList, ActiveBlock::AlbumList, false); - - fetch_startup_route(&mut app); - - assert!(!rx - .try_iter() - .any(|event| matches!(event, IoEvent::GetCurrentUserSavedAlbums(_)))); - } - - #[test] - fn a_spotify_startup_route_fetches_its_data_with_a_session() { - let (mut app, rx) = app_on(RouteId::AlbumList, ActiveBlock::AlbumList, true); - - fetch_startup_route(&mut app); - - assert!(rx - .try_iter() - .any(|event| matches!(event, IoEvent::GetCurrentUserSavedAlbums(_)))); - } - - #[test] - fn the_stats_startup_route_loads_without_a_session() { - let (mut app, rx) = app_on(RouteId::Stats, ActiveBlock::Stats, false); - - fetch_startup_route(&mut app); - - assert!(app.stats_loading); - assert!(rx - .try_iter() - .any(|event| matches!(event, IoEvent::LoadListeningStats(_)))); - } -} diff --git a/src/core/driver/plan.rs b/src/core/driver/plan.rs deleted file mode 100644 index af5b4a7f..00000000 --- a/src/core/driver/plan.rs +++ /dev/null @@ -1,403 +0,0 @@ -//! Pure per-tick decisions, split out of [`super::Driver::tick`] in the house -//! style: scalars in, plain data out. Each one used to live inline in the -//! terminal event loop, where it was untestable without an audio sink, a -//! Spotify session, or a real clock; here every decision runs against fake -//! inputs. The driver probes live state (`player.is_finished()`, `Instant`), -//! asks these functions what to do, then applies the answer. - -#[cfg(feature = "art-decode")] -use crate::core::art::CoverArtStatus; -#[cfg(feature = "audio-decode-queue")] -use crate::infra::queue::{advance_decision, Decision, RepeatMode}; -use std::time::{Duration, Instant, SystemTime}; - -/// Whether the tick dispatches a token refresh now. Not while one is in -/// flight, not while a failed attempt backs off, and not while a decoded -/// source owns the sink: the next Spotify request refreshes lazily on its own. -pub(super) fn oauth_refresh_due( - expiry: Option<SystemTime>, - wall_now: SystemTime, - in_progress: bool, - decoded_owns_sink: bool, - retry_at: Option<Instant>, - now: Instant, -) -> bool { - let Some(expiry) = expiry else { - return false; - }; - if in_progress || decoded_owns_sink { - return false; - } - if retry_at.is_some_and(|at| now < at) { - return false; - } - crate::core::auth::should_refresh_token_at(expiry, wall_now) -} - -/// How often the active non-Spotify playback session is persisted to -/// `last_session.yml` while it keeps playing. -const SESSION_SAVE_INTERVAL: Duration = Duration::from_secs(3); - -/// What the session persister should do this tick. -#[derive(Debug, PartialEq)] -pub(super) enum SessionPersist { - /// A save is due: snapshot the session and write it. - Save, - /// The session just ended (Some -> None transition): clear the file so a - /// stale session is never resurrected on the next launch. - Clear, - None, -} - -/// Throttled `last_session.yml` persistence: save at most every -/// [`SESSION_SAVE_INTERVAL`] while a session is active (immediately on the -/// first tick that has one), and clear the file exactly once when it goes -/// away. -pub(super) fn session_persist_action( - has_session: bool, - was_present: bool, - last_save: Option<Instant>, - now: Instant, -) -> SessionPersist { - if has_session { - let due = last_save - .map(|t| now.duration_since(t) >= SESSION_SAVE_INTERVAL) - .unwrap_or(true); - if due { - SessionPersist::Save - } else { - SessionPersist::None - } - } else if was_present { - SessionPersist::Clear - } else { - SessionPersist::None - } -} - -/// Whether the native queue slot's finished track should advance the queue. -/// `advancing` is the atomic check-and-set flag that guarantees one dispatch -/// per finished track: the sink stays empty for the whole decode/download of -/// the next item, so without it every tick in that window would re-dispatch. -#[cfg(feature = "audio-decode-queue")] -pub(super) fn native_queue_advance_due(finished: bool, advancing: bool) -> bool { - finished && !advancing -} - -/// What the tick does about a decoded-source session, as plain data so the -/// glue between [`advance_decision`] and the dispatch/suspend/teardown calls -/// is testable without a sink. -#[cfg(feature = "audio-decode-queue")] -#[derive(Debug, PartialEq)] -pub(super) enum DecodedAdvance { - /// Mark the session advancing and dispatch the track change: replay the - /// current track (repeat-one) or advance within the context. - Dispatch { - replay: bool, - }, - /// Hand the sink to the native queue. - SuspendToQueue, - /// Context exhausted and the queue is empty: drop the session. - Teardown, - None, -} - -/// Decoded-source auto-advance for one tick, composing [`advance_decision`] -/// (the shared end-of-track policy) with the action the driver takes on it. -#[cfg(feature = "audio-decode-queue")] -pub(super) fn decoded_advance( - finished: bool, - advancing: bool, - has_next: bool, - queue_len: usize, - repeat: RepeatMode, -) -> DecodedAdvance { - match advance_decision(finished, advancing, has_next, queue_len, repeat) { - Decision::AdvanceContext => DecodedAdvance::Dispatch { replay: false }, - Decision::RepeatTrack => DecodedAdvance::Dispatch { replay: true }, - Decision::SuspendToQueue => DecodedAdvance::SuspendToQueue, - Decision::Teardown => DecodedAdvance::Teardown, - Decision::None => DecodedAdvance::None, - } -} - -/// What the per-tick cover-art evaluation should do. See the call site for why -/// this re-evaluates every tick against the desired key instead of latching to -/// the track identity. -#[cfg(feature = "art-decode")] -#[derive(Debug, PartialEq)] -pub(super) enum CoverArtAction { - /// New art resolved: latch its key, mark it loading, dispatch the fetch. - Fetch, - /// Same art as already requested: leave everything alone. - Keep, - /// No art to show (radio, art disabled, nothing playing): drop any stale - /// image once, then show `status` in the pane. - Drop { clear: bool, status: CoverArtStatus }, -} - -#[cfg(feature = "art-decode")] -pub(super) fn cover_art_action( - desired_key: Option<&str>, - last_requested_key: Option<&str>, - art_loaded: bool, - enabled: bool, - snapshot_present: bool, -) -> CoverArtAction { - match desired_key { - Some(key) => { - if last_requested_key == Some(key) { - CoverArtAction::Keep - } else { - CoverArtAction::Fetch - } - } - None => CoverArtAction::Drop { - clear: last_requested_key.is_some() || art_loaded, - status: if enabled && snapshot_present { - CoverArtStatus::Unavailable - } else { - CoverArtStatus::NotStarted - }, - }, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn inside_the_margin() -> (Option<SystemTime>, SystemTime) { - let now = SystemTime::now(); - (Some(now + Duration::from_secs(30)), now) - } - - #[test] - fn a_token_inside_the_margin_refreshes_under_a_spotify_owner() { - let (expiry, wall_now) = inside_the_margin(); - assert!(oauth_refresh_due( - expiry, - wall_now, - false, - false, - None, - Instant::now() - )); - } - - #[test] - fn a_decoded_owner_skips_the_timed_refresh() { - let (expiry, wall_now) = inside_the_margin(); - assert!(!oauth_refresh_due( - expiry, - wall_now, - false, - true, - None, - Instant::now() - )); - } - - #[test] - fn a_failed_refresh_does_not_re_dispatch_before_the_backoff_ends() { - let (expiry, wall_now) = inside_the_margin(); - let now = Instant::now(); - let retry_at = Some(now + Duration::from_secs(15)); - assert!(!oauth_refresh_due( - expiry, wall_now, false, false, retry_at, now - )); - assert!(oauth_refresh_due( - expiry, - wall_now, - false, - false, - retry_at, - now + Duration::from_secs(15) - )); - } - - #[test] - fn no_expiry_and_a_refresh_in_flight_both_wait() { - let (expiry, wall_now) = inside_the_margin(); - let now = Instant::now(); - assert!(!oauth_refresh_due(None, wall_now, false, false, None, now)); - assert!(!oauth_refresh_due(expiry, wall_now, true, false, None, now)); - } - - #[test] - fn first_tick_with_a_session_saves_immediately() { - let now = Instant::now(); - assert_eq!( - session_persist_action(true, false, None, now), - SessionPersist::Save - ); - } - - #[test] - fn session_saves_are_throttled_to_the_interval() { - let t0 = Instant::now(); - let just_after = t0 + Duration::from_secs(1); - let past_interval = t0 + SESSION_SAVE_INTERVAL; - - assert_eq!( - session_persist_action(true, true, Some(t0), just_after), - SessionPersist::None - ); - assert_eq!( - session_persist_action(true, true, Some(t0), past_interval), - SessionPersist::Save - ); - } - - #[test] - fn ending_a_session_clears_the_file_exactly_once() { - let now = Instant::now(); - // Some -> None transition: clear. - assert_eq!( - session_persist_action(false, true, Some(now), now), - SessionPersist::Clear - ); - // Already cleared (was_present false): nothing left to do. - assert_eq!( - session_persist_action(false, false, None, now), - SessionPersist::None - ); - } - - #[cfg(feature = "audio-decode-queue")] - mod decoded { - use super::*; - - #[test] - fn a_finished_queue_track_advances_the_queue_once() { - assert!(native_queue_advance_due(true, false)); - // The advancing latch holds the whole decode/download window. - assert!(!native_queue_advance_due(true, true)); - assert!(!native_queue_advance_due(false, false)); - } - - #[test] - fn a_finished_track_with_a_next_advances_the_context() { - assert_eq!( - decoded_advance(true, false, true, 0, RepeatMode::Off), - DecodedAdvance::Dispatch { replay: false } - ); - } - - #[test] - fn repeat_one_replays_instead_of_advancing() { - assert_eq!( - decoded_advance(true, false, true, 0, RepeatMode::Track), - DecodedAdvance::Dispatch { replay: true } - ); - } - - #[test] - fn a_waiting_queue_preempts_the_context_advance() { - // Even under repeat-one: a queued song must not consume the repeat. - assert_eq!( - decoded_advance(true, false, true, 1, RepeatMode::Track), - DecodedAdvance::SuspendToQueue - ); - } - - #[test] - fn an_exhausted_context_with_an_empty_queue_tears_down() { - assert_eq!( - decoded_advance(true, false, false, 0, RepeatMode::Off), - DecodedAdvance::Teardown - ); - } - - #[test] - fn an_advance_already_in_flight_dispatches_nothing() { - // The sink is empty for the whole decode; without the latch every tick - // in that window would dispatch another skip. - assert_eq!( - decoded_advance(true, true, true, 0, RepeatMode::Off), - DecodedAdvance::None - ); - assert_eq!( - decoded_advance(false, false, true, 0, RepeatMode::Off), - DecodedAdvance::None - ); - } - } - - #[cfg(feature = "art-decode")] - mod cover_art { - use super::*; - - #[test] - fn newly_resolved_art_fetches_and_unchanged_art_keeps() { - assert_eq!( - cover_art_action(Some("url-a"), None, false, true, true), - CoverArtAction::Fetch - ); - assert_eq!( - cover_art_action(Some("url-a"), Some("url-a"), true, true, true), - CoverArtAction::Keep - ); - } - - #[test] - fn art_resolving_late_still_fetches() { - // Native streaming: the snapshot's image_url catches up seconds after - // the track identity flips, so the desired key appears (or changes) - // ticks later. The key comparison, not an identity latch, must fire. - assert_eq!( - cover_art_action(None, None, false, true, true), - CoverArtAction::Drop { - clear: false, - status: CoverArtStatus::Unavailable - } - ); - assert_eq!( - cover_art_action(Some("real-url"), None, false, true, true), - CoverArtAction::Fetch - ); - assert_eq!( - cover_art_action(Some("next-url"), Some("real-url"), true, true, true), - CoverArtAction::Fetch - ); - } - - #[test] - fn losing_the_art_drops_the_stale_image_once() { - // A track with art was playing; now radio (no art) is: clear once... - assert_eq!( - cover_art_action(None, Some("url-a"), true, true, true), - CoverArtAction::Drop { - clear: true, - status: CoverArtStatus::Unavailable - } - ); - // ...and quiet ticks afterwards have nothing left to clear. - assert_eq!( - cover_art_action(None, None, false, true, true), - CoverArtAction::Drop { - clear: false, - status: CoverArtStatus::Unavailable - } - ); - } - - #[test] - fn disabled_art_and_silence_read_as_not_started() { - assert_eq!( - cover_art_action(None, Some("url-a"), true, false, true), - CoverArtAction::Drop { - clear: true, - status: CoverArtStatus::NotStarted - } - ); - assert_eq!( - cover_art_action(None, None, false, true, false), - CoverArtAction::Drop { - clear: false, - status: CoverArtStatus::NotStarted - } - ); - } - } -} diff --git a/src/core/driver/presence.rs b/src/core/driver/presence.rs deleted file mode 100644 index 091cd48c..00000000 --- a/src/core/driver/presence.rs +++ /dev/null @@ -1,394 +0,0 @@ -//! Presence sync: Discord Rich Presence, MPRIS, and the window title. Each -//! keeps a last-published copy so quiet ticks publish nothing. Moved verbatim -//! out of the terminal runner; nothing here touches a terminal — the frontend -//! only applies the returned window title. - -use crate::core::app::App; -#[cfg(feature = "discord-rpc")] -use crate::infra::discord_rpc; -#[cfg(all(feature = "mpris", target_os = "linux"))] -use crate::infra::mpris; - -pub(super) const DEFAULT_WINDOW_TITLE: &str = "spt - spotatui"; - -#[derive(Default)] -pub(super) struct WindowTitleState { - last_title: Option<String>, -} - -#[cfg(feature = "discord-rpc")] -#[derive(Clone, Debug, PartialEq)] -struct DiscordTrackInfo { - title: String, - artist: String, - album: String, - image_url: Option<String>, - duration_ms: u32, -} - -#[cfg(feature = "discord-rpc")] -#[derive(Default)] -pub(super) struct DiscordPresenceState { - last_track: Option<DiscordTrackInfo>, - last_is_playing: Option<bool>, - last_progress_ms: u128, -} - -#[cfg(all(feature = "mpris", target_os = "linux"))] -#[derive(Default, PartialEq)] -struct MprisMetadata { - title: String, - artists: Vec<String>, - album: String, - duration_ms: u32, - art_url: Option<String>, -} - -#[cfg(all(feature = "mpris", target_os = "linux"))] -#[derive(Default)] -pub(super) struct MprisState { - last_metadata: Option<MprisMetadata>, - last_is_playing: Option<bool>, - last_shuffle: Option<bool>, - last_loop: Option<mpris::LoopStatusEvent>, -} - -#[cfg(feature = "discord-rpc")] -fn build_discord_playback(app: &App) -> Option<discord_rpc::DiscordPlayback> { - let snapshot = crate::infra::media_metadata::current_playback_snapshot(app)?; - let artist = snapshot.primary_artist(); - let track_info = DiscordTrackInfo { - title: snapshot.metadata.title, - artist, - album: snapshot.metadata.album, - image_url: snapshot.metadata.image_url, - duration_ms: snapshot.metadata.duration_ms, - }; - - let base_state = if track_info.album.is_empty() { - track_info.artist.clone() - } else { - format!("{} - {}", track_info.artist, track_info.album) - }; - let state = if snapshot.is_playing { - base_state - } else if base_state.is_empty() { - "Paused".to_string() - } else { - format!("Paused: {}", base_state) - }; - - Some(discord_rpc::DiscordPlayback { - title: track_info.title, - artist: track_info.artist, - album: track_info.album, - state, - image_url: track_info.image_url, - duration_ms: track_info.duration_ms, - progress_ms: snapshot.progress_ms, - is_playing: snapshot.is_playing, - }) -} - -#[cfg(feature = "discord-rpc")] -pub(super) fn update_discord_presence( - manager: &discord_rpc::DiscordRpcManager, - state: &mut DiscordPresenceState, - app: &App, -) { - let playback = build_discord_playback(app); - - match playback { - Some(playback) => { - let track_info = DiscordTrackInfo { - title: playback.title.clone(), - artist: playback.artist.clone(), - album: playback.album.clone(), - image_url: playback.image_url.clone(), - duration_ms: playback.duration_ms, - }; - - let track_changed = state.last_track.as_ref() != Some(&track_info); - let playing_changed = state.last_is_playing != Some(playback.is_playing); - let progress_delta = playback.progress_ms.abs_diff(state.last_progress_ms); - let progress_changed = progress_delta > 5000; - - if track_changed || playing_changed || progress_changed { - manager.set_activity(&playback); - state.last_track = Some(track_info); - state.last_is_playing = Some(playback.is_playing); - state.last_progress_ms = playback.progress_ms; - } - } - None => { - if state.last_track.is_some() { - manager.clear(); - state.last_track = None; - state.last_is_playing = None; - state.last_progress_ms = 0; - } - } - } -} - -#[cfg(all(feature = "mpris", target_os = "linux"))] -pub(super) fn update_mpris_state(manager: &mpris::MprisManager, state: &mut MprisState, app: &App) { - use rspotify::model::enums::RepeatState; - - // Local-file playback owns its own state and never populates the Spotify - // playback context, so it takes a dedicated path that reads metadata, play - // state, and position straight from the live local player. Skipped while the - // native queue owns the sink: `local_playback` is then a *suspended* context, - // so fall through to the snapshot path, which renders the queue slot and - // clears its shuffle/repeat instead of publishing this context's stale modes. - #[cfg(feature = "local-files")] - if let Some(local) = app - .local_playback - .as_ref() - .filter(|_| !app.queue_owns_playback()) - { - use crate::infra::media_metadata::{select_media_metadata, LocalMediaMetadata}; - - let is_playing = !local.player.is_paused(); - let position_ms = local.player.position().as_millis() as u64; - - // `select_media_metadata` is the single, unit-tested decision for which - // source the OS integration follows; local always wins while it is active. - let metadata = select_media_metadata( - Some(LocalMediaMetadata { - title: local.name.clone(), - artists: vec![local.artists.clone()], - album: local.album.clone(), - duration_ms: local.duration_ms as u32, - }), - None, - ) - .expect("local metadata is present"); - - let new_metadata = MprisMetadata { - title: metadata.title.clone(), - artists: metadata.artists.clone(), - album: metadata.album.clone(), - duration_ms: metadata.duration_ms, - art_url: metadata.image_url.clone(), - }; - if state.last_metadata.as_ref() != Some(&new_metadata) { - manager.set_metadata( - &metadata.title, - &metadata.artists, - &metadata.album, - metadata.duration_ms, - metadata.image_url, - ); - state.last_metadata = Some(new_metadata); - } - - if state.last_is_playing != Some(is_playing) { - manager.set_playback_status(is_playing); - state.last_is_playing = Some(is_playing); - } - - manager.set_position(position_ms); - - // Local playback carries the decoded shuffle/repeat modes; push them like - // the snapshot branch below so keyboard and MPRIS toggles reach clients - // (this dedicated branch returns before the snapshot path runs). - if state.last_shuffle != Some(app.decoded_shuffle) { - manager.set_shuffle(app.decoded_shuffle); - state.last_shuffle = Some(app.decoded_shuffle); - } - let loop_status = match app.decoded_repeat { - crate::infra::queue::RepeatMode::Off => mpris::LoopStatusEvent::None, - crate::infra::queue::RepeatMode::Track => mpris::LoopStatusEvent::Track, - crate::infra::queue::RepeatMode::Context => mpris::LoopStatusEvent::Playlist, - }; - if state.last_loop != Some(loop_status) { - manager.set_loop_status(loop_status); - state.last_loop = Some(loop_status); - } - return; - } - - if let Some(snapshot) = crate::infra::media_metadata::current_playback_snapshot(app) { - let new_metadata = MprisMetadata { - title: snapshot.metadata.title.clone(), - artists: snapshot.metadata.artists.clone(), - album: snapshot.metadata.album.clone(), - duration_ms: snapshot.metadata.duration_ms, - art_url: snapshot.metadata.image_url.clone(), - }; - if state.last_metadata.as_ref() != Some(&new_metadata) { - manager.set_metadata( - &snapshot.metadata.title, - &snapshot.metadata.artists, - &snapshot.metadata.album, - snapshot.metadata.duration_ms, - snapshot.metadata.image_url.clone(), - ); - state.last_metadata = Some(new_metadata); - } - - if state.last_is_playing != Some(snapshot.is_playing) { - manager.set_playback_status(snapshot.is_playing); - state.last_is_playing = Some(snapshot.is_playing); - } - - manager.set_position(snapshot.progress_ms as u64); - - if state.last_shuffle != Some(snapshot.shuffle) { - manager.set_shuffle(snapshot.shuffle); - state.last_shuffle = Some(snapshot.shuffle); - } - - // A `None` repeat means the source has no repeat control (native queue, - // radio); reset to `None` rather than leaving a stale Track/Playlist that a - // prior decoded context pushed. - let loop_status = match snapshot.repeat { - Some(RepeatState::Track) => mpris::LoopStatusEvent::Track, - Some(RepeatState::Context) => mpris::LoopStatusEvent::Playlist, - Some(RepeatState::Off) | None => mpris::LoopStatusEvent::None, - }; - if state.last_loop != Some(loop_status) { - manager.set_loop_status(loop_status); - state.last_loop = Some(loop_status); - } - } else if state.last_metadata.is_some() { - manager.set_stopped(); - state.last_metadata = None; - state.last_is_playing = None; - } -} - -fn playback_window_title(app: &App) -> String { - let Some(snapshot) = crate::infra::media_metadata::current_playback_snapshot(app) else { - return DEFAULT_WINDOW_TITLE.to_string(); - }; - - let title = sanitize_window_title_component(&snapshot.metadata.title); - let artist_raw = sanitize_window_title_component(&snapshot.primary_artist()); - // Compose the artist segment with the em-dash separator, matching today's - // `"{title} — {artist}"` output; omitted when there's no artist. - let artist = if artist_raw.trim().is_empty() { - String::new() - } else { - format!(" — {}", artist_raw) - }; - app - .user_config - .format - .window_title - .render(&[&title, &artist]) -} - -fn sanitize_window_title_component(value: &str) -> String { - value.chars().filter(|c| !c.is_control()).collect() -} - -pub(super) fn next_window_title(state: &mut WindowTitleState, app: &App) -> Option<String> { - if !app.user_config.behavior.set_window_title { - return state - .last_title - .take() - .map(|_| DEFAULT_WINDOW_TITLE.to_string()); - } - - let title = playback_window_title(app); - if state.last_title.as_ref() == Some(&title) { - None - } else { - state.last_title = Some(title.clone()); - Some(title) - } -} - -/// The teardown half: the default title to restore on exit, or `None` when -/// the title was never changed away from it. Clears the latch either way. -pub(super) fn window_title_reset(state: &mut WindowTitleState) -> Option<&'static str> { - state - .last_title - .take() - .filter(|title| title != DEFAULT_WINDOW_TITLE) - .map(|_| DEFAULT_WINDOW_TITLE) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::app::NativeTrackInfo; - use std::{sync::mpsc::channel, time::SystemTime}; - - fn app() -> App { - let (tx, _rx) = channel(); - App::new( - tx, - crate::core::user_config::UserConfig::new(), - Some(SystemTime::now()), - ) - } - - #[test] - fn playback_window_title_uses_current_native_track() { - let mut app = app(); - app.is_streaming_active = true; - app.native_track_info = Some(NativeTrackInfo { - name: "The Track".to_string(), - artists: vec!["The Artist".to_string()], - album: "The Album".to_string(), - duration_ms: 180_000, - kind: crate::core::app::NativeTrackKind::Track, - image_url: None, - }); - - assert_eq!(playback_window_title(&app), "The Track — The Artist"); - } - - #[test] - fn playback_window_title_strips_control_characters() { - let mut app = app(); - app.is_streaming_active = true; - app.native_track_info = Some(NativeTrackInfo { - name: "The\x1b]2;Bad\x07 Track".to_string(), - artists: vec!["The\nArtist".to_string()], - album: "The Album".to_string(), - duration_ms: 180_000, - kind: crate::core::app::NativeTrackKind::Track, - image_url: None, - }); - - assert_eq!(playback_window_title(&app), "The]2;Bad Track — TheArtist"); - } - - #[test] - fn playback_window_title_falls_back_without_playback() { - let app = app(); - - assert_eq!(playback_window_title(&app), DEFAULT_WINDOW_TITLE); - } - - #[test] - fn disabling_window_title_restores_default_once() { - let mut app = app(); - let mut state = WindowTitleState { - last_title: Some("The Track — The Artist".to_string()), - }; - app.user_config.behavior.set_window_title = false; - - assert_eq!( - next_window_title(&mut state, &app).as_deref(), - Some(DEFAULT_WINDOW_TITLE) - ); - assert_eq!(next_window_title(&mut state, &app), None); - } - - #[test] - fn window_title_reset_fires_only_after_a_custom_title() { - let mut untouched = WindowTitleState::default(); - assert_eq!(window_title_reset(&mut untouched), None); - - let mut changed = WindowTitleState { - last_title: Some("The Track — The Artist".to_string()), - }; - assert_eq!(window_title_reset(&mut changed), Some(DEFAULT_WINDOW_TITLE)); - assert_eq!(window_title_reset(&mut changed), None); - } -} diff --git a/src/core/first_run.rs b/src/core/first_run.rs deleted file mode 100644 index 29e8a6a6..00000000 --- a/src/core/first_run.rs +++ /dev/null @@ -1,383 +0,0 @@ -//! First-run source picker. -//! -//! Historically spotatui forced a Spotify OAuth login before the TUI could open. -//! Now that YouTube, Subsonic/Navidrome, Internet Radio, and Local Files are all -//! free, first launch instead asks which source to set up. Picking Spotify falls -//! through to the existing auth wizard; picking a free source seeds a default -//! `client.yml` (so Spotify can still be added later via in-TUI login), records -//! the choice as the active source, and collects any source-specific config. -//! -//! Only sources whose Cargo feature is compiled in are offered. A build with just -//! Spotify (the slim build) shows no picker and keeps the original -//! behavior. -//! -//! This module is the selection *logic*; all presentation goes through the -//! [`Onboarding`] trait (the terminal picker itself lives in `tui/first_run.rs`). - -use crate::core::config::ClientConfig; -use crate::core::onboarding::Onboarding; -use crate::core::source::Source; -use crate::core::state::RuntimeState; -use crate::core::user_config::UserConfig; -#[cfg(feature = "subsonic")] -use anyhow::anyhow; -use anyhow::Result; - -fn config_file_path_display(user_config: &UserConfig) -> String { - user_config - .path_to_config - .as_ref() - .map(|paths| paths.config_file_path.clone()) - .or_else(|| crate::core::paths::app_config_dir().map(|dir| dir.join("config.yml"))) - .map(|path| path.display().to_string()) - .unwrap_or_else(|| "config.yml".to_string()) -} - -/// A config that cannot be written (a read-only dotfiles link) does not abort -/// the picker; `false` when the save failed. -fn save_config_or_warn(user_config: &UserConfig, onboarding: &dyn Onboarding) -> bool { - match user_config.save_config() { - Ok(()) => true, - Err(e) => { - let path = config_file_path_display(user_config); - log::warn!("could not save {path}: {e}"); - onboarding.info(&format!("Could not save {path}: {e}")); - false - } - } -} - -/// Run the interactive first-run source picker. A no-op after the first launch -/// (detected by the presence of `client.yml`) and when only Spotify is compiled -/// in. Must be called before [`ClientConfig::load_config`], which would otherwise -/// trigger the Spotify-only auth wizard on a fresh install. -pub async fn run_first_run_picker( - user_config: &mut UserConfig, - runtime_state: &mut RuntimeState, - client_config: &mut ClientConfig, - onboarding: &dyn Onboarding, -) -> Result<()> { - // First run is detected by the absence of the Spotify client config file. - let paths = client_config.get_or_build_paths()?; - if paths.config_file_path.exists() { - return Ok(()); - } - - let options = compiled_in_sources(); - - // Only Spotify available (the slim build): keep today's behavior and let - // `load_config` run the wizard. - if options.len() == 1 { - return Ok(()); - } - - let selections = match onboarding.pick_sources(&options)? { - Some(selected) => selected, - // Cancelled (esc / ctrl-c) or nothing checked: fall through to the Spotify - // wizard, matching the historical default. - None => return Ok(()), - }; - - apply_selections( - selections, - user_config, - runtime_state, - client_config, - onboarding, - ) - .await -} - -/// Act on the sources the user chose. `active_source` is set to the first checked -/// source in display order; every checked free source has its config collected. -async fn apply_selections( - selections: Vec<Source>, - user_config: &mut UserConfig, - runtime_state: &mut RuntimeState, - client_config: &mut ClientConfig, - onboarding: &dyn Onboarding, -) -> Result<()> { - // Spotify only: keep today's behavior and let `load_config` run the wizard. - if selections == [Source::Spotify] { - return Ok(()); - } - - let spotify_selected = selections.contains(&Source::Spotify); - let active = selections[0]; - - // If Spotify wasn't chosen, seed a default `client.yml` (no OAuth) so a later - // in-TUI Spotify login has a client id to work with. If Spotify *was* chosen we - // leave `client.yml` absent so `load_config` runs the OAuth wizard below. - if !spotify_selected { - client_config.init_default_spotify_config()?; - } - runtime_state.active_source = active; - // The global song counter opt-in is asked before this picker runs, so the - // user's choice already sits on `user_config`; save_config persists it here. - // The active source is runtime state, so save it separately. - save_config_or_warn(user_config, onboarding); - let state_path = crate::core::state::default_state_path()?; - crate::core::state::save( - &state_path, - &crate::core::state::PersistedRuntimeState::active_source(runtime_state.active_source), - )?; - - // Collect credentials / check prerequisites for each chosen free source. - for source in &selections { - if *source != Source::Spotify { - configure_source(*source, user_config, onboarding).await?; - } - } - - if spotify_selected { - // Fall through: `load_config` runs the existing Spotify auth wizard. - onboarding.info("\nSetting up your other sources, then we'll log in to Spotify...\n"); - return Ok(()); - } - - onboarding.info(&format!( - "\nStarting spotatui with {} as your source. Press `d` anytime to switch or to log in to Spotify.\n", - active.label() - )); - - Ok(()) -} - -/// The sources whose Cargo feature is compiled into this build, in display order. -/// Spotify is always present. -fn compiled_in_sources() -> Vec<Source> { - // `mut` is unused in a Spotify-only (slim) build where every push is cfg'd out. - #[cfg_attr(not(feature = "audio-decode"), allow(unused_mut))] - let mut options = vec![Source::Spotify]; - #[cfg(feature = "youtube")] - options.push(Source::YouTube); - #[cfg(feature = "subsonic")] - options.push(Source::Subsonic); - #[cfg(feature = "internet-radio")] - options.push(Source::Radio); - #[cfg(feature = "local-files")] - options.push(Source::Local); - #[cfg(feature = "qobuz")] - options.push(Source::Qobuz); - options -} - -// `user_config` and `onboarding` are only read by credential/config-collecting -// sources; a build with none of them (slim, or Qobuz alone) leaves them unused. -#[cfg_attr( - not(any(feature = "subsonic", feature = "youtube", feature = "local-files")), - allow(unused_variables) -)] -async fn configure_source( - source: Source, - user_config: &mut UserConfig, - onboarding: &dyn Onboarding, -) -> Result<()> { - match source { - #[cfg(feature = "subsonic")] - Source::Subsonic => configure_subsonic(user_config, onboarding).await?, - #[cfg(feature = "youtube")] - Source::YouTube => configure_youtube(user_config, onboarding), - #[cfg(feature = "local-files")] - Source::Local => configure_local(user_config, onboarding), - #[cfg(feature = "qobuz")] - Source::Qobuz => configure_qobuz(onboarding).await?, - // Radio needs no setup; other sources are handled above when compiled in. - _ => {} - } - Ok(()) -} - -/// Log in to Qobuz through the browser and save the credentials file. -#[cfg(feature = "qobuz")] -async fn configure_qobuz(onboarding: &dyn Onboarding) -> Result<()> { - use crate::infra::qobuz::{auth, shared_qobuz_client, QobuzSource}; - - onboarding.info( - "\nQobuz setup: spotatui logs in through your browser (a paid Qobuz subscription is needed).", - ); - onboarding.progress("Fetching the Qobuz web player constants... "); - let constants = match auth::resolve_constants(&shared_qobuz_client(), None).await { - Ok(constants) => { - onboarding.info("OK"); - constants - } - Err(e) => { - onboarding.info(&format!("failed: {e:#}")); - onboarding.info("Press `d` in the app and pick Qobuz to try again."); - return Ok(()); - } - }; - - let attempt = match auth::LoginAttempt::bind(constants.clone()).await { - Ok(attempt) => attempt, - Err(e) => { - onboarding.info(&format!("Qobuz login could not start: {e:#}")); - onboarding.info("Press `d` in the app and pick Qobuz to try again."); - return Ok(()); - } - }; - let url = attempt.url(); - onboarding.info("\nAttempting to open this URL in your browser:"); - onboarding.info(&format!("{url}\n")); - if let Err(e) = open::that_detached(&url) { - onboarding.info(&format!("Failed to open browser automatically: {e}")); - onboarding.info("Please manually open the URL above in your browser."); - } - onboarding.info("Waiting for the Qobuz login to complete..."); - - let credentials = match attempt.wait().await { - Ok(credentials) => credentials, - Err(e) => { - onboarding.info(&format!("Qobuz login failed: {e:#}")); - onboarding.info("Press `d` in the app and pick Qobuz to try again."); - return Ok(()); - } - }; - auth::save_login(&credentials)?; - onboarding.info("Logged in to Qobuz."); - - // Best-effort stream check: a failure is not fatal, the login is saved. - onboarding.progress("Testing the stream session... "); - let source = QobuzSource::new( - constants.app_id, - constants.app_secret, - credentials.user_auth_token, - ); - match source.session_start().await { - Ok(_) => onboarding.info("OK"), - Err(e) => onboarding.info(&format!("failed: {e:#}")), - } - Ok(()) -} - -#[cfg(feature = "subsonic")] -async fn configure_subsonic( - user_config: &mut UserConfig, - onboarding: &dyn Onboarding, -) -> Result<()> { - onboarding.info("\nSubsonic / Navidrome setup:"); - let url = prompt_required(onboarding, "Server URL (e.g. https://demo.navidrome.org)")?; - let username = prompt_required(onboarding, "Username")?; - let password = prompt_required(onboarding, "Password")?; - - user_config.behavior.subsonic_url = Some(url.clone()); - user_config.behavior.subsonic_username = Some(username.clone()); - user_config.behavior.subsonic_password = Some(password.clone()); - let saved = save_config_or_warn(user_config, onboarding); - - // Best-effort connectivity check: a failure is not fatal (the server may just - // be temporarily down), the details are already saved. - onboarding.progress("Testing connection... "); - let client = crate::infra::subsonic::SubsonicSource::new(url, username, password); - match client.ping().await { - Ok(()) => onboarding.info("OK"), - Err(e) => { - onboarding.info(&format!("failed: {e}")); - if saved { - onboarding.info(&format!( - "Saved anyway. Fix the details in {} and relaunch if needed.", - config_file_path_display(user_config) - )); - } else { - onboarding.info("The details are kept for this session only."); - } - } - } - - Ok(()) -} - -#[cfg(feature = "youtube")] -fn configure_youtube(user_config: &UserConfig, onboarding: &dyn Onboarding) { - let ytdlp = user_config - .behavior - .ytdlp_path - .clone() - .unwrap_or_else(|| "yt-dlp".to_string()); - - onboarding.progress("\nChecking for yt-dlp... "); - match std::process::Command::new(&ytdlp).arg("--version").output() { - Ok(output) if output.status.success() => { - let version = String::from_utf8_lossy(&output.stdout); - onboarding.info(&format!("found ({})", version.trim())); - } - _ => { - onboarding.info("not found"); - onboarding.info("YouTube playback needs the `yt-dlp` binary on your PATH."); - onboarding - .info("Install it (e.g. `pipx install yt-dlp` or your distro package) and relaunch."); - onboarding.info(&format!( - "If it lives at a custom path, set behavior.ytdlp_path in {}.", - config_file_path_display(user_config) - )); - } - } -} - -#[cfg(feature = "local-files")] -fn configure_local(user_config: &UserConfig, onboarding: &dyn Onboarding) { - match &user_config.behavior.local_music_path { - Some(path) => { - onboarding.info(&format!("\nLocal files will be read from: {path}")); - onboarding.info(&format!( - "(Change behavior.local_music_path in {} to use another folder.)", - config_file_path_display(user_config) - )); - } - None => { - onboarding.info("\nNo music folder was detected automatically."); - onboarding.info(&format!( - "Set behavior.local_music_path in {}.", - config_file_path_display(user_config) - )); - } - } -} - -// Only credential-collecting sources (currently Subsonic) use this. -#[cfg(feature = "subsonic")] -fn prompt_required(onboarding: &dyn Onboarding, label: &str) -> Result<String> { - const MAX_RETRIES: u8 = 5; - let mut retries = 0; - loop { - let input = onboarding.prompt_line(&format!(" {label}: "))?; - let trimmed = input.trim().to_string(); - if !trimmed.is_empty() { - return Ok(trimmed); - } - onboarding.info(" (required)"); - retries += 1; - if retries >= MAX_RETRIES { - return Err(anyhow!("Maximum retries ({MAX_RETRIES}) exceeded.")); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::test_helpers::ScriptedOnboarding; - - #[test] - fn a_failed_config_save_warns_and_continues() { - let dir = tempfile::tempdir().unwrap(); - // A missing parent fails the write on every platform, root included. - let config_path = dir.path().join("no-such-directory").join("config.yml"); - let mut user_config = UserConfig::new(); - user_config - .path_to_config - .replace(crate::core::user_config::UserConfigPaths { - config_file_path: config_path.clone(), - }); - let onboarding = ScriptedOnboarding::with_answers(&[]); - - assert!(!save_config_or_warn(&user_config, &onboarding)); - - assert!(!config_path.exists()); - let shown = onboarding.shown.lock().unwrap(); - assert!(shown - .iter() - .any(|line| line.starts_with(&format!("Could not save {}: ", config_path.display())))); - } -} diff --git a/src/core/format.rs b/src/core/format.rs deleted file mode 100644 index dc388d9c..00000000 --- a/src/core/format.rs +++ /dev/null @@ -1,237 +0,0 @@ -//! Format-string templates for customizable playbar / window-title text. -//! -//! A [`Template`] is a sequence of literal segments and `{placeholder}` segments. -//! The syntax deliberately mirrors `format!` placeholders loosely: -//! - `{key}` is substituted with the named value at render time. -//! - `{{` and `}}` produce a literal `{` and `}`. -//! -//! Width specifiers are **not** supported in v1; callers that need a padded -//! field (e.g. the 7-char play state) pre-pad the value before substituting. - -use anyhow::{anyhow, Result}; -use std::collections::HashMap; - -/// One piece of a parsed [`Template`]. -#[derive(Clone, Debug, PartialEq)] -enum Segment { - Literal(String), - Placeholder(usize), -} - -/// A parsed, reusable format template. -#[derive(Clone, Debug, PartialEq)] -pub struct Template { - segments: Vec<Segment>, -} - -impl Template { - /// Parse `input`, allowing only the keys listed in `allowed`. - /// - /// Errors (unknown key, unbalanced brace) are hard errors listing the valid - /// keys, consistent with the rest of the config validation policy. - pub fn parse(input: &str, allowed: &[&str]) -> Result<Self> { - let key_index: HashMap<&str, usize> = - allowed.iter().enumerate().map(|(i, k)| (*k, i)).collect(); - - let bytes = input.as_bytes(); - let mut segments: Vec<Segment> = Vec::new(); - let mut buf = String::new(); - let mut i = 0; - let list = || allowed.to_vec().join(", "); - - while i < bytes.len() { - let b = bytes[i]; - if b == b'{' { - // escaped {{ - if i + 1 < bytes.len() && bytes[i + 1] == b'{' { - buf.push('{'); - i += 2; - continue; - } - // placeholder — find closing } - let start = i + 1; - let mut j = start; - while j < bytes.len() && bytes[j] != b'}' { - j += 1; - } - if j >= bytes.len() { - return Err(anyhow!( - "format template has unbalanced '{{' (missing '}}'): valid keys are {}", - list() - )); - } - let key = &input[start..j]; - let key_trimmed = key.trim(); - let idx = key_index.get(key_trimmed).copied().ok_or_else(|| { - anyhow!( - "format template references unknown key '{{{}}}' (allowed: {})", - key_trimmed, - list() - ) - })?; - if !buf.is_empty() { - segments.push(Segment::Literal(std::mem::take(&mut buf))); - } - segments.push(Segment::Placeholder(idx)); - i = j + 1; - } else if b == b'}' { - // escaped }} - if i + 1 < bytes.len() && bytes[i + 1] == b'}' { - buf.push('}'); - i += 2; - continue; - } - return Err(anyhow!( - "format template has unbalanced '}}' (no matching '{{'): valid keys are {}", - list() - )); - } else { - // safe because we only index ASCII braces above; push the char. - // Using byte slicing from the original str keeps multi-byte UTF-8 intact. - let ch_start = i; - // advance by one UTF-8 char - let next = i + utf8_len(b); - buf.push_str(&input[ch_start..next]); - i = next; - } - } - if !buf.is_empty() { - segments.push(Segment::Literal(buf)); - } - Ok(Self { segments }) - } - - /// Render the template. `values` is indexed by the placeholder index assigned - /// during [`parse`](Self::parse) (position in `allowed`). - /// - /// Newlines / tabs in the rendered output are stripped — the terminal UI - /// treats text as text and would clip across rows, so this is the only real - /// injection surface. - pub fn render(&self, values: &[&str]) -> String { - let mut out = String::new(); - for seg in &self.segments { - match seg { - Segment::Literal(s) => out.push_str(s), - Segment::Placeholder(idx) => { - if let Some(v) = values.get(*idx) { - out.push_str(v); - } - } - } - } - out.retain(|c| c != '\n' && c != '\r' && c != '\t'); - out - } -} - -/// Number of bytes consumed by the UTF-8 codepoint whose first byte is `b`. -fn utf8_len(b: u8) -> usize { - if b < 0x80 { - 1 - } else if b >> 5 == 0b110 { - 2 - } else if b >> 4 == 0b1110 { - 3 - } else { - 4 - } -} - -pub fn millis_to_minutes(millis: u128) -> String { - let minutes = millis / 60000; - let seconds = (millis % 60000) / 1000; - let seconds_display = if seconds < 10 { - format!("0{}", seconds) - } else { - format!("{}", seconds) - }; - - if seconds == 60 { - format!("{}:00", minutes + 1) - } else { - format!("{}:{}", minutes, seconds_display) - } -} - -pub fn display_track_progress(progress: u128, track_duration: std::time::Duration) -> String { - let duration = millis_to_minutes(track_duration.as_millis()); - let progress_display = millis_to_minutes(progress); - let remaining = millis_to_minutes(track_duration.as_millis().saturating_sub(progress)); - - format!("{}/{} (-{})", progress_display, duration, remaining,) -} - -#[cfg(test)] -mod tests { - use super::*; - - const KEYS: &[&str] = &["state", "device", "volume"]; - - #[test] - fn parse_and_render_simple() { - let t = Template::parse("{state} on {device} ({volume}%)", KEYS).unwrap(); - assert_eq!( - t.render(&["Playing", "Living Room", "42"]), - "Playing on Living Room (42%)" - ); - } - - #[test] - fn escapes_braces() { - let t = Template::parse("{{state}} = {state}", KEYS).unwrap(); - assert_eq!(t.render(&["Playing", "", ""]), "{state} = Playing"); - } - - #[test] - fn unknown_key_errors() { - let err = Template::parse("{bogus}", KEYS).unwrap_err(); - assert!(err.to_string().contains("bogus")); - assert!(err.to_string().contains("state")); - } - - #[test] - fn unbalanced_brace_errors() { - assert!(Template::parse("{state", KEYS).is_err()); - assert!(Template::parse("state}", KEYS).is_err()); - } - - #[test] - fn strips_newlines() { - let t = Template::parse("{state}", KEYS).unwrap(); - // a placeholder value containing a newline is sanitized - assert_eq!(t.render(&["Play\ning"]), "Playing"); - } - - #[test] - fn empty_allowed_key_errors_cleanly() { - let err = Template::parse("{x}", &[]).unwrap_err(); - assert!(err.to_string().contains("unknown key")); - } - - #[test] - fn literal_only() { - let t = Template::parse("just text", KEYS).unwrap(); - assert_eq!(t.render(&[]), "just text"); - } - - #[test] - fn millis_to_minutes_test() { - assert_eq!(millis_to_minutes(0), "0:00"); - assert_eq!(millis_to_minutes(1000), "0:01"); - assert_eq!(millis_to_minutes(1500), "0:01"); - assert_eq!(millis_to_minutes(1900), "0:01"); - assert_eq!(millis_to_minutes(60 * 1000), "1:00"); - assert_eq!(millis_to_minutes(60 * 1500), "1:30"); - } - - #[test] - fn display_track_progress_test() { - use std::time::Duration; - let two_minutes = Duration::from_millis(2 * 60 * 1000); - assert_eq!(display_track_progress(0, two_minutes), "0:00/2:00 (-2:00)"); - assert_eq!( - display_track_progress(Duration::from_millis(60 * 1000).as_millis(), two_minutes), - "1:00/2:00 (-1:00)" - ); - } -} diff --git a/src/core/geometry.rs b/src/core/geometry.rs deleted file mode 100644 index d2ff7b26..00000000 --- a/src/core/geometry.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Frontend-neutral viewport geometry. -//! -//! `Viewport` is the rendering surface size in the frontend's own cells -//! (terminal cells for the TUI). It replaces `ratatui::layout::Size` in `App` -//! state so `core/` carries no terminal-toolkit types. - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct Viewport { - pub width: u16, - pub height: u16, -} diff --git a/src/core/input.rs b/src/core/input.rs deleted file mode 100644 index b4beef95..00000000 --- a/src/core/input.rs +++ /dev/null @@ -1,124 +0,0 @@ -//! Frontend-neutral key representation. -//! -//! `Key` is the domain vocabulary for keybindings: config parsing -//! (`core/user_config.rs`), `App` state, and every handler speak this type. -//! The conversion from the terminal backend's `KeyEvent` lives with the -//! terminal frontend in `tui/event/key.rs`, so nothing here depends on a -//! terminal. - -use std::fmt; - -/// Represents an key. -#[derive(PartialEq, Eq, Clone, Copy, Hash, Debug)] -pub enum Key { - /// Both Enter (or Return) and numpad Enter - Enter, - /// Tabulation key - Tab, - /// Backspace key - Backspace, - /// Escape key - Esc, - - /// Left arrow - Left, - /// Right arrow - Right, - /// Up arrow - Up, - /// Down arrow - Down, - - /// Insert key - Ins, - /// Delete key - Delete, - /// Home key - Home, - /// End key - End, - /// Page Up key - PageUp, - /// Page Down key - PageDown, - - /// F0 key - F0, - /// F1 key - F1, - /// F2 key - F2, - /// F3 key - F3, - /// F4 key - F4, - /// F5 key - F5, - /// F6 key - F6, - /// F7 key - F7, - /// F8 key - F8, - /// F9 key - F9, - /// F10 key - F10, - /// F11 key - F11, - /// F12 key - F12, - Char(char), - Ctrl(char), - Alt(char), - Unknown, -} - -impl Key { - /// Returns the function key corresponding to the given number - /// - /// 1 -> F1, etc... Unmapped keys (F13+) return `Unknown`. - pub fn from_f(n: u8) -> Key { - match n { - 0 => Key::F0, - 1 => Key::F1, - 2 => Key::F2, - 3 => Key::F3, - 4 => Key::F4, - 5 => Key::F5, - 6 => Key::F6, - 7 => Key::F7, - 8 => Key::F8, - 9 => Key::F9, - 10 => Key::F10, - 11 => Key::F11, - 12 => Key::F12, - _ => Key::Unknown, - } - } -} - -impl fmt::Display for Key { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match *self { - Key::Alt(' ') => write!(f, "<Alt+Space>"), - Key::Ctrl(' ') => write!(f, "<Ctrl+Space>"), - Key::Char(' ') => write!(f, "<Space>"), - Key::Alt(c) => write!(f, "<Alt+{}>", c), - Key::Ctrl(c) => write!(f, "<Ctrl+{}>", c), - Key::Char(c) => write!(f, "{}", c), - Key::Left | Key::Right | Key::Up | Key::Down => write!(f, "<{:?} Arrow Key>", self), - Key::Enter - | Key::Tab - | Key::Backspace - | Key::Esc - | Key::Ins - | Key::Delete - | Key::Home - | Key::End - | Key::PageUp - | Key::PageDown => write!(f, "<{:?}>", self), - _ => write!(f, "{:?}", self), - } - } -} diff --git a/src/core/limits.rs b/src/core/limits.rs deleted file mode 100644 index 677ec045..00000000 --- a/src/core/limits.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! Frontend-neutral UI limits shared by core state and the frontends. - -/// Upper bound for the persisted playbar height, enforced wherever -/// `playbar_height_rows` enters runtime state. -pub const MAX_PLAYBAR_ROWS: u16 = 50; diff --git a/src/core/migrations.rs b/src/core/migrations.rs deleted file mode 100644 index 415ba87e..00000000 --- a/src/core/migrations.rs +++ /dev/null @@ -1,671 +0,0 @@ -//! Short-lived upgrade shims for moving old on-disk shapes into current state. -//! -//! Keep each migration isolated here so it can be deleted cleanly once the -//! minimum supported upgrade path no longer includes the legacy release. - -use crate::core::source::Source; -use crate::core::state::{PersistedRuntimeState, RuntimeState}; -use crate::core::user_config::BehaviorConfig; -use anyhow::{anyhow, Context, Result}; -use serde_yaml::{Mapping, Value}; -use std::{fs, path::Path}; - -const LEGACY_STATE_FILE_RELATIVE_PATHS: [&str; 4] = [ - "last_session.yml", - "history/listens.jsonl", - "history/last_recap_at.txt", - "history/last_synced_at.txt", -]; - -/// Move app-managed files that used to live under the XDG config directory into -/// the XDG state directory. -/// -/// This intentionally never overwrites an existing state file: once the new -/// path has data, merging the old file is file-format-specific and unsafe for a -/// generic shim. Run once at startup and remove after the config-to-state -/// upgrade window closes. -pub(crate) fn apply_legacy_state_file_migrations() -> Result<()> { - let Some(config_dir) = crate::core::paths::app_config_dir() else { - return Ok(()); - }; - let state_dir = crate::core::paths::app_state_dir() - .ok_or_else(|| anyhow!("cannot resolve the spotatui state directory"))?; - crate::core::paths::ensure_private_dir(&state_dir)?; - - for relative_path in LEGACY_STATE_FILE_RELATIVE_PATHS { - migrate_legacy_path_if_unclaimed( - &config_dir.join(relative_path), - &state_dir.join(relative_path), - )?; - } - - Ok(()) -} - -pub(crate) fn migrate_legacy_path_if_unclaimed( - legacy_path: &Path, - state_path: &Path, -) -> Result<bool> { - if path_exists(state_path)? || !path_exists(legacy_path)? { - return Ok(false); - } - - if let Some(parent) = state_path.parent() { - fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; - } - - match fs::rename(legacy_path, state_path) { - Ok(()) => { - log::info!( - "migrated legacy app data from {} to {}", - legacy_path.display(), - state_path.display() - ); - Ok(true) - } - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(false), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(rename_error) => { - if !copy_legacy_path_if_unclaimed(legacy_path, state_path, &rename_error)? { - return Ok(false); - } - remove_legacy_path_after_migration(legacy_path, state_path); - log::info!( - "migrated legacy app data from {} to {}", - legacy_path.display(), - state_path.display() - ); - Ok(true) - } - } -} - -fn copy_legacy_path_if_unclaimed( - legacy_path: &Path, - state_path: &Path, - rename_error: &std::io::Error, -) -> Result<bool> { - let metadata = match fs::symlink_metadata(legacy_path) { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), - Err(error) => { - return Err(error).with_context(|| format!("checking {}", legacy_path.display())); - } - }; - - if metadata.is_dir() { - return copy_legacy_dir_if_unclaimed(legacy_path, state_path, rename_error); - } - - let mut source = match fs::File::open(legacy_path) { - Ok(file) => file, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), - Err(error) => { - return Err(error).with_context(|| format!("opening {}", legacy_path.display())); - } - }; - let mut target_options = fs::OpenOptions::new(); - target_options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - target_options.mode(0o600); - } - let mut target = match target_options.open(state_path) { - Ok(file) => file, - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => return Ok(false), - Err(error) => { - return Err(error).with_context(|| { - format!( - "renaming {} to {} failed ({rename_error}); creating copy target also failed", - legacy_path.display(), - state_path.display() - ) - }); - } - }; - - if let Err(error) = std::io::copy(&mut source, &mut target) { - let _ = fs::remove_file(state_path); - return Err(error).with_context(|| { - format!( - "renaming {} to {} failed ({rename_error}); copying fallback also failed", - legacy_path.display(), - state_path.display() - ) - }); - } - - Ok(true) -} - -fn copy_legacy_dir_if_unclaimed( - legacy_path: &Path, - state_path: &Path, - rename_error: &std::io::Error, -) -> Result<bool> { - match fs::create_dir(state_path) { - Ok(()) => set_private_dir_permissions(state_path)?, - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => return Ok(false), - Err(error) => { - return Err(error).with_context(|| { - format!( - "renaming {} to {} failed ({rename_error}); creating copy target also failed", - legacy_path.display(), - state_path.display() - ) - }); - } - } - - if let Err(error) = copy_dir_contents(legacy_path, state_path) { - let _ = fs::remove_dir_all(state_path); - return Err(error); - } - - Ok(true) -} - -fn create_private_dir(path: &Path) -> Result<()> { - fs::create_dir(path).with_context(|| format!("creating {}", path.display()))?; - set_private_dir_permissions(path) -} - -fn set_private_dir_permissions(path: &Path) -> Result<()> { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(path, fs::Permissions::from_mode(0o700)) - .with_context(|| format!("setting private permissions on {}", path.display()))?; - } - // Windows scopes %APPDATA% to the user already; nothing to tighten. - #[cfg(not(unix))] - let _ = path; - - Ok(()) -} - -fn copy_dir_contents(legacy_path: &Path, state_path: &Path) -> Result<()> { - for entry in - fs::read_dir(legacy_path).with_context(|| format!("reading {}", legacy_path.display()))? - { - let entry = entry?; - let source = entry.path(); - let target = state_path.join(entry.file_name()); - let file_type = entry - .file_type() - .with_context(|| format!("checking {}", source.display()))?; - - if file_type.is_symlink() { - continue; - } - - if file_type.is_dir() { - create_private_dir(&target)?; - copy_dir_contents(&source, &target)?; - } else { - fs::copy(&source, &target).with_context(|| { - format!( - "copying legacy app data from {} to {}", - source.display(), - target.display() - ) - })?; - } - } - - Ok(()) -} - -fn remove_legacy_path_after_migration(legacy_path: &Path, state_path: &Path) { - let remove_result = match fs::symlink_metadata(legacy_path) { - Ok(metadata) if metadata.is_dir() => fs::remove_dir_all(legacy_path), - Ok(_) => fs::remove_file(legacy_path), - Err(error) => Err(error), - }; - - match remove_result { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => log::warn!( - "migrated {} to {}, but failed to remove the legacy file: {}", - legacy_path.display(), - state_path.display(), - error - ), - } -} - -/// Seed legacy in-app radio favorites from `config.yml` into `state.yml` when -/// the state field does not exist yet. -/// -/// Remove this migration after one release cycle where users could have started -/// the app and converted pre-existing `behavior.radio_stations`. -pub(crate) fn apply_legacy_config_radio_station_migration( - runtime_state: &mut RuntimeState, - persisted_state: &PersistedRuntimeState, - behavior: &BehaviorConfig, -) -> PersistedRuntimeState { - if persisted_state.radio_stations.is_some() || behavior.radio_stations.is_empty() { - return PersistedRuntimeState::default(); - } - - runtime_state.radio_stations = behavior.radio_stations.clone(); - PersistedRuntimeState { - radio_stations: Some(runtime_state.radio_stations.clone()), - ..Default::default() - } -} - -/// Seed runtime-state fields that used to live under `behavior` in `config.yml`. -/// -/// This reads the raw YAML rather than `BehaviorConfigString` because those -/// fields were removed from the typed config. Remove this with the radio shim -/// after the legacy upgrade path no longer needs to be supported. -pub(crate) fn apply_legacy_config_runtime_state_migration( - path: &Path, - runtime_state: &mut RuntimeState, - persisted_state: &PersistedRuntimeState, -) -> Result<PersistedRuntimeState> { - let Some(config) = read_config_value(path)? else { - return Ok(PersistedRuntimeState::default()); - }; - let Some(behavior) = behavior_mapping(&config) else { - return Ok(PersistedRuntimeState::default()); - }; - - let mut patch = PersistedRuntimeState::default(); - if persisted_state.active_source.is_none() { - if let Some(source) = behavior - .get(yaml_key("active_source")) - .and_then(source_from_value) - { - runtime_state.active_source = source; - patch.active_source = Some(source); - } - } - if persisted_state.shuffle_enabled.is_none() { - if let Some(shuffle_enabled) = behavior - .get(yaml_key("shuffle_enabled")) - .and_then(Value::as_bool) - { - runtime_state.shuffle_enabled = shuffle_enabled; - patch.shuffle_enabled = Some(shuffle_enabled); - } - } - if persisted_state.seen_announcement_ids.is_none() { - if let Some(ids) = behavior - .get(yaml_key("seen_announcement_ids")) - .and_then(string_sequence) - { - runtime_state.seen_announcement_ids = ids.clone(); - patch.seen_announcement_ids = Some(ids); - } - } - if persisted_state.dismissed_announcements.is_none() { - if let Some(ids) = behavior - .get(yaml_key("dismissed_announcements")) - .and_then(string_sequence) - { - runtime_state.dismissed_announcements = ids.clone(); - patch.dismissed_announcements = Some(ids); - } - } - - Ok(patch) -} - -fn path_exists(path: &Path) -> Result<bool> { - match fs::symlink_metadata(path) { - Ok(_) => Ok(true), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(error) => Err(error).with_context(|| format!("checking {}", path.display())), - } -} - -fn read_config_value(path: &Path) -> Result<Option<Value>> { - let config_yml = match fs::read_to_string(path) { - Ok(config_yml) => config_yml, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(e) => return Err(e.into()), - }; - if config_yml.trim().is_empty() { - return Ok(None); - } - - Ok(Some(serde_yaml::from_str(&config_yml)?)) -} - -fn behavior_mapping(config: &Value) -> Option<&Mapping> { - config - .as_mapping() - .and_then(|map| map.get(yaml_key("behavior"))) - .and_then(Value::as_mapping) -} - -fn yaml_key(key: &str) -> Value { - Value::String(key.to_string()) -} - -fn source_from_value(value: &Value) -> Option<Source> { - let source = value.as_str()?.trim(); - if source.is_empty() { - return None; - } - - Some(Source::from_config_str(source)) -} - -fn string_sequence(value: &Value) -> Option<Vec<String>> { - value.as_sequence().map(|items| { - items - .iter() - .filter_map(Value::as_str) - .map(|id| id.trim().to_string()) - .filter(|id| !id.is_empty()) - .collect() - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::source::Source; - use crate::core::state::RadioStationConfig; - use crate::core::user_config::UserConfig; - - #[test] - fn legacy_file_rename_moves_file_when_state_path_is_empty() { - let dir = tempfile::tempdir().unwrap(); - let legacy_path = dir - .path() - .join("config") - .join("history") - .join("listens.jsonl"); - let state_path = dir - .path() - .join("state") - .join("history") - .join("listens.jsonl"); - std::fs::create_dir_all(legacy_path.parent().unwrap()).unwrap(); - std::fs::write(&legacy_path, "legacy listens").unwrap(); - - assert!(migrate_legacy_path_if_unclaimed(&legacy_path, &state_path).unwrap()); - assert!(!legacy_path.exists()); - assert_eq!( - std::fs::read_to_string(&state_path).unwrap(), - "legacy listens" - ); - } - - #[test] - fn legacy_file_rename_ignores_missing_legacy_file() { - let dir = tempfile::tempdir().unwrap(); - let legacy_path = dir.path().join("config").join("last_session.yml"); - let state_path = dir.path().join("state").join("last_session.yml"); - - assert!(!migrate_legacy_path_if_unclaimed(&legacy_path, &state_path).unwrap()); - assert!(!state_path.exists()); - } - - #[test] - fn legacy_file_rename_does_not_overwrite_existing_state_file() { - let dir = tempfile::tempdir().unwrap(); - let legacy_path = dir - .path() - .join("config") - .join("history") - .join("last_synced_at.txt"); - let state_path = dir - .path() - .join("state") - .join("history") - .join("last_synced_at.txt"); - std::fs::create_dir_all(legacy_path.parent().unwrap()).unwrap(); - std::fs::create_dir_all(state_path.parent().unwrap()).unwrap(); - std::fs::write(&legacy_path, "legacy cursor").unwrap(); - std::fs::write(&state_path, "offset:42").unwrap(); - - assert!(!migrate_legacy_path_if_unclaimed(&legacy_path, &state_path).unwrap()); - assert_eq!( - std::fs::read_to_string(&legacy_path).unwrap(), - "legacy cursor" - ); - assert_eq!(std::fs::read_to_string(&state_path).unwrap(), "offset:42"); - } - - #[test] - fn legacy_path_migration_moves_directory_when_target_is_empty() { - let dir = tempfile::tempdir().unwrap(); - let legacy_path = dir.path().join("config").join("streaming_cache"); - let state_path = dir.path().join("cache").join("streaming_cache"); - std::fs::create_dir_all(&legacy_path).unwrap(); - std::fs::write(legacy_path.join("credentials.json"), "legacy credentials").unwrap(); - std::fs::create_dir_all(legacy_path.join("audio")).unwrap(); - std::fs::write(legacy_path.join("audio").join("chunk"), "audio cache").unwrap(); - - assert!(migrate_legacy_path_if_unclaimed(&legacy_path, &state_path).unwrap()); - - assert!(!legacy_path.exists()); - assert_eq!( - std::fs::read_to_string(state_path.join("credentials.json")).unwrap(), - "legacy credentials" - ); - assert_eq!( - std::fs::read_to_string(state_path.join("audio").join("chunk")).unwrap(), - "audio cache" - ); - } - - #[cfg(unix)] - #[test] - fn legacy_directory_copy_skips_symlink_entries_before_recursing() { - let dir = tempfile::tempdir().unwrap(); - let legacy_path = dir.path().join("config").join("streaming_cache"); - let state_path = dir.path().join("state").join("streaming_cache"); - std::fs::create_dir_all(legacy_path.join("audio")).unwrap(); - std::fs::create_dir_all(state_path.parent().unwrap()).unwrap(); - std::fs::write(legacy_path.join("audio").join("chunk"), "audio cache").unwrap(); - std::os::unix::fs::symlink(&legacy_path, legacy_path.join("loop")).unwrap(); - let rename_error = std::io::Error::other("forcing copy fallback"); - - assert!(copy_legacy_dir_if_unclaimed(&legacy_path, &state_path, &rename_error).unwrap()); - - assert_eq!( - std::fs::read_to_string(state_path.join("audio").join("chunk")).unwrap(), - "audio cache" - ); - assert!(!state_path.join("loop").exists()); - } - - #[cfg(unix)] - #[test] - fn legacy_directory_copy_sets_private_permissions_on_created_directories() { - use std::os::unix::fs::PermissionsExt; - - let dir = tempfile::tempdir().unwrap(); - let legacy_path = dir.path().join("config").join("streaming_cache"); - let state_path = dir.path().join("state").join("streaming_cache"); - std::fs::create_dir_all(legacy_path.join("audio")).unwrap(); - std::fs::create_dir_all(state_path.parent().unwrap()).unwrap(); - std::fs::write(legacy_path.join("audio").join("chunk"), "audio cache").unwrap(); - let rename_error = std::io::Error::other("forcing copy fallback"); - - assert!(copy_legacy_dir_if_unclaimed(&legacy_path, &state_path, &rename_error).unwrap()); - - let top_level_mode = std::fs::metadata(&state_path).unwrap().permissions().mode() & 0o777; - let subdirectory_mode = std::fs::metadata(state_path.join("audio")) - .unwrap() - .permissions() - .mode() - & 0o777; - assert_eq!(top_level_mode, 0o700); - assert_eq!(subdirectory_mode, 0o700); - } - - #[test] - fn legacy_config_radio_stations_seed_missing_persisted_runtime_field() { - let mut config = UserConfig::new(); - config.behavior.radio_stations = vec![RadioStationConfig { - name: "Groove Salad".to_string(), - url: "https://ice1.somafm.com/groovesalad-128-mp3".to_string(), - }]; - let persisted = PersistedRuntimeState { - volume_percent: Some(42), - ..Default::default() - }; - let mut runtime = RuntimeState::default(); - runtime.apply_persisted(&persisted); - - let patch = - apply_legacy_config_radio_station_migration(&mut runtime, &persisted, &config.behavior); - - assert_eq!( - runtime.radio_stations, - vec![RadioStationConfig { - name: "Groove Salad".to_string(), - url: "https://ice1.somafm.com/groovesalad-128-mp3".to_string(), - }] - ); - assert_eq!(patch.radio_stations, Some(runtime.radio_stations.clone())); - } - - #[test] - fn legacy_config_radio_stations_do_not_override_existing_runtime_field() { - let mut config = UserConfig::new(); - config.behavior.radio_stations = vec![RadioStationConfig { - name: "Configured Groove".to_string(), - url: "https://ice1.somafm.com/groovesalad-128-mp3".to_string(), - }]; - let persisted = PersistedRuntimeState { - radio_stations: Some(vec![RadioStationConfig { - name: "Secret Agent".to_string(), - url: "https://ice1.somafm.com/secretagent-128-mp3".to_string(), - }]), - ..Default::default() - }; - let mut runtime = RuntimeState::default(); - runtime.apply_persisted(&persisted); - - let patch = - apply_legacy_config_radio_station_migration(&mut runtime, &persisted, &config.behavior); - - assert_eq!( - runtime.radio_stations, - vec![RadioStationConfig { - name: "Secret Agent".to_string(), - url: "https://ice1.somafm.com/secretagent-128-mp3".to_string(), - }] - ); - assert_eq!(patch, PersistedRuntimeState::default()); - } - - #[test] - fn legacy_runtime_state_fields_seed_missing_persisted_fields_from_raw_config() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.yml"); - std::fs::write( - &path, - r#" -behavior: - active_source: Radio - shuffle_enabled: true - seen_announcement_ids: - - " seen " - - "" - dismissed_announcements: - - old -"#, - ) - .unwrap(); - let persisted = PersistedRuntimeState { - volume_percent: Some(42), - ..Default::default() - }; - let mut runtime = RuntimeState::default(); - runtime.apply_persisted(&persisted); - - let patch = - apply_legacy_config_runtime_state_migration(&path, &mut runtime, &persisted).unwrap(); - - assert_eq!(runtime.active_source, Source::Radio); - assert!(runtime.shuffle_enabled); - assert_eq!(runtime.seen_announcement_ids, vec!["seen"]); - assert_eq!(runtime.dismissed_announcements, vec!["old"]); - assert_eq!( - patch, - PersistedRuntimeState { - active_source: Some(Source::Radio), - shuffle_enabled: Some(true), - seen_announcement_ids: Some(vec!["seen".to_string()]), - dismissed_announcements: Some(vec!["old".to_string()]), - ..Default::default() - } - ); - } - - #[test] - fn legacy_runtime_state_migration_preserves_free_source_startup() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.yml"); - std::fs::write( - &path, - r#" -behavior: - active_source: Local -"#, - ) - .unwrap(); - let persisted = PersistedRuntimeState::default(); - let mut runtime = RuntimeState::default(); - - let patch = - apply_legacy_config_runtime_state_migration(&path, &mut runtime, &persisted).unwrap(); - - assert_eq!(runtime.active_source, Source::Local); - assert_eq!( - patch, - PersistedRuntimeState { - active_source: Some(Source::Local), - ..Default::default() - } - ); - } - - #[test] - fn legacy_runtime_state_fields_do_not_override_existing_persisted_fields() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.yml"); - std::fs::write( - &path, - r#" -behavior: - active_source: Radio - shuffle_enabled: true - seen_announcement_ids: - - new - dismissed_announcements: - - new-dismissed -"#, - ) - .unwrap(); - let persisted = PersistedRuntimeState { - active_source: Some(Source::Local), - shuffle_enabled: Some(false), - seen_announcement_ids: Some(vec!["existing".to_string()]), - dismissed_announcements: Some(vec!["existing-dismissed".to_string()]), - ..Default::default() - }; - let mut runtime = RuntimeState::default(); - runtime.apply_persisted(&persisted); - - let patch = - apply_legacy_config_runtime_state_migration(&path, &mut runtime, &persisted).unwrap(); - - assert_eq!(runtime.active_source, Source::Local); - assert!(!runtime.shuffle_enabled); - assert_eq!(runtime.seen_announcement_ids, vec!["existing"]); - assert_eq!(runtime.dismissed_announcements, vec!["existing-dismissed"]); - assert_eq!(patch, PersistedRuntimeState::default()); - } -} diff --git a/src/core/mod.rs b/src/core/mod.rs deleted file mode 100644 index 388b302f..00000000 --- a/src/core/mod.rs +++ /dev/null @@ -1,51 +0,0 @@ -// The `cfg_attr(not(feature = "tui"), allow(dead_code))` modules below are -// the core surface whose only callers today live in `tui/`, so a headless -// build counts much of them as dead. Allowed per module rather than per item: -// every tui-enabled CI leg still lints them in full (anything dead in *all* -// builds still warns there), and the shared action layer is what reclaims -// this surface for other frontends as the GUI substrate migration proceeds. -// `action` is allowed dead without `scripting` (not `tui`): until the handler -// conversion sub-PRs land, the scripting engine and the DJ tools are its only -// producers, so a scripting-less leg counts the un-adopted surface as dead. -// The `default` and `all-sources` legs (scripting on) still lint it in full. -#[cfg_attr(not(feature = "scripting"), allow(dead_code))] -pub mod action; -#[cfg_attr(not(feature = "tui"), allow(dead_code))] -pub mod app; -#[cfg(feature = "art-decode")] -pub mod art; -pub mod auth; -pub mod banner; -pub mod config; -#[cfg(feature = "art-decode")] -pub mod cover_theme; -#[cfg_attr(not(feature = "tui"), allow(dead_code))] -pub mod driver; -pub mod first_run; -#[cfg_attr(not(feature = "tui"), allow(dead_code))] -pub mod format; -pub mod geometry; -#[cfg_attr(not(feature = "tui"), allow(dead_code))] -pub mod input; -pub mod limits; -pub mod migrations; -pub mod onboarding; -pub mod pagination; -pub mod paths; -#[cfg_attr(not(feature = "tui"), allow(dead_code))] -pub mod persisted_playback; -pub mod plugin_api; -pub mod queue; -#[cfg_attr(not(feature = "tui"), allow(dead_code))] -pub mod requirement; -#[cfg_attr(not(feature = "tui"), allow(dead_code))] -pub mod sort; -pub mod source; -#[cfg_attr(not(feature = "tui"), allow(dead_code))] -pub mod state; -#[cfg(test)] -pub mod test_helpers; -#[cfg_attr(not(feature = "tui"), allow(dead_code))] -pub mod theme; -#[cfg_attr(not(feature = "tui"), allow(dead_code))] -pub mod user_config; diff --git a/src/core/onboarding.rs b/src/core/onboarding.rs deleted file mode 100644 index 6a094f77..00000000 --- a/src/core/onboarding.rs +++ /dev/null @@ -1,102 +0,0 @@ -//! The first-launch interaction surface. -//! -//! Core and infra never talk to the console: every interactive first-run flow -//! (the source picker, Spotify client setup, OAuth URL display, the manual -//! redirect paste, source credential prompts) goes through this trait so the -//! frontend decides how to present it. The terminal frontend's -//! `ConsoleOnboarding` (`tui/onboarding.rs`) reproduces the historical stdout -//! byte for byte; a windowed frontend can implement the same surface with -//! dialogs instead. -//! -//! The retry loops, validation, and persistence stay with the callers - this -//! trait is only the presentation boundary. - -use crate::core::source::Source; -use anyhow::Result; -use serde::{Deserialize, Serialize}; - -/// The 58-character rule line both first-run banners draw. -pub(crate) const BANNER_RULE: &str = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"; - -/// A typed question a caller needs answered before it can continue. The -/// frontend owns presentation and input parsing; callers never interpret -/// prompt prose. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum OnboardingPrompt { - /// A yes/no question. The terminal frontend draws the banner rule around - /// `title`, then `body`, then asks `question` on its own line. - Confirm { - title: String, - body: String, - question: String, - }, -} - -/// The typed answer to [`OnboardingPrompt::Confirm`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum OnboardingAnswer { - Yes, - No, -} - -/// Parse one raw console line for [`OnboardingPrompt::Confirm`]: empty input, -/// `y`, or `yes` (case-insensitive) means yes; anything else means no. -pub(crate) fn confirm_answer(raw: &str) -> OnboardingAnswer { - match raw.trim().to_lowercase().as_str() { - "" | "y" | "yes" => OnboardingAnswer::Yes, - _ => OnboardingAnswer::No, - } -} - -pub trait Onboarding: Send + Sync { - /// Whether this frontend can ask questions at all. The terminal frontend - /// checks stdin and stdout; a windowed frontend answers true while its - /// event loop runs. - fn is_interactive(&self) -> bool; - - /// Ask one typed question and wait for the answer. Blocking on the - /// terminal frontend; a windowed frontend bridges to its event loop from - /// a blocking thread. - fn ask(&self, prompt: &OnboardingPrompt) -> Result<OnboardingAnswer>; - - /// Show one informational message, terminated like a line (a `println!` on - /// the console). Multi-line text is passed whole. - fn info(&self, text: &str); - - /// Show the beginning of an in-progress line ("Testing connection... "), - /// completed by a later [`Self::info`] (a flushed `print!` on the console). - // Only the source-configuration flows emit progress fragments; builds - // without those sources have no caller. - #[cfg_attr( - not(any(feature = "subsonic", feature = "qobuz", feature = "youtube")), - allow(dead_code) - )] - fn progress(&self, text: &str); - - /// Show `prompt` verbatim (nothing appended) and read one line of input, - /// returned exactly as read - trailing newline included, as - /// [`std::io::BufRead::read_line`] produces it. Callers trim where they - /// mean to. - fn prompt_line(&self, prompt: &str) -> Result<String>; - - /// First-run source picker over the compiled-in `options`. `None` means the - /// user cancelled or confirmed with nothing selected (the caller falls - /// through to the Spotify wizard, matching the historical default). - fn pick_sources(&self, options: &[Source]) -> Result<Option<Vec<Source>>>; -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn confirm_answer_treats_empty_y_and_yes_as_yes() { - assert_eq!(confirm_answer(""), OnboardingAnswer::Yes); - assert_eq!(confirm_answer("\n"), OnboardingAnswer::Yes); - assert_eq!(confirm_answer(" y "), OnboardingAnswer::Yes); - assert_eq!(confirm_answer("YES\n"), OnboardingAnswer::Yes); - assert_eq!(confirm_answer("n"), OnboardingAnswer::No); - assert_eq!(confirm_answer("no"), OnboardingAnswer::No); - assert_eq!(confirm_answer("banana"), OnboardingAnswer::No); - } -} diff --git a/src/core/pagination.rs b/src/core/pagination.rs deleted file mode 100644 index 09bd14dd..00000000 --- a/src/core/pagination.rs +++ /dev/null @@ -1,107 +0,0 @@ -//! Source-agnostic pagination containers for the multi-source domain model. -//! -//! These mirror the shapes of rspotify's `Page<T>` (offset-based) and -//! `CursorBasedPage<T>` (cursor-based) with **no rspotify dependency**, so the -//! paginated state on [`crate::core::app::App`] can hold domain items -//! ([`crate::core::plugin_api`]) instead of `rspotify::model` types. The -//! rspotify → domain conversion lives behind the boundary in -//! `infra/network/mapping.rs` (`map_page` / `map_cursor_page`). -//! -//! Field sets intentionally drop rspotify's `href` (an API self-link the UI -//! never reads) and keep only what the app uses for paging and display. - -// Wired up by the per-screen migration slices, not yet by the binary. -#![allow(dead_code)] - -use serde::{Deserialize, Serialize}; - -/// An offset-based page of domain items — counterpart to -/// `rspotify::model::page::Page`. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Paged<T> { - pub items: Vec<T>, - pub offset: u32, - pub limit: u32, - pub total: u32, - pub next: Option<String>, - pub previous: Option<String>, -} - -impl<T> Default for Paged<T> { - fn default() -> Self { - Paged { - items: Vec::new(), - offset: 0, - limit: 0, - total: 0, - next: None, - previous: None, - } - } -} - -impl<T> Paged<T> { - /// Whether a following page exists (Spotify sets `next` to `null` at the end). - pub fn has_next(&self) -> bool { - self.next.is_some() - } -} - -/// A cursor-based page of domain items — counterpart to -/// `rspotify::model::page::CursorBasedPage`. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct CursorPaged<T> { - pub items: Vec<T>, - pub limit: u32, - pub next: Option<String>, - /// `cursors.after` from the API — the cursor to request the following page. - pub cursor_after: Option<String>, - /// Absent when the source has returned all items (matches rspotify, which - /// notes the field does not always match Spotify's documentation). - pub total: Option<u32>, -} - -impl<T> Default for CursorPaged<T> { - fn default() -> Self { - CursorPaged { - items: Vec::new(), - limit: 0, - next: None, - cursor_after: None, - total: None, - } - } -} - -impl<T> CursorPaged<T> { - pub fn has_next(&self) -> bool { - self.next.is_some() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn paged_default_is_empty_with_no_next() { - let p: Paged<String> = Paged::default(); - assert!(p.items.is_empty()); - assert!(!p.has_next()); - assert_eq!(p.total, 0); - } - - #[test] - fn cursor_paged_tracks_after_cursor() { - let p = CursorPaged { - items: vec![1u32, 2, 3], - limit: 3, - next: Some("https://api/next".to_string()), - cursor_after: Some("abc".to_string()), - total: Some(50), - }; - assert!(p.has_next()); - assert_eq!(p.cursor_after.as_deref(), Some("abc")); - assert_eq!(p.items.len(), 3); - } -} diff --git a/src/core/paths.rs b/src/core/paths.rs deleted file mode 100644 index cf5c5a59..00000000 --- a/src/core/paths.rs +++ /dev/null @@ -1,215 +0,0 @@ -use anyhow::{Context, Result}; -use std::{ - ffi::OsString, - path::{Path, PathBuf}, -}; - -const APP_DIR: &str = "spotatui"; -const FALLBACK_CONFIG_DIR: [&str; 1] = [".config"]; -#[cfg(any(feature = "streaming", test))] -const FALLBACK_CACHE_DIR: [&str; 1] = [".cache"]; -const FALLBACK_STATE_DIR: [&str; 2] = [".local", "state"]; -#[cfg(feature = "streaming")] -const XDG_CACHE_HOME_ENV: &str = "XDG_CACHE_HOME"; -const XDG_CONFIG_HOME_ENV: &str = "XDG_CONFIG_HOME"; -const XDG_STATE_HOME_ENV: &str = "XDG_STATE_HOME"; - -/// Resolve spotatui's app config directory. -/// -/// Uses `$XDG_CONFIG_HOME/spotatui` when XDG_CONFIG_HOME is set to an absolute -/// path, otherwise preserves the historical `$HOME/.config/spotatui` fallback. -pub(crate) fn app_config_dir() -> Option<PathBuf> { - app_dir_from( - std::env::var_os(XDG_CONFIG_HOME_ENV), - dirs::home_dir(), - &FALLBACK_CONFIG_DIR, - ) -} - -/// Resolve spotatui's app cache directory. -/// -/// Uses `$XDG_CACHE_HOME/spotatui` when XDG_CACHE_HOME is set to an absolute -/// path, otherwise falls back to `$HOME/.cache/spotatui`. -#[cfg(feature = "streaming")] -pub(crate) fn app_cache_dir() -> Option<PathBuf> { - app_dir_from( - std::env::var_os(XDG_CACHE_HOME_ENV), - dirs::home_dir(), - &FALLBACK_CACHE_DIR, - ) -} - -/// Resolve spotatui's app state directory. -/// -/// Uses `$XDG_STATE_HOME/spotatui` when XDG_STATE_HOME is set to an absolute -/// path, otherwise falls back to `$HOME/.local/state/spotatui`. -pub(crate) fn app_state_dir() -> Option<PathBuf> { - app_dir_from( - std::env::var_os(XDG_STATE_HOME_ENV), - dirs::home_dir(), - &FALLBACK_STATE_DIR, - ) -} - -/// The Qobuz credentials file: `<config dir>/qobuz_credentials.yml`. -#[cfg(feature = "qobuz")] -pub(crate) fn qobuz_credentials_path() -> Option<PathBuf> { - app_config_dir().map(|dir| dir.join("qobuz_credentials.yml")) -} - -/// Directory holding this run's log file. -/// -/// The OS temp directory rather than the state dir: a log file is written per -/// process id, so they accumulate, and temp is the one location the platform -/// clears on its own. Resolved through `std::env::temp_dir` so Windows lands in -/// `%TEMP%` instead of the literal `/tmp` this used to hard-code, which is -/// drive-relative there and left Windows users looking for a path the app -/// reported but their shell could not find. -pub(crate) fn app_log_dir() -> PathBuf { - std::env::temp_dir().join("spotatui_logs") -} - -/// Path of this process's log file. Callers on both the writing side -/// (`setup_logging`) and the reporting side (the help screen) use this so the -/// path shown to a user is always the path actually written. -pub(crate) fn app_log_path() -> PathBuf { - app_log_dir().join(format!("spotatuilog{}", std::process::id())) -} - -/// Ensure a directory that stores credentials or other private app data exists -/// and is owner-only where supported. -pub(crate) fn ensure_private_dir(dir: &Path) -> Result<()> { - std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)) - .with_context(|| format!("setting private permissions on {}", dir.display()))?; - } - - Ok(()) -} - -fn app_dir_from( - xdg_home: Option<OsString>, - home: Option<PathBuf>, - fallback_dir: &[&str], -) -> Option<PathBuf> { - xdg_home - .and_then(valid_xdg_home) - .or_else(|| { - home.map(|home| { - fallback_dir - .iter() - .fold(home, |home, component| home.join(component)) - }) - }) - .map(|dir| dir.join(APP_DIR)) -} - -fn valid_xdg_home(value: OsString) -> Option<PathBuf> { - if value.is_empty() { - return None; - } - - let path = PathBuf::from(value); - if path.is_absolute() { - Some(path) - } else { - None - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn test_home() -> PathBuf { - std::env::temp_dir().join("spotatui-home-alice") - } - - fn test_xdg_home(name: &str) -> PathBuf { - std::env::temp_dir().join(name) - } - - #[test] - fn app_config_dir_uses_absolute_xdg_config_home() { - let xdg_home = test_xdg_home("xdg-config"); - let path = app_dir_from( - Some(xdg_home.clone().into_os_string()), - Some(test_home()), - &FALLBACK_CONFIG_DIR, - ); - - assert_eq!(path, Some(xdg_home.join(APP_DIR))); - } - - #[test] - fn app_config_dir_falls_back_to_home_when_xdg_is_unset() { - let home = test_home(); - let path = app_dir_from(None, Some(home.clone()), &FALLBACK_CONFIG_DIR); - - assert_eq!(path, Some(home.join(".config").join(APP_DIR))); - } - - #[test] - fn app_config_dir_ignores_empty_or_relative_xdg_config_home() { - let home = test_home(); - - assert_eq!( - app_dir_from( - Some(OsString::new()), - Some(home.clone()), - &FALLBACK_CONFIG_DIR - ), - Some(home.clone().join(".config").join(APP_DIR)) - ); - assert_eq!( - app_dir_from( - Some(OsString::from("relative")), - Some(home.clone()), - &FALLBACK_CONFIG_DIR - ), - Some(home.join(".config").join(APP_DIR)) - ); - } - - #[test] - fn app_cache_dir_uses_xdg_cache_home_or_home_fallback() { - let xdg_home = test_xdg_home("xdg-cache"); - assert_eq!( - app_dir_from( - Some(xdg_home.clone().into_os_string()), - Some(test_home()), - &FALLBACK_CACHE_DIR, - ), - Some(xdg_home.join(APP_DIR)) - ); - - let home = test_home(); - assert_eq!( - app_dir_from(None, Some(home.clone()), &FALLBACK_CACHE_DIR,), - Some(home.join(".cache").join(APP_DIR)) - ); - } - - #[test] - fn app_state_dir_uses_xdg_state_home_or_home_fallback() { - let xdg_home = test_xdg_home("xdg-state"); - assert_eq!( - app_dir_from( - Some(xdg_home.clone().into_os_string()), - Some(test_home()), - &FALLBACK_STATE_DIR, - ), - Some(xdg_home.join(APP_DIR)) - ); - - let home = test_home(); - assert_eq!( - app_dir_from(None, Some(home.clone()), &FALLBACK_STATE_DIR,), - Some(home.join(".local").join("state").join(APP_DIR)) - ); - } -} diff --git a/src/core/persisted_playback.rs b/src/core/persisted_playback.rs deleted file mode 100644 index 7cd61426..00000000 --- a/src/core/persisted_playback.rs +++ /dev/null @@ -1,386 +0,0 @@ -//! Persist the last non-Spotify playback session so it survives restarts. -//! -//! The browse-scope [`Source`](crate::core::source::Source) is already persisted -//! in `state.yml` (see [`RuntimeState::active_source`](crate::core::state::RuntimeState::active_source)). -//! What this module adds -//! is the *playback* side: which track/queue was playing, where in it, and -//! whether it was paused — so that after a restart the app can resume the exact -//! song on the same source. -//! -//! ## Why a side file and not `config.yml` -//! -//! A playback session is a machine-written, frequently-updated blob (a queue of -//! [`TrackInfo`], an index, a live position). Serializing that into the -//! hand-editable `config.yml` would bury the user's real settings under churning -//! metadata. So it lives in its own `last_session.yml` under the app state dir, -//! mirroring the `youtube_playlists.yml` precedent. -//! -//! ## Per-source shape -//! -//! The sources differ in what they need to reconstruct playback, so this is a -//! tagged enum rather than one struct that fits none of them: -//! - **Local** re-reads tags from disk, so only the `file://` URI queue + index -//! are needed. -//! - **Subsonic / YouTube** got their metadata from a remote API / `yt-dlp`; it -//! cannot be re-derived offline, so the full [`TrackInfo`] list is stored. -//! - **Radio** is one infinite stream — no queue, just the station row to -//! reconnect to. - -use crate::core::plugin_api::TrackInfo; -use anyhow::{anyhow, Context, Result}; -use serde::{Deserialize, Serialize}; -use std::path::{Path, PathBuf}; - -const FILE_NAME: &str = "last_session.yml"; - -/// The full persisted session: the active non-Spotify playback (if any) plus the -/// native cross-source queue. Wraps [`PersistedPlayback`] so the queue can be -/// persisted independently of whichever source (if any) owns playback. -/// -/// `deny_unknown_fields` is load-bearing: [`load`] parses this wrapper *first*, -/// and a legacy file is a bare top-level [`PersistedPlayback`] whose `source` -/// tag key would otherwise be silently ignored here (serde ignores unknown -/// fields by default), yielding an empty session and dropping the saved -/// playback. Rejecting unknown fields forces the legacy file to fail this parse -/// so [`load`] falls through to the legacy path. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] -#[serde(deny_unknown_fields)] -pub struct PersistedSession { - #[serde(default)] - pub playback: Option<PersistedPlayback>, - #[serde(default)] - pub queue: Vec<TrackInfo>, -} - -/// Environment override for the session file location (used by tests, and -/// available to users who keep their config elsewhere). -pub const PATH_ENV: &str = "SPOTATUI_LAST_SESSION_PATH"; - -/// A snapshot of the non-Spotify playback session, enough to resume it on the -/// next launch. One variant per source; the discriminant doubles as the source -/// marker (see [`PersistedPlayback::source`]). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(tag = "source")] -pub enum PersistedPlayback { - /// Local files: the `file://` URI queue is enough — tags are re-read from - /// disk when the queue restarts. - Local { - queue: Vec<String>, - index: usize, - position_ms: u64, - paused: bool, - #[serde(default)] - repeat: crate::infra::queue::RepeatMode, - /// The user's shuffle *intent* (`decoded_shuffle`). Stored separately from - /// `shuffle` because a toggle deferred while a track change is in flight can - /// leave the two temporarily disagreeing; the restore trusts this flag and - /// reconciles the queue order to it. - #[serde(default)] - shuffle_on: bool, - /// The applied shuffle permutation, for restoring the original order on - /// un-shuffle. May be absent while `shuffle_on` is set (deferred toggle). - #[serde(default)] - shuffle: Option<crate::infra::queue::ShuffleBackup>, - }, - /// Subsonic: full track metadata (from the API) plus the queue position. - Subsonic { - tracks: Vec<TrackInfo>, - index: usize, - position_ms: u64, - paused: bool, - #[serde(default)] - repeat: crate::infra::queue::RepeatMode, - #[serde(default)] - shuffle_on: bool, - #[serde(default)] - shuffle: Option<crate::infra::queue::ShuffleBackup>, - }, - /// Qobuz: full track metadata (from the API) plus the queue position. - Qobuz { - tracks: Vec<TrackInfo>, - index: usize, - position_ms: u64, - paused: bool, - #[serde(default)] - repeat: crate::infra::queue::RepeatMode, - #[serde(default)] - shuffle_on: bool, - #[serde(default)] - shuffle: Option<crate::infra::queue::ShuffleBackup>, - }, - /// YouTube: full track metadata (from `yt-dlp`) plus the queue position. - YouTube { - tracks: Vec<TrackInfo>, - index: usize, - position_ms: u64, - paused: bool, - #[serde(default)] - repeat: crate::infra::queue::RepeatMode, - #[serde(default)] - shuffle_on: bool, - #[serde(default)] - shuffle: Option<crate::infra::queue::ShuffleBackup>, - }, - /// Radio: a single station to reconnect to. A stream has no seekable - /// position, so none is stored. - Radio { station: TrackInfo, paused: bool }, -} - -/// Location of the session file: `$SPOTATUI_LAST_SESSION_PATH` when set, else -/// `<state dir>/last_session.yml`. -pub fn default_session_path() -> Result<PathBuf> { - if let Ok(path) = std::env::var(PATH_ENV) { - return Ok(PathBuf::from(path)); - } - crate::core::paths::app_state_dir() - .map(|dir| dir.join(FILE_NAME)) - .ok_or_else(|| anyhow!("cannot resolve the spotatui state directory")) -} - -/// Load the persisted session. A missing file means "no session to resume" -/// (`Ok(None)`); a malformed file is an error the caller logs and ignores -/// (never crash startup over an auto-written file). -/// -/// Parses the current [`PersistedSession`] wrapper first, then falls back to a -/// legacy top-level [`PersistedPlayback`] (files written before the queue -/// existed), wrapping it with an empty queue. This is an explicit two-step -/// parse — see the note on [`PersistedSession`] for why the ordering is safe. -pub fn load(path: &Path) -> Result<Option<PersistedSession>> { - match std::fs::read_to_string(path) { - Ok(contents) => { - if let Ok(session) = serde_yaml::from_str::<PersistedSession>(&contents) { - return Ok(Some(session)); - } - serde_yaml::from_str::<PersistedPlayback>(&contents) - .map(|playback| { - Some(PersistedSession { - playback: Some(playback), - queue: Vec::new(), - }) - }) - .with_context(|| format!("malformed session file: {}", path.display())) - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(e) => Err(e).with_context(|| format!("reading {}", path.display())), - } -} - -/// Save the session atomically (write a sibling tempfile, then rename) so a -/// crash mid-write can't leave a half-written file that fails to parse. -pub fn save(path: &Path, session: &PersistedSession) -> Result<()> { - let yaml = serde_yaml::to_string(session).context("serializing playback session")?; - if let Some(dir) = path.parent() { - std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?; - } - let tmp = path.with_extension("yml.tmp"); - std::fs::write(&tmp, yaml).with_context(|| format!("writing {}", tmp.display()))?; - std::fs::rename(&tmp, path).with_context(|| format!("replacing {}", path.display()))?; - Ok(()) -} - -/// Remove the session file. A missing file is not an error — clearing an -/// already-absent session is a no-op. -pub fn clear(path: &Path) -> Result<()> { - match std::fs::remove_file(path) { - Ok(()) => Ok(()), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(e) => Err(e).with_context(|| format!("removing {}", path.display())), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::infra::queue::RepeatMode; - - fn track(uri: &str, name: &str) -> TrackInfo { - TrackInfo { - uri: Some(uri.to_string()), - name: name.to_string(), - artists: vec!["Artist".to_string()], - album: "Album".to_string(), - duration_ms: 1000, - id: None, - album_id: None, - artist_refs: vec![], - is_playable: true, - is_local: false, - track_number: 0, - explicit: false, - image_url: None, - } - } - - fn session(playback: Option<PersistedPlayback>, queue: Vec<TrackInfo>) -> PersistedSession { - PersistedSession { playback, queue } - } - - #[test] - fn missing_file_is_no_session() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("absent.yml"); - assert_eq!(load(&path).unwrap(), None); - } - - #[test] - fn shuffle_intent_round_trips_independently_of_the_backup() { - // A shuffle toggled while a track change is in flight is deferred, so the - // intent (`shuffle_on: true`) can be persisted while the permutation - // (`shuffle`) has not been applied yet. Both must survive independently so - // the restore trusts the flag and reconciles the order to it. - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("last_session.yml"); - let s = session( - Some(PersistedPlayback::Local { - queue: vec!["file:///a.mp3".to_string(), "file:///b.mp3".to_string()], - index: 0, - position_ms: 0, - paused: false, - repeat: RepeatMode::Context, - shuffle_on: true, - shuffle: None, - }), - vec![], - ); - save(&path, &s).unwrap(); - assert_eq!(load(&path).unwrap(), Some(s)); - } - - #[test] - fn save_then_load_round_trips_a_youtube_playback() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("last_session.yml"); - let s = session( - Some(PersistedPlayback::YouTube { - tracks: vec![track("youtube:aaa", "A"), track("youtube:bbb", "B")], - index: 1, - position_ms: 42_000, - paused: true, - repeat: RepeatMode::Track, - shuffle_on: false, - shuffle: None, - }), - vec![], - ); - save(&path, &s).unwrap(); - assert_eq!(load(&path).unwrap(), Some(s)); - } - - #[test] - fn save_then_load_round_trips_qobuz_playback() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("last_session.yml"); - let s = session( - Some(PersistedPlayback::Qobuz { - tracks: vec![track("qobuz:track:1", "Hi-res")], - index: 0, - position_ms: 12_000, - paused: true, - repeat: RepeatMode::Context, - shuffle_on: false, - shuffle: None, - }), - vec![], - ); - save(&path, &s).unwrap(); - assert_eq!(load(&path).unwrap(), Some(s)); - } - - #[test] - fn save_then_load_round_trips_playback_with_queue() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("last_session.yml"); - let s = session( - Some(PersistedPlayback::Subsonic { - tracks: vec![track("subsonic:track:1", "Sub")], - index: 0, - position_ms: 5_000, - paused: false, - repeat: RepeatMode::Off, - shuffle_on: false, - shuffle: None, - }), - vec![ - track("spotify:track:xyz", "Queued A"), - track("file:///b.mp3", "Queued B"), - ], - ); - save(&path, &s).unwrap(); - assert_eq!(load(&path).unwrap(), Some(s)); - } - - #[test] - fn save_then_load_round_trips_queue_only_session() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("last_session.yml"); - // No active playback source, but the native queue has items to persist. - let s = session(None, vec![track("spotify:track:only", "Just Queued")]); - save(&path, &s).unwrap(); - let loaded = load(&path).unwrap().expect("should load"); - assert!(loaded.playback.is_none()); - assert_eq!(loaded.queue.len(), 1); - assert_eq!(loaded.queue[0].name, "Just Queued"); - } - - #[test] - fn legacy_top_level_playback_file_loads_with_empty_queue() { - // A file written before the queue existed is a bare top-level enum with a - // `source` tag; it must still resume playback (and load an empty queue). - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("last_session.yml"); - let legacy = PersistedPlayback::YouTube { - tracks: vec![ - track("youtube:aaa", "Legacy A"), - track("youtube:bbb", "Legacy B"), - ], - index: 1, - position_ms: 12_345, - paused: true, - repeat: RepeatMode::Off, - shuffle_on: false, - shuffle: None, - }; - let legacy_yaml = serde_yaml::to_string(&legacy).unwrap(); - std::fs::write(&path, legacy_yaml).unwrap(); - - let loaded = load(&path).unwrap().expect("legacy file should load"); - assert!(loaded.queue.is_empty()); - // The discriminating assertion: the legacy playback survives the fallback. - assert_eq!(loaded.playback, Some(legacy)); - } - - #[test] - fn clear_removes_the_file_and_is_idempotent() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("last_session.yml"); - save( - &path, - &session( - Some(PersistedPlayback::Local { - queue: vec!["file:///music/a.mp3".to_string()], - index: 0, - position_ms: 0, - paused: false, - repeat: RepeatMode::Off, - shuffle_on: false, - shuffle: None, - }), - vec![], - ), - ) - .unwrap(); - assert!(path.exists()); - clear(&path).unwrap(); - assert!(!path.exists()); - // Clearing an absent file is a no-op, not an error. - clear(&path).unwrap(); - } - - #[test] - fn malformed_file_is_an_error() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("last_session.yml"); - std::fs::write(&path, "this: is: not: valid: yaml: for: our: enum").unwrap(); - assert!(load(&path).is_err()); - } -} diff --git a/src/core/plugin_api.rs b/src/core/plugin_api.rs deleted file mode 100644 index b534c9f4..00000000 --- a/src/core/plugin_api.rs +++ /dev/null @@ -1,1073 +0,0 @@ -//! Plugin-facing domain facade. -//! -//! Serde-serializable snapshots with string IDs/URIs only. rspotify types must never leak -//! through this boundary — that is the compatibility contract for the future scripting API -//! and multi-source refactor. All conversions from rspotify types happen in the mapping -//! functions below; callers receive only the plain structs defined here. - -#![allow(dead_code)] - -use crate::core::app::App; -use crate::infra::media_metadata::current_playback_snapshot; -use rspotify::model::RepeatState; -use serde::{Deserialize, Serialize}; - -pub const API_VERSION: u32 = 6; - -/// A popup dialog produced by a plugin. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct PluginPopup { - pub title: String, - pub lines: Vec<PopupLine>, -} - -/// A single line in a [`PluginPopup`]. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct PopupLine { - pub text: String, - pub fg: Option<crate::core::theme::Color>, - pub bold: bool, - pub italic: bool, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct TrackInfo { - pub uri: Option<String>, - pub name: String, - /// Display artist names. Mirrors `artist_refs[*].name`; retained for the - /// `api_version = 4` scripting contract (plugins read `track.artists`). - pub artists: Vec<String>, - /// Display album name. Retained for the scripting contract. - pub album: String, - pub duration_ms: u64, - // --- Fields below are additive (post-Phase-0). They only ever ADD keys to the - // serialized snapshot, so the `api_version = 4` plugin contract is preserved. --- - /// Spotify base62 track id (`None` for local/unknown tracks). - #[serde(default)] - pub id: Option<String>, - /// Spotify base62 id of the track's album, when known. - #[serde(default)] - pub album_id: Option<String>, - /// Structured, navigable artist references (id + name). Populated when the - /// source provides per-artist data; empty when only a combined display string - /// is available (e.g. native-playback snapshots). - #[serde(default)] - pub artist_refs: Vec<ArtistRef>, - #[serde(default = "default_true")] - pub is_playable: bool, - #[serde(default)] - pub is_local: bool, - #[serde(default)] - pub track_number: u32, - #[serde(default)] - pub explicit: bool, - /// A directly-fetchable cover-art image URL for this track, when the source - /// can provide one (Subsonic getCoverArt, YouTube thumbnail, Qobuz album art). `None` for - /// sources without per-track art. Additive: only adds a key to the serialized - /// snapshot, preserving the api_version = 4 plugin contract. - #[serde(default)] - pub image_url: Option<String>, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct PlaybackState { - pub track: Option<TrackInfo>, - pub is_playing: bool, - pub progress_ms: u64, - pub shuffle: bool, - /// One of `"off"`, `"track"`, or `"context"`. - pub repeat: String, - pub volume_percent: Option<u8>, - pub device: Option<DeviceInfo>, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct DeviceInfo { - pub id: Option<String>, - pub name: String, - /// Lowercased DeviceType name, e.g. `"computer"`, `"smartphone"`, `"speaker"`. - pub kind: String, - pub is_active: bool, - pub volume_percent: Option<u8>, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct PlaylistInfo { - pub uri: String, - pub name: String, - /// Owner display name (falls back to the owner id when no display name is set). - pub owner: String, - pub track_count: u32, - // --- Additive fields (post-Phase-0); see TrackInfo note above. --- - /// Spotify base62 playlist id, when known. - #[serde(default)] - pub id: Option<String>, - /// Owner's base62 user id, when known. Used for ownership checks (the display - /// `owner` is not stable for comparison). - #[serde(default)] - pub owner_id: Option<String>, - #[serde(default)] - pub collaborative: bool, - #[serde(default)] - pub public: Option<bool>, - #[serde(default)] - pub image_url: Option<String>, -} - -/// A navigable reference to an artist: optional Spotify id plus display name. -/// -/// Reused by [`TrackInfo`], [`AlbumInfo`], and [`ArtistInfo`]. `id` is `None` -/// for local/unknown sources or when the API omits it. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] -pub struct ArtistRef { - pub id: Option<String>, - pub name: String, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] -pub struct ArtistInfo { - pub id: Option<String>, - pub uri: Option<String>, - pub name: String, - #[serde(default)] - pub image_url: Option<String>, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] -pub struct AlbumInfo { - pub id: Option<String>, - pub uri: Option<String>, - pub name: String, - #[serde(default)] - pub artists: Vec<ArtistRef>, - /// One of `"album"`, `"single"`, `"compilation"` (lowercased), when known. - #[serde(default)] - pub album_type: Option<String>, - #[serde(default)] - pub release_date: Option<String>, - #[serde(default)] - pub total_tracks: Option<u32>, - #[serde(default)] - pub image_url: Option<String>, - /// Populated when mapped from a full album; empty for simplified albums. - #[serde(default)] - pub tracks: Vec<TrackInfo>, -} - -/// An album as it appears in the user's saved-albums library: the album itself -/// plus the saved-relationship metadata (`added_at`). `added_at` is a property -/// of the *save*, not the album, so it lives here rather than on [`AlbumInfo`] -/// (which is also reused for search results and discographies). Carried so the -/// "Date Added" sort on the saved-albums screen keeps working. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] -pub struct SavedAlbumInfo { - pub album: AlbumInfo, - /// RFC 3339 UTC timestamp; sorts lexicographically == chronologically. - #[serde(default)] - pub added_at: String, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] -pub struct ShowInfo { - pub id: Option<String>, - pub uri: Option<String>, - pub name: String, - #[serde(default)] - pub description: String, - /// Show publisher (the "by …" attribution rendered in the podcast list). - #[serde(default)] - pub publisher: String, - #[serde(default)] - pub image_url: Option<String>, -} - -/// Where playback of an episode was last left off. Mirrors rspotify's -/// `ResumePoint`; carried so the episode list can render the "fully played" -/// marker and resume position. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ResumePointInfo { - pub fully_played: bool, - pub resume_position_ms: u64, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct EpisodeInfo { - pub id: Option<String>, - pub uri: Option<String>, - pub name: String, - pub duration_ms: u64, - /// Parent show name. Populated from a full episode (e.g. a queue item); - /// empty for simplified episodes that are already shown within their show's - /// context (the show-episodes list). - #[serde(default)] - pub show_name: String, - #[serde(default)] - pub description: String, - #[serde(default)] - pub release_date: String, - #[serde(default = "default_true")] - pub is_playable: bool, - /// Resume/played state, when the source provides it (e.g. the show-episodes - /// list). Drives the "fully played" marker and resume-position display. - #[serde(default)] - pub resume_point: Option<ResumePointInfo>, - #[serde(default)] - pub image_url: Option<String>, -} - -/// A playable item in a queue or playlist: either a music track or a podcast -/// episode. Maps from rspotify's `PlayableItem` (the `Unknown` variant maps to -/// `None`). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum PlayableInfo { - Track(TrackInfo), - Episode(EpisodeInfo), -} - -/// Aggregated, source-agnostic search results. Sources without a given -/// capability simply leave the corresponding vector empty. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] -pub struct SearchResults { - #[serde(default)] - pub tracks: Vec<TrackInfo>, - #[serde(default)] - pub albums: Vec<AlbumInfo>, - #[serde(default)] - pub artists: Vec<ArtistInfo>, - #[serde(default)] - pub playlists: Vec<PlaylistInfo>, - #[serde(default)] - pub shows: Vec<ShowInfo>, -} - -/// Retained content of a plugin custom screen. Screens are retained-mode by -/// design: draw runs with `&App` only (the engine is unreachable there), so -/// plugins publish content via effects and the renderer just reads it. -#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] -pub struct PluginScreenContent { - pub title: String, - pub widgets: Vec<PluginWidget>, -} - -/// A size along one axis: an absolute cell count, or a percentage of the parent. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum PluginLength { - Cells(u16), - /// 1..=100 (validated at the API layer). - Percent(u16), -} - -/// How a [`PluginWidgetKind::CoverArt`] image is scaled into its area. -/// -/// `Crop` (CSS `cover`) is deliberately not offered: the terminal image -/// renderer documents that cropping overdraws characters over graphics and -/// that some terminals misbehave when it does (Alacritty's sixel branch is -/// named explicitly). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] -pub enum PluginCoverArtFit { - /// Fit inside the area, keeping the aspect ratio, never upscaling. - #[default] - Contain, - /// Like `Contain` but upscales an image smaller than the area. - Scale, -} - -/// A widget in a [`PluginScreenContent`]. -/// -/// `width`/`height` are per-axis size hints; only the one matching the parent -/// container's axis is used (the top level is an implicit vertical stack). -/// `None` means "share the remaining space evenly". -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct PluginWidget { - pub kind: PluginWidgetKind, - pub width: Option<PluginLength>, - pub height: Option<PluginLength>, -} - -impl PluginWidget { - /// A widget with no size hints on either axis. - pub fn new(kind: PluginWidgetKind) -> Self { - Self { - kind, - width: None, - height: None, - } - } -} - -/// The content of a [`PluginWidget`]. `Row`/`Column` are pure layout containers: -/// they draw no border and no title, they only split their area. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum PluginWidgetKind { - Paragraph { - lines: Vec<PopupLine>, - /// Whether `PageUp`/`PageDown` scrolling applies to this paragraph. - /// Defaults to `true`, matching the `api_version = 5` behavior where every - /// paragraph on a screen scrolled in lockstep. - scroll: bool, - }, - List { - title: Option<String>, - items: Vec<PopupLine>, - /// 0-based index of the highlighted item. - selected: Option<usize>, - }, - Gauge { - /// 0.0..=1.0 (clamped at the API layer). - ratio: f64, - label: Option<String>, - }, - /// The current track's cover art, rendered from the app's already-decoded - /// in-memory image. At most one may appear per screen. - CoverArt { - fit: PluginCoverArtFit, - }, - Row { - children: Vec<PluginWidget>, - }, - Column { - children: Vec<PluginWidget>, - }, -} - -/// A queue item as exposed to plugins. [`PlayableInfo`]'s externally-tagged -/// serde shape (`{ Track = {...} }`) is hostile to Lua, so queue items are -/// flattened into an explicit `kind` plus at most one populated payload field. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct QueueItemSnapshot { - /// `"track"` or `"episode"`. - pub kind: String, - #[serde(default)] - pub track: Option<TrackInfo>, - #[serde(default)] - pub episode: Option<EpisodeInfo>, -} - -impl QueueItemSnapshot { - pub fn from_playable(item: &PlayableInfo) -> Self { - match item { - PlayableInfo::Track(t) => QueueItemSnapshot { - kind: "track".to_string(), - track: Some(t.clone()), - episode: None, - }, - PlayableInfo::Episode(e) => QueueItemSnapshot { - kind: "episode".to_string(), - track: None, - episode: Some(e.clone()), - }, - } - } -} - -/// The playback queue as exposed to plugins. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] -pub struct QueueSnapshot { - #[serde(default)] - pub currently_playing: Option<QueueItemSnapshot>, - #[serde(default)] - pub items: Vec<QueueItemSnapshot>, -} - -/// A single timestamped lyrics line. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct LyricsLineSnapshot { - pub time_ms: u64, - pub text: String, -} - -/// Lyrics for the current track as exposed to plugins. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] -pub struct LyricsSnapshot { - /// One of `"not_started"`, `"loading"`, `"found"`, `"not_found"`. - pub status: String, - #[serde(default)] - pub lines: Vec<LyricsLineSnapshot>, -} - -/// Safe, plugin-visible behavior settings. Secrets and service credentials -/// (sync_token, relay_server_url, subsonic credentials, discord client id, -/// announcement feeds) are deliberately excluded. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] -pub struct BehaviorSnapshot { - pub seek_milliseconds: u32, - pub volume_increment: u8, - pub tick_rate_milliseconds: u64, - pub animation_tick_rate_milliseconds: u64, - pub enable_text_emphasis: bool, - pub show_loading_indicator: bool, - pub enforce_wide_search_bar: bool, - pub liked_icon: String, - pub shuffle_icon: String, - pub repeat_track_icon: String, - pub repeat_context_icon: String, - pub playing_icon: String, - pub paused_icon: String, - pub set_window_title: bool, - pub shuffle_enabled: bool, - pub stop_after_current_track: bool, - pub sidebar_width_percent: u8, - pub playbar_height_rows: u16, - pub library_height_percent: u8, - /// Lowercased active source name, e.g. `"spotify"`, `"local"`. - pub active_source: String, -} - -/// User configuration as exposed to plugins: theme colors as config-file -/// strings plus safe behavior scalars. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] -pub struct ConfigSnapshot { - pub theme: std::collections::BTreeMap<String, String>, - pub behavior: BehaviorSnapshot, -} - -/// Build a [`ConfigSnapshot`] from the live user config and runtime state. -/// Theme colors use the same string forms `parse_theme_item` accepts (named or -/// `"r, g, b"`). -pub fn config_snapshot( - config: &crate::core::user_config::UserConfig, - runtime_state: &crate::core::state::RuntimeState, -) -> ConfigSnapshot { - use crate::core::user_config::color_to_string; - let t = &config.theme; - let theme: std::collections::BTreeMap<String, String> = [ - ("active", t.active), - ("analysis_bar", t.analysis_bar), - ("analysis_bar_text", t.analysis_bar_text), - ("banner", t.banner), - ("background", t.background), - ("error_border", t.error_border), - ("error_text", t.error_text), - ("header", t.header), - ("highlighted_lyrics", t.highlighted_lyrics), - ("hint", t.hint), - ("hovered", t.hovered), - ("inactive", t.inactive), - ("playbar_background", t.playbar_background), - ("playbar_progress", t.playbar_progress), - ("playbar_progress_text", t.playbar_progress_text), - ("playbar_text", t.playbar_text), - ("selected", t.selected), - ("text", t.text), - ] - .into_iter() - .map(|(name, color)| (name.to_string(), color_to_string(color))) - .collect(); - - let b = &config.behavior; - ConfigSnapshot { - theme, - behavior: BehaviorSnapshot { - seek_milliseconds: b.seek_milliseconds, - volume_increment: b.volume_increment, - tick_rate_milliseconds: b.tick_rate_milliseconds, - animation_tick_rate_milliseconds: b.animation_tick_rate_milliseconds, - enable_text_emphasis: b.enable_text_emphasis, - show_loading_indicator: b.show_loading_indicator, - enforce_wide_search_bar: b.enforce_wide_search_bar, - liked_icon: b.liked_icon.clone(), - shuffle_icon: b.shuffle_icon.clone(), - repeat_track_icon: b.repeat_track_icon.clone(), - repeat_context_icon: b.repeat_context_icon.clone(), - playing_icon: b.playing_icon.clone(), - paused_icon: b.paused_icon.clone(), - set_window_title: b.set_window_title, - shuffle_enabled: runtime_state.shuffle_enabled, - stop_after_current_track: b.stop_after_current_track, - sidebar_width_percent: runtime_state.sidebar_width_percent, - playbar_height_rows: runtime_state.playbar_height_rows, - library_height_percent: runtime_state.library_height_percent, - active_source: format!("{:?}", runtime_state.active_source).to_lowercase(), - }, - } -} - -/// Default for serde `is_playable` fields: a track/episode is assumed playable -/// unless the API explicitly says otherwise. -fn default_true() -> bool { - true -} - -// --------------------------------------------------------------------------- -// Mapping helpers -// --------------------------------------------------------------------------- - -impl PlaybackState { - pub fn repeat_from(state: RepeatState) -> String { - match state { - RepeatState::Off => "off".to_string(), - RepeatState::Track => "track".to_string(), - RepeatState::Context => "context".to_string(), - } - } -} - -impl DeviceInfo { - pub fn from_rspotify(device: &rspotify::model::Device) -> Self { - DeviceInfo { - id: device.id.clone(), - name: device.name.clone(), - kind: format!("{:?}", device._type).to_lowercase(), - is_active: device.is_active, - volume_percent: device.volume_percent.map(|v| v.min(100) as u8), - } - } -} - -impl PlaylistInfo { - pub fn from_simplified(p: &rspotify::model::SimplifiedPlaylist) -> Self { - use rspotify::prelude::Id; - let owner = p - .owner - .display_name - .clone() - .unwrap_or_else(|| p.owner.id.id().to_string()); - PlaylistInfo { - uri: p.id.uri(), - name: p.name.clone(), - owner, - track_count: p.items.total, - id: Some(p.id.id().to_string()), - owner_id: Some(p.owner.id.id().to_string()), - collaborative: p.collaborative, - public: p.public, - image_url: p.images.first().map(|img| img.url.clone()), - } - } -} - -/// Build a [`PlaybackState`] from the current [`App`] state. -/// -/// Returns `None` only when there is no playback context at all (both -/// `current_playback_snapshot` and `app.current_playback_context` are absent). -pub fn playback_state(app: &App) -> Option<PlaybackState> { - let snapshot = current_playback_snapshot(app); - let context = app.current_playback_context.as_ref(); - - if snapshot.is_none() && context.is_none() { - return None; - } - - let track = snapshot.as_ref().map(|s| TrackInfo { - uri: s.item_uri.clone(), - name: s.metadata.title.clone(), - artists: s.metadata.artists.clone(), - album: s.metadata.album.clone(), - duration_ms: s.metadata.duration_ms as u64, - id: s.item_id.clone(), - album_id: None, - // The native-playback snapshot carries a single combined artist display - // string (see `media_metadata`), not structured per-artist data, so there - // are no navigable refs to populate here. - artist_refs: Vec::new(), - is_playable: true, - is_local: false, - track_number: 0, - explicit: false, - image_url: None, - }); - - let (is_playing, shuffle, repeat, device) = if let Some(s) = &snapshot { - let repeat_str = s - .repeat - .map(PlaybackState::repeat_from) - .unwrap_or_else(|| "off".to_string()); - let device = context.map(|ctx| DeviceInfo::from_rspotify(&ctx.device)); - (s.is_playing, s.shuffle, repeat_str, device) - } else { - // snapshot is None but context is Some — build from context only - let ctx = context.unwrap(); - let repeat_str = PlaybackState::repeat_from(ctx.repeat_state); - let device = Some(DeviceInfo::from_rspotify(&ctx.device)); - (ctx.is_playing, ctx.shuffle_state, repeat_str, device) - }; - - let volume_percent = device.as_ref().and_then(|d| d.volume_percent); - - let progress_ms = snapshot.as_ref().map(|s| s.progress_ms as u64).unwrap_or(0); - - Some(PlaybackState { - track, - is_playing, - progress_ms, - shuffle, - repeat, - volume_percent, - device, - }) -} - -/// Return a list of available devices from [`App`]'s cached device payload. -pub fn device_list(app: &App) -> Vec<DeviceInfo> { - app - .devices - .as_ref() - .map(|payload| { - payload - .devices - .iter() - .map(DeviceInfo::from_rspotify) - .collect() - }) - .unwrap_or_default() -} - -/// Stable plugin-facing name of a route. Exhaustive on purpose (no `_` arm): -/// adding a `RouteId` without naming it here is a compile error, keeping the -/// scripting contract in sync. -pub fn route_name(route: &crate::core::app::Route) -> String { - use crate::core::app::RouteId; - match &route.id { - RouteId::Analysis => "analysis", - RouteId::AlbumTracks => "album_tracks", - RouteId::AlbumList => "album_list", - RouteId::Artist => "artist", - RouteId::LyricsView => "lyrics", - RouteId::CoverArtView => "cover_art", - RouteId::MiniPlayer => "miniplayer", - RouteId::Error => "error", - RouteId::Home => "home", - RouteId::RecentlyPlayed => "recently_played", - RouteId::Search => "search", - RouteId::SelectedDevice => "devices", - RouteId::TrackTable => "track_table", - RouteId::Discover => "discover", - RouteId::Artists => "artists", - RouteId::Podcasts => "podcasts", - RouteId::PodcastEpisodes => "podcast_episodes", - RouteId::Recommendations => "recommendations", - RouteId::Dialog => "dialog", - RouteId::AnnouncementPrompt => "announcement", - RouteId::RecapPrompt => "recap_prompt", - RouteId::CommunityPinPrompt => "community_pin_prompt", - RouteId::ExitPrompt => "exit_prompt", - RouteId::Settings => "settings", - RouteId::HelpMenu => "help", - RouteId::Queue => "queue", - RouteId::Party => "party", - RouteId::CreatePlaylist => "create_playlist", - RouteId::Friends => "friends", - RouteId::LocalBrowser => "local_browser", - RouteId::Stats => "stats", - #[cfg(feature = "ai-dj")] - RouteId::AiDj => "ai_dj", - RouteId::PluginScreen(name) => return format!("plugin:{name}"), - } - .to_string() -} - -// --------------------------------------------------------------------------- -// Snapshot serializers for plugin data reads -// --------------------------------------------------------------------------- - -/// The user's playlists (full list, folder structure flattened away). -pub fn playlists_snapshot(app: &App) -> Vec<PlaylistInfo> { - app.all_playlists.clone() -} - -/// Saved ("liked") tracks fetched so far, in library order. -pub fn saved_tracks_snapshot(app: &App) -> Vec<TrackInfo> { - app - .library - .saved_tracks - .pages - .iter() - .flat_map(|page| page.items.iter().cloned()) - .collect() -} - -/// Saved albums fetched so far, in library order. -pub fn saved_albums_snapshot(app: &App) -> Vec<SavedAlbumInfo> { - app - .library - .saved_albums - .pages - .iter() - .flat_map(|page| page.items.iter().cloned()) - .collect() -} - -/// Saved shows fetched so far, in library order. -pub fn saved_shows_snapshot(app: &App) -> Vec<ShowInfo> { - app - .library - .saved_shows - .pages - .iter() - .flat_map(|page| page.items.iter().cloned()) - .collect() -} - -/// Recently played tracks (most recent first, as returned by the API). -pub fn recently_played_snapshot(app: &App) -> Vec<TrackInfo> { - app - .recently_played - .as_ref() - .map(|page| page.items.clone()) - .unwrap_or_default() -} - -/// The Spotify playback queue. An unavailable queue (no active device) maps to -/// an empty snapshot rather than an error. -pub fn queue_snapshot(app: &App) -> QueueSnapshot { - let Some(queue) = app.queue.as_ref() else { - return QueueSnapshot::default(); - }; - QueueSnapshot { - currently_playing: queue - .currently_playing - .as_ref() - .map(QueueItemSnapshot::from_playable), - items: queue - .queue - .iter() - .map(QueueItemSnapshot::from_playable) - .collect(), - } -} - -/// The current search results (first page of each category). -pub fn search_results_snapshot(app: &App) -> SearchResults { - SearchResults { - tracks: app - .search_results - .tracks - .as_ref() - .map(|p| p.items.clone()) - .unwrap_or_default(), - albums: app - .search_results - .albums - .as_ref() - .map(|p| p.items.clone()) - .unwrap_or_default(), - artists: app - .search_results - .artists - .as_ref() - .map(|p| p.items.clone()) - .unwrap_or_default(), - playlists: app - .search_results - .playlists - .as_ref() - .map(|p| p.items.clone()) - .unwrap_or_default(), - shows: app - .search_results - .shows - .as_ref() - .map(|p| p.items.clone()) - .unwrap_or_default(), - } -} - -/// Whether `app.lyrics`/`app.lyrics_status` describe the item playing *right now*. -/// -/// The driver's shared track-change detector runs **after** the script engine's -/// tick, so on the tick a track changes the lyrics state still belongs to the -/// previous track. Without this check, `spotatui.get_lyrics` called from a -/// `track_change` handler - the obvious place to call it - sees a terminal -/// status, resolves synchronously, and hands the plugin the *previous* track's -/// lyrics; the one-shot callback is then spent by the time the real ones land. -/// -/// `desired_lyrics_identity` is the detector's record of which item it last -/// published lyrics state for, so comparing it against the live snapshot -/// detects exactly that window. -pub fn lyrics_state_is_current(app: &App) -> bool { - use crate::infra::media_metadata::PlaybackItemKind; - match current_playback_snapshot(app) { - // Nothing playing: the detector clears the identity. - None => app.desired_lyrics_identity.is_none(), - Some(snapshot) => match snapshot.item_kind { - PlaybackItemKind::Track => { - app.desired_lyrics_identity - == Some((snapshot.metadata.title.clone(), snapshot.primary_artist())) - } - // Episodes get no lookup at all; the detector clears the identity and - // publishes `NotFound` for them. - PlaybackItemKind::Episode => app.desired_lyrics_identity.is_none(), - }, - } -} - -/// Lyrics for the current track, with the fetch status spelled out. -pub fn lyrics_snapshot(app: &App) -> LyricsSnapshot { - use crate::core::app::LyricsStatus; - let status = match app.lyrics_status { - LyricsStatus::NotStarted => "not_started", - LyricsStatus::Loading => "loading", - LyricsStatus::Found => "found", - LyricsStatus::NotFound => "not_found", - }; - LyricsSnapshot { - status: status.to_string(), - lines: app - .lyrics - .as_ref() - .map(|lines| { - lines - .iter() - .map(|(time_ms, text)| LyricsLineSnapshot { - time_ms: *time_ms as u64, - text: text.clone(), - }) - .collect() - }) - .unwrap_or_default(), - } -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::{source::Source, state::RuntimeState, user_config::UserConfig}; - use chrono::Utc; - use rspotify::model::{ - context::{Actions, CurrentPlaybackContext}, - CurrentlyPlayingType, Device, DeviceType, RepeatState, - }; - use std::{sync::mpsc::channel, time::SystemTime}; - - fn make_app() -> App { - let (tx, _rx) = channel(); - App::new( - tx, - crate::core::user_config::UserConfig::new(), - Some(SystemTime::now()), - ) - } - - #[allow(deprecated)] - fn make_device(volume: u32) -> Device { - Device { - id: Some("dev-abc".to_string()), - is_active: true, - is_private_session: false, - is_restricted: false, - name: "Living Room TV".to_string(), - _type: DeviceType::Tv, - volume_percent: Some(volume), - } - } - - #[allow(deprecated)] - fn make_playback_context_no_item( - is_playing: bool, - shuffle: bool, - repeat: RepeatState, - device: Device, - ) -> CurrentPlaybackContext { - CurrentPlaybackContext { - device, - repeat_state: repeat, - shuffle_state: shuffle, - context: None, - timestamp: Utc::now(), - progress: None, - is_playing, - item: None, - currently_playing_type: CurrentlyPlayingType::Unknown, - actions: Actions::default(), - } - } - - // --- DeviceInfo::from_rspotify --- - - #[test] - fn device_info_maps_all_fields_and_lowercases_kind() { - let d = make_device(75); - let info = DeviceInfo::from_rspotify(&d); - assert_eq!(info.id.as_deref(), Some("dev-abc")); - assert_eq!(info.name, "Living Room TV"); - assert_eq!(info.kind, "tv"); - assert!(info.is_active); - assert_eq!(info.volume_percent, Some(75)); - } - - #[test] - fn device_info_computer_kind() { - #[allow(deprecated)] - let d = Device { - id: None, - is_active: false, - is_private_session: false, - is_restricted: false, - name: "Laptop".to_string(), - _type: DeviceType::Computer, - volume_percent: Some(50), - }; - let info = DeviceInfo::from_rspotify(&d); - assert_eq!(info.kind, "computer"); - assert_eq!(info.volume_percent, Some(50)); - assert!(info.id.is_none()); - assert!(!info.is_active); - } - - #[test] - fn device_info_volume_clamped_to_u8() { - // volume_percent is u32; values > 255 should not cause panic (min(100) ensures <= 100). - #[allow(deprecated)] - let d = Device { - id: None, - is_active: false, - is_private_session: false, - is_restricted: false, - name: "X".to_string(), - _type: DeviceType::Smartphone, - volume_percent: Some(100), - }; - let info = DeviceInfo::from_rspotify(&d); - assert_eq!(info.volume_percent, Some(100)); - } - - // --- repeat_from --- - - #[test] - fn repeat_off_maps_to_string() { - assert_eq!(PlaybackState::repeat_from(RepeatState::Off), "off"); - } - - #[test] - fn repeat_track_maps_to_string() { - assert_eq!(PlaybackState::repeat_from(RepeatState::Track), "track"); - } - - #[test] - fn repeat_context_maps_to_string() { - assert_eq!(PlaybackState::repeat_from(RepeatState::Context), "context"); - } - - // --- playback_state --- - - #[test] - fn playback_state_returns_none_on_default_app() { - let app = make_app(); - assert!(playback_state(&app).is_none()); - } - - #[test] - fn playback_state_with_context_no_item_returns_some_with_track_none() { - let mut app = make_app(); - let device = make_device(60); - app.current_playback_context = Some(make_playback_context_no_item( - true, - true, - RepeatState::Context, - device, - )); - - let state = playback_state(&app).expect("should be Some"); - assert!(state.track.is_none()); - assert!(state.is_playing); - assert!(state.shuffle); - assert_eq!(state.repeat, "context"); - assert_eq!(state.volume_percent, Some(60)); - let dev = state.device.as_ref().expect("device should be present"); - assert_eq!(dev.id.as_deref(), Some("dev-abc")); - assert_eq!(dev.name, "Living Room TV"); - assert_eq!(dev.kind, "tv"); - } - - // --- config_snapshot --- - - #[test] - fn config_snapshot_uses_runtime_state_for_migrated_fields_without_mutating_config() { - let mut config = UserConfig::new(); - config.behavior.volume_percent = Some(10); - config.behavior.sidebar_width_percent = Some(20); - config.behavior.playbar_height_rows = Some(6); - config.behavior.library_height_percent = Some(30); - config.behavior.stop_after_current_track = true; - - let runtime_state = RuntimeState { - shuffle_enabled: true, - sidebar_width_percent: 42, - playbar_height_rows: 12, - library_height_percent: 64, - active_source: Source::Radio, - ..RuntimeState::default() - }; - - let snapshot = config_snapshot(&config, &runtime_state); - - assert!(snapshot.behavior.shuffle_enabled); - assert_eq!(snapshot.behavior.sidebar_width_percent, 42); - assert_eq!(snapshot.behavior.playbar_height_rows, 12); - assert_eq!(snapshot.behavior.library_height_percent, 64); - assert_eq!(snapshot.behavior.active_source, "radio"); - assert!(snapshot.behavior.stop_after_current_track); - - assert_eq!(config.behavior.volume_percent, Some(10)); - assert_eq!(config.behavior.sidebar_width_percent, Some(20)); - assert_eq!(config.behavior.playbar_height_rows, Some(6)); - assert_eq!(config.behavior.library_height_percent, Some(30)); - } - - // --- PlaylistInfo::from_simplified --- - - #[test] - fn playlist_info_maps_all_fields() { - let playlist = crate::core::test_helpers::simplified_playlist( - "37i9dQZF1DXcBWIGoYBM5M", - "Today's Top Hits", - "spotify", - false, - ); - let info = PlaylistInfo::from_simplified(&playlist); - assert_eq!(info.uri, "spotify:playlist:37i9dQZF1DXcBWIGoYBM5M"); - assert_eq!(info.name, "Today's Top Hits"); - // test_helpers::simplified_playlist sets owner display_name = owner_id - assert_eq!(info.owner, "spotify"); - assert_eq!(info.owner_id.as_deref(), Some("spotify")); - assert_eq!(info.track_count, 5); - } - - #[test] - fn playlist_info_falls_back_to_owner_id_when_no_display_name() { - use rspotify::model::{ - idtypes::{PlaylistId, UserId}, - playlist::PlaylistTracksRef, - user::PublicUser, - }; - use std::collections::HashMap; - - #[allow(deprecated)] - let playlist = rspotify::model::SimplifiedPlaylist { - collaborative: false, - external_urls: HashMap::new(), - href: "https://api.spotify.com/v1/playlists/abc".to_string(), - id: PlaylistId::from_id("37i9dQZF1DXcBWIGoYBM5M") - .unwrap() - .into_static(), - images: Vec::new(), - name: "Chill Vibes".to_string(), - owner: PublicUser { - display_name: None, - external_urls: HashMap::new(), - followers: None, - href: "https://api.spotify.com/v1/users/spotifyuser".to_string(), - id: UserId::from_id("spotifyuser").unwrap().into_static(), - images: Vec::new(), - }, - public: None, - snapshot_id: "snap".to_string(), - tracks: PlaylistTracksRef { - href: "https://api.spotify.com/v1/playlists/abc/tracks".to_string(), - total: 10, - }, - items: PlaylistTracksRef { - href: "https://api.spotify.com/v1/playlists/abc/tracks".to_string(), - total: 10, - }, - }; - let info = PlaylistInfo::from_simplified(&playlist); - assert_eq!(info.owner, "spotifyuser"); - assert_eq!(info.owner_id.as_deref(), Some("spotifyuser")); - assert_eq!(info.track_count, 10); - } -} diff --git a/src/core/queue.rs b/src/core/queue.rs deleted file mode 100644 index b34670a3..00000000 --- a/src/core/queue.rs +++ /dev/null @@ -1,295 +0,0 @@ -//! Native cross-source playback queue: source identity and suspension records. -//! -//! The queue itself is a `Vec<TrackInfo>` on [`App`](crate::core::app::App); this -//! module holds the small value types that classify a queue item by its source -//! (URI scheme) and record how to resume the underlying per-source context once -//! the queue drains. - -/// Which source a queue item plays through, derived from its URI scheme. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum QueueItemSource { - Spotify, - LocalFile, - Subsonic, - YouTube, - Qobuz, -} - -/// Classify a queue item by its URI scheme. Anything that is not a local file, -/// Subsonic, YouTube, or Qobuz URI is treated as Spotify (the `spotify:track:` -/// scheme). Radio URIs (`radio:`) are never queued, so they are rejected before -/// reaching this function. -pub fn queue_item_source(uri: &str) -> QueueItemSource { - if uri.starts_with("file:") { - QueueItemSource::LocalFile - } else if uri.starts_with("subsonic:") { - QueueItemSource::Subsonic - } else if uri.starts_with("youtube:") { - QueueItemSource::YouTube - } else if uri.starts_with("qobuz:") { - QueueItemSource::Qobuz - } else { - QueueItemSource::Spotify - } -} - -/// Whether a URI names something that can actually be queued or played. -/// -/// [`queue_item_source`] classifies by scheme and treats everything it does not -/// recognise as Spotify, which is the right default for an item that already -/// came out of the player. It is the wrong default for a URI handed in from -/// outside: free text, an `https://open.spotify.com/...` link, and -/// `spotify:album:`/`spotify:playlist:` would all be classified Spotify and then -/// queued as if they were tracks. -/// -/// So callers taking a URI from an agent gate on this first. `spotify:track:` is -/// the only Spotify form that names a single track; the other three schemes are -/// opaque handles minted by their own sources, which cannot be validated further -/// here. `radio:` is deliberately absent — a live stream is not a finite track -/// and `App::add_track_to_native_queue` rejects it. -/// -/// A bare scheme with nothing behind it (`spotify:track:`, `file:`) names no -/// track at all, so the content after the prefix has to be non-empty: a model -/// that emits the prefix and stops must be told the argument is wrong rather -/// than have an empty handle queued for it. -#[cfg_attr(not(any(feature = "mcp-server", feature = "ai-dj")), allow(dead_code))] -pub fn is_playable_track_uri(uri: &str) -> bool { - ["spotify:track:", "file:", "subsonic:", "youtube:", "qobuz:"] - .iter() - .any(|prefix| { - uri - .strip_prefix(prefix) - .is_some_and(|rest| !rest.trim().is_empty()) - }) -} - -/// The Cargo feature that would make `uri`'s source playable, if this build is -/// missing it. -/// -/// [`is_playable_track_uri`] answers "is this the right *kind* of URI", which is -/// a property of the scheme alone. Whether anything in *this* binary can consume -/// it is a second question: the per-source routers are each `#[cfg]`-gated, so an -/// `--features mcp-server` build accepts `youtube:` as well-formed and then has -/// nothing to play it with. Callers taking a URI from an agent ask both, so the -/// agent is told which build it is talking to rather than being told the track -/// started. -/// Spotify is deliberately never reported here: without `streaming` it still -/// plays through the Web API on an external Connect device, so -/// [`source_available`]'s stricter answer (which is about the *native* player) -/// would refuse a URI that works. -#[cfg_attr(not(any(feature = "mcp-server", feature = "ai-dj")), allow(dead_code))] -pub fn missing_source_feature(uri: &str) -> Option<&'static str> { - let source = queue_item_source(uri); - if source == QueueItemSource::Spotify || source_available(source) { - return None; - } - Some(match source { - QueueItemSource::LocalFile => "local-files", - QueueItemSource::Subsonic => "subsonic", - QueueItemSource::YouTube => "youtube", - QueueItemSource::Qobuz => "qobuz", - QueueItemSource::Spotify => unreachable!("returned above"), - }) -} - -/// A short, human-readable tag for a queue item's source, shown in the Queue -/// screen next to each row. -pub fn source_label(source: QueueItemSource) -> &'static str { - match source { - QueueItemSource::Spotify => "Spotify", - QueueItemSource::LocalFile => "Local", - QueueItemSource::Subsonic => "Subsonic", - QueueItemSource::YouTube => "YouTube", - QueueItemSource::Qobuz => "Qobuz", - } -} - -/// Whether this build can actually play a queue item from the given source. -/// A slim build (no source features) can only play Spotify tracks via native -/// streaming; each alternative source is gated on its own Cargo feature. The queue -/// consults this to skip unplayable items with a status message instead of -/// stalling the queue. -pub fn source_available(source: QueueItemSource) -> bool { - match source { - QueueItemSource::Spotify => cfg!(feature = "streaming"), - QueueItemSource::LocalFile => cfg!(feature = "local-files"), - QueueItemSource::Subsonic => cfg!(feature = "subsonic"), - QueueItemSource::YouTube => cfg!(feature = "youtube"), - QueueItemSource::Qobuz => cfg!(feature = "qobuz"), - } -} - -/// How to resume the underlying per-source context after the native queue -/// drains. Recorded when a track is queued over an active context. -/// -/// `resume_index: None` means the context was exhausted, so it should be torn -/// down rather than resumed. In a slim build (no source features) this enum has -/// zero variants — the `App` field is `Option<SuspendedContext>`, so that is a -/// valid, always-`None` type. -#[derive(Debug, Clone)] -pub enum SuspendedContext { - /// Snapshot of the native-Spotify context to resume once the queue drains: - /// the context uri and the resume-target track uri (the head of the Spotify - /// mirror queue at suspension time). - #[cfg(feature = "streaming")] - Spotify { - context_uri: Option<String>, - resume_track_uri: Option<String>, - }, - /// A native-Spotify client-side shuffle session - /// ([`App::native_spotify_shuffle`](crate::core::app::App::native_spotify_shuffle)): - /// resumes by index into the session's app-owned play order, with no context - /// reload and no reshuffle. `generation` binds the resume to the session it was - /// snapshotted from, so a session replaced while the queue drains cannot - /// inherit a stale index (the resume handler applies the index only while the - /// live session's generation still matches). - #[cfg(feature = "streaming")] - SpotifyShuffled { - resume_index: Option<usize>, - generation: u64, - /// Context snapshot taken at suspension time (context uri + the mirror - /// queue's head), so this can be converted to a [`Self::Spotify`] resume - /// when the session it indexes into is invalidated while the queue plays - /// (disconnect recovery, failed context fetch). Captured at creation: - /// by conversion time `current_playback_context` may describe the queued - /// track instead of the suspended context. - context_uri: Option<String>, - resume_track_uri: Option<String>, - }, - #[cfg(feature = "local-files")] - Local { - resume_index: Option<usize>, - resume_position_ms: u64, - }, - #[cfg(feature = "subsonic")] - Subsonic { - resume_index: Option<usize>, - resume_position_ms: u64, - }, - #[cfg(feature = "qobuz")] - Qobuz { - resume_index: Option<usize>, - resume_position_ms: u64, - }, - #[cfg(feature = "youtube")] - YouTube { - resume_index: Option<usize>, - resume_position_ms: u64, - }, - /// A live radio stream can't be paused/resumed, so resuming it means - /// reconnecting. The suspended station row is kept to re-open the stream when - /// the queue drains (the radio session itself is torn down at suspension so - /// the queue slot can take the output device). - #[cfg(feature = "internet-radio")] - Radio { - station: crate::core::plugin_api::TrackInfo, - }, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn classifies_uri_schemes() { - assert_eq!( - queue_item_source("spotify:track:abc"), - QueueItemSource::Spotify - ); - assert_eq!( - queue_item_source("file:///music/a.mp3"), - QueueItemSource::LocalFile - ); - assert_eq!( - queue_item_source("subsonic:track:42"), - QueueItemSource::Subsonic - ); - assert_eq!( - queue_item_source("youtube:5NV6Rdv1a3I"), - QueueItemSource::YouTube - ); - assert_eq!(queue_item_source("qobuz:track:42"), QueueItemSource::Qobuz); - // Unknown schemes fall back to Spotify. - assert_eq!( - queue_item_source("something-else"), - QueueItemSource::Spotify - ); - } - - #[test] - fn only_single_track_uris_are_playable() { - for uri in [ - "spotify:track:7o2AeQZzfCERsRmOM86EcB", - "file:/home/jay/Music/a.flac", - "subsonic:track:1", - "youtube:5NV6Rdv1a3I", - "qobuz:track:42", - ] { - assert!(is_playable_track_uri(uri), "{uri} should be playable"); - } - // The cases that used to be fabricated into a track and reported as queued. - // `queue_item_source` classifies every one of these as Spotify, which is - // exactly why the queue path cannot gate on it alone. - for uri in [ - "not-a-uri", - "", - "spotify:album:1DFixLWuPkv3KT3TnV35m3", - "spotify:playlist:37i9dQZF1DXcBWIGoYBM5M", - "spotify:episode:512ojhOuo1ktJprKbVcKyQ", - "spotify:artist:4tZwfgrHOc3mvqYlEYSvVi", - "https://open.spotify.com/track/7o2AeQZzfCERsRmOM86EcB", - "radio:https://example.com/stream.aac", - // A bare scheme names no track: the prefix is right and there is nothing - // behind it. - "spotify:track:", - "spotify:track: ", - "file:", - "subsonic:", - "youtube:", - "qobuz:", - ] { - assert!(!is_playable_track_uri(uri), "{uri} should not be playable"); - } - } - - #[test] - fn source_labels_are_stable() { - assert_eq!(source_label(QueueItemSource::Spotify), "Spotify"); - assert_eq!(source_label(QueueItemSource::LocalFile), "Local"); - assert_eq!(source_label(QueueItemSource::Subsonic), "Subsonic"); - assert_eq!(source_label(QueueItemSource::YouTube), "YouTube"); - assert_eq!(source_label(QueueItemSource::Qobuz), "Qobuz"); - } - - #[test] - fn spotify_is_never_reported_as_missing_a_feature() { - // Without `streaming` a Spotify track still plays through the Web API on an - // external Connect device, so gating it on `source_available` would refuse a - // URI that works. This is the one scheme that must always pass. - assert_eq!(missing_source_feature("spotify:track:abc"), None); - } - - #[test] - fn a_scheme_whose_source_is_not_compiled_in_names_the_feature() { - // The point of the whole helper: `is_playable_track_uri` says these are - // well-formed, but the routers that consume them are `#[cfg]`-gated, so an - // `--features mcp-server` build would accept and then silently drop them. - for (uri, feature, compiled_in) in [ - ( - "file:/music/a.flac", - "local-files", - cfg!(feature = "local-files"), - ), - ("subsonic:abc", "subsonic", cfg!(feature = "subsonic")), - ("youtube:abc", "youtube", cfg!(feature = "youtube")), - ("qobuz:track:1", "qobuz", cfg!(feature = "qobuz")), - ] { - assert!(is_playable_track_uri(uri), "{uri} should be well-formed"); - assert_eq!( - missing_source_feature(uri), - if compiled_in { None } else { Some(feature) }, - "{uri}" - ); - } - } -} diff --git a/src/core/requirement.rs b/src/core/requirement.rs deleted file mode 100644 index 240dde96..00000000 --- a/src/core/requirement.rs +++ /dev/null @@ -1,225 +0,0 @@ -//! What a sidebar row, a key, or a setting needs before it can do anything: -//! a Spotify session, a capability of the active source, or one particular -//! source. The three terminal tables (sidebar, help menu, settings) carry a -//! [`Requirement`] per row and filter through [`availability`], so the rows -//! a frontend offers and the keys that work cannot disagree. - -use crate::core::source::Source; - -/// A capability of the active source, one per `Source::supports_*` predicate. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum Capability { - Search, - PlaylistWrite, - Like, -} - -impl Capability { - pub fn supported_by(self, source: Source) -> bool { - match self { - Capability::Search => source.supports_search(), - Capability::PlaylistWrite => source.supports_playlist_write(), - Capability::Like => source.supports_like(), - } - } -} - -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum Requirement { - None, - /// A Spotify session, whatever the browse scope. - SpotifySession, - /// The active source must have the capability; under Spotify a session too. - Capability(Capability), - /// The browse scope must be this source; for Spotify, with a session. - Source(Source), - /// Met when any one is met; unmet, it reports the first one. - AnyOf(&'static [Requirement]), -} - -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum Availability { - Available, - NeedsSpotify, - /// The active source cannot do it. - NotForSource(Source), - /// Only this source does it. - OnlyForSource(Source), -} - -impl Availability { - pub fn is_available(self) -> bool { - self == Availability::Available - } - - /// The suffix a row keeps when it stays visible although unmet. - pub fn hint(self) -> Option<String> { - match self { - Availability::Available => None, - Availability::NeedsSpotify => Some("needs Spotify".to_string()), - Availability::NotForSource(source) => Some(format!("not for {}", source.label())), - Availability::OnlyForSource(source) => Some(format!("{} only", source.label())), - } - } -} - -pub fn availability( - requirement: Requirement, - source: Source, - spotify_connected: bool, -) -> Availability { - let session = |met: bool| { - if met { - Availability::Available - } else { - Availability::NeedsSpotify - } - }; - match requirement { - Requirement::None => Availability::Available, - Requirement::SpotifySession => session(spotify_connected), - Requirement::Capability(capability) => { - if !capability.supported_by(source) { - Availability::NotForSource(source) - } else if source == Source::Spotify { - session(spotify_connected) - } else { - Availability::Available - } - } - Requirement::Source(required) => { - if source != required { - Availability::OnlyForSource(required) - } else if required == Source::Spotify { - session(spotify_connected) - } else { - Availability::Available - } - } - Requirement::AnyOf(options) => options - .iter() - .map(|option| availability(*option, source, spotify_connected)) - .find(|met| met.is_available()) - .or_else(|| { - options - .first() - .map(|first| availability(*first, source, spotify_connected)) - }) - .unwrap_or(Availability::Available), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn no_requirement_is_always_available() { - for source in Source::ALL { - assert!(availability(Requirement::None, source, false).is_available()); - } - } - - #[test] - fn a_session_requirement_ignores_the_browse_scope() { - for source in Source::ALL { - assert_eq!( - availability(Requirement::SpotifySession, source, true), - Availability::Available - ); - assert_eq!( - availability(Requirement::SpotifySession, source, false), - Availability::NeedsSpotify - ); - } - } - - #[test] - fn a_capability_needs_the_source_and_under_spotify_a_session() { - let like = Requirement::Capability(Capability::Like); - assert_eq!( - availability(like, Source::Spotify, true), - Availability::Available - ); - assert_eq!( - availability(like, Source::Spotify, false), - Availability::NeedsSpotify - ); - assert_eq!( - availability(like, Source::Qobuz, true), - Availability::NotForSource(Source::Qobuz) - ); - let search = Requirement::Capability(Capability::Search); - assert_eq!( - availability(search, Source::Qobuz, false), - Availability::Available - ); - assert_eq!( - availability(search, Source::Local, true), - Availability::NotForSource(Source::Local) - ); - } - - #[test] - fn a_source_requirement_is_the_scope_and_for_spotify_the_session_too() { - let radio = Requirement::Source(Source::Radio); - assert_eq!( - availability(radio, Source::Radio, false), - Availability::Available - ); - assert_eq!( - availability(radio, Source::Spotify, true), - Availability::OnlyForSource(Source::Radio) - ); - let spotify = Requirement::Source(Source::Spotify); - assert_eq!( - availability(spotify, Source::Spotify, true), - Availability::Available - ); - assert_eq!( - availability(spotify, Source::Spotify, false), - Availability::NeedsSpotify - ); - assert_eq!( - availability(spotify, Source::Local, true), - Availability::OnlyForSource(Source::Spotify) - ); - } - - #[test] - fn any_of_is_met_by_one_option_and_reports_the_first_otherwise() { - let like_or_radio = Requirement::AnyOf(&[ - Requirement::Capability(Capability::Like), - Requirement::Source(Source::Radio), - ]); - assert_eq!( - availability(like_or_radio, Source::Radio, false), - Availability::Available - ); - assert_eq!( - availability(like_or_radio, Source::Spotify, true), - Availability::Available - ); - assert_eq!( - availability(like_or_radio, Source::Local, true), - Availability::NotForSource(Source::Local) - ); - } - - #[test] - fn hints_name_the_source_or_spotify() { - assert_eq!(Availability::Available.hint(), None); - assert_eq!( - Availability::NeedsSpotify.hint().as_deref(), - Some("needs Spotify") - ); - assert_eq!( - Availability::NotForSource(Source::Local).hint().as_deref(), - Some("not for Local Files") - ); - assert_eq!( - Availability::OnlyForSource(Source::Radio).hint().as_deref(), - Some("Internet Radio only") - ); - } -} diff --git a/src/core/sort.rs b/src/core/sort.rs deleted file mode 100644 index 68892c82..00000000 --- a/src/core/sort.rs +++ /dev/null @@ -1,383 +0,0 @@ -//! Sorting types and utilities for spotatui contexts -//! -//! Provides sorting functionality for playlists, albums, artists, etc. - -use rspotify::model::track::FullTrack; -use serde::{Deserialize, Serialize}; - -/// Fields that can be used for sorting -/// -/// The serde derives on this enum, `SortOrder` and `SortContext` are the -/// action-vocabulary wire shape: `Action::Sort` carries them directly. -#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Serialize, Deserialize)] -pub enum SortField { - /// Original API order (no sorting applied) - #[default] - Default, - /// Alphabetical by name/title - Name, - /// By date added (for playlists, saved albums) - DateAdded, - /// By artist name (for tracks) - Artist, - /// By track/album duration - Duration, - /// By album name (for tracks) - Album, -} - -impl SortField { - /// Get display name for the sort field - pub fn display_name(&self) -> &'static str { - match self { - SortField::Default => "Default", - SortField::Name => "Name", - SortField::DateAdded => "Date Added", - SortField::Artist => "Artist", - SortField::Duration => "Duration", - SortField::Album => "Album", - } - } - - /// Get the keyboard shortcut for this field - pub fn shortcut(&self) -> Option<char> { - match self { - SortField::Default => Some('d'), - SortField::Name => Some('n'), - SortField::DateAdded => Some('a'), - SortField::Artist => Some('r'), - SortField::Duration => Some('t'), - SortField::Album => Some('l'), - } - } - - /// Lowercase config-file token, e.g. `"name"`, `"date_added"`. - pub fn to_config_str(self) -> &'static str { - match self { - SortField::Default => "default", - SortField::Name => "name", - SortField::DateAdded => "date_added", - SortField::Artist => "artist", - SortField::Duration => "duration", - SortField::Album => "album", - } - } - - /// Parse a config-file token back to a `SortField`. Returns `None` for - /// unknown strings (callers surface the context's valid fields in the error). - pub fn from_config_str(s: &str) -> Option<Self> { - match s.trim() { - "default" => Some(SortField::Default), - "name" => Some(SortField::Name), - "date_added" => Some(SortField::DateAdded), - "artist" => Some(SortField::Artist), - "duration" => Some(SortField::Duration), - "album" => Some(SortField::Album), - _ => None, - } - } -} - -/// Sort order direction -#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Serialize, Deserialize)] -pub enum SortOrder { - #[default] - Ascending, - Descending, -} - -impl SortOrder { - /// Toggle between ascending and descending - pub fn toggle(&self) -> Self { - match self { - SortOrder::Ascending => SortOrder::Descending, - SortOrder::Descending => SortOrder::Ascending, - } - } - - /// Get the sort indicator arrow - #[allow(dead_code)] - pub fn indicator(&self) -> &'static str { - match self { - SortOrder::Ascending => "↑", - SortOrder::Descending => "↓", - } - } - - /// Get the sort indicator using caller-supplied icons (from config). - pub fn indicator_icon<'a>(&self, ascending: &'a str, descending: &'a str) -> &'a str { - match self { - SortOrder::Ascending => ascending, - SortOrder::Descending => descending, - } - } - - /// Config-file token for the direction suffix (`:desc`). - #[allow(dead_code)] - pub fn to_config_suffix(self) -> &'static str { - match self { - SortOrder::Ascending => "", - SortOrder::Descending => ":desc", - } - } - - /// Parse a `:desc` direction suffix. Only `desc` flips to descending; - /// everything else (including `asc` or empty) is ascending. - pub fn parse_suffix(s: &str) -> Self { - match s.trim() { - "desc" => SortOrder::Descending, - _ => SortOrder::Ascending, - } - } -} - -/// Context that supports sorting -#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)] -pub enum SortContext { - /// Tracks in a playlist - PlaylistTracks, - /// User's saved albums - SavedAlbums, - /// User's followed artists - SavedArtists, - #[allow(dead_code)] - /// Recently played tracks - RecentlyPlayed, -} - -impl SortContext { - /// Get the available sort fields for this context - pub fn available_fields(&self) -> &'static [SortField] { - match self { - SortContext::PlaylistTracks => &[ - SortField::Default, - SortField::Name, - SortField::DateAdded, - SortField::Artist, - SortField::Album, - SortField::Duration, - ], - SortContext::SavedAlbums => &[ - SortField::Default, - SortField::Name, - SortField::DateAdded, - SortField::Artist, - ], - SortContext::SavedArtists => &[SortField::Default, SortField::Name], - SortContext::RecentlyPlayed => &[ - SortField::Default, - SortField::Name, - SortField::Artist, - SortField::Album, - ], - } - } -} - -/// Current sort state -#[derive(Clone, Copy, PartialEq, Debug, Default)] -pub struct SortState { - pub field: SortField, - pub order: SortOrder, -} - -impl SortState { - pub fn new() -> Self { - Self::default() - } - - /// Apply a new sort field, toggling order if same field selected - pub fn apply_field(&mut self, field: SortField) { - if self.field == field { - self.order = self.order.toggle(); - } else { - self.field = field; - self.order = SortOrder::Ascending; - } - } - - /// Reset to default sort state - #[allow(dead_code)] - pub fn reset(&mut self) { - self.field = SortField::Default; - self.order = SortOrder::Ascending; - } - - /// Render as a config token: `"field"` or `"field:desc"`. - #[allow(dead_code)] - pub fn to_config_str(self) -> String { - format!( - "{}{}", - self.field.to_config_str(), - self.order.to_config_suffix() - ) - } - - /// Parse a `"<field>"` / `"<field>:desc"` spec, validating that the field is - /// available in `ctx`. Hard error (with the context's valid fields) if the - /// field is unknown or unavailable in this context. - pub fn parse(spec: &str, ctx: SortContext) -> Result<Self, String> { - let (field_str, order_str) = match spec.split_once(':') { - Some((f, o)) => (f, o), - None => (spec, ""), - }; - let field = SortField::from_config_str(field_str).ok_or_else(|| { - format!( - "unknown sort field '{}' (valid for this context: {})", - field_str.trim(), - ctx - .available_fields() - .iter() - .map(|f| f.to_config_str()) - .collect::<Vec<_>>() - .join(", ") - ) - })?; - if !ctx.available_fields().contains(&field) { - return Err(format!( - "sort field '{}' is not available in this context (valid: {})", - field.to_config_str(), - ctx - .available_fields() - .iter() - .map(|f| f.to_config_str()) - .collect::<Vec<_>>() - .join(", ") - )); - } - Ok(Self { - field, - order: SortOrder::parse_suffix(order_str), - }) - } -} - -/// Sort by a precomputed key — one key per item (O(n) allocations) instead of -/// one per comparison (O(n log n)) — honoring the sort direction. Stable in -/// both directions: equal keys keep their prior relative order. -pub fn sort_by_key_with_order<T, K: Ord, F: FnMut(&T) -> K>( - items: &mut [T], - order: SortOrder, - mut key: F, -) { - match order { - SortOrder::Ascending => items.sort_by_cached_key(|item| key(item)), - SortOrder::Descending => items.sort_by_cached_key(|item| std::cmp::Reverse(key(item))), - } -} - -pub struct Sorter { - state: SortState, -} - -impl Sorter { - pub fn new(state: SortState) -> Self { - Self { state } - } - - pub fn sort_tracks(&self, tracks: &mut [FullTrack]) { - if self.state.field == SortField::Default { - return; - } - - tracks.sort_by(|a, b| { - let order = match self.state.field { - SortField::Name => a.name.cmp(&b.name), - SortField::Duration => a.duration.cmp(&b.duration), - SortField::Artist => { - let empty_string = String::new(); - let artist_a = a - .artists - .first() - .map(|ar| &ar.name) - .unwrap_or(&empty_string); - let artist_b = b - .artists - .first() - .map(|ar| &ar.name) - .unwrap_or(&empty_string); - artist_a.cmp(artist_b) - } - SortField::Album => a.album.name.cmp(&b.album.name), - // DateAdded requires PlaylistItem wrapper which we don't have here. - // Assuming Default order is DateAdded for playlists. - _ => std::cmp::Ordering::Equal, - }; - - if self.state.order == SortOrder::Descending { - order.reverse() - } else { - order - } - }); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_sort_state_apply_field() { - let mut state = SortState::new(); - assert_eq!(state.field, SortField::Default); - assert_eq!(state.order, SortOrder::Ascending); - - // Apply new field - state.apply_field(SortField::Name); - assert_eq!(state.field, SortField::Name); - assert_eq!(state.order, SortOrder::Ascending); - - // Apply same field toggles order - state.apply_field(SortField::Name); - assert_eq!(state.field, SortField::Name); - assert_eq!(state.order, SortOrder::Descending); - - // Apply different field resets order - state.apply_field(SortField::Artist); - assert_eq!(state.field, SortField::Artist); - assert_eq!(state.order, SortOrder::Ascending); - } - - #[test] - fn test_sort_order_toggle() { - assert_eq!(SortOrder::Ascending.toggle(), SortOrder::Descending); - assert_eq!(SortOrder::Descending.toggle(), SortOrder::Ascending); - } - - #[test] - fn sort_order_indicators_and_config_suffixes_match_direction() { - assert_eq!(SortOrder::Ascending.indicator(), "↑"); - assert_eq!(SortOrder::Descending.indicator(), "↓"); - assert_eq!(SortOrder::Ascending.to_config_suffix(), ""); - assert_eq!(SortOrder::Descending.to_config_suffix(), ":desc"); - } - - #[test] - fn sort_state_config_round_trips() { - let parsed = SortState::parse("artist:desc", SortContext::PlaylistTracks).unwrap(); - assert_eq!( - parsed, - SortState { - field: SortField::Artist, - order: SortOrder::Descending - } - ); - assert_eq!(parsed.to_config_str(), "artist:desc"); - assert_eq!( - SortState::parse("artist", SortContext::SavedArtists).unwrap_err(), - "sort field 'artist' is not available in this context (valid: default, name)" - ); - } - - #[test] - fn test_context_available_fields() { - let fields = SortContext::PlaylistTracks.available_fields(); - assert!(fields.contains(&SortField::Name)); - assert!(fields.contains(&SortField::Artist)); - - let fields = SortContext::SavedArtists.available_fields(); - assert!(fields.contains(&SortField::Name)); - assert!(!fields.contains(&SortField::Artist)); - } -} diff --git a/src/core/source.rs b/src/core/source.rs deleted file mode 100644 index af0f4b17..00000000 --- a/src/core/source.rs +++ /dev/null @@ -1,258 +0,0 @@ -//! Capability-based media-source traits — the seam for the multi-source refactor. -//! -//! A source is addressed by its URI **scheme** (`spotify:`, `file:`, `subsonic:`), -//! mirroring Mopidy's proven dispatch model. [`MediaSource`] is the required -//! minimum every source implements; the remaining traits are optional -//! capabilities discovered at runtime, so the UI can light up per source (e.g. -//! a source without [`LibraryProvider`] shows no "liked songs" tab). -//! -//! These are definitions only — implementations land in later slices -//! (`SpotifySource` over the existing `Network`, then `infra/local/` and -//! `infra/subsonic/`). All methods speak the domain types from -//! [`crate::core::plugin_api`]; rspotify types never appear here. -//! -//! **Dispatch.** These use native `async fn` in traits (matching the existing -//! `PlaybackNetwork` convention), which is *not* `dyn`-compatible. The planned -//! by-scheme routing therefore dispatches over a **closed enum** of concrete -//! sources (one variant per backend), matching on `scheme()` — not -//! `Box<dyn MediaSource>`. If open/plugin-provided sources are ever needed, add -//! the `async-trait` crate at that point rather than reaching for `dyn` here. - -// No implementors in the binary yet; the multi-source slices wire these up. -#![allow(dead_code)] - -use crate::core::plugin_api::{AlbumInfo, ArtistInfo, PlaylistInfo, SearchResults, TrackInfo}; -use anyhow::Result; -use serde::{Deserialize, Serialize}; - -/// The source the UI is currently scoped to — which catalog the sidebar, search -/// and capability gating reflect. -/// -/// This is a **browse-scope** marker only: it never changes playback routing -/// (that stays URI-scheme driven via `route_local_event` + `App::local_playback`), -/// so switching the active source never interrupts what is currently playing. -/// -/// The enum is deliberately unconditional — every variant compiles in every -/// build (including the slim `telemetry`-only CI build) so handlers and UI code -/// never need `#[cfg]`. Only the per-source *data loading* is gated behind that -/// source's feature (`local-files`, `subsonic`). -/// -/// The serde derives are the action-vocabulary wire shape; the derived unit- -/// variant strings coincide with the `to_config_str()` tokens, while the -/// `state.yml` persistence keeps its own lenient hand-written helpers. -#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Serialize, Deserialize)] -pub enum Source { - #[default] - Spotify, - Local, - Subsonic, - Radio, - YouTube, - Qobuz, -} - -impl Source { - /// Every selectable source, in display order. Add new sources here. - pub const ALL: [Source; 6] = [ - Source::Spotify, - Source::Local, - Source::Subsonic, - Source::Radio, - Source::YouTube, - Source::Qobuz, - ]; - - /// Human-readable label shown in the source picker. - pub fn label(&self) -> &'static str { - match self { - Source::Spotify => "Spotify", - Source::Local => "Local Files", - Source::Subsonic => "Subsonic", - Source::Radio => "Internet Radio", - Source::YouTube => "YouTube", - Source::Qobuz => "Qobuz", - } - } - - /// Config-file token used to persist the active source. - /// Distinct from `label()` so the config key stays stable even if the - /// display name changes. - pub fn to_config_str(self) -> &'static str { - match self { - Source::Spotify => "Spotify", - Source::Local => "Local", - Source::Subsonic => "Subsonic", - Source::Radio => "Radio", - Source::YouTube => "YouTube", - Source::Qobuz => "Qobuz", - } - } - - /// Parse the config-file token back to a `Source`. - /// Unknown strings fall back to `Spotify` so old or hand-edited configs - /// never break startup. - pub fn from_config_str(s: &str) -> Self { - match s { - "Local" => Source::Local, - "Subsonic" => Source::Subsonic, - "Radio" => Source::Radio, - "YouTube" => Source::YouTube, - "Qobuz" => Source::Qobuz, - _ => Source::Spotify, - } - } - - /// Whether this source can search its catalog (implements [`Searcher`]). - pub fn supports_search(&self) -> bool { - matches!( - self, - Source::Spotify | Source::Subsonic | Source::Radio | Source::YouTube | Source::Qobuz - ) - } - - /// Whether this source exposes a saved library — liked songs, saved albums, - /// followed artists (implements [`LibraryProvider`]). - pub fn supports_library(&self) -> bool { - matches!(self, Source::Spotify) - } - - /// Whether this source can mutate playlists: Spotify through the Web API, - /// YouTube through its local playlist file. - pub fn supports_playlist_write(&self) -> bool { - matches!(self, Source::Spotify | Source::YouTube) - } - - /// Whether this source supports liking / saving the currently-playing track - /// (requires a [`LibraryProvider`] that can accept writes). Currently - /// equivalent to [`supports_library`](Self::supports_library). - pub fn supports_like(&self) -> bool { - matches!(self, Source::Spotify) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn serde_tokens_match_the_config_tokens() { - for source in Source::ALL { - let wire = serde_json::to_string(&source).unwrap(); - assert_eq!(wire, format!("\"{}\"", source.to_config_str())); - assert_eq!(serde_json::from_str::<Source>(&wire).unwrap(), source); - } - } - - #[test] - fn spotify_supports_all_capabilities() { - assert!(Source::Spotify.supports_search()); - assert!(Source::Spotify.supports_library()); - assert!(Source::Spotify.supports_playlist_write()); - assert!(Source::Spotify.supports_like()); - } - - #[test] - fn local_supports_no_spotify_only_capabilities() { - assert!(!Source::Local.supports_search()); - assert!(!Source::Local.supports_library()); - assert!(!Source::Local.supports_playlist_write()); - assert!(!Source::Local.supports_like()); - } - - #[test] - fn subsonic_supports_search_only() { - // Subsonic speaks `search3`, so search is on; library/playlist-write/like - // depend on `LibraryProvider`/`PlaylistWriter` impls that are deferred - // follow-ups, so they stay off for now. - assert!(Source::Subsonic.supports_search()); - assert!(!Source::Subsonic.supports_library()); - assert!(!Source::Subsonic.supports_playlist_write()); - assert!(!Source::Subsonic.supports_like()); - } - - #[test] - fn radio_supports_search_only() { - // The radio-browser.info directory is searchable; there is no library, - // no playlists to write, and no like/star concept for a live stream. - assert!(Source::Radio.supports_search()); - assert!(!Source::Radio.supports_library()); - assert!(!Source::Radio.supports_playlist_write()); - assert!(!Source::Radio.supports_like()); - } - - #[test] - fn youtube_supports_search_and_its_local_playlists() { - // yt-dlp searches the catalog and playlists live in a local file; there is - // no library and no like/star concept without a Google account. - assert!(Source::YouTube.supports_search()); - assert!(!Source::YouTube.supports_library()); - assert!(Source::YouTube.supports_playlist_write()); - assert!(!Source::YouTube.supports_like()); - } - - #[test] - fn qobuz_supports_search_only() { - // `catalog/search` is searchable; favorites and playlists are read through - // the sidebar, and like/playlist writes are follow-ups. - assert!(Source::Qobuz.supports_search()); - assert!(!Source::Qobuz.supports_library()); - assert!(!Source::Qobuz.supports_playlist_write()); - assert!(!Source::Qobuz.supports_like()); - } - - #[test] - fn config_str_round_trips_every_source() { - for source in Source::ALL { - assert_eq!(Source::from_config_str(source.to_config_str()), source); - } - } -} - -/// The required minimum every media source implements: browse playlists and the -/// tracks within them. -pub trait MediaSource { - /// Human-readable source name shown in the UI (e.g. `"Spotify"`, `"Navidrome"`). - fn name(&self) -> &str; - - /// URI scheme this source owns, without the colon (e.g. `"spotify"`, `"file"`, - /// `"subsonic"`). Used to route a URI to the source that can handle it. - fn scheme(&self) -> &str; - - /// The user's playlists for this source. - async fn playlists(&self) -> Result<Vec<PlaylistInfo>>; - - /// The tracks of a playlist, identified by its source-native URI. - async fn tracks(&self, playlist_uri: &str) -> Result<Vec<TrackInfo>>; -} - -/// Optional capability: search the source's catalog. -pub trait Searcher { - async fn search(&self, query: &str) -> Result<SearchResults>; -} - -/// Optional capability: the user's saved library (liked tracks, saved albums, -/// followed artists). -pub trait LibraryProvider { - async fn saved_tracks(&self) -> Result<Vec<TrackInfo>>; - async fn saved_albums(&self) -> Result<Vec<AlbumInfo>>; - async fn saved_artists(&self) -> Result<Vec<ArtistInfo>>; -} - -/// Optional capability: mutate playlists (add/remove tracks by URI). -pub trait PlaylistWriter { - async fn add_tracks(&self, playlist_uri: &str, track_uris: &[String]) -> Result<()>; - async fn remove_tracks(&self, playlist_uri: &str, track_uris: &[String]) -> Result<()>; -} - -/// Optional capability: produce a playable audio stream for a URI and route it -/// into the shared rodio sink (so the visualizer and volume control work -/// uniformly across sources). -/// -/// The concrete stream/handle return type is defined in the local-files slice -/// (Phase 3), when the symphonia → rodio pipeline is wired. Until then this is -/// the marker seam that lets the dispatch layer ask "can this source stream?". -pub trait Streamer { - /// Begin streaming the given URI. Returns once playback has started (or - /// errors if the URI is not streamable by this source). - async fn stream(&self, uri: &str) -> Result<()>; -} diff --git a/src/core/state.rs b/src/core/state.rs deleted file mode 100644 index e2268d92..00000000 --- a/src/core/state.rs +++ /dev/null @@ -1,982 +0,0 @@ -//! Persisted runtime state and app-managed mutable data. -//! -//! This is the machine-owned counterpart to `config.yml`. It holds values that -//! change as the app runs, or data the app manages directly, so ordinary config -//! edits do not churn every time volume, source, pane sizes, announcements, or -//! radio stations change. - -use crate::core::{limits::MAX_PLAYBAR_ROWS, source::Source}; -use anyhow::{anyhow, Context, Result}; -use serde::{Deserialize, Serialize}; -use std::path::{Path, PathBuf}; - -const FILE_NAME: &str = "state.yml"; - -/// One saved internet-radio station: a display name plus the direct stream URL. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct RadioStationConfig { - pub name: String, - pub url: String, -} - -/// The Qobuz web-player constants, cached by bundle version (public values only). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct QobuzBundleCache { - pub bundle_version: String, - pub app_id: String, - pub app_secret: String, - pub oauth_key: String, -} - -impl QobuzBundleCache { - fn is_complete(&self) -> bool { - !self.bundle_version.is_empty() - && !self.app_id.is_empty() - && !self.app_secret.is_empty() - && !self.oauth_key.is_empty() - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum RadioStationAddOutcome { - Added, - AlreadyExists, -} - -/// Live runtime/app-managed state. -#[derive(Clone, Debug, PartialEq)] -pub struct RuntimeState { - pub volume_percent: u8, - pub shuffle_enabled: bool, - pub active_source: Source, - pub seen_announcement_ids: Vec<String>, - pub dismissed_announcements: Vec<String>, - pub sidebar_width_percent: u8, - pub playbar_height_rows: u16, - pub library_height_percent: u8, - pub radio_stations: Vec<RadioStationConfig>, - /// Whether the one-time community-playlist-pin prompt has been shown. - pub community_pin_prompt_shown: bool, -} - -impl Default for RuntimeState { - fn default() -> Self { - Self { - volume_percent: 100, - shuffle_enabled: false, - active_source: Source::default(), - seen_announcement_ids: Vec::new(), - dismissed_announcements: Vec::new(), - sidebar_width_percent: 20, - playbar_height_rows: 6, - library_height_percent: 30, - radio_stations: Vec::new(), - community_pin_prompt_shown: false, - } - } -} - -impl RuntimeState { - pub fn apply_persisted(&mut self, state: &PersistedRuntimeState) { - if let Some(volume_percent) = state.volume_percent { - self.volume_percent = volume_percent.min(100); - } - if let Some(shuffle_enabled) = state.shuffle_enabled { - self.shuffle_enabled = shuffle_enabled; - } - if let Some(active_source) = state.active_source { - self.active_source = active_source; - } - if let Some(seen_announcement_ids) = &state.seen_announcement_ids { - self.seen_announcement_ids = sanitized_ids(seen_announcement_ids); - } - if let Some(dismissed_announcements) = &state.dismissed_announcements { - self.dismissed_announcements = sanitized_ids(dismissed_announcements); - } - if let Some(sidebar_width_percent) = state.sidebar_width_percent { - self.sidebar_width_percent = sidebar_width_percent.min(100); - } - if let Some(playbar_height_rows) = state.playbar_height_rows { - self.playbar_height_rows = playbar_height_rows.min(MAX_PLAYBAR_ROWS); - } - if let Some(library_height_percent) = state.library_height_percent { - self.library_height_percent = library_height_percent.min(100); - } - if let Some(radio_stations) = &state.radio_stations { - self.radio_stations = sanitized_radio_stations(radio_stations); - } - if let Some(community_pin_prompt_shown) = state.community_pin_prompt_shown { - self.community_pin_prompt_shown = community_pin_prompt_shown; - } - } - - pub fn to_persisted(&self) -> PersistedRuntimeState { - PersistedRuntimeState { - volume_percent: Some(self.volume_percent.min(100)), - shuffle_enabled: Some(self.shuffle_enabled), - active_source: Some(self.active_source), - seen_announcement_ids: Some(sanitized_ids(&self.seen_announcement_ids)), - dismissed_announcements: Some(sanitized_ids(&self.dismissed_announcements)), - sidebar_width_percent: Some(self.sidebar_width_percent.min(100)), - playbar_height_rows: Some(self.playbar_height_rows.min(MAX_PLAYBAR_ROWS)), - library_height_percent: Some(self.library_height_percent.min(100)), - radio_stations: Some(sanitized_radio_stations(&self.radio_stations)), - community_pin_prompt_shown: Some(self.community_pin_prompt_shown), - qobuz_bundle_cache: None, - } - } - - pub fn add_radio_station( - &mut self, - name: impl AsRef<str>, - url: impl AsRef<str>, - ) -> Result<RadioStationAddOutcome> { - let name = name.as_ref().trim(); - let url = url.as_ref().trim(); - - if name.is_empty() { - return Err(anyhow!("Radio station name is empty")); - } - if url.is_empty() { - return Err(anyhow!("Radio station URL is empty")); - } - - if self - .radio_stations - .iter() - .any(|station| station.url.trim() == url) - { - return Ok(RadioStationAddOutcome::AlreadyExists); - } - - self.radio_stations.push(RadioStationConfig { - name: name.to_string(), - url: url.to_string(), - }); - - Ok(RadioStationAddOutcome::Added) - } - - pub fn remove_radio_station_by_url( - &mut self, - url: impl AsRef<str>, - ) -> Result<Option<RadioStationConfig>> { - let url = url.as_ref().trim(); - if url.is_empty() { - return Err(anyhow!("Radio station URL is empty")); - } - - let Some(index) = self - .radio_stations - .iter() - .position(|station| station.url.trim() == url) - else { - return Ok(None); - }; - - Ok(Some(self.radio_stations.remove(index))) - } - - pub fn mark_announcement_seen(&mut self, announcement_id: impl Into<String>) { - let id = announcement_id.into(); - if id.is_empty() { - return; - } - - if !self.seen_announcement_ids.iter().any(|seen| seen == &id) { - self.seen_announcement_ids.push(id); - } - } -} - -/// Optional state fields exactly as stored in `state.yml`. -/// -/// Fields are optional so sparse or older `state.yml` files can overlay only the -/// values they contain onto runtime defaults. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct PersistedRuntimeState { - #[serde(skip_serializing_if = "Option::is_none")] - pub volume_percent: Option<u8>, - #[serde(skip_serializing_if = "Option::is_none")] - pub shuffle_enabled: Option<bool>, - #[serde( - skip_serializing_if = "Option::is_none", - serialize_with = "source_config::serialize_option", - deserialize_with = "source_config::deserialize_option" - )] - pub active_source: Option<Source>, - #[serde(skip_serializing_if = "Option::is_none")] - pub seen_announcement_ids: Option<Vec<String>>, - #[serde(skip_serializing_if = "Option::is_none")] - pub dismissed_announcements: Option<Vec<String>>, - #[serde(skip_serializing_if = "Option::is_none")] - pub sidebar_width_percent: Option<u8>, - #[serde(skip_serializing_if = "Option::is_none")] - pub playbar_height_rows: Option<u16>, - #[serde(skip_serializing_if = "Option::is_none")] - pub library_height_percent: Option<u8>, - #[serde(skip_serializing_if = "Option::is_none")] - pub radio_stations: Option<Vec<RadioStationConfig>>, - #[serde(skip_serializing_if = "Option::is_none")] - pub community_pin_prompt_shown: Option<bool>, - #[serde(skip_serializing_if = "Option::is_none")] - pub qobuz_bundle_cache: Option<QobuzBundleCache>, -} - -impl PersistedRuntimeState { - pub fn volume_percent(volume_percent: u8) -> Self { - Self { - volume_percent: Some(volume_percent.min(100)), - ..Default::default() - } - } - - pub fn shuffle_enabled(shuffle_enabled: bool) -> Self { - Self { - shuffle_enabled: Some(shuffle_enabled), - ..Default::default() - } - } - - pub fn active_source(active_source: Source) -> Self { - Self { - active_source: Some(active_source), - ..Default::default() - } - } - - pub fn announcements( - seen_announcement_ids: &[String], - dismissed_announcements: &[String], - ) -> Self { - Self { - seen_announcement_ids: Some(sanitized_ids(seen_announcement_ids)), - dismissed_announcements: Some(sanitized_ids(dismissed_announcements)), - ..Default::default() - } - } - - pub fn sidebar_width_percent(sidebar_width_percent: u8) -> Self { - Self { - sidebar_width_percent: Some(sidebar_width_percent.min(100)), - ..Default::default() - } - } - - pub fn playbar_height_rows(playbar_height_rows: u16) -> Self { - Self { - playbar_height_rows: Some(playbar_height_rows.min(MAX_PLAYBAR_ROWS)), - ..Default::default() - } - } - - pub fn library_height_percent(library_height_percent: u8) -> Self { - Self { - library_height_percent: Some(library_height_percent.min(100)), - ..Default::default() - } - } - - pub fn radio_station(radio_station: RadioStationConfig) -> Self { - Self { - radio_stations: Some(vec![radio_station]), - ..Default::default() - } - } - - pub fn community_pin_prompt_shown(community_pin_prompt_shown: bool) -> Self { - Self { - community_pin_prompt_shown: Some(community_pin_prompt_shown), - ..Default::default() - } - } - - #[cfg_attr(not(feature = "qobuz"), allow(dead_code))] - pub fn qobuz_bundle_cache(qobuz_bundle_cache: QobuzBundleCache) -> Self { - Self { - qobuz_bundle_cache: Some(qobuz_bundle_cache), - ..Default::default() - } - } - - pub fn merge_patch(&mut self, patch: &Self) { - merge_state_patch(self, patch); - } - - pub fn is_empty(&self) -> bool { - self.volume_percent.is_none() - && self.shuffle_enabled.is_none() - && self.active_source.is_none() - && self.seen_announcement_ids.is_none() - && self.dismissed_announcements.is_none() - && self.sidebar_width_percent.is_none() - && self.playbar_height_rows.is_none() - && self.library_height_percent.is_none() - && self.radio_stations.is_none() - && self.community_pin_prompt_shown.is_none() - && self.qobuz_bundle_cache.is_none() - } -} - -/// Location of the app runtime-state file: `<state dir>/state.yml`. -pub fn default_state_path() -> Result<PathBuf> { - crate::core::paths::app_state_dir() - .map(|dir| dir.join(FILE_NAME)) - .ok_or_else(|| anyhow!("cannot resolve the spotatui state directory")) -} - -/// Outcome of reading `state.yml` off disk, before deciding how to react to a -/// bad file. Loading (startup) surfaces a malformed file as an error, while -/// saving heals it; both share this classification so the read logic lives in -/// one place. -enum StateRead { - /// The file is absent or blank; treat as empty state. - Empty, - /// The file parsed cleanly. - Parsed(PersistedRuntimeState), - /// The file was readable but its contents did not parse. - Malformed(anyhow::Error), - /// The file could not be read (permissions, other I/O error). - Unreadable(anyhow::Error), -} - -fn read_state(path: &Path) -> StateRead { - // Read raw bytes so non-UTF-8 contents count as a corrupt file we can heal on - // save, not an I/O error to propagate. `read_to_string` would fold invalid - // UTF-8 (e.g. a write truncated mid-multibyte-character) into `ErrorKind`, - // leaving it wedged forever. - let bytes = match std::fs::read(path) { - Ok(bytes) => bytes, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return StateRead::Empty, - Err(error) => { - return StateRead::Unreadable( - anyhow::Error::new(error).context(format!("reading {}", path.display())), - ) - } - }; - - let contents = match std::str::from_utf8(&bytes) { - Ok(contents) => contents, - Err(error) => { - return StateRead::Malformed( - anyhow::Error::new(error) - .context(format!("malformed runtime state file: {}", path.display())), - ) - } - }; - - if contents.trim().is_empty() { - return StateRead::Empty; - } - - match serde_yaml::from_str::<PersistedRuntimeState>(contents) { - Ok(state) => StateRead::Parsed(sanitized_persisted_state(&state)), - Err(error) => StateRead::Malformed( - anyhow::Error::new(error) - .context(format!("malformed runtime state file: {}", path.display())), - ), - } -} - -/// Load state. A missing file means empty state; malformed state is returned as -/// an error so callers can log and continue with config/default values. -pub fn load(path: &Path) -> Result<PersistedRuntimeState> { - match read_state(path) { - StateRead::Empty => Ok(PersistedRuntimeState::default()), - StateRead::Parsed(state) => Ok(state), - StateRead::Malformed(error) | StateRead::Unreadable(error) => Err(error), - } -} - -/// Load existing state for a read-modify-write save, healing a corrupt file. -/// -/// `save` re-reads `state.yml` before merging, so a malformed file would -/// otherwise wedge every future save forever and surface a UI error on each -/// runtime change (volume nudge, layout tweak). Mirror the `config.yml` -/// fallback: move the unreadable file aside to `state.yml.bak` (best effort), -/// warn, and continue from empty state. A genuine read failure is still -/// propagated so a transient error never discards good data by overwriting it -/// with defaults. -fn load_for_save(path: &Path) -> Result<PersistedRuntimeState> { - match read_state(path) { - StateRead::Empty => Ok(PersistedRuntimeState::default()), - StateRead::Parsed(state) => Ok(state), - StateRead::Malformed(error) => { - let backup = path.with_extension("yml.bak"); - match std::fs::rename(path, &backup) { - Ok(()) => log::warn!( - "[state] {error:#}; backed up to {} and starting from defaults", - backup.display() - ), - Err(rename_error) => log::warn!( - "[state] {error:#}; could not back up to {} ({rename_error}); overwriting with defaults", - backup.display() - ), - } - Ok(PersistedRuntimeState::default()) - } - StateRead::Unreadable(error) => Err(error), - } -} - -/// Save a state patch atomically and privately. -/// -/// Runtime saves are intentionally read-modify-write: most save call sites only -/// own one field, so blindly writing a whole in-memory snapshot would clobber -/// updates made by another running instance. -pub fn save(path: &Path, state: &PersistedRuntimeState) -> Result<()> { - let mut merged = load_for_save(path)?; - merge_state_patch(&mut merged, state); - write_state(path, &merged) -} - -/// Remove a radio station without sending a stale full station list. -pub fn save_removing_radio_station(path: &Path, url: &str) -> Result<()> { - let mut merged = load_for_save(path)?; - if let Some(stations) = &mut merged.radio_stations { - let url = url.trim(); - stations.retain(|station| station.url.trim() != url); - *stations = sanitized_radio_stations(stations); - } else { - merged.radio_stations = Some(Vec::new()); - } - write_state(path, &merged) -} - -fn write_state(path: &Path, state: &PersistedRuntimeState) -> Result<()> { - let state = sanitized_persisted_state(state); - let yaml = serde_yaml::to_string(&state).context("serializing runtime state")?; - if let Some(dir) = path.parent() { - crate::core::paths::ensure_private_dir(dir)?; - } - crate::core::auth::write_private_file_atomic(path, yaml.as_bytes()) - .with_context(|| format!("writing {}", path.display()))?; - Ok(()) -} - -fn sanitized_persisted_state(state: &PersistedRuntimeState) -> PersistedRuntimeState { - PersistedRuntimeState { - volume_percent: state.volume_percent.map(|volume| volume.min(100)), - shuffle_enabled: state.shuffle_enabled, - active_source: state.active_source, - seen_announcement_ids: state.seen_announcement_ids.as_deref().map(sanitized_ids), - dismissed_announcements: state.dismissed_announcements.as_deref().map(sanitized_ids), - sidebar_width_percent: state.sidebar_width_percent.map(|width| width.min(100)), - playbar_height_rows: state - .playbar_height_rows - .map(|rows| rows.min(MAX_PLAYBAR_ROWS)), - library_height_percent: state.library_height_percent.map(|height| height.min(100)), - radio_stations: state - .radio_stations - .as_deref() - .map(sanitized_radio_stations), - community_pin_prompt_shown: state.community_pin_prompt_shown, - qobuz_bundle_cache: state - .qobuz_bundle_cache - .clone() - .filter(QobuzBundleCache::is_complete), - } -} - -fn merge_state_patch(merged: &mut PersistedRuntimeState, patch: &PersistedRuntimeState) { - if let Some(volume_percent) = patch.volume_percent { - merged.volume_percent = Some(volume_percent.min(100)); - } - if let Some(shuffle_enabled) = patch.shuffle_enabled { - merged.shuffle_enabled = Some(shuffle_enabled); - } - if let Some(active_source) = patch.active_source { - merged.active_source = Some(active_source); - } - if let Some(seen_announcement_ids) = &patch.seen_announcement_ids { - merged.seen_announcement_ids = Some(merged_ids( - merged.seen_announcement_ids.as_deref().unwrap_or(&[]), - seen_announcement_ids, - )); - } - if let Some(dismissed_announcements) = &patch.dismissed_announcements { - merged.dismissed_announcements = Some(merged_ids( - merged.dismissed_announcements.as_deref().unwrap_or(&[]), - dismissed_announcements, - )); - } - if let Some(sidebar_width_percent) = patch.sidebar_width_percent { - merged.sidebar_width_percent = Some(sidebar_width_percent.min(100)); - } - if let Some(playbar_height_rows) = patch.playbar_height_rows { - merged.playbar_height_rows = Some(playbar_height_rows.min(MAX_PLAYBAR_ROWS)); - } - if let Some(library_height_percent) = patch.library_height_percent { - merged.library_height_percent = Some(library_height_percent.min(100)); - } - if let Some(radio_stations) = &patch.radio_stations { - merged.radio_stations = Some(merged_radio_stations( - merged.radio_stations.as_deref().unwrap_or(&[]), - radio_stations, - )); - } - if let Some(community_pin_prompt_shown) = patch.community_pin_prompt_shown { - merged.community_pin_prompt_shown = Some(community_pin_prompt_shown); - } - if let Some(qobuz_bundle_cache) = &patch.qobuz_bundle_cache { - merged.qobuz_bundle_cache = Some(qobuz_bundle_cache.clone()); - } -} - -fn sanitized_ids(ids: &[String]) -> Vec<String> { - ids - .iter() - .map(|id| id.trim().to_string()) - .filter(|id| !id.is_empty()) - .collect() -} - -fn merged_ids(existing: &[String], incoming: &[String]) -> Vec<String> { - let mut seen = std::collections::HashSet::new(); - existing - .iter() - .chain(incoming.iter()) - .filter_map(|id| { - let id = id.trim(); - if id.is_empty() || !seen.insert(id.to_string()) { - return None; - } - Some(id.to_string()) - }) - .collect() -} - -pub(crate) fn sanitized_radio_stations(stations: &[RadioStationConfig]) -> Vec<RadioStationConfig> { - let mut seen_urls = std::collections::HashSet::new(); - - stations - .iter() - .filter_map(|station| { - let name = station.name.trim(); - let url = station.url.trim(); - if name.is_empty() || url.is_empty() { - return None; - } - - let url = url.to_string(); - if !seen_urls.insert(url.clone()) { - return None; - } - - Some(RadioStationConfig { - name: name.to_string(), - url, - }) - }) - .collect() -} - -pub(crate) fn merged_radio_stations( - existing: &[RadioStationConfig], - incoming: &[RadioStationConfig], -) -> Vec<RadioStationConfig> { - let mut stations = sanitized_radio_stations(existing); - let mut seen_urls = stations - .iter() - .map(|station| station.url.clone()) - .collect::<std::collections::HashSet<_>>(); - - for station in sanitized_radio_stations(incoming) { - if seen_urls.insert(station.url.clone()) { - stations.push(station); - } - } - - stations -} - -mod source_config { - use super::Source; - use serde::{Deserialize, Deserializer, Serializer}; - - pub(super) fn serialize_option<S>( - source: &Option<Source>, - serializer: S, - ) -> Result<S::Ok, S::Error> - where - S: Serializer, - { - match source { - Some(source) => serializer.serialize_some(source.to_config_str()), - None => serializer.serialize_none(), - } - } - - pub(super) fn deserialize_option<'de, D>(deserializer: D) -> Result<Option<Source>, D::Error> - where - D: Deserializer<'de>, - { - Option::<String>::deserialize(deserializer) - .map(|source| source.map(|source| Source::from_config_str(&source))) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn missing_file_loads_as_empty_state() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("state.yml"); - - assert_eq!(load(&path).unwrap(), PersistedRuntimeState::default()); - } - - #[test] - fn state_round_trips_all_runtime_attributes() { - let root = tempfile::tempdir().unwrap(); - let path = root.path().join("spotatui").join("state.yml"); - let state = PersistedRuntimeState { - volume_percent: Some(42), - shuffle_enabled: Some(true), - active_source: Some(Source::Radio), - seen_announcement_ids: Some(vec!["a".to_string(), "b".to_string()]), - dismissed_announcements: Some(vec!["old".to_string()]), - sidebar_width_percent: Some(25), - playbar_height_rows: Some(7), - library_height_percent: Some(35), - radio_stations: Some(vec![RadioStationConfig { - name: "Station".to_string(), - url: "https://example.test/stream".to_string(), - }]), - community_pin_prompt_shown: Some(true), - qobuz_bundle_cache: Some(QobuzBundleCache { - bundle_version: "8.2.0-b034".to_string(), - app_id: "123456789".to_string(), - app_secret: "s".repeat(32), - oauth_key: "k".to_string(), - }), - }; - - save(&path, &state).unwrap(); - - let yaml = std::fs::read_to_string(&path).unwrap(); - assert!(yaml.contains("active_source: Radio")); - assert_eq!(load(&path).unwrap(), state); - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let dir_mode = std::fs::metadata(path.parent().unwrap()) - .unwrap() - .permissions() - .mode() - & 0o777; - let file_mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; - assert_eq!(dir_mode, 0o700); - assert_eq!(file_mode, 0o600); - } - } - - #[test] - fn community_pin_prompt_shown_round_trips() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("state.yml"); - - save( - &path, - &PersistedRuntimeState::community_pin_prompt_shown(true), - ) - .unwrap(); - - assert_eq!(load(&path).unwrap().community_pin_prompt_shown, Some(true)); - } - - #[test] - fn incomplete_qobuz_bundle_cache_is_dropped_on_save() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("state.yml"); - - save( - &path, - &PersistedRuntimeState::qobuz_bundle_cache(QobuzBundleCache { - bundle_version: "8.2.0-b034".to_string(), - app_id: String::new(), - app_secret: "s".to_string(), - oauth_key: "k".to_string(), - }), - ) - .unwrap(); - - assert_eq!(load(&path).unwrap().qobuz_bundle_cache, None); - } - - #[test] - fn state_load_and_save_caps_playbar_height_rows() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("state.yml"); - std::fs::write( - &path, - format!("playbar_height_rows: {}\n", MAX_PLAYBAR_ROWS + 10), - ) - .unwrap(); - - assert_eq!( - load(&path).unwrap().playbar_height_rows, - Some(MAX_PLAYBAR_ROWS) - ); - - save( - &path, - &PersistedRuntimeState { - volume_percent: Some(40), - ..Default::default() - }, - ) - .unwrap(); - - assert_eq!( - load(&path).unwrap().playbar_height_rows, - Some(MAX_PLAYBAR_ROWS) - ); - } - - #[test] - fn state_save_merges_sparse_patches_without_clobbering_unmentioned_fields() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("state.yml"); - - let existing = PersistedRuntimeState { - volume_percent: Some(20), - active_source: Some(Source::Radio), - seen_announcement_ids: Some(vec!["first".to_string()]), - radio_stations: Some(vec![RadioStationConfig { - name: "Alpha".to_string(), - url: "https://example.test/alpha".to_string(), - }]), - ..Default::default() - }; - save(&path, &existing).unwrap(); - - let patch = PersistedRuntimeState { - shuffle_enabled: Some(true), - seen_announcement_ids: Some(vec!["second".to_string()]), - radio_stations: Some(vec![RadioStationConfig { - name: "Beta".to_string(), - url: "https://example.test/beta".to_string(), - }]), - ..Default::default() - }; - save(&path, &patch).unwrap(); - - assert_eq!( - load(&path).unwrap(), - PersistedRuntimeState { - volume_percent: Some(20), - shuffle_enabled: Some(true), - active_source: Some(Source::Radio), - seen_announcement_ids: Some(vec!["first".to_string(), "second".to_string()]), - radio_stations: Some(vec![ - RadioStationConfig { - name: "Alpha".to_string(), - url: "https://example.test/alpha".to_string(), - }, - RadioStationConfig { - name: "Beta".to_string(), - url: "https://example.test/beta".to_string(), - }, - ]), - ..Default::default() - } - ); - } - - #[test] - fn save_heals_a_malformed_state_file_and_keeps_a_backup() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("state.yml"); - let garbage = "this: is: not: valid: yaml: [\n"; - std::fs::write(&path, garbage).unwrap(); - - // A malformed file makes `load` fail, but `save` must still succeed. - assert!(load(&path).is_err()); - save(&path, &PersistedRuntimeState::volume_percent(42)).unwrap(); - - // The corrupt bytes are preserved next to the healed file, untouched. - let backup = path.with_extension("yml.bak"); - assert_eq!(std::fs::read_to_string(&backup).unwrap(), garbage); - - // The rewritten file parses and reflects the patch, starting from defaults. - assert_eq!( - load(&path).unwrap(), - PersistedRuntimeState { - volume_percent: Some(42), - ..Default::default() - } - ); - } - - #[test] - fn save_heals_a_non_utf8_state_file() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("state.yml"); - // A write truncated mid-multibyte-character leaves invalid UTF-8 on disk. - let garbage = b"volume_percent: 5\xff\xfe"; - std::fs::write(&path, garbage).unwrap(); - - assert!(load(&path).is_err()); - save(&path, &PersistedRuntimeState::volume_percent(42)).unwrap(); - - let backup = path.with_extension("yml.bak"); - assert_eq!(std::fs::read(&backup).unwrap(), garbage); - assert_eq!( - load(&path).unwrap(), - PersistedRuntimeState { - volume_percent: Some(42), - ..Default::default() - } - ); - } - - #[test] - fn save_leaves_no_temporary_files_behind() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("state.yml"); - - save(&path, &PersistedRuntimeState::volume_percent(30)).unwrap(); - - let has_temp = std::fs::read_dir(dir.path()) - .unwrap() - .filter_map(Result::ok) - .any(|entry| entry.file_name().to_string_lossy().contains(".tmp")); - assert!(!has_temp, "atomic save left a temporary file behind"); - } - - #[test] - fn radio_station_removal_preserves_other_runtime_state() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("state.yml"); - - save( - &path, - &PersistedRuntimeState { - volume_percent: Some(55), - radio_stations: Some(vec![ - RadioStationConfig { - name: "Alpha".to_string(), - url: "https://example.test/alpha".to_string(), - }, - RadioStationConfig { - name: "Beta".to_string(), - url: "https://example.test/beta".to_string(), - }, - ]), - ..Default::default() - }, - ) - .unwrap(); - - save_removing_radio_station(&path, " https://example.test/alpha ").unwrap(); - - assert_eq!( - load(&path).unwrap(), - PersistedRuntimeState { - volume_percent: Some(55), - radio_stations: Some(vec![RadioStationConfig { - name: "Beta".to_string(), - url: "https://example.test/beta".to_string(), - }]), - ..Default::default() - } - ); - } - - #[test] - fn persisted_runtime_state_builds_sparse_patches() { - let mut patch = PersistedRuntimeState::volume_percent(120); - patch.merge_patch(&PersistedRuntimeState::active_source(Source::Subsonic)); - patch.merge_patch(&PersistedRuntimeState::playbar_height_rows( - MAX_PLAYBAR_ROWS + 10, - )); - - assert_eq!( - patch, - PersistedRuntimeState { - volume_percent: Some(100), - active_source: Some(Source::Subsonic), - playbar_height_rows: Some(MAX_PLAYBAR_ROWS), - ..Default::default() - } - ); - } - - #[test] - fn runtime_state_applies_and_sanitizes_persisted_values() { - let state = PersistedRuntimeState { - volume_percent: Some(150), - active_source: Some(Source::Subsonic), - seen_announcement_ids: Some(vec![" seen ".to_string(), " ".to_string()]), - sidebar_width_percent: Some(120), - playbar_height_rows: Some(MAX_PLAYBAR_ROWS + 10), - library_height_percent: Some(101), - radio_stations: Some(vec![ - RadioStationConfig { - name: " Good ".to_string(), - url: " https://example.test ".to_string(), - }, - RadioStationConfig { - name: "Duplicate".to_string(), - url: "https://example.test".to_string(), - }, - RadioStationConfig { - name: "Broken".to_string(), - url: " ".to_string(), - }, - ]), - ..Default::default() - }; - - let mut runtime = RuntimeState::default(); - runtime.apply_persisted(&state); - - assert_eq!(runtime.volume_percent, 100); - assert_eq!(runtime.active_source, Source::Subsonic); - assert_eq!(runtime.seen_announcement_ids, vec!["seen"]); - assert_eq!(runtime.sidebar_width_percent, 100); - assert_eq!(runtime.playbar_height_rows, MAX_PLAYBAR_ROWS); - assert_eq!(runtime.library_height_percent, 100); - assert_eq!( - runtime.radio_stations, - vec![RadioStationConfig { - name: "Good".to_string(), - url: "https://example.test".to_string(), - }] - ); - } - - #[test] - fn radio_station_helpers_trim_dedupe_and_remove() { - let mut runtime = RuntimeState::default(); - - assert_eq!( - runtime - .add_radio_station(" Groove ", " https://example.test/stream ") - .unwrap(), - RadioStationAddOutcome::Added - ); - assert_eq!(runtime.radio_stations[0].name, "Groove"); - assert_eq!(runtime.radio_stations[0].url, "https://example.test/stream"); - assert_eq!( - runtime - .add_radio_station("Duplicate", "https://example.test/stream") - .unwrap(), - RadioStationAddOutcome::AlreadyExists - ); - - let removed = runtime - .remove_radio_station_by_url(" https://example.test/stream ") - .unwrap(); - assert_eq!( - removed.map(|station| station.name), - Some("Groove".to_string()) - ); - assert!(runtime.radio_stations.is_empty()); - } -} diff --git a/src/core/test_helpers.rs b/src/core/test_helpers.rs deleted file mode 100644 index 8c7d4719..00000000 --- a/src/core/test_helpers.rs +++ /dev/null @@ -1,238 +0,0 @@ -#![cfg(test)] - -use crate::core::app::UserInfo; -use crate::core::plugin_api::PlaylistInfo; -#[cfg(feature = "streaming")] -use crate::core::plugin_api::TrackInfo; -use chrono::Duration; -use rspotify::model::{ - idtypes::{PlaylistId, UserId}, - playlist::PlaylistTracksRef, - track::FullTrack, - user::{PrivateUser, PublicUser}, - SimplifiedAlbum, SimplifiedArtist, SimplifiedPlaylist, TrackId, -}; -use std::collections::HashMap; - -/// A queue-slot [`TrackInfo`] with only its `uri` and `name` set. -#[cfg(feature = "streaming")] -pub fn queued_track(uri: &str, name: &str) -> TrackInfo { - TrackInfo { - uri: Some(uri.to_string()), - name: name.to_string(), - artists: vec!["Artist".to_string()], - album: "Album".to_string(), - duration_ms: 180_000, - id: None, - album_id: None, - artist_refs: Vec::new(), - is_playable: true, - is_local: false, - track_number: 1, - explicit: false, - image_url: None, - } -} - -/// Domain [`UserInfo`] for tests. `display_name` mirrors `private_user`. -pub fn user_info(id: &str) -> UserInfo { - UserInfo { - id: id.to_string(), - display_name: Some("Test User".to_string()), - country: None, - } -} - -/// Domain [`PlaylistInfo`] mirroring [`simplified_playlist`] (owner display name -/// equals the owner id, matching `PlaylistInfo::from_simplified` on that fixture). -pub fn playlist_info(id: &str, name: &str, owner_id: &str, collaborative: bool) -> PlaylistInfo { - PlaylistInfo { - uri: format!("spotify:playlist:{id}"), - name: name.to_string(), - owner: owner_id.to_string(), - track_count: 5, - id: Some(id.to_string()), - owner_id: Some(owner_id.to_string()), - collaborative, - public: Some(false), - image_url: None, - } -} - -// Superseded by `user_info` after the playlist slice migrated `App.user` to the -// domain `UserInfo`; kept until the remaining slices that may still reference it land. -#[allow(deprecated, dead_code)] -pub fn private_user(id: &str) -> PrivateUser { - PrivateUser { - country: None, - display_name: Some("Test User".to_string()), - email: None, - explicit_content: None, - external_urls: HashMap::new(), - followers: None, - href: "https://api.spotify.com/v1/me".to_string(), - id: UserId::from_id(id).unwrap().into_static(), - images: None, - product: None, - } -} - -#[allow(deprecated)] -pub fn public_user(id: &str, display_name: &str) -> PublicUser { - PublicUser { - display_name: Some(display_name.to_string()), - external_urls: HashMap::new(), - followers: None, - href: format!("https://api.spotify.com/v1/users/{id}"), - id: UserId::from_id(id).unwrap().into_static(), - images: Vec::new(), - } -} - -#[allow(deprecated)] -pub fn simplified_playlist( - id: &str, - name: &str, - owner_id: &str, - collaborative: bool, -) -> SimplifiedPlaylist { - let tracks = PlaylistTracksRef { - href: format!("https://api.spotify.com/v1/playlists/{id}/tracks"), - total: 5, - }; - SimplifiedPlaylist { - collaborative, - external_urls: HashMap::new(), - href: format!("https://api.spotify.com/v1/playlists/{id}"), - id: PlaylistId::from_id(id).unwrap().into_static(), - images: Vec::new(), - name: name.to_string(), - owner: public_user(owner_id, owner_id), - public: Some(false), - snapshot_id: "snapshot".to_string(), - tracks: tracks.clone(), - items: tracks, - } -} - -#[allow(deprecated)] -pub fn full_track(id: &str, name: &str) -> FullTrack { - FullTrack { - album: SimplifiedAlbum { - name: "Test Album".to_string(), - ..Default::default() - }, - artists: vec![SimplifiedArtist { - name: "Test Artist".to_string(), - ..Default::default() - }], - available_markets: Vec::new(), - disc_number: 1, - duration: Duration::milliseconds(180_000), - explicit: false, - external_ids: HashMap::new(), - external_urls: HashMap::new(), - href: None, - id: Some(TrackId::from_id(id).unwrap().into_static()), - is_local: false, - is_playable: Some(true), - linked_from: None, - restrictions: None, - name: name.to_string(), - popularity: 50, - preview_url: None, - track_number: 1, - r#type: rspotify::model::Type::Track, - } -} - -/// Scripted [`Onboarding`] double: hands out canned prompt answers in order -/// (each with the trailing newline `read_line` would produce) and records -/// everything shown. -pub struct ScriptedOnboarding { - answers: std::sync::Mutex<std::collections::VecDeque<String>>, - pub shown: std::sync::Mutex<Vec<String>>, - pub asked: std::sync::Mutex<Vec<crate::core::onboarding::OnboardingPrompt>>, - interactive: bool, -} - -impl ScriptedOnboarding { - pub fn with_answers(answers: &[&str]) -> Self { - Self { - answers: std::sync::Mutex::new(answers.iter().map(|answer| format!("{answer}\n")).collect()), - shown: std::sync::Mutex::new(Vec::new()), - asked: std::sync::Mutex::new(Vec::new()), - interactive: true, - } - } - - /// A double for the non-interactive path (`is_interactive` reports false), - /// matching what a windowed or piped frontend would see. - pub fn non_interactive(answers: &[&str]) -> Self { - let mut onboarding = Self::with_answers(answers); - onboarding.interactive = false; - onboarding - } - - pub fn saw(&self, text: &str) -> bool { - self.shown.lock().unwrap().iter().any(|shown| shown == text) - } -} - -impl crate::core::onboarding::Onboarding for ScriptedOnboarding { - fn info(&self, text: &str) { - self.shown.lock().unwrap().push(text.to_string()); - } - - fn progress(&self, text: &str) { - self.shown.lock().unwrap().push(text.to_string()); - } - - fn prompt_line(&self, prompt: &str) -> anyhow::Result<String> { - self.shown.lock().unwrap().push(prompt.to_string()); - self - .answers - .lock() - .unwrap() - .pop_front() - .ok_or_else(|| anyhow::anyhow!("script ran out of answers for prompt {prompt:?}")) - } - - fn pick_sources( - &self, - _options: &[crate::core::source::Source], - ) -> anyhow::Result<Option<Vec<crate::core::source::Source>>> { - Ok(None) - } - - fn is_interactive(&self) -> bool { - self.interactive - } - - fn ask( - &self, - prompt: &crate::core::onboarding::OnboardingPrompt, - ) -> anyhow::Result<crate::core::onboarding::OnboardingAnswer> { - use crate::core::onboarding::{confirm_answer, OnboardingPrompt}; - self.asked.lock().unwrap().push(prompt.clone()); - // Record what the terminal impl would render, so `saw` can pin the text. - let OnboardingPrompt::Confirm { - title, - body, - question, - } = prompt; - { - let mut shown = self.shown.lock().unwrap(); - shown.push(title.clone()); - shown.push(body.clone()); - shown.push(question.clone()); - } - let answer = self - .answers - .lock() - .unwrap() - .pop_front() - .ok_or_else(|| anyhow::anyhow!("script ran out of answers for prompt {prompt:?}"))?; - Ok(confirm_answer(&answer)) - } -} diff --git a/src/core/theme.rs b/src/core/theme.rs deleted file mode 100644 index a34ddf1c..00000000 --- a/src/core/theme.rs +++ /dev/null @@ -1,833 +0,0 @@ -//! Frontend-neutral color and theme types. -//! -//! `Color` is structurally identical to `ratatui::style::Color` so the theme -//! system carries no terminal-toolkit dependency; the TUI converts at the -//! render boundary (`tui/theme.rs`). The on-disk `config.yml` format is -//! hand-parsed by [`parse_theme_item`] / [`color_to_string`] and must stay -//! byte-identical across this move. - -use anyhow::Result; -use serde::{Deserialize, Serialize}; - -/// A color in the terminal's color model. Named variants follow the 16 ANSI -/// palette entries; `Reset` is the frontend's default foreground/background. -/// -/// The serde derives are the *wire* representation for the shared action -/// vocabulary (`core/action`) and future frontend codegen. They are unrelated -/// to the on-disk `config.yml` format, which stays hand-parsed by -/// [`parse_theme_item`] / [`color_to_string`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum Color { - Reset, - Black, - Red, - Green, - Yellow, - Blue, - Magenta, - Cyan, - Gray, - DarkGray, - LightRed, - LightGreen, - LightYellow, - LightBlue, - LightMagenta, - LightCyan, - White, - Rgb(u8, u8, u8), - Indexed(u8), -} - -#[derive(Copy, Clone, Debug, PartialEq)] -pub struct Theme { - #[allow(dead_code)] - pub analysis_bar: Color, - #[allow(dead_code)] - pub analysis_bar_text: Color, - #[allow(dead_code)] - pub active: Color, - pub banner: Color, - pub error_border: Color, - pub error_text: Color, - pub hint: Color, - pub hovered: Color, - pub inactive: Color, - pub playbar_background: Color, - pub playbar_progress: Color, - pub playbar_progress_text: Color, - pub playbar_text: Color, - pub selected: Color, - pub text: Color, - pub background: Color, - pub header: Color, - pub highlighted_lyrics: Color, -} - -impl Default for Theme { - fn default() -> Self { - // Use RGB colors for cross-terminal compatibility - // Named ANSI colors (like Color::Cyan) can be remapped by terminal themes - // causing inconsistent appearance across different terminals - Theme { - analysis_bar: Color::Rgb(0, 200, 200), // LightCyan equivalent - analysis_bar_text: Color::Reset, - active: Color::Rgb(0, 180, 180), // Cyan equivalent - banner: Color::Rgb(0, 200, 200), // LightCyan equivalent - error_border: Color::Rgb(200, 0, 0), // Red equivalent - error_text: Color::Rgb(255, 100, 100), // LightRed equivalent - hint: Color::Rgb(200, 200, 0), // Yellow equivalent - hovered: Color::Rgb(180, 0, 180), // Magenta equivalent - inactive: Color::Rgb(128, 128, 128), // Gray equivalent - playbar_background: Color::Reset, - playbar_progress: Color::Rgb(0, 200, 200), // LightCyan equivalent - playbar_progress_text: Color::Rgb(255, 255, 255), // Bright white for visibility - playbar_text: Color::Reset, - selected: Color::Rgb(0, 200, 200), // LightCyan equivalent - text: Color::Reset, - background: Color::Reset, - header: Color::Reset, - highlighted_lyrics: Color::Rgb(0, 200, 200), // LightCyan equivalent - } - } -} - -/// A [`Theme`] field addressed by name, one variant per struct field. -/// -/// This is the single source of truth coupling the runtime theme-override -/// surface (Lua `spotatui.set_theme`, `Action::SetTheme`) to the struct: -/// a new variant without a struct field breaks [`Theme::set`]'s exhaustive -/// match, and a new `Theme` field breaks the full destructuring at the top -/// of `set`, so either half added alone is a compile error where the old -/// parallel string whitelists silently dropped the write on a typo. -#[cfg_attr(not(feature = "scripting"), allow(dead_code))] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum ThemeField { - Active, - Banner, - ErrorBorder, - ErrorText, - Hint, - Hovered, - Inactive, - PlaybarBackground, - PlaybarProgress, - PlaybarProgressText, - PlaybarText, - Selected, - Text, - Background, - Header, - HighlightedLyrics, - AnalysisBar, - AnalysisBarText, -} - -#[cfg_attr(not(feature = "scripting"), allow(dead_code))] -impl ThemeField { - /// Every field, in the order the Lua API has always advertised them. - pub const ALL: [ThemeField; 18] = [ - ThemeField::Active, - ThemeField::Banner, - ThemeField::ErrorBorder, - ThemeField::ErrorText, - ThemeField::Hint, - ThemeField::Hovered, - ThemeField::Inactive, - ThemeField::PlaybarBackground, - ThemeField::PlaybarProgress, - ThemeField::PlaybarProgressText, - ThemeField::PlaybarText, - ThemeField::Selected, - ThemeField::Text, - ThemeField::Background, - ThemeField::Header, - ThemeField::HighlightedLyrics, - ThemeField::AnalysisBar, - ThemeField::AnalysisBarText, - ]; - - /// The snake_case field name as it appears in `config.yml` and the Lua API. - pub fn name(self) -> &'static str { - match self { - ThemeField::Active => "active", - ThemeField::Banner => "banner", - ThemeField::ErrorBorder => "error_border", - ThemeField::ErrorText => "error_text", - ThemeField::Hint => "hint", - ThemeField::Hovered => "hovered", - ThemeField::Inactive => "inactive", - ThemeField::PlaybarBackground => "playbar_background", - ThemeField::PlaybarProgress => "playbar_progress", - ThemeField::PlaybarProgressText => "playbar_progress_text", - ThemeField::PlaybarText => "playbar_text", - ThemeField::Selected => "selected", - ThemeField::Text => "text", - ThemeField::Background => "background", - ThemeField::Header => "header", - ThemeField::HighlightedLyrics => "highlighted_lyrics", - ThemeField::AnalysisBar => "analysis_bar", - ThemeField::AnalysisBarText => "analysis_bar_text", - } - } - - /// Look up a field by its snake_case name; `None` for unknown names. - pub fn from_name(name: &str) -> Option<ThemeField> { - ThemeField::ALL.into_iter().find(|f| f.name() == name) - } -} - -impl Theme { - /// Set one field by [`ThemeField`], the runtime-override write path. - #[cfg_attr(not(feature = "scripting"), allow(dead_code))] - pub fn set(&mut self, field: ThemeField, color: Color) { - // Full destructuring, no rest pattern: a new `Theme` field fails to - // compile here until this function (and so the variant list) is updated. - let Theme { - analysis_bar: _, - analysis_bar_text: _, - active: _, - banner: _, - error_border: _, - error_text: _, - hint: _, - hovered: _, - inactive: _, - playbar_background: _, - playbar_progress: _, - playbar_progress_text: _, - playbar_text: _, - selected: _, - text: _, - background: _, - header: _, - highlighted_lyrics: _, - } = *self; - match field { - ThemeField::Active => self.active = color, - ThemeField::Banner => self.banner = color, - ThemeField::ErrorBorder => self.error_border = color, - ThemeField::ErrorText => self.error_text = color, - ThemeField::Hint => self.hint = color, - ThemeField::Hovered => self.hovered = color, - ThemeField::Inactive => self.inactive = color, - ThemeField::PlaybarBackground => self.playbar_background = color, - ThemeField::PlaybarProgress => self.playbar_progress = color, - ThemeField::PlaybarProgressText => self.playbar_progress_text = color, - ThemeField::PlaybarText => self.playbar_text = color, - ThemeField::Selected => self.selected = color, - ThemeField::Text => self.text = color, - ThemeField::Background => self.background = color, - ThemeField::Header => self.header = color, - ThemeField::HighlightedLyrics => self.highlighted_lyrics = color, - ThemeField::AnalysisBar => self.analysis_bar = color, - ThemeField::AnalysisBarText => self.analysis_bar_text = color, - } - } -} - -/// Available theme presets -#[derive(Clone, Copy, Debug, PartialEq, Default)] -pub enum ThemePreset { - #[default] - Default, - Terminal, - PookiePink, - Spotify, - Vesper, - Dracula, - Nord, - SolarizedDark, - Monokai, - Gruvbox, - GruvboxLight, - CatppuccinMocha, - TokyoNight, - Custom, // When user has manually customized colors -} - -impl ThemePreset { - pub fn all() -> &'static [ThemePreset] { - &[ - ThemePreset::Default, - ThemePreset::Terminal, - ThemePreset::PookiePink, - ThemePreset::Spotify, - ThemePreset::Vesper, - ThemePreset::Dracula, - ThemePreset::Nord, - ThemePreset::SolarizedDark, - ThemePreset::Monokai, - ThemePreset::Gruvbox, - ThemePreset::GruvboxLight, - ThemePreset::CatppuccinMocha, - ThemePreset::TokyoNight, - ThemePreset::Custom, - ] - } - - pub fn name(&self) -> &'static str { - match self { - ThemePreset::Default => "Default (Cyan)", - ThemePreset::Terminal => "Terminal (ANSI)", - ThemePreset::PookiePink => "Pookie Pink", - ThemePreset::Spotify => "Spotify", - ThemePreset::Vesper => "Vesper", - ThemePreset::Dracula => "Dracula", - ThemePreset::Nord => "Nord", - ThemePreset::SolarizedDark => "Solarized Dark", - ThemePreset::Monokai => "Monokai", - ThemePreset::Gruvbox => "Gruvbox", - ThemePreset::GruvboxLight => "Gruvbox Light", - ThemePreset::CatppuccinMocha => "Catppuccin Mocha", - ThemePreset::TokyoNight => "Tokyo Night", - ThemePreset::Custom => "Custom", - } - } - - pub fn from_name(name: &str) -> Self { - match name { - "Default (Cyan)" => ThemePreset::Default, - "Terminal (ANSI)" => ThemePreset::Terminal, - "Pookie Pink" => ThemePreset::PookiePink, - "Spotify" => ThemePreset::Spotify, - "Vesper" => ThemePreset::Vesper, - "Dracula" => ThemePreset::Dracula, - "Nord" => ThemePreset::Nord, - "Solarized Dark" => ThemePreset::SolarizedDark, - "Monokai" => ThemePreset::Monokai, - "Gruvbox" => ThemePreset::Gruvbox, - "Gruvbox Light" => ThemePreset::GruvboxLight, - "Catppuccin Mocha" => ThemePreset::CatppuccinMocha, - "Tokyo Night" => ThemePreset::TokyoNight, - _ => ThemePreset::Custom, - } - } - - pub fn next(&self) -> Self { - let presets = Self::all(); - let current_idx = presets.iter().position(|p| p == self).unwrap_or(0); - let next_idx = (current_idx + 1) % presets.len(); - presets[next_idx] - } - - pub fn prev(&self) -> Self { - let presets = Self::all(); - let current_idx = presets.iter().position(|p| p == self).unwrap_or(0); - let prev_idx = if current_idx == 0 { - presets.len() - 1 - } else { - current_idx - 1 - }; - presets[prev_idx] - } - - /// Default banner-gradient state for this preset. The Terminal preset - /// exists to follow the terminal's ANSI palette live, which the gradient's - /// fixed RGB colors would defeat, so it defaults to a solid banner. - pub fn default_banner_gradient(&self) -> bool { - !matches!(self, ThemePreset::Terminal) - } - - /// Get the theme colors for this preset - pub fn to_theme(self) -> Theme { - match self { - ThemePreset::Default => Theme::default(), - // Deliberately uses named ANSI colors (unlike the RGB rationale in - // Theme::default) so terminal-palette tools like pywal restyle the UI - // live, without restarting spotatui. - ThemePreset::Terminal => Theme { - analysis_bar: Color::Cyan, - analysis_bar_text: Color::Reset, - active: Color::Cyan, - banner: Color::Cyan, - error_border: Color::Red, - error_text: Color::LightRed, - hint: Color::Yellow, - hovered: Color::Magenta, - inactive: Color::DarkGray, - playbar_background: Color::Reset, - playbar_progress: Color::Cyan, - playbar_progress_text: Color::Reset, - playbar_text: Color::Reset, - selected: Color::Cyan, - text: Color::Reset, - background: Color::Reset, - header: Color::Reset, - highlighted_lyrics: Color::Cyan, - }, - ThemePreset::PookiePink => Theme { - analysis_bar: Color::Rgb(255, 255, 255), // White - analysis_bar_text: Color::Rgb(165, 30, 100), // Dark pink - active: Color::Rgb(150, 25, 92), // Deep pink - banner: Color::Rgb(255, 145, 205), // Light-medium pink - error_border: Color::Rgb(175, 0, 75), // Deep rose - error_text: Color::Rgb(255, 215, 235), // Light pink-white - hint: Color::Rgb(255, 235, 245), // Soft white-pink - hovered: Color::Rgb(220, 85, 155), // Mid pink for hover - inactive: Color::Rgb(255, 195, 225), // Muted pink - playbar_background: Color::Rgb(245, 115, 180), // Pink - playbar_progress: Color::Rgb(255, 255, 255), // White - playbar_progress_text: Color::Rgb(175, 35, 105), // Dark pink - playbar_text: Color::Rgb(255, 255, 255), // White - selected: Color::Rgb(125, 20, 80), // Deeper pink for selected row - text: Color::Rgb(255, 255, 255), // White - background: Color::Rgb(245, 115, 180), // Pink background - header: Color::Rgb(255, 255, 255), // White - highlighted_lyrics: Color::Rgb(255, 230, 245), // Light pink-white - }, - ThemePreset::Vesper => Theme { - analysis_bar: Color::Rgb(153, 255, 228), // Mint (#99FFE4) - analysis_bar_text: Color::Rgb(16, 16, 16), // Near-black (#101010) - active: Color::Rgb(255, 199, 153), // Accent orange (#FFC799) - banner: Color::Rgb(255, 199, 153), // Accent orange - error_border: Color::Rgb(255, 128, 128), // Error red (#FF8080) - error_text: Color::Rgb(255, 128, 128), // Error red - hint: Color::Rgb(255, 199, 153), // Accent orange - hovered: Color::Rgb(255, 207, 168), // Hover orange (#FFCFA8) - inactive: Color::Rgb(190, 190, 190), // Higher-contrast muted gray - playbar_background: Color::Rgb(22, 22, 22), // Elevated bg (#161616) - playbar_progress: Color::Rgb(153, 255, 228), // Mint - playbar_progress_text: Color::Rgb(255, 255, 255), // White for readability - playbar_text: Color::Rgb(210, 210, 210), // Higher-contrast playbar text - selected: Color::Rgb(255, 199, 153), // Accent orange - text: Color::Rgb(255, 255, 255), // White - background: Color::Rgb(16, 16, 16), // Base bg (#101010) - header: Color::Rgb(255, 255, 255), // White - highlighted_lyrics: Color::Rgb(153, 255, 228), // Mint - }, - ThemePreset::Dracula => Theme { - analysis_bar: Color::Rgb(189, 147, 249), // Purple - analysis_bar_text: Color::Rgb(248, 248, 242), // Foreground - active: Color::Rgb(80, 250, 123), // Green - banner: Color::Rgb(255, 121, 198), // Pink - error_border: Color::Rgb(255, 85, 85), // Red - error_text: Color::Rgb(255, 85, 85), - hint: Color::Rgb(241, 250, 140), // Yellow - hovered: Color::Rgb(189, 147, 249), // Purple - inactive: Color::Rgb(98, 114, 164), // Comment - playbar_background: Color::Reset, - playbar_progress: Color::Rgb(80, 250, 123), // Green - playbar_progress_text: Color::Rgb(248, 248, 242), - playbar_text: Color::Rgb(248, 248, 242), - selected: Color::Rgb(139, 233, 253), // Cyan - text: Color::Rgb(248, 248, 242), - background: Color::Reset, - header: Color::Rgb(255, 121, 198), // Pink - highlighted_lyrics: Color::Rgb(255, 121, 198), // Pink - }, - ThemePreset::Nord => Theme { - analysis_bar: Color::Rgb(136, 192, 208), // Nord8 (frost) - analysis_bar_text: Color::Rgb(236, 239, 244), // Nord6 - active: Color::Rgb(163, 190, 140), // Nord14 (green) - banner: Color::Rgb(136, 192, 208), // Nord8 - error_border: Color::Rgb(191, 97, 106), // Nord11 (red) - error_text: Color::Rgb(191, 97, 106), - hint: Color::Rgb(235, 203, 139), // Nord13 (yellow) - hovered: Color::Rgb(180, 142, 173), // Nord15 (purple) - inactive: Color::Rgb(76, 86, 106), // Nord3 - playbar_background: Color::Reset, - playbar_progress: Color::Rgb(136, 192, 208), // Nord8 - playbar_progress_text: Color::Rgb(236, 239, 244), - playbar_text: Color::Rgb(236, 239, 244), - selected: Color::Rgb(129, 161, 193), // Nord9 - text: Color::Rgb(236, 239, 244), // Nord6 - background: Color::Reset, - header: Color::Rgb(136, 192, 208), - highlighted_lyrics: Color::Rgb(136, 192, 208), // Nord8 (frost) - }, - ThemePreset::SolarizedDark => Theme { - analysis_bar: Color::Rgb(38, 139, 210), // Blue - analysis_bar_text: Color::Rgb(253, 246, 227), // Base3 - active: Color::Rgb(133, 153, 0), // Green - banner: Color::Rgb(38, 139, 210), // Blue - error_border: Color::Rgb(220, 50, 47), // Red - error_text: Color::Rgb(220, 50, 47), - hint: Color::Rgb(181, 137, 0), // Yellow - hovered: Color::Rgb(211, 54, 130), // Magenta - inactive: Color::Rgb(88, 110, 117), // Base01 - playbar_background: Color::Reset, - playbar_progress: Color::Rgb(42, 161, 152), // Cyan - playbar_progress_text: Color::Rgb(253, 246, 227), - playbar_text: Color::Rgb(147, 161, 161), // Base1 - selected: Color::Rgb(42, 161, 152), // Cyan - text: Color::Rgb(147, 161, 161), // Base1 - background: Color::Reset, - header: Color::Rgb(38, 139, 210), - highlighted_lyrics: Color::Rgb(38, 139, 210), // Blue - }, - ThemePreset::Monokai => Theme { - analysis_bar: Color::Rgb(102, 217, 239), // Cyan - analysis_bar_text: Color::Rgb(248, 248, 242), // Foreground - active: Color::Rgb(166, 226, 46), // Green - banner: Color::Rgb(249, 38, 114), // Pink - error_border: Color::Rgb(249, 38, 114), // Pink (error) - error_text: Color::Rgb(249, 38, 114), - hint: Color::Rgb(230, 219, 116), // Yellow - hovered: Color::Rgb(174, 129, 255), // Purple - inactive: Color::Rgb(117, 113, 94), // Comment - playbar_background: Color::Reset, - playbar_progress: Color::Rgb(166, 226, 46), // Green - playbar_progress_text: Color::Rgb(248, 248, 242), - playbar_text: Color::Rgb(248, 248, 242), - selected: Color::Rgb(102, 217, 239), // Cyan - text: Color::Rgb(248, 248, 242), - background: Color::Reset, - header: Color::Rgb(249, 38, 114), - highlighted_lyrics: Color::Rgb(249, 38, 114), // Pink - }, - ThemePreset::Gruvbox => Theme { - analysis_bar: Color::Rgb(131, 165, 152), // Aqua - analysis_bar_text: Color::Rgb(235, 219, 178), // fg - active: Color::Rgb(184, 187, 38), // Green - banner: Color::Rgb(254, 128, 25), // Orange - error_border: Color::Rgb(251, 73, 52), // Red - error_text: Color::Rgb(251, 73, 52), - hint: Color::Rgb(250, 189, 47), // Yellow - hovered: Color::Rgb(211, 134, 155), // Purple - inactive: Color::Rgb(146, 131, 116), // Gray - playbar_background: Color::Reset, - playbar_progress: Color::Rgb(184, 187, 38), // Green - playbar_progress_text: Color::Rgb(235, 219, 178), - playbar_text: Color::Rgb(235, 219, 178), - selected: Color::Rgb(131, 165, 152), // Aqua - text: Color::Rgb(235, 219, 178), // fg - background: Color::Reset, - header: Color::Rgb(254, 128, 25), // Orange - highlighted_lyrics: Color::Rgb(254, 128, 25), // Orange - }, - ThemePreset::GruvboxLight => Theme { - analysis_bar: Color::Rgb(66, 123, 88), // Aqua - analysis_bar_text: Color::Rgb(60, 56, 54), // fg - active: Color::Rgb(121, 116, 14), // Green - banner: Color::Rgb(175, 58, 3), // Orange - error_border: Color::Rgb(157, 0, 6), // Red - error_text: Color::Rgb(157, 0, 6), - hint: Color::Rgb(181, 118, 20), // Yellow - hovered: Color::Rgb(143, 63, 113), // Purple - inactive: Color::Rgb(146, 131, 116), // Gray - playbar_background: Color::Rgb(251, 241, 199), // bg - playbar_progress: Color::Rgb(121, 116, 14), // Green - playbar_progress_text: Color::Rgb(60, 56, 54), - playbar_text: Color::Rgb(60, 56, 54), - selected: Color::Rgb(66, 123, 88), // Aqua - text: Color::Rgb(60, 56, 54), // fg - background: Color::Rgb(251, 241, 199), - header: Color::Rgb(175, 58, 3), // Orange - highlighted_lyrics: Color::Rgb(175, 58, 3), // Orange - }, - ThemePreset::CatppuccinMocha => Theme { - analysis_bar: Color::Rgb(166, 227, 161), // Green - analysis_bar_text: Color::Rgb(205, 214, 244), // Text - active: Color::Rgb(180, 190, 254), // Lavender - banner: Color::Rgb(180, 190, 254), // Lavender - error_border: Color::Rgb(243, 139, 168), // Red - error_text: Color::Rgb(243, 139, 168), // Red - hint: Color::Rgb(250, 179, 135), // Peach - hovered: Color::Rgb(137, 180, 250), // Blue - inactive: Color::Rgb(108, 112, 134), // Overlay 0 - playbar_background: Color::Reset, - playbar_progress: Color::Rgb(180, 190, 254), // Lavender - playbar_progress_text: Color::Rgb(88, 91, 112), // Surface 2 - playbar_text: Color::Rgb(186, 194, 222), // Subtext 1 - selected: Color::Rgb(180, 190, 254), // Lavender - text: Color::Rgb(205, 214, 244), // Text - background: Color::Reset, - header: Color::Rgb(180, 190, 254), // Lavender - highlighted_lyrics: Color::Rgb(180, 190, 254), // Lavender - }, - ThemePreset::Spotify => Theme { - analysis_bar: Color::Rgb(29, 185, 84), // Spotify Green #1DB954 - analysis_bar_text: Color::Rgb(255, 255, 255), // White - active: Color::Rgb(29, 185, 84), // Spotify Green - banner: Color::Rgb(29, 185, 84), // Spotify Green - error_border: Color::Rgb(230, 76, 76), // Soft red - error_text: Color::Rgb(230, 76, 76), - hint: Color::Rgb(179, 179, 179), // Gray hint - hovered: Color::Rgb(29, 185, 84), // Spotify Green - inactive: Color::Rgb(83, 83, 83), // Dark gray - playbar_background: Color::Reset, - playbar_progress: Color::Rgb(29, 185, 84), // Spotify Green - playbar_progress_text: Color::Rgb(255, 255, 255), - playbar_text: Color::Rgb(179, 179, 179), // Light gray - selected: Color::Rgb(29, 185, 84), // Spotify Green - text: Color::Rgb(255, 255, 255), // White - background: Color::Reset, - header: Color::Rgb(29, 185, 84), // Spotify Green - highlighted_lyrics: Color::Rgb(29, 185, 84), // Spotify Green - }, - ThemePreset::TokyoNight => Theme { - analysis_bar: Color::Rgb(122, 162, 247), //Darker blue #7aa2f7 - analysis_bar_text: Color::Rgb(42, 195, 222), // Light blue #2ac3de - active: Color::Rgb(115, 218, 202), // Teal #73daca - banner: Color::Rgb(42, 195, 222), // Light blue #2ac3de - error_border: Color::Rgb(247, 118, 142), // Red #f7768e - error_text: Color::Rgb(247, 118, 142), // Red #f7768e - hint: Color::Rgb(255, 158, 100), // Orange #ff9e64 - hovered: Color::Rgb(157, 124, 216), // Purple #9d7cd8 - inactive: Color::Rgb(192, 202, 245), // Light blue #c0caf5 - playbar_background: Color::Rgb(36, 40, 59), // Gray #24283b - playbar_progress: Color::Rgb(122, 162, 247), //Darker blue #7aa2f7 - playbar_progress_text: Color::Rgb(169, 177, 214), // Light purple #a9b1d6 - playbar_text: Color::Rgb(122, 162, 247), //Darker blue #7aa2f7 - selected: Color::Rgb(125, 207, 255), // Light blue #7dcfff - text: Color::Rgb(169, 177, 214), // Light purple #a9b1d6 - background: Color::Rgb(36, 40, 59), // Gray #24283b - header: Color::Rgb(122, 162, 247), // Blue #7aa2f7, - highlighted_lyrics: Color::Rgb(42, 195, 222), // Light blue #2ac3de - }, - ThemePreset::Custom => Theme::default(), // Won't be used directly - } - } -} - -pub fn parse_theme_item(theme_item: &str) -> Result<Color> { - let color = match theme_item { - "Reset" => Color::Reset, - "Black" => Color::Black, - "Red" => Color::Red, - "Green" => Color::Green, - "Yellow" => Color::Yellow, - "Blue" => Color::Blue, - "Magenta" => Color::Magenta, - "Cyan" => Color::Cyan, - "Gray" => Color::Gray, - "DarkGray" => Color::DarkGray, - "LightRed" => Color::LightRed, - "LightGreen" => Color::LightGreen, - "LightYellow" => Color::LightYellow, - "LightBlue" => Color::LightBlue, - "LightMagenta" => Color::LightMagenta, - "LightCyan" => Color::LightCyan, - "White" => Color::White, - _ => { - let colors = theme_item.split(',').collect::<Vec<&str>>(); - if let (Some(r), Some(g), Some(b)) = (colors.first(), colors.get(1), colors.get(2)) { - Color::Rgb( - r.trim().parse::<u8>()?, - g.trim().parse::<u8>()?, - b.trim().parse::<u8>()?, - ) - } else { - log::warn!("Unexpected color {}", theme_item); - Color::Black - } - } - }; - - Ok(color) -} - -pub fn color_to_string(color: Color) -> String { - match color { - Color::Reset => "Reset".to_string(), - Color::Black => "Black".to_string(), - Color::Red => "Red".to_string(), - Color::Green => "Green".to_string(), - Color::Yellow => "Yellow".to_string(), - Color::Blue => "Blue".to_string(), - Color::Magenta => "Magenta".to_string(), - Color::Cyan => "Cyan".to_string(), - Color::Gray => "Gray".to_string(), - Color::DarkGray => "DarkGray".to_string(), - Color::LightRed => "LightRed".to_string(), - Color::LightGreen => "LightGreen".to_string(), - Color::LightYellow => "LightYellow".to_string(), - Color::LightBlue => "LightBlue".to_string(), - Color::LightMagenta => "LightMagenta".to_string(), - Color::LightCyan => "LightCyan".to_string(), - Color::White => "White".to_string(), - Color::Rgb(r, g, b) => format!("{}, {}, {}", r, g, b), - _ => "Reset".to_string(), - } -} - -/// Concrete RGB values for the colors a terminal resolves itself. A frontend -/// that has no terminal palette (a GUI) supplies one of these to [`resolve`]. -/// -/// `Reset` means "the frontend's default"; a caller that needs distinct -/// foreground and background defaults uses two palettes differing only in -/// `reset`. -// No production caller until a second frontend needs concrete RGB; exercised -// by tests today. -#[allow(dead_code)] -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Palette { - /// RGB for [`Color::Reset`]. - pub reset: [u8; 3], - /// RGB for the 16 named ANSI entries, indexed by ANSI number - /// (0 = Black … 7 = Gray, 8 = DarkGray … 15 = White). - pub ansi: [[u8; 3]; 16], -} - -impl Default for Palette { - /// The xterm default palette, with white as the `Reset` fallback. - fn default() -> Self { - Palette { - reset: [255, 255, 255], - ansi: [ - [0, 0, 0], // Black - [205, 0, 0], // Red - [0, 205, 0], // Green - [205, 205, 0], // Yellow - [0, 0, 238], // Blue - [205, 0, 205], // Magenta - [0, 205, 205], // Cyan - [229, 229, 229], // Gray - [127, 127, 127], // DarkGray - [255, 0, 0], // LightRed - [0, 255, 0], // LightGreen - [255, 255, 0], // LightYellow - [92, 92, 255], // LightBlue - [255, 0, 255], // LightMagenta - [0, 255, 255], // LightCyan - [255, 255, 255], // White - ], - } - } -} - -/// Resolve any [`Color`] to concrete RGB. `Rgb` passes through; named ANSI -/// colors and `Reset` come from `palette`; `Indexed` follows the xterm-256 -/// layout (16 palette entries, 6×6×6 color cube, 24-step gray ramp). -// No production caller until a second frontend needs concrete RGB; exercised -// by tests today. -#[allow(dead_code)] -pub fn resolve(color: Color, palette: &Palette) -> [u8; 3] { - match color { - Color::Reset => palette.reset, - Color::Black => palette.ansi[0], - Color::Red => palette.ansi[1], - Color::Green => palette.ansi[2], - Color::Yellow => palette.ansi[3], - Color::Blue => palette.ansi[4], - Color::Magenta => palette.ansi[5], - Color::Cyan => palette.ansi[6], - Color::Gray => palette.ansi[7], - Color::DarkGray => palette.ansi[8], - Color::LightRed => palette.ansi[9], - Color::LightGreen => palette.ansi[10], - Color::LightYellow => palette.ansi[11], - Color::LightBlue => palette.ansi[12], - Color::LightMagenta => palette.ansi[13], - Color::LightCyan => palette.ansi[14], - Color::White => palette.ansi[15], - Color::Rgb(r, g, b) => [r, g, b], - Color::Indexed(i) => match i { - 0..=15 => palette.ansi[i as usize], - 16..=231 => { - const STEPS: [u8; 6] = [0, 95, 135, 175, 215, 255]; - let i = i - 16; - [ - STEPS[(i / 36) as usize], - STEPS[((i / 6) % 6) as usize], - STEPS[(i % 6) as usize], - ] - } - 232..=255 => { - let v = 8 + 10 * (i - 232); - [v, v, v] - } - }, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_theme_item_test() { - assert_eq!(parse_theme_item("Reset").unwrap(), Color::Reset); - assert_eq!(parse_theme_item("Black").unwrap(), Color::Black); - assert_eq!(parse_theme_item("Red").unwrap(), Color::Red); - assert_eq!(parse_theme_item("Green").unwrap(), Color::Green); - assert_eq!(parse_theme_item("Yellow").unwrap(), Color::Yellow); - assert_eq!(parse_theme_item("Blue").unwrap(), Color::Blue); - assert_eq!(parse_theme_item("Magenta").unwrap(), Color::Magenta); - assert_eq!(parse_theme_item("Cyan").unwrap(), Color::Cyan); - assert_eq!(parse_theme_item("Gray").unwrap(), Color::Gray); - assert_eq!(parse_theme_item("DarkGray").unwrap(), Color::DarkGray); - assert_eq!(parse_theme_item("LightRed").unwrap(), Color::LightRed); - assert_eq!(parse_theme_item("LightGreen").unwrap(), Color::LightGreen); - assert_eq!(parse_theme_item("LightYellow").unwrap(), Color::LightYellow); - assert_eq!(parse_theme_item("LightBlue").unwrap(), Color::LightBlue); - assert_eq!( - parse_theme_item("LightMagenta").unwrap(), - Color::LightMagenta - ); - assert_eq!(parse_theme_item("LightCyan").unwrap(), Color::LightCyan); - assert_eq!(parse_theme_item("White").unwrap(), Color::White); - assert_eq!( - parse_theme_item("23, 43, 45").unwrap(), - Color::Rgb(23, 43, 45) - ); - } - - #[test] - fn terminal_preset_colors_round_trip_through_config() { - let theme = ThemePreset::Terminal.to_theme(); - for color in [ - theme.analysis_bar, - theme.analysis_bar_text, - theme.active, - theme.banner, - theme.error_border, - theme.error_text, - theme.hint, - theme.hovered, - theme.inactive, - theme.playbar_background, - theme.playbar_progress, - theme.playbar_progress_text, - theme.playbar_text, - theme.selected, - theme.text, - theme.background, - theme.header, - theme.highlighted_lyrics, - ] { - assert_eq!(parse_theme_item(&color_to_string(color)).unwrap(), color); - } - } - - #[test] - fn resolve_maps_every_color_form_to_rgb() { - let palette = Palette::default(); - - assert_eq!(resolve(Color::Reset, &palette), palette.reset); - assert_eq!(resolve(Color::Rgb(1, 2, 3), &palette), [1, 2, 3]); - // The 17 named variants all land on a palette entry (Reset checked above). - for (color, ansi_index) in [ - (Color::Black, 0), - (Color::Red, 1), - (Color::Green, 2), - (Color::Yellow, 3), - (Color::Blue, 4), - (Color::Magenta, 5), - (Color::Cyan, 6), - (Color::Gray, 7), - (Color::DarkGray, 8), - (Color::LightRed, 9), - (Color::LightGreen, 10), - (Color::LightYellow, 11), - (Color::LightBlue, 12), - (Color::LightMagenta, 13), - (Color::LightCyan, 14), - (Color::White, 15), - ] { - assert_eq!(resolve(color, &palette), palette.ansi[ansi_index]); - // Indexed 0-15 aliases the same entries. - assert_eq!( - resolve(Color::Indexed(ansi_index as u8), &palette), - palette.ansi[ansi_index] - ); - } - // Cube corners and the gray ramp follow the xterm-256 formula. - assert_eq!(resolve(Color::Indexed(16), &palette), [0, 0, 0]); - assert_eq!(resolve(Color::Indexed(231), &palette), [255, 255, 255]); - assert_eq!(resolve(Color::Indexed(196), &palette), [255, 0, 0]); - assert_eq!(resolve(Color::Indexed(232), &palette), [8, 8, 8]); - assert_eq!(resolve(Color::Indexed(255), &palette), [238, 238, 238]); - } -} diff --git a/src/core/user_config.rs b/src/core/user_config.rs deleted file mode 100644 index 6ba98f88..00000000 --- a/src/core/user_config.rs +++ /dev/null @@ -1,3594 +0,0 @@ -use crate::core::format::Template; -use crate::core::input::Key; -use crate::core::state::{sanitized_radio_stations, RadioStationConfig}; -// Re-exported so existing `crate::core::user_config::{Theme, ...}` importers -// keep working now that the theme types live in `core/theme.rs`. -pub use crate::core::theme::{color_to_string, parse_theme_item, Theme, ThemePreset}; -use anyhow::{anyhow, Result}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::{fs, path::PathBuf}; - -const FILE_NAME: &str = "config.yml"; -pub const DEFAULT_TICK_RATE_MILLISECONDS: u64 = 250; -pub const DEFAULT_ANIMATION_TICK_RATE_MILLISECONDS: u64 = 16; -pub const MAX_TICK_RATE_MILLISECONDS: u64 = 999; -#[cfg(feature = "art-decode")] -pub const MIN_PLAYBAR_COVER_ART_SIZE_PERCENT: u16 = 25; -#[cfg(feature = "art-decode")] -pub const MAX_PLAYBAR_COVER_ART_SIZE_PERCENT: u16 = 200; - -#[cfg(feature = "art-decode")] -pub fn clamp_playbar_cover_art_size_percent(value: u16) -> u16 { - value.clamp( - MIN_PLAYBAR_COVER_ART_SIZE_PERCENT, - MAX_PLAYBAR_COVER_ART_SIZE_PERCENT, - ) -} - -#[cfg(feature = "cover-art")] -pub fn normalize_cover_art_dither_algorithm(value: &str) -> &'static str { - match value { - "bayer8x8" => "bayer8x8", - "atkinson" => "atkinson", - _ => "stucki", - } -} - -#[cfg(feature = "cover-art")] -pub(crate) fn normalize_cover_art_dither_pixel_scale(value: i64) -> u8 { - value.clamp(1, 3) as u8 -} - -// Only the settings arm for the playbar size (a render-side setting) calls -// this, so it follows `cover-art` while its `clamp` sibling stays with the -// decode half for config loading. -#[cfg(feature = "cover-art")] -pub fn normalize_playbar_cover_art_size_percent(value: i64) -> u16 { - value.clamp( - MIN_PLAYBAR_COVER_ART_SIZE_PERCENT as i64, - MAX_PLAYBAR_COVER_ART_SIZE_PERCENT as i64, - ) as u16 -} - -pub fn validate_tick_rate_milliseconds(value: u64, label: &str) -> Result<u64> { - if (1..=MAX_TICK_RATE_MILLISECONDS).contains(&value) { - Ok(value) - } else { - Err(anyhow!("{label} must be between 1 and 999 milliseconds")) - } -} - -pub fn normalize_tick_rate_milliseconds(value: i64) -> u64 { - value.clamp(1, MAX_TICK_RATE_MILLISECONDS as i64) as u64 -} - -/// The Qobuz `format_id` values, in the order of [`QOBUZ_QUALITY_LABELS`]. -const QOBUZ_QUALITY_IDS: [u8; 4] = [5, 6, 7, 27]; -/// Settings-screen labels for the Qobuz qualities, in [`QOBUZ_QUALITY_IDS`] order. -#[cfg_attr(not(feature = "qobuz"), allow(dead_code))] -pub const QOBUZ_QUALITY_LABELS: &[&str] = &["MP3 320", "FLAC 16/44.1", "FLAC 24/96", "FLAC 24/192"]; -/// The default quality (FLAC 16/44.1) as an index into both tables. -const QOBUZ_QUALITY_DEFAULT_INDEX: usize = 1; -const QOBUZ_QUALITY_DEFAULT: u8 = QOBUZ_QUALITY_IDS[QOBUZ_QUALITY_DEFAULT_INDEX]; - -/// The settings label of a Qobuz `format_id` (unknown ids read as the default). -#[cfg_attr(not(feature = "qobuz"), allow(dead_code))] -pub fn qobuz_quality_label(quality: u8) -> &'static str { - let index = QOBUZ_QUALITY_IDS - .iter() - .position(|&id| id == quality) - .unwrap_or(QOBUZ_QUALITY_DEFAULT_INDEX); - QOBUZ_QUALITY_LABELS[index] -} - -/// The Qobuz `format_id` behind a settings label (unknown labels read as the default). -#[cfg_attr(not(feature = "qobuz"), allow(dead_code))] -pub fn qobuz_quality_from_label(label: &str) -> u8 { - let index = QOBUZ_QUALITY_LABELS - .iter() - .position(|&l| l == label) - .unwrap_or(QOBUZ_QUALITY_DEFAULT_INDEX); - QOBUZ_QUALITY_IDS[index] -} - -/// A configured Qobuz quality, or the default for an unknown id. -fn qobuz_quality_or_default(quality: u8) -> u8 { - if QOBUZ_QUALITY_IDS.contains(&quality) { - quality - } else { - QOBUZ_QUALITY_DEFAULT - } -} - -/// Parse a human-readable update delay into seconds. -/// Accepts: "0", "30s", "10m", "2h", "7d", or a bare second count. -pub fn parse_update_delay_secs(value: &str) -> Result<u64, String> { - let value = value.trim(); - if value == "0" || value.is_empty() { - return Ok(0); - } - - for (suffix, multiplier, label) in [ - ("d", 86400_u64, "days"), - ("h", 3600_u64, "hours"), - ("m", 60_u64, "minutes"), - ("s", 1_u64, "seconds"), - ] { - if let Some(amount) = value.strip_suffix(suffix) { - return amount - .trim() - .parse::<u64>() - .map(|v| v * multiplier) - .map_err(|_| format!("Invalid {label} value")); - } - } - - value - .parse::<u64>() - .map_err(|_| "Invalid numeric value or unknown suffix".to_string()) -} - -#[cfg(feature = "self-update")] -pub fn format_update_delay_secs(secs: u64) -> String { - if secs >= 86400 { - format!("{}d", secs / 86400) - } else if secs >= 3600 { - format!("{}h", secs / 3600) - } else if secs >= 60 { - format!("{}m", secs / 60) - } else { - format!("{}s", secs) - } -} - -pub(crate) fn default_app_config_dir() -> Option<PathBuf> { - crate::core::paths::app_config_dir() -} - -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -pub struct UserTheme { - pub preset: Option<String>, - #[cfg(feature = "cover-art")] - #[serde(skip_serializing_if = "Option::is_none")] - pub cover_art_dither_color: Option<String>, - pub active: Option<String>, - pub banner: Option<String>, - pub error_border: Option<String>, - pub error_text: Option<String>, - pub hint: Option<String>, - pub hovered: Option<String>, - pub inactive: Option<String>, - pub playbar_background: Option<String>, - pub playbar_progress: Option<String>, - pub playbar_progress_text: Option<String>, - pub playbar_text: Option<String>, - pub selected: Option<String>, - pub text: Option<String>, - pub background: Option<String>, - pub header: Option<String>, - pub highlighted_lyrics: Option<String>, -} - -// `Theme`, `ThemePreset`, `parse_theme_item` and `color_to_string` moved to -// `core/theme.rs` (re-exported above). - -/// Available audio visualizer styles -#[derive(Clone, Copy, Debug, PartialEq, Default, Serialize, Deserialize)] -pub enum VisualizerStyle { - /// Cava mode: cava's engine and mirrored stereo layout drawn in eighth blocks - /// - /// Note: Older configs may contain `Equalizer` (a removed style) or `Classic` - /// (its even older name); both are accepted as aliases so existing config - /// files keep loading and roll over to Cava. - #[default] - #[serde(alias = "Classic", alias = "Equalizer")] - Cava, - /// BarGraph mode: Uses tui-bar-graph with Braille patterns for high-resolution display - BarGraph, -} - -impl VisualizerStyle { - pub fn all() -> &'static [VisualizerStyle] { - &[VisualizerStyle::Cava, VisualizerStyle::BarGraph] - } - - pub fn name(&self) -> &'static str { - match self { - VisualizerStyle::Cava => "Cava", - VisualizerStyle::BarGraph => "Bar Graph", - } - } - - pub fn next(&self) -> Self { - let styles = Self::all(); - let current_idx = styles.iter().position(|s| s == self).unwrap_or(0); - let next_idx = (current_idx + 1) % styles.len(); - styles[next_idx] - } -} - -/// Controls the playback state on startup, both for Spotify and for a persisted -/// non-Spotify session (local/Subsonic/radio/YouTube) that spotatui resumes on -/// launch. -#[derive(Clone, Copy, Debug, PartialEq, Default, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum StartupBehavior { - /// Restore the last state: leave Spotify playback as-is, and resume a persisted - /// non-Spotify session to the exact play/pause state it had when spotatui last - /// closed (a track playing at exit resumes playing). This is the default. - #[default] - Continue, - /// Always start playing on launch (Spotify, or the restored session). - Play, - /// Always pause on launch (Spotify, or the restored session). - Pause, -} - -impl StartupBehavior { - pub fn name(self) -> &'static str { - match self { - StartupBehavior::Continue => "Continue", - StartupBehavior::Play => "Play", - StartupBehavior::Pause => "Pause", - } - } - - pub fn options() -> &'static [&'static str] { - &["Continue", "Play", "Pause"] - } - - pub fn from_name(name: &str) -> Self { - match name { - "Play" => StartupBehavior::Play, - "Pause" => StartupBehavior::Pause, - _ => StartupBehavior::Continue, - } - } -} - -fn parse_key(key: String) -> Result<Key> { - // "ctrl" with no dash and "ctrl-" with nothing after the dash are config - // typos; they must surface as config errors naming the binding, never a - // panic before the UI starts (#441). - fn modifier_char(key: &str, sections: &[&str]) -> Result<char> { - let mut chars = sections - .get(1) - .map(|section| section.chars()) - .ok_or_else(|| { - anyhow!( - "The shortcut \"{}\" is missing its key, e.g. \"ctrl-a\"", - key - ) - })?; - match (chars.next(), chars.next()) { - (Some(c), None) => Ok(c), - _ => Err(anyhow!( - "The shortcut \"{}\" must combine the modifier with exactly one key, e.g. \"ctrl-a\"", - key - )), - } - } - - match key.len() { - 1 => match key.chars().next() { - Some(c) => Ok(Key::Char(c)), - None => Err(anyhow!("The key binding is empty")), - }, - _ => { - let sections: Vec<&str> = key.split('-').collect(); - - if sections.len() > 2 { - return Err(anyhow!( - "Shortcut can only have 2 keys, \"{}\" has {}", - key, - sections.len() - )); - } - - match sections[0].to_lowercase().as_str() { - "ctrl" => Ok(Key::Ctrl(modifier_char(&key, §ions)?)), - "alt" => Ok(Key::Alt(modifier_char(&key, §ions)?)), - "left" => Ok(Key::Left), - "right" => Ok(Key::Right), - "up" => Ok(Key::Up), - "down" => Ok(Key::Down), - "backspace" | "delete" => Ok(Key::Backspace), - "del" => Ok(Key::Delete), - "esc" | "escape" => Ok(Key::Esc), - "pageup" => Ok(Key::PageUp), - "pagedown" => Ok(Key::PageDown), - "space" => Ok(Key::Char(' ')), - "enter" => Ok(Key::Enter), - "tab" => Ok(Key::Tab), - "home" => Ok(Key::Home), - "end" => Ok(Key::End), - "ins" | "insert" => Ok(Key::Ins), - "f0" => Ok(Key::F0), - "f1" => Ok(Key::F1), - "f2" => Ok(Key::F2), - "f3" => Ok(Key::F3), - "f4" => Ok(Key::F4), - "f5" => Ok(Key::F5), - "f6" => Ok(Key::F6), - "f7" => Ok(Key::F7), - "f8" => Ok(Key::F8), - "f9" => Ok(Key::F9), - "f10" => Ok(Key::F10), - "f11" => Ok(Key::F11), - "f12" => Ok(Key::F12), - _ => Err(anyhow!("The key \"{}\" is unknown.", sections[0])), - } - } - } -} - -/// Public version of parse_key for use in app.rs -pub fn parse_key_public(key: String) -> Result<Key> { - parse_key(key) -} - -fn check_reserved_keys(key: Key) -> Result<()> { - let reserved = [ - Key::Char('H'), - Key::Char('M'), - Key::Char('L'), - Key::Up, - Key::Down, - Key::Left, - Key::Right, - Key::Backspace, - Key::Enter, - ]; - for item in reserved.iter() { - if key == *item { - // TODO: Add pretty print for key - return Err(anyhow!( - "The key {:?} is reserved and cannot be remapped", - key - )); - } - } - Ok(()) -} - -/// Public version of check_reserved_keys for use in handlers -pub fn check_reserved_keys_public(key: Key) -> Result<()> { - check_reserved_keys(key) -} - -#[derive(Clone)] -pub struct UserConfigPaths { - pub config_file_path: PathBuf, -} - -#[derive(Default, Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct KeyBindingsString { - back: Option<String>, - move_up: Option<String>, - move_down: Option<String>, - move_left: Option<String>, - move_right: Option<String>, - next_page: Option<String>, - previous_page: Option<String>, - jump_to_start: Option<String>, - jump_to_end: Option<String>, - jump_to_album: Option<String>, - #[cfg(feature = "ai-dj")] - dj_open: Option<String>, - #[cfg(feature = "ai-dj")] - dj_toggle_auto_queue: Option<String>, - #[cfg(feature = "ai-dj")] - dj_vibe_shift: Option<String>, - #[cfg(feature = "ai-dj")] - dj_toggle_fresh_only: Option<String>, - #[cfg(feature = "ai-dj")] - dj_pick_model: Option<String>, - jump_to_artist_album: Option<String>, - jump_to_context: Option<String>, - manage_devices: Option<String>, - decrease_volume: Option<String>, - increase_volume: Option<String>, - toggle_playback: Option<String>, - seek_backwards: Option<String>, - seek_forwards: Option<String>, - next_track: Option<String>, - previous_track: Option<String>, - force_previous_track: Option<String>, - help: Option<String>, - shuffle: Option<String>, - repeat: Option<String>, - search: Option<String>, - submit: Option<String>, - copy_song_url: Option<String>, - copy_album_url: Option<String>, - audio_analysis: Option<String>, - #[serde(alias = "basic_view")] - lyrics_view: Option<String>, - miniplayer_view: Option<String>, - cover_art_view: Option<String>, - add_item_to_queue: Option<String>, - show_queue: Option<String>, - remove_from_queue: Option<String>, - open_settings: Option<String>, - save_settings: Option<String>, - listening_party: Option<String>, - like_track: Option<String>, - generate_recap: Option<String>, -} - -#[derive(Clone, PartialEq)] -pub struct KeyBindings { - pub back: Key, - pub move_up: Key, - pub move_down: Key, - pub move_left: Key, - pub move_right: Key, - pub next_page: Key, - pub previous_page: Key, - pub jump_to_start: Key, - pub jump_to_end: Key, - pub jump_to_album: Key, - /// Open the AI DJ screen. A global binding rather than only a sidebar row: the - /// Library panel is not drawn for non-Spotify sources, so the row alone would - /// make the DJ unreachable there. - #[cfg(feature = "ai-dj")] - pub dj_open: Key, - #[cfg(feature = "ai-dj")] - pub dj_toggle_auto_queue: Key, - #[cfg(feature = "ai-dj")] - pub dj_vibe_shift: Key, - /// Toggle "only tracks I do not already have" for DJ recommendations. - #[cfg(feature = "ai-dj")] - pub dj_toggle_fresh_only: Key, - /// Reopen the "which AI, which model" picker. Which brain the DJ uses is the one - /// setting that costs money (or quota) per turn, so it has to be changeable - /// without hand-editing YAML. - #[cfg(feature = "ai-dj")] - pub dj_pick_model: Key, - pub jump_to_artist_album: Key, - pub jump_to_context: Key, - pub manage_devices: Key, - pub decrease_volume: Key, - pub increase_volume: Key, - pub toggle_playback: Key, - pub seek_backwards: Key, - pub seek_forwards: Key, - pub next_track: Key, - pub previous_track: Key, - pub force_previous_track: Key, - pub help: Key, - pub shuffle: Key, - pub repeat: Key, - pub search: Key, - pub submit: Key, - pub copy_song_url: Key, - pub copy_album_url: Key, - pub audio_analysis: Key, - pub lyrics_view: Key, - pub miniplayer_view: Key, - pub cover_art_view: Key, - pub add_item_to_queue: Key, - pub show_queue: Key, - pub remove_from_queue: Key, - pub open_settings: Key, - pub save_settings: Key, - pub listening_party: Key, - pub like_track: Key, - pub generate_recap: Key, -} - -#[derive(Default, Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct BehaviorConfigString { - pub seek_milliseconds: Option<u32>, - pub volume_increment: Option<u8>, - #[serde(skip_serializing_if = "Option::is_none")] - pub volume_percent: Option<u8>, - pub tick_rate_milliseconds: Option<u64>, - pub animation_tick_rate_milliseconds: Option<u64>, - pub enable_text_emphasis: Option<bool>, - pub banner_gradient: Option<bool>, - pub show_loading_indicator: Option<bool>, - pub enforce_wide_search_bar: Option<bool>, - pub group_folders_first: Option<bool>, - pub enable_global_song_count: Option<bool>, - pub disable_mouse_inputs: Option<bool>, - pub enable_discord_rpc: Option<bool>, - pub discord_rpc_client_id: Option<String>, - pub enable_announcements: Option<bool>, - pub announcement_feed_url: Option<String>, - pub enable_monthly_recap_prompt: Option<bool>, - pub pin_community_playlist: Option<bool>, - pub liked_icon: Option<String>, - pub shuffle_icon: Option<String>, - pub repeat_track_icon: Option<String>, - pub repeat_context_icon: Option<String>, - pub playing_icon: Option<String>, - pub paused_icon: Option<String>, - pub set_window_title: Option<bool>, - pub visualizer_style: Option<VisualizerStyle>, - pub relay_server_url: Option<String>, - pub stop_after_current_track: Option<bool>, - pub startup_behavior: Option<StartupBehavior>, - pub disable_auto_update: Option<bool>, - pub auto_update_delay: Option<String>, - #[cfg(feature = "art-decode")] - pub draw_cover_art: Option<bool>, - #[cfg(feature = "art-decode")] - pub draw_cover_art_forced: Option<bool>, - #[cfg(feature = "art-decode")] - pub playbar_cover_art_size_percent: Option<u16>, - #[cfg(feature = "art-decode")] - pub cover_art_theme: Option<bool>, - #[cfg(feature = "cover-art")] - pub cover_art_dither: Option<bool>, - #[cfg(feature = "cover-art")] - pub cover_art_dither_algorithm: Option<String>, - #[cfg(feature = "cover-art")] - pub cover_art_dither_pixel_scale: Option<i64>, - #[cfg(feature = "mcp-server")] - pub mcp_enabled: Option<bool>, - #[cfg(feature = "ai-dj")] - pub dj_backend: Option<String>, - #[cfg(feature = "ai-dj")] - pub dj_agent_command: Option<Vec<String>>, - #[cfg(feature = "ai-dj")] - pub dj_agent_prompt_via: Option<String>, - #[cfg(feature = "ai-dj")] - pub dj_agent_timeout_secs: Option<u64>, - #[cfg(feature = "ai-dj")] - pub dj_agent_model: Option<String>, - #[cfg(feature = "ai-dj")] - pub dj_model: Option<String>, - #[cfg(feature = "ai-dj")] - pub dj_base_url: Option<String>, - #[cfg(feature = "ai-dj")] - pub dj_api_key: Option<String>, - #[cfg(feature = "ai-dj")] - pub dj_batch_size: Option<usize>, - #[cfg(feature = "ai-dj")] - pub dj_history_period: Option<String>, - #[cfg(feature = "ai-dj")] - pub dj_avoid_library: Option<bool>, - #[cfg(feature = "ai-dj")] - pub dj_configured: Option<bool>, - pub keepawake_enabled: Option<bool>, - pub enable_media_keys: Option<bool>, - pub sync_token: Option<String>, - pub local_music_path: Option<String>, - pub subsonic_url: Option<String>, - pub subsonic_username: Option<String>, - pub subsonic_password: Option<String>, - pub ytdlp_path: Option<String>, - pub qobuz_quality: Option<u8>, - #[serde(skip_serializing_if = "Option::is_none")] - pub radio_stations: Option<Vec<RadioStationConfig>>, - // --- Phase 2: icons / glyphs / labels (defaults = today's glyphs) --- - pub gauge_filled_icon: Option<String>, - pub gauge_unfilled_icon: Option<String>, - pub active_source_icon: Option<String>, - pub episode_played_icon: Option<String>, - pub sort_ascending_icon: Option<String>, - pub sort_descending_icon: Option<String>, - pub list_highlight_icon: Option<String>, - pub playbar_control_labels: Option<HashMap<String, String>>, - // --- Phase 3: behavior constants / startup / sort --- - pub status_message_ttl_percent: Option<u16>, - pub playback_poll_seconds: Option<u64>, - pub table_scroll_padding: Option<u16>, - pub like_animation_frames: Option<u8>, - pub startup_route: Option<String>, - pub default_sort_playlist_tracks: Option<String>, - pub default_sort_saved_albums: Option<String>, - pub default_sort_saved_artists: Option<String>, - pub default_sort_recently_played: Option<String>, - // --- Phase 6: layout arrangement --- - pub sidebar_position: Option<String>, - pub playbar_position: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub sidebar_width_percent: Option<u8>, - #[serde(skip_serializing_if = "Option::is_none")] - pub playbar_height_rows: Option<u16>, - #[serde(skip_serializing_if = "Option::is_none")] - pub library_height_percent: Option<u8>, - pub small_terminal_width: Option<u16>, - pub small_terminal_height: Option<u16>, -} - -#[derive(Clone)] -pub struct BehaviorConfig { - pub seek_milliseconds: u32, - pub volume_increment: u8, - /// User-configured initial volume default. It seeds `state.yml` only when - /// runtime state has no saved volume yet; later runtime volume changes stay - /// authoritative. - pub volume_percent: Option<u8>, - pub tick_rate_milliseconds: u64, - pub animation_tick_rate_milliseconds: u64, - pub enable_text_emphasis: bool, - /// When false, the home banner is drawn in the theme's banner color instead - /// of the animated RGB gradient, so ANSI palettes (e.g. pywal) restyle it live. - pub banner_gradient: bool, - pub show_loading_indicator: bool, - pub enforce_wide_search_bar: bool, - pub group_folders_first: bool, - pub enable_global_song_count: bool, - pub disable_mouse_inputs: bool, - pub enable_discord_rpc: bool, - pub discord_rpc_client_id: Option<String>, - pub enable_announcements: bool, - pub announcement_feed_url: Option<String>, - pub enable_monthly_recap_prompt: bool, - /// Pin the public "spotatui community" playlist to the top of the Spotify - /// playlists sidebar. - pub pin_community_playlist: bool, - pub liked_icon: String, - pub shuffle_icon: String, - pub repeat_track_icon: String, - pub repeat_context_icon: String, - pub playing_icon: String, - pub paused_icon: String, - pub set_window_title: bool, - pub visualizer_style: VisualizerStyle, - pub relay_server_url: String, - pub stop_after_current_track: bool, - pub startup_behavior: StartupBehavior, - pub disable_auto_update: bool, - pub auto_update_delay: String, - #[cfg(feature = "art-decode")] - pub draw_cover_art: bool, - #[cfg(feature = "art-decode")] - pub draw_cover_art_forced: bool, - #[cfg(feature = "art-decode")] - pub playbar_cover_art_size_percent: u16, - /// Recolor the UI accents from the current track's cover art, fading on - /// track change. Off by default so a chosen preset stays untouched. - #[cfg(feature = "art-decode")] - pub cover_art_theme: bool, - #[cfg(feature = "cover-art")] - pub cover_art_dither: bool, - #[cfg(feature = "cover-art")] - pub cover_art_dither_algorithm: String, - #[cfg(feature = "cover-art")] - pub cover_art_dither_pixel_scale: u8, - /// Whether to open the local MCP control socket so `spotatui mcp` (and through - /// it Claude Code, Codex, or any MCP client) can drive playback. - /// - /// Off by default. Opening a socket that can control the player and read - /// listening history is a security-posture change, so it is a deliberate act; - /// the socket binds loopback-only and requires the token from - /// `~/.config/spotatui/mcp.json`. - #[cfg(feature = "mcp-server")] - pub mcp_enabled: bool, - /// Which DJ brain to use: `agent_cli`, `anthropic`, or `openai_compat`. - #[cfg(feature = "ai-dj")] - pub dj_backend: String, - /// argv for the `agent_cli` backend. A config field rather than a hardcoded - /// table so any headless agent works and spotatui never tracks their flags. - #[cfg(feature = "ai-dj")] - pub dj_agent_command: Vec<String>, - /// How the prompt reaches that CLI: `stdin` or `arg`. - /// - /// `None` means "unset: the preset decides". It has to be optional, because a - /// resolved `String` here can never fall through to the preset's own mode, and - /// getting it wrong is not cosmetic: `agy` ignores stdin entirely, so a prompt - /// written there is silently dropped and the DJ answers something else. - #[cfg(feature = "ai-dj")] - pub dj_agent_prompt_via: Option<String>, - #[cfg(feature = "ai-dj")] - pub dj_agent_timeout_secs: u64, - /// Model for the `agent_cli` backend, passed as that CLI's own model flag - /// (`claude --model haiku`). - /// - /// Separate from [`Self::dj_model`] on purpose: they are different namespaces. - /// `dj_model` is an API model id billed per token; this is a CLI alias spent - /// against the subscription the CLI is already logged into. One field would make - /// `claude-haiku-4-5` and `haiku` interchangeable, and they are not. - #[cfg(feature = "ai-dj")] - pub dj_agent_model: Option<String>, - /// Model id for the API backends (`anthropic`, `openai_compat`). - #[cfg(feature = "ai-dj")] - pub dj_model: Option<String>, - /// Base URL for `openai_compat`. Defaults to Ollama's local endpoint. - #[cfg(feature = "ai-dj")] - pub dj_base_url: Option<String>, - /// API key for the HTTP backends. - /// - /// **Stored in plaintext in the YAML config** — prefer the - /// `SPOTATUI_DJ_API_KEY` environment variable, which overrides this field at - /// request time and is never written to disk. The config directory is `0700` - /// on unix and carries a `.gitignore`, but a plaintext secret is still a - /// plaintext secret. - #[cfg(feature = "ai-dj")] - pub dj_api_key: Option<String>, - /// How many tracks the DJ queues per round. Clamped to the resolver's cap. - #[cfg(feature = "ai-dj")] - pub dj_batch_size: usize, - /// History window the taste brief summarises: `7d`, `30d`, `month`, `year`, - /// `all`. - #[cfg(feature = "ai-dj")] - pub dj_history_period: String, - /// Start the DJ in "only tracks I do not already have" mode: reject anything in - /// Liked Songs or the listener's own playlists instead of recommending it. - /// - /// Only the starting value; the DJ screen toggles it per session, because which - /// mode is wanted depends on the ask. Turning it on costs one crawl of every - /// playlist the first time it is used in a session. - #[cfg(feature = "ai-dj")] - pub dj_avoid_library: bool, - /// Whether the DJ's backend and model have ever been chosen deliberately. - /// - /// `Option<bool>`, not `bool`. Key *presence* cannot be the signal: `save_config` - /// writes every `dj_*` key unconditionally as `Some(...)`, and it saves from hot - /// paths (volume, shuffle, sidebar resize, shutdown, first run), so every install - /// that has ever changed its volume already has the whole `dj_*` block on disk. - /// A bare `bool` is worse still: the first unrelated save would write `false` and - /// pin it there forever. Only the `Option` fields survive `build_behavior` - /// untouched, so this round-trips as `null` until the picker sets it. - #[cfg(feature = "ai-dj")] - pub dj_configured: Option<bool>, - pub keepawake_enabled: bool, - /// When false, spotatui ignores OS media-control commands (headphone - /// play/pause/skip buttons, media keys, MPRIS/SMTC/Now Playing, playerctl). - /// It still publishes track metadata to the OS; it just stops reacting. - pub enable_media_keys: bool, - pub sync_token: Option<String>, - /// Filesystem path to the local music library root (browsed by the Local - /// Files screen). Defaults to the OS music directory; `None` if unavailable. - pub local_music_path: Option<String>, - /// Base URL of the Subsonic/OpenSubsonic server (e.g. - /// `https://demo.navidrome.org`). `None` until configured. - pub subsonic_url: Option<String>, - /// Subsonic account username. - pub subsonic_username: Option<String>, - /// Subsonic account password. **Stored in plaintext in the YAML config** — - /// prefer the `SPOTATUI_SUBSONIC_PASSWORD` environment variable, which - /// overrides this field at connection time and is never written to disk. - pub subsonic_password: Option<String>, - /// Path to the `yt-dlp` binary used by the YouTube source. `None` resolves - /// plain `yt-dlp` through `$PATH`. - pub ytdlp_path: Option<String>, - /// Qobuz stream quality: 5 (MP3 320), 6 (FLAC 16/44.1), 7 (FLAC 24/96), - /// 27 (FLAC 24/192). Default 6. - pub qobuz_quality: u8, - /// User-authored stations shown alongside stations saved at runtime. - /// In-app favorite/remove actions mutate `state.yml`, not this list. - pub radio_stations: Vec<RadioStationConfig>, - // --- Phase 2: icons / glyphs / labels --- - pub gauge_filled_icon: String, - pub gauge_unfilled_icon: String, - pub active_source_icon: String, - pub episode_played_icon: String, - pub sort_ascending_icon: String, - pub sort_descending_icon: String, - pub list_highlight_icon: String, - /// Optional override of playbar control button labels, keyed by - /// `prev`/`play_pause`/`next`/`shuffle`/`repeat`/`like`/`vol_down`/`vol_up`. - pub playbar_control_labels: HashMap<String, String>, - // --- Phase 3: behavior constants / startup / sort --- - pub status_message_ttl_percent: u16, - pub playback_poll_seconds: u64, - pub table_scroll_padding: u16, - pub like_animation_frames: u8, - pub startup_route: String, - pub default_sort_playlist_tracks: String, - pub default_sort_saved_albums: String, - pub default_sort_saved_artists: String, - pub default_sort_recently_played: String, - // --- Phase 6: layout arrangement --- - pub sidebar_position: String, - pub playbar_position: String, - /// User-configured initial pane-size defaults. They seed `state.yml` only - /// when runtime state has no saved sizes yet; later runtime resize changes - /// stay authoritative. - pub sidebar_width_percent: Option<u8>, - pub playbar_height_rows: Option<u16>, - pub library_height_percent: Option<u8>, - pub small_terminal_width: u16, - pub small_terminal_height: u16, -} - -/// The DJ brains a config may name. Module-level rather than a local `const` in -/// the load validator so the validator, the picker and [`BehaviorConfig::dj_is_configured`] -/// cannot drift apart. -#[cfg(feature = "ai-dj")] -pub const DJ_BACKENDS: [&str; 3] = ["agent_cli", "anthropic", "openai_compat"]; - -/// The shipped default backend. Also what "not configured" compares against. -#[cfg(feature = "ai-dj")] -pub const DEFAULT_DJ_BACKEND: &str = "agent_cli"; - -/// The shipped default `agent_cli` argv. -/// -/// A function rather than a `const` because it allocates, and one source of truth -/// because [`BehaviorConfig::dj_is_configured`] asks "is this still the value -/// spotatui shipped?" — a second copy would answer that question wrongly the first -/// time either changed. -#[cfg(feature = "ai-dj")] -pub fn default_dj_agent_command() -> Vec<String> { - vec!["claude".to_string(), "-p".to_string()] -} - -impl BehaviorConfig { - // `emphasis` moved to `tui/theme.rs::EmphasisExt` (it returns a terminal - // `Modifier` type, which core no longer names). - - /// Has the DJ already been set up, by the picker or by hand? - /// - /// Two signals, and both are needed. The marker covers "the picker ran, or the - /// user dismissed it". Value-differs-from-default covers the user who configured - /// the DJ in their YAML before the picker existed: those values can only have - /// been typed, because `save_config` writes the defaults back verbatim. - /// - /// Deliberately absent: `dj_batch_size`, `dj_history_period`, `dj_avoid_library` - /// and the timeout are tuning, not a choice of AI. So is `dj_agent_prompt_via`, - /// and that one is the trap — `save_config` already wrote `stdin` into every - /// existing install, so counting it would mean nobody is ever asked, including - /// the Claude Pro users this picker exists for. - #[cfg(feature = "ai-dj")] - pub fn dj_is_configured(&self) -> bool { - if self.dj_configured == Some(true) { - return true; - } - self.dj_backend != DEFAULT_DJ_BACKEND - || self.dj_agent_command != default_dj_agent_command() - || self.dj_agent_model.is_some() - || self.dj_model.is_some() - || self.dj_api_key.is_some() - || self.dj_base_url.is_some() - } -} - -// ===== Phase 4: format templates ===== - -/// Placeholder keys available to every format template, in index order. -pub const FORMAT_KEYS: &[&str] = &[ - "state", "device", "source", "queue", "shuffle", "repeat", "volume", "party", -]; - -/// On-disk format config: all templates optional, defaulting to today's output. -#[derive(Default, Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct FormatConfigString { - pub playbar_status: Option<String>, - pub playbar_status_source: Option<String>, - pub window_title: Option<String>, -} - -/// Parsed format templates. Defaults reproduce today's `format!` output -/// byte-for-byte. -#[derive(Clone, Debug, PartialEq)] -pub struct FormatConfig { - /// Spotify playbar title: `"{state} ({device} | Shuffle: {shuffle} | Repeat: {repeat} | Volume: {volume}%){party}"` - pub playbar_status: Template, - /// Local-source playbar title: `"{state} ({source}{queue}{shuffle}{repeat} | Volume: {volume}%)"`. - /// The `{shuffle}` / `{repeat}` placeholders carry their own ` | Label: value` - /// prefix (like `{artist}` in the window title) and render empty for sources - /// without the controls (internet radio, native queue slots), so their labels - /// never leak as blank controls. - pub playbar_status_source: Template, - /// Window title: `"{state}: {artist} - {title}"` - pub window_title: Template, -} - -impl FormatConfig { - /// Today's hardcoded Spotify playbar format string. - pub const DEFAULT_PLAYBAR_STATUS: &'static str = - "{state} ({device} | Shuffle: {shuffle} | Repeat: {repeat} | Volume: {volume}%){party}"; - /// Today's hardcoded local-source playbar format string. `{shuffle}` and - /// `{repeat}` include their own ` | Label: value` prefix so they can render - /// empty (hiding the whole segment) for sources without those controls. - pub const DEFAULT_PLAYBAR_STATUS_SOURCE: &'static str = - "{state} ({source}{queue}{shuffle}{repeat} | Volume: {volume}%)"; - /// Today's hardcoded window-title format string: `"{title} — {artist}"`. - /// (The artist segment is composed by the call site and omitted when empty.) - pub const DEFAULT_WINDOW_TITLE: &'static str = "{title}{artist}"; - - /// The keys valid for window-title templates (a subset: artist/title are - /// resolved at the call site, not via FORMAT_KEYS). - pub const WINDOW_TITLE_KEYS: &'static [&'static str] = &["title", "artist"]; - - pub fn default_templates() -> Self { - Self { - playbar_status: Template::parse(Self::DEFAULT_PLAYBAR_STATUS, FORMAT_KEYS) - .expect("default playbar_status template must parse"), - playbar_status_source: Template::parse(Self::DEFAULT_PLAYBAR_STATUS_SOURCE, FORMAT_KEYS) - .expect("default playbar_status_source template must parse"), - window_title: Template::parse(Self::DEFAULT_WINDOW_TITLE, Self::WINDOW_TITLE_KEYS) - .expect("default window_title template must parse"), - } - } -} - -impl Default for FormatConfig { - fn default() -> Self { - Self::default_templates() - } -} - -#[derive(Default, Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct UserConfigString { - keybindings: Option<KeyBindingsString>, - behavior: Option<BehaviorConfigString>, - theme: Option<UserTheme>, - plugin_commands: Option<HashMap<String, String>>, - format: Option<FormatConfigString>, - tables: Option<TablesConfigString>, -} - -// ===== Phase 5: table columns ===== - -/// A single on-disk column spec. `header` overrides the default display text. -/// Exactly one of `width_percent` / `width` may be set; both set (or neither -/// for a column that expects a fixed default) — specifying both is a hard -/// error. When neither is set, the column's built-in default width applies. -#[derive(Default, Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct ColumnSpec { - /// Defaulted so an entry missing `id` fails that table's resolution (a - /// recoverable, warn-level error) instead of failing the whole YAML parse. - #[serde(default)] - pub id: String, - pub header: Option<String>, - pub width_percent: Option<f32>, - pub width: Option<u16>, -} - -#[derive(Default, Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct TablesConfigString { - pub songs: Option<Vec<ColumnSpec>>, - pub album_tracks: Option<Vec<ColumnSpec>>, - pub albums: Option<Vec<ColumnSpec>>, - pub podcasts: Option<Vec<ColumnSpec>>, - pub episodes: Option<Vec<ColumnSpec>>, - pub recently_played: Option<Vec<ColumnSpec>>, -} - -/// Validated (but not yet render-bound) per-table column lists. Defaults are -/// represented as empty `Vec`s; the rendering layer substitutes the built-in -/// default columns when a table is empty. This keeps `core` free of `tui` -/// dependencies — the column registry lives in `tui::ui::columns`. -#[derive(Clone, Debug, PartialEq, Default)] -pub struct TablesConfig { - pub songs: Vec<ColumnSpec>, - pub album_tracks: Vec<ColumnSpec>, - pub albums: Vec<ColumnSpec>, - pub podcasts: Vec<ColumnSpec>, - pub episodes: Vec<ColumnSpec>, - pub recently_played: Vec<ColumnSpec>, -} - -#[derive(Clone)] -pub struct UserConfig { - pub keys: KeyBindings, - pub theme: Theme, - pub current_preset: ThemePreset, - pub custom_theme: Theme, - #[cfg(feature = "cover-art")] - pub cover_art_dither_color: Option<crate::core::theme::Color>, - pub behavior: BehaviorConfig, - pub path_to_config: Option<UserConfigPaths>, - /// Keybindings for plugin commands: key -> command name. - pub plugin_command_keys: HashMap<Key, String>, - /// Parsed format templates (Phase 4). - pub format: FormatConfig, - /// Resolved per-table column layouts (Phase 5). - pub tables: TablesConfig, -} - -impl UserConfig { - /// Get the spotatui app config directory. - /// Returns None if neither an absolute XDG config directory nor $HOME is set. - #[cfg(feature = "self-update")] - pub fn get_app_config_dir() -> Option<PathBuf> { - default_app_config_dir() - } - - pub fn new() -> UserConfig { - // Detect platform for platform-specific defaults - #[cfg(target_os = "macos")] - let is_macos = true; - #[cfg(not(target_os = "macos"))] - let is_macos = false; - - UserConfig { - theme: Default::default(), - current_preset: ThemePreset::Default, - custom_theme: Default::default(), - #[cfg(feature = "cover-art")] - cover_art_dither_color: None, - keys: KeyBindings { - back: Key::Char('q'), - move_up: Key::Char('k'), - move_down: Key::Char('j'), - move_left: Key::Char('h'), - move_right: Key::Char('l'), - next_page: Key::Ctrl('d'), - previous_page: Key::Ctrl('u'), - jump_to_start: Key::Ctrl('a'), - jump_to_end: Key::Ctrl('e'), - jump_to_album: Key::Char('a'), - #[cfg(feature = "ai-dj")] - dj_open: Key::Ctrl('j'), - #[cfg(feature = "ai-dj")] - dj_toggle_auto_queue: Key::Ctrl('t'), - #[cfg(feature = "ai-dj")] - dj_vibe_shift: Key::Ctrl('y'), - // Ctrl+O for "only new". The readline-ish keys the DJ prompt implements - // (Ctrl+A/B/D/E/F/H/U) are all spoken for, and this has to work while the - // prompt has focus. - #[cfg(feature = "ai-dj")] - dj_toggle_fresh_only: Key::Ctrl('o'), - // Ctrl+G, chosen because it is the only unused modifier key that also - // survives the DJ prompt: Ctrl+L is the macOS settings alias, Ctrl+N is - // `down_event` for every list in the app, and Ctrl+K / Ctrl+W kill a line in - // the search input. Needs a modifier at all because the DJ prompt takes - // every bare character. - #[cfg(feature = "ai-dj")] - dj_pick_model: Key::Ctrl('g'), - jump_to_artist_album: Key::Char('A'), - jump_to_context: Key::Char('o'), - manage_devices: Key::Char('d'), - decrease_volume: Key::Char('-'), - increase_volume: Key::Char('+'), - toggle_playback: Key::Char(' '), - seek_backwards: Key::Char('<'), - seek_forwards: Key::Char('>'), - next_track: Key::Char('n'), - previous_track: Key::Char('p'), - force_previous_track: Key::Char('P'), - help: Key::Char('?'), - shuffle: Key::Ctrl('s'), - repeat: Key::Ctrl('r'), - search: Key::Char('/'), - submit: Key::Enter, - copy_song_url: Key::Char('c'), - copy_album_url: Key::Char('C'), - audio_analysis: Key::Char('v'), - lyrics_view: Key::Char('B'), - miniplayer_view: Key::Char('T'), - cover_art_view: Key::Char('G'), - add_item_to_queue: Key::Char('z'), - show_queue: Key::Char('Q'), - remove_from_queue: Key::Char('x'), - // On macOS, use Ctrl+, for settings since Alt+, produces ≤ on most keyboard layouts - // On other platforms, keep Alt+, for consistency with many apps - open_settings: if is_macos { - Key::Ctrl(',') - } else { - Key::Alt(',') - }, - save_settings: Key::Alt('s'), - listening_party: Key::Ctrl('p'), - like_track: Key::Char('F'), - generate_recap: Key::Char('R'), - }, - plugin_command_keys: HashMap::new(), - behavior: BehaviorConfig { - seek_milliseconds: 5 * 1000, - volume_increment: 10, - volume_percent: None, - tick_rate_milliseconds: DEFAULT_TICK_RATE_MILLISECONDS, - animation_tick_rate_milliseconds: DEFAULT_ANIMATION_TICK_RATE_MILLISECONDS, - enable_text_emphasis: true, - banner_gradient: true, - show_loading_indicator: true, - enforce_wide_search_bar: false, - group_folders_first: false, - enable_global_song_count: true, - disable_mouse_inputs: false, - enable_discord_rpc: true, - discord_rpc_client_id: None, - enable_announcements: true, - announcement_feed_url: None, - enable_monthly_recap_prompt: true, - pin_community_playlist: true, - liked_icon: "♥".to_string(), - shuffle_icon: "🔀".to_string(), - repeat_track_icon: "🔂".to_string(), - repeat_context_icon: "🔁".to_string(), - playing_icon: "▶".to_string(), - paused_icon: "⏸".to_string(), - set_window_title: true, - visualizer_style: VisualizerStyle::default(), - relay_server_url: "wss://spotatui-party.spotatui.workers.dev/ws".to_string(), - stop_after_current_track: false, - startup_behavior: StartupBehavior::Continue, - disable_auto_update: false, - auto_update_delay: "0".to_string(), - #[cfg(feature = "art-decode")] - draw_cover_art: true, - #[cfg(feature = "art-decode")] - draw_cover_art_forced: false, - #[cfg(feature = "art-decode")] - playbar_cover_art_size_percent: 100, - #[cfg(feature = "art-decode")] - cover_art_theme: false, - #[cfg(feature = "cover-art")] - cover_art_dither: false, - #[cfg(feature = "cover-art")] - cover_art_dither_algorithm: "stucki".to_string(), - #[cfg(feature = "cover-art")] - cover_art_dither_pixel_scale: 1, - #[cfg(feature = "mcp-server")] - mcp_enabled: false, - #[cfg(feature = "ai-dj")] - dj_backend: DEFAULT_DJ_BACKEND.to_string(), - #[cfg(feature = "ai-dj")] - dj_agent_command: default_dj_agent_command(), - // Unset, so the preset's own delivery mode applies. Pinning "stdin" here - // would silently break `agy`, which reads the prompt from argv only. - #[cfg(feature = "ai-dj")] - dj_agent_prompt_via: None, - #[cfg(feature = "ai-dj")] - dj_agent_timeout_secs: 90, - #[cfg(feature = "ai-dj")] - dj_agent_model: None, - #[cfg(feature = "ai-dj")] - dj_model: None, - #[cfg(feature = "ai-dj")] - dj_base_url: None, - #[cfg(feature = "ai-dj")] - dj_api_key: None, - #[cfg(feature = "ai-dj")] - dj_batch_size: crate::infra::dj::DEFAULT_BATCH, - #[cfg(feature = "ai-dj")] - dj_history_period: "30d".to_string(), - // Off by default: the filter costs a playlist crawl, and "more like this" - // is a perfectly reasonable thing to want from a DJ. - #[cfg(feature = "ai-dj")] - dj_avoid_library: false, - #[cfg(feature = "ai-dj")] - dj_configured: None, - keepawake_enabled: true, - enable_media_keys: true, - sync_token: None, - local_music_path: dirs::audio_dir().map(|p| p.to_string_lossy().to_string()), - subsonic_url: None, - subsonic_username: None, - subsonic_password: None, - ytdlp_path: None, - qobuz_quality: QOBUZ_QUALITY_DEFAULT, - radio_stations: Vec::new(), - // --- Phase 2: icons / glyphs / labels (defaults = today's glyphs) --- - gauge_filled_icon: "⣿".to_string(), - gauge_unfilled_icon: "⣉".to_string(), - active_source_icon: "●".to_string(), - episode_played_icon: "✔".to_string(), - sort_ascending_icon: "↑".to_string(), - sort_descending_icon: "↓".to_string(), - list_highlight_icon: "▶".to_string(), - playbar_control_labels: HashMap::new(), - // --- Phase 3: behavior constants / startup / sort --- - status_message_ttl_percent: 100, - playback_poll_seconds: 5, - table_scroll_padding: 5, - like_animation_frames: 10, - startup_route: "home".to_string(), - default_sort_playlist_tracks: "default".to_string(), - default_sort_saved_albums: "default".to_string(), - default_sort_saved_artists: "default".to_string(), - default_sort_recently_played: "default".to_string(), - // --- Phase 6: layout arrangement --- - sidebar_position: "left".to_string(), - playbar_position: "bottom".to_string(), - sidebar_width_percent: None, - playbar_height_rows: None, - library_height_percent: None, - small_terminal_width: 150, - small_terminal_height: 45, - }, - path_to_config: None, - // Phase 4 / 5: parsed templates + resolved columns default to today's - // built-in output (empty TablesConfig == built-in default columns). - format: FormatConfig::default_templates(), - tables: TablesConfig::default(), - } - } - - pub fn get_or_build_paths(&mut self) -> Result<()> { - match default_app_config_dir() { - Some(app_config_dir) => { - let home_config_dir = app_config_dir - .parent() - .ok_or_else(|| anyhow!("Invalid app config directory"))?; - - if !home_config_dir.exists() { - fs::create_dir(home_config_dir)?; - } - - if !app_config_dir.exists() { - fs::create_dir(&app_config_dir)?; - } - - // Restrict the app's own config directory (holds config.yml, which - // carries the Subsonic password and party sync_token in cleartext, - // plus the Spotify token cache) to owner-only. Never touch - // `home_config_dir` (`~/.config`) — that's shared with every other - // application on the system. - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&app_config_dir, fs::Permissions::from_mode(0o700))?; - } - - let config_file_path = &app_config_dir.join(FILE_NAME); - - let paths = UserConfigPaths { - config_file_path: config_file_path.to_path_buf(), - }; - self.path_to_config = Some(paths); - Ok(()) - } - None => Err(anyhow!("No $HOME directory found for client config")), - } - } - - pub fn load_keybindings(&mut self, keybindings: KeyBindingsString) -> Result<()> { - macro_rules! to_keys { - ($name: ident) => { - if let Some(key_string) = keybindings.$name { - match parse_key(key_string) { - Ok(key) => self.keys.$name = key, - // One typo'd binding must not stop the app from launching: - // warn and keep that binding's default, like the rest of the - // config validation (#441). - Err(e) => log::warn!( - "[config] keybindings.{}: {e}; keeping the default", - stringify!($name) - ), - } - } - }; - } - - to_keys!(back); - to_keys!(move_up); - to_keys!(move_down); - to_keys!(move_left); - to_keys!(move_right); - to_keys!(next_page); - to_keys!(previous_page); - to_keys!(jump_to_start); - to_keys!(jump_to_end); - to_keys!(jump_to_album); - #[cfg(feature = "ai-dj")] - to_keys!(dj_open); - #[cfg(feature = "ai-dj")] - to_keys!(dj_toggle_auto_queue); - #[cfg(feature = "ai-dj")] - to_keys!(dj_vibe_shift); - #[cfg(feature = "ai-dj")] - to_keys!(dj_toggle_fresh_only); - #[cfg(feature = "ai-dj")] - to_keys!(dj_pick_model); - to_keys!(jump_to_artist_album); - to_keys!(jump_to_context); - to_keys!(manage_devices); - to_keys!(decrease_volume); - to_keys!(increase_volume); - to_keys!(toggle_playback); - to_keys!(seek_backwards); - to_keys!(seek_forwards); - to_keys!(next_track); - to_keys!(previous_track); - to_keys!(force_previous_track); - to_keys!(help); - to_keys!(shuffle); - to_keys!(repeat); - to_keys!(search); - to_keys!(submit); - to_keys!(copy_song_url); - to_keys!(copy_album_url); - to_keys!(audio_analysis); - to_keys!(lyrics_view); - to_keys!(miniplayer_view); - to_keys!(cover_art_view); - to_keys!(add_item_to_queue); - to_keys!(show_queue); - to_keys!(remove_from_queue); - to_keys!(open_settings); - to_keys!(save_settings); - to_keys!(listening_party); - to_keys!(like_track); - to_keys!(generate_recap); - - Ok(()) - } - - pub fn load_theme(&mut self, theme: UserTheme) -> Result<()> { - #[cfg(feature = "cover-art")] - { - self.cover_art_dither_color = match theme.cover_art_dither_color.as_deref() { - None => None, - Some(value) if value.trim().eq_ignore_ascii_case("auto") => None, - Some(value) => Some(parse_theme_item(value)?), - }; - } - // Individual color fields populate the custom_theme — they only - // become the active theme when current_preset is Custom. - macro_rules! to_theme_item { - ($name: ident) => { - if let Some(theme_item) = theme.$name { - self.custom_theme.$name = parse_theme_item(&theme_item)?; - } - }; - } - // Check if any colour values exist in config already` - let has_color_values = theme.active.is_some() - || theme.banner.is_some() - || theme.error_border.is_some() - || theme.error_text.is_some() - || theme.hint.is_some() - || theme.hovered.is_some() - || theme.inactive.is_some() - || theme.playbar_background.is_some() - || theme.playbar_progress.is_some() - || theme.playbar_progress_text.is_some() - || theme.playbar_text.is_some() - || theme.selected.is_some() - || theme.text.is_some() - || theme.background.is_some() - || theme.header.is_some() - || theme.highlighted_lyrics.is_some(); - - to_theme_item!(active); - to_theme_item!(banner); - to_theme_item!(error_border); - to_theme_item!(error_text); - to_theme_item!(hint); - to_theme_item!(hovered); - to_theme_item!(inactive); - to_theme_item!(playbar_background); - to_theme_item!(playbar_progress); - to_theme_item!(playbar_progress_text); - to_theme_item!(playbar_text); - to_theme_item!(selected); - to_theme_item!(text); - to_theme_item!(background); - to_theme_item!(header); - to_theme_item!(highlighted_lyrics); - - // If the preset value exists in the config, we load it - if let Some(preset_name) = theme.preset { - self.current_preset = ThemePreset::from_name(&preset_name); - } else if has_color_values { - // If there is no preset value, or it is malformed, - // and if the config exists and has some theme colours set: - // we handle backwards compatibility for old theme configs. - // Set to Custom on first load after the upgrade. - self.current_preset = ThemePreset::Custom; - } - - self.theme = match self.current_preset { - ThemePreset::Custom => self.custom_theme, - preset => preset.to_theme(), - }; - - Ok(()) - } - - /// Resolve the effective banner-gradient state once both the behavior and - /// theme sections have loaded: an explicit `banner_gradient` in the config - /// always wins; otherwise the theme preset decides (the Terminal preset - /// defaults to a solid banner so it can follow the terminal palette). - fn resolve_banner_gradient(&mut self, explicit: Option<bool>) { - self.behavior.banner_gradient = - explicit.unwrap_or_else(|| self.current_preset.default_banner_gradient()); - } - - pub fn load_behaviorconfig(&mut self, behavior_config: BehaviorConfigString) -> Result<()> { - if let Some(behavior_string) = behavior_config.seek_milliseconds { - self.behavior.seek_milliseconds = behavior_string; - } - - if let Some(behavior_string) = behavior_config.volume_increment { - if behavior_string > 100 { - return Err(anyhow!( - "Volume increment must be between 0 and 100, is {}", - behavior_string, - )); - } - self.behavior.volume_increment = behavior_string; - } - - if let Some(volume) = behavior_config.volume_percent { - self.behavior.volume_percent = Some(volume.min(100)); - } - - let loaded_tick_rate = behavior_config.tick_rate_milliseconds; - let loaded_animation_tick_rate = behavior_config.animation_tick_rate_milliseconds; - - if let Some(tick_rate) = loaded_tick_rate { - let tick_rate = validate_tick_rate_milliseconds(tick_rate, "Tick rate")?; - // Before animation ticks existed, save_config wrote the old 16ms default - // into user configs. Treat the legacy 16ms normal tick as the old default - // when animation ticks are absent or still equal to the animation default, - // so upgraded users get the new normal UI cadence without manual edits. - self.behavior.tick_rate_milliseconds = if tick_rate - == DEFAULT_ANIMATION_TICK_RATE_MILLISECONDS - && loaded_animation_tick_rate - .map(|animation_tick_rate| { - animation_tick_rate == DEFAULT_ANIMATION_TICK_RATE_MILLISECONDS - }) - .unwrap_or(true) - { - DEFAULT_TICK_RATE_MILLISECONDS - } else { - tick_rate - }; - } - - if let Some(tick_rate) = loaded_animation_tick_rate { - self.behavior.animation_tick_rate_milliseconds = - validate_tick_rate_milliseconds(tick_rate, "Animation tick rate")?; - } - - if let Some(text_emphasis) = behavior_config.enable_text_emphasis { - self.behavior.enable_text_emphasis = text_emphasis; - } - - if let Some(banner_gradient) = behavior_config.banner_gradient { - self.behavior.banner_gradient = banner_gradient; - } - - if let Some(loading_indicator) = behavior_config.show_loading_indicator { - self.behavior.show_loading_indicator = loading_indicator; - } - - if let Some(wide_search_bar) = behavior_config.enforce_wide_search_bar { - self.behavior.enforce_wide_search_bar = wide_search_bar; - } - - if let Some(group_folders_first) = behavior_config.group_folders_first { - self.behavior.group_folders_first = group_folders_first; - } - - if let Some(liked_icon) = behavior_config.liked_icon { - self.behavior.liked_icon = liked_icon; - } - - if let Some(paused_icon) = behavior_config.paused_icon { - self.behavior.paused_icon = paused_icon; - } - - if let Some(shuffle_icon) = behavior_config.shuffle_icon { - self.behavior.shuffle_icon = shuffle_icon; - } - - if let Some(repeat_track_icon) = behavior_config.repeat_track_icon { - self.behavior.repeat_track_icon = repeat_track_icon; - } - - if let Some(repeat_context_icon) = behavior_config.repeat_context_icon { - self.behavior.repeat_context_icon = repeat_context_icon; - } - - if let Some(set_window_title) = behavior_config.set_window_title { - self.behavior.set_window_title = set_window_title; - } - - if let Some(enable_global_song_count) = behavior_config.enable_global_song_count { - self.behavior.enable_global_song_count = enable_global_song_count; - } - - if let Some(disable_mouse_inputs) = behavior_config.disable_mouse_inputs { - self.behavior.disable_mouse_inputs = disable_mouse_inputs; - } - - if let Some(enable_discord_rpc) = behavior_config.enable_discord_rpc { - self.behavior.enable_discord_rpc = enable_discord_rpc; - } - - if let Some(enable_announcements) = behavior_config.enable_announcements { - self.behavior.enable_announcements = enable_announcements; - } - - if let Some(enable_monthly_recap_prompt) = behavior_config.enable_monthly_recap_prompt { - self.behavior.enable_monthly_recap_prompt = enable_monthly_recap_prompt; - } - - if let Some(pin_community_playlist) = behavior_config.pin_community_playlist { - self.behavior.pin_community_playlist = pin_community_playlist; - } - - if let Some(announcement_feed_url) = behavior_config.announcement_feed_url { - let trimmed = announcement_feed_url.trim(); - self.behavior.announcement_feed_url = if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - }; - } - - if let Some(discord_rpc_client_id) = behavior_config.discord_rpc_client_id { - self.behavior.discord_rpc_client_id = Some(discord_rpc_client_id); - } - - if let Some(visualizer_style) = behavior_config.visualizer_style { - self.behavior.visualizer_style = visualizer_style; - } - - if let Some(relay_server_url) = behavior_config.relay_server_url { - let trimmed = relay_server_url.trim(); - if !trimmed.is_empty() { - self.behavior.relay_server_url = trimmed.to_string(); - } - } - if let Some(sync_token) = behavior_config.sync_token { - let trimmed = sync_token.trim(); - if trimmed.is_empty() { - self.behavior.sync_token = None; - } else { - self.behavior.sync_token = Some(trimmed.to_string()); - } - } - if let Some(stop_after_current_track) = behavior_config.stop_after_current_track { - self.behavior.stop_after_current_track = stop_after_current_track; - } - - if let Some(radio_stations) = behavior_config.radio_stations { - self.behavior.radio_stations = sanitized_radio_stations(&radio_stations); - } - - if let Some(sidebar_width_percent) = behavior_config.sidebar_width_percent { - self.behavior.sidebar_width_percent = Some(sidebar_width_percent.min(100)); - } - - if let Some(playbar_height_rows) = behavior_config.playbar_height_rows { - self.behavior.playbar_height_rows = Some(playbar_height_rows); - } - - if let Some(library_height_percent) = behavior_config.library_height_percent { - self.behavior.library_height_percent = Some(library_height_percent.min(100)); - } - - if let Some(startup_behavior) = behavior_config.startup_behavior { - self.behavior.startup_behavior = startup_behavior; - } - - if let Some(disable_auto_update) = behavior_config.disable_auto_update { - self.behavior.disable_auto_update = disable_auto_update; - } - - if let Some(auto_update_delay) = behavior_config.auto_update_delay { - parse_update_delay_secs(&auto_update_delay) - .map_err(|e| anyhow!("Invalid auto-update delay: {e}"))?; - self.behavior.auto_update_delay = auto_update_delay; - } - - #[cfg(feature = "art-decode")] - if let Some(draw_cover_art) = behavior_config.draw_cover_art { - self.behavior.draw_cover_art = draw_cover_art; - } - - #[cfg(feature = "art-decode")] - if let Some(draw_cover_art_forced) = behavior_config.draw_cover_art_forced { - self.behavior.draw_cover_art_forced = draw_cover_art_forced; - } - #[cfg(feature = "art-decode")] - if let Some(playbar_cover_art_size_percent) = behavior_config.playbar_cover_art_size_percent { - self.behavior.playbar_cover_art_size_percent = - clamp_playbar_cover_art_size_percent(playbar_cover_art_size_percent); - } - #[cfg(feature = "art-decode")] - if let Some(cover_art_theme) = behavior_config.cover_art_theme { - self.behavior.cover_art_theme = cover_art_theme; - } - #[cfg(feature = "cover-art")] - { - if let Some(value) = behavior_config.cover_art_dither { - self.behavior.cover_art_dither = value; - } - if let Some(value) = behavior_config.cover_art_dither_algorithm { - self.behavior.cover_art_dither_algorithm = - normalize_cover_art_dither_algorithm(&value).to_string(); - } - if let Some(value) = behavior_config.cover_art_dither_pixel_scale { - self.behavior.cover_art_dither_pixel_scale = normalize_cover_art_dither_pixel_scale(value); - } - } - #[cfg(feature = "mcp-server")] - if let Some(mcp_enabled) = behavior_config.mcp_enabled { - self.behavior.mcp_enabled = mcp_enabled; - } - #[cfg(feature = "ai-dj")] - { - if let Some(dj_backend) = behavior_config.dj_backend { - let normalized = dj_backend.trim().to_ascii_lowercase(); - if DJ_BACKENDS.contains(&normalized.as_str()) { - self.behavior.dj_backend = normalized; - } else { - log::warn!( - "behavior.dj_backend '{dj_backend}' is not one of {DJ_BACKENDS:?}; keeping '{}'", - self.behavior.dj_backend - ); - } - } - if let Some(dj_agent_command) = behavior_config.dj_agent_command { - // Only argv[0] has to be a real word: it is the program to exec, and a - // blank one reaches `spawn` as a confusing ENOENT. Later arguments are - // left alone, since a CLI can legitimately take an empty one. - if dj_agent_command - .first() - .is_some_and(|program| !program.trim().is_empty()) - { - self.behavior.dj_agent_command = dj_agent_command; - } else { - log::warn!("behavior.dj_agent_command has no program to run; keeping the default"); - } - } - if let Some(dj_agent_prompt_via) = behavior_config.dj_agent_prompt_via { - match crate::infra::dj::brain::agent_cli::PromptDelivery::from_config_str( - &dj_agent_prompt_via, - ) { - // Store the canonical form, so `arg`/`argv`/`last-arg` all read back the - // same way. - Some(delivery) => { - self.behavior.dj_agent_prompt_via = Some(delivery.to_config_str().to_string()) - } - None => log::warn!( - "behavior.dj_agent_prompt_via '{dj_agent_prompt_via}' is not 'stdin' or 'arg'; keeping '{}'", - self - .behavior - .dj_agent_prompt_via - .as_deref() - .unwrap_or("unset") - ), - } - } - if let Some(secs) = behavior_config.dj_agent_timeout_secs { - // A sub-second timeout would kill every agent before it started. - self.behavior.dj_agent_timeout_secs = secs.clamp(5, 600); - } - if let Some(dj_agent_model) = behavior_config.dj_agent_model { - self.behavior.dj_agent_model = - Some(dj_agent_model).filter(|model| !model.trim().is_empty()); - } - if let Some(dj_model) = behavior_config.dj_model { - self.behavior.dj_model = Some(dj_model).filter(|model| !model.trim().is_empty()); - } - if let Some(dj_base_url) = behavior_config.dj_base_url { - self.behavior.dj_base_url = Some(dj_base_url).filter(|url| !url.trim().is_empty()); - } - if let Some(dj_api_key) = behavior_config.dj_api_key { - self.behavior.dj_api_key = Some(dj_api_key).filter(|key| !key.trim().is_empty()); - } - if let Some(dj_batch_size) = behavior_config.dj_batch_size { - self.behavior.dj_batch_size = dj_batch_size.clamp(1, crate::infra::dj::MAX_BATCH); - } - if let Some(dj_history_period) = behavior_config.dj_history_period { - const PERIODS: [&str; 5] = ["7d", "30d", "month", "year", "all"]; - let normalized = dj_history_period.trim().to_ascii_lowercase(); - if PERIODS.contains(&normalized.as_str()) { - self.behavior.dj_history_period = normalized; - } else { - log::warn!( - "behavior.dj_history_period '{dj_history_period}' is not one of {PERIODS:?}; keeping '{}'", - self.behavior.dj_history_period - ); - } - } - if let Some(dj_avoid_library) = behavior_config.dj_avoid_library { - self.behavior.dj_avoid_library = dj_avoid_library; - } - // No validation to do: the only value that means anything is `true`, and a - // config that says `false` is telling us the picker has not run yet. - if let Some(dj_configured) = behavior_config.dj_configured { - self.behavior.dj_configured = Some(dj_configured); - } - } - if let Some(keepawake_enabled) = behavior_config.keepawake_enabled { - self.behavior.keepawake_enabled = keepawake_enabled; - } - if let Some(enable_media_keys) = behavior_config.enable_media_keys { - self.behavior.enable_media_keys = enable_media_keys; - } - if let Some(local_music_path) = behavior_config.local_music_path { - let trimmed = local_music_path.trim(); - self.behavior.local_music_path = if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - }; - } - // Subsonic server config: trim-to-None so blank keys read as unset. - let trim_to_none = |value: Option<String>| -> Option<String> { - value.and_then(|v| { - let trimmed = v.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - }) - }; - if let Some(subsonic_url) = trim_to_none(behavior_config.subsonic_url) { - self.behavior.subsonic_url = Some(subsonic_url); - } - if let Some(subsonic_username) = trim_to_none(behavior_config.subsonic_username) { - self.behavior.subsonic_username = Some(subsonic_username); - } - if let Some(subsonic_password) = trim_to_none(behavior_config.subsonic_password) { - self.behavior.subsonic_password = Some(subsonic_password); - } - if let Some(ytdlp_path) = trim_to_none(behavior_config.ytdlp_path) { - self.behavior.ytdlp_path = Some(ytdlp_path); - } - if let Some(qobuz_quality) = behavior_config.qobuz_quality { - self.behavior.qobuz_quality = qobuz_quality_or_default(qobuz_quality); - } - - // ===== Phase 2: icons / glyphs / labels ===== - // Width-restricted glyphs (column math depends on them) are validated to - // exactly one terminal column; free-form labels are accepted as-is. - // A bad glyph degrades to the built-in default with a warning rather than - // failing config load (the app must stay launchable on a typo). - let load_width1_icon = |dest: &mut String, value: Option<String>, field: &str| { - if let Some(icon) = value { - let icon = icon.trim().to_string(); - if icon.is_empty() { - log::warn!("[config] {field} must not be empty; using default"); - return; - } - let width: usize = unicode_width::UnicodeWidthStr::width(icon.as_str()); - if width != 1 { - log::warn!( - "[config] {field} must be exactly one terminal column wide (got {width} columns): {icon}; using default" - ); - return; - } - *dest = icon; - } - }; - load_width1_icon( - &mut self.behavior.gauge_filled_icon, - behavior_config.gauge_filled_icon, - "gauge_filled_icon", - ); - load_width1_icon( - &mut self.behavior.gauge_unfilled_icon, - behavior_config.gauge_unfilled_icon, - "gauge_unfilled_icon", - ); - // playing_icon prefixes the title cell of the playing row (padded to two - // columns in padded_playing_icon), so it must be exactly one column wide. - load_width1_icon( - &mut self.behavior.playing_icon, - behavior_config.playing_icon, - "playing_icon", - ); - // active_source_icon, list_highlight_icon render in free space, not a - // fixed-width column → accept any non-empty glyph. - if let Some(icon) = behavior_config.active_source_icon { - let icon = icon.trim().to_string(); - if !icon.is_empty() { - self.behavior.active_source_icon = icon; - } - } - if let Some(icon) = behavior_config.list_highlight_icon { - let icon = icon.trim().to_string(); - if !icon.is_empty() { - self.behavior.list_highlight_icon = icon; - } - } - // episode_played_icon renders in a width-2 "played" column (tables.rs), - // so it must be exactly one column wide (the leading space is added at - // the call site). - load_width1_icon( - &mut self.behavior.episode_played_icon, - behavior_config.episode_played_icon, - "episode_played_icon", - ); - // sort direction icons render in a width-1 column. - load_width1_icon( - &mut self.behavior.sort_ascending_icon, - behavior_config.sort_ascending_icon, - "sort_ascending_icon", - ); - load_width1_icon( - &mut self.behavior.sort_descending_icon, - behavior_config.sort_descending_icon, - "sort_descending_icon", - ); - // playbar control labels: free-form strings keyed by control id. Keep only - // the known keys so typos don't silently no-op; empty values are dropped - // (falling back to the built-in label). - if let Some(labels) = behavior_config.playbar_control_labels { - let allowed = [ - "prev", - "play_pause", - "next", - "shuffle", - "repeat", - "like", - "vol_down", - "vol_up", - ]; - let mut kept = HashMap::new(); - for (key, val) in labels { - let key = key.trim().to_string(); - let val = val.trim().to_string(); - if !allowed.contains(&key.as_str()) { - log::warn!( - "[config] playbar_control_labels: skipping unknown key '{key}' (allowed: {})", - allowed.join(", ") - ); - continue; - } - if val.is_empty() { - // empty == reset to default; drop the override - continue; - } - kept.insert(key, val); - } - self.behavior.playbar_control_labels = kept; - } - - // ===== Phase 3: behavior constants / startup / sort ===== - if let Some(pct) = behavior_config.status_message_ttl_percent { - self.behavior.status_message_ttl_percent = pct.clamp(10, 1000); - } - if let Some(secs) = behavior_config.playback_poll_seconds { - if secs < 1 { - return Err(anyhow!( - "playback_poll_seconds must be at least 1, is {secs}" - )); - } - self.behavior.playback_poll_seconds = secs; - } - if let Some(padding) = behavior_config.table_scroll_padding { - self.behavior.table_scroll_padding = padding; - } - if let Some(frames) = behavior_config.like_animation_frames { - if frames < 1 { - return Err(anyhow!( - "like_animation_frames must be at least 1, is {frames}" - )); - } - self.behavior.like_animation_frames = frames; - } - if let Some(route) = behavior_config.startup_route { - let route = route.trim().to_string(); - if !route.is_empty() { - // Validation of the route id happens in App::apply_startup_route(); - // store the raw string here so an unknown value degrades to Home + warn - // rather than failing config load. - self.behavior.startup_route = route; - } - } - // Per-context default sort: "<field>" or "<field>:desc". Validate against - // the context's available fields; a typo degrades to the default order - // with a warning rather than failing config load. - let load_sort_default = |dest: &mut String, - value: Option<String>, - ctx: crate::core::sort::SortContext, - field: &str| { - if let Some(spec) = value { - let spec = spec.trim().to_string(); - if spec.is_empty() { - return; - } - match crate::core::sort::SortState::parse(&spec, ctx) { - Ok(_) => *dest = spec, - Err(e) => log::warn!("[config] {field}: {e}; using default sort"), - } - } - }; - load_sort_default( - &mut self.behavior.default_sort_playlist_tracks, - behavior_config.default_sort_playlist_tracks, - crate::core::sort::SortContext::PlaylistTracks, - "default_sort_playlist_tracks", - ); - load_sort_default( - &mut self.behavior.default_sort_saved_albums, - behavior_config.default_sort_saved_albums, - crate::core::sort::SortContext::SavedAlbums, - "default_sort_saved_albums", - ); - load_sort_default( - &mut self.behavior.default_sort_saved_artists, - behavior_config.default_sort_saved_artists, - crate::core::sort::SortContext::SavedArtists, - "default_sort_saved_artists", - ); - load_sort_default( - &mut self.behavior.default_sort_recently_played, - behavior_config.default_sort_recently_played, - crate::core::sort::SortContext::RecentlyPlayed, - "default_sort_recently_played", - ); - - // ===== Phase 6: layout arrangement ===== - if let Some(pos) = behavior_config.sidebar_position { - let pos = pos.trim().to_string(); - match pos.as_str() { - "left" | "right" | "hidden" => self.behavior.sidebar_position = pos, - _ => log::warn!( - "[config] sidebar_position '{pos}' is invalid (expected left|right|hidden); using left" - ), - } - } - if let Some(pos) = behavior_config.playbar_position { - let pos = pos.trim().to_string(); - match pos.as_str() { - "bottom" | "top" => self.behavior.playbar_position = pos, - _ => log::warn!( - "[config] playbar_position '{pos}' is invalid (expected bottom|top); using bottom" - ), - } - } - if let Some(w) = behavior_config.small_terminal_width { - self.behavior.small_terminal_width = w.max(1); - } - if let Some(h) = behavior_config.small_terminal_height { - self.behavior.small_terminal_height = h.max(1); - } - Ok(()) - } - - fn named_action_keys(&self) -> Vec<Key> { - let k = &self.keys; - vec![ - k.back, - k.move_up, - k.move_down, - k.move_left, - k.move_right, - k.next_page, - k.previous_page, - k.jump_to_start, - k.jump_to_end, - k.jump_to_album, - #[cfg(feature = "ai-dj")] - k.dj_open, - #[cfg(feature = "ai-dj")] - k.dj_toggle_auto_queue, - #[cfg(feature = "ai-dj")] - k.dj_vibe_shift, - #[cfg(feature = "ai-dj")] - k.dj_toggle_fresh_only, - #[cfg(feature = "ai-dj")] - k.dj_pick_model, - k.jump_to_artist_album, - k.jump_to_context, - k.manage_devices, - k.decrease_volume, - k.increase_volume, - k.toggle_playback, - k.seek_backwards, - k.seek_forwards, - k.next_track, - k.previous_track, - k.force_previous_track, - k.help, - k.shuffle, - k.repeat, - k.search, - k.submit, - k.copy_song_url, - k.copy_album_url, - k.audio_analysis, - k.lyrics_view, - k.miniplayer_view, - k.cover_art_view, - k.add_item_to_queue, - k.show_queue, - k.remove_from_queue, - k.open_settings, - k.save_settings, - k.listening_party, - k.like_track, - k.generate_recap, - ] - } - - pub fn load_plugin_commands(&mut self, entries: HashMap<String, String>) { - let named_keys = self.named_action_keys(); - let mut result: HashMap<Key, String> = HashMap::new(); - for (cmd_name, key_str) in entries { - let key = match parse_key(key_str.clone()) { - Ok(k) => k, - Err(e) => { - log::warn!( - "[config] plugin_commands: skipping '{cmd_name}': invalid key '{key_str}': {e}" - ); - continue; - } - }; - if let Err(e) = check_reserved_keys(key) { - log::warn!("[config] plugin_commands: skipping '{cmd_name}': {e}"); - continue; - } - if named_keys.contains(&key) { - log::warn!( - "[config] plugin_commands: skipping '{cmd_name}': key '{key_str}' collides with a named action" - ); - continue; - } - result.insert(key, cmd_name); - } - self.plugin_command_keys = result; - } - - pub fn load_config(&mut self) -> Result<()> { - let paths = match &self.path_to_config { - Some(path) => path, - None => { - self.get_or_build_paths()?; - self.path_to_config.as_ref().unwrap() - } - }; - if paths.config_file_path.exists() { - let config_string = fs::read_to_string(&paths.config_file_path)?; - // serde fails if file is empty - if config_string.trim().is_empty() { - return Ok(()); - } - - let config_yml: UserConfigString = serde_yaml::from_str(&config_string)?; - - if let Some(keybindings) = config_yml.keybindings.clone() { - self.load_keybindings(keybindings)?; - } - - let explicit_banner_gradient = config_yml - .behavior - .as_ref() - .and_then(|behavior| behavior.banner_gradient); - if let Some(behavior) = config_yml.behavior { - self.load_behaviorconfig(behavior)?; - } - if let Some(theme) = config_yml.theme { - self.load_theme(theme)?; - } - self.resolve_banner_gradient(explicit_banner_gradient); - if let Some(plugin_commands) = config_yml.plugin_commands { - self.load_plugin_commands(plugin_commands); - } - if let Some(format) = config_yml.format { - self.load_formatconfig(format); - } - if let Some(tables) = config_yml.tables { - self.load_tablesconfig(tables); - } - - Ok(()) - } else { - Ok(()) - } - } - - /// Validate and apply format templates (Phase 4). Each template is parsed - /// against `FORMAT_KEYS` (or the window-title subset); a parse error - /// degrades that template to the built-in default with a warning listing - /// the valid keys, so a typo never blocks app launch. - pub fn load_formatconfig(&mut self, format: FormatConfigString) { - if let Some(s) = format.playbar_status { - match Template::parse(s.trim(), FORMAT_KEYS) { - Ok(t) => self.format.playbar_status = t, - Err(e) => log::warn!("[config] format.playbar_status: {e}; using default"), - } - } - if let Some(s) = format.playbar_status_source { - match Template::parse(s.trim(), FORMAT_KEYS) { - Ok(t) => self.format.playbar_status_source = t, - Err(e) => log::warn!("[config] format.playbar_status_source: {e}; using default"), - } - } - if let Some(s) = format.window_title { - match Template::parse(s.trim(), FormatConfig::WINDOW_TITLE_KEYS) { - Ok(t) => self.format.window_title = t, - Err(e) => log::warn!("[config] format.window_title: {e}; using default"), - } - } - } - - /// Validate and apply table column specs (Phase 5). Unknown / duplicate - /// ids, empty lists, or both-widths-set degrade that table to its built-in - /// default columns with a warning listing valid ids, so a typo never - /// blocks app launch. - pub fn load_tablesconfig(&mut self, tables: TablesConfigString) { - // Each table is validated against its registry of valid column ids (kept - // in the rendering layer). Empty specs are dropped (== built-in defaults). - let load = |table: &'static str, specs: Option<Vec<ColumnSpec>>| -> Vec<ColumnSpec> { - match resolve_table_specs(table, specs) { - Ok(specs) => specs, - Err(e) => { - log::warn!("[config] {e}; using default columns"); - Vec::new() - } - } - }; - self.tables.songs = load("songs", tables.songs); - self.tables.album_tracks = load("album_tracks", tables.album_tracks); - self.tables.albums = load("albums", tables.albums); - self.tables.podcasts = load("podcasts", tables.podcasts); - self.tables.episodes = load("episodes", tables.episodes); - self.tables.recently_played = load("recently_played", tables.recently_played); - } - - /// Save the current configuration to the config file - pub fn save_config(&self) -> Result<()> { - let paths = match &self.path_to_config { - Some(path) => path, - None => return Err(anyhow!("Config path not initialized")), - }; - - // Helper to build behavior config from current values - let build_behavior = || BehaviorConfigString { - seek_milliseconds: Some(self.behavior.seek_milliseconds), - volume_increment: Some(self.behavior.volume_increment), - volume_percent: self.behavior.volume_percent, - tick_rate_milliseconds: Some(self.behavior.tick_rate_milliseconds), - animation_tick_rate_milliseconds: Some(self.behavior.animation_tick_rate_milliseconds), - enable_text_emphasis: Some(self.behavior.enable_text_emphasis), - banner_gradient: Some(self.behavior.banner_gradient), - show_loading_indicator: Some(self.behavior.show_loading_indicator), - enforce_wide_search_bar: Some(self.behavior.enforce_wide_search_bar), - group_folders_first: Some(self.behavior.group_folders_first), - enable_global_song_count: Some(self.behavior.enable_global_song_count), - disable_mouse_inputs: Some(self.behavior.disable_mouse_inputs), - enable_discord_rpc: Some(self.behavior.enable_discord_rpc), - discord_rpc_client_id: self.behavior.discord_rpc_client_id.clone(), - enable_announcements: Some(self.behavior.enable_announcements), - announcement_feed_url: self.behavior.announcement_feed_url.clone(), - enable_monthly_recap_prompt: Some(self.behavior.enable_monthly_recap_prompt), - pin_community_playlist: Some(self.behavior.pin_community_playlist), - liked_icon: Some(self.behavior.liked_icon.clone()), - shuffle_icon: Some(self.behavior.shuffle_icon.clone()), - repeat_track_icon: Some(self.behavior.repeat_track_icon.clone()), - repeat_context_icon: Some(self.behavior.repeat_context_icon.clone()), - playing_icon: Some(self.behavior.playing_icon.clone()), - paused_icon: Some(self.behavior.paused_icon.clone()), - set_window_title: Some(self.behavior.set_window_title), - visualizer_style: Some(self.behavior.visualizer_style), - relay_server_url: Some(self.behavior.relay_server_url.clone()), - sync_token: self.behavior.sync_token.clone(), - local_music_path: self.behavior.local_music_path.clone(), - subsonic_url: self.behavior.subsonic_url.clone(), - subsonic_username: self.behavior.subsonic_username.clone(), - subsonic_password: self.behavior.subsonic_password.clone(), - ytdlp_path: self.behavior.ytdlp_path.clone(), - qobuz_quality: Some(self.behavior.qobuz_quality), - radio_stations: if self.behavior.radio_stations.is_empty() { - None - } else { - Some(self.behavior.radio_stations.clone()) - }, - stop_after_current_track: Some(self.behavior.stop_after_current_track), - startup_behavior: Some(self.behavior.startup_behavior), - disable_auto_update: Some(self.behavior.disable_auto_update), - auto_update_delay: Some(self.behavior.auto_update_delay.clone()), - #[cfg(feature = "art-decode")] - draw_cover_art: Some(self.behavior.draw_cover_art), - #[cfg(feature = "art-decode")] - draw_cover_art_forced: Some(self.behavior.draw_cover_art_forced), - #[cfg(feature = "art-decode")] - playbar_cover_art_size_percent: Some(self.behavior.playbar_cover_art_size_percent), - #[cfg(feature = "art-decode")] - cover_art_theme: Some(self.behavior.cover_art_theme), - #[cfg(feature = "cover-art")] - cover_art_dither: Some(self.behavior.cover_art_dither), - #[cfg(feature = "cover-art")] - cover_art_dither_algorithm: Some(self.behavior.cover_art_dither_algorithm.clone()), - #[cfg(feature = "cover-art")] - cover_art_dither_pixel_scale: Some(i64::from(normalize_cover_art_dither_pixel_scale( - i64::from(self.behavior.cover_art_dither_pixel_scale), - ))), - #[cfg(feature = "mcp-server")] - mcp_enabled: Some(self.behavior.mcp_enabled), - #[cfg(feature = "ai-dj")] - dj_backend: Some(self.behavior.dj_backend.clone()), - #[cfg(feature = "ai-dj")] - dj_agent_command: Some(self.behavior.dj_agent_command.clone()), - // Passed through rather than wrapped in `Some`: it is already an `Option`, - // and `null` on disk is what lets the preset decide. - #[cfg(feature = "ai-dj")] - dj_agent_prompt_via: self.behavior.dj_agent_prompt_via.clone(), - #[cfg(feature = "ai-dj")] - dj_agent_timeout_secs: Some(self.behavior.dj_agent_timeout_secs), - #[cfg(feature = "ai-dj")] - dj_agent_model: self.behavior.dj_agent_model.clone(), - #[cfg(feature = "ai-dj")] - dj_model: self.behavior.dj_model.clone(), - #[cfg(feature = "ai-dj")] - dj_base_url: self.behavior.dj_base_url.clone(), - #[cfg(feature = "ai-dj")] - dj_api_key: self.behavior.dj_api_key.clone(), - #[cfg(feature = "ai-dj")] - dj_batch_size: Some(self.behavior.dj_batch_size), - #[cfg(feature = "ai-dj")] - dj_history_period: Some(self.behavior.dj_history_period.clone()), - #[cfg(feature = "ai-dj")] - dj_avoid_library: Some(self.behavior.dj_avoid_library), - // Never `Some(false)`: an automatic save must not answer the picker's - // question on the user's behalf. - #[cfg(feature = "ai-dj")] - dj_configured: self.behavior.dj_configured, - keepawake_enabled: Some(self.behavior.keepawake_enabled), - enable_media_keys: Some(self.behavior.enable_media_keys), - // --- Phase 2/3/6 new fields (persist whatever the user set) --- - gauge_filled_icon: Some(self.behavior.gauge_filled_icon.clone()), - gauge_unfilled_icon: Some(self.behavior.gauge_unfilled_icon.clone()), - active_source_icon: Some(self.behavior.active_source_icon.clone()), - episode_played_icon: Some(self.behavior.episode_played_icon.clone()), - sort_ascending_icon: Some(self.behavior.sort_ascending_icon.clone()), - sort_descending_icon: Some(self.behavior.sort_descending_icon.clone()), - list_highlight_icon: Some(self.behavior.list_highlight_icon.clone()), - playbar_control_labels: if self.behavior.playbar_control_labels.is_empty() { - None - } else { - Some(self.behavior.playbar_control_labels.clone()) - }, - status_message_ttl_percent: Some(self.behavior.status_message_ttl_percent), - playback_poll_seconds: Some(self.behavior.playback_poll_seconds), - table_scroll_padding: Some(self.behavior.table_scroll_padding), - like_animation_frames: Some(self.behavior.like_animation_frames), - startup_route: Some(self.behavior.startup_route.clone()), - default_sort_playlist_tracks: Some(self.behavior.default_sort_playlist_tracks.clone()), - default_sort_saved_albums: Some(self.behavior.default_sort_saved_albums.clone()), - default_sort_saved_artists: Some(self.behavior.default_sort_saved_artists.clone()), - default_sort_recently_played: Some(self.behavior.default_sort_recently_played.clone()), - sidebar_position: Some(self.behavior.sidebar_position.clone()), - playbar_position: Some(self.behavior.playbar_position.clone()), - sidebar_width_percent: self.behavior.sidebar_width_percent, - playbar_height_rows: self.behavior.playbar_height_rows, - library_height_percent: self.behavior.library_height_percent, - small_terminal_width: Some(self.behavior.small_terminal_width), - small_terminal_height: Some(self.behavior.small_terminal_height), - }; - - // Helper to convert Key to config string - let key_to_config_string = |key: Key| -> String { - match key { - Key::Char(' ') => "space".to_string(), - Key::Char(c) => c.to_string(), - Key::Ctrl(c) => format!("ctrl-{}", c), - Key::Alt(c) => format!("alt-{}", c), - Key::Enter => "enter".to_string(), - Key::Tab => "tab".to_string(), - Key::Esc => "esc".to_string(), - Key::Backspace => "backspace".to_string(), - Key::Delete => "del".to_string(), - Key::Left => "left".to_string(), - Key::Right => "right".to_string(), - Key::Up => "up".to_string(), - Key::Down => "down".to_string(), - Key::Home => "home".to_string(), - Key::End => "end".to_string(), - Key::Ins => "ins".to_string(), - Key::PageUp => "pageup".to_string(), - Key::PageDown => "pagedown".to_string(), - Key::F0 => "f0".to_string(), - Key::F1 => "f1".to_string(), - Key::F2 => "f2".to_string(), - Key::F3 => "f3".to_string(), - Key::F4 => "f4".to_string(), - Key::F5 => "f5".to_string(), - Key::F6 => "f6".to_string(), - Key::F7 => "f7".to_string(), - Key::F8 => "f8".to_string(), - Key::F9 => "f9".to_string(), - Key::F10 => "f10".to_string(), - Key::F11 => "f11".to_string(), - Key::F12 => "f12".to_string(), - _ => "unknown".to_string(), - } - }; - - // Helper to build keybindings config from current values - let build_keybindings = || KeyBindingsString { - back: Some(key_to_config_string(self.keys.back)), - move_up: Some(key_to_config_string(self.keys.move_up)), - move_down: Some(key_to_config_string(self.keys.move_down)), - move_left: Some(key_to_config_string(self.keys.move_left)), - move_right: Some(key_to_config_string(self.keys.move_right)), - next_page: Some(key_to_config_string(self.keys.next_page)), - previous_page: Some(key_to_config_string(self.keys.previous_page)), - jump_to_start: Some(key_to_config_string(self.keys.jump_to_start)), - jump_to_end: Some(key_to_config_string(self.keys.jump_to_end)), - jump_to_album: Some(key_to_config_string(self.keys.jump_to_album)), - #[cfg(feature = "ai-dj")] - dj_open: Some(key_to_config_string(self.keys.dj_open)), - #[cfg(feature = "ai-dj")] - dj_toggle_auto_queue: Some(key_to_config_string(self.keys.dj_toggle_auto_queue)), - #[cfg(feature = "ai-dj")] - dj_vibe_shift: Some(key_to_config_string(self.keys.dj_vibe_shift)), - #[cfg(feature = "ai-dj")] - dj_toggle_fresh_only: Some(key_to_config_string(self.keys.dj_toggle_fresh_only)), - #[cfg(feature = "ai-dj")] - dj_pick_model: Some(key_to_config_string(self.keys.dj_pick_model)), - jump_to_artist_album: Some(key_to_config_string(self.keys.jump_to_artist_album)), - jump_to_context: Some(key_to_config_string(self.keys.jump_to_context)), - manage_devices: Some(key_to_config_string(self.keys.manage_devices)), - decrease_volume: Some(key_to_config_string(self.keys.decrease_volume)), - increase_volume: Some(key_to_config_string(self.keys.increase_volume)), - toggle_playback: Some(key_to_config_string(self.keys.toggle_playback)), - seek_backwards: Some(key_to_config_string(self.keys.seek_backwards)), - seek_forwards: Some(key_to_config_string(self.keys.seek_forwards)), - next_track: Some(key_to_config_string(self.keys.next_track)), - previous_track: Some(key_to_config_string(self.keys.previous_track)), - force_previous_track: Some(key_to_config_string(self.keys.force_previous_track)), - help: Some(key_to_config_string(self.keys.help)), - shuffle: Some(key_to_config_string(self.keys.shuffle)), - repeat: Some(key_to_config_string(self.keys.repeat)), - search: Some(key_to_config_string(self.keys.search)), - submit: Some(key_to_config_string(self.keys.submit)), - copy_song_url: Some(key_to_config_string(self.keys.copy_song_url)), - copy_album_url: Some(key_to_config_string(self.keys.copy_album_url)), - audio_analysis: Some(key_to_config_string(self.keys.audio_analysis)), - lyrics_view: Some(key_to_config_string(self.keys.lyrics_view)), - miniplayer_view: Some(key_to_config_string(self.keys.miniplayer_view)), - cover_art_view: Some(key_to_config_string(self.keys.cover_art_view)), - add_item_to_queue: Some(key_to_config_string(self.keys.add_item_to_queue)), - show_queue: Some(key_to_config_string(self.keys.show_queue)), - remove_from_queue: Some(key_to_config_string(self.keys.remove_from_queue)), - open_settings: Some(key_to_config_string(self.keys.open_settings)), - save_settings: Some(key_to_config_string(self.keys.save_settings)), - listening_party: Some(key_to_config_string(self.keys.listening_party)), - like_track: Some(key_to_config_string(self.keys.like_track)), - generate_recap: Some(key_to_config_string(self.keys.generate_recap)), - }; - - // Helper to build theme config from current values - let build_theme = || UserTheme { - preset: Some(self.current_preset.name().to_string()), - #[cfg(feature = "cover-art")] - cover_art_dither_color: self.cover_art_dither_color.map(color_to_string), - active: Some(color_to_string(self.custom_theme.active)), - banner: Some(color_to_string(self.custom_theme.banner)), - error_border: Some(color_to_string(self.custom_theme.error_border)), - error_text: Some(color_to_string(self.custom_theme.error_text)), - hint: Some(color_to_string(self.custom_theme.hint)), - hovered: Some(color_to_string(self.custom_theme.hovered)), - inactive: Some(color_to_string(self.custom_theme.inactive)), - playbar_background: Some(color_to_string(self.custom_theme.playbar_background)), - playbar_progress: Some(color_to_string(self.custom_theme.playbar_progress)), - playbar_progress_text: Some(color_to_string(self.custom_theme.playbar_progress_text)), - playbar_text: Some(color_to_string(self.custom_theme.playbar_text)), - selected: Some(color_to_string(self.custom_theme.selected)), - text: Some(color_to_string(self.custom_theme.text)), - background: Some(color_to_string(self.custom_theme.background)), - header: Some(color_to_string(self.custom_theme.header)), - highlighted_lyrics: Some(color_to_string(self.custom_theme.highlighted_lyrics)), - }; - - // If the file exists, try to read it first to preserve keybindings - let final_config = if paths.config_file_path.exists() { - let config_string = fs::read_to_string(&paths.config_file_path)?; - if !config_string.trim().is_empty() { - let mut existing: UserConfigString = serde_yaml::from_str(&config_string)?; - // Update behavior, theme, and keybindings - existing.behavior = Some(build_behavior()); - existing.theme = Some(build_theme()); - existing.keybindings = Some(build_keybindings()); - existing - } else { - UserConfigString { - keybindings: Some(build_keybindings()), - behavior: Some(build_behavior()), - theme: Some(build_theme()), - plugin_commands: None, - format: None, - tables: None, - } - } - } else { - UserConfigString { - keybindings: Some(build_keybindings()), - behavior: Some(build_behavior()), - theme: Some(build_theme()), - plugin_commands: None, - format: None, - tables: None, - } - }; - - // Serialize to a String/bytes first, then write via a private-file helper - // (0o600 on Unix — this file can carry the Subsonic password in - // cleartext, so it deserves the same protection as the Spotify token - // cache) using a temp-file + atomic rename, so a crash mid-write can't - // corrupt the config. Do not log `content_yml`: it may contain plaintext - // credentials. - let content_yml = serde_yaml::to_string(&final_config)?; - crate::core::auth::write_private_file_atomic(&paths.config_file_path, content_yml.as_bytes())?; - - Ok(()) - } - pub fn padded_liked_icon(&self) -> String { - format!("{} ", self.behavior.liked_icon) - } - - /// The configured `playing_icon` followed by a single trailing space, for - /// prepending to the title cell of the currently-playing row. Width-2 - /// (the icon is validated to one column at load time). - pub fn padded_playing_icon(&self) -> String { - format!("{} ", self.behavior.playing_icon) - } - #[cfg(feature = "art-decode")] - pub fn do_draw_cover_art(&self, full_image_support: bool) -> bool { - self.behavior.draw_cover_art && (self.behavior.draw_cover_art_forced || full_image_support) - } - - /// Whether anything needs the cover art fetched and decoded: the art pane, - /// or the adaptive theme, which extracts its palette from the decoded image - /// even when the pane itself is hidden (disabled, or a terminal without - /// image support). Draw sites keep their own `do_draw_cover_art` gate. - #[cfg(feature = "art-decode")] - pub fn needs_cover_art(&self, full_image_support: bool) -> bool { - self.do_draw_cover_art(full_image_support) || self.behavior.cover_art_theme - } -} - -/// Canonical valid column ids per table. This is the single source of truth -/// shared by config validation (here) and the rendering registry -/// (`tui::ui::columns`). Adding a column id means adding it here *and* to the -/// registry; the round-trip test guards the two staying in sync. -pub fn valid_column_ids(table: &str) -> &'static [&'static str] { - match table { - "songs" | "album_tracks" | "recently_played" => { - &["liked", "index", "title", "artist", "album", "length"] - } - "albums" => &["title", "artist", "date", "liked"], - "podcasts" => &["title", "publisher"], - "episodes" => &["played", "date", "title", "duration"], - _ => &[], - } -} - -/// Validate a single table's column specs: unknown id, duplicate id, empty -/// list, or both widths set are hard errors. An empty/absent list yields an -/// empty `Vec` (== built-in default columns at render time). -fn resolve_table_specs( - table: &'static str, - specs: Option<Vec<ColumnSpec>>, -) -> Result<Vec<ColumnSpec>> { - let Some(specs) = specs else { - return Ok(Vec::new()); - }; - let valid = valid_column_ids(table); - let mut seen: Vec<String> = Vec::new(); - let mut out = Vec::with_capacity(specs.len()); - for spec in specs { - if spec.id.trim().is_empty() { - return Err(anyhow!( - "tables.{table}: column with empty id (valid ids: {})", - valid.join(", ") - )); - } - let id = spec.id.trim().to_string(); - if !valid.contains(&id.as_str()) { - return Err(anyhow!( - "tables.{table}: unknown column id '{id}' (valid: {})", - valid.join(", ") - )); - } - if seen.iter().any(|s| s == &id) { - return Err(anyhow!("tables.{table}: duplicate column id '{id}'")); - } - if spec.width_percent.is_some() && spec.width.is_some() { - return Err(anyhow!( - "tables.{table}: column '{id}' sets both width_percent and width — pick one" - )); - } - if let Some(pct) = spec.width_percent { - if !(0.0..=100.0).contains(&pct) { - return Err(anyhow!( - "tables.{table}: column '{id}' width_percent {pct} out of range 0..=100" - )); - } - if pct == 0.0 { - return Err(anyhow!( - "tables.{table}: column '{id}' has width_percent 0 (it would be invisible) — remove the column instead" - )); - } - } - if spec.width == Some(0) { - return Err(anyhow!( - "tables.{table}: column '{id}' has width 0 (it would be invisible) — remove the column instead" - )); - } - seen.push(id.clone()); - out.push(ColumnSpec { - id, - header: spec - .header - .map(|h| h.trim().to_string()) - .filter(|h| !h.is_empty()), - width_percent: spec.width_percent, - width: spec.width, - }); - } - if out.is_empty() { - return Err(anyhow!( - "tables.{table}: column list must not be empty (omit the key to use defaults)" - )); - } - let percent_sum: f32 = out.iter().filter_map(|c| c.width_percent).sum(); - if percent_sum > 100.0 { - return Err(anyhow!( - "tables.{table}: width_percent values sum to {percent_sum} (must be <= 100) — trailing columns would be clipped" - )); - } - Ok(out) -} - -#[cfg(test)] -mod tests { - #[test] - fn removed_visualizer_styles_deserialize_as_cava() { - use super::VisualizerStyle; - // Configs written before the Equalizer style was removed (or before it - // was renamed from Classic) must keep loading, rolling over to Cava. - for old_name in ["Equalizer", "Classic", "Cava"] { - let style: VisualizerStyle = serde_yaml::from_str(old_name).unwrap(); - assert_eq!(style, VisualizerStyle::Cava, "{old_name}"); - } - let style: VisualizerStyle = serde_yaml::from_str("BarGraph").unwrap(); - assert_eq!(style, VisualizerStyle::BarGraph); - } - - #[test] - fn test_parse_key() { - use super::parse_key; - use crate::core::input::Key; - assert_eq!(parse_key(String::from("j")).unwrap(), Key::Char('j')); - assert_eq!(parse_key(String::from("J")).unwrap(), Key::Char('J')); - assert_eq!(parse_key(String::from("ctrl-j")).unwrap(), Key::Ctrl('j')); - assert_eq!(parse_key(String::from("ctrl-J")).unwrap(), Key::Ctrl('J')); - assert_eq!(parse_key(String::from("-")).unwrap(), Key::Char('-')); - assert_eq!(parse_key(String::from("esc")).unwrap(), Key::Esc); - assert_eq!(parse_key(String::from("del")).unwrap(), Key::Delete); - // Test new keys - assert_eq!(parse_key(String::from("enter")).unwrap(), Key::Enter); - assert_eq!(parse_key(String::from("tab")).unwrap(), Key::Tab); - assert_eq!(parse_key(String::from("home")).unwrap(), Key::Home); - assert_eq!(parse_key(String::from("end")).unwrap(), Key::End); - assert_eq!(parse_key(String::from("ins")).unwrap(), Key::Ins); - assert_eq!(parse_key(String::from("insert")).unwrap(), Key::Ins); - assert_eq!(parse_key(String::from("f0")).unwrap(), Key::F0); - assert_eq!(parse_key(String::from("f1")).unwrap(), Key::F1); - assert_eq!(parse_key(String::from("f2")).unwrap(), Key::F2); - assert_eq!(parse_key(String::from("f3")).unwrap(), Key::F3); - assert_eq!(parse_key(String::from("f4")).unwrap(), Key::F4); - assert_eq!(parse_key(String::from("f5")).unwrap(), Key::F5); - assert_eq!(parse_key(String::from("f6")).unwrap(), Key::F6); - assert_eq!(parse_key(String::from("f7")).unwrap(), Key::F7); - assert_eq!(parse_key(String::from("f8")).unwrap(), Key::F8); - assert_eq!(parse_key(String::from("f9")).unwrap(), Key::F9); - assert_eq!(parse_key(String::from("f10")).unwrap(), Key::F10); - assert_eq!(parse_key(String::from("f11")).unwrap(), Key::F11); - assert_eq!(parse_key(String::from("f12")).unwrap(), Key::F12); - } - - #[test] - fn malformed_modifier_bindings_error_instead_of_panicking() { - use super::parse_key; - // "ctrl"/"alt" without a key (with or without the dash) used to index or - // panic out of bounds during config load, aborting before the UI started; - // multi-character suffixes were silently truncated to their first char. - for bad in ["ctrl", "ctrl-", "ctrl-ab", "alt", "alt-", "alt-ab"] { - let err = parse_key(bad.to_string()).unwrap_err(); - assert!( - err.to_string().contains(bad), - "error for {bad:?} must name the binding: {err}" - ); - } - assert!(parse_key(String::new()).is_err()); - } - - #[test] - fn a_malformed_keybinding_keeps_the_default_and_still_loads() { - use super::{KeyBindingsString, UserConfig}; - use crate::core::input::Key; - - let mut config = UserConfig::new(); - let default_back = config.keys.back; - let bindings = KeyBindingsString { - back: Some("ctrl-".to_string()), - move_up: Some("w".to_string()), - ..KeyBindingsString::default() - }; - // The bad binding is warned about and skipped; the load succeeds and the - // valid binding in the same section still applies. - config.load_keybindings(bindings).unwrap(); - assert_eq!(config.keys.back, default_back); - assert_eq!(config.keys.move_up, Key::Char('w')); - } - - #[test] - fn banner_gradient_defaults_off_for_terminal_preset_unless_explicit() { - use super::{ThemePreset, UserConfig}; - - let mut config = UserConfig::new(); - - // No explicit config value: the preset decides - config.current_preset = ThemePreset::Terminal; - config.resolve_banner_gradient(None); - assert!(!config.behavior.banner_gradient); - - config.current_preset = ThemePreset::Default; - config.resolve_banner_gradient(None); - assert!(config.behavior.banner_gradient); - - // An explicit value always wins over the preset default - config.current_preset = ThemePreset::Terminal; - config.resolve_banner_gradient(Some(true)); - assert!(config.behavior.banner_gradient); - } - - /// Golden round-trip over a real-shaped `config.yml` fixture: the theme - /// parser is hand-rolled (not serde-derived on `Color`), so moving it to - /// `core/theme.rs` must keep the on-disk format byte-identical. Every value - /// string in the fixture has to come back out of the real save path - /// unchanged, and re-loading the saved file has to yield the same colors. - #[test] - fn theme_config_round_trips_byte_identically_through_save() { - use super::{ - color_to_string, parse_theme_item, ThemePreset, UserConfig, UserConfigPaths, - UserConfigString, UserTheme, - }; - use crate::core::theme::Color; - - let path = concat!(env!("CARGO_MANIFEST_DIR"), "/examples/theme_roundtrip.yml"); - let raw = std::fs::read_to_string(path).expect("theme fixture must exist"); - let fixture: UserConfigString = - serde_yaml::from_str(&raw).expect("theme fixture must deserialize"); - let fixture_theme = fixture.theme.expect("fixture must carry a theme section"); - - let mut config = UserConfig::new(); - config - .load_theme(fixture_theme.clone()) - .expect("fixture theme must load"); - assert_eq!(config.current_preset, ThemePreset::Custom); - - // Spot-check the parse: named colors and both "r, g, b" triples. - assert_eq!(config.theme.active, Color::Reset); - assert_eq!(config.theme.banner, Color::Black); - assert_eq!(config.theme.playbar_text, Color::Gray); - assert_eq!(config.theme.header, Color::Rgb(23, 43, 45)); - assert_eq!(config.theme.highlighted_lyrics, Color::Rgb(255, 145, 205)); - - // Save through the real path and compare the theme section field by - // field: every string must be byte-identical to the fixture's. - let dir = tempfile::tempdir().unwrap(); - let config_path = dir.path().join("config.yml"); - config.path_to_config = Some(UserConfigPaths { - config_file_path: config_path.clone(), - }); - config.save_config().unwrap(); - let saved_raw = std::fs::read_to_string(&config_path).unwrap(); - let saved: UserConfigString = serde_yaml::from_str(&saved_raw).unwrap(); - let saved_theme = saved.theme.expect("save_config must write the theme"); - let fields = |t: &UserTheme| { - [ - t.preset.clone(), - t.active.clone(), - t.banner.clone(), - t.error_border.clone(), - t.error_text.clone(), - t.hint.clone(), - t.hovered.clone(), - t.inactive.clone(), - t.playbar_background.clone(), - t.playbar_progress.clone(), - t.playbar_progress_text.clone(), - t.playbar_text.clone(), - t.selected.clone(), - t.text.clone(), - t.background.clone(), - t.header.clone(), - t.highlighted_lyrics.clone(), - ] - }; - assert_eq!(fields(&saved_theme), fields(&fixture_theme)); - - // Re-loading the saved file yields the same active theme. - let mut reloaded = UserConfig::new(); - reloaded.load_theme(saved_theme).unwrap(); - assert_eq!(reloaded.theme, config.theme); - - // The three named colors the 16 fixture fields could not carry, plus the - // fallback for `Indexed`, which has no on-disk representation today. - for color in [Color::LightBlue, Color::LightMagenta, Color::LightCyan] { - assert_eq!(parse_theme_item(&color_to_string(color)).unwrap(), color); - } - assert_eq!(color_to_string(Color::Indexed(42)), "Reset"); - } - - #[cfg(feature = "cover-art")] - #[test] - fn dither_behavior_loads_clamps_and_round_trips() { - use super::{BehaviorConfigString, UserConfig, UserConfigPaths, UserConfigString}; - let mut config = UserConfig::new(); - assert!(!config.behavior.cover_art_dither); - assert_eq!(config.behavior.cover_art_dither_algorithm, "stucki"); - assert_eq!(config.behavior.cover_art_dither_pixel_scale, 1); - assert_eq!( - super::normalize_cover_art_dither_algorithm("bayer8x8"), - "bayer8x8" - ); - assert_eq!( - super::normalize_cover_art_dither_algorithm("unknown"), - "stucki" - ); - let explicit_bayer: BehaviorConfigString = - serde_yaml::from_str("cover_art_dither_algorithm: bayer8x8").unwrap(); - config.load_behaviorconfig(explicit_bayer).unwrap(); - assert_eq!(config.behavior.cover_art_dither_algorithm, "bayer8x8"); - let behavior: BehaviorConfigString = serde_yaml::from_str( - "cover_art_dither: true\ncover_art_dither_algorithm: stucki\ncover_art_dither_pixel_scale: 4\n", - ).unwrap(); - config.load_behaviorconfig(behavior).unwrap(); - assert!(config.behavior.cover_art_dither); - assert_eq!(config.behavior.cover_art_dither_algorithm, "stucki"); - assert_eq!(config.behavior.cover_art_dither_pixel_scale, 3); - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.yml"); - config.path_to_config = Some(UserConfigPaths { - config_file_path: path.clone(), - }); - config.behavior.cover_art_dither_pixel_scale = 4; - config.save_config().unwrap(); - let saved: UserConfigString = - serde_yaml::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); - let behavior = saved.behavior.unwrap(); - assert_eq!(behavior.cover_art_dither, Some(true)); - assert_eq!( - behavior.cover_art_dither_algorithm.as_deref(), - Some("stucki") - ); - assert_eq!(behavior.cover_art_dither_pixel_scale, Some(3)); - let low: BehaviorConfigString = - serde_yaml::from_str("cover_art_dither_pixel_scale: -3").unwrap(); - config.load_behaviorconfig(low).unwrap(); - assert_eq!(config.behavior.cover_art_dither_pixel_scale, 1); - } - - #[cfg(feature = "cover-art")] - #[test] - fn dithering_does_not_bypass_terminal_cover_art_requirement() { - use super::UserConfig; - let mut config = UserConfig::new(); - config.behavior.cover_art_dither = true; - assert!(!config.do_draw_cover_art(false)); - config.behavior.draw_cover_art_forced = true; - assert!(config.do_draw_cover_art(false)); - config.behavior.draw_cover_art = false; - assert!(!config.do_draw_cover_art(false)); - } - - #[cfg(feature = "cover-art")] - #[test] - fn dither_color_is_independent_of_theme_preset_and_auto_clears_it() { - use super::{ThemePreset, UserConfig, UserTheme}; - use crate::core::theme::Color; - let mut config = UserConfig::new(); - let theme: UserTheme = - serde_yaml::from_str("preset: Nord\ncover_art_dither_color: '12, 34, 56'\n").unwrap(); - config.load_theme(theme).unwrap(); - assert_eq!(config.current_preset, ThemePreset::Nord); - assert_eq!(config.cover_art_dither_color, Some(Color::Rgb(12, 34, 56))); - let theme: UserTheme = - serde_yaml::from_str("preset: Gruvbox Light\ncover_art_dither_color: auto\n").unwrap(); - config.load_theme(theme).unwrap(); - assert_eq!(config.current_preset, ThemePreset::GruvboxLight); - assert_eq!(config.cover_art_dither_color, None); - } - - #[test] - fn test_reserved_key() { - use super::check_reserved_keys; - use crate::core::input::Key; - - assert!( - check_reserved_keys(Key::Enter).is_err(), - "Enter key should be reserved" - ); - } - - #[test] - fn test_startup_behavior_deserialization() { - use super::{BehaviorConfigString, StartupBehavior}; - - let config: BehaviorConfigString = serde_yaml::from_str("startup_behavior: pause").unwrap(); - assert_eq!(config.startup_behavior, Some(StartupBehavior::Pause)); - - let config: BehaviorConfigString = serde_yaml::from_str("startup_behavior: play").unwrap(); - assert_eq!(config.startup_behavior, Some(StartupBehavior::Play)); - - let config: BehaviorConfigString = serde_yaml::from_str("startup_behavior: continue").unwrap(); - assert_eq!(config.startup_behavior, Some(StartupBehavior::Continue)); - - // Missing field defaults to None (not overriding the config default) - let config: BehaviorConfigString = serde_yaml::from_str("{}").unwrap(); - assert_eq!(config.startup_behavior, None); - } - - #[test] - fn tick_rates_load_defaults_explicit_values_and_legacy_defaults() { - use super::{ - BehaviorConfigString, UserConfig, DEFAULT_ANIMATION_TICK_RATE_MILLISECONDS, - DEFAULT_TICK_RATE_MILLISECONDS, - }; - - for (yaml, expected_tick_rate, expected_animation_tick_rate) in [ - ( - "", - DEFAULT_TICK_RATE_MILLISECONDS, - DEFAULT_ANIMATION_TICK_RATE_MILLISECONDS, - ), - ( - "tick_rate_milliseconds: 500\nanimation_tick_rate_milliseconds: 20", - 500, - 20, - ), - ( - "tick_rate_milliseconds: 100", - 100, - DEFAULT_ANIMATION_TICK_RATE_MILLISECONDS, - ), - ( - "tick_rate_milliseconds: 16", - DEFAULT_TICK_RATE_MILLISECONDS, - DEFAULT_ANIMATION_TICK_RATE_MILLISECONDS, - ), - ( - "tick_rate_milliseconds: 16\nanimation_tick_rate_milliseconds: 16", - DEFAULT_TICK_RATE_MILLISECONDS, - DEFAULT_ANIMATION_TICK_RATE_MILLISECONDS, - ), - ] { - let behavior: BehaviorConfigString = serde_yaml::from_str(yaml).unwrap(); - let mut config = UserConfig::new(); - config.load_behaviorconfig(behavior).unwrap(); - - assert_eq!(config.behavior.tick_rate_milliseconds, expected_tick_rate); - assert_eq!( - config.behavior.animation_tick_rate_milliseconds, - expected_animation_tick_rate - ); - } - } - - #[test] - fn zero_tick_rates_are_rejected() { - use super::{BehaviorConfigString, UserConfig}; - - for yaml in [ - "tick_rate_milliseconds: 0", - "animation_tick_rate_milliseconds: 0", - ] { - let behavior: BehaviorConfigString = serde_yaml::from_str(yaml).unwrap(); - let mut config = UserConfig::new(); - - assert!(config.load_behaviorconfig(behavior).is_err()); - } - } - - #[test] - fn parse_update_delay_secs_accepts_supported_units() { - use super::parse_update_delay_secs; - - assert_eq!(parse_update_delay_secs("0"), Ok(0)); - assert_eq!(parse_update_delay_secs(""), Ok(0)); - assert_eq!(parse_update_delay_secs("7d"), Ok(7 * 86400)); - assert_eq!(parse_update_delay_secs("2h"), Ok(2 * 3600)); - assert_eq!(parse_update_delay_secs("10m"), Ok(10 * 60)); - assert_eq!(parse_update_delay_secs("30s"), Ok(30)); - assert_eq!(parse_update_delay_secs("120"), Ok(120)); - assert!(parse_update_delay_secs("bogus").is_err()); - } - - #[test] - fn invalid_auto_update_delay_is_rejected() { - use super::{BehaviorConfigString, UserConfig}; - - let behavior: BehaviorConfigString = serde_yaml::from_str("auto_update_delay: bogus").unwrap(); - let mut config = UserConfig::new(); - - assert!(config.load_behaviorconfig(behavior).is_err()); - } - - #[cfg(feature = "art-decode")] - #[test] - fn missing_playbar_cover_art_size_keeps_default() { - use super::{BehaviorConfigString, UserConfig}; - - let behavior: BehaviorConfigString = serde_yaml::from_str("{}").unwrap(); - let mut config = UserConfig::new(); - config.load_behaviorconfig(behavior).unwrap(); - - assert_eq!(config.behavior.playbar_cover_art_size_percent, 100); - } - - #[cfg(feature = "art-decode")] - #[test] - fn playbar_cover_art_size_loads_from_yaml() { - use super::{BehaviorConfigString, UserConfig}; - - let behavior: BehaviorConfigString = - serde_yaml::from_str("playbar_cover_art_size_percent: 150").unwrap(); - let mut config = UserConfig::new(); - config.load_behaviorconfig(behavior).unwrap(); - - assert_eq!(config.behavior.playbar_cover_art_size_percent, 150); - } - - #[cfg(feature = "art-decode")] - #[test] - fn playbar_cover_art_size_clamps_out_of_range_values() { - use super::{BehaviorConfigString, UserConfig}; - - let behavior: BehaviorConfigString = - serde_yaml::from_str("playbar_cover_art_size_percent: 10").unwrap(); - let mut config = UserConfig::new(); - config.load_behaviorconfig(behavior).unwrap(); - assert_eq!(config.behavior.playbar_cover_art_size_percent, 25); - - let behavior: BehaviorConfigString = - serde_yaml::from_str("playbar_cover_art_size_percent: 250").unwrap(); - config.load_behaviorconfig(behavior).unwrap(); - assert_eq!(config.behavior.playbar_cover_art_size_percent, 200); - } - - /// A config written by a build *with* the DJ features must still load in a - /// build without them, and vice versa. - /// - /// This is a real cross-build hazard, not a hypothetical: a user can save from - /// the Settings screen on an `ai-dj` build and then run a slim release binary. - /// `BehaviorConfigString` has no `deny_unknown_fields`, so serde ignores keys it - /// does not know — this test is what keeps that true if the derive ever changes. - #[test] - fn unknown_behavior_keys_are_ignored_rather_than_fatal() { - use super::{BehaviorConfigString, UserConfig}; - - // Every DJ/MCP key, as an `ai-dj` + `mcp-server` build would write them, - // alongside a key that no build has ever had. - let yaml = " -mcp_enabled: true -dj_backend: openai_compat -dj_agent_command: - - claude - - -p -dj_agent_prompt_via: stdin -dj_agent_timeout_secs: 120 -dj_agent_model: haiku -dj_model: some-model -dj_base_url: http://localhost:11434/v1 -dj_api_key: secret -dj_batch_size: 7 -dj_history_period: 7d -dj_configured: true -a_key_from_the_future: 42 -volume_increment: 5 -"; - let parsed: BehaviorConfigString = - serde_yaml::from_str(yaml).expect("unknown keys must not fail the parse"); - // The key every build does understand still came through. - assert_eq!(parsed.volume_increment, Some(5)); - - // And the merge accepts it without erroring. - let mut config = UserConfig::new(); - config - .load_behaviorconfig(parsed) - .expect("loading must not fail"); - assert_eq!(config.behavior.volume_increment, 5); - } - - /// The DJ keys a build *does* understand survive a save/load round trip. - #[cfg(feature = "ai-dj")] - #[test] - fn dj_behavior_keys_round_trip_through_yaml() { - use super::{BehaviorConfigString, UserConfig}; - - let mut config = UserConfig::new(); - config.behavior.dj_backend = "openai_compat".to_string(); - config.behavior.dj_batch_size = 7; - config.behavior.dj_history_period = "7d".to_string(); - config.behavior.dj_agent_command = vec!["codex".to_string()]; - config.behavior.dj_agent_prompt_via = Some("arg".to_string()); - config.behavior.dj_agent_model = Some("haiku".to_string()); - config.behavior.dj_agent_timeout_secs = 240; - config.behavior.dj_model = Some("some-model".to_string()); - config.behavior.dj_base_url = Some("http://localhost:1234/v1".to_string()); - config.behavior.dj_api_key = Some("sk-not-a-real-key".to_string()); - config.behavior.dj_avoid_library = true; - config.behavior.dj_configured = Some(true); - - // Mirrors what `save_config` writes, then reads it back. Every persisted DJ - // key belongs here: one left out is one a persistence regression keeps green. - let written = serde_yaml::to_string(&BehaviorConfigString { - dj_backend: Some(config.behavior.dj_backend.clone()), - dj_batch_size: Some(config.behavior.dj_batch_size), - dj_history_period: Some(config.behavior.dj_history_period.clone()), - dj_agent_command: Some(config.behavior.dj_agent_command.clone()), - dj_agent_prompt_via: config.behavior.dj_agent_prompt_via.clone(), - dj_agent_model: config.behavior.dj_agent_model.clone(), - dj_agent_timeout_secs: Some(config.behavior.dj_agent_timeout_secs), - dj_model: config.behavior.dj_model.clone(), - dj_base_url: config.behavior.dj_base_url.clone(), - dj_api_key: config.behavior.dj_api_key.clone(), - dj_avoid_library: Some(config.behavior.dj_avoid_library), - dj_configured: config.behavior.dj_configured, - ..Default::default() - }) - .unwrap(); - - let mut reloaded = UserConfig::new(); - reloaded - .load_behaviorconfig(serde_yaml::from_str(&written).unwrap()) - .unwrap(); - assert_eq!(reloaded.behavior.dj_backend, "openai_compat"); - assert_eq!(reloaded.behavior.dj_batch_size, 7); - assert_eq!(reloaded.behavior.dj_history_period, "7d"); - assert_eq!(reloaded.behavior.dj_agent_command, vec!["codex"]); - assert_eq!( - reloaded.behavior.dj_agent_prompt_via.as_deref(), - Some("arg") - ); - assert_eq!(reloaded.behavior.dj_agent_model.as_deref(), Some("haiku")); - assert_eq!(reloaded.behavior.dj_agent_timeout_secs, 240); - assert_eq!(reloaded.behavior.dj_model.as_deref(), Some("some-model")); - assert_eq!( - reloaded.behavior.dj_base_url.as_deref(), - Some("http://localhost:1234/v1") - ); - assert_eq!( - reloaded.behavior.dj_api_key.as_deref(), - Some("sk-not-a-real-key") - ); - assert!(reloaded.behavior.dj_avoid_library); - assert_eq!(reloaded.behavior.dj_configured, Some(true)); - } - - /// argv[0] is the program to exec, so a blank one is no command at all. - #[cfg(feature = "ai-dj")] - #[test] - fn a_dj_agent_command_without_a_program_keeps_the_default() { - let default = super::default_dj_agent_command(); - - let config = loaded("dj_agent_command:\n - ' '\n - -p"); - assert_eq!( - config.behavior.dj_agent_command, default, - "a blank argv[0] reaches spawn as a confusing ENOENT" - ); - - // A blank *later* argument is the user's business, so it still loads. - let config = loaded("dj_agent_command:\n - claude\n - ''"); - assert_eq!(config.behavior.dj_agent_command, vec!["claude", ""]); - } - - /// The reopen binding is really registered, not just declared. - /// - /// Several sites have to agree for a binding to work and a miss in any of them - /// fails silently. This covers the three that are reachable without a config file - /// on disk: the shipped default, the rebind path, and the named-action list that - /// stops a plugin shadowing it. - #[cfg(feature = "ai-dj")] - #[test] - fn the_dj_model_picker_binding_round_trips_and_is_reserved() { - use super::{KeyBindingsString, UserConfig}; - use crate::core::input::Key; - use std::collections::HashMap; - - let mut config = UserConfig::new(); - assert_eq!(config.keys.dj_pick_model, Key::Ctrl('g')); - - config - .load_keybindings(KeyBindingsString { - dj_pick_model: Some("ctrl-x".to_string()), - ..Default::default() - }) - .unwrap(); - assert_eq!(config.keys.dj_pick_model, Key::Ctrl('x')); - - // Named actions are off limits to Lua `plugin_commands`, or a plugin silently - // steals the binding. - let mut entries = HashMap::new(); - entries.insert("steal_it".to_string(), "ctrl-x".to_string()); - config.load_plugin_commands(entries); - assert!(config.plugin_command_keys.is_empty()); - } - - /// The exact YAML an existing install carries: every `dj_*` key present, every - /// one of them written by an automatic `save_config` rather than typed. - #[cfg(feature = "ai-dj")] - fn machine_written_dj_defaults() -> &'static str { - " -dj_backend: agent_cli -dj_agent_command: - - claude - - -p -dj_agent_prompt_via: stdin -dj_agent_timeout_secs: 90 -dj_batch_size: 6 -dj_history_period: 30d -dj_avoid_library: false -dj_model: null -dj_base_url: null -dj_api_key: null -" - } - - #[cfg(feature = "ai-dj")] - fn loaded(yaml: &str) -> super::UserConfig { - use super::{BehaviorConfigString, UserConfig}; - let mut config = UserConfig::new(); - config - .load_behaviorconfig(serde_yaml::from_str::<BehaviorConfigString>(yaml).unwrap()) - .unwrap(); - config - } - - #[cfg(feature = "ai-dj")] - #[test] - fn a_fresh_config_is_not_treated_as_a_configured_dj() { - use super::UserConfig; - assert!(!UserConfig::new().behavior.dj_is_configured()); - } - - #[cfg(feature = "ai-dj")] - #[test] - fn an_install_carrying_only_machine_written_dj_defaults_is_still_asked_once() { - // The test that keeps the predicate from silently excluding everybody: every - // key below is present on disk today, so presence can never be the signal. - // `dj_agent_prompt_via: stdin` in particular is the trap. - let config = loaded(machine_written_dj_defaults()); - assert!( - !config.behavior.dj_is_configured(), - "a config written entirely by save_config has not chosen anything" - ); - } - - #[cfg(feature = "ai-dj")] - #[test] - fn the_completion_marker_makes_a_default_config_count_as_configured() { - let yaml = format!("{}dj_configured: true\n", machine_written_dj_defaults()); - assert!(loaded(&yaml).behavior.dj_is_configured()); - } - - #[cfg(feature = "ai-dj")] - #[test] - fn a_hand_edited_dj_backend_counts_as_configured_without_the_marker() { - assert!(loaded("dj_backend: anthropic").behavior.dj_is_configured()); - assert!(loaded("dj_agent_command:\n - agy") - .behavior - .dj_is_configured()); - assert!(loaded("dj_agent_model: haiku").behavior.dj_is_configured()); - } - - #[cfg(feature = "ai-dj")] - #[test] - fn a_configured_api_key_counts_as_configured_without_the_marker() { - assert!(loaded("dj_api_key: sk-whatever") - .behavior - .dj_is_configured()); - assert!(loaded("dj_base_url: http://localhost:11434/v1") - .behavior - .dj_is_configured()); - } - - #[cfg(feature = "ai-dj")] - #[test] - fn tuning_the_dj_is_not_choosing_an_ai() { - // Batch size and the history window are knobs, not a backend choice, so a user - // who touched them is still owed the question. - let config = loaded("dj_batch_size: 3\ndj_history_period: 7d\ndj_avoid_library: true"); - assert!(!config.behavior.dj_is_configured()); - } - - #[cfg(feature = "ai-dj")] - #[test] - fn the_configured_marker_stays_null_until_something_sets_it() { - use super::{UserConfig, UserConfigPaths, UserConfigString}; - - // Through the real save path, because that is where the hazard is: `save_config` - // runs from volume changes and shutdown, and if it wrote `false` it would answer - // the picker's question on the user's behalf and pin the answer forever. - let dir = tempfile::tempdir().unwrap(); - let config_path = dir.path().join("config.yml"); - let mut config = UserConfig::new(); - config.path_to_config = Some(UserConfigPaths { - config_file_path: config_path.clone(), - }); - - config.save_config().unwrap(); - let on_disk = |path: &std::path::Path| -> super::BehaviorConfigString { - let raw = std::fs::read_to_string(path).unwrap(); - serde_yaml::from_str::<UserConfigString>(&raw) - .unwrap() - .behavior - .unwrap() - }; - let saved = on_disk(&config_path); - assert_eq!( - saved.dj_configured, None, - "an automatic save answers nothing" - ); - assert_eq!( - saved.dj_agent_prompt_via, None, - "and leaves the delivery mode for the preset to decide" - ); - - config.behavior.dj_configured = Some(true); - config.save_config().unwrap(); - assert_eq!(on_disk(&config_path).dj_configured, Some(true)); - } - - #[cfg(feature = "ai-dj")] - #[test] - fn an_invalid_dj_agent_prompt_via_keeps_the_previous_value() { - let config = loaded("dj_agent_prompt_via: carrier pigeon"); - assert_eq!( - config.behavior.dj_agent_prompt_via, None, - "unset stays unset, so the preset still decides" - ); - - let config = loaded("dj_agent_prompt_via: argv"); - assert_eq!( - config.behavior.dj_agent_prompt_via.as_deref(), - Some("arg"), - "the canonical form is stored, not what the user typed" - ); - } - - #[test] - fn plugin_commands_valid_entry_lands_in_plugin_command_keys() { - use super::UserConfig; - use crate::core::input::Key; - use std::collections::HashMap; - - let mut config = UserConfig::new(); - let mut entries = HashMap::new(); - // Ctrl+K rather than Ctrl+G: with `ai-dj` built in, Ctrl+G is `dj_pick_model` - // and `load_plugin_commands` correctly refuses to shadow a named action, so the - // old fixture would only have passed in the slim build. - entries.insert("toggle_lyrics".to_string(), "ctrl-k".to_string()); - config.load_plugin_commands(entries); - assert_eq!( - config.plugin_command_keys.get(&Key::Ctrl('k')), - Some(&"toggle_lyrics".to_string()) - ); - } - - #[test] - fn plugin_commands_reserved_key_is_skipped() { - use super::UserConfig; - use crate::core::input::Key; - use std::collections::HashMap; - - let mut config = UserConfig::new(); - let mut entries = HashMap::new(); - // Enter is a reserved key - entries.insert("submit_action".to_string(), "enter".to_string()); - config.load_plugin_commands(entries); - assert!(!config.plugin_command_keys.contains_key(&Key::Enter)); - } - - #[test] - fn plugin_commands_named_action_collision_is_skipped() { - use super::UserConfig; - use crate::core::input::Key; - use std::collections::HashMap; - - let mut config = UserConfig::new(); - // 'q' is the default 'back' key - let mut entries = HashMap::new(); - entries.insert("my_cmd".to_string(), "q".to_string()); - config.load_plugin_commands(entries); - assert!(!config.plugin_command_keys.contains_key(&Key::Char('q'))); - } - - #[test] - fn plugin_commands_remove_from_queue_collision_is_skipped() { - use super::UserConfig; - use crate::core::input::Key; - use std::collections::HashMap; - - let mut config = UserConfig::new(); - // 'x' is the default 'remove_from_queue' key; it was missing from the - // named-action collision list, so a plugin could shadow it silently. - let mut entries = HashMap::new(); - entries.insert("my_cmd".to_string(), "x".to_string()); - config.load_plugin_commands(entries); - assert!(!config.plugin_command_keys.contains_key(&Key::Char('x'))); - } - - #[test] - fn plugin_commands_invalid_key_string_is_skipped() { - use super::UserConfig; - use std::collections::HashMap; - - let mut config = UserConfig::new(); - let mut entries = HashMap::new(); - entries.insert("my_cmd".to_string(), "not-a-real-key".to_string()); - config.load_plugin_commands(entries); - assert!(config.plugin_command_keys.is_empty()); - } - - #[test] - fn sync_token_loads_as_user_config_and_trims_blank_to_none() { - use super::{BehaviorConfigString, UserConfig}; - - let behavior: BehaviorConfigString = serde_yaml::from_str("sync_token: ' token '\n").unwrap(); - let mut config = UserConfig::new(); - config.load_behaviorconfig(behavior).unwrap(); - assert_eq!(config.behavior.sync_token, Some("token".to_string())); - - let behavior: BehaviorConfigString = serde_yaml::from_str("sync_token: ' '\n").unwrap(); - config.load_behaviorconfig(behavior).unwrap(); - assert_eq!(config.behavior.sync_token, None); - } - - #[test] - fn volume_percent_loads_as_configured_runtime_default() { - use super::{BehaviorConfigString, UserConfig}; - - let behavior: BehaviorConfigString = serde_yaml::from_str("volume_percent: 150\n").unwrap(); - let mut config = UserConfig::new(); - config.load_behaviorconfig(behavior).unwrap(); - assert_eq!(config.behavior.volume_percent, Some(100)); - - let behavior: BehaviorConfigString = serde_yaml::from_str("{}").unwrap(); - let mut config = UserConfig::new(); - config.load_behaviorconfig(behavior).unwrap(); - assert_eq!(config.behavior.volume_percent, None); - } - - #[test] - fn pane_sizes_load_as_configured_runtime_defaults() { - use super::{BehaviorConfigString, UserConfig}; - - let behavior: BehaviorConfigString = serde_yaml::from_str( - r#" -sidebar_width_percent: 120 -playbar_height_rows: 9 -library_height_percent: 101 -"#, - ) - .unwrap(); - let mut config = UserConfig::new(); - config.load_behaviorconfig(behavior).unwrap(); - assert_eq!(config.behavior.sidebar_width_percent, Some(100)); - assert_eq!(config.behavior.playbar_height_rows, Some(9)); - assert_eq!(config.behavior.library_height_percent, Some(100)); - - let behavior: BehaviorConfigString = serde_yaml::from_str("{}").unwrap(); - let mut config = UserConfig::new(); - config.load_behaviorconfig(behavior).unwrap(); - assert_eq!(config.behavior.sidebar_width_percent, None); - assert_eq!(config.behavior.playbar_height_rows, None); - assert_eq!(config.behavior.library_height_percent, None); - } - - #[test] - fn radio_stations_load_as_user_config() { - use super::{BehaviorConfigString, UserConfig}; - use crate::core::state::RadioStationConfig; - - let behavior: BehaviorConfigString = serde_yaml::from_str( - r#" -radio_stations: - - name: " Groove Salad " - url: " https://ice1.somafm.com/groovesalad-128-mp3 " - - name: Duplicate - url: https://ice1.somafm.com/groovesalad-128-mp3 - - name: "" - url: https://blank-name.example/dropped -"#, - ) - .unwrap(); - let mut config = UserConfig::new(); - config.load_behaviorconfig(behavior).unwrap(); - assert_eq!( - config.behavior.radio_stations, - vec![RadioStationConfig { - name: "Groove Salad".to_string(), - url: "https://ice1.somafm.com/groovesalad-128-mp3".to_string(), - }] - ); - } - - #[test] - fn active_source_unknown_string_falls_back_to_spotify() { - use crate::core::source::Source; - - // Unknown/garbage strings must not panic and fall back to Spotify - assert_eq!(Source::from_config_str("Tidal"), Source::Spotify); - assert_eq!(Source::from_config_str(""), Source::Spotify); - assert_eq!(Source::from_config_str("local"), Source::Spotify); // case-sensitive - } - - #[test] - fn active_source_to_config_str_matches_from_config_str() { - use crate::core::source::Source; - - // Round-trip: to_config_str → from_config_str must be identity for both variants - assert_eq!( - Source::from_config_str(Source::Spotify.to_config_str()), - Source::Spotify - ); - assert_eq!( - Source::from_config_str(Source::Local.to_config_str()), - Source::Local - ); - } - - #[test] - fn example_config_loads_without_falling_back() { - use super::{UserConfig, UserConfigString}; - - // The shipped example must always be valid: it deserializes, and every - // section applies as written instead of degrading to defaults. - let path = concat!(env!("CARGO_MANIFEST_DIR"), "/examples/config.example.yml"); - let raw = std::fs::read_to_string(path).expect("example config must exist"); - let yml: UserConfigString = - serde_yaml::from_str(&raw).expect("example config must deserialize"); - - let mut config = UserConfig::new(); - if let Some(behavior) = yml.behavior { - config - .load_behaviorconfig(behavior) - .expect("example behavior section must load"); - } - if let Some(format) = yml.format { - config.load_formatconfig(format); - } - if let Some(tables) = yml.tables { - config.load_tablesconfig(tables); - } - - // Spot-check that the documented values were applied, not defaulted away. - assert_eq!(config.behavior.startup_route, "home"); - assert_eq!(config.behavior.playing_icon, "▶"); - // Every documented table resolves to its example columns (an empty Vec - // would mean that table's spec was rejected and fell back to defaults). - assert_eq!(config.tables.songs.len(), 5); - assert_eq!(config.tables.album_tracks.len(), 5); - assert_eq!(config.tables.albums.len(), 3); - assert_eq!(config.tables.podcasts.len(), 2); - assert_eq!(config.tables.episodes.len(), 4); - assert_eq!(config.tables.recently_played.len(), 4); - assert_eq!( - config.tables.songs[2].header.as_deref(), - Some("Band"), - "header override from the example must survive resolution" - ); - } - - #[test] - fn structural_behavior_errors_degrade_to_defaults_instead_of_failing_load() { - use super::{BehaviorConfigString, UserConfig}; - - // A two-column playing icon, an empty gauge icon, an invalid sort field, - // and an unknown playbar label key must all warn-and-fallback: the app - // must stay launchable on a config typo. - let yaml = r#" -playing_icon: "WW" -gauge_filled_icon: "" -default_sort_saved_albums: "dtae_added" -playbar_control_labels: - bogus_key: "x" - play_pause: "PLAY" -"#; - let behavior: BehaviorConfigString = serde_yaml::from_str(yaml).unwrap(); - let mut config = UserConfig::new(); - let defaults = UserConfig::new(); - - config.load_behaviorconfig(behavior).unwrap(); - - assert_eq!(config.behavior.playing_icon, defaults.behavior.playing_icon); - assert_eq!( - config.behavior.gauge_filled_icon, - defaults.behavior.gauge_filled_icon - ); - assert_eq!( - config.behavior.default_sort_saved_albums, - defaults.behavior.default_sort_saved_albums - ); - // The unknown key is skipped, the valid one is kept. - assert_eq!( - config.behavior.playbar_control_labels.get("play_pause"), - Some(&"PLAY".to_string()) - ); - assert!(!config - .behavior - .playbar_control_labels - .contains_key("bogus_key")); - } - - #[test] - fn playing_icon_is_width_validated_like_other_fixed_cell_icons() { - use super::{BehaviorConfigString, UserConfig}; - - let behavior: BehaviorConfigString = serde_yaml::from_str("playing_icon: \"»\"").unwrap(); - let mut config = UserConfig::new(); - config.load_behaviorconfig(behavior).unwrap(); - assert_eq!(config.behavior.playing_icon, "»"); - } - - #[test] - fn invalid_format_template_falls_back_to_default() { - use super::{FormatConfig, FormatConfigString, UserConfig}; - - let mut config = UserConfig::new(); - config.load_formatconfig(FormatConfigString { - window_title: Some("{bogus}".to_string()), - playbar_status: Some("{unbalanced".to_string()), - ..Default::default() - }); - - let defaults = FormatConfig::default(); - assert_eq!(config.format.window_title, defaults.window_title); - assert_eq!(config.format.playbar_status, defaults.playbar_status); - } - - #[test] - fn invalid_table_columns_fall_back_to_default_columns() { - use super::{ColumnSpec, TablesConfigString, UserConfig}; - - let mut config = UserConfig::new(); - config.load_tablesconfig(TablesConfigString { - songs: Some(vec![ColumnSpec { - id: "bogus".to_string(), - ..Default::default() - }]), - albums: Some(vec![ColumnSpec { - id: "title".to_string(), - ..Default::default() - }]), - ..Default::default() - }); - - // The bad table degrades to defaults (empty == built-in columns); the - // valid table is kept. - assert!(config.tables.songs.is_empty()); - assert_eq!(config.tables.albums.len(), 1); - } - - #[test] - fn column_spec_missing_id_is_recoverable_not_a_parse_error() { - use super::{TablesConfigString, UserConfig}; - - // Missing `id` must not fail YAML deserialization of the whole config; - // it degrades that table to defaults during resolution. - let tables: TablesConfigString = serde_yaml::from_str("songs:\n - { width_percent: 40 }\n") - .expect("missing id must not fail deserialization"); - let mut config = UserConfig::new(); - config.load_tablesconfig(tables); - assert!(config.tables.songs.is_empty()); - } - - #[test] - fn table_specs_reject_zero_and_oversubscribed_widths() { - use super::{resolve_table_specs, ColumnSpec}; - - let col = |id: &str, pct: Option<f32>, width: Option<u16>| ColumnSpec { - id: id.to_string(), - header: None, - width_percent: pct, - width, - }; - - assert!(resolve_table_specs("songs", Some(vec![col("title", Some(0.0), None)])).is_err()); - assert!(resolve_table_specs("songs", Some(vec![col("title", None, Some(0))])).is_err()); - assert!(resolve_table_specs( - "songs", - Some(vec![ - col("title", Some(70.0), None), - col("artist", Some(70.0), None) - ]) - ) - .is_err()); - // A valid subset still resolves. - assert!(resolve_table_specs( - "songs", - Some(vec![ - col("title", Some(60.0), None), - col("artist", Some(40.0), None) - ]) - ) - .is_ok()); - } -} diff --git a/src/gates.rs b/src/gates.rs deleted file mode 100644 index b8b0a588..00000000 --- a/src/gates.rs +++ /dev/null @@ -1,464 +0,0 @@ -//! Ratchet counters for the GUI substrate migration: each counter measures a -//! form of TUI/core coupling that must reach zero before a second frontend -//! can share the core (`crate::tui` imports outside `tui/`, direct `App` -//! field writes in handlers, raw `IoEvent` dispatch from the TUI, mouse -//! handling that synthesizes keystrokes, wildcard arms in the action tree, -//! producers outside the TUI that write its `App::view` presentation state, -//! `App` fields any module can still write, reads of the cached Spotify -//! playback context that bypass the ownership resolver). -//! -//! `tools/gates.count` holds the measured baselines, and the test below pins -//! every counter to its baseline exactly, so a PR that moves a number must -//! also move the file. The direction is enforced by -//! `tools/check_gates_ratchet.sh` against the merge base: coupling counters -//! may only fall, while the two adoption counters may only rise. -//! `test_attribute_total` is the first adoption counter, so a refactor cannot -//! silently delete a test module's worth of tests (it is a text count over -//! `src/`, so it catches deletions, not a module that still compiles under a -//! stale feature gate but stops running). `action_refs_in_tui_handlers` is -//! the second: shared `Action::` uses in production handler code, which must -//! rise with every handler conversion. Without it a conversion could replace -//! field writes with arbitrary `App` method calls and never adopt the shared -//! vocabulary. - -use std::collections::BTreeMap; -use std::fs; -use std::path::{Path, PathBuf}; - -fn repo_root() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) -} - -fn collect_rs_files(dir: &Path, out: &mut Vec<PathBuf>) { - let Ok(entries) = fs::read_dir(dir) else { - return; - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - collect_rs_files(&path, out); - } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") { - out.push(path); - } - } -} - -fn read_source(path: &Path) -> String { - fs::read_to_string(path).unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display())) -} - -/// Sums non-overlapping occurrences of `needle` across every `.rs` file under -/// `dirs` (recursive) plus the standalone `files`. -fn count_occurrences(dirs: &[&str], files: &[&str], needle: &str) -> usize { - let root = repo_root(); - let mut paths = Vec::new(); - for dir in dirs { - collect_rs_files(&root.join(dir), &mut paths); - } - for file in files { - paths.push(root.join(file)); - } - paths - .iter() - .map(|path| read_source(path).matches(needle).count()) - .sum() -} - -fn is_ident_byte(byte: u8) -> bool { - byte.is_ascii_alphanumeric() || byte == b'_' -} - -/// True when the byte before `pos` continues an identifier, so a needle found -/// at `pos` is the tail of a longer name (`wrapped_app.`, `PlaybackAction::`). -fn preceded_by_ident(source: &str, pos: usize) -> bool { - pos > 0 && is_ident_byte(source.as_bytes()[pos - 1]) -} - -/// Walks the `field(.field)*` chain that starts at `chain_start` and reports -/// whether an assignment operator follows it on the same line. False when no -/// field name starts there. Comparisons (`==`), match guards (`=>`), method -/// calls, and indexed writes are not writes. This is the exact matcher the -/// write baselines were measured with. -fn chain_is_written(source: &str, chain_start: usize) -> bool { - let bytes = source.as_bytes(); - let mut pos = chain_start; - loop { - let ident_start = pos; - while pos < bytes.len() && is_ident_byte(bytes[pos]) { - pos += 1; - } - if pos == ident_start { - break; - } - if pos < bytes.len() && bytes[pos] == b'.' { - pos += 1; - continue; - } - break; - } - if pos == chain_start { - return false; // no field name after the prefix - } - // Only same-line spacing may separate the chain from the operator. - while pos < bytes.len() && (bytes[pos] == b' ' || bytes[pos] == b'\t') { - pos += 1; - } - match bytes.get(pos) { - Some(b'+' | b'-' | b'*' | b'/' | b'%' | b'|' | b'&' | b'^') => { - bytes.get(pos + 1) == Some(&b'=') - } - Some(shift @ (b'<' | b'>')) => { - bytes.get(pos + 1) == Some(shift) && bytes.get(pos + 2) == Some(&b'=') - } - Some(b'=') => !matches!(bytes.get(pos + 1), Some(b'=') | Some(b'>')), - _ => false, - } -} - -/// Counts reads of `App::current_playback_context`: a `.current_playback_context` -/// access outside a line comment that is not a whole-value assignment, so the -/// `= Some(..)` seeds in tests and the producers' whole-value writes do not -/// count. A nested write (`.is_playing = false`) and a `&mut` borrow through -/// the field do count: both reach into the cached context. A whole-value -/// assignment rustfmt wrapped onto the next line would count too; none -/// exists today. String literals are not stripped (no other counter does). -/// The cached context names the *suspended* Spotify track while a decoded -/// source or the native queue slot owns the sink, so every reader outside -/// the ownership resolver (`core/app/playback_routing.rs`), the snapshot -/// builder (`infra/media_metadata.rs`) and this file is a candidate for -/// reading the wrong track. No `#[cfg(test)]` cut: `tui/runner.rs` has production reads after -/// its test module, and every test occurrence is a write anyway. -fn count_playback_context_reads(source: &str) -> usize { - let needle = concat!(".current_", "playback_context"); - source - .lines() - .filter(|line| !line.trim_start().starts_with("//")) - .flat_map(|line| { - line.match_indices(needle).filter(move |&(start, needle)| { - // A whole-value assignment is a write; anything deeper (`.item`, - // `.as_ref()`, `.is_playing = ..`) reads through the field. - line.as_bytes().get(start + needle.len()) == Some(&b'.') - || !chain_is_written(line, start + 1) - }) - }) - .count() -} - -/// Counts direct `App` field writes: `app.<field>` (optionally a deeper -/// `.field` chain) followed on the same line by `=` or a compound assignment -/// operator. Chains into `app.view` (the TUI's own presentation state, see -/// `core::app::ViewState`) are not coupling and do not count. -fn count_app_field_writes(source: &str) -> usize { - source - .match_indices("app.") - .filter(|&(start, _)| !preceded_by_ident(source, start)) - .map(|(start, needle)| start + needle.len()) - .filter(|&chain_start| !source[chain_start..].starts_with("view.")) - .filter(|&chain_start| chain_is_written(source, chain_start)) - .count() -} - -/// Counts writes into `App::view`: `<receiver>.view.<field>` (optionally a -/// deeper chain) followed on the same line by an assignment operator. Measured -/// over every file outside `src/tui/` and `src/core/app/` (and this file), -/// tests included like the handler write counter: a producer that resets or -/// clamps a cursor after it replaces a list, and the test that pins that -/// behavior. Each one is a place where the TUI's presentation state leaks into -/// shared code; the end state is a producer-side revision the frontend reacts to. -fn count_view_field_writes(source: &str) -> usize { - let bytes = source.as_bytes(); - // A field access has a receiver directly before the dot; a `.view.` that - // opens a string literal or a line is not one. - let has_receiver = |start: usize| { - start > 0 && (is_ident_byte(bytes[start - 1]) || matches!(bytes[start - 1], b')' | b']')) - }; - source - .match_indices(".view.") - .filter(|&(start, _)| has_receiver(start)) - .filter(|&(start, needle)| chain_is_written(source, start + needle.len())) - .count() -} - -/// Counts shared `Action::` uses in the production half of one handler file: -/// the text before its first test-gated item (every handler keeps its tests in -/// one tail module, gated by `#[cfg(test)]` or `#[cfg(all(test, …))]`). The -/// match is word-bounded, so `PlaybackAction::` and similar do not count. -fn count_production_action_refs(source: &str) -> usize { - let tests_start = ["#[cfg(test)]", "#[cfg(all(test"] - .iter() - .filter_map(|gate| source.find(gate)) - .min() - .unwrap_or(source.len()); - let production = &source[..tests_start]; - production - .match_indices("Action::") - .filter(|&(start, _)| !preceded_by_ident(production, start)) - .count() -} - -/// Counts the fields of `pub struct App` that any module can write: `pub` and -/// `pub(crate)`. A `pub(super)` or private field is reached only through `App` -/// methods. The body is brace-matched from the struct header; a field is a -/// two-space-indented `[pub[(crate)] ]name:` line, with no space required -/// after the colon, because a field whose type wraps to the next line ends -/// at the colon. -fn count_public_app_fields(source: &str) -> usize { - let header = "pub struct App {"; - let start = source - .find(header) - .expect("src/core/app/mod.rs declares `pub struct App {`"); - let body = &source[start + header.len()..]; - let mut depth = 1usize; - let mut end = body.len(); - for (index, byte) in body.bytes().enumerate() { - match byte { - b'{' => depth += 1, - b'}' => { - depth -= 1; - if depth == 0 { - end = index; - break; - } - } - _ => {} - } - } - body[..end] - .lines() - .filter_map(|line| line.strip_prefix(" ")) - .filter(|rest| !rest.starts_with(' ')) - .filter(|rest| { - let name = rest - .strip_prefix("pub(crate) ") - .or_else(|| rest.strip_prefix("pub ")) - .unwrap_or(""); - let name_len = name.bytes().take_while(|byte| is_ident_byte(*byte)).count(); - name_len > 0 && name.as_bytes().get(name_len) == Some(&b':') - }) - .count() -} - -fn load_baselines() -> BTreeMap<String, usize> { - let path = repo_root().join("tools").join("gates.count"); - let text = read_source(&path); - let mut map = BTreeMap::new(); - for raw in text.lines() { - let line = raw.split('#').next().unwrap_or("").trim(); - if line.is_empty() { - continue; - } - let (key, value) = line - .split_once('=') - .unwrap_or_else(|| panic!("malformed line in tools/gates.count: {raw}")); - let value = value - .trim() - .parse::<usize>() - .unwrap_or_else(|_| panic!("bad number in tools/gates.count: {raw}")); - map.insert(key.trim().to_string(), value); - } - map -} - -#[test] -fn ratchet_counters_match_the_checked_in_baselines() { - let root = repo_root(); - - let mut handler_files = Vec::new(); - collect_rs_files(&root.join("src/tui/handlers"), &mut handler_files); - let (app_field_writes, action_refs) = - handler_files - .iter() - .fold((0usize, 0usize), |(writes, refs), path| { - let source = read_source(path); - ( - writes + count_app_field_writes(&source), - refs + count_production_action_refs(&source), - ) - }); - - // Needles that would otherwise count their own literal in this file are - // assembled with concat! (this file lives under the `src/` scan). - let test_attribute_total = count_occurrences(&["src"], &[], concat!("#[", "test]")) - + count_occurrences(&["src"], &[], concat!("#[", "tokio::test")); - - // Every Rust file outside `src/tui/` is in scope, so a new top-level module - // (or a future `src/gui.rs`) cannot re-import the TUI unseen. This file is - // the one exclusion: it names the needle in its own doc and data, and being - // test-only it cannot couple anything to `tui/`. - let mut non_tui_files = Vec::new(); - collect_rs_files(&root.join("src"), &mut non_tui_files); - let tui_dir = root.join("src").join("tui"); - let gates_file = root.join("src").join("gates.rs"); - non_tui_files.retain(|path| !path.starts_with(&tui_dir) && *path != gates_file); - // The view-write scan also skips `src/core/app/` (whose methods are `App`'s - // own presentation helpers): it counts every producer that still writes a - // TUI cursor. - let app_dir = root.join("src").join("core").join("app"); - let (crate_tui_refs, view_writes) = - non_tui_files - .iter() - .fold((0usize, 0usize), |(refs, writes), path| { - let source = read_source(path); - let view_writes = if path.starts_with(&app_dir) { - 0 - } else { - count_view_field_writes(&source) - }; - ( - refs + source.matches("crate::tui").count(), - writes + view_writes, - ) - }); - - // (name, measured value). Every counter must match its baseline exactly; - // the direction a baseline may move is enforced against the merge base by - // tools/check_gates_ratchet.sh. - let public_app_fields = count_public_app_fields(&read_source(&root.join("src/core/app/mod.rs"))); - - // Every Rust file under `src/` except the two that resolve playback from the - // raw field on purpose, and this file (its matcher test spells the needle). - let resolver_files = [ - app_dir.join("playback_routing.rs"), - root.join("src").join("infra").join("media_metadata.rs"), - gates_file.clone(), - ]; - let mut playback_reader_files = Vec::new(); - collect_rs_files(&root.join("src"), &mut playback_reader_files); - playback_reader_files.retain(|path| !resolver_files.contains(path)); - let playback_context_reads = playback_reader_files - .iter() - .map(|path| count_playback_context_reads(&read_source(path))) - .sum(); - - let measured: [(&str, usize); 10] = [ - ("crate_tui_refs_outside_tui", crate_tui_refs), - ("app_field_writes_in_tui_handlers", app_field_writes), - ( - "ioevent_refs_in_tui", - count_occurrences(&["src/tui"], &[], "IoEvent::"), - ), - // Both spellings of a synthesized keystroke. - ( - "synthetic_keys_in_mouse_handler", - count_occurrences(&[], &["src/tui/handlers/mouse.rs"], "handler(Key::") - + count_occurrences( - &[], - &["src/tui/handlers/mouse.rs"], - "handle_block_events(Key::", - ), - ), - ( - "wildcard_arms_in_action_tree", - count_occurrences(&["src/core/action"], &[], "_ =>"), - ), - ("view_writes_outside_tui", view_writes), - ("pub_fields_on_app", public_app_fields), - ("direct_playback_context_reads", playback_context_reads), - ("action_refs_in_tui_handlers", action_refs), - ("test_attribute_total", test_attribute_total), - ]; - - let mut baselines = load_baselines(); - let mut report = String::new(); - for (name, actual) in measured { - match baselines.remove(name) { - None => report.push_str(&format!( - "{name}: missing from tools/gates.count (measured {actual})\n" - )), - Some(baseline) if baseline != actual => report.push_str(&format!( - "{name}: baseline {baseline}, measured {actual}; move the baseline in \ - tools/gates.count in this same PR (tools/check_gates_ratchet.sh enforces \ - the direction it may move)\n" - )), - Some(_) => {} - } - } - for (name, baseline) in baselines { - report.push_str(&format!( - "{name} = {baseline}: unknown counter in tools/gates.count, remove or fix the name\n" - )); - } - assert!(report.is_empty(), "\nratchet violations:\n{report}"); -} - -#[test] -fn field_write_matcher_counts_assignment_operators() { - let src = "app.x = 1;\napp.a.b += 2;\napp.c -= 3;\napp.d *= 4;\napp.e <<= 1;\napp.f >>= 2;\n"; - assert_eq!(count_app_field_writes(src), 6); -} - -#[test] -fn field_write_matcher_ignores_comparisons_guards_calls_and_other_idents() { - let src = "if app.x == 1 {}\n\ - Key::Up if app.flag => {}\n\ - app.list.push(1);\n\ - foo(app.x);\n\ - if app.x != y {}\n\ - if app.x <= y {}\n\ - if app.x >= y {}\n\ - let s = app.y << 2;\n\ - wrapped_app.x = 1;\n"; - assert_eq!(count_app_field_writes(src), 0); -} - -#[test] -fn field_write_matcher_skips_view_chains() { - let src = "app.view.x = 1;\napp.view.a.b += 2;\napp.y = 3;\napp.viewer = 4;\n"; - assert_eq!(count_app_field_writes(src), 2); -} - -#[test] -fn view_write_matcher_needs_a_receiver_and_an_assignment() { - let src = "app.view.x = 1;\n\ - guard.view.a.b += 2;\n\ - if app.view.x == 1 {}\n\ - app.view.list.push(1);\n\ - let s = \".view.x = 1\";\n\ - foo(app.view.x);\n"; - assert_eq!(count_view_field_writes(src), 2); -} - -#[test] -fn playback_context_read_matcher_skips_whole_value_writes_and_comments() { - let src = "let item = self.current_playback_context.as_ref();\n\ - app.current_playback_context = Some(ctx);\n\ - if let Some(c) = &app.current_playback_context {}\n\ - ctx.current_playback_context.is_playing = false;\n\ - app.current_playback_context.item = None;\n\ - /// reads `app.current_playback_context` in a doc comment\n\ - let current_playback_context = None;\n"; - assert_eq!(count_playback_context_reads(src), 4); -} - -#[test] -fn public_field_matcher_counts_pub_and_pub_crate_fields_only() { - let src = "pub struct App {\n\ - \x20 pub a: u8,\n\ - \x20 pub(crate) b: u8,\n\ - \x20 pub(super) c: u8,\n\ - \x20 d: u8,\n\ - \x20 /// pub e: u8 in a doc comment\n\ - \x20 #[cfg(feature = \"x\")]\n\ - \x20 pub wrapped:\n\ - \x20 Option<u8>,\n\ - \x20 pub view: ViewState,\n\ - }\n\ - pub struct Other {\n\ - \x20 pub z: u8,\n\ - }\n"; - assert_eq!(count_public_app_fields(src), 4); -} - -#[test] -fn action_ref_matcher_is_word_bounded_and_stops_at_the_test_module() { - let src = "app.apply(Action::Play);\n\ - let x = PlaybackAction::Pause;\n\ - app.apply(Action::Next)?;\n\ - #[cfg(test)]\n\ - mod tests { fn t() { app.apply(Action::Play); } }\n"; - assert_eq!(count_production_action_refs(src), 2); - let gated = "app.apply(Action::Play);\n\ - #[cfg(all(test, feature = \"x\"))]\n\ - mod tests { fn t() { app.apply(Action::Play); } }\n"; - assert_eq!(count_production_action_refs(gated), 1); -} diff --git a/src/infra/audio/analyzer.rs b/src/infra/audio/analyzer.rs deleted file mode 100644 index b2387131..00000000 --- a/src/infra/audio/analyzer.rs +++ /dev/null @@ -1,279 +0,0 @@ -use std::sync::{Arc, Mutex}; - -use super::cavacore::{ - max_bars_per_channel, Cava, CavaBuilder, Channels, DEFAULT_NOISE_REDUCTION, MAX_SAMPLE_RATE, -}; - -/// cavacore was written for 16-bit-scale samples (cava's own f32 input path -/// multiplies by USHRT_MAX); cpal/PipeWire deliver f32 in [-1, 1], so scale up -/// before the f64 cast or autosens ramps against a mis-scaled noise floor. -const SAMPLE_SCALE: f64 = 32768.0; - -/// Cap on cavacore's autosens gain. Uncapped autosens (cava's behavior) -/// normalizes ANY input level to full-scale bars given a few seconds; the cap -/// (~+15 dB of boost) still evens out track-to-track loudness but keeps -/// genuinely quiet audio rendering short bars instead of climbing to 100%. -const MAX_AUTOSENS_GAIN: f64 = 6.0; - -/// Spectrum data for visualization -#[derive(Clone, Debug, Default)] -pub struct SpectrumData { - /// Normalized bars (0.0-1.0) in cava's default stereo display order: left - /// channel reversed on the left half (treble at the outer edge, bass at the - /// center), right channel forward on the right half. - pub bands: Vec<f32>, -} - -/// Audio analyzer wrapping the vendored cavacore engine: log-spaced per-bar -/// cutoffs over bass/mid/treble FFTs, integral smoothing, gravity fall-off and -/// autosens (one persistent gain, fast duck / slow recovery). Runs in stereo -/// and mirrors the two channels like cava's default display. -pub struct AudioAnalyzer { - cava: Cava, - sample_rate: u32, - /// Bars per audio channel; the display shows twice this, mirrored. - bars_per_channel: usize, - /// Interleaved stereo 16-bit-scale samples awaiting the next `process()`. - pending: Vec<f64>, - /// Raw engine output: [left 0..bpc][right bpc..2bpc], each bass to treble. - output: Box<[f64]>, - spectrum: SpectrumData, -} - -impl AudioAnalyzer { - pub fn new(sample_rate: u32, display_bars: usize) -> Self { - let sample_rate = clamp_sample_rate(sample_rate); - Self::with_plan( - sample_rate, - clamp_bars_per_channel(sample_rate, display_bars / 2), - ) - } - - /// Build the cavacore plan and the buffers it sizes. Both setters below go - /// through here, so a rebuild starts from exactly the same state a fresh - /// analyzer does - which is also what cava does on resize. - fn with_plan(sample_rate: u32, bars_per_channel: usize) -> Self { - let cava = build_cava(sample_rate, bars_per_channel); - let output = cava.make_output(); - - Self { - spectrum: SpectrumData { - bands: vec![0.0; output.len()], - }, - pending: Vec::new(), - output, - cava, - sample_rate, - bars_per_channel, - } - } - - /// Rebuild for a new display bar count (terminal resize or style switch). - pub fn set_bars(&mut self, display_bars: usize) { - let bars_per_channel = clamp_bars_per_channel(self.sample_rate, display_bars / 2); - if bars_per_channel != self.bars_per_channel { - *self = Self::with_plan(self.sample_rate, bars_per_channel); - } - } - - /// Adopt the rate the audio server actually negotiated. Only the PipeWire - /// backend renegotiates after construction, so cpal builds never call this. - #[allow(dead_code)] - pub fn set_sample_rate(&mut self, sample_rate: u32) { - let sample_rate = clamp_sample_rate(sample_rate); - if sample_rate != self.sample_rate { - let bars_per_channel = clamp_bars_per_channel(sample_rate, self.bars_per_channel); - *self = Self::with_plan(sample_rate, bars_per_channel); - } - } - - /// Push interleaved capture frames. Owning the channel rule here keeps both - /// capture backends from restating it: channels 0/1 pass through to cava's - /// stereo pipeline, a mono device duplicates. Deinterleaving and the 16-bit - /// scaling happen in one pass into `pending`, so callers on the audio - /// thread need no temporary buffer. - pub fn push_frames(&mut self, data: &[f32], channels: usize) { - let channels = channels.max(1); - for frame in data.chunks_exact(channels) { - let left = frame[0]; - let right = if channels > 1 { frame[1] } else { left }; - self.pending.push(f64::from(left) * SAMPLE_SCALE); - self.pending.push(f64::from(right) * SAMPLE_SCALE); - } - - // Keep only the newest samples, matching cavacore's own sliding window, so - // a stalled UI cannot grow the backlog. Whole frames in means the cap (an - // even number) stays channel-aligned. - let max_pending = self.cava.input_len(); - if self.pending.len() > max_pending { - let excess = self.pending.len() - max_pending; - self.pending.drain(..excess); - } - } - - /// Process buffered samples and update spectrum - pub fn process(&mut self) -> SpectrumData { - // Zero new samples still executes: cavacore keeps applying gravity and the - // integral filter to the stale window, which is what animates bars falling - // after pause instead of freezing them. - self.cava.execute(&self.pending, &mut self.output); - self.pending.clear(); - - mirror_stereo_bands(&self.output, &mut self.spectrum.bands); - - self.spectrum.clone() - } -} - -/// cava.c's default stereo display order (its `p.stereo` mirroring loop with -/// `reverse = 0`): display position n < bars/2 shows the left channel at index -/// bars/2 - n - 1 (reversed), position n >= bars/2 shows the right channel at -/// index n - bars/2 (forward). Bass meets in the middle. -fn mirror_stereo_bands(output: &[f64], bands: &mut [f32]) { - let bars_per_channel = output.len() / 2; - for n in 0..bars_per_channel { - bands[n] = output[bars_per_channel - n - 1].clamp(0.0, 1.0) as f32; - bands[bars_per_channel + n] = output[bars_per_channel + n].clamp(0.0, 1.0) as f32; - } -} - -/// Floor 4 (not 1): build_cava caps `freq_end` at Nyquist but never below 2, -/// so any admitted rate must keep Nyquist >= 2 or the builder's sanity check -/// rejects the range and the expect below panics. -fn clamp_sample_rate(sample_rate: u32) -> u32 { - sample_rate.clamp(4, MAX_SAMPLE_RATE) -} - -fn clamp_bars_per_channel(sample_rate: u32, bars_per_channel: usize) -> usize { - bars_per_channel.clamp(1, max_bars_per_channel(sample_rate)) -} - -fn build_cava(sample_rate: u32, bars_per_channel: usize) -> Cava { - // cava's default range, with the top capped at Nyquist for low-rate devices - // (and the start kept under the top for absurdly low ones). - let freq_end = 10_000.min(sample_rate / 2); - let freq_start = 50.min(freq_end - 1).max(1); - - CavaBuilder::default() - .bars_per_channel(bars_per_channel) - .sample_rate(sample_rate) - .audio_channels(Channels::Stereo) - .enable_autosens(true) - .max_sens(MAX_AUTOSENS_GAIN) - .noise_reduction(DEFAULT_NOISE_REDUCTION) - .frequency_range(freq_start..freq_end) - .build() - .expect("cavacore build with pre-clamped parameters") -} - -/// Thread-safe wrapper for AudioAnalyzer -pub type SharedAnalyzer = Arc<Mutex<AudioAnalyzer>>; - -pub fn create_shared_analyzer(sample_rate: u32, display_bars: usize) -> SharedAnalyzer { - Arc::new(Mutex::new(AudioAnalyzer::new(sample_rate, display_bars))) -} - -/// Resize to `desired_bars` and read the next frame of spectrum data. Both -/// capture backends share this so the "resize before you read" ordering is -/// stated once. -pub fn spectrum_for(analyzer: &SharedAnalyzer, desired_bars: usize) -> Option<SpectrumData> { - let mut analyzer = analyzer.lock().ok()?; - analyzer.set_bars(desired_bars); - Some(analyzer.process()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn push_scales_samples_to_16_bit_range() { - let mut analyzer = AudioAnalyzer::new(48_000, 12); - analyzer.push_frames(&[1.0, -0.5], 2); - assert_eq!(analyzer.pending, vec![32_768.0, -16_384.0]); - } - - #[test] - fn mono_capture_frames_are_duplicated_to_both_channels() { - let mut analyzer = AudioAnalyzer::new(48_000, 12); - analyzer.push_frames(&[1.0, -0.5], 1); - assert_eq!( - analyzer.pending, - vec![32_768.0, 32_768.0, -16_384.0, -16_384.0] - ); - } - - #[test] - fn extra_capture_channels_are_dropped_after_the_stereo_pair() { - let mut analyzer = AudioAnalyzer::new(48_000, 12); - // One 5.1 frame: only front left/right reach the engine. - analyzer.push_frames(&[1.0, -1.0, 0.5, 0.25, 0.125, 0.0625], 6); - assert_eq!(analyzer.pending, vec![32_768.0, -32_768.0]); - } - - #[test] - fn pending_backlog_keeps_the_newest_samples() { - let mut analyzer = AudioAnalyzer::new(48_000, 12); - let max_pending = analyzer.cava.input_len(); - analyzer.push_frames(&vec![0.0; max_pending], 2); - analyzer.push_frames(&[1.0, 1.0], 2); - - assert_eq!(analyzer.pending.len(), max_pending); - assert_eq!(analyzer.pending.last(), Some(&32_768.0)); - } - - #[test] - fn process_drains_pending_and_reports_requested_bars() { - let mut analyzer = AudioAnalyzer::new(48_000, 12); - analyzer.push_frames(&[0.25; 512], 2); - - let spectrum = analyzer.process(); - - assert!(analyzer.pending.is_empty()); - assert_eq!(spectrum.bands.len(), 12); - } - - #[test] - fn set_bars_rebuilds_the_plan_for_the_new_count() { - let mut analyzer = AudioAnalyzer::new(48_000, 12); - analyzer.set_bars(40); - assert_eq!(analyzer.process().bands.len(), 40); - } - - #[test] - fn bands_are_mirrored_like_cavas_stereo_display() { - // Engine output: [left: 0.1, 0.2, 0.3][right: 0.4, 0.5, 0.6]. - let mut bands = vec![0.0f32; 6]; - mirror_stereo_bands(&[0.1, 0.2, 0.3, 0.4, 0.5, 0.6], &mut bands); - - // Left half reversed (treble at the outer edge), right half forward. - assert_eq!(bands, vec![0.3, 0.2, 0.1, 0.4, 0.5, 0.6]); - } - - #[test] - fn bars_are_clamped_to_what_the_sample_rate_supports() { - // 8 kHz -> treble buffer 128 -> at most 65 bars per channel. - let analyzer = AudioAnalyzer::new(8_000, 512); - assert_eq!(analyzer.bars_per_channel, 65); - assert_eq!(analyzer.spectrum.bands.len(), 130); - } - - #[test] - fn degenerate_sample_rates_never_panic_the_builder() { - for rate in [0, 1, 2, 3, 4, 7_999] { - let mut analyzer = AudioAnalyzer::new(rate, 12); - analyzer.set_sample_rate(5); - let _ = analyzer.process(); - } - } - - #[test] - fn sample_rate_change_rebuilds_without_panicking() { - let mut analyzer = AudioAnalyzer::new(48_000, 12); - analyzer.push_frames(&[0.5; 256], 2); - analyzer.set_sample_rate(44_100); - - let spectrum = analyzer.process(); - assert_eq!(spectrum.bands.len(), 12); - } -} diff --git a/src/infra/audio/capture.rs b/src/infra/audio/capture.rs deleted file mode 100644 index 3e03028d..00000000 --- a/src/infra/audio/capture.rs +++ /dev/null @@ -1,204 +0,0 @@ -use super::analyzer::{create_shared_analyzer, spectrum_for, SharedAnalyzer, SpectrumData}; -use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; -#[cfg(not(target_os = "windows"))] -use cpal::BufferSize; -use cpal::{Device, Stream, StreamConfig}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; - -/// Manages audio capture from system output (loopback) -pub struct AudioCaptureManager { - _stream: Stream, - analyzer: SharedAnalyzer, - active: Arc<AtomicBool>, -} - -impl AudioCaptureManager { - /// Create a new audio capture manager sized for `display_bars` - /// Returns None if no suitable audio device is found - pub fn new(display_bars: usize) -> Option<Self> { - let host = cpal::default_host(); - - // Try to find a loopback/monitor device - let device = Self::find_loopback_device(&host)?; - - // Device name is now stored internally for debugging if needed - let _device_name = device - .description() - .ok() - .map(|description| description.name().to_string()); - - // Get a compatible config that won't interfere with playback - let config = Self::get_compatible_config(&device)?; - - let analyzer = create_shared_analyzer(config.sample_rate, display_bars); - let active = Arc::new(AtomicBool::new(true)); - - let stream = Self::build_stream(&device, &config, analyzer.clone(), active.clone())?; - - // Start the stream - if stream.play().is_err() { - return None; - } - - Some(Self { - _stream: stream, - analyzer, - active, - }) - } - - /// Get the current spectrum data, sized to `desired_bars` (the analyzer - /// rebuilds its plan when the count changes, and clamps unsupportable counts) - pub fn get_spectrum(&self, desired_bars: usize) -> Option<SpectrumData> { - if !self.active.load(Ordering::Relaxed) { - return None; - } - - spectrum_for(&self.analyzer, desired_bars) - } - - /// Check if audio capture is currently active - pub fn is_active(&self) -> bool { - self.active.load(Ordering::Relaxed) - } - - /// Find a suitable loopback/monitor device - /// Prefers: default output monitor > bluetooth > speakers > HDMI - fn find_loopback_device(host: &cpal::Host) -> Option<Device> { - #[cfg(target_os = "windows")] - { - // On Windows, WASAPI supports loopback on output devices - host.default_output_device() - } - - #[cfg(target_os = "linux")] - { - // On Linux with PipeWire/PulseAudio, look for a monitor device - // Priority: bluetooth first, then speakers, then anything else - if let Ok(devices) = host.input_devices() { - let mut monitors: Vec<Device> = Vec::new(); - - // Scan available input devices - - for device in devices { - if let Ok(description) = device.description() { - let name_lower = description.name().to_lowercase(); - if name_lower.contains("monitor") { - monitors.push(device); - } - } - } - - // If no monitors found, will fall through to default input device - - // Sort by priority: bluetooth first, then speakers, then anything else - monitors.sort_by_key(|d| { - let name_lower = d - .description() - .map(|description| description.name().to_lowercase()) - .unwrap_or_default(); - if name_lower.contains("bluez") || name_lower.contains("bluetooth") { - return 0; // Highest priority - likely the active wireless device - } - if name_lower.contains("speaker") || name_lower.contains("analog") { - return 1; // Second priority - built-in speakers - } - if name_lower.contains("hdmi") { - return 3; // Low priority - usually not used for music - } - 2 // Default priority - }); - - if let Some(device) = monitors.into_iter().next() { - return Some(device); - } - } - - // Fallback: try default input device - on PipeWire systems this might - // route correctly, but may be the microphone on pure ALSA systems. - // User can disable audio-viz feature if this causes issues. - if let Some(device) = host.default_input_device() { - return Some(device); - } - - None - } - - #[cfg(target_os = "macos")] - { - // On macOS, audio loopback requires virtual audio device - if let Some(device) = host.default_input_device() { - return Some(device); - } - } - - #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] - { - host.default_input_device() - } - - #[cfg(target_os = "macos")] - None - } - - /// Get a stream config that won't interfere with playback - /// Uses default buffer size to let the audio server manage timing - fn get_compatible_config(device: &Device) -> Option<StreamConfig> { - #[cfg(target_os = "windows")] - { - if let Ok(config) = device.default_output_config() { - return Some(config.into()); - } - } - - #[cfg(not(target_os = "windows"))] - { - // Get the default config and ensure we use default buffer size - if let Ok(config) = device.default_input_config() { - let stream_config = StreamConfig { - channels: config.channels(), - sample_rate: config.sample_rate(), - // Use Default buffer size - let PipeWire/PulseAudio manage this - // This is critical for avoiding audio interference - buffer_size: BufferSize::Default, - }; - return Some(stream_config); - } - } - - None - } - - /// Build the audio input stream - fn build_stream( - device: &Device, - config: &StreamConfig, - analyzer: SharedAnalyzer, - active: Arc<AtomicBool>, - ) -> Option<Stream> { - let channels = config.channels as usize; - let active_clone = active.clone(); - - let data_callback = move |data: &[f32], _: &cpal::InputCallbackInfo| { - if let Ok(mut analyzer) = analyzer.lock() { - analyzer.push_frames(data, channels); - } - }; - - let error_callback = move |_err: cpal::StreamError| { - // Silently deactivate on error to avoid corrupting TUI - active_clone.store(false, Ordering::Relaxed); - }; - - device - .build_input_stream(config, data_callback, error_callback, None) - .ok() - } -} - -impl Drop for AudioCaptureManager { - fn drop(&mut self) { - self.active.store(false, Ordering::Relaxed); - } -} diff --git a/src/infra/audio/cavacore/builder.rs b/src/infra/audio/cavacore/builder.rs deleted file mode 100644 index 5d1d5f13..00000000 --- a/src/infra/audio/cavacore/builder.rs +++ /dev/null @@ -1,209 +0,0 @@ -use std::ops::Range; - -use crate::infra::audio::cavacore::{Channels, Error}; - -/// The recommended default noise reduction value. -/// Is automatically set if a new [`CavaBuilder`] is created. -pub const DEFAULT_NOISE_REDUCTION: f64 = 0.77; - -/// The sample rate cap upstream encoded in its `SampleRate` bounded type; -/// enforced by `sanity_check` instead (see the module-level divergence notes). -pub const MAX_SAMPLE_RATE: u32 = 384_000; - -#[derive(Debug, Clone)] -pub struct CavaBuilder { - pub(crate) bars_per_channel: usize, - pub(crate) sample_rate: u32, - pub(crate) audio_channels: Channels, - pub(crate) enable_autosens: bool, - pub(crate) noise_reduction: f64, - pub(crate) freq_range: Range<u32>, - pub(crate) max_sens: f64, -} - -impl CavaBuilder { - pub fn bars_per_channel(mut self, bars_per_channel: usize) -> Self { - self.bars_per_channel = bars_per_channel; - self - } - - pub fn sample_rate(mut self, sample_rate: u32) -> Self { - self.sample_rate = sample_rate; - self - } - - pub fn audio_channels(mut self, audio_channels: Channels) -> Self { - self.audio_channels = audio_channels; - self - } - - pub fn enable_autosens(mut self, enable_autosens: bool) -> Self { - self.enable_autosens = enable_autosens; - self - } - - /// Adjust noise reduciton filters. Has to be within the range `0..=1`. - /// - /// The raw visualization is very noisy, this factor adjusts the integral - /// and gravity filters inside cavacore to keep the signal smooth: - /// `1` will be very slow and smooth, `0` will be fast but noisy. - pub fn noise_reduction(mut self, noise_reduction: f64) -> Self { - self.noise_reduction = noise_reduction; - self - } - - pub fn frequency_range(mut self, freq_range: Range<u32>) -> Self { - self.freq_range = freq_range; - self - } - - /// EXTENSION(spotatui): cap the autosens gain so near-silent input cannot be - /// amplified all the way to full-scale bars. Upstream and C cava have no - /// such cap (the default, infinity, keeps their behavior). - pub fn max_sens(mut self, max_sens: f64) -> Self { - self.max_sens = max_sens; - self - } - - pub fn sanity_check(&self) -> Result<(), Vec<Error>> { - let mut errors = Vec::new(); - let treble_buffer_size = self.compute_treble_buffer_size(); - - // These three checks replace upstream's NonZeroUsize / SampleRate / - // NonZeroU32 parameter types. - if self.bars_per_channel == 0 { - errors.push(Error::ZeroBars); - } - - if self.sample_rate == 0 || self.sample_rate > MAX_SAMPLE_RATE { - errors.push(Error::InvalidSampleRate(self.sample_rate)); - } - - if self.freq_range.start == 0 { - errors.push(Error::ZeroFreqStart); - } - - let max_amount_bars = max_bars_from_treble_buffer(treble_buffer_size); - if self.bars_per_channel > max_amount_bars { - errors.push(Error::TooHighAmountBars { - amount_bars: self.bars_per_channel, - sample_rate: self.sample_rate, - max_amount_bars, - }); - } - - if self.freq_range.is_empty() { - errors.push(Error::EmptyFreqRange { - start: self.freq_range.start, - end: self.freq_range.end, - }); - } - - if self.freq_range.end > self.sample_rate / 2 { - errors.push(Error::NyquistIssue { - freq: self.freq_range.end, - max_freq: self.sample_rate / 2, - }); - } - - if !errors.is_empty() { - return Err(errors); - } - - Ok(()) - } - - pub(crate) fn compute_treble_buffer_size(&self) -> usize { - treble_buffer_size(self.sample_rate) - } -} - -/// The treble FFT window cavacore picks for a sample rate. The bass window (and -/// so the whole sliding input buffer for mono audio) is 8x this. -pub fn treble_buffer_size(sample_rate: u32) -> usize { - let factor = if sample_rate <= 8_125 { - 1 - } else if sample_rate <= 16_250 { - 2 - } else if sample_rate <= 32_500 { - 4 - } else if sample_rate <= 75_000 { - 8 - } else if sample_rate <= 150_000 { - 16 - } else if sample_rate <= 300_000 { - 32 - } else { - 64 - }; - - factor * 128 -} - -/// The most bars per channel the treble window can resolve at `sample_rate`. -/// `sanity_check` rejects anything above this, so callers that must not see a -/// build error clamp to it first. -pub fn max_bars_per_channel(sample_rate: u32) -> usize { - max_bars_from_treble_buffer(treble_buffer_size(sample_rate)) -} - -fn max_bars_from_treble_buffer(treble_buffer_size: usize) -> usize { - treble_buffer_size / 2 + 1 -} - -impl Default for CavaBuilder { - fn default() -> Self { - Self { - bars_per_channel: 32, - sample_rate: 44_100, - audio_channels: Channels::Stereo, - enable_autosens: true, - noise_reduction: DEFAULT_NOISE_REDUCTION, - freq_range: 50..10_000, - max_sens: f64::INFINITY, - } - } -} - -#[cfg(test)] -mod tests { - use super::CavaBuilder; - - #[test] - fn sample_rate() { - assert!(CavaBuilder::default() - .sample_rate(0) - .sanity_check() - .is_err()); - assert!(CavaBuilder::default() - .sample_rate(384_001) - .sanity_check() - .is_err()); - } - - #[test] - fn treble_buffer_size() { - let mut builder = CavaBuilder::default(); - builder = builder.sample_rate(8_125); - - assert_eq!(128, builder.compute_treble_buffer_size()); - - builder = builder.sample_rate(16_250); - assert_eq!(128 * 2, builder.compute_treble_buffer_size()); - - builder = builder.sample_rate(32_500); - assert_eq!(128 * 4, builder.compute_treble_buffer_size()); - - builder = builder.sample_rate(75_000); - assert_eq!(128 * 8, builder.compute_treble_buffer_size()); - - builder = builder.sample_rate(150_000); - assert_eq!(128 * 16, builder.compute_treble_buffer_size()); - - builder = builder.sample_rate(300_000); - assert_eq!(128 * 32, builder.compute_treble_buffer_size()); - - builder = builder.sample_rate(300_001); - assert_eq!(128 * 64, builder.compute_treble_buffer_size()); - } -} diff --git a/src/infra/audio/cavacore/error.rs b/src/infra/audio/cavacore/error.rs deleted file mode 100644 index d054b064..00000000 --- a/src/infra/audio/cavacore/error.rs +++ /dev/null @@ -1,65 +0,0 @@ -use std::fmt; - -#[derive(Debug, Clone)] -pub enum Error { - TooHighAmountBars { - amount_bars: usize, - sample_rate: u32, - max_amount_bars: usize, - }, - - InvalidNoiseReduction(f64), - - EmptyFreqRange { - start: u32, - end: u32, - }, - - NyquistIssue { - freq: u32, - max_freq: u32, - }, - - // The three variants below replace upstream's bounded/non-zero parameter - // types (see the module-level divergence notes). - InvalidSampleRate(u32), - - ZeroBars, - - ZeroFreqStart, -} - -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Error::TooHighAmountBars { - amount_bars, - sample_rate, - max_amount_bars, - } => write!( - f, - "{amount_bars} bars for a sample rate of {sample_rate} can't be more than {max_amount_bars} bars" - ), - Error::InvalidNoiseReduction(value) => write!( - f, - "Noise reduction has to be within the range [0..1]. Your value: {value}." - ), - Error::EmptyFreqRange { start, end } => write!( - f, - "Frequency start and end frequency musn't be equal but given frequency range: {start}..{end} (Hz)" - ), - Error::NyquistIssue { freq, max_freq } => write!( - f, - "Due to the Nyquist sampling theorem the highesest frequency cutoff does not exceed 'sample rate' / 2 (= {max_freq}) but you set it to {freq}" - ), - Error::InvalidSampleRate(rate) => write!( - f, - "Sample rate has to be within the range 1..=384000. Your value: {rate}." - ), - Error::ZeroBars => write!(f, "The amount of bars per channel mustn't be zero"), - Error::ZeroFreqStart => write!(f, "The frequency range mustn't start at 0 Hz"), - } - } -} - -impl std::error::Error for Error {} diff --git a/src/infra/audio/cavacore/mod.rs b/src/infra/audio/cavacore/mod.rs deleted file mode 100644 index a2afa899..00000000 --- a/src/infra/audio/cavacore/mod.rs +++ /dev/null @@ -1,741 +0,0 @@ -//! Vendored from the `cavacore` crate v2.0.2 — a Rust port of cava's core -//! processing engine (<https://github.com/karlstav/cava/blob/master/CAVACORE.md>). -//! Upstream: <https://github.com/TornaxO7/cavacore-rs> (archived). -//! -//! Vendored instead of depended on because upstream is archived and ships -//! correctness bugs we need fixed. Local changes, kept minimal on purpose -//! (each patch site is marked `PATCH(spotatui)`): -//! - Autosens fix: upstream only *raised* `sens` during digital silence; the C -//! original raises it while signal is present (`if (!silence)`). Without the -//! fix, opening the visualizer mid-song leaves bars at ~2% height forever. -//! - Cutoff fix: upstream cast the fractional `relative_cut_off` to u32 before -//! multiplying by the FFT size at all five cutoff sites, truncating every -//! cutoff to 0 and collapsing the log-spaced bar mapping into consecutive -//! near-DC bins. C multiplies in float first. -//! - Input-order fix: upstream copied each new chunk into the sliding buffer -//! in forward order; C writes it time-reversed (newest sample at index 0), -//! which keeps the buffer free of chunk-boundary discontinuities. -//! - Signed-comparison fix: the band-selection loop compared bar indices -//! against the cutoff bars `as usize`; the cutoffs are -1 sentinels when a -//! range has no bar, wrapping to usize::MAX. C compares signed ints. -//! - Cutoff-adjustment guard: the non-monotonic-cutoff fixup reads index -//! n - 2 under an `n > 0` guard; at n == 1 that is an out-of-bounds read in -//! C and a panic here, so the guard is `n > 1` (a deliberate divergence). -//! - Dropped the `bounded-integer` and `thiserror` deps: `SampleRate` / -//! `NonZero*` parameter types became plain integers validated in -//! `sanity_check`, and `Error` implements `Display` by hand. -//! - Engine bounds the integration must respect are exposed rather than -//! restated app-side: `MAX_SAMPLE_RATE` and `max_bars_per_channel(rate)` are -//! the values `sanity_check` enforces, and `Cava::input_len()` is the -//! sliding input window a caller caps its sample backlog to. -//! - EXTENSION (not in C cava): `CavaBuilder::max_sens` caps the autosens gain -//! so near-silent input cannot be normalized up to full-scale bars; the -//! default (infinity) preserves upstream behavior. -//! -//! Upstream license (MIT): -//! -//! ```text -//! MIT License -//! -//! Copyright (c) 2025 TornaxO7 -//! -//! Permission is hereby granted, free of charge, to any person obtaining a copy -//! of this software and associated documentation files (the "Software"), to deal -//! in the Software without restriction, including without limitation the rights -//! to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -//! copies of the Software, and to permit persons to whom the Software is -//! furnished to do so, subject to the following conditions: -//! -//! The above copyright notice and this permission notice shall be included in all -//! copies or substantial portions of the Software. -//! -//! THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -//! IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -//! FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -//! AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -//! LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -//! OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -//! SOFTWARE. -//! ``` -use std::sync::Arc; - -use realfft::{num_complex::Complex, FftNum, RealFftPlanner, RealToComplex}; - -mod builder; -mod error; - -pub use builder::{max_bars_per_channel, CavaBuilder, DEFAULT_NOISE_REDUCTION, MAX_SAMPLE_RATE}; -pub use error::Error; - -#[repr(u8)] -#[derive(Debug, PartialEq, Eq, Clone, Copy)] -pub enum Channels { - Mono = 1, - Stereo, -} - -pub struct Cava { - bars_per_channel: usize, - sample_rate: u32, - enable_autosens: bool, - init_sens: bool, - sens: f64, - max_sens: f64, - framerate: f64, - frame_skip: f64, - noise_reduction: f64, - - left: AudioData<f64>, - right: Option<AudioData<f64>>, - - bass_hann_window: Box<[f64]>, - mid_hann_window: Box<[f64]>, - treble_hann_window: Box<[f64]>, - - input_buffer: Box<[f64]>, - buffer_lower_cut_off: Box<[u32]>, - buffer_upper_cut_off: Box<[u32]>, - eq: Box<[f64]>, - - bass_cut_off_bar: i32, - treble_cut_off_bar: i32, - - cava_fall: Box<[f64]>, - cava_mem: Box<[f64]>, - cava_peak: Box<[f64]>, - prev_cava_out: Box<[f64]>, -} - -impl Cava { - /// Length of the sliding input window one `execute` can consume, in - /// interleaved samples. Callers that buffer between calls cap their backlog - /// here instead of re-deriving it from the sample rate and channel count. - pub fn input_len(&self) -> usize { - self.input_buffer.len() - } - - pub fn make_output(&self) -> Box<[f64]> { - let amount_channels = if self.right.is_some() { 2 } else { 1 }; - let total_amount_bars = self.bars_per_channel * amount_channels; - - vec![0.; total_amount_bars].into_boxed_slice() - } - - pub fn execute(&mut self, input: &[f64], output: &mut [f64]) { - if input.is_empty() { - self.frame_skip += 1.; - } else { - self.framerate -= self.framerate / 64.; - - let amount_audio_channels = if self.right.is_some() { 2. } else { 1. }; - - self.framerate += (self.sample_rate as f64 * amount_audio_channels * self.frame_skip) - / input.len() as f64 - / 64.; - - self.frame_skip = 1.; - - let input_buffer_len = self.input_buffer.len(); - self - .input_buffer - .copy_within(..input_buffer_len - input.len(), input.len()); - // PATCH(spotatui): C writes the new chunk time-reversed (newest sample - // at index 0), keeping the whole buffer one continuous time-reversed - // stream; upstream copied it forward, splicing a discontinuity into the - // FFT windows at every chunk boundary. - for (n, &sample) in input.iter().enumerate() { - self.input_buffer[input.len() - n - 1] = sample; - } - } - - // fill the bass, mid and treble buffers - if let Some(right) = self.right.as_mut() { - // bass - for i in 0..self.left.in_bass.len() { - right.in_bass[i] = self.input_buffer[i * 2] * self.bass_hann_window[i]; - self.left.in_bass[i] = self.input_buffer[i * 2 + 1] * self.bass_hann_window[i]; - } - - // mid - for i in 0..self.left.in_mid.len() { - right.in_mid[i] = self.input_buffer[i * 2] * self.mid_hann_window[i]; - self.left.in_mid[i] = self.input_buffer[i * 2 + 1] * self.mid_hann_window[i]; - } - - // treble - for i in 0..self.left.in_treble.len() { - right.in_treble[i] = self.input_buffer[i * 2] * self.treble_hann_window[i]; - self.left.in_treble[i] = self.input_buffer[i * 2 + 1] * self.treble_hann_window[i]; - } - - right - .p_bass - .process(&mut right.in_bass, &mut right.out_bass) - .unwrap(); - right - .p_mid - .process(&mut right.in_mid, &mut right.out_mid) - .unwrap(); - right - .p_treble - .process(&mut right.in_treble, &mut right.out_treble) - .unwrap(); - } else { - // bass - for i in 0..self.left.in_bass.len() { - self.left.in_bass[i] = self.input_buffer[i] * self.bass_hann_window[i]; - } - - // mid - for i in 0..self.left.in_mid.len() { - self.left.in_mid[i] = self.input_buffer[i] * self.mid_hann_window[i]; - } - - // treble - for i in 0..self.left.in_treble.len() { - self.left.in_treble[i] = self.input_buffer[i] * self.treble_hann_window[i]; - } - } - - // fft goes brrrrrrr - self - .left - .p_bass - .process(&mut self.left.in_bass, &mut self.left.out_bass) - .unwrap(); - self - .left - .p_mid - .process(&mut self.left.in_mid, &mut self.left.out_mid) - .unwrap(); - self - .left - .p_treble - .process(&mut self.left.in_treble, &mut self.left.out_treble) - .unwrap(); - - // separate frequency bands - for n in 0..self.bars_per_channel { - let mut tmp_l = 0.; - let mut tmp_r = 0.; - - // add upp FFT values within bands - // PATCH(spotatui): compare in signed space like C. The cutoff bars are - // -1 sentinels when a range has no bar; upstream compared `n` against - // them `as usize`, so -1 wrapped to usize::MAX and every bar summed the - // bass FFT whenever no bass bar existed. - let bar = n as i32; - for i in self.buffer_lower_cut_off[n] as usize..=self.buffer_upper_cut_off[n] as usize { - if bar <= self.bass_cut_off_bar { - tmp_l += self.left.out_bass[i].norm(); - if let Some(right) = &self.right { - tmp_r += right.out_bass[i].norm(); - } - } else if bar <= self.treble_cut_off_bar { - tmp_l += self.left.out_mid[i].norm(); - if let Some(right) = &self.right { - tmp_r += right.out_mid[i].norm(); - } - } else { - tmp_l += self.left.out_treble[i].norm(); - if let Some(right) = &self.right { - tmp_r += right.out_treble[i].norm(); - } - } - } - - // getting average multiply with eq - tmp_l /= self.buffer_upper_cut_off[n] as f64 - self.buffer_lower_cut_off[n] as f64 + 1.; - tmp_l *= self.eq[n]; - output[n] = tmp_l; - - if self.right.is_some() { - tmp_r /= self.buffer_upper_cut_off[n] as f64 - self.buffer_lower_cut_off[n] as f64 + 1.; - tmp_r *= self.eq[n]; - output[n + self.bars_per_channel] = tmp_r; - } - } - - // applying sens or getting max value - if self.enable_autosens { - for val in output.iter_mut() { - *val *= self.sens; - } - } - - // process [smoothing] - let mut is_overshooting = false; - let gravity_mod = { - let gravity_mod = (60. / self.framerate).powf(2.5) * 1.54 / self.noise_reduction; - - gravity_mod.max(1.) - }; - - let amount_channels = if self.right.is_some() { 2 } else { 1 }; - for (n, out) in output - .iter_mut() - .enumerate() - .take(self.bars_per_channel * amount_channels) - { - // [smoothing]: falloff - if *out < self.prev_cava_out[n] && self.noise_reduction > 0.1 { - *out = self.cava_peak[n] * (1. - (self.cava_fall[n].powf(2.) * gravity_mod)); - - if *out < 0.0 { - *out = 0.0; - } - self.cava_fall[n] += 0.028; - } else { - self.cava_peak[n] = *out; - self.cava_fall[n] = 0.; - } - self.prev_cava_out[n] = *out; - - // [smoothing]: integral - *out += self.cava_mem[n] * self.noise_reduction; - self.cava_mem[n] = *out; - if self.enable_autosens { - // check if we overshoot target height - if *out > 1. { - is_overshooting = true; - } - } - } - - // calculating automatic sense adjustment - if self.enable_autosens { - if is_overshooting { - self.sens *= 0.98; - self.init_sens = false; - } else { - // PATCH(spotatui): upstream had `if is_silent`, inverted against the C - // original's `if (!silence)` — sens must rise while signal plays (slow - // recovery) and duck on overshoot, not rise only during silence. - let is_silent = input.iter().all(|&v| v == 0.); - if !is_silent { - self.sens *= 1.002; - if self.init_sens { - self.sens *= 1.1; - } - } - } - - // EXTENSION(spotatui): optional autosens gain cap (upstream has none) so - // near-silent input stops short of full-scale bars. - if self.sens > self.max_sens { - self.sens = self.max_sens; - } - } - } -} - -impl CavaBuilder { - pub fn build(&self) -> Result<Cava, Vec<Error>> { - self.sanity_check()?; - - let bars_per_channel = self.bars_per_channel; - let sample_rate = self.sample_rate; - let enable_autosens = self.enable_autosens; - let init_sens = true; - let sens = 1.; - let max_sens = self.max_sens; - let framerate = 75.; - let frame_skip = 1.; - let noise_reduction = self.noise_reduction; - - let treble_buffer_size = self.compute_treble_buffer_size(); - let (left, right): (AudioData<f64>, Option<AudioData<f64>>) = { - let left = AudioData::new(treble_buffer_size); - let right = match self.audio_channels { - Channels::Mono => None, - Channels::Stereo => Some(AudioData::new(treble_buffer_size)), - }; - (left, right) - }; - - let bass_hann_window = compute_hann_window(left.in_bass.len()); - let mid_hann_window = compute_hann_window(left.in_mid.len()); - let treble_hann_window = compute_hann_window(left.in_treble.len()); - - let input_buffer = - vec![0.0; left.in_bass.len() * self.audio_channels as usize].into_boxed_slice(); - - let mut buffer_lower_cut_off = vec![0; bars_per_channel + 1].into_boxed_slice(); - let mut buffer_upper_cut_off = vec![0; bars_per_channel + 1].into_boxed_slice(); - let mut eq = vec![0.; bars_per_channel + 1].into_boxed_slice(); - let mut cut_off_frequency = vec![0f64; bars_per_channel + 1].into_boxed_slice(); - - let total_amount_bars = bars_per_channel * self.audio_channels as usize; - let cava_fall = vec![0.0; total_amount_bars].into_boxed_slice(); - let cava_mem = vec![0.0; total_amount_bars].into_boxed_slice(); - let cava_peak = vec![0.0; total_amount_bars].into_boxed_slice(); - let prev_cava_out = vec![0.0; total_amount_bars].into_boxed_slice(); - - // process: calculate cutoff frequencies and eq - let bass_cut_off = 100.; - let treble_cut_off = 500.; - - // calculate frequency constant (used to distribute bars across the frequency band) - let frequency_constant = (self.freq_range.start as f64 / self.freq_range.end as f64).log10() - / (1. / (self.bars_per_channel as f64 + 1.) - 1.); - - let mut relative_cut_off = vec![0.; self.bars_per_channel + 1].into_boxed_slice(); - let mut bass_cut_off_bar = -1; - let mut treble_cut_off_bar = -1; - let mut first_bar = true; - let mut first_treble_bar = 0; - let mut bar_buffer = vec![0; self.bars_per_channel + 1]; - - for n in 0..bars_per_channel + 1 { - let mut bar_distribution_coefficient = -frequency_constant; - bar_distribution_coefficient += - (n as f64 + 1.) / (bars_per_channel as f64 + 1.) * frequency_constant; - - cut_off_frequency[n] = self.freq_range.end as f64 * 10f64.powf(bar_distribution_coefficient); - - // PATCH(spotatui): n > 1, not n > 0. The adjustment reads index n - 2, - // which at n == 1 is an out-of-bounds read in C and a usize-underflow - // panic here. Only reachable when bar 0 lands above the 100 Hz bass - // cutoff, which needs a freq range starting higher than the analyzer's. - if n > 1 { - // what? - let condition = cut_off_frequency[n - 1] >= cut_off_frequency[n] - && cut_off_frequency[n - 1] > bass_cut_off; - - if condition { - cut_off_frequency[n] = - cut_off_frequency[n - 1] + (cut_off_frequency[n - 1] - cut_off_frequency[n - 2]); - } - } - - relative_cut_off[n] = cut_off_frequency[n] / (self.sample_rate as f64 / 2.); - - // some random magic? - eq[n] = cut_off_frequency[n].powf(1.); - eq[n] /= 2f64.powf(29.); - eq[n] /= (left.in_bass.len() as f64).log2(); - - // PATCH(spotatui): upstream cast relative_cut_off (a 0..1 fraction) to - // u32 BEFORE the multiply at all five cutoff sites below, truncating - // every cutoff to bin 0 and collapsing cava's log-spaced bar mapping - // into consecutive near-DC bins. C multiplies in float and truncates on - // the int assignment: `relative_cut_off[n] * (bufferSize / 2)`. - if cut_off_frequency[n] < bass_cut_off { - // BASS - bar_buffer[n] = 1; - buffer_lower_cut_off[n] = (relative_cut_off[n] * (left.in_bass.len() as f64 / 2.)) as u32; - bass_cut_off_bar += 1; - treble_cut_off_bar += 1; - if bass_cut_off_bar > 0 { - first_bar = false; - } - - if buffer_lower_cut_off[n] > left.in_bass.len() as u32 / 2 { - buffer_lower_cut_off[n] = left.in_bass.len() as u32 / 2; - } - } else if cut_off_frequency[n] > bass_cut_off && cut_off_frequency[n] < treble_cut_off { - // MID - bar_buffer[n] = 2; - buffer_lower_cut_off[n] = (relative_cut_off[n] * (left.in_mid.len() as f64 / 2.)) as u32; - treble_cut_off_bar += 1; - - if (treble_cut_off_bar - bass_cut_off_bar) == 1 { - first_bar = true; - if n > 0 { - buffer_upper_cut_off[n - 1] = - (relative_cut_off[n] * (left.in_bass.len() as f64 / 2.)) as u32; - } - } else { - first_bar = false; - } - - if buffer_lower_cut_off[n] > left.in_mid.len() as u32 / 2 { - buffer_lower_cut_off[n] = left.in_mid.len() as u32 / 2; - } - } else { - // TREBLE - bar_buffer[n] = 3; - buffer_lower_cut_off[n] = (relative_cut_off[n] * (left.in_treble.len() as f64 / 2.)) as u32; - first_treble_bar += 1; - if first_treble_bar == 1 { - first_bar = true; - if n > 0 { - buffer_upper_cut_off[n - 1] = - (relative_cut_off[n] * (left.in_mid.len() as f64 / 2.)) as u32; - } - } else { - first_bar = false; - } - - if buffer_lower_cut_off[n] > left.in_treble.len() as u32 / 2 { - buffer_lower_cut_off[n] = left.in_treble.len() as u32 / 2; - } - } - - if n > 0 { - if !first_bar { - buffer_upper_cut_off[n - 1] = buffer_lower_cut_off[n].saturating_sub(1); - - if buffer_lower_cut_off[n] <= buffer_lower_cut_off[n - 1] { - let mut room_for_more = false; - - if bar_buffer[n] == 1 { - if buffer_lower_cut_off[n - 1] + 1 < left.in_bass.len() as u32 / 2 + 1 { - room_for_more = true; - } - } else if bar_buffer[n] == 2 { - if buffer_lower_cut_off[n - 1] + 1 < left.in_mid.len() as u32 / 2 + 1 { - room_for_more = true; - } - } else if bar_buffer[n] == 3 - && buffer_lower_cut_off[n - 1] + 1 < left.in_treble.len() as u32 / 2 + 1 - { - room_for_more = true; - } - - if room_for_more { - // push the spectrum up - buffer_lower_cut_off[n] = buffer_lower_cut_off[n - 1] + 1; - buffer_upper_cut_off[n - 1] = buffer_lower_cut_off[n] - 1; - - // calculate new cut off frequency - if bar_buffer[n] == 1 { - relative_cut_off[n] = - buffer_lower_cut_off[n] as f64 / (left.in_bass.len() as f64 / 2.); - } else if bar_buffer[n] == 2 { - relative_cut_off[n] = - buffer_lower_cut_off[n] as f64 / (left.in_mid.len() as f64 / 2.); - } else if bar_buffer[n] == 3 { - relative_cut_off[n] = - buffer_lower_cut_off[n] as f64 / (left.in_treble.len() as f64 / 2.); - } - - cut_off_frequency[n] = relative_cut_off[n] * (self.sample_rate as f64 / 2.); - } - } - } else if buffer_upper_cut_off[n - 1] <= buffer_lower_cut_off[n - 1] { - buffer_upper_cut_off[n - 1] = buffer_lower_cut_off[n - 1] + 1; - } - } - } - - Ok(Cava { - bars_per_channel, - sample_rate, - enable_autosens, - init_sens, - sens, - max_sens, - framerate, - frame_skip, - noise_reduction, - left, - right, - bass_hann_window, - mid_hann_window, - treble_hann_window, - input_buffer, - buffer_lower_cut_off, - buffer_upper_cut_off, - eq, - bass_cut_off_bar, - treble_cut_off_bar, - cava_fall, - cava_mem, - cava_peak, - prev_cava_out, - }) - } -} - -struct AudioData<F: FftNum> { - p_bass: Arc<dyn RealToComplex<F>>, - p_mid: Arc<dyn RealToComplex<F>>, - p_treble: Arc<dyn RealToComplex<F>>, - - out_bass: Vec<Complex<F>>, - out_mid: Vec<Complex<F>>, - out_treble: Vec<Complex<F>>, - - in_bass: Vec<F>, - in_mid: Vec<F>, - in_treble: Vec<F>, -} - -impl<F: FftNum> AudioData<F> { - pub fn new(treble_buffer_size: usize) -> Self { - let mut planner = RealFftPlanner::new(); - - let bass = planner.plan_fft_forward(treble_buffer_size * 8); - let mid = planner.plan_fft_forward(treble_buffer_size * 4); - let treble = planner.plan_fft_forward(treble_buffer_size); - - let out_bass = bass.make_output_vec(); - let out_mid = mid.make_output_vec(); - let out_treble = treble.make_output_vec(); - - let in_bass = bass.make_input_vec(); - let in_mid = mid.make_input_vec(); - let in_treble = treble.make_input_vec(); - - Self { - p_bass: bass, - p_mid: mid, - p_treble: treble, - - out_bass, - out_mid, - out_treble, - - in_bass, - in_mid, - in_treble, - } - } -} - -fn compute_hann_window(buffer_size: usize) -> Box<[f64]> { - let mut hann_window = Vec::with_capacity(buffer_size); - - for i in 0..buffer_size { - let multiplier = - 0.5 * (1. - (2. * std::f64::consts::PI * i as f64 / (buffer_size as f64 - 1.)).cos()); - hann_window.push(multiplier); - } - - hann_window.into_boxed_slice() -} - -#[cfg(test)] -mod tests { - mod audio_data { - use crate::infra::audio::cavacore::AudioData; - - #[test] - fn buffer_size() { - let treble_buffer_size = 128 * 4; - - let audio_data: AudioData<f32> = AudioData::new(treble_buffer_size); - - assert_eq!(audio_data.in_bass.len(), treble_buffer_size * 8); - assert_eq!(audio_data.in_mid.len(), treble_buffer_size * 4); - assert_eq!(audio_data.in_treble.len(), treble_buffer_size); - } - } - - // Pins the vendored autosens patch: sens must ramp while signal is present - // and hold during digital silence, matching the C original. Upstream 2.0.2 - // had the condition inverted, which left bars near zero when the visualizer - // opened mid-song (ducking-only sens can never recover from a loud passage). - mod autosens_patch { - use crate::infra::audio::cavacore::{Cava, CavaBuilder, Channels}; - - fn cava() -> Cava { - CavaBuilder::default() - .bars_per_channel(10) - .sample_rate(44_100) - .audio_channels(Channels::Mono) - .build() - .unwrap() - } - - #[test] - fn sens_ramps_while_signal_is_present() { - let mut cava = cava(); - let mut output = cava.make_output(); - // Non-zero but far too quiet to overshoot: the ramp branch must run. - let quiet_signal = vec![1.0; 512]; - - let before = cava.sens; - for _ in 0..10 { - cava.execute(&quiet_signal, &mut output); - } - - assert!( - cava.sens > before, - "sens must rise while signal plays: {} -> {}", - before, - cava.sens - ); - } - - #[test] - fn sens_stops_ramping_at_the_max_sens_cap() { - let mut cava = CavaBuilder::default() - .bars_per_channel(10) - .sample_rate(44_100) - .audio_channels(Channels::Mono) - .max_sens(2.0) - .build() - .unwrap(); - let mut output = cava.make_output(); - // Non-zero but far too quiet to overshoot: uncapped, the init ramp - // (x1.1 per frame) would push sens far past 2.0 within 100 frames. - let quiet_signal = vec![1.0; 512]; - - for _ in 0..100 { - cava.execute(&quiet_signal, &mut output); - } - - assert!(cava.sens <= 2.0, "sens exceeded the cap: {}", cava.sens); - } - - #[test] - fn sens_holds_during_silence() { - let mut cava = cava(); - let mut output = cava.make_output(); - let silence = vec![0.0; 512]; - - let before = cava.sens; - for _ in 0..10 { - cava.execute(&silence, &mut output); - } - - assert_eq!( - cava.sens, before, - "sens must not ramp during digital silence" - ); - } - } - - // Pins the other two vendored patches (cutoff truncation, input chunk order). - mod port_patches { - use crate::infra::audio::cavacore::{Cava, CavaBuilder, Channels}; - - fn cava() -> Cava { - CavaBuilder::default() - .bars_per_channel(10) - .sample_rate(44_100) - .audio_channels(Channels::Mono) - .build() - .unwrap() - } - - // Upstream's cast-before-multiply truncated every cutoff to 0 and - // degenerated the mapping into consecutive bins 0,1,2,... With float math - // (matching C), the top bar of a 10-bar 44.1kHz plan reads treble bin - // ~136 (~5.9 kHz), nowhere near bin 9. - #[test] - fn cutoffs_are_log_spaced_not_consecutive() { - let cava = cava(); - assert!( - cava.buffer_lower_cut_off[9] > 100, - "top bar reads bin {} - cutoffs degenerated to consecutive bins", - cava.buffer_lower_cut_off[9] - ); - // Bar 0 starts at 50 Hz: 50/22050 * 4096 = bin 9 of the bass FFT. - assert_eq!(cava.buffer_lower_cut_off[0], 9); - } - - // The sliding buffer must stay one continuous time-reversed stream - // (newest sample at index 0) across execute calls, as in C. - #[test] - fn input_buffer_is_a_continuous_time_reversed_stream() { - let mut cava = cava(); - let mut output = cava.make_output(); - cava.execute(&[1., 2., 3.], &mut output); - cava.execute(&[4., 5., 6.], &mut output); - assert_eq!(cava.input_buffer[..6], [6., 5., 4., 3., 2., 1.]); - } - } -} diff --git a/src/infra/audio/mod.rs b/src/infra/audio/mod.rs deleted file mode 100644 index 0d9a2845..00000000 --- a/src/infra/audio/mod.rs +++ /dev/null @@ -1,90 +0,0 @@ -// Audio capture and analysis module for real-time visualization -// This module provides cross-platform audio capture: -// - Linux: PipeWire native (via pipewire-rs) -// - Windows/macOS: cpal (WASAPI/CoreAudio) -// -// It also hosts the shared decoded-audio output engine (`LocalPlayer`), used by -// any source that plays through the local rodio sink (local files, Subsonic, Qobuz). - -// Shared decode/output engine. Gated on `audio-decode`, which `local-files`, -// `subsonic` and `qobuz` pull in, so the player is reachable from each source. -#[cfg(feature = "audio-decode")] -mod player; -#[cfg(feature = "audio-decode")] -pub use player::LocalPlayer; -#[cfg(feature = "qobuz")] -pub use player::PreparedStream; -#[cfg(feature = "audio-decode-queue")] -pub use player::Reopen; - -#[cfg(any(feature = "audio-viz", feature = "audio-viz-cpal"))] -mod analyzer; - -// Vendored cava DSP engine (see cavacore/mod.rs for provenance and the local -// patches). Lint allowances instead of cleanups keep it diffable against -// upstream: it carries items our integration never constructs. -#[cfg(any(feature = "audio-viz", feature = "audio-viz-cpal"))] -#[allow(dead_code, clippy::all)] -mod cavacore; - -// Platform-specific capture backends -#[cfg(all(feature = "audio-viz", target_os = "linux"))] -mod pipewire_capture; - -#[cfg(feature = "audio-viz-cpal")] -#[allow(dead_code)] -mod capture; - -// Re-export the appropriate capture manager based on platform -#[cfg(all(feature = "audio-viz", target_os = "linux"))] -pub use pipewire_capture::PipeWireCapture as AudioCaptureManager; - -// On Linux with audio-viz, use cpal only if audio-viz is not enabled -// This prevents conflict when --all-features is used -#[cfg(all( - feature = "audio-viz-cpal", - not(all(feature = "audio-viz", target_os = "linux")) -))] -pub use capture::AudioCaptureManager; - -// Re-export SpectrumData -#[cfg(any(feature = "audio-viz", feature = "audio-viz-cpal"))] -#[allow(unused_imports)] -pub use analyzer::SpectrumData; - -// Fallback types when no audio-viz feature is enabled -#[cfg(not(any( - all(feature = "audio-viz", target_os = "linux"), - feature = "audio-viz-cpal" -)))] -#[derive(Clone, Default)] -#[allow(dead_code)] -pub struct SpectrumData { - pub bands: Vec<f32>, -} - -#[cfg(not(any( - all(feature = "audio-viz", target_os = "linux"), - feature = "audio-viz-cpal" -)))] -#[allow(dead_code)] -pub struct AudioCaptureManager; - -#[cfg(not(any( - all(feature = "audio-viz", target_os = "linux"), - feature = "audio-viz-cpal" -)))] -#[allow(dead_code)] -impl AudioCaptureManager { - pub fn new(_display_bars: usize) -> Option<Self> { - None - } - - pub fn get_spectrum(&self, _desired_bars: usize) -> Option<SpectrumData> { - None - } - - pub fn is_active(&self) -> bool { - false - } -} diff --git a/src/infra/audio/pipewire_capture.rs b/src/infra/audio/pipewire_capture.rs deleted file mode 100644 index 0a68f446..00000000 --- a/src/infra/audio/pipewire_capture.rs +++ /dev/null @@ -1,214 +0,0 @@ -// PipeWire-native audio capture for Linux -// This provides direct access to PipeWire's audio graph for monitor capture - -use super::analyzer::{create_shared_analyzer, spectrum_for, SharedAnalyzer, SpectrumData}; -use pipewire as pw; -use pw::spa::param::audio::AudioInfoRaw; -use pw::spa::pod::Pod; -use std::mem; -use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; -use std::sync::Arc; -use std::thread; - -/// Manages audio capture from PipeWire sink monitors -pub struct PipeWireCapture { - analyzer: SharedAnalyzer, - active: Arc<AtomicBool>, - _thread: thread::JoinHandle<()>, -} - -impl PipeWireCapture { - /// Create a new PipeWire audio capture manager sized for `display_bars` - /// Returns None if PipeWire initialization fails - pub fn new(display_bars: usize) -> Option<Self> { - // Built at the rate we request below; the param_changed callback swaps in - // the rate PipeWire actually negotiates. - let analyzer = create_shared_analyzer(48000, display_bars); - let active = Arc::new(AtomicBool::new(true)); - - let analyzer_clone = analyzer.clone(); - let active_clone = active.clone(); - - // PipeWire requires its own thread with a main loop - let thread = thread::spawn(move || { - if let Err(e) = run_pipewire_capture(analyzer_clone, active_clone) { - log::error!("[audio-viz] PipeWire capture error: {:?}", e); - } - }); - - // Give PipeWire a moment to initialize - std::thread::sleep(std::time::Duration::from_millis(300)); - - if active.load(Ordering::Relaxed) { - Some(Self { - analyzer, - active, - _thread: thread, - }) - } else { - None - } - } - - /// Get the current spectrum data, sized to `desired_bars` (the analyzer - /// rebuilds its plan when the count changes, and clamps unsupportable counts) - pub fn get_spectrum(&self, desired_bars: usize) -> Option<SpectrumData> { - if !self.active.load(Ordering::Relaxed) { - return None; - } - - spectrum_for(&self.analyzer, desired_bars) - } - - /// Check if audio capture is currently active - pub fn is_active(&self) -> bool { - self.active.load(Ordering::Relaxed) - } -} - -impl Drop for PipeWireCapture { - fn drop(&mut self) { - self.active.store(false, Ordering::Relaxed); - } -} - -/// User data passed to the stream callbacks -#[derive(Default)] -struct StreamData { - channels: AtomicU32, - format: std::sync::Mutex<AudioInfoRaw>, -} - -fn run_pipewire_capture( - analyzer: SharedAnalyzer, - active: Arc<AtomicBool>, -) -> Result<(), pw::Error> { - // pipewire 0.9+ no longer has a separate init() call - initialization happens in MainLoopBox::new() - let mainloop = pw::main_loop::MainLoopBox::new(None)?; - let context = pw::context::ContextBox::new(mainloop.loop_(), None)?; - let core = context.connect(None)?; - - // Properties for audio capture from sink monitors - let props = pw::properties::properties! { - *pw::keys::MEDIA_TYPE => "Audio", - *pw::keys::MEDIA_CATEGORY => "Capture", - *pw::keys::MEDIA_ROLE => "Music", - // This is the key property - capture from sink monitor ports! - *pw::keys::STREAM_CAPTURE_SINK => "true", - }; - - let stream = pw::stream::StreamBox::new(&core, "spotatui-audio-viz", props)?; - - let active_clone = active.clone(); - let analyzer_clone = analyzer.clone(); - let param_analyzer = analyzer.clone(); - - // Set up stream listener - let _listener = stream - .add_local_listener_with_user_data(StreamData::default()) - .param_changed(move |_, user_data, id, param| { - let Some(param) = param else { return }; - if id != pw::spa::param::ParamType::Format.as_raw() { - return; - } - - // Parse the format to get channel count and negotiated sample rate - if let Ok(mut format) = user_data.format.lock() { - if format.parse(param).is_ok() { - let channels = format.channels(); - user_data.channels.store(channels, Ordering::Relaxed); - - let rate = format.rate(); - if rate > 0 { - if let Ok(mut analyzer) = param_analyzer.lock() { - analyzer.set_sample_rate(rate); - } - } - } - } - }) - .process(move |stream, user_data| { - if !active_clone.load(Ordering::Relaxed) { - return; - } - - let Some(mut buffer) = stream.dequeue_buffer() else { - return; - }; - - let datas = buffer.datas_mut(); - if datas.is_empty() { - return; - } - - let data = &mut datas[0]; - let n_channels = user_data.channels.load(Ordering::Relaxed).max(1) as usize; - - // Get the actual size of data in the chunk - let chunk = data.chunk(); - let n_bytes = chunk.size() as usize; - - if n_bytes == 0 { - return; - } - - if let Some(samples_bytes) = data.data() { - // Only process the valid portion of the buffer - let valid_bytes = &samples_bytes[..n_bytes.min(samples_bytes.len())]; - - // Decode the interleaved f32 stream; the analyzer owns the channel - // rule (0/1 through, mono duplicated) so it is stated in one place. - // The `as_chunks` rewrite clippy 1.98 suggests must be verified on a - // Linux build (this module never compiles elsewhere), so it waits. - #[allow(clippy::chunks_exact_to_as_chunks)] - let samples: Vec<f32> = valid_bytes - .chunks_exact(mem::size_of::<f32>()) - .map(|bytes| f32::from_le_bytes(bytes.try_into().unwrap_or([0; 4]))) - .collect(); - - if !samples.is_empty() { - if let Ok(mut analyzer) = analyzer_clone.lock() { - analyzer.push_frames(&samples, n_channels); - } - } - } - }) - .register()?; - - // Set up audio format - request F32LE, stereo, 48kHz - let mut audio_info = AudioInfoRaw::new(); - audio_info.set_format(pw::spa::param::audio::AudioFormat::F32LE); - audio_info.set_rate(48000); - audio_info.set_channels(2); - - let obj = pw::spa::pod::Object { - type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(), - id: pw::spa::param::ParamType::EnumFormat.as_raw(), - properties: audio_info.into(), - }; - - let values: Vec<u8> = pw::spa::pod::serialize::PodSerializer::serialize( - std::io::Cursor::new(Vec::new()), - &pw::spa::pod::Value::Object(obj), - ) - .map_err(|_| pw::Error::CreationFailed)? - .0 - .into_inner(); - - let mut params = [Pod::from_bytes(&values).unwrap()]; - - // Connect the stream with RT_PROCESS for real-time processing - stream.connect( - pw::spa::utils::Direction::Input, - None, - pw::stream::StreamFlags::AUTOCONNECT - | pw::stream::StreamFlags::MAP_BUFFERS - | pw::stream::StreamFlags::RT_PROCESS, - &mut params, - )?; - - // Run the main loop - this blocks until quit is called - mainloop.run(); - - Ok(()) -} diff --git a/src/infra/discord_rpc.rs b/src/infra/discord_rpc.rs deleted file mode 100644 index 791aa069..00000000 --- a/src/infra/discord_rpc.rs +++ /dev/null @@ -1,144 +0,0 @@ -use anyhow::Result; -use discord_rich_presence::{activity, DiscordIpc, DiscordIpcClient}; -use std::sync::mpsc::{self, Receiver, Sender}; -use std::thread; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; - -const REPO_URL: &str = "https://github.com/LargeModGames/spotatui"; -const REPO_TAGLINE: &str = "Open-source on GitHub"; - -#[derive(Clone, Debug)] -pub struct DiscordPlayback { - pub title: String, - pub artist: String, - pub album: String, - pub state: String, - pub image_url: Option<String>, - pub duration_ms: u32, - pub progress_ms: u128, - pub is_playing: bool, -} - -enum DiscordRpcCommand { - SetActivity(DiscordPlayback), - ClearActivity, -} - -pub struct DiscordRpcManager { - command_tx: Sender<DiscordRpcCommand>, -} - -impl DiscordRpcManager { - pub fn new(app_id: String) -> Result<Self> { - let (command_tx, command_rx) = mpsc::channel(); - - thread::spawn(move || run_discord_rpc_loop(app_id, command_rx)); - - Ok(Self { command_tx }) - } - - pub fn set_activity(&self, playback: &DiscordPlayback) { - let _ = self - .command_tx - .send(DiscordRpcCommand::SetActivity(playback.clone())); - } - - pub fn clear(&self) { - let _ = self.command_tx.send(DiscordRpcCommand::ClearActivity); - } -} - -fn run_discord_rpc_loop(app_id: String, command_rx: Receiver<DiscordRpcCommand>) { - let mut client: Option<DiscordIpcClient> = None; - let mut last_connect_attempt = Instant::now() - Duration::from_secs(30); - - for command in command_rx { - if !ensure_connected(&app_id, &mut client, &mut last_connect_attempt) { - continue; - } - - let mut disconnect = false; - - if let Some(ref mut ipc_client) = client { - let result = match command { - DiscordRpcCommand::SetActivity(playback) => { - let activity = build_activity(&playback); - ipc_client.set_activity(activity) - } - DiscordRpcCommand::ClearActivity => ipc_client.clear_activity(), - }; - - if result.is_err() { - let _ = ipc_client.close(); - disconnect = true; - } - } - - if disconnect { - client = None; - } - } - - if let Some(ref mut client) = client { - let _ = client.clear_activity(); - let _ = client.close(); - } -} - -fn ensure_connected( - app_id: &str, - client: &mut Option<DiscordIpcClient>, - last_connect_attempt: &mut Instant, -) -> bool { - if client.is_some() { - return true; - } - - if last_connect_attempt.elapsed() < Duration::from_secs(5) { - return false; - } - - *last_connect_attempt = Instant::now(); - - let mut new_client = DiscordIpcClient::new(app_id); - match new_client.connect() { - Ok(()) => { - *client = Some(new_client); - true - } - Err(_) => false, - } -} - -fn build_activity(playback: &DiscordPlayback) -> activity::Activity<'_> { - let mut activity = activity::Activity::new() - .details(&playback.title) - .details_url(REPO_URL) - .state(&playback.state) - .state_url(REPO_URL) - .activity_type(activity::ActivityType::Listening); - - if let Some(image_url) = playback.image_url.as_deref() { - let assets = activity::Assets::new() - .large_image(image_url) - .large_text(REPO_URL) - .small_text(REPO_TAGLINE); - activity = activity.assets(assets); - } - - if playback.is_playing && playback.duration_ms > 0 { - let now_secs = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64; - let progress_secs = (playback.progress_ms / 1000) as i64; - let duration_secs = (playback.duration_ms as i64) / 1000; - let start = now_secs.saturating_sub(progress_secs); - let end = start.saturating_add(duration_secs); - - let timestamps = activity::Timestamps::new().start(start).end(end); - activity = activity.timestamps(timestamps); - } - - activity -} diff --git a/src/infra/dj/CLAUDE.md b/src/infra/dj/CLAUDE.md deleted file mode 100644 index 627eb8c0..00000000 --- a/src/infra/dj/CLAUDE.md +++ /dev/null @@ -1,34 +0,0 @@ -### The DJ: lanes, guards, and the avoid-library filter - -The tool surface is defined once - `src/infra/dj/tools.rs::TOOLS` (8 tools). MCP -publishes it verbatim; the in-TUI brain renders the same table into its prompt. - -- **Lanes**: `AskDj` / `DjTopUp` run on the service lane (a brain call can take - minutes; they touch only `App` and use `dispatch_without_spinner` - plain - `dispatch` would pin the global spinner for the whole call). `DjToolCall` / - `DjIndexLibrary` run on the serial lane because they need the real Spotify - client. `DjToolCall` additionally bypasses the auth gate so an unauthenticated - MCP caller gets a diagnosable error instead of a dropped oneshot; - `DjIndexLibrary` does not. -- **Two staleness guards, both load-bearing**: `app.dj.generation` decides whether - a turn's results are still wanted (re-checked before every mutating tool call); - `dj.turn_seq` decides who may clear `dj.thinking` - without it an abandoned turn - clears its replacement's flag and the top-up fires a duplicate refill. -- **The filter is two gates, both needed**: `resolve_suggestions` rejects on the - *name the model gave* before paying for a search; `reject_owned_tracks` rejects - on the *resolved track ID* afterwards (the only gate that sees `uri` entries). - Rejections go in `ResolveReport::in_library` - never `duplicates` (wrong words - for the user), never `unresolved` (tells the model a real track doesn't exist). -- **The filter is OFF by default everywhere** (`behavior.dj_avoid_library: - false`). The in-TUI difference is *ownership*: once the listener toggles it on - (Ctrl+O), `agent::apply_policy` forces `exclude_owned` onto every - `queue_tracks` whether or not the model asked. Over MCP the agent decides per - call; `search_tracks` only *marks* results `owned`; `play_now` is never filtered. -- **Where the crawl runs is a latency decision**: `search_tracks` never crawls - inline - it answers from Liked Songs, dispatches `IoEvent::DjIndexLibrary`, and - reports `ownership_complete: false` (seconds of pagination inside a tool call - would head-of-line-block the serial lane). `queue_tracks(exclude_owned)` is the - one caller that crawls inline via `dj_library_index`, and refuses the call - outright if the crawl fails: it was asked for a guarantee. -- The taste brief is aggregates and names only - no timestamps, IDs, or identity. - That is a privacy property, not a token optimization. diff --git a/src/infra/dj/agent.rs b/src/infra/dj/agent.rs deleted file mode 100644 index 056db741..00000000 --- a/src/infra/dj/agent.rs +++ /dev/null @@ -1,683 +0,0 @@ -//! The in-TUI DJ's turn loop. -//! -//! One turn is several steps: ask the brain what to do, run whatever it asked -//! for, hand the results back, repeat. A step that calls nothing ends the turn — -//! which is what a plain conversational reply is. -//! -//! Runs on the **service** lane, where a brain call may take minutes. The tools -//! themselves do not run here: [`exec::AppExecutor`] answers the read-only ones -//! from the `App` lock and sends the rest down the serial lane, which is the only -//! one with a Spotify client. -//! -//! The loop is bounded because every step of an `agent_cli` turn is a fresh -//! subprocess, so an unbounded turn would spend the listener's subscription and -//! their patience. - -use super::brain::{DjBrain, ToolExchange, ToolInvocation}; -use super::exec::ToolExecutor; -use super::session::TurnContext; -use super::tools::{self, DjToolCall, ToolCallError}; -use super::{DjLine, DjSpeaker}; -use crate::core::app::App; -use std::sync::Arc; -use tokio::sync::Mutex; - -/// Steps a conversational turn may take. -/// -/// Four is room to look something up, search, queue, and report — while bounding -/// an agent-CLI turn to four subprocesses. -pub const MAX_STEPS: usize = 4; - -/// Steps an auto-queue refill or a vibe shift may take. -/// -/// Half, because the queue runway is the budget. `QUEUE_LOW_WATER` is two tracks, -/// roughly six to eight minutes; four steps at the default 90-second agent -/// timeout could eat all of it, and the refill would land after the music had -/// already stopped. `queue_tracks` resolves names itself, so a refill has no need -/// of a search step anyway. -pub const MAX_STEPS_MUST_ACT: usize = 2; - -/// Tool calls executed from a single step. A model that asks for twenty in one -/// breath gets the first few run and the rest reported back unrun. -const MAX_CALLS_PER_STEP: usize = 4; - -/// How a turn ended. -pub struct TurnOutcome { - /// Whether any tool call actually changed the queue or playback. Drives whether - /// the listener's words become the standing vibe. - pub acted: bool, - /// Set when the turn was abandoned because the generation moved on. - pub abandoned: bool, - /// Whether the model set the standing vibe itself, which its own words should - /// not then be overwritten by. - pub vibe_set: bool, -} - -/// Everything one turn needs. -pub struct Turn<'a, E: ToolExecutor> { - pub app: &'a Arc<Mutex<App>>, - pub brain: &'a DjBrain, - pub context: &'a TurnContext, - pub executor: &'a E, - /// The generation this turn belongs to. A bump the turn did not cause means the - /// listener has moved on and everything after it is unwanted. - pub generation: u64, - /// Whether this turn has to end in music rather than words. - pub must_act: bool, -} - -impl<E: ToolExecutor> Turn<'_, E> { - pub async fn run(mut self) -> anyhow::Result<TurnOutcome> { - let max_steps = if self.must_act { - MAX_STEPS_MUST_ACT - } else { - MAX_STEPS - }; - let mut scratch: Vec<ToolExchange> = Vec::new(); - let mut acted = false; - let mut vibe_set = false; - - for step_number in 1..=max_steps { - if self.abandoned().await { - return Ok(TurnOutcome { - acted, - abandoned: true, - vibe_set, - }); - } - self.show_step(step_number, max_steps).await; - - let request = self.context.to_request(scratch.clone(), self.must_act); - let step = self.brain.step(&request).await?; - - if let Some(say) = step.say.filter(|say| !say.trim().is_empty()) { - self.push(DjLine::dj(say)).await; - } - if step.calls.is_empty() { - return Ok(TurnOutcome { - acted, - abandoned: false, - vibe_set, - }); - } - - for call in step.calls.iter().take(MAX_CALLS_PER_STEP) { - match self.run_call(call).await { - CallResult::Ran { - exchange, - mutated, - set_vibe, - } => { - acted |= mutated; - vibe_set |= set_vibe; - scratch.push(exchange); - } - // The listener has moved on mid-turn. Stop before running anything else - // against a session they have left. - CallResult::Abandoned => { - return Ok(TurnOutcome { - acted, - abandoned: true, - vibe_set, - }) - } - } - } - if step.calls.len() > MAX_CALLS_PER_STEP { - scratch.push(ToolExchange { - name: "spotatui".into(), - arguments: serde_json::json!({}), - result: format!( - "Only the first {MAX_CALLS_PER_STEP} of your {} tool calls were run. Ask for the rest \ - next step if you still need them.", - step.calls.len() - ), - }); - } - } - - // Out of steps with the model still working. Said out loud: silence here looks - // identical to a hang, and on an agent CLI the turn has just cost real quota. - self - .push(DjLine::system(format!( - "Stopped after {max_steps} steps. Ask again if that is not where you wanted to end up." - ))) - .await; - Ok(TurnOutcome { - acted, - abandoned: false, - vibe_set, - }) - } - - /// Parse, apply the in-TUI policy, execute, and report one call. - async fn run_call(&mut self, call: &ToolInvocation) -> CallResult { - let parsed = match tools::parse_call(&call.name, &call.arguments) { - Ok(parsed) => self.apply_policy(parsed), - // Handed back to the model rather than aborting: a malformed call is - // something it can correct on the next step, which is the entire benefit of - // running a loop. - Err(e) => { - let detail = match &e { - ToolCallError::UnknownTool(name) => { - format!("There is no tool called {name}. Use one of the tools listed above.") - } - ToolCallError::InvalidArguments(detail) => format!("Invalid arguments: {detail}"), - }; - self.show_call(&call.name, &detail).await; - return CallResult::Ran { - exchange: ToolExchange { - name: call.name.clone(), - arguments: call.arguments.clone(), - result: detail, - }, - mutated: false, - set_vibe: false, - }; - } - }; - - let mutated = !tools::spec(parsed.tool_name()).is_some_and(|spec| spec.read_only); - // Checked immediately before the call, not merely at the top of the step: a - // brain call takes long enough for the listener to shift the vibe or leave, - // and queueing the old direction's tracks after that is the bug this prevents. - if mutated && self.abandoned().await { - return CallResult::Abandoned; - } - - // Noted before the call consumes it: `set_dj_vibe` bumps the generation - // itself, and that one bump has to be told apart from the listener's. - let sets_vibe = matches!(parsed, DjToolCall::SetDjVibe { .. }); - let before = self.generation; - let outcome = self.executor.call(parsed).await; - - // A serial-lane call takes seconds, which is ample time for the listener to - // hit vibe-shift. Adopting every bump would swallow theirs and leave a stale - // turn queueing alongside the one they just started, so only the single bump - // our own `set_dj_vibe` makes is adopted. Their bump on top of ours moves the - // counter by two, and still aborts. - let after = { self.app.lock().await.dj.generation }; - if after != before { - if sets_vibe && !outcome.is_error && after == before.wrapping_add(1) { - self.generation = after; - } else { - return CallResult::Abandoned; - } - } - self.show_call(&call.name, &outcome.text).await; - - CallResult::Ran { - exchange: ToolExchange { - name: call.name.clone(), - arguments: call.arguments.clone(), - result: outcome.text, - }, - mutated: mutated && !outcome.is_error, - set_vibe: sets_vibe && !outcome.is_error, - } - } - - /// The rules that make this the in-TUI DJ rather than a bare MCP client. - fn apply_policy(&self, call: DjToolCall) -> DjToolCall { - match call { - DjToolCall::QueueTracks { - items, - exclude_owned, - .. - } => DjToolCall::QueueTracks { - items, - // In-TUI the filter is a toggle the listener set, so it applies whether or - // not the model thought to ask. Over MCP the agent decides for itself. - exclude_owned: exclude_owned || self.context.avoid_library, - // The recently-played window. Enforced here rather than in - // `App::dj_skip_keys` so an MCP agent told to queue a specific track still - // gets it, however recently it played. - extra_skip_keys: self.context.recent_keys(), - }, - other => other, - } - } - - async fn abandoned(&self) -> bool { - self.app.lock().await.dj.generation != self.generation - } - - async fn push(&self, line: DjLine) { - let mut app = self.app.lock().await; - app.dj.push_line(line); - } - - /// One transcript line per tool call, so the listener sees the DJ work rather - /// than watching it stall. - async fn show_call(&self, name: &str, result: &str) { - let summary = first_line(result); - self - .push(DjLine { - speaker: DjSpeaker::System, - text: format!("· {name} → {summary}"), - }) - .await; - } - - async fn show_step(&self, step: usize, of: usize) { - let mut app = self.app.lock().await; - app.dj.step = Some((step, of)); - } -} - -enum CallResult { - Ran { - exchange: ToolExchange, - mutated: bool, - set_vibe: bool, - }, - Abandoned, -} - -/// The first line of a tool result, bounded. -/// -/// A queue report runs to several lines and a search to twenty; the transcript -/// wants the headline, and the model gets the whole thing through `scratch` -/// either way. -fn first_line(text: &str) -> String { - const MAX: usize = 120; - let line = text - .lines() - .find(|line| !line.trim().is_empty()) - .unwrap_or(""); - if line.chars().count() <= MAX { - return line.to_string(); - } - line.chars().take(MAX).collect::<String>() + "…" -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::infra::dj::brief::TasteBrief; - use crate::infra::dj::tools::ToolOutcome; - - fn context(avoid_library: bool) -> TurnContext { - TurnContext { - brief: TasteBrief { - recent_keys: vec!["nightcall|kavinsky".to_string()], - ..TasteBrief::default() - }, - history: vec![], - want: 6, - avoid_library, - } - } - - /// Records what it was asked to run and answers from a script. - struct ScriptedExecutor { - calls: std::sync::Mutex<Vec<DjToolCall>>, - } - - impl ScriptedExecutor { - fn new() -> Self { - Self { - calls: std::sync::Mutex::new(Vec::new()), - } - } - fn seen(&self) -> Vec<DjToolCall> { - self.calls.lock().unwrap().clone() - } - } - - impl ToolExecutor for ScriptedExecutor { - async fn call(&self, call: DjToolCall) -> ToolOutcome { - self.calls.lock().unwrap().push(call); - ToolOutcome::ok("done") - } - } - - fn turn<'a>( - app: &'a Arc<Mutex<App>>, - brain: &'a DjBrain, - context: &'a TurnContext, - executor: &'a ScriptedExecutor, - ) -> Turn<'a, ScriptedExecutor> { - Turn { - app, - brain, - context, - executor, - generation: 0, - must_act: false, - } - } - - #[test] - fn the_filter_toggle_is_applied_whether_or_not_the_model_asked() { - // In-TUI the filter is the listener's toggle, so it cannot depend on the model - // remembering to pass `exclude_owned`. - let app = Arc::new(Mutex::new(App::default())); - let brain = DjBrain::OpenAiCompat(super::super::brain::openai_compat::OpenAiCompatBrain::new( - None, None, None, - )); - let executor = ScriptedExecutor::new(); - let filtered = context(true); - let turn = turn(&app, &brain, &filtered, &executor); - - let call = DjToolCall::QueueTracks { - items: vec![], - exclude_owned: false, - extra_skip_keys: vec![], - }; - match turn.apply_policy(call) { - DjToolCall::QueueTracks { - exclude_owned, - extra_skip_keys, - .. - } => { - assert!(exclude_owned, "the toggle has to win over the model"); - // And the recently-played window travels with the call rather than living - // in the shared skip set, where it would also filter MCP callers. - assert_eq!(extra_skip_keys, vec!["nightcall|kavinsky".to_string()]); - } - other => panic!("policy changed the call into {other:?}"), - } - } - - #[test] - fn an_unfiltered_session_leaves_the_models_own_choice_alone() { - let app = Arc::new(Mutex::new(App::default())); - let brain = DjBrain::OpenAiCompat(super::super::brain::openai_compat::OpenAiCompatBrain::new( - None, None, None, - )); - let executor = ScriptedExecutor::new(); - let unfiltered = context(false); - let turn = turn(&app, &brain, &unfiltered, &executor); - - for asked in [true, false] { - let call = DjToolCall::QueueTracks { - items: vec![], - exclude_owned: asked, - extra_skip_keys: vec![], - }; - match turn.apply_policy(call) { - DjToolCall::QueueTracks { exclude_owned, .. } => assert_eq!(exclude_owned, asked), - other => panic!("policy changed the call into {other:?}"), - } - } - assert!( - executor.seen().is_empty(), - "policy must not execute anything" - ); - } - - #[test] - fn a_tool_result_is_summarised_to_one_line_for_the_transcript() { - let long = format!("Queued 6 track(s):\n{}", "A — B\n".repeat(10)); - assert_eq!(first_line(&long), "Queued 6 track(s):"); - assert!(first_line(&"x".repeat(400)).ends_with('…')); - assert_eq!(first_line("\n\n \nreal line"), "real line"); - assert_eq!(first_line(""), ""); - } - - #[test] - fn a_refill_gets_fewer_steps_than_a_conversation() { - // The queue runway is the budget: four agent-CLI steps can outlast it. - assert!(MAX_STEPS_MUST_ACT < MAX_STEPS); - } - - // --- the loop itself, driven end to end --------------------------------- - // - // A stub agent CLI stands in for the brain, because it is the only backend - // whose "model" can be scripted without a network. Each step is a fresh - // invocation, so a stub that branches on whether the prompt already contains - // tool results is exactly a multi-step model. - - /// Every stub these tests exec, written before any of them spawns anything. - /// - /// The ordering is load-bearing, for the reason `agent_cli`'s own `stubs()` - /// documents: `fork` duplicates every open fd, so a test writing a stub while - /// another forks leaks a *writable* fd for that file into the child, and - /// exec'ing it then fails with `ETXTBSY`. Renaming into place does **not** help - /// — the fd refers to the inode, which the rename carries with it. Writing the - /// whole set behind one `OnceLock` does, because by the time any fork happens - /// there is no writable fd left to inherit. - #[cfg(unix)] - fn stubs() -> &'static std::collections::HashMap<&'static str, std::path::PathBuf> { - static STUBS: std::sync::OnceLock<std::collections::HashMap<&'static str, std::path::PathBuf>> = - std::sync::OnceLock::new(); - STUBS.get_or_init(|| { - use std::os::unix::fs::PermissionsExt; - let dir = std::env::temp_dir().join(format!("spotatui-agent-stubs-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - [ - // Calls a tool, then answers once it can see the result. - ( - "two-step", - r#"PROMPT=$(cat) -case "$PROMPT" in - *"Tools you have already run"*) echo '{"say":"Two tracks waiting.","tool_calls":[]}' ;; - *) echo '{"say":"Let me look.","tool_calls":[{"name":"get_queue","arguments":{}}]}' ;; -esac"#, - ), - ( - "queues-once", - r#"cat >/dev/null -echo '{"say":"Queueing.","tool_calls":[{"name":"queue_tracks","arguments":{"tracks":[{"title":"Nude","artist":"Radiohead"}]}}]}'"#, - ), - ( - "sets-vibe", - r#"PROMPT=$(cat) -case "$PROMPT" in - *"Tools you have already run"*) echo '{"say":"Set.","tool_calls":[]}' ;; - *) echo '{"say":"Noting that.","tool_calls":[{"name":"set_dj_vibe","arguments":{"vibe":"deep focus"}}]}' ;; -esac"#, - ), - // Never stops, so the step cap is the only thing that ends the turn. - ( - "never-stops", - r#"cat >/dev/null -echo '{"say":"Still going.","tool_calls":[{"name":"get_queue","arguments":{}}]}'"#, - ), - ] - .into_iter() - .map(|(name, body)| { - let path = dir.join(name); - std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); - (name, path) - }) - .collect() - }) - } - - #[cfg(unix)] - fn stub_brain(name: &str) -> DjBrain { - use super::super::brain::agent_cli::{AgentCliBrain, PromptDelivery}; - let path = stubs()[name].clone(); - let dir = path.parent().unwrap().to_path_buf(); - DjBrain::AgentCli( - AgentCliBrain::new( - vec![path.to_string_lossy().to_string()], - PromptDelivery::Stdin, - std::time::Duration::from_secs(10), - dir, - ) - .unwrap(), - ) - } - - /// Answers a scripted outcome and can disturb the app while it "runs". - struct SlowExecutor { - app: Arc<Mutex<App>>, - /// Simulates the listener hitting vibe-shift during a serial-lane call. - bump_during_call: bool, - calls: std::sync::Mutex<Vec<DjToolCall>>, - } - - impl ToolExecutor for SlowExecutor { - async fn call(&self, call: DjToolCall) -> ToolOutcome { - self.calls.lock().unwrap().push(call); - if self.bump_during_call { - self.app.lock().await.dj.bump_generation(); - } - ToolOutcome::ok("done") - } - } - - fn slow_executor(app: &Arc<Mutex<App>>, bump_during_call: bool) -> SlowExecutor { - SlowExecutor { - app: Arc::clone(app), - bump_during_call, - calls: std::sync::Mutex::new(Vec::new()), - } - } - - #[cfg(unix)] - #[tokio::test] - async fn a_tool_result_feeds_the_next_step_and_the_turn_ends_on_words() { - // The loop's whole contract in one test: call, feed the result back, and stop - // when the model has something to say and nothing to run. - let brain = stub_brain("two-step"); - let app = Arc::new(Mutex::new(App::default())); - let executor = slow_executor(&app, false); - let context = context(false); - - let outcome = Turn { - app: &app, - brain: &brain, - context: &context, - executor: &executor, - generation: 0, - must_act: false, - } - .run() - .await - .unwrap(); - - assert!(!outcome.abandoned); - assert!(!outcome.acted, "get_queue only reads"); - assert_eq!(executor.calls.lock().unwrap().len(), 1); - - let transcript: Vec<String> = app - .lock() - .await - .dj - .transcript - .iter() - .map(|line| line.text.clone()) - .collect(); - assert!( - transcript.iter().any(|t| t == "Let me look."), - "{transcript:?}" - ); - // Only reachable if the second invocation saw the first call's result. - assert!( - transcript.iter().any(|t| t == "Two tracks waiting."), - "{transcript:?}" - ); - assert!( - transcript.iter().any(|t| t.starts_with("· get_queue")), - "the listener has to see the DJ work: {transcript:?}" - ); - } - - #[cfg(unix)] - #[tokio::test] - async fn a_listener_bump_during_a_call_abandons_the_turn() { - // The race the generation guard exists for: a serial-lane call takes seconds, - // and a vibe shift landing inside that window must stop this turn rather than - // have it queue on alongside the one the listener just started. - let brain = stub_brain("queues-once"); - let app = Arc::new(Mutex::new(App::default())); - let executor = slow_executor(&app, true); - let context = context(false); - - let outcome = Turn { - app: &app, - brain: &brain, - context: &context, - executor: &executor, - generation: 0, - must_act: false, - } - .run() - .await - .unwrap(); - - assert!( - outcome.abandoned, - "a bump the loop did not cause has to end the turn" - ); - assert_eq!( - executor.calls.lock().unwrap().len(), - 1, - "and nothing may run after it" - ); - } - - #[cfg(unix)] - #[tokio::test] - async fn the_dj_s_own_vibe_call_does_not_abort_its_own_turn() { - // `set_dj_vibe` bumps the generation itself. Treating that as the listener - // moving on would have the DJ kill its turn the moment it set a vibe. - let brain = stub_brain("sets-vibe"); - let app = Arc::new(Mutex::new(App::default())); - // The real tool, so the real bump: a stub executor would not reproduce this. - let (tx, _rx) = std::sync::mpsc::channel(); - let executor = super::super::exec::AppExecutor::silent(Arc::clone(&app), tx); - let context = context(false); - - let outcome = Turn { - app: &app, - brain: &brain, - context: &context, - executor: &executor, - generation: 0, - must_act: false, - } - .run() - .await - .unwrap(); - - assert!(!outcome.abandoned, "the DJ must survive its own vibe call"); - assert!(outcome.vibe_set, "so the typed words do not overwrite it"); - assert_eq!(app.lock().await.dj.vibe.as_deref(), Some("deep focus")); - } - - #[cfg(unix)] - #[tokio::test] - async fn a_model_that_never_stops_is_capped_and_says_so() { - let brain = stub_brain("never-stops"); - let app = Arc::new(Mutex::new(App::default())); - let executor = slow_executor(&app, false); - let context = context(false); - - let outcome = Turn { - app: &app, - brain: &brain, - context: &context, - executor: &executor, - generation: 0, - must_act: false, - } - .run() - .await - .unwrap(); - - assert!(!outcome.abandoned); - assert_eq!(executor.calls.lock().unwrap().len(), MAX_STEPS); - let transcript: Vec<String> = app - .lock() - .await - .dj - .transcript - .iter() - .map(|line| line.text.clone()) - .collect(); - // Silence after a capped turn is indistinguishable from a hang, and on an - // agent CLI it has just cost four subprocesses of real quota. - assert!( - transcript - .iter() - .any(|t| t.contains(&format!("Stopped after {MAX_STEPS} steps"))), - "{transcript:?}" - ); - } -} diff --git a/src/infra/dj/brain/agent_cli.rs b/src/infra/dj/brain/agent_cli.rs deleted file mode 100644 index cd595039..00000000 --- a/src/infra/dj/brain/agent_cli.rs +++ /dev/null @@ -1,696 +0,0 @@ -//! The agent-CLI brain: reuse a coding agent the user already has. -//! -//! `claude`, `codex`, `agy` (Antigravity, which replaced Google's Gemini CLI), -//! and friends all run headless and read a prompt from stdin or argv, so spotatui -//! can borrow whichever one the user already has installed and authenticated. -//! **No API key is involved** — the agent uses the subscription it is already -//! logged into. -//! -//! The command line is a **config field, not a hardcoded table**: the user -//! supplies argv, spotatui writes the prompt and reads stdout. Presets exist as -//! conveniences, but this is what makes the backend genuinely -//! provider-agnostic, and it means spotatui never has to track any CLI's flag -//! churn. -//! -//! Each step is a fresh subprocess with several seconds of startup cost, so a -//! multi-step turn is genuinely expensive here — which is why [`super::super::agent`] -//! caps the number of steps rather than letting a turn run as long as it likes. - -use super::{parse_step, system_prompt, user_prompt, DjRequest, DjStep}; -use anyhow::{anyhow, Context, Result}; -use std::path::PathBuf; -use std::process::Stdio; -use std::time::Duration; -use tokio::io::AsyncWriteExt; -use tokio::process::Command; - -/// How the prompt reaches the CLI. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum PromptDelivery { - /// Written to the child's stdin. Verified for `claude -p` and `codex exec -`. - #[default] - Stdin, - /// Appended as the final argv entry, so the preceding flag receives it as its - /// value. `agy` (and the legacy `gemini`) **require** this: `agy` ignores stdin - /// entirely, and a prompt written there is silently dropped in favour of - /// whatever the CLI decides to answer on its own. - Arg, -} - -impl PromptDelivery { - pub fn from_config_str(value: &str) -> Option<Self> { - match value.trim().to_ascii_lowercase().as_str() { - "stdin" => Some(Self::Stdin), - "arg" | "argv" | "lastarg" | "last-arg" => Some(Self::Arg), - _ => None, - } - } - - pub fn to_config_str(self) -> &'static str { - match self { - Self::Stdin => "stdin", - Self::Arg => "arg", - } - } -} - -/// A known agent CLI: how to run it headless, and how to name a model. -/// -/// Split into `base` + `delivery_argv` rather than one flat argv because the -/// model flag has to land **between** them. [`AgentCliBrain::run`] appends the -/// prompt as the last argv entry for [`PromptDelivery::Arg`], so a flag appended -/// to the tail would swallow the prompt: `agy -p --model X "<prompt>"` passes -/// `--model` as the value of `-p`. Verified argv shapes: -/// -/// ```text -/// claude --model haiku -p (prompt on stdin) -/// codex exec --model gpt-5 - (prompt on stdin, `-` is the positional) -/// agy --model gemini-3.6-flash-low -p "…" (prompt as the value of -p) -/// copilot --no-color --model … -p "…" (prompt as the value of -p) -/// opencode run -m openai/gpt-5.4 "…" (prompt as a bare trailing positional) -/// gemini -m … -p "…" (legacy) -/// ``` -pub struct AgentPreset { - /// Lookup key. Also the binary name, which is what detection stats on `PATH`. - pub key: &'static str, - /// argv before the model flag. `base[0]` is the program. - pub base: &'static [&'static str], - /// argv after the model flag: the flag or positional that carries the prompt. - /// - /// Empty for a CLI that takes the prompt as a bare trailing positional - /// (`opencode run "…"`), since [`AgentCliBrain::run`] appends it either way. - pub delivery_argv: &'static [&'static str], - /// How this CLI names a model. `None` means it cannot be told. - pub model_flag: Option<&'static str>, - pub delivery: PromptDelivery, -} - -impl AgentPreset { - /// Full argv, with the model flag inserted before the delivery token. - /// - /// `None` reproduces byte-for-byte what the flat preset table produced before - /// the model flag existed, which is what keeps an install that never chose a - /// model on exactly its previous behaviour. - pub fn argv(&self, model: Option<&str>) -> Vec<String> { - let mut argv: Vec<String> = self.base.iter().map(|part| part.to_string()).collect(); - if let (Some(flag), Some(model)) = (self.model_flag, model) { - argv.push(flag.to_string()); - argv.push(model.to_string()); - } - argv.extend(self.delivery_argv.iter().map(|part| part.to_string())); - argv - } -} - -/// Convenience presets. Verified against the installed CLIs' own `--help`. -pub const PRESETS: &[AgentPreset] = &[ - AgentPreset { - key: "claude", - base: &["claude"], - delivery_argv: &["-p"], - model_flag: Some("--model"), - delivery: PromptDelivery::Stdin, - }, - AgentPreset { - key: "codex", - base: &["codex", "exec"], - delivery_argv: &["-"], - model_flag: Some("--model"), - delivery: PromptDelivery::Stdin, - }, - // Antigravity, which replaced the Gemini CLI. `agy` IGNORES stdin: the prompt - // has to be the value of `-p`, so `Arg` here is load-bearing, not a preference. - AgentPreset { - key: "agy", - base: &["agy"], - delivery_argv: &["-p"], - model_flag: Some("--model"), - delivery: PromptDelivery::Arg, - }, - // `--no-color` keeps ANSI escapes out of the JSON. A plain completion needs no - // `--allow-all-tools`, despite what the non-interactive help implies. - AgentPreset { - key: "copilot", - base: &["copilot", "--no-color"], - delivery_argv: &["-p"], - model_flag: Some("--model"), - delivery: PromptDelivery::Arg, - }, - // The prompt is a trailing positional here, not a flag value, so nothing - // carries it and `delivery_argv` is empty. - AgentPreset { - key: "opencode", - base: &["opencode", "run"], - delivery_argv: &[], - model_flag: Some("-m"), - delivery: PromptDelivery::Arg, - }, - // Legacy. Google superseded it with `agy`; kept so an existing config that - // names it keeps working. - AgentPreset { - key: "gemini", - base: &["gemini"], - delivery_argv: &["-p"], - model_flag: Some("-m"), - delivery: PromptDelivery::Arg, - }, -]; - -/// Look up a preset by the binary name, for config convenience. -pub fn preset(name: &str) -> Option<&'static AgentPreset> { - PRESETS.iter().find(|preset| preset.key == name) -} - -#[derive(Debug)] -pub struct AgentCliBrain { - command: Vec<String>, - prompt_via: PromptDelivery, - timeout: Duration, - cwd: PathBuf, -} - -impl AgentCliBrain { - /// `cwd` should be a scratch directory, **not** the user's current directory. - /// - /// Coding agents read `CLAUDE.md` / `AGENTS.md` and project files from their - /// working directory. Pointed at a repository, the DJ gets a prompt polluted - /// with codebase context and starts suggesting tracks about Rust. - pub fn new( - command: Vec<String>, - prompt_via: PromptDelivery, - timeout: Duration, - cwd: PathBuf, - ) -> Result<Self> { - if command.is_empty() { - return Err(anyhow!( - "behavior.dj_agent_command is empty; set it to the agent CLI to run, e.g. [\"claude\", \"-p\"]" - )); - } - Ok(Self { - command, - prompt_via, - timeout, - cwd, - }) - } - - pub async fn step(&self, request: &DjRequest) -> Result<DjStep> { - let prompt = format!("{}\n\n{}", system_prompt(), user_prompt(request)); - let stdout = self.run(&prompt).await?; - parse_step(&stdout).with_context(|| { - format!( - "could not read a decision out of `{}` output", - self.command.join(" ") - ) - }) - } - - async fn run(&self, prompt: &str) -> Result<String> { - // The scratch directory is created lazily; a missing cwd makes `spawn` fail - // with a confusing ENOENT that looks like a missing binary. - if let Err(e) = tokio::fs::create_dir_all(&self.cwd).await { - log::debug!("DJ: could not create the agent scratch dir: {e}"); - } - - let (program, args) = self.command.split_first().expect("checked non-empty"); - let mut command = Command::new(program); - command - .args(args) - .current_dir(&self.cwd) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .kill_on_drop(true); - - match self.prompt_via { - PromptDelivery::Stdin => { - command.stdin(Stdio::piped()); - } - PromptDelivery::Arg => { - command.arg(prompt).stdin(Stdio::null()); - } - } - - // The OS error and the cwd go in the message: `spawn` fails with the same - // ENOENT for a missing binary and for a cwd it cannot enter (#478), and the - // transcript shows only the top of the error chain. - let mut child = command.spawn().map_err(|e| { - anyhow!( - "could not run `{program}` from `{}` ({e}). Is it installed and on PATH? (behavior.dj_agent_command)", - self.cwd.display() - ) - })?; - - let stdin = match self.prompt_via { - PromptDelivery::Stdin => Some( - child - .stdin - .take() - .ok_or_else(|| anyhow!("could not open stdin for `{program}`"))?, - ), - PromptDelivery::Arg => None, - }; - - // The write and the wait have to run *together*. The prompt is not small — - // it carries every tool schema plus this turn's tool results — and an agent - // CLI starts printing to stdout immediately. Writing it all first deadlocks - // once both pipes fill: we wait for the child to read, the child waits for - // us to read. Worse, that hang is in `write_all`, before the timeout below - // is ever armed, so the serial IoEvent pump stops for good. - let feed = async { - // Dropping stdin is what tells the CLI the prompt is complete; without it - // the child waits for more input and we hit the timeout instead. - if let Some(mut stdin) = stdin { - stdin.write_all(prompt.as_bytes()).await?; - stdin.shutdown().await?; - } - Ok::<_, std::io::Error>(()) - }; - let feed_and_wait = async { - let (written, output) = tokio::join!(feed, child.wait_with_output()); - written.with_context(|| format!("could not send the prompt to `{program}`"))?; - output.with_context(|| format!("`{program}` failed to run")) - }; - - let output = match tokio::time::timeout(self.timeout, feed_and_wait).await { - Ok(result) => result?, - // `kill_on_drop` reaps the child when the future is dropped here. - Err(_) => { - return Err(anyhow!( - "`{program}` did not answer within {}s", - self.timeout.as_secs() - )) - } - }; - - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - // Some CLIs write a usable answer to stdout and still exit non-zero, so - // only fail outright when there is nothing to read. - if stdout.trim().is_empty() { - return Err(anyhow!( - "`{program}` exited with {}: {}", - output.status, - stderr.trim().chars().take(300).collect::<String>() - )); - } - log::debug!("DJ: `{program}` exited non-zero but produced output; using it"); - } - Ok(stdout) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn request() -> DjRequest { - DjRequest { - want: 2, - ..DjRequest::default() - } - } - - /// Every agent stand-in these tests exec, keyed by name. - /// - /// **All of them are written before any test spawns anything, and that is the - /// point** — it is not merely tidier than writing each on demand. - /// - /// `fork` duplicates every file descriptor open in the process at that instant. - /// A test writing its stub while another test forks leaks a *writable* fd for - /// that stub into the new child, which holds it for the sliver of time before its - /// own `exec`. Exec'ing a file that any process holds open for writing fails with - /// `ETXTBSY`, so the first test lost a coin flip roughly one run in five. - /// - /// Renaming the file into place does **not** fix this: the stray fd refers to the - /// inode, which the rename carries along with it. Only ordering fixes it. Every - /// test calls [`stub`] before it spawns, and the first call writes the whole set - /// while the rest block on the `OnceLock`, so by the time any `fork` happens - /// there is no writable fd left to inherit. - /// - /// The directory is per-process for the adjacent reason: a fixed path collides - /// with a previous `cargo test` run still finishing. - fn stubs() -> &'static std::collections::HashMap<&'static str, PathBuf> { - static STUBS: std::sync::OnceLock<std::collections::HashMap<&'static str, PathBuf>> = - std::sync::OnceLock::new(); - STUBS.get_or_init(|| { - let dir = std::env::temp_dir().join(format!("spotatui-dj-stubs-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - [ - ( - "fake-dj", - r#"cat >/dev/null -echo 'Here you go: ```json' -echo '{"say":"Some mellow stuff.","tool_calls":[{"name":"queue_tracks","arguments":{"tracks":[{"title":"Weird Fishes","artist":"Radiohead"}]}}]}' -echo '```'"#, - ), - // Echoes back what it read, so a prompt that never arrived fails the test - // rather than silently producing a generic answer. - ( - "echo-dj", - r#"PROMPT=$(cat) -case "$PROMPT" in - *"Take the next step now"*) echo '{"say":"got it","tool_calls":[]}' ;; - *) echo '{"say":"no prompt received","tool_calls":[]}' ;; -esac"#, - ), - ( - "arg-dj", - r#"case "$*" in - *"Take the next step now"*) echo '{"say":"via argv","tool_calls":[]}' ;; - *) echo '{"say":"missing","tool_calls":[]}' ;; -esac"#, - ), - ( - "grumpy-dj", - r#"cat >/dev/null -echo '{"say":"fine","tool_calls":[]}' -exit 3"#, - ), - ( - "broken-dj", - r#"cat >/dev/null -echo 'not logged in' >&2 -exit 1"#, - ), - ("chatty-dj", "cat >/dev/null\necho 'I would rather not.'"), - // Fills its stdout pipe *before* it reads a byte of stdin, which is what - // a real agent CLI does with its progress chatter. Feed it a prompt - // larger than the pipe buffer and only a concurrent write survives. - ( - "backpressure-dj", - r#"i=0 -while [ $i -lt 2000 ]; do - echo 'still thinking .............................................................' - i=$((i + 1)) -done -PROMPT=$(cat) -case "$PROMPT" in - *"Take the next step now"*) echo '{"say":"drained","tool_calls":[]}' ;; - *) echo '{"say":"no prompt received","tool_calls":[]}' ;; -esac"#, - ), - ("slow-dj", "sleep 30"), - ] - .into_iter() - .map(|(name, body)| { - let path = dir.join(name); - std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); - } - (name, path) - }) - .collect() - }) - } - - /// Path of one stand-in, as an argv entry. - fn stub(name: &str) -> String { - stubs()[name].to_string_lossy().to_string() - } - - fn scratch() -> PathBuf { - std::env::temp_dir().join("spotatui-dj-scratch") - } - - fn brain(argv: Vec<String>, delivery: PromptDelivery) -> AgentCliBrain { - AgentCliBrain::new(argv, delivery, Duration::from_secs(10), scratch()).unwrap() - } - - #[test] - fn an_empty_command_is_rejected_with_an_actionable_message() { - let err = AgentCliBrain::new( - vec![], - PromptDelivery::Stdin, - Duration::from_secs(1), - scratch(), - ) - .unwrap_err() - .to_string(); - assert!(err.contains("dj_agent_command")); - } - - #[cfg(unix)] - #[tokio::test] - async fn reads_a_fenced_reply_from_stdout() { - let command = stub("fake-dj"); - let reply = brain(vec![command.clone()], PromptDelivery::Stdin) - .step(&request()) - .await - .unwrap(); - assert_eq!(reply.say.as_deref(), Some("Some mellow stuff.")); - assert_eq!(reply.calls[0].name, "queue_tracks"); - } - - #[cfg(unix)] - #[tokio::test] - async fn a_prior_tool_result_reaches_the_next_step() { - // The loop's whole premise: what a tool returned has to be in the next - // prompt, or the DJ cannot act on what it just learned. - let command = stub("echo-dj"); - let request = DjRequest { - want: 2, - scratch: vec![super::super::ToolExchange { - name: "search_tracks".into(), - arguments: serde_json::json!({"query": "nude radiohead"}), - result: "Nude — Radiohead [spotify:track:abc] [new]".into(), - }], - ..DjRequest::default() - }; - let prompt = user_prompt(&request); - assert!(prompt.contains("search_tracks"), "{prompt}"); - assert!(prompt.contains("spotify:track:abc"), "{prompt}"); - // And the stub still answers, so the enlarged prompt is still deliverable. - let reply = brain(vec![command], PromptDelivery::Stdin) - .step(&request) - .await - .unwrap(); - assert_eq!(reply.say.as_deref(), Some("got it")); - } - - #[cfg(unix)] - #[tokio::test] - async fn stdin_delivery_actually_sends_the_prompt() { - // Echoes back what it read, so a prompt that never arrived would fail here - // rather than silently producing a generic answer. - let command = stub("echo-dj"); - let reply = brain(vec![command.clone()], PromptDelivery::Stdin) - .step(&request()) - .await - .unwrap(); - assert_eq!(reply.say.as_deref(), Some("got it")); - } - - /// A prompt bigger than the pipe buffer, against a CLI that talks first. - /// - /// Writing the whole prompt before collecting the output deadlocks here, and the - /// outer timeout is the point of the test: that hang lands in `write_all`, - /// *before* the brain arms its own timeout, so a regression would otherwise stop - /// the suite rather than fail it. - #[cfg(unix)] - #[tokio::test] - async fn a_prompt_larger_than_the_pipe_buffer_does_not_deadlock() { - let command = stub("backpressure-dj"); - let request = DjRequest { - want: 2, - scratch: vec![super::super::ToolExchange { - name: "search_tracks".into(), - arguments: serde_json::json!({"query": "everything"}), - result: "Nude — Radiohead [spotify:track:abc] [new]\n".repeat(4000), - }], - ..DjRequest::default() - }; - let prompt = format!("{}\n\n{}", system_prompt(), user_prompt(&request)); - assert!( - prompt.len() > 64 * 1024, - "the prompt must exceed the pipe buffer to exercise this at all, was {}", - prompt.len() - ); - - let reply = tokio::time::timeout( - Duration::from_secs(30), - brain(vec![command], PromptDelivery::Stdin).step(&request), - ) - .await - .expect("deadlocked: the prompt write must run alongside the output read") - .unwrap(); - assert_eq!(reply.say.as_deref(), Some("drained")); - } - - #[cfg(unix)] - #[tokio::test] - async fn arg_delivery_passes_the_prompt_as_the_final_argument() { - let command = stub("arg-dj"); - let reply = brain(vec![command.clone()], PromptDelivery::Arg) - .step(&request()) - .await - .unwrap(); - assert_eq!(reply.say.as_deref(), Some("via argv")); - } - - #[cfg(unix)] - #[tokio::test] - async fn a_nonzero_exit_with_usable_output_is_still_accepted() { - let command = stub("grumpy-dj"); - let reply = brain(vec![command.clone()], PromptDelivery::Stdin) - .step(&request()) - .await - .unwrap(); - assert_eq!(reply.say.as_deref(), Some("fine")); - } - - #[cfg(unix)] - #[tokio::test] - async fn a_nonzero_exit_with_no_output_reports_stderr() { - let command = stub("broken-dj"); - let err = brain(vec![command.clone()], PromptDelivery::Stdin) - .step(&request()) - .await - .unwrap_err() - .to_string(); - assert!(err.contains("not logged in"), "{err}"); - } - - #[cfg(unix)] - #[tokio::test] - async fn garbage_output_reports_which_command_produced_it() { - let command = stub("chatty-dj"); - let err = brain(vec![command.clone()], PromptDelivery::Stdin) - .step(&request()) - .await - .unwrap_err() - .to_string(); - assert!(err.contains("chatty-dj"), "{err}"); - } - - #[cfg(unix)] - #[tokio::test] - async fn a_hanging_cli_hits_the_timeout_instead_of_blocking_forever() { - let command = stub("slow-dj"); - let brain = AgentCliBrain::new( - vec![command.clone()], - PromptDelivery::Arg, - Duration::from_millis(150), - scratch(), - ) - .unwrap(); - let err = brain.step(&request()).await.unwrap_err().to_string(); - assert!(err.contains("did not answer within"), "{err}"); - } - - #[tokio::test] - async fn a_missing_binary_says_so_rather_than_failing_obscurely() { - let err = brain( - vec!["spotatui-definitely-not-a-real-binary".to_string()], - PromptDelivery::Stdin, - ) - .step(&request()) - .await - .unwrap_err() - .to_string(); - assert!(err.contains("Is it installed"), "{err}"); - // The cwd and the OS error are part of the diagnostic (#478): a cwd that - // cannot be entered fails `spawn` with the same ENOENT as a missing binary. - // The OS error sits in the parentheses after the cwd; its wording is the - // platform's, so only the structure is pinned everywhere. - assert!( - err.contains(&format!("from `{}` (", scratch().display())), - "{err}" - ); - #[cfg(unix)] - assert!(err.contains("No such file or directory"), "{err}"); - } - - #[test] - fn presets_cover_the_installed_agents_and_round_trip() { - for entry in PRESETS { - let found = preset(entry.key).expect("every preset is findable by its key"); - // The key doubles as the binary name, which is what PATH detection stats. - assert_eq!(found.base[0], entry.key); - assert_eq!( - PromptDelivery::from_config_str(entry.delivery.to_config_str()), - Some(entry.delivery) - ); - } - assert!(preset("nonexistent-agent").is_none()); - } - - #[test] - fn a_preset_with_no_model_produces_exactly_the_argv_it_did_before() { - assert_eq!(preset("claude").unwrap().argv(None), vec!["claude", "-p"]); - assert_eq!( - preset("codex").unwrap().argv(None), - vec!["codex", "exec", "-"] - ); - assert_eq!(preset("gemini").unwrap().argv(None), vec!["gemini", "-p"]); - } - - #[test] - fn the_model_flag_lands_before_the_delivery_flag_for_arg_delivery() { - // `run` appends the prompt *after* this argv, so `-p` has to stay last or - // `--model` becomes the value of `-p` and the prompt is never delivered. - assert_eq!( - preset("agy").unwrap().argv(Some("gemini-3.6-flash-low")), - vec!["agy", "--model", "gemini-3.6-flash-low", "-p"] - ); - } - - #[test] - fn the_model_flag_lands_before_the_trailing_stdin_positional() { - assert_eq!( - preset("codex").unwrap().argv(Some("gpt-5")), - vec!["codex", "exec", "--model", "gpt-5", "-"] - ); - } - - #[test] - fn the_opencode_preset_appends_the_prompt_as_a_bare_positional() { - // `delivery_argv` is empty here, so nothing may be left dangling after the - // model flag for `run` to read as a value. - assert_eq!( - preset("opencode").unwrap().argv(Some("openai/gpt-5.4")), - vec!["opencode", "run", "-m", "openai/gpt-5.4"] - ); - assert_eq!( - preset("opencode").unwrap().argv(None), - vec!["opencode", "run"] - ); - } - - #[test] - fn the_copilot_preset_keeps_p_last_so_it_receives_the_prompt() { - assert_eq!( - preset("copilot").unwrap().argv(Some("claude-sonnet-4.5")), - vec![ - "copilot", - "--no-color", - "--model", - "claude-sonnet-4.5", - "-p" - ] - ); - } - - #[test] - fn the_agy_preset_delivers_the_prompt_as_an_argument() { - // Not a preference: `agy` reads nothing from stdin, so `Stdin` here would make - // it answer a question nobody asked. - assert_eq!(preset("agy").unwrap().delivery, PromptDelivery::Arg); - } - - #[test] - fn prompt_delivery_parses_the_forms_a_user_might_write() { - assert_eq!( - PromptDelivery::from_config_str("STDIN"), - Some(PromptDelivery::Stdin) - ); - assert_eq!( - PromptDelivery::from_config_str(" arg "), - Some(PromptDelivery::Arg) - ); - assert_eq!(PromptDelivery::from_config_str("carrier pigeon"), None); - } -} diff --git a/src/infra/dj/brain/anthropic.rs b/src/infra/dj/brain/anthropic.rs deleted file mode 100644 index f5266941..00000000 --- a/src/infra/dj/brain/anthropic.rs +++ /dev/null @@ -1,318 +0,0 @@ -//! The Anthropic Messages API brain. -//! -//! Raw HTTP rather than an SDK, because there is no official Anthropic SDK for -//! Rust — which is the documented approach for an unsupported language. -//! -//! Two model-behaviour constraints are baked in and should not be "fixed" later: -//! -//! * **No sampling parameters.** Current models reject `temperature` / `top_p` / -//! `top_k` with a 400, so variety cannot come from a temperature knob. It comes -//! from rotating the history window and from asking for more candidates than -//! are queued. -//! * **`stop_reason == "refusal"` must be checked before reading `content`**, or -//! a refused request panics on an empty array. -//! -//! Non-streaming on purpose: a DJ turn is one sentence plus a track list, well -//! inside the client timeout, and streaming would buy nothing but machinery. - -use super::{parse_step, step_schema, system_prompt, user_prompt, DjRequest, DjStep}; -use anyhow::{anyhow, Result}; -use serde_json::{json, Value}; - -pub const DEFAULT_MODEL: &str = "claude-haiku-4-5"; -const DEFAULT_BASE_URL: &str = "https://api.anthropic.com"; -const API_VERSION: &str = "2023-06-01"; -const MAX_TOKENS: u32 = 2048; - -pub struct AnthropicBrain { - api_key: String, - model: String, - /// A field rather than a constant so the HTTP layer is testable against a local - /// listener. `friends.rs` hardcodes its URLs and consequently has no tests; - /// `requests.rs` threads a base URL and does. - base_url: String, - http: reqwest::Client, -} - -impl AnthropicBrain { - pub fn new(api_key: String, model: Option<String>, base_url: Option<String>) -> Result<Self> { - if api_key.trim().is_empty() { - return Err(anyhow!( - "no API key for the Anthropic DJ backend. Set SPOTATUI_DJ_API_KEY, or \ - behavior.dj_api_key in config.yml" - )); - } - Ok(Self { - api_key, - model: model.unwrap_or_else(|| DEFAULT_MODEL.to_string()), - base_url: base_url.unwrap_or_else(|| DEFAULT_BASE_URL.to_string()), - // Shared, and always with an explicit timeout — see `super::shared_client`. - http: super::shared_client(), - }) - } - - pub async fn step(&self, request: &DjRequest) -> Result<DjStep> { - let body = json!({ - "model": self.model, - "max_tokens": MAX_TOKENS, - "system": system_prompt(), - "messages": [{ "role": "user", "content": user_prompt(request) }], - // Constrain the shape at the API level instead of asking nicely for JSON. - "output_config": { "format": { "type": "json_schema", "schema": step_schema() } }, - }); - - let url = format!("{}/v1/messages", self.base_url.trim_end_matches('/')); - let response = self - .http - .post(&url) - .header("x-api-key", &self.api_key) - .header("anthropic-version", API_VERSION) - .header("content-type", "application/json") - .json(&body) - .send() - .await - .map_err(|e| anyhow!("could not reach the Anthropic API: {e}"))?; - - let status = response.status(); - let payload: Value = response - .json() - .await - .map_err(|e| anyhow!("Anthropic API returned a body we could not read ({status}): {e}"))?; - - if !status.is_success() { - let message = payload - .get("error") - .and_then(|error| error.get("message")) - .and_then(Value::as_str) - .unwrap_or("no error message"); - return Err(anyhow!("Anthropic API error {status}: {message}")); - } - - // Checked before touching `content`: a refusal comes back as HTTP 200 with an - // empty content array, so indexing first would panic. - if payload.get("stop_reason").and_then(Value::as_str) == Some("refusal") { - let category = payload - .get("stop_details") - .and_then(|details| details.get("category")) - .and_then(Value::as_str) - .unwrap_or("unspecified"); - return Err(anyhow!( - "the model declined this request (category: {category})" - )); - } - - let text = first_text_block(&payload) - .ok_or_else(|| anyhow!("Anthropic API returned no text content"))?; - parse_step(&text) - } -} - -/// Hand-written so the API key can never reach a log line or panic message. -/// -/// A `#[derive(Debug)]` here would print the key verbatim anywhere the struct is -/// formatted, which is exactly the accident this avoids. -impl std::fmt::Debug for AnthropicBrain { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("AnthropicBrain") - .field("model", &self.model) - .field("base_url", &self.base_url) - .field("api_key", &"<redacted>") - .finish() - } -} - -/// Concatenate the `text` blocks of a Messages API response. -/// -/// A response may lead with `thinking` blocks, so filtering by type is required -/// rather than reading `content[0]`. -fn first_text_block(payload: &Value) -> Option<String> { - let blocks = payload.get("content")?.as_array()?; - let text = blocks - .iter() - .filter(|block| block.get("type").and_then(Value::as_str) == Some("text")) - .filter_map(|block| block.get("text").and_then(Value::as_str)) - .collect::<Vec<_>>() - .join("\n"); - (!text.trim().is_empty()).then_some(text) -} - -#[cfg(test)] -mod tests { - use super::*; - use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; - use tokio::net::TcpListener; - - fn request() -> DjRequest { - DjRequest { - want: 2, - ..DjRequest::default() - } - } - - /// Minimal one-shot HTTP server, mirroring `network::requests`' test pattern - /// (there is no mockito/wiremock in this repo). Returns the request body. - async fn serve_once(status: &str, body: Value) -> (String, tokio::task::JoinHandle<String>) { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}", listener.local_addr().unwrap()); - let status = status.to_string(); - let handle = tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.unwrap(); - let (read_half, mut write_half) = stream.split(); - let mut reader = BufReader::new(read_half); - - // Read headers, note the declared length, then read exactly that body. - let mut content_length = 0usize; - loop { - let mut line = String::new(); - reader.read_line(&mut line).await.unwrap(); - if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") { - content_length = value.trim().parse().unwrap_or(0); - } - if line == "\r\n" || line.is_empty() { - break; - } - } - let mut request_body = vec![0u8; content_length]; - if content_length > 0 { - tokio::io::AsyncReadExt::read_exact(&mut reader, &mut request_body) - .await - .unwrap(); - } - - let payload = body.to_string(); - write_half - .write_all( - format!( - "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{payload}", - payload.len() - ) - .as_bytes(), - ) - .await - .unwrap(); - write_half.flush().await.unwrap(); - String::from_utf8_lossy(&request_body).to_string() - }); - (base_url, handle) - } - - fn brain(base_url: String) -> AnthropicBrain { - AnthropicBrain::new("test-key".into(), None, Some(base_url)).unwrap() - } - - #[test] - fn a_blank_key_is_rejected_with_the_env_var_named() { - let err = AnthropicBrain::new(" ".into(), None, None) - .unwrap_err() - .to_string(); - assert!(err.contains("SPOTATUI_DJ_API_KEY")); - } - - #[tokio::test] - async fn parses_a_successful_reply() { - let (base_url, server) = serve_once( - "200 OK", - json!({ - "stop_reason": "end_turn", - "content": [{"type": "text", "text": r#"{"say":"Mellow.","tool_calls":[{"name":"queue_tracks","arguments":{"tracks":[{"title":"Nude","artist":"Radiohead"}]}}]}"#}] - }), - ) - .await; - let reply = brain(base_url).step(&request()).await.unwrap(); - assert_eq!(reply.say.as_deref(), Some("Mellow.")); - assert_eq!(reply.calls[0].name, "queue_tracks"); - server.await.unwrap(); - } - - #[tokio::test] - async fn sends_no_sampling_parameters() { - // Current Anthropic models reject temperature/top_p/top_k with a 400, so - // this asserts we never send them. - let (base_url, server) = serve_once( - "200 OK", - json!({"stop_reason": "end_turn", "content": [{"type": "text", "text": r#"{"say":"ok","tracks":[]}"#}]}), - ) - .await; - let _ = brain(base_url).step(&request()).await; - let sent = server.await.unwrap(); - let body: Value = serde_json::from_str(&sent).unwrap(); - assert!(body.get("temperature").is_none()); - assert!(body.get("top_p").is_none()); - assert!(body.get("top_k").is_none()); - // And it does constrain the output shape at the API level. - assert_eq!( - body["output_config"]["format"]["type"], - json!("json_schema") - ); - assert_eq!(body["model"], json!(DEFAULT_MODEL)); - } - - #[tokio::test] - async fn a_refusal_is_reported_rather_than_panicking_on_empty_content() { - // Regression guard: a refusal is HTTP 200 with `content: []`, so any code - // that reads content[0] first would panic here. - let (base_url, server) = serve_once( - "200 OK", - json!({"stop_reason": "refusal", "stop_details": {"category": "cyber"}, "content": []}), - ) - .await; - let err = brain(base_url) - .step(&request()) - .await - .unwrap_err() - .to_string(); - assert!(err.contains("declined"), "{err}"); - assert!(err.contains("cyber"), "{err}"); - server.await.unwrap(); - } - - #[tokio::test] - async fn an_api_error_surfaces_the_servers_message() { - let (base_url, server) = serve_once( - "401 Unauthorized", - json!({"error": {"message": "invalid x-api-key"}}), - ) - .await; - let err = brain(base_url) - .step(&request()) - .await - .unwrap_err() - .to_string(); - assert!(err.contains("invalid x-api-key"), "{err}"); - server.await.unwrap(); - } - - #[tokio::test] - async fn thinking_blocks_before_the_text_are_skipped() { - let (base_url, server) = serve_once( - "200 OK", - json!({ - "stop_reason": "end_turn", - "content": [ - {"type": "thinking", "thinking": ""}, - {"type": "text", "text": r#"{"say":"after thinking","tracks":[{"title":"A","artist":"B"}]}"#} - ] - }), - ) - .await; - let reply = brain(base_url).step(&request()).await.unwrap(); - assert_eq!(reply.say.as_deref(), Some("after thinking")); - server.await.unwrap(); - } - - #[tokio::test] - async fn a_response_with_no_text_content_is_an_error() { - let (base_url, server) = serve_once( - "200 OK", - json!({"stop_reason": "end_turn", "content": [{"type": "thinking", "thinking": ""}]}), - ) - .await; - let err = brain(base_url) - .step(&request()) - .await - .unwrap_err() - .to_string(); - assert!(err.contains("no text content"), "{err}"); - server.await.unwrap(); - } -} diff --git a/src/infra/dj/brain/mod.rs b/src/infra/dj/brain/mod.rs deleted file mode 100644 index 8d646ef2..00000000 --- a/src/infra/dj/brain/mod.rs +++ /dev/null @@ -1,725 +0,0 @@ -//! The in-TUI DJ's "brain": where the DJ's decisions come from. -//! -//! Three backends behind one contract: -//! -//! * [`agent_cli`] — a coding agent the user already has installed and -//! authenticated (`claude`, `codex`, `copilot`, …). **No API key**, since it -//! rides their existing subscription. -//! * [`anthropic`] — the Anthropic Messages API with a key. -//! * [`openai_compat`] — anything speaking `/chat/completions`, which covers -//! OpenAI, OpenRouter, and local models via Ollama or LM Studio. -//! -//! ## One step at a time -//! -//! A brain does not answer a whole turn; it answers one **step** — some words for -//! the listener, some tool calls, or both. [`super::agent`] runs the tools and -//! calls back with the results, so the DJ can search before it commits, look at -//! the queue, or simply ask a question and stop. A step with nothing to call is -//! how a plain conversational reply happens. -//! -//! The tools are [`super::tools::TOOLS`] verbatim — the same table the MCP server -//! publishes — so a tool added there is one the in-TUI DJ can use with no change -//! here. -//! -//! **All three backends speak the same JSON step protocol** rather than each -//! adopting its provider's native tool-calling. `agent_cli` is a one-shot -//! subprocess and has no native form to adopt, and keeping one protocol means one -//! prompt and one parser for all three. The two HTTP backends still get the shape -//! enforced for them: [`step_schema`] is what they hand to structured output. - -pub mod agent_cli; -pub mod anthropic; -pub mod openai_compat; - -use super::brief::TasteBrief; -use super::tools::TOOLS; -use anyhow::{anyhow, Result}; -use serde_json::{json, Value}; -use std::sync::OnceLock; -use std::time::Duration; - -/// Shared HTTP client for the DJ's API backends. -/// -/// A blanket timeout is mandatory, not optional — the same reasoning as -/// `network::requests::shared_http_client`: a hung request on the serial IoEvent -/// pump would freeze every other event behind it. The window is generous because -/// a reasoning model can legitimately take a while. -pub fn shared_client() -> reqwest::Client { - static CLIENT: OnceLock<reqwest::Client> = OnceLock::new(); - CLIENT - .get_or_init(|| { - reqwest::Client::builder() - .connect_timeout(Duration::from_secs(10)) - .timeout(Duration::from_secs(120)) - .build() - .unwrap_or_else(|e| { - // Nothing can put the timeouts back: they only exist on the builder - // that just failed. Say so, so a DJ turn that hangs forever has a - // reason in the log rather than looking like a stuck model. - log::warn!( - "DJ: could not build the timed HTTP client ({e}); falling back to an untimed one" - ); - reqwest::Client::default() - }) - }) - .clone() -} - -/// What the DJ is asked for, at one step of a turn. -/// -/// `Default` exists for the test fixtures: the struct gains a field per prompt -/// feature, and the backend tests' near-identical literals would each need -/// updating for every one. -#[derive(Default)] -pub struct DjRequest { - pub brief: TasteBrief, - /// The conversation so far, oldest first, as `(speaker, text)`. - pub history: Vec<(String, String)>, - /// Tools already called *this turn*, with what they returned. Empty on the - /// first step; this is how the loop feeds results back. - pub scratch: Vec<ToolExchange>, - /// How many tracks to queue, when this turn queues any. - pub want: usize, - /// Whether the listener wants only tracks they do not already have. A hard - /// filter enforces this after the fact; saying it in the prompt is what stops - /// the model burning the whole batch on their favourites first. - pub avoid_library: bool, - /// Whether this turn must end in music rather than words. - /// - /// Set for an auto-queue refill and a vibe shift, where nobody is watching the - /// screen: a clarifying question there is indistinguishable from a hang. - pub must_act: bool, -} - -/// One completed tool call, as the next step sees it. -#[derive(Clone)] -pub struct ToolExchange { - pub name: String, - pub arguments: Value, - /// What the tool returned, verbatim. Errors included — an error the model can - /// read is how it recovers without a repair round. - pub result: String, -} - -/// A tool the model wants run. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ToolInvocation { - pub name: String, - pub arguments: Value, -} - -/// One step of a turn: something to say, something to run, or both. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct DjStep { - /// A sentence for the transcript, if the model offered one. - pub say: Option<String>, - /// Empty means the turn is over — which is exactly what a conversational reply - /// looks like. - pub calls: Vec<ToolInvocation>, -} - -/// The backends, dispatched by `match`. -/// -/// Deliberately an enum rather than a trait: all three are known at compile time, -/// and `async fn` in a trait object would need the `async-trait` crate — a new -/// dependency for no benefit. -#[derive(Debug)] -pub enum DjBrain { - AgentCli(agent_cli::AgentCliBrain), - Anthropic(anthropic::AnthropicBrain), - OpenAiCompat(openai_compat::OpenAiCompatBrain), -} - -impl DjBrain { - pub async fn step(&self, request: &DjRequest) -> Result<DjStep> { - match self { - Self::AgentCli(brain) => brain.step(request).await, - Self::Anthropic(brain) => brain.step(request).await, - Self::OpenAiCompat(brain) => brain.step(request).await, - } - } - - /// Short label for status messages. - pub fn label(&self) -> &'static str { - match self { - Self::AgentCli(_) => "agent CLI", - Self::Anthropic(_) => "Anthropic", - Self::OpenAiCompat(_) => "OpenAI-compatible", - } - } -} - -/// The system prompt every backend uses. -/// -/// The tool list is rendered from [`TOOLS`], so the DJ inherits whatever the MCP -/// server publishes. -pub fn system_prompt() -> String { - format!( - "You are the DJ for a terminal music player, talking to the listener in a chat \ - window. You can hold a conversation and you can act on the player.\n\n\ - Work one step at a time. Each step you reply with ONLY a JSON object, no prose \ - outside it:\n{}\n\n\ - `say` is what the listener reads: a sentence or two, plain text.\n\ - `tool_calls` is what you want run. Anything you call is executed and the \ - results come straight back to you, so you can look something up, then decide.\n\n\ - Rules:\n\ - - Reply with words and NO tool calls when you are done, or when you would \ - rather ask the listener something than guess. Asking is normal; do not queue \ - music just to have done something.\n\ - - Do not narrate a tool call you have not made. Call it, read the result, then \ - tell the listener what actually happened.\n\ - - When you queue, give real, existing tracks with their exact released titles \ - and the primary artist. queue_tracks reports what it could not find, so read \ - its result instead of assuming.\n\ - - Do not repeat anything listed as recently played, queued, or now playing.\n\ - - Vary the artists; do not queue several tracks by the same one unless asked.\n\n\ - Tools:\n{}", - step_shape_hint(), - tool_catalogue() - ) -} - -fn step_shape_hint() -> String { - json!({ - "say": "what the listener reads, or empty", - "tool_calls": [{"name": "a tool name", "arguments": {}}] - }) - .to_string() -} - -/// The tool table as prompt text: name, what it does, and its argument schema. -fn tool_catalogue() -> String { - TOOLS - .iter() - .map(|tool| { - format!( - "- {}: {}\n arguments: {}", - tool.name, - tool.description, - (tool.schema)() - ) - }) - .collect::<Vec<_>>() - .join("\n") -} - -/// The JSON Schema backends use to constrain a step, where they support it. -/// -/// `arguments` is deliberately an unconstrained object: the per-tool schemas vary, -/// and no single schema can describe them all. [`super::tools::parse_call`] -/// validates the arguments properly once the tool is known. -/// -/// That is also why this is **not** offered to OpenAI as a `strict` schema. -/// Strict structured output requires `properties` and `additionalProperties: -/// false` on every object, which a free-form argument bag cannot have; asking for -/// strict anyway earns a 400 and a second, unstructured request on every turn. -/// Non-strict, the schema is still sent and still guides the reply, and -/// [`parse_step`] is tolerant enough to cover the rest. -pub fn step_schema() -> Value { - json!({ - "type": "object", - "properties": { - "say": {"type": "string"}, - "tool_calls": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": {"type": "string", "enum": TOOLS.iter().map(|tool| tool.name).collect::<Vec<_>>()}, - "arguments": {"type": "object"} - }, - "required": ["name", "arguments"], - "additionalProperties": false - } - } - }, - "required": ["say", "tool_calls"], - "additionalProperties": false - }) -} - -/// The user-turn text: the taste brief, the conversation, and the ask. -pub fn user_prompt(request: &DjRequest) -> String { - let mut prompt = request.brief.to_prompt_block(); - - if !request.history.is_empty() { - prompt.push_str("\nConversation so far:\n"); - for (speaker, text) in &request.history { - prompt.push_str(&format!("{speaker}: {text}\n")); - } - } - - if !request.scratch.is_empty() { - prompt.push_str("\nTools you have already run this turn:\n"); - for exchange in &request.scratch { - prompt.push_str(&format!( - "{}({}) returned:\n{}\n\n", - exchange.name, exchange.arguments, exchange.result - )); - } - } - - if request.avoid_library { - prompt.push_str( - "\nThe listener only wants tracks they do NOT already own. Anything already \ - in their Liked Songs or their own playlists will be rejected, so avoid the \ - obvious well-known picks for this taste and reach for adjacent artists, \ - deeper album cuts, and newer releases instead.\n", - ); - } - - prompt.push_str(&if request.must_act { - // No listener is watching a refill, so a question here reads as a hang. - format!( - "\nQueue {} track(s) now with queue_tracks, naming them by title and artist — \ - it resolves names itself, so do not spend a step searching first. Do not ask \ - any questions; nobody is watching. Reply with only the JSON object.", - request.want - ) - } else { - format!( - "\nTake the next step now. Queue about {} track(s) if this is the point at \ - which you queue. Reply with only the JSON object.", - request.want - ) - }); - prompt -} - -/// Pull a [`DjStep`] out of whatever a backend produced. -/// -/// Deliberately tolerant. A structured-output request gives back bare JSON, but -/// an agent CLI wraps its answer in prose and often a ```json fence — and each -/// CLI does it differently. Scanning for the JSON is uniform across all of them -/// and survives version bumps in any of them. -pub fn parse_step(raw: &str) -> Result<DjStep> { - let candidate = extract_json_object(raw) - .ok_or_else(|| anyhow!("no JSON object found in the reply: {}", truncate(raw, 200)))?; - let value: Value = serde_json::from_str(&candidate).map_err(|e| { - anyhow!( - "reply was not valid JSON ({e}): {}", - truncate(&candidate, 200) - ) - })?; - - let say = value - .get("say") - .and_then(Value::as_str) - .map(str::trim) - .filter(|say| !say.is_empty()) - .map(str::to_string); - - let mut calls: Vec<ToolInvocation> = value - .get("tool_calls") - .and_then(Value::as_array) - .map(|calls| { - calls - .iter() - .filter_map(|call| { - let name = call.get("name").and_then(Value::as_str)?.trim(); - if name.is_empty() { - return None; - } - Some(ToolInvocation { - name: name.to_string(), - // A tool taking no arguments is often written without the key at all. - arguments: call.get("arguments").cloned().unwrap_or_else(|| json!({})), - }) - }) - .collect() - }) - .unwrap_or_default(); - - // A bare `tracks` array is not the protocol, but small local models fall back to - // it constantly. Reading it as the queue call it plainly means costs ten lines - // and is the difference between a weak model working and doing nothing. - if calls.is_empty() { - if let Some(tracks) = value.get("tracks").and_then(Value::as_array) { - if !tracks.is_empty() { - calls.push(ToolInvocation { - name: "queue_tracks".to_string(), - arguments: json!({ "tracks": tracks }), - }); - } - } - } - - if calls.is_empty() && say.is_none() { - return Err(anyhow!( - "reply contained neither a message nor any tool calls" - )); - } - Ok(DjStep { say, calls }) -} - -/// Find the JSON object in a blob of text. -/// -/// Prefers the **last** fenced ```json block, then the last balanced `{…}` run: -/// a model that reasons out loud before answering tends to put the real answer -/// last, and an agent CLI may echo the requested shape from the prompt before -/// producing its own. -fn extract_json_object(raw: &str) -> Option<String> { - if let Some(fenced) = last_fenced_block(raw) { - if let Some(object) = last_balanced_object(&fenced) { - return Some(object); - } - } - last_balanced_object(raw) -} - -fn last_fenced_block(raw: &str) -> Option<String> { - let mut blocks = Vec::new(); - let mut rest = raw; - while let Some(start) = rest.find("```") { - let after = &rest[start + 3..]; - // Skip an optional language tag on the fence line. - let body_start = after.find('\n').map(|i| i + 1).unwrap_or(after.len()); - let body = &after[body_start..]; - match body.find("```") { - Some(end) => { - blocks.push(body[..end].to_string()); - rest = &body[end + 3..]; - } - // Unterminated fence: take the remainder, since a truncated reply is - // still worth trying to read. - None => { - blocks.push(body.to_string()); - break; - } - } - } - blocks.pop() -} - -/// The last top-level balanced `{…}` in `raw`, ignoring braces inside strings. -fn last_balanced_object(raw: &str) -> Option<String> { - let bytes = raw.as_bytes(); - let mut best: Option<(usize, usize)> = None; - let mut start: Option<usize> = None; - let mut depth = 0usize; - let mut in_string = false; - let mut escaped = false; - - for (index, &byte) in bytes.iter().enumerate() { - if in_string { - if escaped { - escaped = false; - } else if byte == b'\\' { - escaped = true; - } else if byte == b'"' { - in_string = false; - } - continue; - } - match byte { - b'"' => in_string = true, - b'{' => { - if depth == 0 { - start = Some(index); - } - depth += 1; - } - b'}' => { - depth = depth.saturating_sub(1); - if depth == 0 { - if let Some(open) = start.take() { - best = Some((open, index + 1)); - } - } - } - _ => {} - } - } - best.map(|(open, close)| raw[open..close].to_string()) -} - -fn truncate(value: &str, max: usize) -> String { - let trimmed = value.trim(); - if trimmed.chars().count() <= max { - return trimmed.to_string(); - } - trimmed.chars().take(max).collect::<String>() + "…" -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_a_bare_json_step() { - let step = parse_step( - r#"{"say":"Here you go.","tool_calls":[{"name":"queue_tracks","arguments":{"tracks":[{"title":"Nude","artist":"Radiohead"}]}}]}"#, - ) - .unwrap(); - assert_eq!(step.say.as_deref(), Some("Here you go.")); - assert_eq!(step.calls.len(), 1); - assert_eq!(step.calls[0].name, "queue_tracks"); - } - - #[test] - fn a_step_with_words_and_no_calls_is_a_conversational_reply() { - // The whole point of the loop's terminal case: the DJ is allowed to just talk. - let step = - parse_step(r#"{"say":"Instrumental, or are vocals fine?","tool_calls":[]}"#).unwrap(); - assert_eq!( - step.say.as_deref(), - Some("Instrumental, or are vocals fine?") - ); - assert!(step.calls.is_empty()); - } - - #[test] - fn parses_a_fenced_step_wrapped_in_prose() { - // What an agent CLI actually emits. - let raw = "Sure! Here's a mellow set for you.\n\n```json\n{\"say\":\"Mellow.\",\"tool_calls\":[{\"name\":\"get_queue\",\"arguments\":{}}]}\n```\n\nLet me know if you want more."; - let step = parse_step(raw).unwrap(); - assert_eq!(step.say.as_deref(), Some("Mellow.")); - assert_eq!(step.calls[0].name, "get_queue"); - } - - #[test] - fn prefers_the_last_fenced_block_when_the_prompt_shape_is_echoed() { - // Regression guard: a CLI that repeats the requested shape before answering - // must not have the example parsed as the answer. - let raw = concat!( - "I'll use this shape:\n```json\n{\"say\":\"what the listener reads\",\"tool_calls\":[]}\n```\n", - "Here is the actual set:\n```json\n{\"say\":\"Real answer.\",\"tool_calls\":[{\"name\":\"skip_track\",\"arguments\":{}}]}\n```" - ); - let step = parse_step(raw).unwrap(); - assert_eq!(step.say.as_deref(), Some("Real answer.")); - assert_eq!(step.calls.len(), 1); - } - - #[test] - fn handles_an_unlabelled_fence() { - let raw = - "```\n{\"say\":\"ok\",\"tool_calls\":[{\"name\":\"get_queue\",\"arguments\":{}}]}\n```"; - assert_eq!(parse_step(raw).unwrap().calls.len(), 1); - } - - #[test] - fn a_call_with_no_arguments_key_is_still_a_call() { - // Models routinely omit `arguments` for a no-argument tool. - let step = parse_step(r#"{"say":"","tool_calls":[{"name":"get_now_playing"}]}"#).unwrap(); - assert_eq!(step.calls[0].name, "get_now_playing"); - assert_eq!(step.calls[0].arguments, json!({})); - } - - #[test] - fn a_bare_tracks_array_is_read_as_the_queue_call_it_means() { - // Small local models fall back to this shape constantly; reading it is the - // difference between one of them working and doing nothing at all. - let step = - parse_step(r#"{"say":"ok","tracks":[{"title":"Nude","artist":"Radiohead"}]}"#).unwrap(); - assert_eq!(step.calls.len(), 1); - assert_eq!(step.calls[0].name, "queue_tracks"); - assert_eq!(step.calls[0].arguments["tracks"][0]["title"], json!("Nude")); - - // But an explicit tool call always wins over it. - let both = parse_step( - r#"{"say":"ok","tracks":[{"title":"A","artist":"B"}],"tool_calls":[{"name":"skip_track","arguments":{}}]}"#, - ) - .unwrap(); - assert_eq!(both.calls.len(), 1); - assert_eq!(both.calls[0].name, "skip_track"); - } - - #[test] - fn braces_inside_strings_do_not_confuse_the_scanner() { - let raw = r#"{"say":"a } brace { inside","tool_calls":[{"name":"get_queue","arguments":{}}]}"#; - let step = parse_step(raw).unwrap(); - assert_eq!(step.say.as_deref(), Some("a } brace { inside")); - assert_eq!(step.calls.len(), 1); - } - - #[test] - fn escaped_quotes_inside_strings_are_handled() { - let raw = r#"{"say":"they said \"hi\"","tool_calls":[]}"#; - assert_eq!( - parse_step(raw).unwrap().say.as_deref(), - Some("they said \"hi\"") - ); - } - - #[test] - fn a_call_missing_a_name_is_dropped_not_fatal() { - let raw = r#"{"say":"ok","tool_calls":[{"arguments":{}},{"name":"get_queue","arguments":{}}]}"#; - let step = parse_step(raw).unwrap(); - assert_eq!(step.calls.len(), 1); - assert_eq!(step.calls[0].name, "get_queue"); - } - - #[test] - fn a_reply_with_neither_message_nor_calls_is_an_error() { - assert!(parse_step(r#"{"tool_calls":[]}"#).is_err()); - } - - #[test] - fn non_json_output_is_an_error_naming_what_was_seen() { - let err = parse_step("I'm afraid I can't do that.") - .unwrap_err() - .to_string(); - assert!(err.contains("no JSON object")); - assert!(err.contains("can't do that")); - } - - #[test] - fn truncated_json_reports_a_parse_failure() { - let err = parse_step(r#"{"say":"oops","tool_calls":[{"name":"#) - .unwrap_err() - .to_string(); - assert!( - err.contains("no JSON object") || err.contains("not valid JSON"), - "{err}" - ); - } - - #[test] - fn the_prompt_offers_every_tool_the_mcp_server_publishes() { - // Iterated rather than listed, so a tool added to the table fails this until - // the in-TUI DJ can see it too. That inheritance is the point of the design. - let prompt = system_prompt(); - for tool in TOOLS { - assert!(prompt.contains(tool.name), "{} is not offered", tool.name); - } - let names = step_schema()["properties"]["tool_calls"]["items"]["properties"]["name"]["enum"] - .as_array() - .unwrap() - .clone(); - assert_eq!(names.len(), TOOLS.len(), "the schema must offer them too"); - } - - #[test] - fn a_tool_result_reaches_the_next_step_verbatim() { - let request = DjRequest { - want: 5, - scratch: vec![ToolExchange { - name: "queue_tracks".into(), - arguments: json!({"tracks": []}), - result: "Not found (skipped): Ghost Track — Nobody".into(), - }], - ..DjRequest::default() - }; - let prompt = user_prompt(&request); - // How the model learns to stop naming a track that does not exist. It replaces - // the old repair round, which had to be told the same thing out of band. - assert!(prompt.contains("Ghost Track — Nobody")); - assert!(prompt.contains("queue_tracks")); - } - - #[test] - fn a_must_act_turn_is_told_to_queue_without_asking() { - // Nobody is watching an auto-queue refill, so a question there is a hang. - let request = DjRequest { - want: 6, - must_act: true, - ..DjRequest::default() - }; - let prompt = user_prompt(&request); - assert!(prompt.contains("Queue 6 track(s) now")); - assert!(prompt.contains("Do not ask any questions")); - // And it must not spend one of its two steps on a search it does not need. - assert!(prompt.contains("do not spend a step searching")); - - let conversational = DjRequest { - want: 6, - ..DjRequest::default() - }; - assert!(!user_prompt(&conversational).contains("Do not ask any questions")); - } - - #[test] - fn conversation_history_is_included_in_order() { - let request = DjRequest { - history: vec![ - ("Listener".into(), "something chill".into()), - ("DJ".into(), "here you go".into()), - ], - want: 3, - ..DjRequest::default() - }; - let prompt = user_prompt(&request); - let chill = prompt.find("something chill").unwrap(); - let here = prompt.find("here you go").unwrap(); - assert!(chill < here, "history must stay in order"); - } - - #[test] - fn the_last_listener_line_is_the_live_request() { - // Load-bearing: there is no separate instruction slot, so what the listener - // typed reaches the model *only* as the final history line — which is why the - // handler pushes it to the transcript and does not also pass it alongside. - let request = DjRequest { - history: vec![ - ("Listener".into(), "something chill".into()), - ("DJ".into(), "here you go".into()), - ("Listener".into(), "now something faster".into()), - ], - want: 3, - ..DjRequest::default() - }; - let prompt = user_prompt(&request); - assert_eq!( - prompt.matches("now something faster").count(), - 1, - "the ask must appear once, not once per delivery route" - ); - let ask = prompt.find("now something faster").unwrap(); - assert!(ask > prompt.find("Conversation so far:").unwrap()); - assert!( - ask < prompt.find("Take the next step now").unwrap(), - "the newest line has to sit immediately before the ask to read as the request" - ); - } - - #[test] - fn the_avoid_library_prompt_asks_for_the_unfamiliar() { - let request = DjRequest { - want: 6, - avoid_library: true, - ..DjRequest::default() - }; - let prompt = user_prompt(&request); - assert!(prompt.contains("do NOT already own")); - // Without this steer the model reaches for the listener's favourites first and - // the filter eats the batch. - assert!(prompt.contains("newer releases")); - - let off = DjRequest { - want: 6, - ..DjRequest::default() - }; - assert!(!user_prompt(&off).contains("already own")); - } - - #[test] - fn real_agent_cli_output_parses_noise_and_all() { - // Captured verbatim from `copilot --no-color -p "<the real prompt>"`. It - // narrates its own shell activity on *stdout* before the answer, which is - // exactly why the parser scans for the last balanced object rather than - // trusting the whole stream to be JSON. - let raw = "\u{2717} Write dummy\n \u{2514} noop\n\n\u{25cf} noop (shell)\n \u{2502} echo not used\n \u{2514} 2 lines\u{2026}\n\n{\"say\":\"\",\"tool_calls\": [{\"name\":\"get_now_playing\",\"arguments\":{}},{\"name\":\"get_queue\", \"arguments\":{}}]}"; - let step = parse_step(raw).unwrap(); - assert_eq!(step.calls.len(), 2); - assert_eq!(step.calls[0].name, "get_now_playing"); - assert_eq!(step.calls[1].name, "get_queue"); - // An empty `say` is a real answer from a real CLI, and must not become a - // blank transcript line. - assert_eq!(step.say, None); - } - - #[test] - fn schema_matches_the_shape_the_prompt_describes() { - let schema = step_schema(); - let required = schema["required"].as_array().unwrap(); - assert!(required.contains(&json!("say"))); - assert!(required.contains(&json!("tool_calls"))); - let call_required = schema["properties"]["tool_calls"]["items"]["required"] - .as_array() - .unwrap(); - assert!(call_required.contains(&json!("name"))); - assert!(call_required.contains(&json!("arguments"))); - // The prompt's example must be parseable by our own parser. - assert!(parse_step(&step_shape_hint()).is_ok()); - } -} diff --git a/src/infra/dj/brain/openai_compat.rs b/src/infra/dj/brain/openai_compat.rs deleted file mode 100644 index d410899a..00000000 --- a/src/infra/dj/brain/openai_compat.rs +++ /dev/null @@ -1,404 +0,0 @@ -//! The OpenAI-compatible brain: one adapter, many providers. -//! -//! `POST {base_url}/chat/completions` is spoken by OpenAI, OpenRouter, Ollama, -//! LM Studio, vLLM, and llama.cpp, so the **local model** case is just a -//! `base_url` pointing at `http://localhost:11434/v1` with no API key at all. -//! -//! Structured output is requested via `response_format`, but degraded gracefully: -//! plenty of local servers ignore or reject it, and the reply parser is tolerant -//! enough to read a fenced JSON block out of prose anyway. - -use super::{parse_step, step_schema, system_prompt, user_prompt, DjRequest, DjStep}; -use anyhow::{anyhow, Result}; -use serde_json::{json, Value}; - -pub const DEFAULT_MODEL: &str = "gpt-4o-mini"; -/// Ollama's OpenAI-compatible endpoint — the most common local-model setup. -pub const DEFAULT_BASE_URL: &str = "http://localhost:11434/v1"; -const MAX_TOKENS: u32 = 2048; - -/// Which token-limit field this server takes. -/// -/// `max_tokens` is what every OpenAI-compatible server has always accepted; -/// OpenAI's newer models require `max_completion_tokens` and reject the old name. -/// Sending the new one everywhere would break the local servers this backend -/// exists for, so it is a downgrade the server asks for, not a default. -#[derive(Clone, Copy, PartialEq, Eq)] -enum TokenLimit { - MaxTokens, - MaxCompletionTokens, -} - -impl TokenLimit { - fn field(self) -> &'static str { - match self { - Self::MaxTokens => "max_tokens", - Self::MaxCompletionTokens => "max_completion_tokens", - } - } -} - -/// A non-success HTTP reply, kept as a type so the retry can read the status. -/// -/// The retry used to search the *rendered* error for "400" or "response_format", -/// which both the base URL (`http://localhost:11400/v1`) and the model's own -/// words could contain — so unrelated failures bought a second, full-price model -/// call. Only this error means "the server refused something we sent". -#[derive(Debug)] -struct RejectedRequest { - status: reqwest::StatusCode, - message: String, -} - -impl RejectedRequest { - /// The two statuses an OpenAI-compatible server uses for a field it will not - /// take. A 401, 404, 429, or 5xx is a real failure and retrying is pure waste. - fn rejected_a_field(&self) -> bool { - self.status == reqwest::StatusCode::BAD_REQUEST - || self.status == reqwest::StatusCode::UNPROCESSABLE_ENTITY - } - - /// Whether the *server's own* message names this field. Deliberately not the - /// rendered error chain, which is what made the old guard misfire. - fn blames(&self, field: &str) -> bool { - self.message.contains(field) - } -} - -impl std::fmt::Display for RejectedRequest { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}: {}", self.status, self.message) - } -} - -impl std::error::Error for RejectedRequest {} - -pub struct OpenAiCompatBrain { - /// Optional: a local server usually needs none. - api_key: Option<String>, - model: String, - base_url: String, - http: reqwest::Client, -} - -impl OpenAiCompatBrain { - pub fn new(api_key: Option<String>, model: Option<String>, base_url: Option<String>) -> Self { - Self { - api_key: api_key.filter(|key| !key.trim().is_empty()), - model: model.unwrap_or_else(|| DEFAULT_MODEL.to_string()), - base_url: base_url.unwrap_or_else(|| DEFAULT_BASE_URL.to_string()), - http: super::shared_client(), - } - } - - /// One turn, downgrading the request only for fields the server actually - /// refused. - /// - /// Both downgrades are one-way and each fires at most once, so a turn costs at - /// most three requests and cannot loop. - pub async fn step(&self, request: &DjRequest) -> Result<DjStep> { - let mut structured = true; - let mut tokens = TokenLimit::MaxTokens; - loop { - let error = match self.attempt(request, structured, tokens).await { - Ok(reply) => return Ok(reply), - Err(error) => error, - }; - - // Only the server refusing a field we sent is worth paying for a second - // model call. A transport error, a 401, or a reply we could not parse - // would fail exactly the same way twice. - let Some(rejected) = error.downcast_ref::<RejectedRequest>() else { - return Err(error); - }; - if !rejected.rejected_a_field() { - return Err(error); - } - - if tokens == TokenLimit::MaxTokens && rejected.blames("max_tokens") { - // OpenAI's newer models take `max_completion_tokens` and refuse the old - // name outright, so dropping `response_format` here would fix nothing. - log::debug!("DJ: server rejected max_tokens, retrying with max_completion_tokens"); - tokens = TokenLimit::MaxCompletionTokens; - } else if structured { - // Plenty of local servers reject `response_format`. The prompt alone plus - // the tolerant parser is enough, so this is not a hard failure. - log::debug!("DJ: server rejected response_format, retrying without it: {error}"); - structured = false; - } else { - return Err(error); - } - } - } - - async fn attempt( - &self, - request: &DjRequest, - structured: bool, - tokens: TokenLimit, - ) -> Result<DjStep> { - let mut body = json!({ - "model": self.model, - "messages": [ - { "role": "system", "content": system_prompt() }, - { "role": "user", "content": user_prompt(request) }, - ], - }); - body[tokens.field()] = json!(MAX_TOKENS); - if structured { - body["response_format"] = json!({ - "type": "json_schema", - "json_schema": { "name": "dj_reply", "schema": step_schema() } - }); - } - - let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/')); - let mut post = self - .http - .post(&url) - .header("content-type", "application/json"); - if let Some(key) = &self.api_key { - post = post.header("authorization", format!("Bearer {key}")); - } - - let response = post - .json(&body) - .send() - .await - .map_err(|e| anyhow!("could not reach {url}: {e}"))?; - - let status = response.status(); - let payload: Value = response - .json() - .await - .map_err(|e| anyhow!("{url} returned a body we could not read ({status}): {e}"))?; - - if !status.is_success() { - let message = payload - .get("error") - .and_then(|error| error.get("message")) - .and_then(Value::as_str) - .unwrap_or("no error message"); - return Err( - RejectedRequest { - status, - message: message.to_string(), - } - .into(), - ); - } - - let text = payload - .get("choices") - .and_then(Value::as_array) - .and_then(|choices| choices.first()) - .and_then(|choice| choice.get("message")) - .and_then(|message| message.get("content")) - .and_then(Value::as_str) - .ok_or_else(|| anyhow!("{url} returned no message content"))?; - - parse_step(text) - } -} - -/// Hand-written to keep the API key out of logs and panic messages — see the -/// equivalent note on `AnthropicBrain`. -impl std::fmt::Debug for OpenAiCompatBrain { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("OpenAiCompatBrain") - .field("model", &self.model) - .field("base_url", &self.base_url) - .field("api_key", &self.api_key.as_ref().map(|_| "<redacted>")) - .finish() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; - use tokio::net::TcpListener; - - fn request() -> DjRequest { - DjRequest { - want: 2, - ..DjRequest::default() - } - } - - /// Serve `responses` in order, collecting each request body. - async fn serve( - responses: Vec<(&'static str, Value)>, - ) -> (String, tokio::task::JoinHandle<Vec<String>>) { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}/v1", listener.local_addr().unwrap()); - let handle = tokio::spawn(async move { - let mut bodies = Vec::new(); - for (status, body) in responses { - let (mut stream, _) = listener.accept().await.unwrap(); - let (read_half, mut write_half) = stream.split(); - let mut reader = BufReader::new(read_half); - let mut content_length = 0usize; - loop { - let mut line = String::new(); - reader.read_line(&mut line).await.unwrap(); - if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") { - content_length = value.trim().parse().unwrap_or(0); - } - if line == "\r\n" || line.is_empty() { - break; - } - } - let mut request_body = vec![0u8; content_length]; - if content_length > 0 { - reader.read_exact(&mut request_body).await.unwrap(); - } - bodies.push(String::from_utf8_lossy(&request_body).to_string()); - - let payload = body.to_string(); - write_half - .write_all( - format!( - "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{payload}", - payload.len() - ) - .as_bytes(), - ) - .await - .unwrap(); - write_half.flush().await.unwrap(); - } - bodies - }); - (base_url, handle) - } - - fn completion(text: &str) -> Value { - json!({"choices": [{"message": {"role": "assistant", "content": text}}]}) - } - - #[tokio::test] - async fn parses_a_successful_completion() { - let (base_url, server) = serve(vec![( - "200 OK", - completion(r#"{"say":"Local pick.","tracks":[{"title":"Nude","artist":"Radiohead"}]}"#), - )]) - .await; - let brain = OpenAiCompatBrain::new(None, None, Some(base_url)); - let reply = brain.step(&request()).await.unwrap(); - assert_eq!(reply.say.as_deref(), Some("Local pick.")); - server.await.unwrap(); - } - - #[tokio::test] - async fn a_local_server_needs_no_api_key_header() { - let (base_url, server) = - serve(vec![("200 OK", completion(r#"{"say":"ok","tracks":[]}"#))]).await; - let brain = OpenAiCompatBrain::new(None, None, Some(base_url)); - let _ = brain.step(&request()).await; - // The request went out and was accepted without any credential. - assert_eq!(server.await.unwrap().len(), 1); - } - - #[tokio::test] - async fn a_blank_api_key_is_treated_as_absent() { - let brain = OpenAiCompatBrain::new(Some(" ".into()), None, None); - assert!(brain.api_key.is_none()); - } - - #[tokio::test] - async fn retries_without_response_format_when_the_server_rejects_it() { - // Many local servers 400 on `response_format`; the prompt alone plus the - // tolerant parser is enough, so this must not be a hard failure. - let (base_url, server) = serve(vec![ - ( - "400 Bad Request", - json!({"error": {"message": "unknown field response_format"}}), - ), - ( - "200 OK", - completion(r#"{"say":"second try","tracks":[{"title":"A","artist":"B"}]}"#), - ), - ]) - .await; - let brain = OpenAiCompatBrain::new(None, None, Some(base_url)); - let reply = brain.step(&request()).await.unwrap(); - assert_eq!(reply.say.as_deref(), Some("second try")); - - let bodies = server.await.unwrap(); - assert_eq!(bodies.len(), 2, "should have retried exactly once"); - let first: Value = serde_json::from_str(&bodies[0]).unwrap(); - let second: Value = serde_json::from_str(&bodies[1]).unwrap(); - assert!(first.get("response_format").is_some()); - assert!(second.get("response_format").is_none()); - } - - #[tokio::test] - async fn a_rejected_max_tokens_is_retried_with_max_completion_tokens() { - // OpenAI's newer models refuse `max_tokens` outright. Dropping - // `response_format` instead would fail the same way a second time. - let (base_url, server) = serve(vec![ - ( - "400 Bad Request", - json!({"error": {"message": "Unsupported parameter: 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead."}}), - ), - ( - "200 OK", - completion(r#"{"say":"newer model","tool_calls":[]}"#), - ), - ]) - .await; - let brain = OpenAiCompatBrain::new(None, None, Some(base_url)); - let reply = brain.step(&request()).await.unwrap(); - assert_eq!(reply.say.as_deref(), Some("newer model")); - - let bodies = server.await.unwrap(); - assert_eq!(bodies.len(), 2, "should have retried exactly once"); - let second: Value = serde_json::from_str(&bodies[1]).unwrap(); - assert!(second.get("max_completion_tokens").is_some()); - assert!(second.get("max_tokens").is_none()); - assert!( - second.get("response_format").is_some(), - "the token field was the complaint, so the schema request survives" - ); - } - - #[tokio::test] - async fn a_reply_that_merely_mentions_400_is_not_retried() { - // The retry reads the HTTP status, not the rendered error. This reply parses - // to an error whose text contains both old triggers, and it must still cost - // exactly one model call. - let (base_url, server) = serve(vec![( - "200 OK", - completion("sorry, I hit error 400 on response_format"), - )]) - .await; - let brain = OpenAiCompatBrain::new(None, None, Some(base_url)); - assert!(brain.step(&request()).await.is_err()); - assert_eq!( - server.await.unwrap().len(), - 1, - "an unparseable reply is not a rejected field" - ); - } - - #[tokio::test] - async fn an_unrelated_error_is_not_retried() { - let (base_url, server) = serve(vec![( - "401 Unauthorized", - json!({"error": {"message": "bad token"}}), - )]) - .await; - let brain = OpenAiCompatBrain::new(Some("nope".into()), None, Some(base_url)); - let err = brain.step(&request()).await.unwrap_err().to_string(); - assert!(err.contains("bad token"), "{err}"); - assert_eq!(server.await.unwrap().len(), 1, "must not retry a 401"); - } - - #[test] - fn defaults_point_at_ollama() { - let brain = OpenAiCompatBrain::new(None, None, None); - assert_eq!(brain.base_url, DEFAULT_BASE_URL); - assert!(brain.base_url.contains("11434")); - } -} diff --git a/src/infra/dj/brief.rs b/src/infra/dj/brief.rs deleted file mode 100644 index be28e678..00000000 --- a/src/infra/dj/brief.rs +++ /dev/null @@ -1,302 +0,0 @@ -//! The taste brief: a compact, **aggregate** summary of what the user listens -//! to, assembled from the local listening history. -//! -//! This is the only thing about the user that ever reaches a model, so it is -//! deliberately built from aggregates rather than raw records: names of tracks, -//! artists and albums, and nothing else. No timestamps, no Spotify IDs, no -//! identity, no play counts tied to a clock. That is a privacy property, not a -//! token optimisation — see `docs/ai-dj.md`. - -use crate::infra::history::{ - aggregate_top_albums, aggregate_top_artists, aggregate_top_tracks, filter_listens_for_period, - ListenRecord, RecapPeriod, -}; - -/// How many entries of each kind to carry. Enough for a model to infer taste, -/// small enough to stay cheap in a prompt (and in a local model's context). -const TOP_ARTISTS: usize = 15; -const TOP_TRACKS: usize = 20; -const TOP_ALBUMS: usize = 10; -/// Recent plays exist so the model can be told *not* to repeat them, and so the -/// enqueue path can dedupe against them. -const RECENT: usize = 25; - -/// An aggregate portrait of the user's listening, plus the current steer. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct TasteBrief { - pub period_label: &'static str, - pub total_plays: usize, - pub top_artists: Vec<String>, - /// `"Title — Artist"` display strings, as produced by the Stats aggregation. - pub top_tracks: Vec<String>, - pub top_albums: Vec<String>, - /// Newest first, `"Title — Artist"`. - pub recent: Vec<String>, - pub now_playing: Option<String>, - /// The user's steer ("something chill for focusing"), when they gave one. - pub vibe: Option<String>, - /// Normalised dedupe keys for [`Self::recent`]. Never rendered into a prompt — - /// this is for the enqueue path, so the DJ does not re-queue what just played. - pub recent_keys: Vec<String>, -} - -pub fn period_label(period: RecapPeriod) -> &'static str { - match period { - RecapPeriod::SevenDays => "the last 7 days", - RecapPeriod::ThirtyDays => "the last 30 days", - RecapPeriod::Month => "this month", - RecapPeriod::Year => "this year", - RecapPeriod::All => "all time", - } -} - -/// Normalise a title/artist pair into a dedupe key. -/// -/// Lowercased, punctuation-insensitive and whitespace-collapsed, so -/// `"Weird Fishes / Arpeggi"` and `"weird fishes/arpeggi"` collide — a model -/// rarely reproduces a title byte-for-byte, and near-misses are exactly the -/// duplicates worth catching. -pub fn dedupe_key(title: &str, artist: &str) -> String { - // The first artist only: a model may list more or fewer collaborators than - // the catalogue does, and requiring the full list to match defeats the point. - let primary = artist.split(',').next().unwrap_or(artist); - format!("{}\u{1}{}", normalize(title), normalize(primary)) -} - -/// Lowercase, drop non-alphanumerics, collapse the resulting gaps to single -/// spaces. Shared by [`dedupe_key`] and the resolver's match scoring so the two -/// agree on what "the same track" means. -pub fn normalize(value: &str) -> String { - let mut out = String::with_capacity(value.len()); - let mut pending_space = false; - for ch in value.chars() { - if ch.is_alphanumeric() { - if pending_space && !out.is_empty() { - out.push(' '); - } - pending_space = false; - out.extend(ch.to_lowercase()); - } else { - pending_space = true; - } - } - out -} - -/// Build the brief from an already-loaded set of records. -/// -/// Callers must not read the listens file on an async task: [`load_listens`] is -/// blocking and `listens.jsonl` is unbounded append-only, so a long-time user's -/// file is large. Load it in `spawn_blocking`, then call this. -/// -/// [`load_listens`]: crate::infra::history::load_listens -pub fn build_brief(listens: &[ListenRecord], period: RecapPeriod) -> TasteBrief { - let filtered = filter_listens_for_period(listens, period); - - let mut recent = Vec::new(); - let mut recent_keys = Vec::new(); - let mut seen = std::collections::HashSet::new(); - // `filtered` is in file order (oldest first); walk backwards for newest first. - for record in filtered.iter().rev() { - let artist = record.artists.join(", "); - let key = dedupe_key(&record.title, &artist); - if !seen.insert(key.clone()) { - continue; - } - recent.push(format!("{} — {}", record.title, artist)); - recent_keys.push(key); - if recent.len() >= RECENT { - break; - } - } - - TasteBrief { - period_label: period_label(period), - total_plays: filtered.len(), - top_artists: rank_displays(aggregate_top_artists(&filtered, TOP_ARTISTS)), - top_tracks: rank_displays(aggregate_top_tracks(&filtered, TOP_TRACKS)), - top_albums: rank_displays(aggregate_top_albums(&filtered, TOP_ALBUMS)), - recent, - now_playing: None, - vibe: None, - recent_keys, - } -} - -fn rank_displays(entries: Vec<crate::infra::history::RankedEntry>) -> Vec<String> { - entries.into_iter().map(|entry| entry.display).collect() -} - -impl TasteBrief { - /// Render the brief as the plain-text block handed to a model. - /// - /// Deliberately prose-and-lists rather than JSON: every backend consumes this, - /// including small local models that handle a bulleted list better than a - /// nested object. - pub fn to_prompt_block(&self) -> String { - let mut out = String::new(); - out.push_str(&format!( - "Listening history ({}, {} qualifying plays):\n", - self.period_label, self.total_plays - )); - push_list(&mut out, "Top artists", &self.top_artists); - push_list(&mut out, "Top tracks", &self.top_tracks); - push_list(&mut out, "Top albums", &self.top_albums); - push_list( - &mut out, - "Recently played (do not repeat these)", - &self.recent, - ); - if let Some(now) = &self.now_playing { - out.push_str(&format!("\nCurrently playing: {now}\n")); - } - if let Some(vibe) = &self.vibe { - out.push_str(&format!("\nThe listener asked for: {vibe}\n")); - } - out - } - - /// Whether there is enough history to say anything useful about taste. - /// - /// Consumed by the MCP `get_listening_history` tool, which tells the agent to - /// ask the user rather than infer from nothing. - #[cfg_attr(not(feature = "mcp-server"), allow(dead_code))] - pub fn is_sparse(&self) -> bool { - self.total_plays < 5 - } -} - -fn push_list(out: &mut String, label: &str, values: &[String]) { - if values.is_empty() { - return; - } - out.push_str(&format!("\n{label}:\n")); - for value in values { - out.push_str(&format!("- {value}\n")); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::infra::history::{HistoryItemKind, HistoryPlaybackSource}; - use chrono::{Duration, Utc}; - - fn record(title: &str, artist: &str, uri: Option<&str>, duration_ms: u32) -> ListenRecord { - let now = Utc::now(); - let listened_ms = u64::from(duration_ms); - ListenRecord { - started_at: now - Duration::minutes(5), - ended_at: now, - listened_ms, - duration_ms, - // Mirror the collector: `qualified` is precomputed on write. - qualified: duration_ms > 30_000 && listened_ms >= u64::from(duration_ms / 2).min(240_000), - title: title.to_string(), - artists: vec![artist.to_string()], - album: format!("{title} EP"), - item_kind: HistoryItemKind::Track, - item_id: None, - item_uri: uri.map(str::to_string), - context_uri: None, - // Every non-Spotify source is written as `ExternalDevice` by - // `source_snapshot`, so this field cannot distinguish them. The brief - // must not depend on it; `item_uri`'s scheme is the real discriminator. - source: HistoryPlaybackSource::ExternalDevice, - } - } - - #[test] - fn brief_aggregates_names_only() { - let listens = vec![ - record( - "Weird Fishes", - "Radiohead", - Some("spotify:track:a"), - 300_000, - ), - record("Nude", "Radiohead", Some("spotify:track:b"), 300_000), - record( - "Teardrop", - "Massive Attack", - Some("spotify:track:c"), - 300_000, - ), - ]; - let brief = build_brief(&listens, RecapPeriod::All); - - assert_eq!(brief.total_plays, 3); - assert!(brief.top_artists.contains(&"Radiohead".to_string())); - assert_eq!(brief.recent.len(), 3); - // Newest first: the last record in file order leads. - assert!(brief.recent[0].starts_with("Teardrop")); - - let prompt = brief.to_prompt_block(); - assert!(prompt.contains("Radiohead")); - // No identifiers of any kind leak into the prompt. - assert!(!prompt.contains("spotify:track")); - } - - #[test] - fn non_spotify_listens_are_included_and_identified_by_uri_scheme() { - // Regression guard for the mislabelling at media_metadata.rs:344 — a YouTube - // play is written with `source: ExternalDevice`, so anything that wants to - // tell sources apart must read the URI scheme instead. - let listens = vec![record( - "Some Mix", - "Uploader", - Some("youtube:video:xyz"), - 600_000, - )]; - let brief = build_brief(&listens, RecapPeriod::All); - - assert_eq!(brief.total_plays, 1); - assert!(brief.recent[0].starts_with("Some Mix")); - assert!(listens[0] - .item_uri - .as_deref() - .is_some_and(|uri| uri.starts_with("youtube:"))); - } - - #[test] - fn radio_never_qualifies_so_it_cannot_reach_the_brief() { - // The radio branch passes `duration_ms = 0` (media_metadata.rs:282), so - // `qualifies_listen` rejects it and `filter_listens_for_period` drops it. - // This is correct, not a bug to be "fixed". - let listens = vec![record( - "SomaFM Groove Salad", - "SomaFM", - Some("radio:soma"), - 0, - )]; - assert!(!listens[0].qualified); - - let brief = build_brief(&listens, RecapPeriod::All); - assert_eq!(brief.total_plays, 0); - assert!(brief.recent.is_empty()); - assert!(brief.is_sparse()); - } - - #[test] - fn recent_is_deduped_by_normalised_key() { - let listens = vec![ - record("Weird Fishes", "Radiohead", None, 300_000), - record("weird fishes!", "radiohead", None, 300_000), - ]; - let brief = build_brief(&listens, RecapPeriod::All); - assert_eq!(brief.recent.len(), 1, "near-miss titles must collide"); - assert_eq!(brief.recent_keys.len(), 1); - } - - #[test] - fn dedupe_key_ignores_case_punctuation_and_extra_artists() { - assert_eq!( - dedupe_key("Weird Fishes / Arpeggi", "Radiohead"), - dedupe_key("weird fishes arpeggi", "Radiohead, Thom Yorke") - ); - assert_ne!( - dedupe_key("Nude", "Radiohead"), - dedupe_key("Nude", "Prince") - ); - } -} diff --git a/src/infra/dj/exec.rs b/src/infra/dj/exec.rs deleted file mode 100644 index e7cb67ca..00000000 --- a/src/infra/dj/exec.rs +++ /dev/null @@ -1,163 +0,0 @@ -//! How a DJ tool call actually reaches the player. -//! -//! Shared by both front doors, which is why it lives here rather than under -//! `infra::mcp`: the MCP server drives it for a remote agent, and the in-TUI DJ's -//! agent loop drives it for the local brain. Anything that only needs the `App` -//! lock is answered inline; the rest goes down the serial IoEvent lane and waits -//! on a `oneshot`, because only that lane has the real Spotify client. - -use super::tools::{self, DjToolCall, ToolOutcome}; -use crate::core::app::App; -use crate::infra::network::IoEvent; -use std::sync::mpsc::Sender; -use std::sync::Arc; -use tokio::sync::{oneshot, Mutex}; - -/// How long to wait for the IoEvent pump to answer a network-backed tool call. -/// -/// The serial lane can be busy behind other Spotify work, but a caller is waiting -/// on the other end, so this bounds it rather than hanging them. -const TOOL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(45); - -pub trait ToolExecutor { - async fn call(&self, call: DjToolCall) -> ToolOutcome; -} - -/// Executes against the live player. -pub struct AppExecutor { - app: Arc<Mutex<App>>, - io_tx: Sender<IoEvent>, - /// Whether a mutating call raises a status-bar toast. - /// - /// On for MCP, where an agent is changing the player from outside and the user - /// should see it happen without reading a log. Off for the in-TUI DJ, whose - /// transcript already shows every call it made. - announce: bool, -} - -impl AppExecutor { - /// For the MCP front door: mutating calls are announced in the status bar. - #[cfg_attr(not(feature = "mcp-server"), allow(dead_code))] - pub fn new(app: Arc<Mutex<App>>, io_tx: Sender<IoEvent>) -> Self { - Self { - app, - io_tx, - announce: true, - } - } - - /// For the in-TUI DJ, which reports its own tool calls in the transcript. - #[cfg_attr(not(feature = "ai-dj"), allow(dead_code))] - pub fn silent(app: Arc<Mutex<App>>, io_tx: Sender<IoEvent>) -> Self { - Self { - app, - io_tx, - announce: false, - } - } - - async fn announce(&self, message: String) { - let mut app = self.app.lock().await; - app.set_status_message(format!("MCP: {message}"), 5); - } -} - -impl ToolExecutor for AppExecutor { - async fn call(&self, call: DjToolCall) -> ToolOutcome { - let spec = tools::spec(call.tool_name()); - let announce = self.announce && !spec.is_some_and(|spec| spec.read_only); - // The tool table is the single source of truth for which lane a call needs; - // `DjToolCall::needs_network` must agree with it, which a test asserts. - debug_assert_eq!( - spec.map(|spec| spec.needs_network), - Some(call.needs_network()), - "tool table and DjToolCall disagree about needing the network" - ); - - // Anything that only needs `App` is answered here and now. - if !call.needs_network() { - if let Some(outcome) = tools::execute_app_only(&self.app, &call).await { - if announce && !outcome.is_error { - self.announce(outcome.text.clone()).await; - } - return outcome; - } - } - - // The rest need the real Spotify client, which only exists on the serial - // IoEvent lane — the service lane builds a `Network` with `None` for it. - let (tx, rx) = oneshot::channel(); - // Sent straight down the channel rather than through `App::dispatch`, which - // would set the global `is_loading` spinner for the duration of the call. - if self - .io_tx - .send(IoEvent::DjToolCall(Box::new((call, tx)))) - .is_err() - { - return ToolOutcome::error("spotatui is shutting down; the request was not run"); - } - - let outcome = match tokio::time::timeout(TOOL_TIMEOUT, rx).await { - Ok(Ok(outcome)) => outcome, - Ok(Err(_)) => ToolOutcome::error("spotatui dropped the request before answering"), - Err(_) => ToolOutcome::error( - "spotatui did not answer in time; it may be busy. Try again in a moment.", - ), - }; - if announce && !outcome.is_error { - self.announce(outcome.text.clone()).await; - } - outcome - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn app_executor_reports_a_closed_pump() { - let (tx, rx) = std::sync::mpsc::channel(); - drop(rx); - let app = Arc::new(Mutex::new(App::default())); - let executor = AppExecutor::new(app, tx); - // `search_tracks` needs the network, so it takes the IoEvent path. - let outcome = executor - .call(DjToolCall::SearchTracks { - query: "nude".into(), - limit: 5, - }) - .await; - assert!(outcome.is_error); - assert!(outcome.text.contains("shutting down")); - } - - #[tokio::test] - async fn app_executor_answers_app_only_calls_without_the_pump() { - let (tx, rx) = std::sync::mpsc::channel(); - drop(rx); - let app = Arc::new(Mutex::new(App::default())); - let executor = AppExecutor::new(app, tx); - // Nothing is playing in a default App, but the call still succeeds — it - // never touches the (closed) IoEvent channel. - let outcome = executor.call(DjToolCall::GetNowPlaying).await; - assert!(!outcome.is_error); - assert!(outcome.text.contains("Nothing is playing")); - } - - #[tokio::test] - async fn the_silent_executor_leaves_the_status_bar_alone() { - // The in-TUI DJ shows its own calls in the transcript, so an "MCP: …" toast - // for its work would be both duplicated and wrongly attributed. - let (tx, _rx) = std::sync::mpsc::channel(); - let app = Arc::new(Mutex::new(App::default())); - let executor = AppExecutor::silent(Arc::clone(&app), tx); - let outcome = executor - .call(DjToolCall::SetDjVibe { - vibe: Some("mellow".into()), - }) - .await; - assert!(!outcome.is_error); - assert!(app.lock().await.status_message().is_none()); - } -} diff --git a/src/infra/dj/library.rs b/src/infra/dj/library.rs deleted file mode 100644 index 19d48630..00000000 --- a/src/infra/dj/library.rs +++ /dev/null @@ -1,340 +0,0 @@ -//! "Do they already have this?" — the two halves of the avoid-library filter. -//! -//! The two halves are deliberately asymmetric, because the API is: -//! -//! * **Liked Songs** — `me/library/contains` answers exactly, for up to 40 URIs -//! per call. A DJ batch is at most [`MAX_BATCH`](super::MAX_BATCH) tracks, so one -//! call per turn against the canonical truth. Nothing cached, nothing stale. -//! * **Playlists** — no `contains` equivalent exists, so the only way to know is -//! to crawl every playlist and keep the answer. Hence [`build_index`] and the -//! [`DjLibrary`] cached on `App`. -//! -//! Both live here rather than in `network/dj.rs` for the same reason -//! [`super::resolve`] does: they take `&Network` and *return* an answer instead of -//! writing into `App`. - -use super::{dedupe_key, DjLibrary, MAX_LIBRARY_TRACKS}; -use crate::infra::network::Network; -use serde::Deserialize; -use std::collections::HashSet; - -/// Playlists per page. The Spotify maximum, to keep the round trips down. -const PLAYLIST_PAGE: usize = 50; -/// Tracks per page. Also the maximum. -const TRACK_PAGE: usize = 100; - -#[derive(Deserialize)] -struct PlaylistPage { - #[serde(default)] - items: Vec<PlaylistEntry>, - #[serde(default)] - next: Option<String>, -} - -#[derive(Deserialize)] -struct PlaylistEntry { - id: Option<String>, - #[serde(default)] - collaborative: bool, - #[serde(default)] - owner: Option<Owner>, -} - -#[derive(Deserialize)] -struct Owner { - id: Option<String>, -} - -#[derive(Deserialize)] -struct TrackPage { - #[serde(default)] - items: Vec<TrackItem>, - #[serde(default)] - next: Option<String>, -} - -#[derive(Deserialize)] -struct TrackItem { - /// `playlists/{id}/items` returns `item`; the older `tracks` path returned `track`, - /// and `normalize_spotify_payload` only rewrites that when `added_at` is present - /// — which the `fields` filter below strips. Accept both rather than depend on - /// which one arrives. - #[serde(default, alias = "item")] - track: Option<TrackRef>, -} - -#[derive(Deserialize)] -struct TrackRef { - id: Option<String>, - name: Option<String>, - #[serde(default)] - artists: Vec<ArtistRef>, -} - -#[derive(Deserialize)] -struct ArtistRef { - name: Option<String>, -} - -/// Whether this playlist counts as one of the listener's own. -/// -/// Followed playlists are excluded on purpose. Discover Weekly, Release Radar and -/// the large editorial playlists hold thousands of tracks between them; counting -/// those as "already have it" would reject nearly every good recommendation and -/// read as the filter being broken. -fn is_own_playlist(entry: &PlaylistEntry, owner_id: &str) -> bool { - entry.collaborative - || entry - .owner - .as_ref() - .and_then(|owner| owner.id.as_deref()) - .is_some_and(|id| id == owner_id) -} - -/// Crawl the listener's own playlists into a [`DjLibrary`]. -/// -/// Errors only if the playlist listing itself fails. A single unreadable playlist -/// is skipped and logged: a partial index still filters, while failing the whole -/// crawl would turn the feature off for one bad playlist. A skip marks the index -/// [`truncated`](DjLibrary::truncated) all the same — the filter is no longer -/// answering from everything the listener has, and a summary that claimed -/// otherwise would read as the filter working when it is quietly under-filtering. -pub async fn build_index(net: &Network, owner_id: &str) -> anyhow::Result<DjLibrary> { - let mut library = DjLibrary::default(); - let playlist_ids = own_playlist_ids(net, owner_id).await?; - library.playlists = playlist_ids.len(); - - for id in playlist_ids { - if library.tracks >= MAX_LIBRARY_TRACKS { - library.truncated = true; - break; - } - match collect_playlist(net, &id, &mut library).await { - Ok(()) => {} - Err(e) => { - // Keep going, but stop claiming the index is complete: the tracks in - // this playlist will not be filtered, and only `truncated` says so. - library.truncated = true; - log::debug!("DJ: skipping playlist {id} while indexing: {e}"); - } - } - } - - Ok(library) -} - -/// IDs of every playlist the listener owns or collaborates on. -async fn own_playlist_ids(net: &Network, owner_id: &str) -> anyhow::Result<Vec<String>> { - let mut ids = Vec::new(); - let mut offset = 0usize; - loop { - let page = net - .spotify_get_typed::<PlaylistPage>( - "me/playlists", - &[ - ("limit", PLAYLIST_PAGE.to_string()), - ("offset", offset.to_string()), - ], - ) - .await?; - - let count = page.items.len(); - ids.extend( - page - .items - .iter() - .filter(|entry| is_own_playlist(entry, owner_id)) - .filter_map(|entry| entry.id.clone()), - ); - - if is_last_page(count, PLAYLIST_PAGE, page.next.is_some()) { - break; - } - offset += PLAYLIST_PAGE; - } - Ok(ids) -} - -/// Whether to stop paginating. -/// -/// Deliberately trusts neither signal alone. A short page is **not** proof of the -/// end: `normalize_spotify_payload` strips the nulls Spotify returns for -/// unreadable playlists and removed tracks, so a middle page can arrive short. And -/// `next` is requested through a `fields` filter on the track endpoint, so it -/// cannot be assumed present either. Stop only when both agree, with `count == 0` -/// as the backstop that makes an unterminated `next` chain finite rather than an -/// infinite loop. -/// -/// Cost of being wrong in the safe direction: one extra empty request for a -/// playlist whose length is an exact multiple of the page size. -fn is_last_page(count: usize, page_size: usize, has_next: bool) -> bool { - count == 0 || (!has_next && count < page_size) -} - -/// Fold one playlist's tracks into the index. -async fn collect_playlist( - net: &Network, - playlist_id: &str, - library: &mut DjLibrary, -) -> anyhow::Result<()> { - let path = format!("playlists/{playlist_id}/items"); - let mut offset = 0usize; - loop { - let page = net - .spotify_get_typed::<TrackPage>( - &path, - &[ - // Only what the two gates need. A playlist page is otherwise ~40x this - // size, and the crawl pays that per 100 tracks. - ( - "fields", - "items(item(id,name,artists(name))),next".to_string(), - ), - ("limit", TRACK_PAGE.to_string()), - ("offset", offset.to_string()), - ], - ) - .await?; - - let count = page.items.len(); - for item in &page.items { - // Episodes and removed tracks arrive as a null `track`. - let Some(track) = item.track.as_ref() else { - continue; - }; - let Some(name) = track.name.as_deref().filter(|name| !name.is_empty()) else { - continue; - }; - let artists = track - .artists - .iter() - .filter_map(|artist| artist.name.clone()) - .collect::<Vec<_>>() - .join(", "); - library.keys.insert(dedupe_key(name, &artists)); - if let Some(id) = track.id.clone() { - library.ids.insert(id); - } - library.tracks += 1; - if library.tracks >= MAX_LIBRARY_TRACKS { - library.truncated = true; - return Ok(()); - } - } - - if is_last_page(count, TRACK_PAGE, page.next.is_some()) { - break; - } - offset += TRACK_PAGE; - } - Ok(()) -} - -/// Which of these track IDs are in the listener's Liked Songs. -/// -/// Fails **open**: a check that errors returns no IDs, so the tracks are kept. -/// Dropping a good recommendation because a lookup failed is the worse outcome: -/// the user asked for music, not for an empty queue. -pub async fn liked_among(net: &Network, ids: &[String]) -> HashSet<String> { - let uris: Vec<String> = ids.iter().map(|id| format!("spotify:track:{id}")).collect(); - match net.library_contains_uris(&uris).await { - Ok(flags) => ids - .iter() - .zip(flags) - .filter(|(_, is_liked)| *is_liked) - .map(|(id, _)| id.clone()) - .collect(), - Err(e) => { - log::debug!("DJ: Liked Songs check failed, keeping the batch: {e}"); - HashSet::new() - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn entry(id: &str, owner: Option<&str>, collaborative: bool) -> PlaylistEntry { - PlaylistEntry { - id: Some(id.to_string()), - collaborative, - owner: owner.map(|id| Owner { - id: Some(id.to_string()), - }), - } - } - - #[test] - fn own_and_collaborative_playlists_count_but_followed_ones_do_not() { - assert!(is_own_playlist(&entry("a", Some("me"), false), "me")); - // Collaborative: someone else owns it, but the listener files tracks into it. - assert!(is_own_playlist(&entry("b", Some("friend"), true), "me")); - // The case that matters: Discover Weekly would otherwise reject nearly - // everything worth recommending. - assert!(!is_own_playlist(&entry("c", Some("spotify"), false), "me")); - assert!(!is_own_playlist(&entry("d", None, false), "me")); - } - - #[test] - fn a_playlist_page_parses_the_minimal_shape() { - let page: PlaylistPage = serde_json::from_str( - r#"{"items":[{"id":"p1","collaborative":false,"owner":{"id":"me"}}],"next":null}"#, - ) - .unwrap(); - assert_eq!(page.items.len(), 1); - assert!(page.next.is_none()); - } - - #[test] - fn a_track_page_parses_both_the_track_and_item_shapes() { - let page: TrackPage = serde_json::from_str( - r#"{"items":[ - {"track":{"id":"t1","name":"Nude","artists":[{"name":"Radiohead"}]}}, - {"item":{"id":"t2","name":"Nightcall","artists":[{"name":"Kavinsky"}]}}, - {"track":null} - ],"next":null}"#, - ) - .unwrap(); - assert_eq!(page.items.len(), 3); - assert!(page.items[0].track.is_some()); - assert!(page.items[1].track.is_some(), "the `item` alias must parse"); - assert!(page.items[2].track.is_none(), "a null track is skipped"); - } - - #[test] - fn pagination_stops_only_when_both_signals_agree() { - // A full page always continues, even with `next` absent: the track endpoint - // asks for `next` through a `fields` filter, so its absence proves nothing. - assert!(!is_last_page(100, 100, false)); - assert!(!is_last_page(100, 100, true)); - // Short but `next` present: a middle page that lost rows to null-stripping. - // Stopping here would silently index only part of a playlist. - assert!(!is_last_page(48, 50, true)); - // Short and no `next`: genuinely the end. - assert!(is_last_page(48, 50, false)); - // Empty always stops, even with `next` set — the backstop against a `next` - // chain that never terminates. - assert!(is_last_page(0, 50, true)); - } - - #[test] - fn the_index_summary_admits_when_it_stopped_short() { - let full = DjLibrary { - tracks: 12, - playlists: 3, - ..DjLibrary::default() - }; - assert!(full.summary().contains("12 track(s) across 3 playlist(s)")); - assert!(!full.summary().contains("stopped at")); - - let capped = DjLibrary { - truncated: true, - ..full - }; - assert!( - capped.summary().contains("not filtered"), - "a partial index has to say so, or the filter looks broken" - ); - } -} diff --git a/src/infra/dj/mod.rs b/src/infra/dj/mod.rs deleted file mode 100644 index 1f8c846d..00000000 --- a/src/infra/dj/mod.rs +++ /dev/null @@ -1,442 +0,0 @@ -//! The DJ tool layer, shared by both front doors. -//! -//! There are two ways to drive the DJ and they deliberately share everything -//! below the model: -//! -//! * **MCP** (`mcp-server`) — spotatui exposes [`tools`] over an MCP stdio -//! server, so Claude Code / Codex / any MCP client is the DJ. No API key. -//! * **In-TUI** (`ai-dj`) — a DJ screen whose "brain" is a local agent CLI, an -//! API key, or a local model, and which uses the same tools internally. -//! -//! The split that matters for anyone extending this: [`brief`] and [`tools`] -//! only ever touch `App`, while [`resolve`] needs the real Spotify client. That -//! is why the DJ's IoEvents land on two different lanes — see -//! `crate::infra::network::mod` (`runs_on_service_lane`). - -// `dj-core` is an implementation feature with no consumer of its own — it exists -// to be pulled in by `mcp-server` and `ai-dj`, exactly as `audio-decode` is -// pulled in by the media sources. Enabled on its own, everything here is -// legitimately unreachable, so scope the allow to that case rather than blanket -// it (cf. `#[cfg_attr(not(feature = "scripting"), allow(dead_code))]` in -// `infra::network`). -#![cfg_attr(not(any(feature = "mcp-server", feature = "ai-dj")), allow(dead_code))] - -/// The in-TUI DJ's turn loop: brain, tools, repeat. -#[cfg(feature = "ai-dj")] -pub mod agent; -/// The in-TUI DJ's model backends. -#[cfg(feature = "ai-dj")] -pub mod brain; -pub mod brief; -/// How a tool call reaches the live player. Shared by both front doors, and -/// gated on having one: it constructs `IoEvent::DjToolCall`, which bare -/// `dj-core` does not compile. -#[cfg(any(feature = "mcp-server", feature = "ai-dj"))] -pub mod exec; -/// The avoid-library filter's two lookups. Shared: the in-TUI DJ filters with it -/// by default, and the MCP front door uses it to mark `search_tracks` results as -/// owned and to honour `queue_tracks(exclude_owned)`. -pub mod library; -pub mod resolve; -/// Turn assembly for the in-TUI DJ. -#[cfg(feature = "ai-dj")] -pub mod session; -/// What AI the listener has, and which of its models: the data behind the DJ's -/// setup picker. -#[cfg(feature = "ai-dj")] -pub mod setup; -/// The tool surface, shared by both front doors: the MCP server publishes it -/// verbatim, and the in-TUI DJ's agent loop drives the same table. -pub mod tools; - -pub use brief::dedupe_key; - -/// Cap on the avoid-library crawl. A listener with more saved tracks than this -/// gets a partial index rather than an unbounded startup cost, and is told so — -/// silently filtering against half a library would look like the filter is -/// broken. -pub const MAX_LIBRARY_TRACKS: usize = 20_000; - -/// What the listener already has, for the avoid-library filter. -/// -/// Playlists only. Liked Songs are deliberately absent: `me/library/contains` -/// answers that question exactly, for a whole batch in one call, so caching it -/// would add staleness for nothing. See [`library`]. -#[derive(Clone, Debug, Default)] -pub struct DjLibrary { - /// [`dedupe_key`] per track, so a suggestion can be rejected before it costs a - /// catalogue search. - pub keys: std::collections::HashSet<String>, - /// Spotify track IDs, for the exact gate after a suggestion resolves. Catches - /// the case the key set misses: the model named the track differently enough to - /// normalise apart, but search landed on the copy they own. - pub ids: std::collections::HashSet<String>, - pub playlists: usize, - pub tracks: usize, - /// Whether the crawl stopped at [`MAX_LIBRARY_TRACKS`]. - pub truncated: bool, -} - -impl DjLibrary { - /// One line for the transcript, so the cost of the crawl is visible. - pub fn summary(&self) -> String { - let mut text = format!( - "Indexed {} track(s) across {} playlist(s)", - self.tracks, self.playlists - ); - if self.truncated { - text.push_str(&format!( - " (stopped at {MAX_LIBRARY_TRACKS}; the rest is not filtered)" - )); - } - text - } -} - -/// `"Title — Artist"` for whatever is loaded, across every source. -/// -/// Lives here rather than in [`tools`] because it is needed with or without a -/// front door compiled in. -pub fn current_track_label(app: &crate::core::app::App) -> Option<String> { - let snapshot = crate::infra::media_metadata::current_playback_snapshot(app)?; - Some(format!( - "{} — {}", - snapshot.metadata.title, - snapshot.primary_artist() - )) -} - -/// One track a model proposed. Names only — resolution to a URI happens in -/// [`resolve`], which drops anything the catalogue cannot confidently match. -#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub struct DjSuggestion { - pub title: String, - pub artist: String, - /// Optional one-liner from the model. Shown in the DJ transcript when present; - /// never used for matching. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub why: Option<String>, -} - -impl DjSuggestion { - pub fn label(&self) -> String { - format!("{} — {}", self.title, self.artist) - } -} - -/// Who said a line in the DJ transcript. -#[cfg_attr(not(feature = "ai-dj"), allow(dead_code))] -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum DjSpeaker { - User, - Dj, - /// Machine-generated notes ("queued 5 tracks, 1 not found"), styled apart from - /// the model's own prose. - System, -} - -#[cfg_attr(not(feature = "ai-dj"), allow(dead_code))] -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct DjLine { - pub speaker: DjSpeaker, - pub text: String, -} - -#[cfg_attr(not(feature = "ai-dj"), allow(dead_code))] -impl DjLine { - pub fn user(text: impl Into<String>) -> Self { - Self { - speaker: DjSpeaker::User, - text: text.into(), - } - } - pub fn dj(text: impl Into<String>) -> Self { - Self { - speaker: DjSpeaker::Dj, - text: text.into(), - } - } - pub fn system(text: impl Into<String>) -> Self { - Self { - speaker: DjSpeaker::System, - text: text.into(), - } - } -} - -/// How many tracks the DJ asks for, and will accept, per round. -/// -/// Capped for three converging reasons: on an external Connect device each -/// queued Spotify track costs its own Web API call -/// (`App::add_track_to_native_queue`); a deep queue cannot respond to a vibe -/// shift; and one model invocation per batch (rather than per track) is what -/// keeps latency and subscription usage sane. -#[cfg_attr(not(feature = "ai-dj"), allow(dead_code))] -pub const DEFAULT_BATCH: usize = 6; -pub const MAX_BATCH: usize = 8; - -/// Refill the queue when it drops to this many tracks. Two tracks is roughly -/// 6–8 minutes of runway, comfortably more than the worst case for a refill: -/// `agent::MAX_STEPS_MUST_ACT` brain calls at `behavior.dj_agent_timeout_secs` -/// each. That bound is why a refill gets fewer steps than a conversation. -#[cfg_attr(not(feature = "ai-dj"), allow(dead_code))] -pub const QUEUE_LOW_WATER: usize = 2; - -/// Why a turn was started, which decides who may abandon it. -/// -/// A refill runs with nobody watching, so the auto-queue toggle is free to drop -/// one. A question the listener typed is theirs, and dropping it would look like -/// the DJ ignoring them. -#[cfg_attr(not(feature = "ai-dj"), allow(dead_code))] -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum TurnKind { - /// The listener asked for something, or shifted the vibe. - #[default] - Ask, - /// An auto-queue refill. - Refill, -} - -/// Everything the DJ keeps on `App`. -#[cfg_attr(not(feature = "ai-dj"), allow(dead_code))] -#[derive(Clone, Debug, Default)] -pub struct DjState { - /// Whether the DJ keeps the queue topped up as tracks finish. - pub auto_queue: bool, - /// Bumped whenever in-flight DJ work becomes irrelevant: DJ switched off, a - /// vibe shift, a source change. - /// - /// **Load-bearing, not defensive.** A top-up dispatched from the advance path - /// lands seconds later; without re-checking this value before writing, a stale - /// batch queues tracks for a session the user already left. Same idiom as - /// `App::desired_lyrics_identity` and `App::desired_cover_art_key`. - pub generation: u64, - pub transcript: Vec<DjLine>, - /// The DJ's own progress flag. Deliberately *not* the global `App::is_loading`: - /// a brain call can run for a minute or more, and pinning the global spinner - /// that long is a UX bug. - pub thinking: bool, - /// Which step of how many the turn in flight is on, for the `…thinking` row. - /// - /// A multi-step turn on an agent CLI is minutes of silence otherwise, and - /// "thinking" alone cannot be told apart from a hang. - pub step: Option<(usize, usize)>, - /// Which turn owns [`Self::thinking`]. - /// - /// Distinct from [`Self::generation`], which says whether a turn's *results* are - /// still wanted. This says who may clear the progress flag. An abandoned turn - /// finishing would otherwise clear a flag its replacement had already set, and - /// `wants_top_up` gates on exactly that flag — so the next tick would dispatch a - /// second refill and two batches would land. - pub turn_seq: u64, - pub vibe: Option<String>, - pub input: Vec<char>, - pub input_idx: usize, - pub input_cursor: u16, - /// Scroll offset into the *wrapped* transcript rows, not the message list. - pub scroll: u16, - /// URIs of the tracks the DJ put into the native queue. A vibe shift drops - /// exactly these, so the change is audible now rather than six tracks from - /// now. - /// - /// Identity, not a count: the DJ's picks are not guaranteed to be a contiguous - /// tail — the user can queue by hand after a batch lands, or delete a DJ pick - /// from the queue screen — and truncating by count in either state drops the - /// wrong tracks. - pub queued_uris: std::collections::HashSet<String>, - /// Reject anything the listener already has, rather than recommending it. - /// - /// Seeded from `behavior.dj_avoid_library` and toggleable at runtime, because - /// which mode you want depends on the ask: "more like this" wants their - /// favourites in scope, "find me something new" does not. - pub avoid_library: bool, - /// The playlist snapshot behind [`Self::avoid_library`]. `None` until the crawl - /// has run; built lazily, since it costs one API call per 100 playlist tracks - /// and a listener who never turns the filter on should never pay it. - pub library: Option<DjLibrary>, - /// A crawl is in flight. Guards against a second one being dispatched behind it - /// (the toggle and the first turn can both ask). - pub library_indexing: bool, - /// What the turn holding [`Self::thinking`] is, so the auto-queue toggle can - /// abandon a refill without abandoning a question the listener asked. - pub turn_kind: TurnKind, - /// The backend/model picker, when it is open. `None` = closed. - /// - /// DJ-local rather than `app.view.dialog`: a `DialogContext::DjSetup` variant would - /// force `#[cfg(feature = "ai-dj")]` arms into `draw_dialog`'s exhaustive match, - /// `handle_escape` and `mouse.rs`, and `app.view.dialog` is an `Option<String>` that - /// could not carry a two-step cursor anyway. - /// - /// Gated one level finer than the struct around it (`dj-core`), which is safe - /// because neither `App` initializer names the field and `Default` is derived. - /// That is what lets the picker's types live in an `ai-dj`-only module. - #[cfg(feature = "ai-dj")] - pub setup: Option<setup::DjSetup>, -} - -impl DjState { - /// Invalidate in-flight work and return the new generation. - /// - /// Used by `set_dj_vibe` over MCP as well as by the in-TUI vibe shift. - pub fn bump_generation(&mut self) -> u64 { - self.generation = self.generation.wrapping_add(1); - self.generation - } - - /// Claim the progress flag for a turn about to be dispatched. - /// - /// Every path that starts a turn goes through here, so the returned value is - /// the one thing allowed to clear `thinking` again. - #[cfg_attr(not(feature = "ai-dj"), allow(dead_code))] - pub fn begin_turn(&mut self, kind: TurnKind) -> u64 { - self.thinking = true; - self.turn_kind = kind; - self.turn_seq = self.turn_seq.wrapping_add(1); - self.turn_seq - } - - /// Whether the turn holding the progress flag is one the listener is waiting on. - /// - /// The auto-queue toggle abandons work in flight, and it may only abandon its - /// own: a listener who asked a question and then flipped the toggle is still - /// owed the answer. - #[cfg_attr(not(feature = "ai-dj"), allow(dead_code))] - pub fn asked_turn_in_flight(&self) -> bool { - self.thinking && self.turn_kind == TurnKind::Ask - } - - /// Clear the progress flag, but only if `seq` still owns it. - #[cfg_attr(not(feature = "ai-dj"), allow(dead_code))] - pub fn finish_turn(&mut self, seq: u64) { - if self.turn_seq == seq { - self.thinking = false; - self.step = None; - } - } - - #[cfg_attr(not(feature = "ai-dj"), allow(dead_code))] - pub fn push_line(&mut self, line: DjLine) { - self.transcript.push(line); - // The transcript is a conversation, not a log; an unbounded Vec here would - // grow for the life of the process and re-wrap on every draw. - const MAX_LINES: usize = 200; - if self.transcript.len() > MAX_LINES { - let overflow = self.transcript.len() - MAX_LINES; - self.transcript.drain(0..overflow); - } - } - - /// Close the picker, resetting every part of it together. - /// - /// One named dismissal path, so a step and a cursor can never be left - /// half-finished for the next open to render. - #[cfg(feature = "ai-dj")] - pub fn close_setup(&mut self) { - self.setup = None; - } - - #[cfg_attr(not(feature = "ai-dj"), allow(dead_code))] - pub fn clear_input(&mut self) { - self.input.clear(); - self.input_idx = 0; - self.input_cursor = 0; - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn generation_bump_invalidates() { - let mut state = DjState::default(); - let first = state.bump_generation(); - let second = state.bump_generation(); - assert_ne!(first, second); - } - - #[test] - fn transcript_is_bounded() { - let mut state = DjState::default(); - for i in 0..250 { - state.push_line(DjLine::dj(format!("line {i}"))); - } - assert_eq!(state.transcript.len(), 200); - // Oldest dropped, newest kept. - assert_eq!(state.transcript.last().unwrap().text, "line 249"); - } - - #[test] - fn only_the_newest_turn_may_clear_the_progress_flag() { - // The interleaving this exists for: turn A is in flight, the listener toggles - // auto-queue off and on, the tick dispatches B — then A finishes. If A could - // clear the flag, `wants_top_up` would see an idle DJ with a short queue and - // dispatch C, and B and C would both queue a batch. - let mut state = DjState::default(); - let a = state.begin_turn(TurnKind::Refill); - let b = state.begin_turn(TurnKind::Refill); - assert_ne!(a, b); - - state.finish_turn(a); - assert!(state.thinking, "a stale turn does not own the flag"); - - state.finish_turn(b); - assert!(!state.thinking, "the turn that owns it does"); - assert!(state.step.is_none(), "and clears the step counter with it"); - } - - #[test] - fn clear_input_clears_cursor_state() { - let mut state = DjState::default(); - state.input = "chill".chars().collect(); - state.input_idx = 5; - state.input_cursor = 5; - state.clear_input(); - assert!(state.input.is_empty()); - assert_eq!(state.input_idx, 0); - assert_eq!(state.input_cursor, 0); - } - - #[test] - fn suggestion_label_is_stable() { - let suggestion = DjSuggestion { - title: "Nude".into(), - artist: "Radiohead".into(), - why: None, - }; - assert_eq!(suggestion.label(), "Nude — Radiohead"); - } -} - -/// Payload for `IoEvent::AskDj`. -#[cfg(feature = "ai-dj")] -pub struct AskDjRequest { - /// A one-shot steer for the brain that is **not** in the transcript. - /// - /// The caller owns the transcript: whatever the listener typed is pushed by the - /// handler before this event is dispatched, and reaches the brain through the - /// conversation history that `session::turn_context` builds from it. Pushing it - /// here as well is what produced the doubled `you` line. Use this field only for - /// an instruction the listener never typed, such as the vibe shift's "change - /// direction" — those are deliberately transient and do not persist into later - /// turns' history. - pub extra_instruction: Option<String>, - /// Generation this turn was dispatched for. Re-checked before anything is - /// queued so a turn the user has since abandoned is dropped. - pub generation: u64, - /// Whether this turn must end in music rather than words. - /// - /// Set for an auto-queue refill and a vibe shift. Both run with nobody watching - /// the screen, where a clarifying question is indistinguishable from a hang. - pub must_act: bool, - /// Which turn this is, from [`DjState::begin_turn`]. Only this turn may clear - /// the progress flag when it ends. - pub turn_seq: u64, - /// What to store as the standing auto-queue direction, but only if the turn - /// actually queues something. - /// - /// Conditional because the DJ can now hold a conversation: setting it - /// unconditionally made "what have I been listening to?" the vibe every later - /// refill followed. - pub vibe_on_success: Option<String>, -} diff --git a/src/infra/dj/resolve.rs b/src/infra/dj/resolve.rs deleted file mode 100644 index 7f3cd063..00000000 --- a/src/infra/dj/resolve.rs +++ /dev/null @@ -1,579 +0,0 @@ -//! Turning names into playable URIs. -//! -//! A model hands back `(title, artist)` pairs, some of which do not exist. This -//! module searches the catalogue for each one and **drops anything it cannot -//! confidently match** — hallucinated tracks are the primary failure mode once -//! the model is the recommender, and queueing a fuzzy near-miss is worse than -//! queueing nothing. Search always returns *something*, so a title match is -//! required rather than trusting the top hit. -//! -//! Spotify's own `/recommendations` is not used anywhere here: it has been -//! restricted to apps that held extended quota before 2024-11-27, and spotatui -//! users register their own client ID, so it returns 403 for them. The model is -//! the recommender; this module is only the lookup. - -use super::brief::{dedupe_key, normalize}; -use super::DjSuggestion; -use crate::core::plugin_api::TrackInfo; -use crate::infra::network::search::TrackSearchResponse; -use crate::infra::network::Network; -use rspotify::model::track::FullTrack; -use std::collections::HashSet; - -/// How many candidates to ask the catalogue for per suggestion. Enough to get -/// past a remaster or a live version at the top of the list, small enough to -/// keep the response cheap. -const CANDIDATES: usize = 5; - -/// Shortest normalised title allowed to match as a *substring* rather than -/// exactly. -/// -/// "Go" or "Hey" is a substring of half the catalogue, so for a title that short -/// the substring branch stops rejecting anything and the resolver hands back -/// whatever search felt like returning. Four is the lowest bar that still lets -/// the suffix cases through: "Nude" inside "Nude - Remastered 2017" is exactly -/// four characters, and that is the case this whole branch exists for. Anything -/// shorter has to match exactly. -const MIN_SUBSTRING_TITLE: usize = 4; - -/// What separates two artists inside one credit. -/// -/// The catalogue joins its artist list with `", "`; a model writes a -/// collaboration with `&`, `;`, ` x ` or `feat.`. Splitting on these is the whole -/// point of [`shares_an_artist`] — a `contains` test accepts "Radiohead Tribute -/// Band" for "Radiohead", which is a different band playing the same songs. -const CREDIT_PUNCTUATION: [char; 3] = [',', ';', '&']; -/// Standalone words that join two artists. Matched as whole tokens, never as -/// substrings: "Daft Punk" contains "ft ". -const CREDIT_JOIN_WORDS: [&str; 4] = ["x", "feat", "ft", "featuring"]; - -/// What came back from a resolve pass. -/// -/// Not `Eq`: `TrackInfo` is only `PartialEq` (its shape is pinned by the plugin -/// contract). -#[derive(Clone, Debug, Default, PartialEq)] -pub struct ResolveReport { - /// Playable tracks, in the order the suggestions arrived. - pub resolved: Vec<TrackInfo>, - /// `"Title — Artist"` for suggestions with no confident catalogue match. - /// Reported back to whichever model asked, so it can correct itself on its - /// next step rather than naming the same nonexistent track again. - pub unresolved: Vec<String>, - /// Suggestions dropped because they were already queued, playing, or recently - /// played — see [`SkipSets::session`], which spans all three. - pub duplicates: Vec<String>, - /// Suggestions dropped because the listener already has them, with the - /// avoid-library filter on. - /// - /// A separate bucket from [`Self::duplicates`] because the two need different - /// words in front of the user and different words to the model: "already queued" - /// is wrong for a track they own, and reporting these as *not in the catalogue* - /// would tell the model something false. - pub in_library: Vec<String>, -} - -/// The two reasons a suggestion can be rejected before it is even searched for. -/// -/// Kept apart rather than merged into one set so [`ResolveReport`] can say which -/// happened. Both hold [`dedupe_key`] values. -pub struct SkipSets<'a> { - /// Already queued, playing, or recently played. Always applied. - pub session: &'a HashSet<String>, - /// In the listener's own playlists. Empty unless the caller asked to avoid the - /// library: in-TUI that is the <kbd>Ctrl</kbd>+<kbd>O</kbd> toggle, over MCP it is - /// `queue_tracks(exclude_owned: true)`. Left empty for an ordinary MCP queue, where - /// the agent named specific tracks and dropping one behind its back would be wrong. - pub library: &'a HashSet<String>, -} - -impl ResolveReport { - /// One-line summary for a status message or an MCP tool result. - pub fn summary(&self) -> String { - let mut parts = vec![format!("queued {}", self.resolved.len())]; - if !self.unresolved.is_empty() { - parts.push(format!("{} not found", self.unresolved.len())); - } - if !self.in_library.is_empty() { - parts.push(format!("{} already yours", self.in_library.len())); - } - if !self.duplicates.is_empty() { - parts.push(format!("{} already queued", self.duplicates.len())); - } - parts.join(", ") - } -} - -/// Resolve suggestions to tracks, skipping duplicates and stopping at `cap`. -/// -/// `skips` holds [`dedupe_key`] values for everything to reject unheard, split by -/// reason. `cap` bounds the batch — on an external Connect device each queued -/// Spotify track becomes its own Web API call -/// (`App::add_track_to_native_queue`), so an unbounded batch is an unbounded -/// number of round trips. -/// -/// The library gate here is the *cheap* one: it matches on the name the model -/// gave, before any search is paid for. The exact gate runs on the resolved track -/// ID afterwards, in the caller — see `Network::reject_owned_tracks`. -pub async fn resolve_suggestions( - net: &Network, - suggestions: &[DjSuggestion], - skips: &SkipSets<'_>, - cap: usize, - #[allow(unused_variables)] youtube_fallback: Option<&YouTubeResolver>, -) -> ResolveReport { - let mut report = ResolveReport::default(); - // Guards against a model repeating itself inside one reply, which it does. - let mut claimed: HashSet<String> = HashSet::new(); - - for suggestion in suggestions { - if report.resolved.len() >= cap { - break; - } - let label = suggestion.label(); - let key = dedupe_key(&suggestion.title, &suggestion.artist); - if skips.library.contains(&key) { - report.in_library.push(label); - continue; - } - if skips.session.contains(&key) || !claimed.insert(key) { - report.duplicates.push(label); - continue; - } - - match resolve_one(net, suggestion, youtube_fallback).await { - Some(track) => report.resolved.push(track), - None => { - // Deliberately debug-level and never a toast: a model that invents a - // track is routine, and surfacing it would be constant noise. - log::debug!("DJ: no catalogue match for {label}"); - report.unresolved.push(label); - } - } - } - - report -} - -async fn resolve_one( - net: &Network, - suggestion: &DjSuggestion, - youtube_fallback: Option<&YouTubeResolver>, -) -> Option<TrackInfo> { - if let Some(track) = resolve_spotify(net, &suggestion.title, &suggestion.artist).await { - return Some(track); - } - #[cfg(feature = "youtube")] - if let Some(resolver) = youtube_fallback { - if let Some(track) = resolver - .resolve(&suggestion.title, &suggestion.artist) - .await - { - return Some(track); - } - } - let _ = youtube_fallback; - None -} - -/// Search Spotify for one track and return the best confident match. -/// -/// Unlike `SearchNetwork::get_search_results` and `search_tracks_for_playlist`, -/// which are fire-and-forget and write into `App`, this **returns** its result — -/// the DJ needs an answer, not a UI side effect. -pub async fn resolve_spotify(net: &Network, title: &str, artist: &str) -> Option<TrackInfo> { - // Spotify field filters keep the fuzzy matching honest; a bare concatenated - // query happily returns a different artist's song with a similar name. - let query = vec![ - ( - "q", - format!("track:\"{}\" artist:\"{}\"", escape(title), escape(artist)), - ), - ("type", "track".to_string()), - ("limit", CANDIDATES.to_string()), - ("offset", "0".to_string()), - ]; - - let response = match net - .spotify_get_typed::<TrackSearchResponse>("search", &query) - .await - { - Ok(response) => response, - Err(e) => { - // A resolve miss must never surface as the modal error page; the caller - // reports an aggregate count instead. - log::debug!("DJ: Spotify search failed for {title} — {artist}: {e}"); - return None; - } - }; - - best_spotify_match(&response.tracks.items, title, artist) -} - -/// Strip the characters that would break out of a quoted Spotify field filter. -fn escape(value: &str) -> String { - value.replace(['"', '\\'], " ") -} - -fn best_spotify_match(candidates: &[FullTrack], title: &str, artist: &str) -> Option<TrackInfo> { - candidates - .iter() - .filter(|track| track.id.is_some()) - .filter_map(|track| { - let names = track - .artists - .iter() - .map(|a| a.name.clone()) - .collect::<Vec<_>>() - .join(", "); - let score = match_score(&track.name, &names, title, artist)?; - Some((score, track)) - }) - // `max_by_key` keeps the *last* maximum; candidates are in relevance order, - // so prefer the earliest by comparing on a descending index tiebreak. - .enumerate() - .max_by_key(|(index, (score, _))| (*score, usize::MAX - index)) - .map(|(_, (_, track))| TrackInfo::from(track)) -} - -/// Score a candidate against what was asked for, or `None` to reject it. -/// -/// Rejecting is the important half. Search never returns nothing, so without a -/// floor every invented track resolves to whatever was closest. -fn match_score( - candidate_title: &str, - candidate_artist: &str, - want_title: &str, - want_artist: &str, -) -> Option<u8> { - let (ct, ca) = (normalize(candidate_title), normalize(candidate_artist)); - let (wt, wa) = (normalize(want_title), normalize(want_artist)); - if ct.is_empty() || wt.is_empty() { - return None; - } - - let title_score = if ct == wt { - 2 - } else if contains_meaningfully(&ct, &wt) { - // Covers "Weird Fishes" vs "Weird Fishes / Arpeggi" and - // "Nude" vs "Nude - Remastered". - 1 - } else { - return None; - }; - - // An artist mismatch on an otherwise-matching title is usually a cover, a - // karaoke version, or a different band entirely — all wrong. A credit is a - // *list* of artists, so the question is whether the two credits name an artist - // in common, not whether one string sits inside the other. - let artist_score = if ca == wa { - 2 - } else if !wa.is_empty() && shares_an_artist(candidate_artist, want_artist) { - 1 - } else if wa.is_empty() { - 0 - } else { - return None; - }; - - Some(title_score * 2 + artist_score) -} - -/// Substring title match, guarded on the length of the *contained* title in -/// whichever direction it matched — see [`MIN_SUBSTRING_TITLE`]. -fn contains_meaningfully(candidate: &str, wanted: &str) -> bool { - let long_enough = |title: &str| title.chars().count() >= MIN_SUBSTRING_TITLE; - (long_enough(wanted) && candidate.contains(wanted)) - || (long_enough(candidate) && wanted.contains(candidate)) -} - -/// Whether two credits name at least one artist in common. -/// -/// Equality per listed artist, not containment across the whole credit. That is -/// what separates the two cases this has to get right: "Bob Moses, RAC" and -/// "Bob Moses" list an artist in common and are the same track, while "Radiohead -/// Tribute Band" lists exactly one artist, which is not Radiohead. -fn shares_an_artist(candidate: &str, wanted: &str) -> bool { - let wanted = credited_artists(wanted); - !wanted.is_empty() - && credited_artists(candidate) - .iter() - .any(|name| wanted.contains(name)) -} - -/// Split a credit into the artists it names, each normalised for comparison. -fn credited_artists(credit: &str) -> Vec<String> { - let mut names = Vec::new(); - for chunk in credit.split(CREDIT_PUNCTUATION) { - let mut current: Vec<&str> = Vec::new(); - for token in chunk.split_whitespace() { - let bare = token - .trim_matches(|c: char| !c.is_alphanumeric()) - .to_lowercase(); - if CREDIT_JOIN_WORDS.contains(&bare.as_str()) { - push_artist(&mut names, ¤t); - current.clear(); - } else { - current.push(token); - } - } - push_artist(&mut names, ¤t); - } - if names.is_empty() { - // An artist named after a join word (there is a producer called X) splits to - // nothing; fall back to the credit whole rather than to "matches anything". - push_artist(&mut names, &[credit]); - } - names -} - -fn push_artist(names: &mut Vec<String>, tokens: &[&str]) { - let name = artist_key(&tokens.join(" ")); - if !name.is_empty() { - names.push(name); - } -} - -/// Normalise one artist name for equality: [`normalize`] plus a dropped leading -/// "the", because a model writes "Chemical Brothers" for a catalogue credited -/// "The Chemical Brothers" and that is the same band. -fn artist_key(name: &str) -> String { - let normalized = normalize(name); - normalized - .strip_prefix("the ") - .unwrap_or(&normalized) - .to_string() -} - -/// YouTube fallback for tracks Spotify does not have (or cannot license). -/// -/// Holds a built source rather than building one per lookup, because -/// `YouTubeSource::search` shells out to `yt-dlp` and the config read behind -/// `build_source` needs the `App` lock. -pub struct YouTubeResolver { - #[cfg(feature = "youtube")] - source: crate::infra::youtube::YouTubeSource, -} - -impl YouTubeResolver { - #[cfg(feature = "youtube")] - pub async fn new(app: &std::sync::Arc<tokio::sync::Mutex<crate::core::app::App>>) -> Self { - Self { - source: crate::infra::youtube::dispatch::build_source(app).await, - } - } - - #[cfg(feature = "youtube")] - async fn resolve(&self, title: &str, artist: &str) -> Option<TrackInfo> { - use crate::core::source::Searcher; - let query = format!("{artist} {title}"); - let results = match self.source.search(&query).await { - Ok(results) => results, - Err(e) => { - log::debug!("DJ: YouTube search failed for {query}: {e}"); - return None; - } - }; - // A YouTube title is free text ("Radiohead - Weird Fishes [HQ]"), so the - // strict scoring used for Spotify would reject nearly everything. Require - // the normalised title to appear somewhere in the video title instead. - let wanted = normalize(title); - results.tracks.into_iter().find(|track| { - let haystack = normalize(&format!("{} {}", track.name, track.artists.join(" "))); - !wanted.is_empty() && haystack.contains(&wanted) - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// `TrackInfo` has no `Default` (the plugin contract pins its shape), so - /// build a minimal placeholder for the report-shape tests. - fn stub_track() -> TrackInfo { - TrackInfo { - uri: Some("spotify:track:stub".to_string()), - name: "Stub".to_string(), - artists: vec!["Stub".to_string()], - album: String::new(), - duration_ms: 200_000, - id: Some("stub".to_string()), - album_id: None, - artist_refs: Vec::new(), - is_playable: true, - is_local: false, - track_number: 1, - explicit: false, - image_url: None, - } - } - - #[test] - fn exact_title_and_artist_scores_highest() { - let exact = match_score("Weird Fishes", "Radiohead", "Weird Fishes", "Radiohead"); - let partial = match_score( - "Weird Fishes / Arpeggi", - "Radiohead", - "Weird Fishes", - "Radiohead", - ); - assert!(exact > partial); - assert!(partial.is_some()); - } - - #[test] - fn punctuation_and_case_do_not_matter() { - assert!(match_score("NUDE!", "radiohead", "Nude", "Radiohead").is_some()); - } - - #[test] - fn remaster_suffix_still_matches() { - assert!(match_score("Nude - Remastered 2017", "Radiohead", "Nude", "Radiohead").is_some()); - } - - #[test] - fn wrong_artist_is_rejected_even_with_matching_title() { - // The cover-version trap: right title, wrong band. - assert_eq!( - match_score("Nude", "Karaoke Kings", "Nude", "Radiohead"), - None - ); - } - - #[test] - fn unrelated_title_is_rejected() { - // The hallucination trap: search would have returned this happily. - assert_eq!( - match_score("Purple Moonlight", "Radiohead", "Weird Fishes", "Radiohead"), - None - ); - } - - #[test] - fn collaborator_differences_still_match() { - assert!(match_score( - "Tearing Me Up", - "Bob Moses", - "Tearing Me Up", - "Bob Moses, RAC" - ) - .is_some()); - } - - #[test] - fn a_tribute_band_is_not_the_artist() { - // The case containment cannot see: "radiohead tribute band" contains - // "radiohead", and a token-subset test does not help either — {radiohead} is - // a subset of {radiohead, tribute, band}. One listed artist, and it is not - // the one that was asked for. - assert_eq!( - match_score("Nude", "Radiohead Tribute Band", "Nude", "Radiohead"), - None - ); - // And the other way round, for a model that names the tribute act. - assert_eq!( - match_score("Nude", "Radiohead", "Nude", "Radiohead Tribute Band"), - None - ); - } - - #[test] - fn a_credit_that_lists_the_artist_still_matches() { - // Whichever side carries the longer credit, and whichever separator the - // model reached for. - for (candidate, wanted) in [ - ("Bob Moses, RAC", "Bob Moses"), - ("Bob Moses", "Bob Moses, RAC"), - ("Kaytranada & Anderson .Paak", "Kaytranada"), - ("Skrillex x Diplo", "Diplo"), - ("Calvin Harris feat. Rihanna", "Rihanna"), - ("Calvin Harris (feat. Rihanna)", "Calvin Harris"), - ] { - assert!( - match_score("Tearing Me Up", candidate, "Tearing Me Up", wanted).is_some(), - "{candidate} should match {wanted}" - ); - } - } - - #[test] - fn a_leading_the_is_not_a_different_band() { - // A model writes the credit without the article about as often as with it. - assert!(match_score( - "Block Rockin Beats", - "The Chemical Brothers", - "Block Rockin Beats", - "Chemical Brothers" - ) - .is_some()); - } - - #[test] - fn a_join_word_is_not_matched_inside_a_name() { - // "daft punk" contains "ft " — splitting on raw substrings would cut the - // band in half and then match "punk" against anything punk. - assert_eq!( - match_score("Around the World", "Daft Punk", "Around the World", "Punk"), - None - ); - } - - #[test] - fn a_short_title_has_to_match_exactly() { - // "go" is a substring of half the catalogue, so the substring branch stops - // rejecting anything for a title this short. - assert_eq!( - match_score("Go Your Own Way", "Fleetwood Mac", "Go", "Fleetwood Mac"), - None - ); - // Same in the other direction: a short *candidate* inside a long request. - assert_eq!( - match_score("Go", "Fleetwood Mac", "Go Your Own Way", "Fleetwood Mac"), - None - ); - // The title itself is still perfectly resolvable, exactly. - assert!(match_score("Go", "The Chemical Brothers", "Go", "Chemical Brothers").is_some()); - } - - #[test] - fn the_summary_distinguishes_owned_from_already_queued() { - let owned = ResolveReport { - in_library: vec!["a".into()], - ..ResolveReport::default() - }; - let summary = owned.summary(); - assert!(summary.contains("1 already yours")); - assert!( - !summary.contains("already queued"), - "a track they own was never queued; saying so explains nothing" - ); - } - - #[test] - fn summary_counts_what_is_still_in_the_report() { - // `summary()` reads `resolved`, so a caller that takes the vector first gets - // "queued 0" no matter how many landed. Guards the ordering in `dj_queue`. - let mut report = ResolveReport { - resolved: vec![stub_track()], - ..ResolveReport::default() - }; - assert!(report.summary().contains("queued 1")); - let _ = std::mem::take(&mut report.resolved); - assert!(report.summary().contains("queued 0")); - } - - #[test] - fn summary_mentions_every_bucket() { - let report = ResolveReport { - resolved: vec![stub_track()], - unresolved: vec!["ghost".into()], - duplicates: vec!["dupe".into()], - in_library: vec!["owned".into()], - }; - let summary = report.summary(); - assert!(summary.contains("queued 1")); - assert!(summary.contains("1 not found")); - assert!(summary.contains("1 already queued")); - assert!(summary.contains("1 already yours")); - } -} diff --git a/src/infra/dj/session.rs b/src/infra/dj/session.rs deleted file mode 100644 index 8af800d1..00000000 --- a/src/infra/dj/session.rs +++ /dev/null @@ -1,753 +0,0 @@ -//! Assembling a DJ turn: build the brain, build the brief, snapshot the context. -//! -//! This is the glue between config and [`super::agent`], which runs the turn. It -//! is used from the **service** lane — a brain call is slow (up to a couple of -//! minutes for an agent CLI) and touches only `App` plus its own HTTP client or -//! subprocess, so parking it on the serial pump would freeze every other event -//! behind it. -//! -//! Resolution deliberately does *not* happen here: it needs the real Spotify -//! client, which the service lane's `Network` does not have. The loop's tool -//! calls cross to the serial lane through `IoEvent::DjToolCall` instead. - -use super::brain::{ - agent_cli::{AgentCliBrain, PromptDelivery}, - anthropic::AnthropicBrain, - openai_compat::OpenAiCompatBrain, - DjBrain, DjRequest, ToolExchange, -}; -use super::brief::{self, TasteBrief}; -use super::{DjLine, DjSpeaker}; -use crate::core::app::App; -use crate::core::user_config::BehaviorConfig; -use crate::infra::history::RecapPeriod; -use anyhow::{anyhow, Result}; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::time::Duration; -use tokio::sync::Mutex; - -/// Environment variable that wins over `behavior.dj_api_key` at request time. -/// -/// Mirrors how the Subsonic password is handled: the config field exists for -/// convenience, but the env var is the recommended route because it never lands -/// on disk. -pub const API_KEY_ENV: &str = "SPOTATUI_DJ_API_KEY"; - -/// Where agent CLIs are run from. -/// -/// **Not** the user's current directory. Coding agents read `CLAUDE.md` / -/// `AGENTS.md` and project files from their working directory, so an agent -/// launched inside a repository answers with that repository on its mind. -/// -/// Resolved once per process, so every step of every turn runs from the same -/// directory and the fallback is reported once. The config dir is preferred; a -/// private subdirectory of the OS temp dir is the fallback (#478: a build -/// sandbox sets `HOME` to a path that cannot be created, and `spawn` with a -/// missing cwd fails with an ENOENT that looks like a missing binary). -fn agent_scratch_dir() -> PathBuf { - static DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new(); - DIR - .get_or_init(|| { - scratch_dir_from( - crate::core::user_config::default_app_config_dir().map(|dir| dir.join("dj-scratch")), - &std::env::temp_dir(), - ) - }) - .clone() -} - -/// [`agent_scratch_dir`] with the inputs passed in rather than read. -/// -/// The config scratch dir is used when it can be created, entered, and written. -/// Otherwise a fresh, uniquely named directory is made under `temp`. Never a -/// fixed name there: a shared temp root lets another local user plant that name -/// (or a symlink to a directory of theirs) and so choose the agent's working -/// directory, instruction files included. A relative temp dir is skipped, as -/// `paths.rs` skips a relative XDG value: it would resolve against the process -/// cwd, the one place an agent must not run. When nothing qualifies, the -/// preferred path is returned and `spawn` reports the cwd it could not enter. -fn scratch_dir_from(preferred: Option<PathBuf>, temp: &Path) -> PathBuf { - if let Some(dir) = preferred.as_ref().filter(|dir| usable_dir(dir)) { - return dir.clone(); - } - match temp.is_absolute().then(|| fresh_temp_dir(temp)).flatten() { - Some(dir) => { - log::warn!( - "DJ: could not use the scratch dir {preferred:?}; running agent CLIs from {}", - dir.display() - ); - dir - } - None => { - log::warn!( - "DJ: no usable scratch dir (config {preferred:?}, temp {}); agent CLIs will not start", - temp.display() - ); - preferred.unwrap_or_else(|| temp.join("dj-scratch")) - } - } -} - -/// Whether `Command::current_dir` can enter `dir` and the agent can write in it. -/// -/// `create_dir_all` alone is not enough: it succeeds on a directory that already -/// exists without search permission, and `spawn` then fails on the `chdir` with -/// the same misleading error as a missing cwd. Creating and removing a file -/// inside needs search and write on the directory, which is what the agent -/// needs too. -fn usable_dir(dir: &Path) -> bool { - if std::fs::create_dir_all(dir).is_err() { - return false; - } - let probe = dir.join(format!(".probe-{}", std::process::id())); - let written = std::fs::write(&probe, b"").is_ok(); - let _ = std::fs::remove_file(&probe); - written -} - -/// A new, uniquely named, owner-only directory under `temp`, or `None` when -/// none could be made. A non-recursive create refuses an existing entry, a -/// symlink included, so the result is always one this process made. It is not -/// removed on exit: temp is the one location the platform clears on its own, -/// and the per-process log file already relies on that. -fn fresh_temp_dir(temp: &Path) -> Option<PathBuf> { - // Owner-only from the start, whatever the umask: the agent's working - // directory must not be readable or writable by other local users. - #[cfg(unix)] - let builder = { - use std::os::unix::fs::DirBuilderExt; - let mut builder = std::fs::DirBuilder::new(); - builder.mode(0o700); - builder - }; - #[cfg(not(unix))] - let builder = std::fs::DirBuilder::new(); - let pid = std::process::id(); - (0..8).find_map(|attempt| { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|since| since.subsec_nanos()) - .unwrap_or(0); - let dir = temp.join(format!("spotatui-dj-scratch-{pid}-{nanos}-{attempt}")); - builder.create(&dir).ok().map(|()| dir) - }) -} - -/// The API-key precedence rule: the env var wins, the config field is the -/// fallback, and a blank value from either counts as unset. -/// -/// Blank has to mean unset on both sides, because `setup::anthropic_key_present` -/// reads them that way when it decides whether the picker shows a key as present. -/// A blank config key that got through here would be sent as an empty bearer -/// token, which fails less clearly than no credential at all. -/// -/// Split out so the tests can drive both branches without calling `set_var`: -/// `setenv`/`getenv` are not thread-safe (which is why they are `unsafe` in edition -/// 2024), and every test in this binary shares one process. A test that exported -/// `SPOTATUI_DJ_API_KEY` for a moment raced every other thread reading it — -/// including the picker's own `detect_backends`. -fn resolve_api_key_with(env_key: Option<&str>, behavior: &BehaviorConfig) -> Option<String> { - env_key - .filter(|key| !key.trim().is_empty()) - .or_else(|| { - behavior - .dj_api_key - .as_deref() - .filter(|key| !key.trim().is_empty()) - }) - .map(str::to_string) -} - -pub fn period_from_config(value: &str) -> RecapPeriod { - match value { - "7d" => RecapPeriod::SevenDays, - "month" => RecapPeriod::Month, - "year" => RecapPeriod::Year, - "all" => RecapPeriod::All, - // Validated on load, so anything else is already impossible; default rather - // than panic if that ever changes. - _ => RecapPeriod::ThirtyDays, - } -} - -/// Build the configured brain, or explain why it cannot be built. -pub fn build_brain(behavior: &BehaviorConfig) -> Result<DjBrain> { - let from_env = std::env::var(API_KEY_ENV).ok(); - build_brain_with(from_env.as_deref(), behavior) -} - -/// [`build_brain`] with the environment passed in rather than read. -/// -/// Same reason as [`resolve_api_key_with`]: "no key anywhere" is a case the tests -/// have to be able to produce, and producing it by unsetting a process-global was -/// a data race against every other test thread. -fn build_brain_with(env_key: Option<&str>, behavior: &BehaviorConfig) -> Result<DjBrain> { - match behavior.dj_backend.as_str() { - "agent_cli" => { - let (command, delivery) = resolve_agent_command(behavior); - Ok(DjBrain::AgentCli(AgentCliBrain::new( - command, - delivery, - Duration::from_secs(behavior.dj_agent_timeout_secs), - agent_scratch_dir(), - )?)) - } - "anthropic" => { - let key = resolve_api_key_with(env_key, behavior).unwrap_or_default(); - Ok(DjBrain::Anthropic(AnthropicBrain::new( - key, - behavior.dj_model.clone(), - behavior.dj_base_url.clone(), - )?)) - } - "openai_compat" => Ok(DjBrain::OpenAiCompat(OpenAiCompatBrain::new( - resolve_api_key_with(env_key, behavior), - behavior.dj_model.clone(), - behavior.dj_base_url.clone(), - ))), - other => Err(anyhow!( - "unknown behavior.dj_backend '{other}'; expected agent_cli, anthropic, or openai_compat" - )), - } -} - -/// Which preset, if any, this configured argv is "just the default shape" of. -/// -/// Two shapes count. A bare binary name (`["codex"]`) is the documented -/// convenience. An argv byte-equal to what that preset produces with no model -/// (`["claude", "-p"]`) counts too, because that is the argv *spotatui shipped* -/// and then wrote to disk itself on the first unrelated `save_config` — it is not -/// something the user typed. Anything else is theirs, including its model flag. -/// -/// This is what keeps the "an explicit multi-part command is never rewritten" -/// invariant honest while still letting a default install pick a model: with -/// `dj_agent_model` unset the resolved argv is unchanged either way. -fn expandable_preset(command: &[String]) -> Option<&'static super::brain::agent_cli::AgentPreset> { - let first = command.first()?.trim(); - let preset = super::brain::agent_cli::preset(first)?; - if command.len() == 1 || command == preset.argv(None).as_slice() { - Some(preset) - } else { - None - } -} - -/// Does spotatui own this install's `dj_agent_command`? -/// -/// The single answer to "is this argv ours to rewrite", shared by the three places -/// that would otherwise each invent their own and disagree: -/// -/// - [`resolve_agent_command`] threads `dj_agent_model` in as a model flag only -/// when this is `true`; -/// - `setup::active_label` names that model in the DJ title only when this is -/// `true`, so the title cannot advertise a model the CLI never receives; -/// - `setup::detect_backends` gives a `false` command a picker row of its own, -/// because a hand-written argv has no preset row and Enter would otherwise -/// replace it with `["claude"]`. -/// -/// A `false` here means the argv is the user's: it is passed through verbatim, -/// including whatever model flag they put in it themselves. -pub(crate) fn owns_agent_command(behavior: &BehaviorConfig) -> bool { - expandable_preset(&behavior.dj_agent_command).is_some() -} - -/// Expand a bare agent name to its known argv, so `dj_agent_command: ["codex"]` -/// just works instead of silently running `codex` with no subcommand, and thread -/// `dj_agent_model` through as that CLI's own model flag. -/// -/// An explicit multi-part command is always honoured as written — the presets are -/// a convenience, never an override, and a hand-written argv keeps ownership of -/// its own flags including the model one. -fn resolve_agent_command(behavior: &BehaviorConfig) -> (Vec<String>, PromptDelivery) { - let configured_delivery = behavior - .dj_agent_prompt_via - .as_deref() - .and_then(PromptDelivery::from_config_str); - if let Some(preset) = expandable_preset(&behavior.dj_agent_command) { - let model = behavior - .dj_agent_model - .as_deref() - .map(str::trim) - .filter(|model| !model.is_empty()); - // The preset's delivery mode wins unless the user set one explicitly, since - // the two have to agree for the CLI to receive the prompt at all. - return ( - preset.argv(model), - configured_delivery.unwrap_or(preset.delivery), - ); - } - ( - behavior.dj_agent_command.clone(), - configured_delivery.unwrap_or_default(), - ) -} - -/// Build the taste brief, off the async runtime and off the `App` lock. -pub async fn build_taste_brief(app: &Arc<Mutex<App>>, period: RecapPeriod) -> Result<TasteBrief> { - // `load_listens` is blocking and reads the whole (unbounded, append-only) file. - let listens = tokio::task::spawn_blocking(crate::infra::history::load_listens) - .await - .map_err(|e| anyhow!("listening-history task failed: {e}"))? - .map_err(|e| anyhow!("could not read listening history: {e}"))?; - - let mut summary = brief::build_brief(&listens, period); - { - let app = app.lock().await; - summary.now_playing = super::current_track_label(&app); - summary.vibe = app.dj.vibe.clone(); - } - Ok(summary) -} - -/// Everything a brain call needs, snapshotted off the `App` lock. -pub struct TurnContext { - pub brief: TasteBrief, - pub history: Vec<(String, String)>, - pub want: usize, - /// Whether the listener has the avoid-library filter on. Reaches the prompt so - /// the model aims away from their favourites, and reaches the resolve step so it - /// enforces what the prompt asked for. - pub avoid_library: bool, -} - -impl TurnContext { - /// Dedupe keys for the recently-played window, for the loop's queue policy. - pub fn recent_keys(&self) -> Vec<String> { - self.brief.recent_keys.clone() - } -} - -/// How many transcript lines the brain gets. -/// -/// Sized for a back-and-forth, not a single request and answer: now that the DJ -/// can ask a question, the reply to it is worthless without the several turns of -/// preference-narrowing that led there. -const HISTORY_LINES: usize = 24; - -/// Turn the transcript into `(speaker, text)` pairs for the brain, oldest last. -/// -/// Pure and separate from [`turn_context`] so the mapping is unit-testable: the -/// async path pulls in the taste brief, which reads the user's real listening -/// history file. -/// -/// `extra` is appended as a final `Listener` line. It is for instructions that are -/// deliberately *absent* from the transcript (the vibe shift); anything the -/// listener actually typed is already in `transcript` and must not be passed here, -/// or the brain sees it twice. -fn history_from_transcript(transcript: &[DjLine], extra: Option<&str>) -> Vec<(String, String)> { - let mut history = transcript - .iter() - // Machine notes ("queued 5 tracks") are for the user, not the model. - .filter(|line| line.speaker != DjSpeaker::System) - .map(|line| { - let speaker = match line.speaker { - DjSpeaker::User => "Listener", - _ => "DJ", - }; - (speaker.to_string(), line.text.clone()) - }) - .rev() - .take(HISTORY_LINES) - .collect::<Vec<_>>(); - history.reverse(); - if let Some(line) = extra { - history.push(("Listener".to_string(), line.to_string())); - } - history -} - -/// Snapshot the conversation and settings for one turn. -pub async fn turn_context( - app: &Arc<Mutex<App>>, - extra_instruction: Option<&str>, -) -> Result<TurnContext> { - let (period, want, history, avoid_library) = { - let app = app.lock().await; - let behavior = &app.user_config.behavior; - ( - period_from_config(&behavior.dj_history_period), - behavior.dj_batch_size, - history_from_transcript(&app.dj.transcript, extra_instruction), - // The live toggle, not the config default: the config only seeds it. - app.dj.avoid_library, - ) - }; - - let brief = build_taste_brief(app, period).await?; - Ok(TurnContext { - brief, - history, - want, - avoid_library, - }) -} - -impl TurnContext { - /// One step's request: the standing context, plus whatever the loop has run so - /// far this turn. - pub fn to_request(&self, scratch: Vec<ToolExchange>, must_act: bool) -> DjRequest { - DjRequest { - brief: self.brief.clone(), - history: self.history.clone(), - scratch, - want: self.want, - avoid_library: self.avoid_library, - must_act, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::user_config::UserConfig; - - fn behavior(backend: &str) -> BehaviorConfig { - let mut config = UserConfig::new(); - config.behavior.dj_backend = backend.to_string(); - config.behavior - } - - #[test] - fn builds_each_configured_backend() { - assert!(matches!( - build_brain(&behavior("agent_cli")).unwrap(), - DjBrain::AgentCli(_) - )); - assert!(matches!( - build_brain(&behavior("openai_compat")).unwrap(), - DjBrain::OpenAiCompat(_) - )); - } - - #[test] - fn anthropic_without_a_key_fails_with_the_env_var_named() { - // `None` for the environment rather than `remove_var`: a machine that exports - // the key would otherwise fail this for the wrong reason, and unsetting it for a - // moment would race every other test thread reading the same variable. - let err = build_brain_with(None, &behavior("anthropic")) - .unwrap_err() - .to_string(); - assert!(err.contains(API_KEY_ENV), "{err}"); - } - - #[test] - fn an_unknown_backend_is_reported_with_the_valid_options() { - let err = build_brain(&behavior("telepathy")).unwrap_err().to_string(); - assert!(err.contains("agent_cli")); - assert!(err.contains("openai_compat")); - } - - #[test] - fn the_env_var_wins_over_the_config_field() { - // The environment is a parameter here, not a global. `set_var` around a live - // test binary is a data race against every other thread that reads it, and the - // picker's own detection reads exactly this variable. - let mut behavior = behavior("openai_compat"); - behavior.dj_api_key = Some("from-config".to_string()); - assert_eq!( - resolve_api_key_with(Some("from-env"), &behavior).as_deref(), - Some("from-env") - ); - } - - #[test] - fn a_blank_env_var_falls_back_to_the_config_field() { - let mut behavior = behavior("openai_compat"); - behavior.dj_api_key = Some("from-config".to_string()); - assert_eq!( - resolve_api_key_with(Some(" "), &behavior).as_deref(), - Some("from-config"), - "an exported-but-empty variable is not a key" - ); - assert_eq!( - resolve_api_key_with(None, &behavior).as_deref(), - Some("from-config"), - "and neither is an unset one" - ); - } - - #[test] - fn a_blank_config_key_is_no_key_at_all() { - // The picker reads it that way, so this has to agree: a config key of spaces - // showing as "not ready" while the session sent it as a bearer token is a - // failure with no visible cause. - let mut behavior = behavior("openai_compat"); - behavior.dj_api_key = Some(" ".to_string()); - assert_eq!(resolve_api_key_with(None, &behavior), None); - assert_eq!( - resolve_api_key_with(Some("from-env"), &behavior).as_deref(), - Some("from-env") - ); - } - - #[test] - fn every_configured_period_string_maps_to_a_recap_period() { - for (value, expected) in [ - ("7d", RecapPeriod::SevenDays), - ("30d", RecapPeriod::ThirtyDays), - ("month", RecapPeriod::Month), - ("year", RecapPeriod::Year), - ("all", RecapPeriod::All), - ] { - assert_eq!(period_from_config(value), expected); - } - } - - #[test] - fn a_bare_agent_name_expands_to_its_preset_argv() { - let mut behavior = behavior("agent_cli"); - behavior.dj_agent_command = vec!["codex".to_string()]; - let (argv, _) = resolve_agent_command(&behavior); - assert_eq!(argv, vec!["codex", "exec", "-"]); - } - - #[test] - fn a_bare_agy_name_also_adopts_its_arg_delivery() { - let mut behavior = behavior("agent_cli"); - behavior.dj_agent_command = vec!["agy".to_string()]; - // Unset, which is a state the real load path can now produce, so the preset's - // own mode is adopted; `agy` reads the prompt from argv and ignores stdin, and - // getting this wrong means it receives nothing at all. - behavior.dj_agent_prompt_via = None; - let (argv, delivery) = resolve_agent_command(&behavior); - assert_eq!(argv, vec!["agy", "-p"]); - assert_eq!(delivery, PromptDelivery::Arg); - } - - #[test] - fn an_explicit_prompt_delivery_still_overrides_the_preset() { - let mut behavior = behavior("agent_cli"); - behavior.dj_agent_command = vec!["agy".to_string()]; - behavior.dj_agent_prompt_via = Some("stdin".to_string()); - let (_, delivery) = resolve_agent_command(&behavior); - assert_eq!(delivery, PromptDelivery::Stdin); - } - - #[test] - fn an_explicit_command_is_never_rewritten_by_a_preset() { - let mut behavior = behavior("agent_cli"); - behavior.dj_agent_command = vec!["claude".to_string(), "--custom".to_string()]; - let (argv, _) = resolve_agent_command(&behavior); - assert_eq!(argv, vec!["claude", "--custom"]); - } - - #[test] - fn an_unknown_bare_name_is_left_alone() { - let mut behavior = behavior("agent_cli"); - behavior.dj_agent_command = vec!["my-own-agent".to_string()]; - let (argv, _) = resolve_agent_command(&behavior); - assert_eq!(argv, vec!["my-own-agent"]); - } - - #[test] - fn an_install_with_no_model_configured_resolves_exactly_the_argv_it_did_before() { - // The guarantee: adding the model flag must change nothing for anyone who - // never picked a model. - let behavior = behavior("agent_cli"); - assert_eq!(behavior.dj_agent_model, None, "the shipped default"); - let (argv, delivery) = resolve_agent_command(&behavior); - assert_eq!(argv, vec!["claude", "-p"]); - assert_eq!(delivery, PromptDelivery::Stdin); - } - - #[test] - fn the_shipped_default_command_still_accepts_a_model_because_it_is_the_preset_shape() { - // `["claude", "-p"]` is on disk in every existing install, written there by an - // automatic `save_config` rather than typed, so it has to stay expandable. - let mut behavior = behavior("agent_cli"); - behavior.dj_agent_model = Some("haiku".to_string()); - let (argv, _) = resolve_agent_command(&behavior); - assert_eq!(argv, vec!["claude", "--model", "haiku", "-p"]); - } - - #[test] - fn ownership_is_exactly_which_commands_receive_a_model_flag() { - // The picker and the DJ title both branch on `owns_agent_command`, so it has to - // BE the rule `resolve_agent_command` follows rather than merely resemble it. - // Both halves are asserted here for that reason: the helper's answer, and the - // argv that answer is supposed to predict. - for (command, owned) in [ - (vec!["claude"], true), - (vec!["claude", "-p"], true), - (vec!["claude", "--verbose", "-p"], false), - (vec!["my-own-agent"], false), - ] { - let mut behavior = behavior("agent_cli"); - behavior.dj_agent_command = command.iter().map(|part| part.to_string()).collect(); - assert_eq!(owns_agent_command(&behavior), owned, "{command:?}"); - - behavior.dj_agent_model = Some("haiku".to_string()); - let (argv, _) = resolve_agent_command(&behavior); - assert_eq!( - argv.contains(&"haiku".to_string()), - owned, - "{command:?} disagrees with the argv it resolves to" - ); - } - } - - #[test] - fn a_hand_written_command_never_gains_a_model_flag() { - let mut behavior = behavior("agent_cli"); - behavior.dj_agent_command = vec![ - "claude".to_string(), - "--verbose".to_string(), - "-p".to_string(), - ]; - behavior.dj_agent_model = Some("haiku".to_string()); - let (argv, _) = resolve_agent_command(&behavior); - assert_eq!(argv, vec!["claude", "--verbose", "-p"]); - } - - #[test] - fn a_typed_prompt_reaches_the_brain_exactly_once() { - // Regression guard: the handler pushes the listener's line and the brain reads - // it from the transcript. When `ask_dj` also pushed it and it was *also* passed - // as an extra line, one request arrived three times over. - let transcript = vec![DjLine::user("play something like Remind Me to Forget")]; - let history = history_from_transcript(&transcript, None); - assert_eq!( - history, - vec![( - "Listener".to_string(), - "play something like Remind Me to Forget".to_string() - )] - ); - } - - #[test] - fn machine_notes_are_kept_out_of_the_history() { - let transcript = vec![ - DjLine::user("something chill"), - DjLine::dj("Six downtempo cuts."), - DjLine::system("queued 6"), - ]; - let history = history_from_transcript(&transcript, None); - assert_eq!(history.len(), 2); - assert_eq!(history[0].0, "Listener"); - assert_eq!(history[1].0, "DJ"); - } - - #[test] - fn an_extra_instruction_lands_last_and_is_attributed_to_the_listener() { - // The vibe shift's steer: never in the transcript, so it has to be appended. - let transcript = vec![DjLine::user("something chill")]; - let history = history_from_transcript(&transcript, Some("Change direction.")); - assert_eq!(history.len(), 2); - assert_eq!( - history.last().unwrap(), - &("Listener".to_string(), "Change direction.".to_string()) - ); - } - - #[test] - fn the_history_window_keeps_the_newest_lines_oldest_first() { - // Built off the constant, so raising the window does not silently stop this - // testing a window at all. - let total = HISTORY_LINES + 5; - let transcript = (0..total) - .map(|i| DjLine::user(format!("line {i}"))) - .collect::<Vec<_>>(); - let history = history_from_transcript(&transcript, None); - assert_eq!(history.len(), HISTORY_LINES); - assert_eq!(history.first().unwrap().1, "line 5", "oldest kept, first"); - assert_eq!( - history.last().unwrap().1, - format!("line {}", total - 1), - "newest kept, last" - ); - } - - #[test] - fn each_step_carries_the_filter_flag_and_the_results_so_far() { - let context = context_with_filter(); - let first = context.to_request(vec![], false); - assert!(first.avoid_library, "the prompt has to know"); - assert!(first.scratch.is_empty(), "nothing has run yet"); - assert!(!first.must_act); - - // The context is reusable across steps — the loop calls it once per step, and - // only the scratch grows. - let second = context.to_request( - vec![ToolExchange { - name: "get_queue".into(), - arguments: serde_json::json!({}), - result: "empty".into(), - }], - true, - ); - assert_eq!(second.scratch.len(), 1); - assert!(second.must_act); - } - - fn context_with_filter() -> TurnContext { - TurnContext { - brief: TasteBrief::default(), - history: vec![], - want: 6, - avoid_library: true, - } - } - - #[test] - fn the_agent_scratch_dir_is_not_the_current_directory() { - // Regression guard for the polluted-context trap: an agent run in a repo - // reads its CLAUDE.md and answers about code instead of music. - let scratch = agent_scratch_dir(); - let cwd = std::env::current_dir().unwrap(); - assert_ne!(scratch, cwd); - assert!(scratch.ends_with("dj-scratch")); - } - - #[test] - fn the_config_scratch_dir_is_used_when_it_can_be_created() { - let temp = tempfile::tempdir().unwrap(); - let preferred = temp.path().join("config").join("dj-scratch"); - let chosen = scratch_dir_from(Some(preferred.clone()), temp.path()); - assert_eq!(chosen, preferred); - assert!(chosen.is_dir(), "it is created, not only named"); - } - - #[test] - fn an_uncreatable_config_dir_falls_back_to_a_fresh_private_temp_dir() { - // #478: a build sandbox sets HOME to a path that cannot be created. The - // agent must still get a directory of its own, never the shared temp root. - let temp = tempfile::tempdir().unwrap(); - let blocker = temp.path().join("not-a-dir"); - std::fs::write(&blocker, b"").unwrap(); - let chosen = scratch_dir_from(Some(blocker.join("dj-scratch")), temp.path()); - assert!(chosen.is_dir(), "{}", chosen.display()); - assert_eq!(chosen.parent(), Some(temp.path()), "{}", chosen.display()); - // Fresh every time: never a fixed name another local user can plant. - let again = scratch_dir_from(Some(blocker.join("dj-scratch")), temp.path()); - assert_ne!(again, chosen); - // And private: group and others get nothing, whatever the umask. - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = std::fs::metadata(&chosen).unwrap().permissions().mode(); - assert_eq!(mode & 0o077, 0, "mode {mode:o}"); - } - } - - #[cfg(unix)] - #[test] - fn a_config_dir_without_search_permission_is_not_used() { - use std::os::unix::fs::PermissionsExt; - let temp = tempfile::tempdir().unwrap(); - let preferred = temp.path().join("dj-scratch"); - std::fs::create_dir(&preferred).unwrap(); - // Read and write, no search: `create_dir_all` is content, `chdir` is not. - std::fs::set_permissions(&preferred, std::fs::Permissions::from_mode(0o600)).unwrap(); - if std::fs::write(preferred.join("probe"), b"").is_ok() { - // A privileged user ignores mode bits; there is nothing to test here. - return; - } - let chosen = scratch_dir_from(Some(preferred.clone()), temp.path()); - std::fs::set_permissions(&preferred, std::fs::Permissions::from_mode(0o700)).unwrap(); - assert_ne!(chosen, preferred); - assert!(chosen.is_dir(), "{}", chosen.display()); - } -} diff --git a/src/infra/dj/setup.rs b/src/infra/dj/setup.rs deleted file mode 100644 index 86157eee..00000000 --- a/src/infra/dj/setup.rs +++ /dev/null @@ -1,1524 +0,0 @@ -//! "Which AI do you have, and which of its models?" — the data behind the DJ's -//! setup picker. -//! -//! Two cost problems hide behind the same words, and this module exists because -//! the fix is different for each. The `anthropic` backend bills an API key per -//! token, so the answer there is a cheaper model id. The default `agent_cli` -//! backend spends the user's Claude Pro/Max (or ChatGPT) subscription, where a -//! continuous auto-queue is a fresh prompt every few tracks and the heaviest model -//! exhausts a Pro plan in a handful of turns. Which backend is in use therefore -//! changes what "cheaper" even means, and the picker has to say so per row. -//! -//! **Nothing here spawns a process or makes a request.** Detection stats `PATH`; -//! see [`on_path`]. - -use crate::core::user_config::BehaviorConfig; -use std::path::{Path, PathBuf}; - -use super::brain::agent_cli; - -/// Is `name` an executable file on any `PATH` entry? -/// -/// Stat only, never a spawn. This runs on the render thread when the picker opens, -/// and asking a coding agent CLI to print its version costs hundreds of -/// milliseconds per binary; four of those would be a visible stall. -pub fn on_path(name: &str) -> bool { - let Some(path) = std::env::var_os("PATH") else { - return false; - }; - binary_in_dirs(name, &std::env::split_paths(&path).collect::<Vec<_>>()) -} - -/// Split out from [`on_path`] so the tests can point at a directory they built, -/// rather than mutating the process environment (which races other tests). -pub(crate) fn binary_in_dirs(name: &str, dirs: &[PathBuf]) -> bool { - dirs.iter().any(|dir| is_executable(&dir.join(name))) -} - -#[cfg(unix)] -fn is_executable(path: &Path) -> bool { - use std::os::unix::fs::PermissionsExt; - std::fs::metadata(path) - .map(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0) - .unwrap_or(false) -} - -/// On Windows the extension is what makes a file runnable, and `PATHEXT` is the -/// list. `is_file()` on the bare name would miss `claude.cmd`, which is how npm -/// shims land. -#[cfg(not(unix))] -fn is_executable(path: &Path) -> bool { - if path.is_file() { - return true; - } - let exts = std::env::var("PATHEXT").unwrap_or_else(|_| ".EXE;.CMD;.BAT;.COM".to_string()); - exts.split(';').any(|ext| { - let mut candidate = path.as_os_str().to_owned(); - candidate.push(ext); - Path::new(&candidate).is_file() - }) -} - -/// One row of the picker's first step. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct BackendOption { - /// The `behavior.dj_backend` value this row selects. - pub backend: &'static str, - /// For `agent_cli` rows, the preset key, which is also the binary name. `None` - /// for the HTTP backends **and** for the one row below that carries the user's - /// own command; [`BackendOption::command`] is what tells those two apart. - pub agent: Option<&'static str>, - /// The user's own `dj_agent_command`, on the single row that shows it back to - /// them, and `None` on every row spotatui owns. - /// - /// This is the explicit discriminator rather than a third meaning smuggled into - /// `agent`: `agent: None` has always meant "an HTTP backend" and both - /// [`models_for`] and [`apply_choice`] branch on it, so an unhandled third state - /// there would write `dj_model` for a CLI. `Some` means "an agent CLI whose argv - /// is the user's, and which this picker must not rewrite". - pub command: Option<Vec<String>>, - /// What the row is called. - pub label: String, - /// What it bills against, or what is still missing. The whole reason the picker - /// exists is that "rides your Pro plan" and "$5 per MTok" are not - /// interchangeable, so this is never empty. - pub note: String, - /// Usable right now with no further setup: the binary is on `PATH`, or the key is - /// present. A `false` row is still selectable, because "I am about to install it" - /// is a real answer. - pub ready: bool, -} - -impl BackendOption { - /// Does this row run an agent CLI, whether one of spotatui's presets or the - /// user's own command? - /// - /// The question every "which config field does this row write" decision actually - /// asks. `agent.is_some()` alone answers it wrongly for the user's own command, - /// which would send it down the HTTP branch and write `dj_model`. - fn is_agent(&self) -> bool { - self.agent.is_some() || self.command.is_some() - } -} - -/// Agent CLIs the picker offers even when they are not installed, so a user can -/// see what spotatui supports. -/// -/// `gemini` is deliberately absent: Google superseded the Gemini CLI with -/// Antigravity's `agy`. It stays in [`agent_cli::PRESETS`] so an existing config -/// keeps working, and detection still offers it when the binary is actually -/// installed, but spotatui does not advertise it any more. -const RECOMMENDED_AGENTS: &[&str] = &["claude", "codex", "agy", "copilot", "opencode"]; - -/// What each known agent bills against. Paired with detection rather than baked -/// into the label, because "not on PATH" is appended to it. -fn agent_note(key: &str) -> &'static str { - match key { - "claude" => "uses your Claude Pro/Max plan", - "codex" => "uses your ChatGPT plan", - "agy" => "uses your Antigravity plan", - "copilot" => "uses your GitHub Copilot plan", - "opencode" => "uses whatever provider opencode is logged into", - "gemini" => "legacy Gemini CLI, superseded by agy", - // An unadvertised preset someone added: say the one thing that is true of - // every agent CLI rather than inventing a plan name. - _ => "uses that CLI's own subscription", - } -} - -/// Mirrors `session::resolve_api_key`'s precedence without pulling the session -/// module into the picker: the env var wins, the config field is the fallback. -/// -/// The environment is a parameter rather than a read, for the same reason -/// `session::resolve_api_key_with` takes one: the test that needs "no key anywhere" -/// used to produce it with `remove_var`, which is a data race against every other -/// thread in the test binary. -fn anthropic_key_present(env_key: Option<&str>, behavior: &BehaviorConfig) -> bool { - env_key.is_some_and(|key| !key.trim().is_empty()) - || behavior - .dj_api_key - .as_ref() - .is_some_and(|key| !key.trim().is_empty()) -} - -/// The note on the row that carries the user's own command. -const OWN_COMMAND_NOTE: &str = "your own command, run exactly as written"; - -/// The suffix appended to an agent row whose binary is missing. A const because -/// the tests assert on it, and a note that silently stopped saying this would -/// otherwise still look right. -const NOT_INSTALLED: &str = " · not on PATH"; - -/// Which backends this machine can actually run, in the order the picker shows -/// them. -/// -/// Driven off [`agent_cli::PRESETS`] rather than a second list of names, so -/// detection cannot drift from what `session::resolve_agent_command` supports. -pub fn detect_backends(behavior: &BehaviorConfig) -> Vec<BackendOption> { - let env_key = std::env::var(super::session::API_KEY_ENV).ok(); - detect_backends_with(behavior, env_key.as_deref(), on_path) -} - -/// [`detect_backends`] with both pieces of ambient state passed in. -/// -/// Same seam as [`binary_in_dirs`], one level up: `ready` and the -/// [`NOT_INSTALLED`] suffix are the whole point of detection, and asserting on them -/// through the real `PATH` would make the test say something different on every -/// machine (green here, green on a CI runner with none of these CLIs installed, -/// green with the readiness check deleted). -pub(crate) fn detect_backends_with( - behavior: &BehaviorConfig, - env_key: Option<&str>, - probe: impl Fn(&str) -> bool, -) -> Vec<BackendOption> { - let mut rows: Vec<BackendOption> = own_command_row(behavior, &probe).into_iter().collect(); - - // One probe per preset, not two: `on_path` re-reads `PATH` and stats every - // directory in it, and the picker opens on a keypress. - rows.extend(agent_cli::PRESETS.iter().filter_map(|preset| { - let ready = probe(preset.key); - if !ready && !RECOMMENDED_AGENTS.contains(&preset.key) { - return None; - } - Some(BackendOption { - backend: "agent_cli", - agent: Some(preset.key), - command: None, - label: format!("{} (no API key)", preset.key), - note: if ready { - agent_note(preset.key).to_string() - } else { - format!("{}{NOT_INSTALLED}", agent_note(preset.key)) - }, - ready, - }) - })); - - let has_key = anthropic_key_present(env_key, behavior); - rows.push(BackendOption { - backend: "anthropic", - agent: None, - command: None, - label: "anthropic (API key)".to_string(), - note: if has_key { - "Anthropic API, pay per token".to_string() - } else { - format!( - "needs {} (or behavior.dj_api_key)", - super::session::API_KEY_ENV - ) - }, - ready: has_key, - }); - - // Always offered and always ready: "is Ollama running" is a question only a - // request can answer, and this module makes no requests. - rows.push(BackendOption { - backend: "openai_compat", - agent: None, - command: None, - label: "openai_compat (local or self-hosted)".to_string(), - note: "any OpenAI-compatible endpoint, e.g. Ollama on localhost".to_string(), - ready: true, - }); - - rows -} - -/// The row for a `dj_agent_command` spotatui does not own, or `None` when it does. -/// -/// Without it such a command has no row at all, so the picker opens on row 0 -/// (`claude`) and two Enters replace the whole command with `["claude"]` — a config -/// the docs explicitly invite ("Any other headless command works too"), silently -/// overwritten by a modal that never showed it. It goes **first** so that is the -/// row the cursor opens on. -fn own_command_row( - behavior: &BehaviorConfig, - probe: impl Fn(&str) -> bool, -) -> Option<BackendOption> { - if behavior.dj_backend != "agent_cli" || super::session::owns_agent_command(behavior) { - return None; - } - let program = behavior.dj_agent_command.first()?.trim(); - if program.is_empty() { - return None; - } - // Only a bare name is a `PATH` question. `/opt/bin/my-agent` or `npx` wrappers - // are already answered, and dimming them with "not on PATH" would be a lie. - let ready = if program.contains(std::path::is_separator) { - true - } else { - probe(program) - }; - Some(BackendOption { - backend: "agent_cli", - agent: None, - command: Some(behavior.dj_agent_command.clone()), - label: format!("{} (keep)", behavior.dj_agent_command.join(" ")), - note: if ready { - OWN_COMMAND_NOTE.to_string() - } else { - format!("{OWN_COMMAND_NOTE}{NOT_INSTALLED}") - }, - ready, - }) -} - -/// One row of the picker's second step. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ModelOption { - /// What gets written. `None` means "write nothing": no model flag for an agent - /// CLI, `dj_model: null` for an HTTP backend. This row has to exist so a user can - /// get back to the shipped behaviour after picking once. - pub value: Option<String>, - pub label: String, - /// Price or quota note, which is the entire point of showing a list rather than - /// asking for a string. - pub note: String, - /// Opens the free-text step instead of committing. - pub custom: bool, -} - -/// Public aliases the `claude` CLI accepts, cheapest first. -/// -/// Cheapest FIRST, and the cursor starts on row 0 on purpose. This is the whole -/// reason the picker exists: `claude -p` spends the user's Claude Pro/Max quota, -/// and a continuous auto-queue is a fresh prompt every few tracks. `haiku` is the -/// alias that survives that on a Pro plan; anyone who wants `opus` is one row down. -const CLAUDE_CLI_MODELS: &[(&str, &str)] = &[ - ("haiku", "cheapest, easiest on a Pro plan"), - ("sonnet", "balanced"), - ("opus", "heavy, a Pro plan hits its limit fast"), - ("fable", "heaviest, hits it faster still"), -]; - -/// Anthropic API model ids with list price per million tokens (input/output). -/// -/// Row 0 is also [`crate::infra::dj::brain::anthropic::DEFAULT_MODEL`], so the -/// picker's starting cursor and the code's own fallback cannot disagree. -const ANTHROPIC_MODELS: &[(&str, &str)] = &[ - ("claude-haiku-4-5", "$1/$5 per MTok, 200K ctx"), - ("claude-sonnet-5", "$3/$15 per MTok, 1M ctx"), - ("claude-opus-5", "$5/$25 per MTok, 1M ctx"), - ("claude-opus-4-8", "$5/$25 per MTok, 1M ctx"), - ("claude-fable-5", "$10/$50 per MTok, 1M ctx"), -]; - -/// Antigravity's models as `agy models` printed them when this was written. -/// -/// A snapshot, not a contract: Google changes this list server-side, which is why -/// the "Custom…" row is always there. Spotatui does not run `agy models` itself, -/// because a subprocess on the render path is exactly what the DJ's two-lane rule -/// exists to prevent. -const AGY_MODELS: &[(&str, &str)] = &[ - ("gemini-3.6-flash-low", "cheapest"), - ("gemini-3.6-flash-medium", ""), - ("gemini-3.6-flash-high", ""), - ("gemini-3.5-flash-low", ""), - ("gemini-3.1-pro-low", ""), - ("gemini-3.1-pro-high", "heaviest Gemini"), - ("claude-sonnet-4-6", ""), - ("claude-opus-4-6-thinking", "heaviest of all"), - ("gpt-oss-120b-medium", ""), -]; - -/// Which config field carries this row's model, trimmed to a real value. -/// -/// The two fields are different namespaces and never merge: `dj_agent_model` is a -/// CLI alias spent against a subscription, `dj_model` is an API model id billed per -/// token. -fn current_model(row: &BackendOption, behavior: &BehaviorConfig) -> Option<String> { - let field = if row.is_agent() { - &behavior.dj_agent_model - } else { - &behavior.dj_model - }; - field - .as_deref() - .map(str::trim) - .filter(|model| !model.is_empty()) - .map(str::to_string) -} - -/// The model an HTTP backend falls back on with `dj_model` unset. -fn builtin_default(backend: &str) -> &'static str { - if backend == "anthropic" { - super::brain::anthropic::DEFAULT_MODEL - } else { - super::brain::openai_compat::DEFAULT_MODEL - } -} - -/// What the free-text step starts with, so the common edit is a one-character fix -/// rather than a retype. -fn custom_prefill(row: &BackendOption, behavior: &BehaviorConfig) -> String { - match current_model(row, behavior) { - Some(model) => model, - // "No model flag at all" is a real state for an agent CLI, so there is nothing - // to prefill; the HTTP backends always have a model id in play, and starting - // from it makes a near-miss a one-word edit. - None if row.is_agent() => String::new(), - None => builtin_default(row.backend).to_string(), - } -} - -/// The second step's rows for a chosen backend, plus which row the cursor starts -/// on. -/// -/// Every backend spotatui can *configure* ends with a "use the backend's own -/// default" row and a free-text row, so no such backend is a dead end and every one -/// of them can be returned to the shipped behaviour. The single exception is the -/// row standing for a command spotatui does not own, which gets one row and no -/// model choice at all: there is nothing to escape to, because a model written -/// there would never reach the CLI. See the early return below. -/// -/// There is deliberately no probe behind this: `agy models` and -/// `GET /v1/models` would each be a new flag/shape for spotatui to track, and a -/// stale suggestion costs one fast local error message ("There's an issue with the -/// selected model") while the free-text row is always right there. -pub fn models_for(row: &BackendOption, behavior: &BehaviorConfig) -> (Vec<ModelOption>, usize) { - // The user's own command: one row, and it changes nothing. There is deliberately - // no suggestion list and no free-text row here, because - // `session::resolve_agent_command` provably drops the model flag for an argv - // spotatui does not own — offering a model would write a `dj_agent_model` that - // never reaches the CLI, which is exactly the lie `active_label` was fixed to stop - // telling. Whatever model this command uses is in the command. - if row.command.is_some() { - return ( - vec![ModelOption { - value: None, - label: "Keep this command as it is".to_string(), - note: "spotatui adds no model flag to a command it does not own".to_string(), - custom: false, - }], - 0, - ); - } - - let suggestions: &[(&str, &str)] = match row.agent { - Some("claude") => CLAUDE_CLI_MODELS, - Some("agy") => AGY_MODELS, - // `codex` and the legacy `gemini`: which ids their subscription accepts is not - // knowable from here, so free text is the honest answer. - Some(_) => &[], - None if row.backend == "anthropic" => ANTHROPIC_MODELS, - // `openai_compat`: a local model list is only knowable from the endpoint, and - // this module makes no requests. - None => &[], - }; - - let mut rows: Vec<ModelOption> = suggestions - .iter() - .map(|(value, note)| ModelOption { - value: Some(value.to_string()), - label: value.to_string(), - note: note.to_string(), - custom: false, - }) - .collect(); - - // Computed before the escape hatches are appended, so `unwrap_or(0)` lands on the - // cheapest suggestion where there is one and on "use the default" where there is - // not. That fallback is only correct when nothing is configured, hence the row - // added just below. - let current = current_model(row, behavior); - let mut cursor = rows - .iter() - .position(|option| option.value == current) - .unwrap_or(0); - - // A model the user typed at the "Custom…" step (`claude-sonnet-4-5-20250929`) - // matches no curated suggestion, and only the suggestions carry a value. Without - // a row of its own the cursor would fall back to row 0 — the cheapest suggestion — - // so reopening the picker to *check* what is set and pressing Enter would replace - // it with something else, silently. Inserted before the escape hatches so "use the - // default" is still second-to-last and "Custom…" still last. - if let Some(model) = current.filter(|model| { - !rows - .iter() - .any(|option| option.value.as_deref() == Some(model.as_str())) - }) { - rows.push(ModelOption { - value: Some(model.clone()), - label: model, - note: "currently configured".to_string(), - custom: false, - }); - cursor = rows.len() - 1; - } - - if row.is_agent() { - rows.push(ModelOption { - value: None, - label: "Use the CLI's default".to_string(), - note: "no model flag".to_string(), - custom: false, - }); - } else { - rows.push(ModelOption { - value: None, - label: format!( - "Use the built-in default ({})", - builtin_default(row.backend) - ), - note: String::new(), - custom: false, - }); - } - rows.push(ModelOption { - value: None, - label: "Custom…".to_string(), - // `opencode` rejects a bare model name, so the one CLI whose format is not - // guessable says so here rather than failing at the first turn. - note: match row.agent { - Some("opencode") => "type a model name, as provider/model".to_string(), - _ => "type a model name".to_string(), - }, - custom: true, - }); - - (rows, cursor) -} - -/// Which picker row the current config already names, so reopening reads as "here -/// is what you have" rather than "start again". -fn is_active_row(row: &BackendOption, behavior: &BehaviorConfig) -> bool { - if row.backend != behavior.dj_backend { - return false; - } - match (&row.command, row.agent) { - // The user's own command matches the config it was built from, and nothing else. - (Some(command), _) => command == &behavior.dj_agent_command, - // `owns_agent_command` as well as the name: `["claude", "--verbose", "-p"]` - // starts with `claude` but is not the `claude` preset row, and claiming it here - // is what would let Enter rewrite it. - (None, Some(agent)) => { - super::session::owns_agent_command(behavior) - && behavior - .dj_agent_command - .first() - .is_some_and(|first| first.trim() == agent) - } - (None, None) => true, - } -} - -/// `claude/haiku`, `anthropic/claude-haiku-4-5`, or `None` when the backend is not -/// one spotatui knows. -/// -/// Reports what config names, which is also exactly what the picker writes. Shared -/// by `ui::ai_dj::title` and the picker's initial cursor, so the screen and the -/// picker can never disagree about what is active. -pub fn active_label(behavior: &BehaviorConfig) -> Option<String> { - let (name, model) = match behavior.dj_backend.as_str() { - "agent_cli" => { - let agent = behavior.dj_agent_command.first()?.trim(); - if agent.is_empty() { - return None; - } - // Only an argv spotatui owns actually receives `--model`: - // `session::resolve_agent_command` drops the flag for a hand-written command, - // so naming `dj_agent_model` here would put a model in the title that the CLI - // is never told about — the invisible-mode bug this title exists to prevent, - // in the title itself. - let model = super::session::owns_agent_command(behavior) - .then(|| behavior.dj_agent_model.clone()) - .flatten(); - (agent.to_string(), model) - } - "anthropic" | "openai_compat" => (behavior.dj_backend.clone(), behavior.dj_model.clone()), - // Load validates `dj_backend`, so this is only reachable from a config built in - // code. Saying nothing beats inventing a name for it. - _ => return None, - }; - let model = model - .map(|model| model.trim().to_string()) - .filter(|model| !model.is_empty()); - Some(match model { - Some(model) => format!("{name}/{model}"), - None => name, - }) -} - -/// Which of the picker's three steps is on screen. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum DjSetupStep { - #[default] - Backend, - Model, - /// Free-text model entry. A typing surface, so the key routing for this step is - /// deliberately different from the other two. - Custom, -} - -/// The picker. Lives on `DjState`, so it must be `Clone + Debug`. -#[derive(Clone, Debug)] -pub struct DjSetup { - pub step: DjSetupStep, - /// Built once when the picker opens. Detection stats `PATH`, and re-statting on - /// every keypress would be syscalls for nothing. - pub backends: Vec<BackendOption>, - pub backend_index: usize, - pub models: Vec<ModelOption>, - pub model_index: usize, - /// Free-text buffer for the "Custom…" row. - pub custom: String, -} - -impl DjSetup { - /// Open on the row that is already active. - pub fn new(behavior: &BehaviorConfig) -> Self { - let backends = detect_backends(behavior); - let backend_index = backends - .iter() - .position(|row| is_active_row(row, behavior)) - .unwrap_or(0); - Self { - step: DjSetupStep::Backend, - backends, - backend_index, - models: Vec::new(), - model_index: 0, - custom: String::new(), - } - } - - /// How many rows the current step has. The free-text step has none, which is - /// what makes every navigation method a no-op there. - fn rows(&self) -> usize { - match self.step { - DjSetupStep::Backend => self.backends.len(), - DjSetupStep::Model => self.models.len(), - DjSetupStep::Custom => 0, - } - } - - pub fn move_down(&mut self) { - let last = self.rows().saturating_sub(1); - match self.step { - DjSetupStep::Backend => self.backend_index = (self.backend_index + 1).min(last), - DjSetupStep::Model => self.model_index = (self.model_index + 1).min(last), - DjSetupStep::Custom => {} - } - } - - pub fn move_up(&mut self) { - match self.step { - DjSetupStep::Backend => self.backend_index = self.backend_index.saturating_sub(1), - DjSetupStep::Model => self.model_index = self.model_index.saturating_sub(1), - DjSetupStep::Custom => {} - } - } - - /// Jump straight to row `n` (1-based), reporting whether there was such a row. - /// - /// The caller acts on `true` by advancing: the rows are numbered on screen, and a - /// digit shortcut that still needs an Enter saves nothing. - pub fn select_row(&mut self, n: usize) -> bool { - if n == 0 || n > self.rows() { - return false; - } - match self.step { - DjSetupStep::Backend => self.backend_index = n - 1, - DjSetupStep::Model => self.model_index = n - 1, - DjSetupStep::Custom => return false, - } - true - } - - /// Enter the model step for the selected backend. - pub fn enter_model_step(&mut self, behavior: &BehaviorConfig) { - let Some(row) = self.selected_backend().cloned() else { - return; - }; - let (models, index) = models_for(&row, behavior); - self.models = models; - self.model_index = index; - self.step = DjSetupStep::Model; - } - - /// Enter the free-text step, prefilled. - pub fn enter_custom_step(&mut self, behavior: &BehaviorConfig) { - self.custom = self - .selected_backend() - .map(|row| custom_prefill(row, behavior)) - .unwrap_or_default(); - self.step = DjSetupStep::Custom; - } - - pub fn selected_backend(&self) -> Option<&BackendOption> { - self.backends.get(self.backend_index) - } - - pub fn selected_model(&self) -> Option<&ModelOption> { - self.models.get(self.model_index) - } - - /// The config edit this picker would make, or `None` if it is not finished. - /// - /// Pure, so the handler tests can assert on the decision without a filesystem. - pub fn choice(&self) -> Option<DjSetupChoice> { - let backend = self.selected_backend()?; - let model = match self.step { - // A backend on its own is not an answer: which model is the half the user - // came for. - DjSetupStep::Backend => return None, - DjSetupStep::Model => { - let row = self.selected_model()?; - if row.custom { - return None; - } - row.value.clone() - } - // An empty buffer means "use the default" rather than an error, so the - // free-text step is never a dead end either. - DjSetupStep::Custom => { - let typed = self.custom.trim(); - (!typed.is_empty()).then(|| typed.to_string()) - } - }; - Some(DjSetupChoice { - backend: backend.backend.to_string(), - agent_command: match &backend.command { - // Reported so `describe` can name the command, not written: `keep_command` - // tells `apply_choice` to leave every command field exactly as it found it. - Some(command) => Some(command.clone()), - None => backend.agent.map(|agent| vec![agent.to_string()]), - }, - keep_command: backend.command.is_some(), - model, - ready: backend.ready, - }) - } -} - -/// The config edit a completed picker makes. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct DjSetupChoice { - pub backend: String, - /// A **bare** preset name for `agent_cli` (`["claude"]`), never a full argv, so - /// `session::resolve_agent_command` keeps ownership of the flags and can keep - /// inserting the model flag in the right place as the presets change. `None` for - /// the HTTP backends, which leave `dj_agent_command` alone. - pub agent_command: Option<Vec<String>>, - /// The chosen row was the user's own `dj_agent_command`, so this choice writes - /// **no** command and **no** model: it only records that the question was - /// answered. Without it, the row would fall into the `Some(command)` arm of - /// [`apply_choice`] and clear `dj_agent_prompt_via` and `dj_agent_model` behind - /// the user's back — for `agy`, whose delivery mode is `arg`, clearing the - /// delivery drops the prompt entirely. - pub keep_command: bool, - /// Goes to `dj_agent_model` for `agent_cli`, `dj_model` for the HTTP backends. - pub model: Option<String>, - /// Whether the chosen backend is usable right now, for the confirmation message. - pub ready: bool, -} - -impl DjSetupChoice { - /// `claude/haiku`, `anthropic/claude-haiku-4-5`, `codex`. - pub fn describe(&self) -> String { - let name = self - .agent_command - .as_ref() - .and_then(|command| command.first().cloned()) - .unwrap_or_else(|| self.backend.clone()); - match &self.model { - Some(model) => format!("{name}/{model}"), - None => name, - } - } -} - -/// Write a finished choice into config. Pure and unit-testable: no IO, no save. -/// -/// Clears the *other* backend's model field as well. A stale `dj_agent_model` left -/// behind by a switch to `anthropic` would silently reappear on the next switch -/// back, which is exactly the invisible-mode bug `ui::ai_dj::title` exists to -/// prevent. -pub fn apply_choice(behavior: &mut BehaviorConfig, choice: &DjSetupChoice) { - behavior.dj_backend = choice.backend.clone(); - // "Keep what I wrote" is an answer, and the only correct way to honour it is to - // write nothing at all — not even the identical command back, since the - // `Some(command)` arm below also clears the delivery mode and the model. - if choice.keep_command { - return; - } - match &choice.agent_command { - Some(command) => { - behavior.dj_agent_command = command.clone(); - // Cleared so the preset decides. This is what makes `agy` work at all: it - // ignores stdin, and every existing install has `stdin` on disk from an - // earlier automatic save, which would silently drop the prompt. - behavior.dj_agent_prompt_via = None; - behavior.dj_agent_model = choice.model.clone(); - behavior.dj_model = None; - } - None => { - behavior.dj_model = choice.model.clone(); - behavior.dj_agent_model = None; - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::user_config::UserConfig; - use crate::infra::dj::session::API_KEY_ENV; - - fn behavior() -> BehaviorConfig { - UserConfig::new().behavior - } - - /// A per-process scratch dir with one file in it, `mode` deciding whether it - /// counts as a binary. - /// - /// Per-process for the same reason `agent_cli`'s stub table is: a fixed path - /// collides with a previous `cargo test` run that is still finishing. - #[cfg(unix)] - fn dir_containing(name: &str, mode: u32) -> PathBuf { - use std::os::unix::fs::PermissionsExt; - let dir = std::env::temp_dir().join(format!( - "spotatui-dj-detect-{}-{name}-{mode:o}", - std::process::id() - )); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join(name); - std::fs::write(&path, "#!/bin/sh\ntrue\n").unwrap(); - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap(); - dir - } - - /// Detection with nothing ambient: no API key, and every agent CLI installed. - /// - /// The environment is passed, never mutated. `remove_var`/`set_var` around a live - /// test binary is a data race with the sibling tests in `session.rs` that read the - /// same variable on other threads, and `setenv`/`getenv` are not thread-safe — - /// which is why edition 2024 makes them `unsafe`. - fn detect_installed(behavior: &BehaviorConfig) -> Vec<BackendOption> { - detect_backends_with(behavior, None, |_| true) - } - - #[test] - fn detection_ignores_a_binary_that_is_not_on_path() { - assert!(!binary_in_dirs( - "spotatui-definitely-not-a-real-binary", - &[std::env::temp_dir()] - )); - } - - #[cfg(unix)] - #[test] - fn detection_finds_an_executable_on_a_path_entry() { - let dir = dir_containing("pretend-agent", 0o755); - assert!(binary_in_dirs("pretend-agent", &[dir])); - } - - #[cfg(unix)] - #[test] - fn a_non_executable_file_with_the_right_name_is_not_a_binary() { - // A README or a config named `claude` must not read as an installed CLI. - let dir = dir_containing("inert-agent", 0o644); - assert!(!binary_in_dirs("inert-agent", &[dir])); - } - - #[test] - fn every_offered_agent_row_names_a_real_preset() { - // The drift guard: a row whose agent has no preset would resolve to a bare - // binary name with no subcommand and no delivery flag. - for row in detect_backends(&behavior()) { - if let Some(agent) = row.agent { - assert!( - agent_cli::preset(agent).is_some(), - "row {agent} has no preset" - ); - assert_eq!(row.backend, "agent_cli"); - } - assert!(!row.note.is_empty(), "{} has no note", row.label); - } - } - - #[test] - fn the_deprecated_gemini_agent_is_not_advertised_but_still_resolves() { - // Asserted against the list, not against detection: `RECOMMENDED_AGENTS - // .contains(key) || probe(key)` is trivially true for anything installed, so - // comparing "offered" with "on PATH" would hold even with gemini put back on the - // advertised list — and the binary happens to be installed on the machine this - // was written on. - assert!( - !RECOMMENDED_AGENTS.contains(&"gemini"), - "the legacy CLI must not be advertised" - ); - assert!( - agent_cli::preset("gemini").is_some(), - "but an existing config that names it still has to resolve" - ); - assert!( - !detect_backends_with(&behavior(), None, |_| false) - .iter() - .any(|row| row.agent == Some("gemini")), - "so it appears only when the binary is actually there" - ); - } - - #[test] - fn an_agent_row_is_offered_but_dimmed_when_its_binary_is_missing() { - // "I am about to install it" is a real answer, so a missing CLI is still a row — - // it just has to say so. Driven off an injected probe rather than the real PATH: - // with `on_path` this assertion says something different on every machine, and - // passes on a bare CI runner even with the readiness check deleted. - let rows = detect_backends_with(&behavior(), None, |_| false); - let agents: Vec<_> = rows.iter().filter(|row| row.agent.is_some()).collect(); - assert_eq!( - agents.len(), - RECOMMENDED_AGENTS.len(), - "nothing installed still offers every recommended CLI" - ); - for row in agents { - assert!(!row.ready, "{} claims to be usable", row.label); - assert!(row.note.ends_with(NOT_INSTALLED), "{}", row.note); - } - } - - #[test] - fn an_agent_row_is_ready_and_says_only_what_it_bills_when_the_binary_is_there() { - for row in detect_installed(&behavior()) { - if row.agent.is_none() { - continue; - } - assert!( - row.ready, - "{} is installed but not offered as ready", - row.label - ); - assert!(!row.note.contains(NOT_INSTALLED), "{}", row.note); - } - } - - #[test] - fn the_anthropic_row_is_offered_without_a_key_and_says_what_is_missing() { - let mut behavior = behavior(); - behavior.dj_api_key = None; - let rows = detect_backends_with(&behavior, None, |_| true); - let row = rows - .iter() - .find(|row| row.backend == "anthropic") - .expect("the anthropic row is always offered"); - assert!(!row.ready); - assert!(row.note.contains(API_KEY_ENV), "{}", row.note); - - // And the other way round, from the same injected environment rather than from - // whatever this machine happens to export. - let rows = detect_backends_with(&behavior, Some("sk-ant-not-a-real-key"), |_| true); - let row = rows - .iter() - .find(|row| row.backend == "anthropic") - .expect("the anthropic row is always offered"); - assert!(row.ready); - assert!(!row.note.contains(API_KEY_ENV), "{}", row.note); - } - - #[test] - fn a_blank_exported_key_does_not_count_as_a_key() { - assert!(!anthropic_key_present(Some(" "), &behavior())); - let mut behavior = behavior(); - behavior.dj_api_key = Some("from-config".to_string()); - assert!(anthropic_key_present(None, &behavior)); - } - - /// One picker row, found the way the picker itself finds it. - fn row(matches: impl Fn(&BackendOption) -> bool) -> BackendOption { - detect_backends(&behavior()) - .into_iter() - .find(|row| matches(row)) - .expect("the row is offered") - } - - fn agent_row(agent: &'static str) -> BackendOption { - row(|row| row.agent == Some(agent)) - } - - fn backend_row(backend: &'static str) -> BackendOption { - row(|row| row.agent.is_none() && row.backend == backend) - } - - /// Drive the picker to the model step for one row, the way the handler does. - fn at_model_step(behavior: &BehaviorConfig, wanted: &BackendOption) -> DjSetup { - let mut setup = DjSetup::new(behavior); - setup.backend_index = setup - .backends - .iter() - .position(|row| row == wanted) - .expect("the row is offered"); - setup.enter_model_step(behavior); - setup - } - - #[test] - fn the_claude_model_list_starts_on_the_cheapest_alias() { - // The reason the picker exists: `claude -p` spends a Pro/Max quota per turn, so - // the cursor must not land the user on the model that exhausts it. - let (models, cursor) = models_for(&agent_row("claude"), &behavior()); - assert_eq!(models[cursor].value.as_deref(), Some("haiku")); - } - - #[test] - fn the_claude_model_list_reopens_on_the_alias_already_chosen() { - let mut behavior = behavior(); - behavior.dj_agent_model = Some("opus".to_string()); - let (models, cursor) = models_for(&agent_row("claude"), &behavior); - assert_eq!(models[cursor].value.as_deref(), Some("opus")); - } - - #[test] - fn the_model_list_keeps_a_model_that_is_not_one_of_its_suggestions() { - // The reopen-to-check case. A model typed at the "Custom…" step matches no - // curated row, and every escape-hatch row has `value: None`, so without a row of - // its own the cursor falls back to row 0 — `haiku` — and a reflexive Enter - // replaces a deliberate choice with the cheapest suggestion. - let mut behavior = behavior(); - behavior.dj_agent_model = Some("claude-sonnet-4-5-20250929".to_string()); - let (models, cursor) = models_for(&agent_row("claude"), &behavior); - assert_eq!( - models[cursor].value.as_deref(), - Some("claude-sonnet-4-5-20250929"), - "the cursor must open on what is configured, not on row 0" - ); - assert_eq!(models[cursor].note, "currently configured"); - // And it lands in front of the escape hatches, so they keep their positions. - assert!(models.last().expect("never empty").custom); - assert!(models[models.len() - 2].value.is_none()); - - // The same for an API model id that has aged out of the priced list. - let mut behavior = behavior; - behavior.dj_backend = "anthropic".to_string(); - behavior.dj_model = Some("claude-3-5-sonnet-20241022".to_string()); - let (models, cursor) = models_for(&backend_row("anthropic"), &behavior); - assert_eq!( - models[cursor].value.as_deref(), - Some("claude-3-5-sonnet-20241022") - ); - } - - #[test] - fn the_anthropic_model_list_starts_on_the_default_model() { - // Read from the const rather than repeated, so the picker cannot drift from the - // model the brain would have used anyway. - let (models, cursor) = models_for(&backend_row("anthropic"), &behavior()); - assert_eq!( - models[cursor].value.as_deref(), - Some(crate::infra::dj::brain::anthropic::DEFAULT_MODEL) - ); - } - - #[test] - fn the_anthropic_model_list_prices_every_row() { - // A model list without prices is just as opaque as the config field it replaces. - let (models, _) = models_for(&backend_row("anthropic"), &behavior()); - for option in models.iter().filter(|option| option.value.is_some()) { - assert!( - option.note.contains("per MTok"), - "{} has no price", - option.label - ); - } - } - - #[test] - fn every_model_list_ends_with_a_free_text_row() { - // The no-dead-end guarantee. There is no probe behind these lists, so a stale - // suggestion set must never be the only way to name a model. - // - // Both configs, because the default one cannot produce the own-command row, and - // that row is the single deliberate exception to the guarantee. Iterating only - // the default config would let the exception widen silently to a backend that - // does need an escape hatch, which is the failure this test exists to catch. - for behavior in [behavior(), hand_written()] { - for backend in detect_backends(&behavior) { - let (models, cursor) = models_for(&backend, &behavior); - assert!( - cursor < models.len(), - "{} starts out of range", - backend.label - ); - let last = models.last().expect("never empty"); - - if backend.command.is_some() { - // The exception, asserted rather than skipped: one row, and it is not a - // model choice, because a model named here would never reach the CLI. - assert_eq!( - models.len(), - 1, - "{} offers a model flag it cannot pass", - backend.label - ); - assert!(!last.custom && last.value.is_none(), "{}", backend.label); - continue; - } - - assert!(last.custom, "{} cannot type a model name", backend.label); - let default_row = &models[models.len() - 2]; - assert!( - default_row.value.is_none() && !default_row.custom, - "{} cannot return to its own default", - backend.label - ); - } - } - } - - #[test] - fn a_cli_with_no_known_models_still_offers_its_own_default_and_free_text() { - // Which ids `codex` accepts is not knowable without asking it, and asking costs - // a subprocess. Two rows is the honest answer, not an empty list. - let (models, cursor) = models_for(&agent_row("codex"), &behavior()); - assert_eq!(models.len(), 2); - assert_eq!(cursor, 0, "the cursor starts on 'use the CLI's default'"); - assert!(models[0].value.is_none()); - assert!(models[1].custom); - } - - #[test] - fn the_picker_opens_on_the_backend_already_configured() { - let mut behavior = behavior(); - behavior.dj_backend = "anthropic".to_string(); - let setup = DjSetup::new(&behavior); - assert_eq!( - setup.selected_backend().map(|row| row.backend), - Some("anthropic"), - "reopening should read as 'here is what you have'" - ); - } - - #[test] - fn choosing_an_agent_writes_a_bare_command_so_the_preset_keeps_ownership() { - let mut behavior = behavior(); - let setup = at_model_step(&behavior, &agent_row("agy")); - let choice = setup.choice().expect("a model row is selected"); - apply_choice(&mut behavior, &choice); - - // Bare, not `["agy", "--model", …, "-p"]`: the argv shape is the preset's job, - // and only the preset knows the model flag has to precede the delivery flag. - assert_eq!(behavior.dj_agent_command, vec!["agy".to_string()]); - assert_eq!( - behavior.dj_agent_model.as_deref(), - Some("gemini-3.6-flash-low") - ); - assert_eq!(behavior.dj_backend, "agent_cli"); - } - - #[test] - fn choosing_an_agent_clears_the_prompt_delivery_so_the_preset_decides() { - // `agy` ignores stdin entirely, and every existing install has `stdin` on disk - // from an automatic save. Leaving it there would deliver the prompt nowhere. - let mut behavior = behavior(); - behavior.dj_agent_prompt_via = Some("stdin".to_string()); - let setup = at_model_step(&behavior, &agent_row("agy")); - apply_choice(&mut behavior, &setup.choice().unwrap()); - assert_eq!(behavior.dj_agent_prompt_via, None); - } - - #[test] - fn choosing_an_http_backend_writes_dj_model_not_dj_agent_model() { - let mut behavior = behavior(); - let setup = at_model_step(&behavior, &backend_row("anthropic")); - apply_choice(&mut behavior, &setup.choice().unwrap()); - assert_eq!( - behavior.dj_model.as_deref(), - Some(crate::infra::dj::brain::anthropic::DEFAULT_MODEL) - ); - assert_eq!(behavior.dj_agent_model, None); - // An API backend has no argv to speak of, so the agent command is left alone. - assert_eq!( - behavior.dj_agent_command, - crate::core::user_config::default_dj_agent_command() - ); - } - - #[test] - fn switching_backends_clears_the_other_backends_model() { - // `haiku` and `claude-haiku-4-5` are different namespaces. A leftover would - // reappear on the next switch back, as a mode nobody chose. - let mut behavior = behavior(); - let setup = at_model_step(&behavior, &agent_row("claude")); - apply_choice(&mut behavior, &setup.choice().unwrap()); - assert_eq!(behavior.dj_agent_model.as_deref(), Some("haiku")); - - let setup = at_model_step(&behavior, &backend_row("anthropic")); - apply_choice(&mut behavior, &setup.choice().unwrap()); - assert_eq!(behavior.dj_agent_model, None); - assert!(behavior.dj_model.is_some()); - - let setup = at_model_step(&behavior, &agent_row("claude")); - apply_choice(&mut behavior, &setup.choice().unwrap()); - assert_eq!(behavior.dj_model, None); - } - - #[test] - fn the_use_the_default_row_clears_the_model_instead_of_writing_one() { - let mut behavior = behavior(); - behavior.dj_agent_model = Some("opus".to_string()); - let mut setup = at_model_step(&behavior, &agent_row("claude")); - // The row before "Custom…" is always "use the default". - setup.model_index = setup.models.len() - 2; - apply_choice(&mut behavior, &setup.choice().unwrap()); - assert_eq!( - behavior.dj_agent_model, None, - "picking once must not be a one-way door" - ); - } - - #[test] - fn the_free_text_row_is_not_itself_an_answer() { - let mut setup = at_model_step(&behavior(), &agent_row("claude")); - setup.model_index = setup.models.len() - 1; - assert!( - setup.choice().is_none(), - "'Custom…' opens the typing step, it does not commit" - ); - } - - #[test] - fn a_typed_model_commits_and_an_empty_one_falls_back_to_the_default() { - let behavior = behavior(); - let mut setup = at_model_step(&behavior, &agent_row("codex")); - setup.enter_custom_step(&behavior); - setup.custom = " gpt-5-codex ".to_string(); - assert_eq!( - setup.choice().unwrap().model.as_deref(), - Some("gpt-5-codex"), - "trimmed, because a trailing space in argv is a different model name" - ); - - setup.custom = " ".to_string(); - assert_eq!( - setup.choice().unwrap().model, - None, - "an empty buffer means 'use the default', not an error" - ); - } - - #[test] - fn the_free_text_step_for_an_http_backend_starts_from_its_built_in_default() { - // `openai_compat` has no suggestion list, so the buffer is the only place the - // model id can come from; starting empty would make the common case a retype. - let behavior = behavior(); - let mut setup = at_model_step(&behavior, &backend_row("openai_compat")); - setup.enter_custom_step(&behavior); - assert_eq!( - setup.custom, - crate::infra::dj::brain::openai_compat::DEFAULT_MODEL - ); - } - - #[test] - fn a_backend_on_its_own_is_not_a_finished_choice() { - let setup = DjSetup::new(&behavior()); - assert_eq!(setup.step, DjSetupStep::Backend); - assert!(setup.choice().is_none(), "the model is half the question"); - } - - #[test] - fn navigation_saturates_at_both_ends() { - let mut setup = DjSetup::new(&behavior()); - let rows = setup.backends.len(); - for _ in 0..rows + 5 { - setup.move_down(); - } - assert_eq!(setup.backend_index, rows - 1); - for _ in 0..rows + 5 { - setup.move_up(); - } - assert_eq!(setup.backend_index, 0); - assert!(!setup.select_row(rows + 1), "out of range is a no-op"); - assert!(setup.select_row(2)); - assert_eq!(setup.backend_index, 1); - } - - #[test] - fn active_label_reports_the_backend_and_model_pair() { - let mut behavior = behavior(); - assert_eq!(active_label(&behavior).as_deref(), Some("claude")); - - behavior.dj_agent_model = Some("haiku".to_string()); - assert_eq!(active_label(&behavior).as_deref(), Some("claude/haiku")); - - behavior.dj_backend = "anthropic".to_string(); - behavior.dj_model = Some("claude-sonnet-5".to_string()); - assert_eq!( - active_label(&behavior).as_deref(), - Some("anthropic/claude-sonnet-5") - ); - } - - /// The config `docs/ai-dj.md` invites: "Any other headless command works too." - /// - /// `arg` delivery and a model are both set, because both are things the picker - /// used to quietly discard for this config. - fn hand_written() -> BehaviorConfig { - let mut behavior = behavior(); - behavior.dj_agent_command = vec!["my-agent".to_string(), "--headless".to_string()]; - behavior.dj_agent_prompt_via = Some("arg".to_string()); - behavior.dj_agent_model = Some("haiku".to_string()); - behavior - } - - #[test] - fn a_hand_written_command_gets_the_first_row_and_survives_two_enters() { - let behavior = hand_written(); - let mut setup = DjSetup::new(&behavior); - let row = setup.selected_backend().expect("a row is selected").clone(); - assert_eq!( - row.command.as_ref(), - Some(&behavior.dj_agent_command), - "the picker has to open on the command it found, not on `claude`" - ); - assert!(row.label.contains("my-agent --headless"), "{}", row.label); - - // One row, and it is not a model choice. `resolve_agent_command` drops the model - // flag for an argv spotatui does not own, so any suggestion here would write a - // `dj_agent_model` the CLI is never told about. - let (models, cursor) = models_for(&row, &behavior); - assert_eq!(models.len(), 1, "{models:?}"); - assert_eq!(cursor, 0); - assert!(models[0].value.is_none() && !models[0].custom); - - let mut after = behavior.clone(); - setup.enter_model_step(&behavior); - apply_choice( - &mut after, - &setup.choice().expect("the single row is an answer"), - ); - assert_eq!(after.dj_agent_command, behavior.dj_agent_command); - assert_eq!( - after.dj_agent_prompt_via, behavior.dj_agent_prompt_via, - "clearing it would hand an arg-delivery CLI its prompt on stdin, i.e. nowhere" - ); - assert_eq!(after.dj_agent_model, behavior.dj_agent_model); - assert_eq!(after.dj_model, behavior.dj_model); - } - - #[test] - fn the_title_omits_a_model_the_cli_is_never_told_about() { - let mut behavior = behavior(); - behavior.dj_agent_command = vec![ - "claude".to_string(), - "--verbose".to_string(), - "-p".to_string(), - ]; - behavior.dj_agent_model = Some("haiku".to_string()); - assert_eq!( - active_label(&behavior).as_deref(), - Some("claude"), - "the spawned argv has no --model, so the title must not advertise one" - ); - - // The very same field is reported once the command is one spotatui owns, which - // is what makes the title and the resolved argv the same statement. - behavior.dj_agent_command = vec!["claude".to_string()]; - assert_eq!(active_label(&behavior).as_deref(), Some("claude/haiku")); - } - - #[test] - fn the_picker_always_opens_on_a_row_that_preserves_what_is_configured() { - // One invariant over six configs: the row the cursor opens on either names - // exactly what is configured, or is the row that explicitly preserves it. - // Anything else is a modal that misreports the config and then overwrites it on - // Enter — which is the same user-visible failure whether the mismatch is in the - // backend list, the model list, or the title. - // - // Every expectation below is a literal. Deriving them from the helper the code - // under test uses (`session::owns_agent_command`) would let a bug in it cancel - // out on both sides. - let mut bare = behavior(); - bare.dj_agent_command = vec!["claude".to_string()]; - let mut preset_and_model = bare.clone(); - preset_and_model.dj_agent_model = Some("opus".to_string()); - let mut typed_model = bare.clone(); - typed_model.dj_agent_model = Some("claude-sonnet-4-5-20250929".to_string()); - let mut flagged_preset = behavior(); - flagged_preset.dj_agent_command = vec![ - "claude".to_string(), - "--verbose".to_string(), - "-p".to_string(), - ]; - flagged_preset.dj_agent_model = Some("haiku".to_string()); - let mut api = behavior(); - api.dj_backend = "anthropic".to_string(); - api.dj_model = Some("claude-opus-5".to_string()); - - struct Case { - name: &'static str, - behavior: BehaviorConfig, - /// The row the picker must open on: `(backend, agent, is it the user's own - /// command?)`. - row: (&'static str, Option<&'static str>, bool), - /// The model row the cursor must land on. - model: Option<&'static str>, - /// What the DJ title says before the picker runs, and after Enter, Enter. - /// Equal for every config that already names something. - title: (&'static str, &'static str), - /// Whether Enter, Enter has to leave every config field exactly as it was. - untouched: bool, - } - - for case in [ - Case { - name: "a fresh default install", - behavior: behavior(), - row: ("agent_cli", Some("claude"), false), - model: Some("haiku"), - title: ("claude", "claude/haiku"), - untouched: false, - }, - Case { - name: "a bare preset name", - behavior: bare, - row: ("agent_cli", Some("claude"), false), - model: Some("haiku"), - title: ("claude", "claude/haiku"), - untouched: false, - }, - Case { - name: "a preset with a chosen alias", - behavior: preset_and_model, - row: ("agent_cli", Some("claude"), false), - model: Some("opus"), - title: ("claude/opus", "claude/opus"), - untouched: true, - }, - Case { - name: "a model typed at the Custom step", - behavior: typed_model, - row: ("agent_cli", Some("claude"), false), - model: Some("claude-sonnet-4-5-20250929"), - title: ( - "claude/claude-sonnet-4-5-20250929", - "claude/claude-sonnet-4-5-20250929", - ), - untouched: true, - }, - Case { - name: "a hand-written multi-part command", - behavior: hand_written(), - row: ("agent_cli", None, true), - model: None, - // No model, either side: this argv never receives the `--model` flag, so - // `dj_agent_model: haiku` is set but not in effect. - title: ("my-agent", "my-agent"), - untouched: true, - }, - Case { - name: "a preset name with a flag added by hand", - behavior: flagged_preset, - row: ("agent_cli", None, true), - model: None, - // The trap case: it starts with `claude`, so the preset row would happily - // claim it and Enter would drop `--verbose`. - title: ("claude", "claude"), - untouched: true, - }, - Case { - name: "an API backend with a model id", - behavior: api, - row: ("anthropic", None, false), - model: Some("claude-opus-5"), - title: ("anthropic/claude-opus-5", "anthropic/claude-opus-5"), - untouched: true, - }, - ] { - let Case { - name, - behavior, - row: (backend, agent, own_command), - model, - title, - untouched, - } = case; - assert_eq!( - active_label(&behavior).as_deref(), - Some(title.0), - "{name}: the title misreports the config before the picker even opens" - ); - - let mut setup = DjSetup::new(&behavior); - let row = setup - .selected_backend() - .unwrap_or_else(|| panic!("{name}: nothing selected")); - assert_eq!(row.backend, backend, "{name}"); - assert_eq!(row.agent, agent, "{name}"); - assert_eq!( - row.command.as_ref(), - own_command.then_some(&behavior.dj_agent_command), - "{name}" - ); - - setup.enter_model_step(&behavior); - let selected = setup - .selected_model() - .unwrap_or_else(|| panic!("{name}: no model row")); - assert_eq!(selected.value.as_deref(), model, "{name}"); - - let mut after = behavior.clone(); - apply_choice( - &mut after, - &setup - .choice() - .unwrap_or_else(|| panic!("{name}: the open row is not an answer")), - ); - assert_eq!( - active_label(&after).as_deref(), - Some(title.1), - "{name}: the title after the picker ran" - ); - if untouched { - assert_eq!(after.dj_backend, behavior.dj_backend, "{name}"); - assert_eq!( - after.dj_agent_command, behavior.dj_agent_command, - "{name}: the command was rewritten" - ); - assert_eq!(after.dj_agent_model, behavior.dj_agent_model, "{name}"); - assert_eq!(after.dj_model, behavior.dj_model, "{name}"); - assert_eq!( - after.dj_agent_prompt_via, behavior.dj_agent_prompt_via, - "{name}: prompt delivery was cleared" - ); - } else { - // Nothing was configured to preserve, so the picker is free to suggest, and - // deliberately does: cheapest first, which is the reason it exists. What it - // still may not change is which CLI is being run. - assert_eq!( - after.dj_agent_command.first(), - behavior.dj_agent_command.first(), - "{name}" - ); - assert_eq!(after.dj_agent_model.as_deref(), model, "{name}"); - } - } - } - - #[test] - fn active_label_says_nothing_for_a_backend_it_does_not_know() { - let mut behavior = behavior(); - behavior.dj_backend = "carrier-pigeon".to_string(); - assert_eq!(active_label(&behavior), None); - } - - #[test] - fn describe_names_the_agent_rather_than_the_backend() { - // "agent_cli/haiku" would tell the listener nothing about which CLI is running. - let choice = DjSetupChoice { - backend: "agent_cli".to_string(), - agent_command: Some(vec!["claude".to_string()]), - keep_command: false, - model: Some("haiku".to_string()), - ready: true, - }; - assert_eq!(choice.describe(), "claude/haiku"); - let choice = DjSetupChoice { - model: None, - ..choice - }; - assert_eq!(choice.describe(), "claude"); - } -} diff --git a/src/infra/dj/tools.rs b/src/infra/dj/tools.rs deleted file mode 100644 index 03c2b49d..00000000 --- a/src/infra/dj/tools.rs +++ /dev/null @@ -1,1017 +0,0 @@ -//! The DJ tool surface: one definition, two consumers. -//! -//! The MCP server (`mcp-server`) publishes these verbatim as MCP tools; the -//! in-TUI DJ (`ai-dj`) calls the same handlers directly. Keeping the list here -//! means an agent driving spotatui over MCP and the built-in DJ can never drift -//! apart in what they are able to do. -//! -//! Schemas are JSON Schema 2020-12, which is the MCP default dialect (a tool -//! taking no arguments uses `{"type":"object","additionalProperties":false}`, the -//! spec's recommended empty schema). The list order is fixed and deterministic: -//! MCP clients cache `tools/list`, and a stable order also improves prompt-cache -//! hit rates when the tools are rendered into model context. - -use super::{brief, DjSuggestion, MAX_BATCH}; -use crate::core::action::Action; -use crate::core::app::App; -use crate::infra::network::search::SPOTIFY_SEARCH_LIMIT; -use serde_json::{json, Value}; -use std::sync::Arc; -use tokio::sync::Mutex; - -/// Static description of one tool. -pub struct ToolSpec { - pub name: &'static str, - /// MCP-only: the in-TUI DJ renders `name` and `description` alone. - #[cfg_attr(not(feature = "mcp-server"), allow(dead_code))] - pub title: &'static str, - pub description: &'static str, - /// Whether the tool only reads state. Surfaced to MCP clients so they can - /// decide what to confirm with the user. - pub read_only: bool, - /// Whether executing it needs the live Spotify client (and therefore the - /// serial IoEvent lane) rather than just the `App` lock. - pub needs_network: bool, - pub schema: fn() -> Value, -} - -/// Every tool, in a fixed order. -pub const TOOLS: &[ToolSpec] = &[ - ToolSpec { - name: "get_listening_history", - title: "Listening history", - description: "Summarise what the user has been listening to: top artists, top tracks, top albums, and recent plays. Call this first when choosing music for them. Returns aggregate names only — no identifiers or timestamps.", - read_only: true, - needs_network: false, - schema: || { - json!({ - "type": "object", - "properties": { - "period": { - "type": "string", - "enum": ["7d", "30d", "month", "year", "all"], - "description": "Window to summarise. Defaults to 30d." - } - }, - "additionalProperties": false - }) - }, - }, - ToolSpec { - name: "get_now_playing", - title: "Now playing", - description: "The currently playing or paused track, whether playback is active, and how many tracks are waiting in the queue.", - read_only: true, - needs_network: false, - schema: || json!({"type": "object", "additionalProperties": false}), - }, - ToolSpec { - name: "get_queue", - title: "Queue", - description: "The tracks currently queued up, in play order.", - read_only: true, - needs_network: false, - schema: || json!({"type": "object", "additionalProperties": false}), - }, - ToolSpec { - name: "search_tracks", - title: "Search tracks", - description: "Search the catalogue for tracks and return their playable URIs. Use this to check that something exists before queueing it by name. Each result is marked `owned` when the user already has it (Liked Songs, or a playlist they own or collaborate on) — prefer results marked new when they asked for something they have not heard.", - read_only: true, - needs_network: true, - schema: || { - json!({ - "type": "object", - "properties": { - "query": {"type": "string", "description": "Free-text search, e.g. 'radiohead weird fishes'."}, - "limit": {"type": "integer", "minimum": 1, "maximum": SPOTIFY_SEARCH_LIMIT, "description": format!("Maximum results. Defaults to {SPOTIFY_SEARCH_LIMIT}.")} - }, - "required": ["query"], - "additionalProperties": false - }) - }, - }, - ToolSpec { - name: "queue_tracks", - title: "Queue tracks", - description: "Add tracks to the play queue. Give either a `uri` from search_tracks, or a `title` plus `artist` to be looked up. Tracks that cannot be found are skipped and reported back, so check the result rather than assuming everything was queued.", - read_only: false, - needs_network: true, - schema: || { - json!({ - "type": "object", - "properties": { - "tracks": { - "type": "array", - "minItems": 1, - "maxItems": MAX_BATCH, - "description": "Tracks to queue, in play order.", - "items": { - "type": "object", - "properties": { - "uri": {"type": "string", "description": "Exact URI from search_tracks. Preferred when known."}, - "title": {"type": "string"}, - "artist": {"type": "string"} - }, - "additionalProperties": false - } - }, - "exclude_owned": { - "type": "boolean", - "description": "Skip tracks the user already has (Liked Songs, or a playlist they own or collaborate on) instead of queueing them; the skipped ones are reported back. Defaults to false. Set it when they asked for music they have not heard, not when they named specific tracks. Nothing is substituted for a skipped track, so fewer may be queued than requested." - } - }, - "required": ["tracks"], - "additionalProperties": false - }) - }, - }, - ToolSpec { - name: "play_now", - title: "Play now", - description: "Start playing a track immediately, interrupting whatever is playing. Use queue_tracks instead unless the user asked for something right now. The track has to exist: give a URI from search_tracks rather than one you assembled yourself.", - read_only: false, - needs_network: true, - schema: || { - json!({ - "type": "object", - "properties": {"uri": {"type": "string", "description": "A track URI from search_tracks."}}, - "required": ["uri"], - "additionalProperties": false - }) - }, - }, - ToolSpec { - name: "skip_track", - title: "Skip track", - description: "Skip to the next track.", - read_only: false, - needs_network: false, - schema: || json!({"type": "object", "additionalProperties": false}), - }, - ToolSpec { - name: "set_dj_vibe", - title: "Set DJ vibe", - description: "Set the standing direction the built-in auto-queue DJ follows when it refills the queue on its own, e.g. 'mellow instrumental for focusing'. Pass null to clear it. This does not start playback or queue anything. The built-in DJ is optional and may be switched off or not built in at all, so read the result: it says whether anything will act on the vibe. Either way it is stored and returned by get_listening_history, so you can honour it yourself when you queue.", - read_only: false, - needs_network: false, - schema: || { - json!({ - "type": "object", - "properties": {"vibe": {"type": ["string", "null"], "description": "The direction to follow, or null to clear."}}, - "additionalProperties": false - }) - }, - }, -]; - -pub fn spec(name: &str) -> Option<&'static ToolSpec> { - TOOLS.iter().find(|tool| tool.name == name) -} - -/// A validated tool invocation. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum DjToolCall { - GetListeningHistory { - period: crate::infra::history::RecapPeriod, - }, - GetNowPlaying, - GetQueue, - SearchTracks { - query: String, - limit: usize, - }, - QueueTracks { - items: Vec<QueueItem>, - /// Reject anything the listener already has rather than queueing it. - /// - /// Off by default, and deliberately so: an agent told to queue one specific - /// track the listener owns should get that track, not an explanation. It is - /// opt-in for the *recommendation* case, where "you already have this" is the - /// answer the caller actually wants. - exclude_owned: bool, - /// Extra [`super::dedupe_key`] values to reject, on top of what is queued and - /// playing. - /// - /// Not an argument any caller can pass: [`parse_call`] always leaves it empty, - /// so an MCP agent told to queue a specific track always gets that track. The - /// in-TUI DJ fills it with its recently-played window, which is a taste rule - /// belonging to that front door alone — put in `App::dj_skip_keys` it would - /// silently drop tracks an agent explicitly asked for. - extra_skip_keys: Vec<String>, - }, - PlayNow { - uri: String, - }, - SkipTrack, - SetDjVibe { - vibe: Option<String>, - }, -} - -/// One entry of `queue_tracks`: either an exact URI or a name to resolve. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum QueueItem { - Uri(String), - Named(DjSuggestion), -} - -/// Why a call could not be parsed. -/// -/// Distinct from an execution failure: per MCP, a bad request shape is a -/// protocol error while a tool that ran and failed is a result with -/// `isError: true`. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum ToolCallError { - UnknownTool(String), - InvalidArguments(String), -} - -impl std::fmt::Display for ToolCallError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::UnknownTool(name) => write!(f, "Unknown tool: {name}"), - Self::InvalidArguments(detail) => write!(f, "Invalid arguments: {detail}"), - } - } -} - -/// What a tool produced. -pub struct ToolOutcome { - /// Human/model-readable text. Always populated. - pub text: String, - /// Machine-readable mirror, when the tool has one. MCP-only: the in-TUI DJ - /// feeds `text` back to its model, which reads prose perfectly well. - #[cfg_attr(not(feature = "mcp-server"), allow(dead_code))] - pub structured: Option<Value>, - /// True for an execution failure the model can act on and retry. - pub is_error: bool, -} - -impl ToolOutcome { - pub fn ok(text: impl Into<String>) -> Self { - Self { - text: text.into(), - structured: None, - is_error: false, - } - } - - pub fn with_data(text: impl Into<String>, structured: Value) -> Self { - Self { - text: text.into(), - structured: Some(structured), - is_error: false, - } - } - - pub fn error(text: impl Into<String>) -> Self { - Self { - text: text.into(), - structured: None, - is_error: true, - } - } -} - -fn period_from_str(value: &str) -> Option<crate::infra::history::RecapPeriod> { - use crate::infra::history::RecapPeriod; - match value { - "7d" => Some(RecapPeriod::SevenDays), - "30d" => Some(RecapPeriod::ThirtyDays), - "month" => Some(RecapPeriod::Month), - "year" => Some(RecapPeriod::Year), - "all" => Some(RecapPeriod::All), - _ => None, - } -} - -/// Validate `(name, arguments)` into a [`DjToolCall`]. -/// -/// Every input is checked here rather than in the handlers, because MCP requires -/// servers to validate tool inputs and because both front doors funnel through -/// this one function. -pub fn parse_call(name: &str, args: &Value) -> Result<DjToolCall, ToolCallError> { - use crate::infra::history::RecapPeriod; - let invalid = |detail: &str| ToolCallError::InvalidArguments(detail.to_string()); - // An absent `arguments` is equivalent to `{}` for the no-argument tools. - let obj = match args { - Value::Null => &Value::Null, - Value::Object(_) => args, - _ => return Err(invalid("arguments must be an object")), - }; - let get = |key: &str| obj.get(key); - - match name { - "get_listening_history" => { - let period = match get("period") { - None | Some(Value::Null) => RecapPeriod::ThirtyDays, - Some(Value::String(value)) => period_from_str(value) - .ok_or_else(|| invalid("period must be one of 7d, 30d, month, year, all"))?, - Some(_) => return Err(invalid("period must be a string")), - }; - Ok(DjToolCall::GetListeningHistory { period }) - } - "get_now_playing" => Ok(DjToolCall::GetNowPlaying), - "get_queue" => Ok(DjToolCall::GetQueue), - "search_tracks" => { - let query = get("query") - .and_then(Value::as_str) - .ok_or_else(|| invalid("query is required and must be a string"))? - .trim() - .to_string(); - if query.is_empty() { - return Err(invalid("query must not be empty")); - } - let limit = match get("limit") { - None | Some(Value::Null) => SPOTIFY_SEARCH_LIMIT as usize, - Some(value) => value - .as_u64() - .filter(|n| (1..=u64::from(SPOTIFY_SEARCH_LIMIT)).contains(n)) - .ok_or_else(|| { - invalid(&format!( - "limit must be an integer between 1 and {SPOTIFY_SEARCH_LIMIT}" - )) - })? as usize, - }; - Ok(DjToolCall::SearchTracks { query, limit }) - } - "queue_tracks" => { - let entries = get("tracks") - .and_then(Value::as_array) - .ok_or_else(|| invalid("tracks is required and must be an array"))?; - if entries.is_empty() { - return Err(invalid("tracks must not be empty")); - } - if entries.len() > MAX_BATCH { - return Err(ToolCallError::InvalidArguments(format!( - "tracks must contain at most {MAX_BATCH} entries" - ))); - } - let mut items = Vec::with_capacity(entries.len()); - for entry in entries { - let uri = entry.get("uri").and_then(Value::as_str); - let title = entry.get("title").and_then(Value::as_str); - let artist = entry.get("artist").and_then(Value::as_str); - match (uri, title, artist) { - (Some(uri), _, _) if !uri.trim().is_empty() => { - items.push(QueueItem::Uri(uri.trim().to_string())) - } - // Both halves have to be real: a blank artist resolves to nothing, so - // accepting it would turn an argument error into a silent "not found". - (_, Some(title), Some(artist)) - if !title.trim().is_empty() && !artist.trim().is_empty() => - { - items.push(QueueItem::Named(DjSuggestion { - title: title.trim().to_string(), - artist: artist.trim().to_string(), - why: None, - })) - } - _ => { - return Err(invalid( - "each track needs either a uri, or both title and artist", - )) - } - } - } - let exclude_owned = match get("exclude_owned") { - None | Some(Value::Null) => false, - Some(Value::Bool(value)) => *value, - Some(_) => return Err(invalid("exclude_owned must be a boolean")), - }; - Ok(DjToolCall::QueueTracks { - items, - exclude_owned, - // Never from the wire: see the field's own note. - extra_skip_keys: Vec::new(), - }) - } - "play_now" => { - let uri = get("uri") - .and_then(Value::as_str) - .map(str::trim) - .filter(|uri| !uri.is_empty()) - .ok_or_else(|| invalid("uri is required and must be a non-empty string"))? - .to_string(); - // Rejected at parse time, unlike `queue_tracks`: this tool takes exactly - // one URI, so there is no partial success to report and an invalid - // argument is the whole request. - if !crate::core::queue::is_playable_track_uri(&uri) { - return Err(invalid( - "uri must name a single playable track (spotify:track:…, file:…, \ - subsonic:…, youtube:…, or qobuz:track:…); album, playlist and https \ - links are not playable here", - )); - } - Ok(DjToolCall::PlayNow { uri }) - } - "skip_track" => Ok(DjToolCall::SkipTrack), - "set_dj_vibe" => { - let vibe = match get("vibe") { - None | Some(Value::Null) => None, - Some(Value::String(value)) => { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - } - Some(_) => return Err(invalid("vibe must be a string or null")), - }; - Ok(DjToolCall::SetDjVibe { vibe }) - } - other => Err(ToolCallError::UnknownTool(other.to_string())), - } -} - -impl DjToolCall { - pub fn needs_network(&self) -> bool { - matches!( - self, - Self::SearchTracks { .. } | Self::QueueTracks { .. } | Self::PlayNow { .. } - ) - } - - pub fn tool_name(&self) -> &'static str { - match self { - Self::GetListeningHistory { .. } => "get_listening_history", - Self::GetNowPlaying => "get_now_playing", - Self::GetQueue => "get_queue", - Self::SearchTracks { .. } => "search_tracks", - Self::QueueTracks { .. } => "queue_tracks", - Self::PlayNow { .. } => "play_now", - Self::SkipTrack => "skip_track", - Self::SetDjVibe { .. } => "set_dj_vibe", - } - } -} - -/// Execute the calls that only need the `App` lock (plus, for history, a -/// blocking file read). -/// -/// Returns `None` for calls that need the Spotify client; those go through the -/// serial IoEvent lane instead — see `crate::infra::network::dj`. -pub async fn execute_app_only(app: &Arc<Mutex<App>>, call: &DjToolCall) -> Option<ToolOutcome> { - match call { - DjToolCall::GetListeningHistory { period } => Some(history_outcome(app, *period).await), - DjToolCall::GetNowPlaying => Some(now_playing_outcome(app).await), - DjToolCall::GetQueue => Some(queue_outcome(app).await), - DjToolCall::SkipTrack => { - let mut app = app.lock().await; - // Through the shared vocabulary, so `App::next_track`'s ownership routing - // (queue slot, decoded sources, suspend-to-queue, native shuffle - // bookkeeping) applies; the old raw `IoEvent::NextTrack` dispatch missed - // the App-side branches. - app.apply(Action::NextTrack); - Some(ToolOutcome::ok("Skipped to the next track")) - } - DjToolCall::SetDjVibe { vibe } => { - let mut app = app.lock().await; - // The apply arm bumps the DJ generation exactly once (the adopt-one-bump - // rule in the turn loop depends on that count). - app.apply(Action::SetDjVibe(vibe.clone())); - Some(ToolOutcome::ok(match vibe.as_deref() { - Some(vibe) => format!("DJ vibe set to: {vibe}. {}", vibe_effect(&app)), - None => "DJ vibe cleared".to_string(), - })) - } - // These need the real Spotify client, which only the serial lane has. - // `play_now` is among them because it confirms the track exists before - // interrupting what is playing: dispatching blind used to report success - // for a URI that stopped playback instead of starting it. - DjToolCall::SearchTracks { .. } - | DjToolCall::QueueTracks { .. } - | DjToolCall::PlayNow { .. } => None, - } -} - -/// What, if anything, will actually act on a vibe that was just set. -/// -/// Worth a sentence rather than a bare "vibe set". The vibe is a standing -/// direction for the *in-TUI* auto-queue DJ, and there are two ordinary -/// situations where nothing consumes it on its own: a build without `ai-dj` -/// (`--features mcp-server` alone is a complete build, and the commonest one), -/// and a build with it where the listener has not switched auto-queue on. An -/// agent told only "DJ vibe set to: mellow" reasonably concludes the music is -/// about to change, and would be wrong both times. -/// -/// It is never *ignored*, which is why this points at the fallback: the vibe is -/// stored on `App` and comes back out through `get_listening_history`, so an -/// agent running its own top-up loop can read it and honour it itself. -fn vibe_effect(app: &App) -> &'static str { - #[cfg(feature = "ai-dj")] - { - if app.dj.auto_queue { - "The built-in DJ will follow it from its next refill." - } else { - "The built-in DJ's auto-queue is off, so nothing will act on it until the user turns it on; \ - queue tracks yourself in the meantime. get_listening_history returns it either way." - } - } - #[cfg(not(feature = "ai-dj"))] - { - // `App::dj` exists under `dj-core`, so `auto_queue` is readable here — but it - // is permanently false in this build, and saying "auto-queue is off" would - // imply it could be turned on. - let _ = app; - "This build has no built-in auto-queue DJ, so nothing will act on it automatically. It is \ - stored and returned by get_listening_history, so you can follow it yourself when you queue." - } -} - -async fn history_outcome( - app: &Arc<Mutex<App>>, - period: crate::infra::history::RecapPeriod, -) -> ToolOutcome { - // `load_listens` is blocking and reads the whole file, which is unbounded - // append-only — never call it directly on an async task. - let loaded = tokio::task::spawn_blocking(crate::infra::history::load_listens).await; - let listens = match loaded { - Ok(Ok(listens)) => listens, - Ok(Err(e)) => return ToolOutcome::error(format!("Could not read listening history: {e}")), - Err(e) => return ToolOutcome::error(format!("Listening history task failed: {e}")), - }; - - let mut summary = brief::build_brief(&listens, period); - { - let app = app.lock().await; - summary.now_playing = super::current_track_label(&app); - summary.vibe = app.dj.vibe.clone(); - } - - if summary.is_sparse() { - return ToolOutcome::ok(format!( - "Very little listening history so far ({} qualifying plays in {}). Ask the user what they \ - feel like instead of inferring it.", - summary.total_plays, summary.period_label - )); - } - - let structured = json!({ - "period": summary.period_label, - "total_plays": summary.total_plays, - "top_artists": summary.top_artists, - "top_tracks": summary.top_tracks, - "top_albums": summary.top_albums, - "recent": summary.recent, - "now_playing": summary.now_playing, - "vibe": summary.vibe, - }); - ToolOutcome::with_data(summary.to_prompt_block(), structured) -} - -async fn now_playing_outcome(app: &Arc<Mutex<App>>) -> ToolOutcome { - let app = app.lock().await; - let queue_depth = app.native_queue.len(); - // One snapshot for both the label and the transport state: it is the - // source-agnostic view (Spotify, local, Subsonic, YouTube, radio), so this - // works whatever is actually producing sound. - match crate::infra::media_metadata::current_playback_snapshot(&app) { - Some(snapshot) => { - let label = format!( - "{} — {}", - snapshot.metadata.title, - snapshot.primary_artist() - ); - let state = if snapshot.is_playing { - "playing" - } else { - "paused" - }; - ToolOutcome::with_data( - format!("{state}: {label} ({queue_depth} track(s) queued)"), - json!({ - "track": label, - "is_playing": snapshot.is_playing, - "is_live": snapshot.is_live, - "queue_depth": queue_depth, - }), - ) - } - // Same keys as the playing branch, `is_live` included: a payload whose shape - // depends on playback state makes a client read a missing key as unknown - // rather than as "no, this is not a live stream". - None => ToolOutcome::with_data( - format!("Nothing is playing ({queue_depth} track(s) queued)"), - json!({ - "track": Value::Null, - "is_playing": false, - "is_live": false, - "queue_depth": queue_depth, - }), - ), - } -} - -async fn queue_outcome(app: &Arc<Mutex<App>>) -> ToolOutcome { - let app = app.lock().await; - if app.native_queue.is_empty() { - return ToolOutcome::with_data("The queue is empty", json!({"tracks": []})); - } - let labels = app - .native_queue - .iter() - .map(|track| format!("{} — {}", track.name, track.artists.join(", "))) - .collect::<Vec<_>>(); - let text = labels - .iter() - .enumerate() - .map(|(index, label)| format!("{}. {}", index + 1, label)) - .collect::<Vec<_>>() - .join("\n"); - ToolOutcome::with_data(text, json!({"tracks": labels})) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::infra::history::RecapPeriod; - - #[test] - fn every_tool_schema_is_a_valid_object_schema() { - for tool in TOOLS { - let schema = (tool.schema)(); - assert_eq!( - schema.get("type").and_then(Value::as_str), - Some("object"), - "{} must have an object input schema", - tool.name - ); - // MCP requires a valid schema object, never null. - assert!(schema.is_object(), "{} schema must be an object", tool.name); - } - } - - #[test] - fn tool_names_satisfy_mcp_naming_rules() { - for tool in TOOLS { - assert!( - (1..=128).contains(&tool.name.len()), - "{} name length", - tool.name - ); - assert!( - tool - .name - .chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')), - "{} has characters MCP disallows", - tool.name - ); - } - } - - #[test] - fn tool_names_are_unique_and_order_is_deterministic() { - let names: Vec<_> = TOOLS.iter().map(|tool| tool.name).collect(); - let unique: std::collections::HashSet<_> = names.iter().collect(); - assert_eq!(names.len(), unique.len(), "duplicate tool name"); - // Order is part of the contract: MCP clients cache tools/list. - assert_eq!(names[0], "get_listening_history"); - assert_eq!(names.last(), Some(&"set_dj_vibe")); - } - - #[test] - fn no_argument_tools_use_the_recommended_empty_schema() { - for name in ["get_now_playing", "get_queue", "skip_track"] { - let schema = (spec(name).unwrap().schema)(); - assert_eq!( - schema.get("additionalProperties"), - Some(&Value::Bool(false)), - "{name} should reject unexpected arguments" - ); - assert!(schema.get("properties").is_none(), "{name} takes no args"); - } - } - - #[test] - fn history_period_defaults_and_parses() { - let call = parse_call("get_listening_history", &json!({})).unwrap(); - assert_eq!( - call, - DjToolCall::GetListeningHistory { - period: RecapPeriod::ThirtyDays - } - ); - let call = parse_call("get_listening_history", &json!({"period": "7d"})).unwrap(); - assert_eq!( - call, - DjToolCall::GetListeningHistory { - period: RecapPeriod::SevenDays - } - ); - assert!(matches!( - parse_call("get_listening_history", &json!({"period": "fortnight"})), - Err(ToolCallError::InvalidArguments(_)) - )); - } - - #[test] - fn null_arguments_are_accepted_for_no_argument_tools() { - assert_eq!( - parse_call("get_now_playing", &Value::Null).unwrap(), - DjToolCall::GetNowPlaying - ); - } - - #[test] - fn unknown_tool_is_distinct_from_bad_arguments() { - // MCP treats these differently: unknown tool and malformed request are - // protocol errors, a tool that ran and failed is isError. - assert!(matches!( - parse_call("make_coffee", &json!({})), - Err(ToolCallError::UnknownTool(_)) - )); - assert!(matches!( - parse_call("search_tracks", &json!({})), - Err(ToolCallError::InvalidArguments(_)) - )); - } - - #[test] - fn search_validates_query_and_limit() { - assert!(parse_call("search_tracks", &json!({"query": " "})).is_err()); - assert!(parse_call("search_tracks", &json!({"query": "a", "limit": 0})).is_err()); - assert!(parse_call( - "search_tracks", - &json!({"query": "a", "limit": SPOTIFY_SEARCH_LIMIT + 1}) - ) - .is_err()); - let call = parse_call("search_tracks", &json!({"query": " nude ", "limit": 3})).unwrap(); - assert_eq!( - call, - DjToolCall::SearchTracks { - query: "nude".into(), - limit: 3 - } - ); - } - - #[test] - fn queue_accepts_uris_and_named_pairs() { - let call = parse_call( - "queue_tracks", - &json!({"tracks": [ - {"uri": "spotify:track:abc"}, - {"title": "Nude", "artist": "Radiohead"} - ]}), - ) - .unwrap(); - let DjToolCall::QueueTracks { - items, - exclude_owned, - extra_skip_keys, - } = call - else { - panic!("expected QueueTracks"); - }; - assert_eq!(items[0], QueueItem::Uri("spotify:track:abc".into())); - assert!(matches!(&items[1], QueueItem::Named(s) if s.title == "Nude")); - assert!( - !exclude_owned, - "an agent told to queue a specific track must get it, even one the listener owns" - ); - assert!( - extra_skip_keys.is_empty(), - "the wire can never fill this: it is the in-TUI DJ's own taste policy" - ); - - let call = parse_call( - "queue_tracks", - &json!({"tracks": [{"uri": "spotify:track:abc"}], "exclude_owned": true}), - ) - .unwrap(); - assert!(matches!( - call, - DjToolCall::QueueTracks { - exclude_owned: true, - .. - } - )); - } - - #[test] - fn play_now_rejects_anything_that_is_not_a_single_track() { - // Rejected at parse time rather than reported as a failed execution: the - // tool takes exactly one URI, so a bad one is the whole request. It used to - // be dispatched blind, which stopped playback while reporting success. - for uri in [ - "not-a-uri", - "spotify:album:1DFixLWuPkv3KT3TnV35m3", - "spotify:playlist:37i9dQZF1DXcBWIGoYBM5M", - "https://open.spotify.com/track/7o2AeQZzfCERsRmOM86EcB", - ] { - let error = - parse_call("play_now", &json!({"uri": uri})).expect_err(&format!("{uri} must not parse")); - let ToolCallError::InvalidArguments(detail) = error else { - panic!("{uri} should be an argument error, not an unknown tool"); - }; - // Names the shape that does work, so the model can fix it in one step. - assert!(detail.contains("spotify:track:"), "{uri}: {detail}"); - } - - for uri in [ - "spotify:track:abc", - "file:/music/a.flac", - "youtube:abc", - "qobuz:track:1", - ] { - assert!( - parse_call("play_now", &json!({"uri": uri})).is_ok(), - "{uri}" - ); - } - } - - #[test] - fn play_now_needs_the_network_so_it_can_check_the_track_exists() { - // The lane split is what makes the existence check possible at all; the - // app-only lane has no Spotify client. - let call = parse_call("play_now", &json!({"uri": "spotify:track:abc"})).unwrap(); - assert!(call.needs_network()); - } - - #[test] - fn exclude_owned_must_be_a_boolean_if_present() { - // A string "true" is the shape a model gets wrong; accepting it silently - // would turn the guarantee on (or off) by accident. - assert!(matches!( - parse_call( - "queue_tracks", - &json!({"tracks": [{"uri": "spotify:track:a"}], "exclude_owned": "true"}) - ), - Err(ToolCallError::InvalidArguments(_)) - )); - // Explicit null is the same as absent. - assert!(matches!( - parse_call( - "queue_tracks", - &json!({"tracks": [{"uri": "spotify:track:a"}], "exclude_owned": null}) - ), - Ok(DjToolCall::QueueTracks { - exclude_owned: false, - .. - }) - )); - } - - #[test] - fn the_queue_schema_declares_exclude_owned_without_requiring_it() { - let schema = (spec("queue_tracks").unwrap().schema)(); - let property = schema - .get("properties") - .and_then(|properties| properties.get("exclude_owned")) - .expect("exclude_owned must be declared, or clients cannot pass it"); - assert_eq!(property.get("type"), Some(&json!("boolean"))); - // `additionalProperties: false` means an undeclared param is rejected - // outright, so the schema and `parse_call` have to agree. - assert_eq!( - schema.get("additionalProperties"), - Some(&Value::Bool(false)) - ); - let required = schema - .get("required") - .and_then(Value::as_array) - .expect("tracks stays required"); - assert!( - !required.contains(&json!("exclude_owned")), - "the default has to be usable without passing anything" - ); - } - - #[test] - fn queue_rejects_incomplete_entries_and_oversized_batches() { - assert!(parse_call("queue_tracks", &json!({"tracks": [{"title": "Nude"}]})).is_err()); - assert!(parse_call("queue_tracks", &json!({"tracks": []})).is_err()); - // A blank half is as incomplete as a missing one. Accepted, it would build a - // suggestion nothing can resolve and be reported as "not in the catalogue", - // which tells the model the track does not exist instead of that it sent a - // bad argument. - for entry in [ - json!({"title": "Nude", "artist": " "}), - json!({"title": " ", "artist": "Radiohead"}), - ] { - assert!( - matches!( - parse_call("queue_tracks", &json!({"tracks": [entry.clone()]})), - Err(ToolCallError::InvalidArguments(_)) - ), - "{entry} should be an argument error" - ); - } - let too_many: Vec<_> = (0..MAX_BATCH + 1) - .map(|i| json!({"uri": format!("spotify:track:{i}")})) - .collect(); - assert!(parse_call("queue_tracks", &json!({"tracks": too_many})).is_err()); - } - - #[tokio::test] - async fn now_playing_keeps_one_payload_shape_whether_or_not_anything_plays() { - // A default `App` has no playback context and no native track, so the - // snapshot is `None` — the branch that used to drop `is_live`. - let app = Arc::new(Mutex::new(App::default())); - let outcome = execute_app_only(&app, &DjToolCall::GetNowPlaying) - .await - .expect("get_now_playing is answered without the network"); - let data = outcome - .structured - .expect("now_playing returns structured data"); - - assert_eq!(data.get("track"), Some(&Value::Null)); - assert_eq!(data.get("is_playing"), Some(&json!(false))); - // The key the playing branch always sends. Absent, a client cannot tell "not - // a live stream" from "this build never says". - assert_eq!(data.get("is_live"), Some(&json!(false))); - assert_eq!(data.get("queue_depth"), Some(&json!(0))); - } - - #[tokio::test] - async fn setting_a_vibe_says_whether_anything_will_act_on_it() { - let app = Arc::new(Mutex::new(App::default())); - let outcome = execute_app_only( - &app, - &DjToolCall::SetDjVibe { - vibe: Some("mellow instrumental".into()), - }, - ) - .await - .expect("set_dj_vibe is answered without the network"); - - assert!(!outcome.is_error); - assert!(outcome.text.contains("mellow instrumental")); - assert_eq!( - app.lock().await.dj.vibe.as_deref(), - Some("mellow instrumental") - ); - - // The whole point: a bare "vibe set" lets an agent conclude the music is - // about to change. Nothing is refilling the queue in either build here, so - // the result has to say so and point at the fallback. - assert!( - outcome.text.contains("get_listening_history"), - "the agent needs to be told it can read the vibe back: {}", - outcome.text - ); - #[cfg(feature = "ai-dj")] - assert!( - outcome.text.contains("auto-queue is off"), - "a default App has auto-queue off: {}", - outcome.text - ); - #[cfg(not(feature = "ai-dj"))] - assert!( - outcome.text.contains("no built-in auto-queue DJ"), - "without ai-dj there is no DJ to switch on, and saying \"off\" would imply there is: {}", - outcome.text - ); - } - - #[tokio::test] - async fn clearing_a_vibe_stays_terse() { - // Nothing was set, so there is no expectation to correct. - let app = Arc::new(Mutex::new(App::default())); - let outcome = execute_app_only(&app, &DjToolCall::SetDjVibe { vibe: None }) - .await - .unwrap(); - assert_eq!(outcome.text, "DJ vibe cleared"); - assert!(app.lock().await.dj.vibe.is_none()); - } - - #[test] - fn vibe_treats_blank_as_cleared() { - assert_eq!( - parse_call("set_dj_vibe", &json!({"vibe": " "})).unwrap(), - DjToolCall::SetDjVibe { vibe: None } - ); - assert_eq!( - parse_call("set_dj_vibe", &json!({"vibe": " mellow "})).unwrap(), - DjToolCall::SetDjVibe { - vibe: Some("mellow".into()) - } - ); - } - - #[test] - fn needs_network_matches_the_spec_table() { - // The lane split depends on these agreeing; a mismatch would send a - // Spotify-dependent call to a Network built with no client. - for tool in TOOLS { - let args = match tool.name { - "search_tracks" => json!({"query": "x"}), - "queue_tracks" => json!({"tracks": [{"uri": "spotify:track:x"}]}), - "play_now" => json!({"uri": "spotify:track:x"}), - _ => json!({}), - }; - let call = parse_call(tool.name, &args).unwrap(); - assert_eq!( - call.needs_network(), - tool.needs_network, - "{} disagrees about needing the network", - tool.name - ); - assert_eq!(call.tool_name(), tool.name); - } - } -} diff --git a/src/infra/history.rs b/src/infra/history.rs deleted file mode 100644 index f837e57b..00000000 --- a/src/infra/history.rs +++ /dev/null @@ -1,2819 +0,0 @@ -use crate::core::app::{ActiveBlock, App, RecapPromptState}; -use crate::infra::media_metadata::{ - current_playback_snapshot, PlaybackItemKind, PlaybackSnapshot, PlaybackSource, -}; -use anyhow::{anyhow, Result}; -use chrono::{DateTime, Datelike, Duration, Local, NaiveDate, TimeZone, Timelike, Utc}; -use serde::{Deserialize, Serialize}; -use std::collections::{BTreeMap, BTreeSet}; -use std::fs::{self, OpenOptions}; -use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write}; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::time::Instant; -use tokio::sync::{oneshot, Mutex}; -use tokio::task::{JoinHandle, JoinSet}; - -const HISTORY_SUBDIR: &str = "history"; -const LISTENS_FILE_NAME: &str = "listens.jsonl"; -const LAST_RECAP_FILE_NAME: &str = "last_recap_at.txt"; -const LAST_SYNCED_FILE_NAME: &str = "last_synced_at.txt"; -const SYNC_BASE_URL: &str = "https://spotatui.com"; -const CLOUD_SYNC_PATH: &str = "/api/sync"; -const NOW_PLAYING_SYNC_PATH: &str = "/api/sync/now-playing"; -/// Heartbeat interval: must be well under the 5-minute online threshold used by the website. -const NOW_PLAYING_HEARTBEAT_SECS: u64 = 60; -/// Push immediately when observed progress deviates this far from the progress -/// expected since the last push (a seek), keeping the widget's bar honest. -const NOW_PLAYING_SEEK_THRESHOLD_MS: u128 = 5_000; -const SYNC_BASE_URL_ENV_KEY: &str = "SPOTATUI_SYNC_BASE_URL"; -const MAX_INTERVAL_MS: u64 = 5_000; -const REPLAY_RESET_THRESHOLD_MS: u128 = 15_000; -const REPLAY_PREVIOUS_PROGRESS_FLOOR_MS: u128 = 30_000; - -#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum HistoryPlaybackSource { - NativeContext, - NativeRawList, - ExternalDevice, -} - -#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum HistoryItemKind { - Track, - Episode, -} - -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] -pub struct ListenRecord { - pub started_at: DateTime<Utc>, - pub ended_at: DateTime<Utc>, - pub listened_ms: u64, - pub duration_ms: u32, - pub qualified: bool, - pub title: String, - pub artists: Vec<String>, - pub album: String, - pub item_kind: HistoryItemKind, - pub item_id: Option<String>, - pub item_uri: Option<String>, - pub context_uri: Option<String>, - pub source: HistoryPlaybackSource, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct SessionIdentity { - title: String, - artists: Vec<String>, - album: String, - item_id: Option<String>, - item_uri: Option<String>, - context_uri: Option<String>, - source: HistoryPlaybackSource, -} - -#[derive(Clone, Debug)] -struct ActiveListenSession { - started_at: DateTime<Utc>, - identity: SessionIdentity, - title: String, - artists: Vec<String>, - album: String, - item_kind: HistoryItemKind, - item_id: Option<String>, - item_uri: Option<String>, - context_uri: Option<String>, - source: HistoryPlaybackSource, - duration_ms: u32, - listened_ms: u64, - last_progress_ms: u128, - last_is_playing: bool, -} - -#[derive(Default)] -struct HistoryCollector { - current: Option<ActiveListenSession>, - last_observed_at: Option<Instant>, -} - -#[derive(Clone, Debug, Serialize)] -struct NowPlayingPayload { - title: String, - artists: Vec<String>, - album: String, - #[serde(skip_serializing_if = "Option::is_none")] - image_url: Option<String>, - duration_ms: u32, - progress_ms: u64, - is_playing: bool, - is_live: bool, - /// Client Unix millis when `progress_ms` was sampled, so the backend can - /// extrapolate the widget's progress bar between heartbeats. - progress_at_ms: i64, -} - -impl NowPlayingPayload { - fn from_snapshot(snapshot: &PlaybackSnapshot, sampled_at_ms: i64) -> Self { - Self { - title: snapshot.metadata.title.clone(), - artists: snapshot.metadata.artists.clone(), - album: snapshot.metadata.album.clone(), - image_url: snapshot.metadata.image_url.clone(), - duration_ms: snapshot.metadata.duration_ms, - progress_ms: snapshot.progress_ms.min(u64::MAX as u128) as u64, - is_playing: snapshot.is_playing, - is_live: snapshot.is_live, - progress_at_ms: sampled_at_ms, - } - } -} - -#[derive(Debug)] -enum NowPlayingAction { - Push(NowPlayingPayload), - Clear, -} - -/// State captured at the last push; `Some` exactly while the cloud holds -/// now-playing state for this session. -struct PushedState { - title: String, - artists: Vec<String>, - is_playing: bool, - progress_ms: u128, - at: Instant, -} - -/// Decides when the cloud now-playing state needs an update. Pushes on track -/// change, play/pause flip, seek discontinuity, and a heartbeat (also while -/// paused, so the public widget can show "paused" instead of decaying to -/// offline); clears once when track playback stops mid-session. -#[derive(Default)] -struct NowPlayingTracker { - pushed: Option<PushedState>, -} - -impl NowPlayingTracker { - fn observe( - &mut self, - snapshot: Option<&PlaybackSnapshot>, - now: Instant, - ) -> Option<NowPlayingAction> { - let snap = snapshot.filter(|s| s.item_kind == PlaybackItemKind::Track); - let Some(snap) = snap else { - return self.pushed.take().map(|_| NowPlayingAction::Clear); - }; - - let should_push = match &self.pushed { - None => true, - Some(pushed) => { - let identity_changed = - pushed.title != snap.metadata.title || pushed.artists != snap.metadata.artists; - let play_state_changed = pushed.is_playing != snap.is_playing; - let heartbeat_due = now.duration_since(pushed.at).as_secs() >= NOW_PLAYING_HEARTBEAT_SECS; - let expected_progress = if pushed.is_playing { - pushed.progress_ms + now.duration_since(pushed.at).as_millis() - } else { - pushed.progress_ms - }; - let seek_detected = - snap.progress_ms.abs_diff(expected_progress) > NOW_PLAYING_SEEK_THRESHOLD_MS; - identity_changed || play_state_changed || heartbeat_due || seek_detected - } - }; - - if should_push { - self.pushed = Some(PushedState { - title: snap.metadata.title.clone(), - artists: snap.metadata.artists.clone(), - is_playing: snap.is_playing, - progress_ms: snap.progress_ms, - at: now, - }); - return Some(NowPlayingAction::Push(NowPlayingPayload::from_snapshot( - snap, - Utc::now().timestamp_millis(), - ))); - } - None - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum RecapPeriod { - SevenDays, - ThirtyDays, - Month, - Year, - All, -} - -impl RecapPeriod { - pub const ALL_PERIODS: [RecapPeriod; 5] = [ - RecapPeriod::SevenDays, - RecapPeriod::ThirtyDays, - RecapPeriod::Month, - RecapPeriod::Year, - RecapPeriod::All, - ]; - - pub fn next(self) -> Self { - self.cycle(1) - } - - pub fn prev(self) -> Self { - self.cycle(Self::ALL_PERIODS.len() - 1) - } - - fn cycle(self, offset: usize) -> Self { - let index = Self::ALL_PERIODS - .iter() - .position(|period| *period == self) - .unwrap_or(0); - Self::ALL_PERIODS[(index + offset) % Self::ALL_PERIODS.len()] - } -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct StreakSummary { - pub current_days: u32, - pub longest_days: u32, - pub today_ms: u64, -} - -/// Aggregated listening stats backing the in-app Stats screen. -#[derive(Clone, Debug)] -pub struct StatsData { - pub total_plays: usize, - pub total_time_ms: u64, - pub top_tracks: Vec<RankedEntry>, - pub top_artists: Vec<RankedEntry>, - pub top_albums: Vec<RankedEntry>, - pub days: Vec<RankedEntry>, -} - -const STATS_LIST_LIMIT: usize = 20; - -pub fn build_stats_data(filtered: &[ListenRecord]) -> StatsData { - StatsData { - total_plays: filtered.len(), - total_time_ms: filtered.iter().map(|record| record.listened_ms).sum(), - top_tracks: aggregate_top_tracks(filtered, STATS_LIST_LIMIT), - top_artists: aggregate_top_artists(filtered, STATS_LIST_LIMIT), - top_albums: aggregate_top_albums(filtered, STATS_LIST_LIMIT), - days: aggregate_days(filtered), - } -} - -pub fn compute_streaks(listens: &[ListenRecord]) -> StreakSummary { - streaks_from_day_totals(&qualified_day_totals(listens)) -} - -/// Listened milliseconds per local day, qualified records only. The history -/// collector keeps one of these updated incrementally so streaks don't require -/// re-reading the listens file after every track. -fn qualified_day_totals(listens: &[ListenRecord]) -> BTreeMap<NaiveDate, u64> { - let mut totals: BTreeMap<NaiveDate, u64> = BTreeMap::new(); - for record in listens.iter().filter(|record| record.qualified) { - let date = record.ended_at.with_timezone(&Local).date_naive(); - *totals.entry(date).or_default() += record.listened_ms; - } - totals -} - -fn streaks_from_day_totals(day_totals: &BTreeMap<NaiveDate, u64>) -> StreakSummary { - let today = Local::now().date_naive(); - let days: BTreeSet<NaiveDate> = day_totals.keys().copied().collect(); - streaks_from_days( - &days, - today, - day_totals.get(&today).copied().unwrap_or_default(), - ) -} - -fn streaks_from_days(days: &BTreeSet<NaiveDate>, today: NaiveDate, today_ms: u64) -> StreakSummary { - let mut longest_days = 0u32; - let mut run = 0u32; - let mut prev: Option<NaiveDate> = None; - let mut last_run = 0u32; - for &day in days { - run = match prev { - Some(prev_day) if day == prev_day + Duration::days(1) => run + 1, - _ => 1, - }; - longest_days = longest_days.max(run); - last_run = run; - prev = Some(day); - } - - // A streak stays alive until midnight: count the trailing run if it ends - // today or yesterday. - let current_days = match prev { - Some(last_day) if last_day == today || last_day + Duration::days(1) == today => last_run, - _ => 0, - }; - - StreakSummary { - current_days, - longest_days, - today_ms, - } -} - -/// Spawn a cloud history sync unless one is already in flight. Overlapping -/// syncs would race on the append cursor and double-send records; a skipped -/// sync is harmless because the next trigger (next song or exit) resumes from -/// the same cursor. -fn spawn_cloud_history_sync( - client: &reqwest::Client, - sync_token: &str, - in_flight: &Arc<std::sync::atomic::AtomicBool>, - network_tasks: &mut JoinSet<()>, -) { - use std::sync::atomic::Ordering; - - if in_flight.swap(true, Ordering::AcqRel) { - return; - } - let client = client.clone(); - let token = sync_token.to_string(); - let in_flight = Arc::clone(in_flight); - network_tasks.spawn(async move { - if let Err(e) = sync_history_to_cloud_with_client(&client, &token).await { - log::warn!("failed to sync listening history to cloud: {}", e); - } - in_flight.store(false, Ordering::Release); - }); -} - -pub struct HistoryCollectorHandle { - shutdown_tx: Option<oneshot::Sender<()>>, - task: JoinHandle<()>, -} - -impl HistoryCollectorHandle { - /// Stop the collector and wait briefly for its network work to finish. If a - /// request is stuck, aborting the collector also aborts its owned `JoinSet`, - /// ensuring no now-playing upload can race the final exit clear. - pub async fn shutdown(mut self) { - if let Some(shutdown_tx) = self.shutdown_tx.take() { - let _ = shutdown_tx.send(()); - } - - if tokio::time::timeout(std::time::Duration::from_secs(1), &mut self.task) - .await - .is_err() - { - log::warn!("history collector shutdown timed out; aborting outstanding sync work"); - self.task.abort(); - let _ = self.task.await; - } - } -} - -pub fn spawn_history_collector(app: Arc<Mutex<App>>) -> HistoryCollectorHandle { - let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); - let task = tokio::spawn(async move { - let mut collector = HistoryCollector::default(); - let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1)); - let mut last_auto_check = Instant::now(); - let http_client = crate::infra::network::requests::shared_http_client().clone(); - let mut network_tasks = JoinSet::new(); - - // Check on startup - perform_auto_recap_check(&app).await; - - // Streak cache: load the full history once, then keep per-day totals - // updated incrementally from the records this collector appends (avoids - // re-reading the ever-growing listens file after every track). - let mut day_totals: Option<BTreeMap<NaiveDate, u64>> = - match tokio::task::spawn_blocking(load_listens).await { - Ok(Ok(listens)) => Some(qualified_day_totals(&listens)), - Ok(Err(error)) => { - log::warn!("failed to load listens for streak cache: {}", error); - None - } - Err(error) => { - log::warn!("streak cache task failed: {}", error); - None - } - }; - if let Some(totals) = &day_totals { - app.lock().await.listening_streaks = Some(streaks_from_day_totals(totals)); - } - - // Sync history to cloud on startup. This takes the lock properly rather - // than `try_lock`: the startup work above ends by acquiring (and releasing) - // the app mutex, and tokio's fair mutex hands the freed permit straight to - // any queued waiter, so a `try_lock` here intermittently loses to the - // render loop. Losing it silently pinned the token to `None` for the whole - // process, disabling per-song and now-playing sync and dumping the entire - // session onto the exit sync. - let mut sync_token_opt: Option<String> = - app.lock().await.user_config.behavior.sync_token.clone(); - log::info!( - "cloud sync {} at history collector startup", - if sync_token_opt.is_some() { - "enabled" - } else { - "disabled (no sync token configured)" - } - ); - - let history_sync_in_flight = Arc::new(std::sync::atomic::AtomicBool::new(false)); - if let Some(ref token) = sync_token_opt { - spawn_cloud_history_sync( - &http_client, - token, - &history_sync_in_flight, - &mut network_tasks, - ); - } - - // Now-playing tracking state - let mut now_playing_tracker = NowPlayingTracker::default(); - - loop { - tokio::select! { - _ = interval.tick() => {} - _ = &mut shutdown_rx => break, - } - while let Some(result) = network_tasks.try_join_next() { - if let Err(error) = result { - log::warn!("history collector network task failed: {}", error); - } - } - - if last_auto_check.elapsed().as_secs() >= 3600 { - last_auto_check = Instant::now(); - perform_auto_recap_check(&app).await; - } - - // Re-read the token from the guard we already hold: it can be pasted or - // cleared in Settings mid-session, and the collector is only spawned once. - let (snapshot, latest_token) = if let Ok(app_guard) = app.try_lock() { - ( - current_playback_snapshot(&app_guard), - app_guard.user_config.behavior.sync_token.clone(), - ) - } else { - continue; - }; - - if latest_token != sync_token_opt { - log::info!( - "cloud sync token {}", - if latest_token.is_some() { - "configured" - } else { - "cleared" - } - ); - sync_token_opt = latest_token; - } - - // Now-playing sync: push on track change, play/pause flip, seek, or - // heartbeat; clear once when track playback stops. - if let Some(ref token) = sync_token_opt { - let action = now_playing_tracker.observe(snapshot.as_ref(), Instant::now()); - match action { - Some(NowPlayingAction::Push(payload)) => { - let token_clone = token.clone(); - let client = http_client.clone(); - network_tasks.spawn(async move { - if let Err(e) = sync_now_playing_to_cloud(&client, &token_clone, &payload).await { - log::warn!("failed to sync now-playing: {}", e); - } - }); - } - Some(NowPlayingAction::Clear) => { - let token_clone = token.clone(); - let client = http_client.clone(); - network_tasks.spawn(async move { - if let Err(e) = clear_now_playing_with_client(&client, &token_clone).await { - log::warn!("failed to clear now-playing: {}", e); - } - }); - } - None => {} - } - } - - match collector.observe(snapshot) { - Ok(Some(record)) => { - if record.qualified { - if let Some(totals) = &mut day_totals { - let date = record.ended_at.with_timezone(&Local).date_naive(); - *totals.entry(date).or_default() += record.listened_ms; - app.lock().await.listening_streaks = Some(streaks_from_day_totals(totals)); - } - } - // Push the finished listen to the cloud right away instead of only - // at startup/exit; long sessions otherwise pile up records that - // stall the exit sync. - if let Some(ref token) = sync_token_opt { - spawn_cloud_history_sync( - &http_client, - token, - &history_sync_in_flight, - &mut network_tasks, - ); - } - } - Ok(None) => {} - Err(error) => { - log::warn!("listening history collector failed: {}", error); - } - } - } - while let Some(result) = network_tasks.join_next().await { - if let Err(error) = result { - log::warn!("history collector network task failed: {}", error); - } - } - }); - - HistoryCollectorHandle { - shutdown_tx: Some(shutdown_tx), - task, - } -} - -/// Default output path for the shareable recap page, used by the automatic -/// monthly check and the in-app `generate_recap` key. -pub fn recap_output_path() -> Result<PathBuf> { - crate::core::paths::app_state_dir() - .map(|dir| dir.join("spotatui-recap.html")) - .ok_or_else(|| anyhow!("cannot resolve the spotatui state directory")) -} - -fn last_recap_file_path() -> Result<PathBuf> { - history_state_file_path(LAST_RECAP_FILE_NAME) -} - -fn auto_recap_is_due(last_recap_contents: Option<&str>, now_ts: i64) -> bool { - match last_recap_contents { - Some(content) => match content.trim().parse::<i64>() { - Ok(ts) => now_ts - ts >= 30 * 24 * 3600, - Err(_) => true, - }, - None => true, - } -} - -async fn perform_auto_recap_check(app: &Arc<Mutex<App>>) { - if !app - .lock() - .await - .user_config - .behavior - .enable_monthly_recap_prompt - { - return; - } - - let path = match last_recap_file_path() { - Ok(p) => p, - Err(e) => { - log::warn!("failed to get last recap file path: {}", e); - return; - } - }; - - let now = Utc::now(); - if !auto_recap_is_due(fs::read_to_string(&path).ok().as_deref(), now.timestamp()) { - return; - } - - let output_path = match recap_output_path() { - Ok(p) => p, - Err(_) => return, - }; - - // The export reads and renders the whole history file; keep it off the - // runtime worker threads. - let export_path = output_path.clone(); - let result = tokio::task::spawn_blocking(move || { - export_history_recap(RecapPeriod::ThirtyDays, &export_path) - }) - .await - .map_err(anyhow::Error::from) - .and_then(|result| result); - - match result { - Ok(count) => { - if count > 0 { - if let Err(e) = fs::write(&path, now.timestamp().to_string()) { - log::warn!("failed to write last recap timestamp: {}", e); - } - - let mut app_guard = app.lock().await; - if app_guard.get_current_route().active_block == ActiveBlock::Input { - // Never steal keystrokes from an input field; fall back to a status - // message instead of the popup. - app_guard.set_status_message( - format!( - "Monthly listening recap ready at {} ({} listens)", - output_path.display(), - count - ), - 10, - ); - } else { - app_guard.show_recap_prompt(RecapPromptState { - path: output_path, - listens: count, - }); - } - } - } - Err(e) => { - log::warn!("failed to automatically generate recap: {}", e); - } - } -} - -pub fn export_history_recap(period: RecapPeriod, output_path: &Path) -> Result<usize> { - let listens = load_listens()?; - let filtered = filter_listens_for_period(&listens, period); - // The share card is toggleable between the requested period and All Time - // (or Last 30 Days when All Time itself was requested). - let alt_period = if period == RecapPeriod::All { - RecapPeriod::ThirtyDays - } else { - RecapPeriod::All - }; - let alt_filtered = filter_listens_for_period(&listens, alt_period); - let html = render_history_recap_html(period, &filtered, alt_period, &alt_filtered); - - if let Some(parent) = output_path.parent() { - fs::create_dir_all(parent)?; - } - fs::write(output_path, html)?; - - Ok(filtered.len()) -} - -pub fn parse_recap_period(value: &str) -> Result<RecapPeriod> { - match value { - "7d" => Ok(RecapPeriod::SevenDays), - "30d" => Ok(RecapPeriod::ThirtyDays), - "month" => Ok(RecapPeriod::Month), - "year" => Ok(RecapPeriod::Year), - "all" => Ok(RecapPeriod::All), - _ => Err(anyhow!("unsupported recap period '{}'", value)), - } -} - -pub fn load_listens() -> Result<Vec<ListenRecord>> { - let path = listens_file_path()?; - if !path.exists() { - return Ok(Vec::new()); - } - - let file = fs::File::open(path)?; - let reader = BufReader::new(file); - let mut listens = Vec::new(); - for line in reader.lines() { - let line = line?; - if line.trim().is_empty() { - continue; - } - - match serde_json::from_str::<ListenRecord>(&line) { - Ok(record) => listens.push(record), - Err(error) => { - log::warn!("skipping malformed history line: {}", error); - } - } - } - - Ok(listens) -} - -impl HistoryCollector { - /// Returns the listen record appended to the history file, if any. - fn observe(&mut self, snapshot: Option<PlaybackSnapshot>) -> Result<Option<ListenRecord>> { - let mut appended = None; - let now_utc = Utc::now(); - let now_instant = Instant::now(); - - if let Some(current) = &mut self.current { - if let Some(last_observed_at) = self.last_observed_at { - let elapsed_ms = now_instant - .saturating_duration_since(last_observed_at) - .as_millis() - .min(MAX_INTERVAL_MS as u128) as u64; - if current.last_is_playing { - current.listened_ms = current.listened_ms.saturating_add(elapsed_ms); - } - } - } - self.last_observed_at = Some(now_instant); - - let snapshot = snapshot.filter(|snapshot| snapshot.item_kind == PlaybackItemKind::Track); - match snapshot { - Some(snapshot) => { - let identity = SessionIdentity::from_snapshot(&snapshot); - let should_roll = self.current.as_ref().is_some_and(|current| { - current.identity != identity - || (current.last_progress_ms > REPLAY_PREVIOUS_PROGRESS_FLOOR_MS - && snapshot.progress_ms + REPLAY_RESET_THRESHOLD_MS < current.last_progress_ms) - }); - - if should_roll { - appended = self.finalize_current(now_utc)?; - } - - if self.current.is_none() { - self.current = Some(ActiveListenSession::from_snapshot(snapshot, now_utc)); - return Ok(appended); - } - - if let Some(current) = &mut self.current { - current.duration_ms = snapshot.metadata.duration_ms; - current.last_progress_ms = snapshot.progress_ms; - current.last_is_playing = snapshot.is_playing; - } - } - None => { - appended = self.finalize_current(now_utc)?; - } - } - - Ok(appended) - } - - fn finalize_current(&mut self, ended_at: DateTime<Utc>) -> Result<Option<ListenRecord>> { - let Some(current) = self.current.take() else { - return Ok(None); - }; - - if current.listened_ms == 0 { - return Ok(None); - } - - let record = ListenRecord::from_active_session(current, ended_at); - append_listen_record(&record)?; - Ok(Some(record)) - } -} - -impl SessionIdentity { - fn from_snapshot(snapshot: &PlaybackSnapshot) -> Self { - Self { - title: snapshot.metadata.title.clone(), - artists: snapshot.metadata.artists.clone(), - album: snapshot.metadata.album.clone(), - item_id: snapshot.item_id.clone(), - item_uri: snapshot.item_uri.clone(), - context_uri: snapshot.context_uri.clone(), - source: history_source_from_snapshot(snapshot), - } - } -} - -impl ActiveListenSession { - fn from_snapshot(snapshot: PlaybackSnapshot, started_at: DateTime<Utc>) -> Self { - let source = history_source_from_snapshot(&snapshot); - let identity = SessionIdentity::from_snapshot(&snapshot); - let PlaybackSnapshot { - metadata, - item_id, - item_uri, - context_uri, - source: _, - progress_ms, - is_playing, - .. - } = snapshot; - let title = metadata.title; - let artists = metadata.artists; - let album = metadata.album; - let duration_ms = metadata.duration_ms; - Self { - started_at, - identity, - title, - artists, - album, - item_kind: HistoryItemKind::Track, - item_id, - item_uri, - context_uri, - source, - duration_ms, - listened_ms: 0, - last_progress_ms: progress_ms, - last_is_playing: is_playing, - } - } -} - -impl ListenRecord { - fn from_active_session(session: ActiveListenSession, ended_at: DateTime<Utc>) -> Self { - let qualified = qualifies_listen(session.duration_ms, session.listened_ms); - Self { - started_at: session.started_at, - ended_at, - listened_ms: session.listened_ms, - duration_ms: session.duration_ms, - qualified, - title: session.title, - artists: session.artists, - album: session.album, - item_kind: session.item_kind, - item_id: session.item_id, - item_uri: session.item_uri, - context_uri: session.context_uri, - source: session.source, - } - } -} - -fn listens_file_path() -> Result<PathBuf> { - history_state_file_path(LISTENS_FILE_NAME) -} - -fn history_state_file_path(file_name: &str) -> Result<PathBuf> { - crate::core::paths::app_state_dir() - .map(|dir| dir.join(HISTORY_SUBDIR).join(file_name)) - .ok_or_else(|| anyhow!("cannot resolve the spotatui state directory")) -} - -fn append_listen_record(record: &ListenRecord) -> Result<()> { - let path = listens_file_path()?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - - let mut file = OpenOptions::new().create(true).append(true).open(path)?; - serde_json::to_writer(&mut file, record)?; - writeln!(file)?; - Ok(()) -} - -fn qualifies_listen(duration_ms: u32, listened_ms: u64) -> bool { - if duration_ms <= 30_000 { - return false; - } - - let threshold_ms = u64::from(duration_ms / 2).min(240_000); - listened_ms >= threshold_ms -} - -fn history_source_from_snapshot(snapshot: &PlaybackSnapshot) -> HistoryPlaybackSource { - match snapshot.source { - PlaybackSource::NativeContext => HistoryPlaybackSource::NativeContext, - PlaybackSource::NativeRawList => HistoryPlaybackSource::NativeRawList, - PlaybackSource::ExternalDevice => HistoryPlaybackSource::ExternalDevice, - } -} - -pub fn filter_listens_for_period( - listens: &[ListenRecord], - period: RecapPeriod, -) -> Vec<ListenRecord> { - let now = Utc::now(); - listens - .iter() - .filter(|record| record.qualified) - .filter(|record| match period { - RecapPeriod::SevenDays => record.ended_at >= now - Duration::days(7), - RecapPeriod::ThirtyDays => record.ended_at >= now - Duration::days(30), - RecapPeriod::Month => { - record.ended_at.year() == now.year() && record.ended_at.month() == now.month() - } - RecapPeriod::Year => record.ended_at.year() == now.year(), - RecapPeriod::All => true, - }) - .cloned() - .collect() -} - -/// The raw (unescaped) values the share card shows for one period. -struct CardData { - period: RecapPeriod, - track_title: String, - track_artist: String, - top_artist: String, - top_album: String, - plays: usize, - time: String, -} - -impl CardData { - fn from_listens(period: RecapPeriod, listens: &[ListenRecord]) -> Self { - Self::from_tops( - period, - aggregate_top_tracks(listens, 1).first(), - aggregate_top_artists(listens, 1).first(), - aggregate_top_albums(listens, 1).first(), - listens.len(), - listens.iter().map(|record| record.listened_ms).sum(), - ) - } - - /// Build from already-aggregated ranked lists; the first entry of each wins. - fn from_tops( - period: RecapPeriod, - top_track: Option<&RankedEntry>, - top_artist: Option<&RankedEntry>, - top_album: Option<&RankedEntry>, - plays: usize, - total_ms: u64, - ) -> Self { - let top_track_raw = top_track - .map(|entry| entry.display.as_str()) - .unwrap_or("No data"); - let (track_title, track_artist) = if top_track_raw == "No data" { - ("No data".to_string(), "No data".to_string()) - } else { - ( - top_track_raw - .split(" - ") - .next() - .unwrap_or(top_track_raw) - .to_string(), - top_track_raw.split(" - ").nth(1).unwrap_or("").to_string(), - ) - }; - - Self { - period, - track_title, - track_artist, - top_artist: top_artist - .map(|entry| entry.display.as_str()) - .unwrap_or("No data") - .to_string(), - top_album: top_album - .map(|entry| entry.display.as_str()) - .unwrap_or("No data") - .to_string(), - plays, - time: format_duration(total_ms), - } - } - - /// Render as a JS object literal for the recap page's inline script. - fn to_js_object(&self) -> String { - format!( - "{{ period: {}, track: {}, artist: {}, topArtist: {}, topAlbum: {}, plays: {}, time: {} }}", - js_string(period_label(self.period)), - js_string(&self.track_title), - js_string(&self.track_artist), - js_string(&self.top_artist), - js_string(&self.top_album), - self.plays, - js_string(&self.time), - ) - } -} - -fn render_history_recap_html( - period: RecapPeriod, - listens: &[ListenRecord], - alt_period: RecapPeriod, - alt_listens: &[ListenRecord], -) -> String { - let top_tracks = aggregate_top_tracks(listens, 5); - let top_artists = aggregate_top_artists(listens, 5); - let top_albums = aggregate_top_albums(listens, 5); - let listening_days = aggregate_days(listens); - let listening_hours = aggregate_hours(listens); - - // The primary card's No. 1 entries fall out of the limit-5 lists above; only - // the alternative period needs its own aggregation. - let card = CardData::from_tops( - period, - top_tracks.first(), - top_artists.first(), - top_albums.first(), - listens.len(), - listens.iter().map(|record| record.listened_ms).sum(), - ); - let alt_card = CardData::from_listens(alt_period, alt_listens); - - format!( - r#"<!DOCTYPE html> -<html lang="en"> -<head> - <meta charset="utf-8"> - <meta name="viewport" content="width=device-width, initial-scale=1"> - <title>spotatui recap - - - - - - -

- - - - -"#, - title = escape_html(period_label(period)), - summary = escape_html(if listens.is_empty() { - "No qualified local listening history was recorded for this window yet." - } else { - "A shareable HTML snapshot of your qualified local listening history, generated directly from spotatui." - }), - alt_title = escape_html(period_label(alt_period)), - total_plays = card.plays, - total_time = escape_html(&card.time), - top_track_title = escape_html(&card.track_title), - top_track_artist = escape_html(&card.track_artist), - top_artist_name = escape_html(&card.top_artist), - top_album_title = escape_html(&card.top_album), - card_js = card.to_js_object(), - alt_card_js = alt_card.to_js_object(), - top_tracks_html = render_ranked_entries(&top_tracks, "No tracks yet."), - top_artists_html = render_ranked_entries(&top_artists, "No artists yet."), - top_albums_html = render_ranked_entries(&top_albums, "No albums yet."), - recent_html = render_recent_entries(listens), - days_html = render_bar_entries(&listening_days), - hours_html = render_bar_entries(&listening_hours), - ) -} - -#[derive(Clone, Debug)] -pub struct RankedEntry { - pub display: String, - pub detail: String, - pub value: u64, - pub uri: Option, -} - -pub fn aggregate_top_tracks(listens: &[ListenRecord], limit: usize) -> Vec { - let mut totals: BTreeMap)> = BTreeMap::new(); - for record in listens { - let key = record - .item_id - .clone() - .or_else(|| record.item_uri.clone()) - .unwrap_or_else(|| format!("{}::{}", record.title, record.artists.join(", "))); - let entry = totals.entry(key).or_insert_with(|| { - ( - format!("{} - {}", record.title, record.artists.join(", ")), - 0, - 0, - record.item_uri.clone(), - ) - }); - entry.1 += record.listened_ms; - entry.2 += 1; - } - - sort_ranked_entries( - totals - .into_values() - .map(|(display, listened_ms, plays, uri)| RankedEntry { - display, - detail: format!("{} plays · {}", plays, format_duration(listened_ms)), - value: listened_ms, - uri, - }) - .collect(), - limit, - ) -} - -fn split_artists(combo: &str) -> Vec { - let normalized = combo.replace(" and ", ", ").replace(" & ", ", "); - normalized - .split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect() -} - -pub fn aggregate_top_artists(listens: &[ListenRecord], limit: usize) -> Vec { - let mut totals: BTreeMap = BTreeMap::new(); - for record in listens { - for artist_combo in &record.artists { - let individual_artists = split_artists(artist_combo); - for artist in individual_artists { - let entry = totals.entry(artist).or_insert((0, 0)); - entry.0 += record.listened_ms; - entry.1 += 1; - } - } - } - - sort_ranked_entries( - totals - .into_iter() - .map(|(artist, (listened_ms, plays))| RankedEntry { - display: artist, - detail: format!("{} track hits · {}", plays, format_duration(listened_ms)), - value: listened_ms, - uri: None, - }) - .collect(), - limit, - ) -} - -pub fn aggregate_top_albums(listens: &[ListenRecord], limit: usize) -> Vec { - let mut totals: BTreeMap = BTreeMap::new(); - for record in listens { - if record.album.trim().is_empty() { - continue; - } - let entry = totals.entry(record.album.clone()).or_insert((0, 0)); - entry.0 += record.listened_ms; - entry.1 += 1; - } - - sort_ranked_entries( - totals - .into_iter() - .map(|(album, (listened_ms, plays))| RankedEntry { - display: album, - detail: format!("{} plays · {}", plays, format_duration(listened_ms)), - value: listened_ms, - uri: None, - }) - .collect(), - limit, - ) -} - -pub fn aggregate_days(listens: &[ListenRecord]) -> Vec { - let mut totals: BTreeMap = BTreeMap::new(); - for record in listens { - let label = record.ended_at.format("%Y-%m-%d").to_string(); - *totals.entry(label).or_default() += record.listened_ms; - } - - totals - .into_iter() - .rev() - .take(10) - .map(|(label, listened_ms)| RankedEntry { - display: label, - detail: format_duration(listened_ms), - value: listened_ms, - uri: None, - }) - .collect::>() - .into_iter() - .rev() - .collect() -} - -fn aggregate_hours(listens: &[ListenRecord]) -> Vec { - let mut totals: BTreeMap = BTreeMap::new(); - for record in listens { - let local_hour = record.ended_at.with_timezone(&chrono::Local).hour(); - *totals.entry(local_hour).or_default() += record.listened_ms; - } - - (0..24) - .map(|hour| RankedEntry { - display: format!("{hour:02}:00"), - detail: format_duration(*totals.get(&hour).unwrap_or(&0)), - value: *totals.get(&hour).unwrap_or(&0), - uri: None, - }) - .collect() -} - -fn sort_ranked_entries(mut entries: Vec, limit: usize) -> Vec { - entries.sort_by(|left, right| { - right - .value - .cmp(&left.value) - .then_with(|| left.display.cmp(&right.display)) - }); - entries.truncate(limit); - entries -} - -fn render_ranked_entries(entries: &[RankedEntry], empty_label: &str) -> String { - if entries.is_empty() { - return format!(r#"

{}

"#, escape_html(empty_label)); - } - - let items = entries - .iter() - .enumerate() - .map(|(i, entry)| { - let parts: Vec<&str> = entry.display.split(" - ").collect(); - let (title, subtitle) = if parts.len() == 2 { - (parts[0], format!("
{}
", escape_html(parts[1]))) - } else { - (entry.display.as_str(), "".to_string()) - }; - - let detail_html = format!(r#"
{}
"#, escape_html(&entry.detail)); - format!( - "
  • #{}
    {}{}{}
  • ", - i + 1, - escape_html(title), - subtitle, - detail_html - ) - }) - .collect::>() - .join(""); - format!("
      {items}
    ") -} - -fn render_recent_entries(listens: &[ListenRecord]) -> String { - let recent_records = listens.iter().rev().take(5).collect::>(); - - if recent_records.is_empty() { - return r#"

    No recent plays.

    "#.to_string(); - } - - let items = recent_records - .iter() - .map(|record| { - let local_time = record.ended_at.with_timezone(&chrono::Local); - let time_str = local_time.format("%b %d, %H:%M").to_string(); - format!( - "
  • {}
    {}
    listened at {}
  • ", - escape_html(&record.title), - escape_html(&record.artists.join(", ")), - escape_html(&time_str) - ) - }) - .collect::>() - .join(""); - format!("
      {items}
    ") -} - -fn render_bar_entries(entries: &[RankedEntry]) -> String { - if entries.is_empty() { - return r#"

    No data yet.

    "#.to_string(); - } - - let max_value = entries - .iter() - .map(|entry| entry.value) - .max() - .unwrap_or(1) - .max(1); - let rows = entries - .iter() - .map(|entry| { - let width = (entry.value as f64 / max_value as f64) * 100.0; - format!( - r#"
    -
    {}{}
    -
    -
    "#, - escape_html(&entry.display), - escape_html(&entry.detail), - width - ) - }) - .collect::>() - .join(""); - format!(r#"
    {rows}
    "#) -} - -pub fn period_label(period: RecapPeriod) -> &'static str { - match period { - RecapPeriod::SevenDays => "Last 7 Days", - RecapPeriod::ThirtyDays => "Last 30 Days", - RecapPeriod::Month => "This Month", - RecapPeriod::Year => "This Year", - RecapPeriod::All => "All Time", - } -} - -pub fn format_duration(total_ms: u64) -> String { - let total_minutes = total_ms / 1000 / 60; - let hours = total_minutes / 60; - let minutes = total_minutes % 60; - if hours == 0 { - format!("{minutes}m") - } else { - format!("{hours}h {minutes}m") - } -} - -fn escape_html(input: &str) -> String { - input - .replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) - .replace('\'', "'") -} - -/// Encode a string as a JS string literal (quotes included). Unlike -/// `escape_html`, the value round-trips exactly; needed for the recap page's -/// inline `"#.to_string(); - let records = [record]; - let html = render_history_recap_html(RecapPeriod::All, &records, RecapPeriod::ThirtyDays, &[]); - // DOM side stays HTML-escaped - assert!(html.contains(""Live" & Loud")); - // JS side round-trips the raw value as a valid string literal, with the - // script-closing sequence neutralized - assert!(html.contains(r#"\"Live\" & Loud"#)); - assert!(html.contains(r#"<\/script>"#)); - assert!(!html.contains(r#"& Loud "#)); - } - - #[test] - fn recap_html_embeds_both_card_periods_with_toggle() { - let this_month = record_at(20, 120_000, true); - let html = render_history_recap_html( - RecapPeriod::ThirtyDays, - std::slice::from_ref(&this_month), - RecapPeriod::All, - &[this_month.clone(), record_at(21, 60_000, true)], - ); - // Toggle buttons for both periods - assert!(html.contains(r#"onclick="setActiveCard(0)">Last 30 Days<"#)); - assert!(html.contains(r#"onclick="setActiveCard(1)">All Time<"#)); - // Both card datasets are embedded with their own play counts - assert!(html.contains(r#"period: "Last 30 Days", track:"#)); - assert!(html.contains(r#"period: "All Time", track:"#)); - assert!(html.contains("plays: 1,")); - assert!(html.contains("plays: 2,")); - } - - #[test] - fn js_string_round_trips_special_characters() { - assert_eq!(js_string("plain"), r#""plain""#); - assert_eq!(js_string(r#"a "b" & c"#), r#""a \"b\" & c""#); - assert_eq!(js_string(""), r#""<\/script>""#); - } - - #[test] - fn auto_recap_due_on_missing_invalid_or_old_timestamp() { - let now = 1_800_000_000_i64; - assert!(auto_recap_is_due(None, now)); - assert!(auto_recap_is_due(Some("not a number"), now)); - assert!(auto_recap_is_due( - Some(&(now - 31 * 24 * 3600).to_string()), - now - )); - assert!(!auto_recap_is_due( - Some(&(now - 24 * 3600).to_string()), - now - )); - } -} diff --git a/src/infra/local/dispatch.rs b/src/infra/local/dispatch.rs deleted file mode 100644 index 5d172903..00000000 --- a/src/infra/local/dispatch.rs +++ /dev/null @@ -1,591 +0,0 @@ -//! Local-file playback routing. -//! -//! This is the seam that keeps the Spotify [`Network`](crate::infra::network) -//! Spotify-only: [`route_local_event`] is called from the runtime IoEvent pump -//! *before* `handle_network_event`. When the event targets local files (a -//! `file://` playback URI, a transport control while a local file owns the -//! session, or a browse request) it is handled here and consumed; otherwise it -//! falls through to the normal Spotify dispatch. -//! -//! ## Decoupling -//! -//! Local playback owns a single piece of state, [`App::local_playback`]. This -//! module never writes Spotify/librespot fields (`native_track_info`, -//! `song_progress_ms`, `is_streaming_active`, …): the playbar reads progress and -//! pause state live from the player, so the two playback worlds cannot desync. -//! -//! ## Device ownership -//! -//! Only one backend holds the audio output device at a time (required on -//! exclusive-ALSA setups, harmless elsewhere). Starting local playback pauses -//! native Spotify (librespot releases the device when its sink stops); starting -//! Spotify tears the local session down (dropping it releases the device). -//! -//! ## Publish-once -//! -//! `local_playback` is set exactly once, in the success arm of [`start_local_queue`], -//! *after* the source is decoding. While it is `None` neither the playbar nor -//! the runtime tick touch local state, so the brief "opening" window is simply -//! invisible — there is no half-initialised state for a tick to misread. - -use std::sync::Arc; -use std::time::Duration; - -use tokio::sync::Mutex; - -use super::{file_uri_to_path, track_info_from_path, LocalPlaybackState, LocalSource}; -use crate::core::app::{App, TrackTableContext}; -use crate::core::source::{MediaSource, Source}; -use crate::infra::audio::LocalPlayer; -use crate::infra::network::IoEvent; -use crate::infra::queue::{advance_index, replay_file, restage, ResumePoint}; - -/// Whether a URI is owned by the local-files source. -fn is_file_uri(uri: &str) -> bool { - uri.starts_with("file:") -} - -/// Intercept playback events that target local files. -/// -/// Returns `true` if the event was handled locally (and must **not** be -/// forwarded to the Spotify network), `false` to let the normal dispatch run. -pub async fn route_local_event(app: &Arc>, event: &IoEvent) -> bool { - match event { - // Browse: scan the configured music directory and a folder's tracks. - IoEvent::GetLocalPlaylists => { - load_local_playlists(app).await; - true - } - IoEvent::GetLocalTracks(uri) => { - load_local_tracks(app, uri).await; - true - } - // Start playing a folder of local files: queue every track and start at the - // selected offset. `on_enter` for a LocalPlaylist sends the whole folder - // this way so Next/Previous/auto-advance have a queue to move through. - IoEvent::StartPlayback(None, Some(uris), offset) - if uris.first().is_some_and(|u| is_file_uri(u)) => - { - start_local_queue(app, uris.clone(), offset.unwrap_or(0)).await; - true - } - // Start playing a single local file (no surrounding folder context): a - // one-track queue. - IoEvent::StartPlayback(Some(uri), _, _) if is_file_uri(uri) => { - start_local_queue(app, vec![uri.clone()], 0).await; - true - } - // Bare "resume current" — ours only while a local file owns the session. - IoEvent::StartPlayback(None, None, None) => match player(app).await { - Some(player) => { - player.resume(); - true - } - None => false, - }, - // Any other start is a real Spotify play: relinquish the device first, then - // let the network handle it. - IoEvent::StartPlayback(..) => { - teardown_local(app).await; - false - } - IoEvent::PausePlayback => match player(app).await { - Some(player) => { - player.pause(); - true - } - None => false, - }, - IoEvent::Seek(position_ms) => match player(app).await { - Some(player) => { - // The playbar reads position live from the player, so the seek shows up - // on the next render with nothing else to update. - let _ = player.seek(Duration::from_millis(*position_ms as u64)); - true - } - None => false, - }, - IoEvent::ChangeVolume(volume) => match player(app).await { - Some(player) => { - player.set_volume(*volume); - // Keep the playbar's volume readout in sync. - let mut app = app.lock().await; - app.runtime_state.volume_percent = *volume; - app.schedule_state_save(crate::core::state::PersistedRuntimeState::volume_percent( - *volume, - )); - true - } - None => false, - }, - // Skip forward in the local queue. Also the target of the runner tick's - // auto-advance dispatch and (via U3) OS media-key Next. Consumed whenever a - // local file owns the session so it never reaches Spotify. - IoEvent::NextTrack => skip(app, Direction::Next).await, - // Skip backward. `ForcePreviousTrack` (restart-or-previous) behaves the same - // here: there is no "restart current vs go back" distinction for local files. - IoEvent::PreviousTrack | IoEvent::ForcePreviousTrack => skip(app, Direction::Prev).await, - // Repeat-one auto-advance: replay the current track (dispatched by the runner - // tick only while a local file owns the session). - IoEvent::ReplayCurrentTrack => replay_current(app).await, - // Explicit repeat-mode set (e.g. a plugin's `set_repeat`): store it as the - // decoded repeat mode while a queueable decoded source owns playback (the - // method's ownership gate excludes a context suspended under the native - // queue), so it never reaches the Spotify context. - IoEvent::Repeat(state) => app.lock().await.set_decoded_repeat_from_state(*state), - _ => false, - } -} - -/// Skip direction within the local queue. -#[derive(Clone, Copy)] -enum Direction { - Next, - Prev, -} - -/// Move the local queue index in `direction` and play the new track. -/// -/// Returns `true` if a local file owns the session (so the event is consumed -/// and never reaches Spotify), `false` otherwise. At a queue boundary the index -/// is clamped — the skip is a no-op but the event is still consumed. -async fn skip(app: &Arc>, direction: Direction) -> bool { - // Read the target index under a short lock, then release it before the - // blocking decode in `play_index`. - let target = { - let mut guard = app.lock().await; - // Repeat-all wraps at the ends; Off/Repeat-one clamp (manual skip advances - // normally under repeat-one — only *auto* advance replays). - let mode = guard.decoded_repeat; - let Some(local) = guard.local_playback.as_mut() else { - return false; // not ours — let Spotify handle it - }; - // Mark a track change in progress so the runner tick does not mistake the - // empty sink during the upcoming decode for end-of-track and fire a spurious - // auto-advance. Cleared in `play_index`'s commit once the new source is in - // the sink. (We can't unqueue an already-dispatched auto-advance, so a Next - // pressed mid-advance may skip one extra track — benign and accepted.) - local.advancing = true; - let forward = matches!(direction, Direction::Next); - advance_index(local.index, local.queue.len(), mode, forward) - }; - - match target { - Some(idx) => play_index(app, idx).await, - None => { - // Boundary hit: the skip clamps to a no-op. Clear the guard we optimistically - // set so it does not wedge auto-advance off for the rest of the track. - if let Some(local) = app.lock().await.local_playback.as_mut() { - local.advancing = false; - } - } - } - // Either way the event is ours and must not fall through to Spotify. - true -} - -/// Replay the current local track (repeat-one auto-advance) via [`replay_file`]; -/// a drained rodio sink cannot seek, so the file is re-decoded from disk. On any -/// failure (bad index/URI or decode error) the session is **torn down** rather -/// than left with an empty sink — otherwise the runner tick would see -/// end-of-track again next tick and re-fire replay forever. -/// -/// Returns `true` when a local file owns the session (event consumed), `false` -/// otherwise. -async fn replay_current(app: &Arc>) -> bool { - let target = { - let mut guard = app.lock().await; - let Some(local) = guard.local_playback.as_mut() else { - return false; // not ours — let Spotify handle it - }; - local.advancing = true; - let resume = local.resume_at.take().or(Some(ResumePoint { - position_ms: 0, - paused: local.player.is_paused(), - })); - local - .queue - .get(local.index) - .and_then(|uri| file_uri_to_path(uri).ok()) - .map(|path| (Arc::clone(&local.player), path, resume)) - }; - let replayed = match target { - Some((player, path, resume)) => replay_file(player, path, resume).await, - None => false, - }; - if replayed { - // Same track, same metadata: just clear the in-flight guard. The track did - // play, so forget earlier failures like any other successful play — a stale - // count would otherwise tear a later run down early. - if let Some(local) = app.lock().await.local_playback.as_mut() { - local.advancing = false; - local.failed_since_played.clear(); - } - } else { - // Take under the lock, stop off it: a sink clear waits for the audio thread. - let ended = app.lock().await.local_playback.take(); - if let Some(local) = ended { - Arc::clone(&local.player).stop_detached_holding(local); - } - set_error(app, "Cannot replay local file".to_string()).await; - } - true -} - -/// The live local player, if a local file currently owns the session. -async fn player(app: &Arc>) -> Option> { - app - .lock() - .await - .local_playback - .as_ref() - .map(|local| Arc::clone(&local.player)) -} - -/// Begin playing a queue of local files, taking over the playback session and -/// starting at `start_idx` (clamped into range). Subsequent skips and -/// auto-advance reuse [`play_index`] against the queue published here. -async fn start_local_queue(app: &Arc>, queue: Vec, start_idx: usize) { - if queue.is_empty() { - set_error(app, "No local tracks to play".to_string()).await; - return; - } - let index = start_idx.min(queue.len() - 1); - - let path = match file_uri_to_path(&queue[index]) { - Ok(path) => path, - Err(e) => { - set_error(app, format!("Invalid local file URI: {e}")).await; - return; - } - }; - - app.lock().await.claim_decoded_sink(Source::Local); - - // Pause native Spotify so librespot releases the output device. - #[cfg(feature = "streaming")] - { - let streaming = app.lock().await.streaming_player.clone(); - if let Some(player) = streaming { - player.pause(); - } - } - - // The other decoded sources never see this file:// start (the pump's - // `!handled_locally` short-circuit), so their sessions are torn down here. - let players = app.lock().await.take_decoded_sessions_except(Source::Local); - for player in players { - player.stop_detached(); - } - - let player = match acquire_player(app).await { - Some(player) => player, - None => return, // error already surfaced - }; - - // Tag reading and decoder construction are blocking file I/O — keep them off - // the async executor. - let decode_path = path.clone(); - let decode_player = Arc::clone(&player); - let result = tokio::task::spawn_blocking(move || { - let info = track_info_from_path(&decode_path); - decode_player.play_file(&decode_path).map(|()| info) - }) - .await; - - match result { - Ok(Ok(info)) => { - let volume = app.lock().await.runtime_state.volume_percent; - player.set_volume(volume); - - // Publish the session exactly once, now that the source is decoding. - // Publish-once covers the empty-sink race here: `local_playback` is `None` - // throughout the decode above, so `advancing` starts `false`. - let display_name = info.name.clone(); - let mut app = app.lock().await; - let mut state = LocalPlaybackState { - player, - queue, - index, - name: info.name, - artists: info.artists.join(", "), - album: info.album, - duration_ms: info.duration_ms, - advancing: false, - shuffle_backup: None, - failed_since_played: std::collections::HashSet::new(), - resume_at: None, - }; - // Honor the player-global decoded shuffle for the freshly-built queue so it - // persists across contexts like Spotify (the just-decoded start track stays - // playing, moved to the front). - if app.decoded_shuffle { - state.set_shuffle(true); - } - app.local_playback = Some(state); - app.set_status_message(format!("\u{266a} {display_name}"), 4); - } - Ok(Err(e)) => set_error(app, format!("Cannot play local file: {e}")).await, - Err(e) => set_error(app, format!("Local playback task failed: {e}")).await, - } -} - -/// Decode and stage a single local file on `player`, returning its metadata; -/// the caller starts it. Extracted so the native queue engine can play a -/// one-off `file://` track on a borrowed player without touching -/// `local_playback`. Mirrors the core of [`play_index`] (tag read + blocking -/// decode) with no session bookkeeping. -#[cfg_attr(not(feature = "local-files"), allow(dead_code))] -pub(crate) async fn stage_single_file( - player: &Arc, - uri: &str, -) -> anyhow::Result { - let path = file_uri_to_path(uri)?; - let decode_player = Arc::clone(player); - let info = tokio::task::spawn_blocking(move || { - let info = track_info_from_path(&path); - decode_player.stage_file(&path).map(|()| info) - }) - .await??; - Ok(info) -} - -/// Play the queued track at `target`, reusing the already-published session's -/// player and queue. Used by Next/Previous and the runner tick's auto-advance. -/// -/// The index is committed to `target` in **both** the success and failure arms: -/// on a decode failure the sink drains and the runner tick auto-advances from -/// `target` to the *following* track, so a single corrupt file is skipped past -/// rather than retried forever. `advancing` is cleared in both arms once the new -/// source is in the sink (or the play failed), reopening auto-advance. -pub(crate) async fn play_index(app: &Arc>, target: usize) { - // Snapshot the player + URI under a short lock. - let (player, uri, resume) = { - let mut guard = app.lock().await; - let Some(local) = guard.local_playback.as_mut() else { - return; // session torn down between dispatch and here - }; - match local.queue.get(target) { - Some(uri) => ( - Arc::clone(&local.player), - uri.clone(), - local.resume_at.take(), - ), - None => { - // Out of range — nothing to play. The caller (skip/auto-advance) - // optimistically set `advancing`; clear it here so this dead-end does - // not wedge auto-advance off for the rest of the session. - local.advancing = false; - return; - } - } - }; - - let path = match file_uri_to_path(&uri) { - Ok(path) => path, - Err(e) => { - // Commit the index so the tick advances past this entry, then surface. - fail_index(app, target, format!("Invalid local file URI: {e}")).await; - return; - } - }; - - // Blocking tag read + decode off the executor. - let decode_path = path.clone(); - let decode_player = Arc::clone(&player); - let result = tokio::task::spawn_blocking(move || { - let info = track_info_from_path(&decode_path); - restage(&decode_player, &decode_path, resume).map(|()| info) - }) - .await; - - match result { - Ok(Ok(info)) => { - let display_name = info.name.clone(); - commit_index(app, target, info).await; - app - .lock() - .await - .set_status_message(format!("\u{266a} {display_name}"), 4); - } - Ok(Err(e)) => { - // `stage_file` clears the sink before any fallible step, so on a normal - // decode/open error the sink is already empty and the runner tick - // auto-advances past this bad track. Stop the player anyway as - // defense-in-depth: it is idempotent and guarantees no stale audio even if - // the blocking closure failed *before* reaching `stage_file`. - Arc::clone(&player).stop_detached(); - fail_index(app, target, format!("Cannot play local file: {e}")).await; - } - Err(e) => { - // The blocking task panicked (e.g. inside the tag read) *before* - // `stage_file` could clear the sink, so the old track may still be playing. - // Stop it explicitly so the empty sink lets the tick auto-advance instead - // of dead-ending on a stale track with a silently moved index. - Arc::clone(&player).stop_detached(); - fail_index(app, target, format!("Local playback task failed: {e}")).await; - } - } -} - -/// Commit `target` as the live index and clear the auto-advance guard. On a -/// successful play, also refresh the displayed track metadata; on failure leave -/// the previous metadata in place (the empty sink + moved index lets the tick -/// carry on past the bad track). -/// -async fn commit_index( - app: &Arc>, - target: usize, - info: crate::core::plugin_api::TrackInfo, -) { - let mut guard = app.lock().await; - if let Some(local) = guard.local_playback.as_mut() { - local.index = target; - local.advancing = false; - local.failed_since_played.clear(); - local.name = info.name; - local.artists = info.artists.join(", "); - local.album = info.album; - local.duration_ms = info.duration_ms; - } -} - -/// Record a failed play of `target`: move the index on and clear the -/// auto-advance guard so the tick carries past the bad track (the empty sink + -/// moved index), leave the previous metadata in place, and surface `msg`. -/// -/// Once *every* track in the queue has failed since the last successful play the -/// session ends instead — see -/// [`failed_since_played`](LocalPlaybackState::failed_since_played). -/// -/// That teardown takes the session out under the **same lock** that observed the -/// exhaustion, and leaves `advancing` latched rather than clearing it. Clearing -/// the guard and releasing the lock first would leave a window where a runner -/// tick sees a live session with an empty sink, decides `AdvanceContext`, and -/// dispatches `NextTrack`; by the time that event is routed the session is gone, -/// so it falls through to Spotify and skips a track on the user's real device. -async fn fail_index(app: &Arc>, target: usize, msg: String) { - let exhausted = { - let mut guard = app.lock().await; - let Some(local) = guard.local_playback.as_mut() else { - return; - }; - local.index = target; - local.failed_since_played.insert(target); - if local.failed_since_played.len() < local.queue.len() { - local.advancing = false; - None - } else { - guard.local_playback.take() - } - }; - - match exhausted { - Some(local) => { - Arc::clone(&local.player).stop_detached_holding(local); - set_error(app, format!("{msg} — no playable tracks left, stopping")).await; - } - None => set_error(app, msg).await, - } -} - -/// Reuse the live player if a local file is already playing, otherwise open the -/// output device for a fresh one. A freshly opened player is **not** published -/// to `App` here — [`start_local_queue`] publishes it only on success, so there -/// is no window where `local_playback` is `Some` with an empty sink. -async fn acquire_player(app: &Arc>) -> Option> { - if let Some(player) = player(app).await { - return Some(player); - } - - match tokio::task::spawn_blocking(LocalPlayer::new).await { - Ok(Ok(player)) => Some(Arc::new(player)), - Ok(Err(e)) => { - set_error(app, format!("No audio output for local playback: {e}")).await; - None - } - Err(e) => { - set_error(app, format!("Audio output init failed: {e}")).await; - None - } - } -} - -/// End the local session, releasing the output device. -async fn teardown_local(app: &Arc>) { - // Take under the lock, stop off it: a sink clear waits for the audio thread. - let session = app.lock().await.local_playback.take(); - if let Some(local) = session { - // If `local` held the last reference the keepalive thread exits and the - // output device is released. - Arc::clone(&local.player).stop_detached_holding(local); - } -} - -async fn set_error(app: &Arc>, message: String) { - app.lock().await.set_status_message(message, 6); -} - -/// The configured music-library root, or `None` (with a status message) if it -/// is unset. -async fn music_root(app: &Arc>) -> Option { - let root = app - .lock() - .await - .user_config - .behavior - .local_music_path - .clone(); - if root.is_none() { - set_error( - app, - "No local music folder configured (set behavior.local_music_path)".to_string(), - ) - .await; - } - root -} - -/// Scan the music root's folders into `app.local_playlists`. -/// -/// `LocalSource`'s methods are async but do blocking filesystem I/O, so they run -/// on the blocking pool (via `block_on`) rather than stalling the executor. -async fn load_local_playlists(app: &Arc>) { - let Some(root) = music_root(app).await else { - return; - }; - let result = tokio::task::spawn_blocking(move || { - futures::executor::block_on(LocalSource::new(root).playlists()) - }) - .await; - match result { - Ok(Ok(playlists)) => { - let mut app = app.lock().await; - app.local_playlists = playlists; - app.view.local_playlists_index = 0; - } - Ok(Err(e)) => set_error(app, format!("Cannot scan music folder: {e}")).await, - Err(e) => set_error(app, format!("Local folder scan failed: {e}")).await, - } -} - -/// Scan a folder's audio files into the shared track table (tagged as -/// [`TrackTableContext::LocalPlaylist`] so selecting a row plays the file). -async fn load_local_tracks(app: &Arc>, playlist_uri: &str) { - let Some(root) = music_root(app).await else { - return; - }; - let uri = playlist_uri.to_string(); - let result = tokio::task::spawn_blocking(move || { - futures::executor::block_on(LocalSource::new(root).tracks(&uri)) - }) - .await; - match result { - Ok(Ok(tracks)) => { - let mut app = app.lock().await; - app.set_track_table(tracks, TrackTableContext::LocalPlaylist); - } - Ok(Err(e)) => set_error(app, format!("Cannot read folder: {e}")).await, - Err(e) => set_error(app, format!("Local track scan failed: {e}")).await, - } -} diff --git a/src/infra/local/mod.rs b/src/infra/local/mod.rs deleted file mode 100644 index 58e98c72..00000000 --- a/src/infra/local/mod.rs +++ /dev/null @@ -1,1222 +0,0 @@ -//! Local-files media source — scan a music directory and expose its audio -//! files via the multi-source [`MediaSource`] and [`Streamer`] traits. -//! -//! Gated behind the `local-files` Cargo feature. Nothing in the main dispatch -//! layer calls this yet; it will be wired in Phase 3 of the multi-source -//! refactor. - -// Not yet wired into dispatch / UI. -#![allow(dead_code)] - -pub mod dispatch; - -use std::collections::HashSet; -use std::path::{Path, PathBuf}; -use std::sync::Arc; - -use crate::infra::audio::LocalPlayer; - -/// The active local-file playback session. -/// -/// Holds the live [`LocalPlayer`], an ordered **queue** of track URIs plus the -/// **current index**, and the *static* metadata of the track at that index. -/// Dynamic state (position, paused) is read live from `player` at render time, -/// so it is never mirrored into — and therefore never desynced from — -/// Spotify/librespot state fields. `App` holds this in a single `Option`: -/// `Some` exactly while a local file owns the playback session. -/// -/// The queue + index are the *single source of truth* for which local track is -/// playing; Next/Previous/auto-advance all move `index` and re-`play_file`. -pub struct LocalPlaybackState { - pub player: Arc, - /// `file://` URIs for every track in the playing folder, in scan order. - pub queue: Vec, - /// Index into [`queue`](Self::queue) of the currently playing track. - pub index: usize, - pub name: String, - /// Display string of the joined artist names. - pub artists: String, - pub album: String, - pub duration_ms: u64, - /// Set while a track change (auto-advance *or* a manual Next/Previous) is - /// decoding. The runner tick fires far faster than the dispatched track - /// change is processed, so without this guard the empty sink between "track - /// ended / cleared" and "next source appended" would dispatch `NextTrack` - /// every tick and skip several tracks per change. Same class of guard as the - /// "don't treat the pre-playback empty sink as end-of-track" invariant: it - /// covers the analogous mid-change decode window. - pub advancing: bool, - /// Backup of the pre-shuffle queue order while shuffle is on (`None` when the - /// queue is in natural order). Set by [`set_shuffle(true)`](Self::set_shuffle) - /// and consumed by `set_shuffle(false)` to restore order + index exactly. - pub shuffle_backup: Option, - /// Indices of the tracks that have failed to play since the last successful - /// one, bounding the deliberate "skip past an unplayable file" behavior. - /// - /// Local playback leaves the session alive on a play failure so the tick moves - /// past the bad track. That self-terminates under `RepeatMode::Off` (the last - /// track has no next, so `advance_decision` reaches `Decision::Teardown`), but - /// `RepeatMode::Context` wraps forever and never yields `Teardown` — so with an - /// entirely unplayable queue (an unmounted share, an ejected drive) the tick - /// would re-fail every track at tick rate with no terminating state. Cleared on - /// every successful play; once it covers the whole queue there is nothing left - /// to play and the session is torn down instead. Same class of bounded guard as - /// `App::spotify_queue_guard_reloads`. - /// - /// Distinct indices rather than a failure count: a count would also trip on a - /// user skipping back and forth across the same two bad tracks, tearing down a - /// session whose remaining tracks play fine. - pub failed_since_played: std::collections::HashSet, - /// A seek and pause to apply when the next track is staged (device - /// recovery, the native queue's resume). - pub resume_at: Option, -} - -impl LocalPlaybackState { - /// Turn in-place shuffle on or off for this session — see - /// [`toggle_shuffle`](crate::infra::queue::toggle_shuffle) for the shared - /// semantics (current track stays playing at the front; un-shuffle restores - /// order + index; idempotent). - pub fn set_shuffle(&mut self, on: bool) { - // The permutation moves every track, so remembered failures no longer name - // the tracks that failed. - self.failed_since_played.clear(); - crate::infra::queue::toggle_shuffle( - &mut self.queue, - &mut self.index, - &mut self.shuffle_backup, - on, - ); - } -} - -use anyhow::{Context, Result}; -use lofty::file::TaggedFileExt; -use lofty::prelude::*; -use lofty::read_from_path; -use url::Url; - -use crate::core::plugin_api::{ArtistRef, PlaylistInfo, TrackInfo}; -use crate::core::source::{MediaSource, Streamer}; - -// --------------------------------------------------------------------------- -// Audio-file extension filter -// --------------------------------------------------------------------------- - -/// File extensions recognised as audio files. -/// -/// Restricted to formats the default rodio decoder can actually play -/// (FLAC / MP3 / MP4-AAC / Ogg-Vorbis / WAV — see [`LocalPlayer::play_file`]). -/// Extensions the decoder rejects at playback time are deliberately excluded so -/// the library never lists a file that would fail to decode: -/// - `opus` — rodio's `vorbis` feature decodes Ogg-*Vorbis* only; there is no -/// Opus codec in the build, so `.opus` files probe but fail to decode. -/// - `aiff` / `aif` / `wv` (WavPack) / `ape` (Monkey's Audio) — no decoder in -/// the default feature set. -const AUDIO_EXTENSIONS: &[&str] = &["mp3", "flac", "ogg", "m4a", "aac", "wav"]; - -fn is_audio_file(path: &Path) -> bool { - path - .extension() - .and_then(|e| e.to_str()) - .map(|e| { - AUDIO_EXTENSIONS - .iter() - .any(|&ext| e.eq_ignore_ascii_case(ext)) - }) - .unwrap_or(false) -} - -// --------------------------------------------------------------------------- -// LocalSource -// --------------------------------------------------------------------------- - -/// A media source that exposes local audio files rooted at a directory. -/// -/// Every folder at or below `root` that directly holds audio becomes a -/// playlist, named by its path relative to `root`, down to [`MAX_SCAN_DEPTH`] -/// levels. When audio files sit directly in `root`, a synthetic `"(root)"` -/// playlist is also returned as the first entry so those loose files are -/// browsable. -pub struct LocalSource { - root: PathBuf, -} - -impl LocalSource { - /// Create a [`LocalSource`] for `root`. The path is **not** validated at - /// construction time so this function is always cheap and infallible. - pub fn new(root: impl Into) -> Self { - LocalSource { root: root.into() } - } - - /// The root directory this source is scanning. - pub fn root(&self) -> &Path { - &self.root - } -} - -// --------------------------------------------------------------------------- -// MediaSource implementation -// --------------------------------------------------------------------------- - -impl MediaSource for LocalSource { - fn name(&self) -> &str { - "Local Files" - } - - fn scheme(&self) -> &str { - "file" - } - - /// Return one [`PlaylistInfo`] per directory *at or below* `root` that - /// directly contains at least one audio file, plus a synthetic `"(root)"` - /// entry when the music root itself directly contains one. - /// - /// The scan is recursive because a music library is commonly two levels deep - /// (`Artist/Album/…`, or a downloader's `Jamendo/Playlist - Fresh & New/…`): - /// listing only the immediate subdirectories showed those container folders - /// with a `0` track count and an empty track table when selected. - /// - /// The playlist `uri` is `file://`, the `name` is the directory's - /// path relative to `root` joined with `/` (`"Jamendo/Playlist - Fresh & New"`), - /// and `track_count` is the number of audio files directly inside that - /// directory, counted by the rule [`tracks`](Self::tracks) lists them by - /// (see [`classify`]). Directories that only *contain* other directories are - /// not listed — there is nothing to play in them. - /// - /// The walk stops [`MAX_SCAN_DEPTH`] levels below `root`. A linked folder is - /// walked once, and a folder that cannot be read is skipped with a warning. - /// Only an unreadable `root` is an error. - /// - /// The `"(root)"` entry, when present, is always placed first so loose files - /// at the root are easy to find. - /// - /// TODO(multi-source): move std::fs::read_dir calls into tokio::task::spawn_blocking - /// or tokio::fs before wiring — blocking I/O on the async executor stalls - /// the Tokio runtime under slow/remote filesystems. - async fn playlists(&self) -> Result> { - // An unreadable root stays fatal; the walk skips unreadable folders. - std::fs::read_dir(&self.root).with_context(|| format!("reading music root {:?}", self.root))?; - - let mut dirs: Vec<(PathBuf, u32)> = Vec::new(); - collect_audio_dirs(&self.root, 0, &mut HashSet::new(), &mut dirs); - - // The root's own entry becomes the synthetic "(root)" sentinel below. - let root_audio_count = dirs - .iter() - .position(|(path, _)| path == &self.root) - .map_or(0, |i| dirs.remove(i).1); - - let mut playlists: Vec = dirs - .into_iter() - .map(|(path, track_count)| PlaylistInfo { - name: relative_display_name(&self.root, &path), - uri: path_to_file_uri(&path), - owner: "local".to_string(), - track_count, - id: None, - collaborative: false, - public: None, - image_url: None, - owner_id: None, - }) - .collect(); - - // Sort alphabetically for stable ordering across runs. The relative names - // carry `/`, so a parent folder's playlists stay grouped together. - playlists.sort_by(|a, b| a.name.cmp(&b.name)); - - // Prepend a synthetic "(root)" entry when the root directory itself - // contains at least one audio file, so loose files are browsable. - // Note: a user-created subdirectory literally named "(root)" would result - // in two entries with the same display name but different URIs; this is - // unlikely in practice and acceptable given the clear "(root)" sentinel. - if root_audio_count > 0 { - playlists.insert( - 0, - PlaylistInfo { - uri: path_to_file_uri(&self.root), - name: "(root)".to_string(), - owner: "local".to_string(), - track_count: root_audio_count, - id: None, - collaborative: false, - public: None, - image_url: None, - owner_id: None, - }, - ); - } - - Ok(playlists) - } - - /// Return one [`TrackInfo`] per audio file in the directory identified by - /// `playlist_uri`. - /// - /// `playlist_uri` must have been produced by [`LocalSource::playlists`] - /// (i.e. `file://`). Tags are read via `lofty`; missing fields - /// fall back to the file name / empty string. - /// - /// TODO(multi-source): move blocking lofty tag-reads into tokio::task::spawn_blocking - /// before wiring — lofty::read_from_path does synchronous file I/O and will - /// stall the Tokio runtime for large directories. - async fn tracks(&self, playlist_uri: &str) -> Result> { - let dir_path = file_uri_to_path(playlist_uri) - .with_context(|| format!("parsing playlist URI: {playlist_uri}"))?; - - let entries = std::fs::read_dir(&dir_path) - .with_context(|| format!("reading playlist dir {:?}", dir_path))?; - - let mut tracks = Vec::new(); - for entry in entries { - let entry = entry.context("reading directory entry")?; - if let Some(ScanEntry::Audio(path)) = classify(&entry) { - tracks.push(track_info_from_path(&path)); - } - } - - // Sort by track_number, then by name. - // track_number = 0 means "no tag" — sort those after all explicitly - // numbered tracks (so track 1, 2, ... N appear before untagged files). - tracks.sort_by(|a, b| { - let key = |n: u32| if n == 0 { u32::MAX } else { n }; - key(a.track_number) - .cmp(&key(b.track_number)) - .then(a.name.cmp(&b.name)) - }); - Ok(tracks) - } -} - -// --------------------------------------------------------------------------- -// Streamer skeleton -// --------------------------------------------------------------------------- - -impl Streamer for LocalSource { - /// Open the audio file at `uri` and construct a symphonia decoder to prove - /// the decode pipeline is reachable. - /// - /// The decoded frames are NOT yet routed anywhere — the rodio sink wiring - /// lives in `src/infra/player/streaming.rs` and is out of scope for this - /// slice. - /// - /// TODO(multi-source): route decoded frames into the shared sink (Phase 3 wiring) - /// TODO(multi-source): move blocking file I/O into tokio::task::spawn_blocking - /// before wiring — std::fs::File::open and symphonia probing are synchronous - /// and will stall the Tokio executor thread on slow/remote filesystems. - async fn stream(&self, uri: &str) -> Result<()> { - use symphonia::core::codecs::audio::AudioDecoderOptions; - use symphonia::core::formats::probe::Hint; - use symphonia::core::formats::{FormatOptions, TrackType}; - use symphonia::core::io::MediaSourceStream; - use symphonia::core::meta::MetadataOptions; - - let path = - file_uri_to_path(uri).with_context(|| format!("parsing track URI for streaming: {uri}"))?; - - let file = - std::fs::File::open(&path).with_context(|| format!("opening audio file {:?}", path))?; - - let mss = MediaSourceStream::new(Box::new(file), Default::default()); - - let mut hint = Hint::new(); - if let Some(ext) = path.extension().and_then(|e| e.to_str()) { - hint.with_extension(ext); - } - - let format = symphonia::default::get_probe() - .probe( - &hint, - mss, - FormatOptions::default(), - MetadataOptions::default(), - ) - .context("probing audio format")?; - - // Find the first audio track with a known codec. - let track = format - .default_track(TrackType::Audio) - .context("no audio tracks found in file")?; - - let _decoder = symphonia::default::get_codecs() - .make_audio_decoder( - track - .codec_params - .as_ref() - .context("codec parameters missing")? - .audio() - .context("track is not an audio codec")?, - &AudioDecoderOptions::default(), - ) - .context("constructing audio decoder")?; - - // TODO(multi-source): route decoded frames into the shared sink (Phase 3 wiring) - Ok(()) - } -} - -// --------------------------------------------------------------------------- -// Tag-reading helpers -// --------------------------------------------------------------------------- - -/// Build a [`TrackInfo`] by reading ID3/Vorbis/etc. tags from `path` via -/// `lofty`. All fields fall back gracefully when tags are absent. -fn track_info_from_path(path: &Path) -> TrackInfo { - let uri = path_to_file_uri(path); - - // Default fallback name: the filename without extension. - let fallback_name = path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("Unknown Track") - .to_string(); - - // Try to read tags; if lofty fails, return minimal metadata. - let tagged = match read_from_path(path) { - Ok(t) => t, - Err(_) => { - return TrackInfo { - uri: Some(uri), - name: fallback_name, - artists: Vec::new(), - album: String::new(), - duration_ms: 0, - id: None, - album_id: None, - artist_refs: Vec::new(), - is_playable: true, - is_local: true, - track_number: 0, - explicit: false, - // Cover art for local files is read from the file's embedded picture on - // demand (see `extract_embedded_cover`), not carried as a URL here. - image_url: None, - }; - } - }; - - // Duration from audio properties. - let duration_ms = tagged.properties().duration().as_millis() as u64; - - // Prefer the primary tag; fall back to any tag. - let tag = tagged.primary_tag().or_else(|| tagged.first_tag()); - - let (name, artist_name, album, track_number) = if let Some(t) = tag { - let name = t.title().map(|s| s.to_string()).unwrap_or(fallback_name); - let artist = t.artist().map(|s| s.to_string()).unwrap_or_default(); - let album = t.album().map(|s| s.to_string()).unwrap_or_default(); - let track_number = t.track().unwrap_or(0); - (name, artist, album, track_number) - } else { - (fallback_name, String::new(), String::new(), 0) - }; - - // Split the combined artist string on common separators to produce a list. - let artists: Vec = if artist_name.is_empty() { - Vec::new() - } else { - split_artists(&artist_name) - }; - - let artist_refs: Vec = artists - .iter() - .map(|name| ArtistRef { - id: None, - name: name.clone(), - }) - .collect(); - - TrackInfo { - uri: Some(uri), - name, - artists, - album, - duration_ms, - id: None, - album_id: None, - artist_refs, - is_playable: true, - is_local: true, - track_number, - explicit: false, - image_url: None, - } -} - -/// Split an artist string on `";"`, `" / "`, or `" & "` — common multi-artist -/// separators in music tags. Returns a single-element vec when there is no -/// recognised separator. -/// -/// Note: bare `"/"` (without surrounding spaces) is intentionally **not** used -/// as a separator because it appears inside single-artist names such as "AC/DC". -/// The spaced form `" / "` is the separator recommended by MusicBrainz for -/// multi-artist fields and is much less likely to appear within a single name. -fn split_artists(artist: &str) -> Vec { - // Try each separator in preference order. - for sep in &[";", " / ", " & "] { - if artist.contains(sep) { - return artist - .split(sep) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); - } - } - vec![artist.to_string()] -} - -// --------------------------------------------------------------------------- -// URI helpers -// --------------------------------------------------------------------------- - -/// Convert an absolute [`Path`] to a percent-encoded `file://` URI string. -/// -/// Uses [`url::Url::from_file_path`] so that spaces, non-ASCII characters, and -/// non-UTF-8 byte sequences are all correctly percent-encoded. The `url` crate -/// is already a direct dependency of this crate. -/// -/// Falls back to a raw `format!("file://{}", path.to_string_lossy())` on paths -/// that `from_file_path` rejects (e.g. truly relative paths), but those should -/// not reach this function in normal operation. -fn path_to_file_uri(path: &Path) -> String { - Url::from_file_path(path) - .map(|u| u.to_string()) - .unwrap_or_else(|_| { - // Fallback: should not be reached for absolute paths from read_dir. - format!("file://{}", path.to_string_lossy()) - }) -} - -/// Parse a `file://` URI back to a [`PathBuf`]. -/// -/// Accepts both percent-encoded URIs (produced by [`path_to_file_uri`]) and -/// bare `file://` URIs for compatibility. -pub(crate) fn file_uri_to_path(uri: &str) -> Result { - let url = Url::parse(uri).with_context(|| format!("invalid URI: {uri}"))?; - url - .to_file_path() - .map_err(|_| anyhow::anyhow!("URI is not a valid file:// path: {uri}")) -} - -/// Decode the first embedded cover-art picture from a local audio file into an -/// image, for the cover-art pane. Reads tags via `lofty` (synchronous file I/O -/// and image decode), so callers must run this on a blocking thread rather than -/// the async runtime. Returns an error when the file has no embedded artwork. -/// -/// Gated on both `local-files` (for `lofty`) and `art-decode` (for `image`), so -/// builds enabling only one of them do not pull in the other's dependency. -#[cfg(feature = "art-decode")] -pub(crate) fn extract_embedded_cover(path: &Path) -> Result { - let tagged = read_from_path(path).with_context(|| format!("reading tags from {:?}", path))?; - let tag = tagged - .primary_tag() - .or_else(|| tagged.first_tag()) - .context("file has no tags")?; - let picture = tag - .pictures() - .first() - .context("file has no embedded cover art")?; - image::load_from_memory(picture.data()).context("decoding embedded cover art") -} - -/// How many directory levels below the music root the scan descends: the -/// backstop behind the visited set in [`collect_audio_dirs`]. -const MAX_SCAN_DEPTH: usize = 8; - -/// Whether a directory entry is hidden (`.Trash`, `.DS_Store`, an AppleDouble -/// `._track.mp3`) and should be skipped by the scan. -fn is_hidden(entry: &std::fs::DirEntry) -> bool { - entry.file_name().to_string_lossy().starts_with('.') -} - -/// What the scan makes of one directory entry. -enum ScanEntry { - /// A playable file, linked or not. - Audio(PathBuf), - /// A folder, linked or not. - Dir(PathBuf), -} - -/// The one entry policy shared by the listing and the track table, so a -/// folder's `track_count` always matches the rows `tracks` returns for it: -/// hidden entries are skipped, and a symlink or junction is looked through -/// once, so a linked track plays and a linked folder is walked. -fn classify(entry: &std::fs::DirEntry) -> Option { - if is_hidden(entry) { - return None; - } - let file_type = entry.file_type().ok()?; - let path = entry.path(); - let (is_dir, is_file) = if file_type.is_symlink() { - let target = std::fs::metadata(&path).ok()?; - (target.is_dir(), target.is_file()) - } else { - (file_type.is_dir(), file_type.is_file()) - }; - if is_dir { - Some(ScanEntry::Dir(path)) - } else if is_file && is_audio_file(&path) { - Some(ScanEntry::Audio(path)) - } else { - None - } -} - -/// A directory's path relative to `root`, joined with `/` for display -/// (`"Jamendo/Playlist - Fresh & New"`). Falls back to the full path if `path` -/// is not below `root`, which cannot happen for paths produced by the scan. -fn relative_display_name(root: &Path, path: &Path) -> String { - path - .strip_prefix(root) - .unwrap_or(path) - .components() - .map(|c| c.as_os_str().to_string_lossy()) - .collect::>() - .join("/") -} - -/// Walk `dir` and everything below it, pushing `(path, count)` for every -/// directory that directly contains at least one audio file. -/// -/// A linked folder is walked like any other: `seen` holds the canonical path -/// of every folder visited, so a link loop is walked once, and -/// [`MAX_SCAN_DEPTH`] bounds the descent. A folder that cannot be read is -/// skipped with a warning rather than failing the whole scan: one unreadable -/// folder must not hide the rest of the library. -fn collect_audio_dirs( - dir: &Path, - depth: usize, - seen: &mut HashSet, - out: &mut Vec<(PathBuf, u32)>, -) { - let canonical = match std::fs::canonicalize(dir) { - Ok(canonical) => canonical, - Err(e) => { - log::warn!("[local] skipping folder {}: {e}", dir.display()); - return; - } - }; - if !seen.insert(canonical) { - return; - } - let entries = match std::fs::read_dir(dir) { - Ok(entries) => entries, - Err(e) => { - log::warn!("[local] skipping unreadable folder {}: {e}", dir.display()); - return; - } - }; - - let mut audio_count = 0u32; - let mut subdirs = Vec::new(); - for entry in entries { - let entry = match entry { - Ok(entry) => entry, - Err(e) => { - log::warn!("[local] skipping an entry of {}: {e}", dir.display()); - continue; - } - }; - match classify(&entry) { - Some(ScanEntry::Audio(_)) => audio_count += 1, - Some(ScanEntry::Dir(path)) => subdirs.push(path), - None => {} - } - } - - if audio_count > 0 { - out.push((dir.to_path_buf(), audio_count)); - } - // Sorted, so a folder and a link to it dedupe on the same path every run. - subdirs.sort(); - if depth < MAX_SCAN_DEPTH { - for sub in subdirs { - collect_audio_dirs(&sub, depth + 1, seen, out); - } - } else if !subdirs.is_empty() { - log::warn!( - "[local] not scanning below {}: it is {MAX_SCAN_DEPTH} levels under the music root", - dir.display() - ); - } -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Write; - - // --------------------------------------------------------------------------- - // Helpers - // --------------------------------------------------------------------------- - - /// Write a minimal valid WAV file (44-byte header + silence). - /// - /// Symphonia can probe and decode this without external data. - fn write_wav(path: &Path, sample_rate: u32, num_samples: u32) { - let data_size = num_samples * 2; // 16-bit mono - let file_size = 36 + data_size; - - let mut f = std::fs::File::create(path).unwrap(); - // RIFF header - f.write_all(b"RIFF").unwrap(); - f.write_all(&file_size.to_le_bytes()).unwrap(); - f.write_all(b"WAVE").unwrap(); - // fmt chunk - f.write_all(b"fmt ").unwrap(); - f.write_all(&16u32.to_le_bytes()).unwrap(); // chunk size - f.write_all(&1u16.to_le_bytes()).unwrap(); // PCM - f.write_all(&1u16.to_le_bytes()).unwrap(); // mono - f.write_all(&sample_rate.to_le_bytes()).unwrap(); - f.write_all(&(sample_rate * 2).to_le_bytes()).unwrap(); // byte rate - f.write_all(&2u16.to_le_bytes()).unwrap(); // block align - f.write_all(&16u16.to_le_bytes()).unwrap(); // bits per sample - // data chunk - f.write_all(b"data").unwrap(); - f.write_all(&data_size.to_le_bytes()).unwrap(); - f.write_all(&vec![0u8; data_size as usize]).unwrap(); - } - - // --------------------------------------------------------------------------- - // URI round-trip - // --------------------------------------------------------------------------- - - #[test] - fn uri_round_trip() { - let path = PathBuf::from("/home/user/music/track.mp3"); - let uri = path_to_file_uri(&path); - assert_eq!(uri, "file:///home/user/music/track.mp3"); - let back = file_uri_to_path(&uri).unwrap(); - assert_eq!(back, path); - } - - #[test] - fn file_uri_to_path_rejects_non_file_uri() { - assert!(file_uri_to_path("spotify:track:abc").is_err()); - } - - // --------------------------------------------------------------------------- - // is_audio_file - // --------------------------------------------------------------------------- - - #[test] - fn audio_extensions_are_recognised() { - // Only formats the default rodio decoder can actually play. - for ext in ["mp3", "flac", "ogg", "m4a", "aac", "wav"] { - let p = PathBuf::from(format!("track.{ext}")); - assert!(is_audio_file(&p), "expected {ext} to be audio"); - } - } - - #[test] - fn non_audio_extensions_are_rejected() { - for name in ["image.jpg", "cover.png", "README.md", "playlist.m3u"] { - let p = PathBuf::from(name); - assert!(!is_audio_file(&p), "expected {name} not to be audio"); - } - } - - /// The whitelist must not offer files the default rodio decoder rejects at - /// playback time. Listing an undecodable extension makes `play_file` fail - /// mid-queue; guarding the whitelist keeps such files out of the library so a - /// manual Next/Previous can never land on one. See `AUDIO_EXTENSIONS`. - #[test] - fn undecodable_extensions_are_not_whitelisted() { - // rodio's default features decode Ogg-Vorbis, not Opus; the rest have no - // decoder in the build at all. - for ext in ["opus", "aiff", "aif", "wv", "ape"] { - assert!( - !AUDIO_EXTENSIONS.contains(&ext), - "{ext} is not decodable by the default rodio build and must be excluded" - ); - assert!( - !is_audio_file(&PathBuf::from(format!("track.{ext}"))), - "an undecodable {ext} file must not be treated as audio" - ); - } - - // The formats the decoder *does* support must still be present. - for ext in ["mp3", "flac", "ogg", "m4a", "aac", "wav"] { - assert!( - AUDIO_EXTENSIONS.contains(&ext), - "{ext} is decodable and must stay whitelisted" - ); - } - } - - #[test] - fn audio_extension_check_is_case_insensitive() { - assert!(is_audio_file(&PathBuf::from("TRACK.MP3"))); - assert!(is_audio_file(&PathBuf::from("Album.FLAC"))); - } - - // --------------------------------------------------------------------------- - // split_artists - // --------------------------------------------------------------------------- - - #[test] - fn split_artists_semicolon() { - let parts = split_artists("Alice;Bob;Carol"); - assert_eq!(parts, vec!["Alice", "Bob", "Carol"]); - } - - #[test] - fn split_artists_ampersand() { - let parts = split_artists("Alice & Bob"); - assert_eq!(parts, vec!["Alice", "Bob"]); - } - - #[test] - fn split_artists_spaced_slash() { - let parts = split_artists("Alice / Bob"); - assert_eq!(parts, vec!["Alice", "Bob"]); - } - - #[test] - fn split_artists_bare_slash_is_not_a_separator() { - // Band names like "AC/DC" must NOT be split on bare '/'. - let parts = split_artists("AC/DC"); - assert_eq!( - parts, - vec!["AC/DC"], - "bare slash in a name should not split" - ); - } - - #[test] - fn split_artists_single() { - let parts = split_artists("Alice"); - assert_eq!(parts, vec!["Alice"]); - } - - // --------------------------------------------------------------------------- - // playlists() — directory scan - // --------------------------------------------------------------------------- - - #[test] - fn playlists_returns_one_entry_per_subdir() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - - // Create in reverse alphabetical order to verify that the sort actually fires. - // Each needs an audio file: a folder with nothing to play is not a playlist. - std::fs::create_dir(root.join("Rock")).unwrap(); - write_wav(&root.join("Rock/riff.wav"), 44100, 100); - std::fs::create_dir(root.join("Jazz")).unwrap(); - write_wav(&root.join("Jazz/blue.wav"), 44100, 100); - // A loose audio file in root triggers the synthetic "(root)" entry. - std::fs::File::create(root.join("stray.mp3")).unwrap(); - - let src = LocalSource::new(root); - let playlists = tokio::runtime::Runtime::new() - .unwrap() - .block_on(src.playlists()) - .unwrap(); - - let names: Vec<&str> = playlists.iter().map(|p| p.name.as_str()).collect(); - assert_eq!( - names, - vec!["(root)", "Jazz", "Rock"], - "synthetic (root) entry should come first; subdirs sorted alphabetically" - ); - - for pl in &playlists { - assert_eq!(pl.owner, "local"); - assert!(pl.uri.starts_with("file://"), "uri should be a file:// URI"); - } - } - - // --------------------------------------------------------------------------- - // playlists() — synthetic (root) entry - // --------------------------------------------------------------------------- - - #[test] - fn playlists_adds_root_entry_when_root_has_loose_audio_files() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - - // A subdirectory (for variety) and a loose audio file directly in root. - std::fs::create_dir(root.join("Albums")).unwrap(); - write_wav(&root.join("loose.wav"), 44100, 100); - - let src = LocalSource::new(root); - let playlists = tokio::runtime::Runtime::new() - .unwrap() - .block_on(src.playlists()) - .unwrap(); - - // "(root)" must be present and first. - assert!( - !playlists.is_empty(), - "expected at least one playlist entry" - ); - let first = &playlists[0]; - assert_eq!( - first.name, "(root)", - "first entry must be the synthetic root" - ); - assert_eq!( - first.uri, - path_to_file_uri(root), - "root entry URI must point at the music root" - ); - assert_eq!(first.track_count, 1, "root entry must count the loose file"); - assert_eq!(first.owner, "local"); - } - - #[test] - fn playlists_omits_root_entry_when_root_has_no_loose_audio_files() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - - // Only subdirectories; no loose files in root. - let sub = root.join("Classical"); - std::fs::create_dir(&sub).unwrap(); - write_wav(&sub.join("piece.wav"), 44100, 100); - // A non-audio file in root must not trigger the "(root)" entry. - std::fs::File::create(root.join("cover.jpg")).unwrap(); - - let src = LocalSource::new(root); - let playlists = tokio::runtime::Runtime::new() - .unwrap() - .block_on(src.playlists()) - .unwrap(); - - let names: Vec<&str> = playlists.iter().map(|p| p.name.as_str()).collect(); - assert!( - !names.contains(&"(root)"), - "no loose audio files in root means no (root) entry; got {names:?}" - ); - } - - #[test] - fn playlists_counts_audio_files_in_subdir() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let sub = root.join("Albums"); - std::fs::create_dir(&sub).unwrap(); - - write_wav(&sub.join("track1.wav"), 44100, 100); - write_wav(&sub.join("track2.wav"), 44100, 100); - // A non-audio file should not count. - std::fs::File::create(sub.join("cover.jpg")).unwrap(); - - let src = LocalSource::new(root); - let playlists = tokio::runtime::Runtime::new() - .unwrap() - .block_on(src.playlists()) - .unwrap(); - - assert_eq!(playlists.len(), 1); - assert_eq!(playlists[0].track_count, 2); - } - - // --------------------------------------------------------------------------- - // tracks() — file scan + TrackInfo construction - // --------------------------------------------------------------------------- - - #[test] - fn tracks_returns_one_entry_per_audio_file() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let sub = root.join("Classical"); - std::fs::create_dir(&sub).unwrap(); - - write_wav(&sub.join("piece.wav"), 44100, 200); - // A JPEG cover should be skipped. - std::fs::File::create(sub.join("cover.jpg")).unwrap(); - - let src = LocalSource::new(root); - let uri = path_to_file_uri(&sub); - let tracks = tokio::runtime::Runtime::new() - .unwrap() - .block_on(src.tracks(&uri)) - .unwrap(); - - assert_eq!(tracks.len(), 1); - let t = &tracks[0]; - assert!(t.is_local, "track should be marked local"); - assert!(t.is_playable, "track should be playable"); - assert!( - t.uri.as_deref().map_or(false, |u| u.starts_with("file://")), - "track URI should be a file:// URI" - ); - } - - #[test] - fn tracks_falls_back_to_filename_when_no_tags() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let sub = root.join("Misc"); - std::fs::create_dir(&sub).unwrap(); - - write_wav(&sub.join("my_song.wav"), 44100, 100); - - let src = LocalSource::new(root); - let uri = path_to_file_uri(&sub); - let tracks = tokio::runtime::Runtime::new() - .unwrap() - .block_on(src.tracks(&uri)) - .unwrap(); - - assert_eq!(tracks.len(), 1); - // lofty will have no tags for this bare WAV, so name should fall back to stem. - let t = &tracks[0]; - // The name is either "my_song" (fallback) or a tag title; either is acceptable. - assert!(!t.name.is_empty(), "name should not be empty"); - } - - // --------------------------------------------------------------------------- - // Streamer skeleton - // --------------------------------------------------------------------------- - - #[test] - fn streamer_constructs_decoder_for_valid_wav() { - let dir = tempfile::tempdir().unwrap(); - let wav = dir.path().join("sample.wav"); - write_wav(&wav, 44100, 1000); - - let src = LocalSource::new(dir.path()); - let uri = path_to_file_uri(&wav); - - let result = tokio::runtime::Runtime::new() - .unwrap() - .block_on(src.stream(&uri)); - - assert!( - result.is_ok(), - "streamer should succeed for valid WAV: {result:?}" - ); - } - - #[test] - fn streamer_errors_for_missing_file() { - let dir = tempfile::tempdir().unwrap(); - let src = LocalSource::new(dir.path()); - let uri = "file:///nonexistent/path/track.wav"; - - let result = tokio::runtime::Runtime::new() - .unwrap() - .block_on(src.stream(uri)); - - assert!(result.is_err(), "streamer should fail for missing file"); - } - - // --------------------------------------------------------------------------- - // LocalSource accessors - // --------------------------------------------------------------------------- - - #[test] - fn local_source_name_and_scheme() { - let src = LocalSource::new("/tmp/music"); - assert_eq!(src.name(), "Local Files"); - assert_eq!(src.scheme(), "file"); - } - - // --------------------------------------------------------------------------- - // playlists() — recursive scan - // --------------------------------------------------------------------------- - - #[test] - fn playlists_finds_nested_folders_with_audio() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - - // The shape that broke browsing: a container folder whose audio lives one - // level further down. - std::fs::create_dir_all(root.join("Jamendo/Fresh & New")).unwrap(); - write_wav(&root.join("Jamendo/Fresh & New/001.wav"), 44100, 100); - - let src = LocalSource::new(root); - let playlists = tokio::runtime::Runtime::new() - .unwrap() - .block_on(src.playlists()) - .unwrap(); - - let names: Vec<&str> = playlists.iter().map(|p| p.name.as_str()).collect(); - assert_eq!( - names, - vec!["Jamendo/Fresh & New"], - "the nested folder holding the audio is the playlist" - ); - assert_eq!(playlists[0].track_count, 1); - assert_eq!( - file_uri_to_path(&playlists[0].uri).unwrap(), - root.join("Jamendo/Fresh & New"), - "uri must point at the folder that actually holds the files" - ); - } - - #[test] - fn playlists_omits_folders_with_no_direct_audio() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - - std::fs::create_dir_all(root.join("Artist/Album")).unwrap(); - write_wav(&root.join("Artist/Album/track.wav"), 44100, 100); - // A container with neither audio nor audio-bearing children at all. - std::fs::create_dir(root.join("Empty")).unwrap(); - - let src = LocalSource::new(root); - let playlists = tokio::runtime::Runtime::new() - .unwrap() - .block_on(src.playlists()) - .unwrap(); - - let names: Vec<&str> = playlists.iter().map(|p| p.name.as_str()).collect(); - assert_eq!( - names, - vec!["Artist/Album"], - "container and empty folders have nothing to play; got {names:?}" - ); - } - - #[test] - fn playlists_stops_at_max_scan_depth() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - - // One level deeper than the cap allows. - let mut deep = root.to_path_buf(); - for i in 0..=MAX_SCAN_DEPTH { - deep = deep.join(format!("d{i}")); - } - std::fs::create_dir_all(&deep).unwrap(); - write_wav(&deep.join("buried.wav"), 44100, 100); - - let src = LocalSource::new(root); - let playlists = tokio::runtime::Runtime::new() - .unwrap() - .block_on(src.playlists()) - .unwrap(); - - assert!( - playlists.is_empty(), - "a folder below the depth cap must not be scanned; got {:?}", - playlists.iter().map(|p| &p.name).collect::>() - ); - } - - fn list(root: &Path) -> Vec { - tokio::runtime::Runtime::new() - .unwrap() - .block_on(LocalSource::new(root).playlists()) - .unwrap() - } - - fn rows(root: &Path, dir: &Path) -> Vec { - tokio::runtime::Runtime::new() - .unwrap() - .block_on(LocalSource::new(root).tracks(&path_to_file_uri(dir))) - .unwrap() - } - - /// A folder link: a symlink on Unix, a junction on Windows (no privilege needed). - fn link_dir(link: &Path, target: &Path) { - #[cfg(unix)] - std::os::unix::fs::symlink(target, link).unwrap(); - #[cfg(windows)] - { - // `cmd` reads a `/` inside a path as a switch. - let backslashed = |path: &Path| path.to_string_lossy().replace('/', "\\"); - let output = std::process::Command::new("cmd") - .args([ - "/C", - "mklink", - "/J", - &backslashed(link), - &backslashed(target), - ]) - .output() - .unwrap(); - assert!(output.status.success(), "mklink /J failed: {output:?}"); - } - } - - #[test] - fn hidden_entries_are_skipped_by_the_count_and_the_track_table() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - std::fs::create_dir(root.join("Album")).unwrap(); - write_wav(&root.join("Album/track.wav"), 44100, 100); - // An exFAT drive's AppleDouble sibling, and a hidden folder. - write_wav(&root.join("Album/._track.wav"), 44100, 100); - std::fs::create_dir(root.join(".Trash")).unwrap(); - write_wav(&root.join(".Trash/gone.wav"), 44100, 100); - - let playlists = list(root); - let names: Vec<&str> = playlists.iter().map(|p| p.name.as_str()).collect(); - assert_eq!(names, vec!["Album"]); - assert_eq!(playlists[0].track_count, 1); - assert_eq!(rows(root, &root.join("Album")).len(), 1); - } - - #[cfg(unix)] - #[test] - fn a_linked_track_counts_and_lists_like_a_real_one() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let elsewhere = tempfile::tempdir().unwrap(); - write_wav(&elsewhere.path().join("far.wav"), 44100, 100); - std::fs::create_dir(root.join("Album")).unwrap(); - write_wav(&root.join("Album/near.wav"), 44100, 100); - std::os::unix::fs::symlink(elsewhere.path().join("far.wav"), root.join("Album/far.wav")) - .unwrap(); - - let playlists = list(root); - assert_eq!(playlists[0].track_count, 2); - assert_eq!(rows(root, &root.join("Album")).len(), 2); - } - - #[test] - fn a_linked_folder_is_walked_once() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let nas = tempfile::tempdir().unwrap(); - std::fs::create_dir(nas.path().join("Album")).unwrap(); - write_wav(&nas.path().join("Album/track.wav"), 44100, 100); - link_dir(&root.join("NAS"), nas.path()); - // A loop back into the linked tree: walked once, never forever. - link_dir(&nas.path().join("Album/loop"), nas.path()); - - let playlists = list(root); - let names: Vec<&str> = playlists.iter().map(|p| p.name.as_str()).collect(); - assert_eq!(names, vec!["NAS/Album"]); - assert_eq!(playlists[0].track_count, 1); - } - - #[cfg(unix)] - #[test] - fn an_unreadable_folder_is_skipped_not_fatal() { - use std::os::unix::fs::PermissionsExt; - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - std::fs::create_dir(root.join("Album")).unwrap(); - write_wav(&root.join("Album/track.wav"), 44100, 100); - std::fs::create_dir(root.join("Locked")).unwrap(); - write_wav(&root.join("Locked/track.wav"), 44100, 100); - std::fs::set_permissions(root.join("Locked"), std::fs::Permissions::from_mode(0o000)).unwrap(); - - let playlists = list(root); - // Restore before the tempdir is removed. - std::fs::set_permissions(root.join("Locked"), std::fs::Permissions::from_mode(0o755)).unwrap(); - let names: Vec<&str> = playlists.iter().map(|p| p.name.as_str()).collect(); - assert_eq!(names, vec!["Album"]); - } - - #[test] - fn current_uri_reads_the_indexed_track() { - // current_uri() is index-driven and bounds-checked; exercise it without a - // real player by building the queue fields directly is not possible - // (player is required), so assert the underlying slice access contract that - // current_uri relies on. - let queue = vec!["x".to_string(), "y".to_string()]; - assert_eq!(queue.get(1).map(String::as_str), Some("y")); - assert_eq!(queue.get(5).map(String::as_str), None); - } -} diff --git a/src/infra/macos_media.rs b/src/infra/macos_media.rs deleted file mode 100644 index b23c16a5..00000000 --- a/src/infra/macos_media.rs +++ /dev/null @@ -1,374 +0,0 @@ -//! macOS Now Playing / Media Key integration -//! -//! Exposes spotatui as a controllable media player via macOS's MediaPlayer framework, enabling: -//! - Media key support (play/pause, next, previous) -//! - Control Center / Touch Bar Now Playing widget -//! - Headphone button controls -//! -//! This module is only available on macOS with the `macos-media` feature enabled. - -use anyhow::Result; -use block2::RcBlock; -use log::info; -use objc2::msg_send; -use objc2::runtime::{AnyClass, AnyObject}; -use objc2::AnyThread; -use objc2_app_kit::NSImage; -use objc2_foundation::{NSData, NSDate, NSMutableDictionary, NSNumber, NSRunLoop, NSString}; -use objc2_media_player::{ - MPMediaItemArtwork, MPMediaItemPropertyAlbumTitle, MPMediaItemPropertyArtist, - MPMediaItemPropertyArtwork, MPMediaItemPropertyPlaybackDuration, MPMediaItemPropertyTitle, - MPNowPlayingInfoCenter, MPNowPlayingInfoPropertyElapsedPlaybackTime, - MPNowPlayingInfoPropertyPlaybackRate, MPNowPlayingPlaybackState, MPRemoteCommandCenter, - MPRemoteCommandEvent, MPRemoteCommandHandlerStatus, -}; -use std::ptr::NonNull; -use std::sync::Arc; -use std::thread; -use std::time::Duration; -use tokio::sync::mpsc; - -/// Events that can be received from external macOS media controls (media keys, Control Center, etc.) -#[derive(Debug, Clone)] -pub enum MacMediaEvent { - PlayPause, - Play, - Pause, - Next, - Previous, - Stop, -} - -/// Commands to send TO the Now Playing center to update its state -#[derive(Debug, Clone)] -#[allow(dead_code, clippy::enum_variant_names)] -pub enum MacMediaCommand { - SetMetadata { - title: String, - artists: Vec, - album: String, - duration_ms: u32, - art_url: Option, - }, - SetPlaybackStatus(bool), // true = playing, false = paused - SetPosition(u64), // position in milliseconds - SetVolume(u8), // 0-100 (not directly supported by Now Playing, but kept for API parity) - SetStopped, -} - -/// Manager for the macOS Now Playing integration -pub struct MacMediaManager { - event_rx: std::sync::Mutex>>, - command_tx: mpsc::UnboundedSender, -} - -impl MacMediaManager { - /// Create and start the macOS media integration - /// - /// Registers command handlers with MPRemoteCommandCenter and sets up Now Playing info - /// The handler runs in a dedicated thread because it requires the main run loop - pub fn new() -> Result { - let (event_tx, event_rx) = mpsc::unbounded_channel(); - let (command_tx, mut command_rx) = mpsc::unbounded_channel::(); - - // Clone event_tx for use in callbacks - let event_tx = Arc::new(event_tx); - - // MPRemoteCommandCenter requires an initialized NSApplication to route media key events; - // without one, macOS ignores the handlers and falls through to Music.app. - thread::spawn(move || { - // Initialize NSApplication with raw msg_send because objc2-app-kit's - // sharedApplication() requires MainThreadMarker (unavailable in CLI apps). - unsafe { - let cls = AnyClass::get(c"NSApplication").expect("NSApplication class not found"); - let app: objc2::rc::Retained = msg_send![cls, sharedApplication]; - // NSApplicationActivationPolicyProhibited = 2 (no Dock icon, no menu bar) - let _activation_policy_set: bool = msg_send![&app, setActivationPolicy: 2isize]; - } - info!("macos media: NSApplication initialized with Prohibited activation policy"); - - // Get the shared command center - let command_center = unsafe { MPRemoteCommandCenter::sharedCommandCenter() }; - - // Set up play command handler - let tx = Arc::clone(&event_tx); - let play_handler: RcBlock< - dyn Fn(NonNull) -> MPRemoteCommandHandlerStatus, - > = RcBlock::new(move |_event: NonNull| { - info!("macos media: received Play event"); - let _ = tx.send(MacMediaEvent::Play); - MPRemoteCommandHandlerStatus::Success - }); - unsafe { - command_center - .playCommand() - .addTargetWithHandler(&play_handler); - } - - // Set up pause command handler - let tx = Arc::clone(&event_tx); - let pause_handler: RcBlock< - dyn Fn(NonNull) -> MPRemoteCommandHandlerStatus, - > = RcBlock::new(move |_event: NonNull| { - info!("macos media: received Pause event"); - let _ = tx.send(MacMediaEvent::Pause); - MPRemoteCommandHandlerStatus::Success - }); - unsafe { - command_center - .pauseCommand() - .addTargetWithHandler(&pause_handler); - } - - // Set up toggle play/pause command handler - let tx = Arc::clone(&event_tx); - let toggle_handler: RcBlock< - dyn Fn(NonNull) -> MPRemoteCommandHandlerStatus, - > = RcBlock::new(move |_event: NonNull| { - info!("macos media: received PlayPause event"); - let _ = tx.send(MacMediaEvent::PlayPause); - MPRemoteCommandHandlerStatus::Success - }); - unsafe { - command_center - .togglePlayPauseCommand() - .addTargetWithHandler(&toggle_handler); - } - - // Set up next track command handler - let tx = Arc::clone(&event_tx); - let next_handler: RcBlock< - dyn Fn(NonNull) -> MPRemoteCommandHandlerStatus, - > = RcBlock::new(move |_event: NonNull| { - info!("macos media: received Next event"); - let _ = tx.send(MacMediaEvent::Next); - MPRemoteCommandHandlerStatus::Success - }); - unsafe { - command_center - .nextTrackCommand() - .addTargetWithHandler(&next_handler); - } - - // Set up previous track command handler - let tx = Arc::clone(&event_tx); - let prev_handler: RcBlock< - dyn Fn(NonNull) -> MPRemoteCommandHandlerStatus, - > = RcBlock::new(move |_event: NonNull| { - info!("macos media: received Previous event"); - let _ = tx.send(MacMediaEvent::Previous); - MPRemoteCommandHandlerStatus::Success - }); - unsafe { - command_center - .previousTrackCommand() - .addTargetWithHandler(&prev_handler); - } - - // Set up stop command handler - let tx = Arc::clone(&event_tx); - let stop_handler: RcBlock< - dyn Fn(NonNull) -> MPRemoteCommandHandlerStatus, - > = RcBlock::new(move |_event: NonNull| { - info!("macos media: received Stop event"); - let _ = tx.send(MacMediaEvent::Stop); - MPRemoteCommandHandlerStatus::Success - }); - unsafe { - command_center - .stopCommand() - .addTargetWithHandler(&stop_handler); - } - - info!("macos media: remote command handlers registered"); - - // Get the now playing info center - let info_center = unsafe { MPNowPlayingInfoCenter::defaultCenter() }; - - // Interleave command processing with NSRunLoop ticks so macOS can deliver - // MPRemoteCommandCenter events to our handler blocks. - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("Failed to create macOS media runtime"); - - rt.block_on(async move { - let mut interval = tokio::time::interval(Duration::from_millis(100)); - loop { - tokio::select! { - Some(cmd) = command_rx.recv() => { - handle_now_playing_command(&cmd, &info_center).await; - } - _ = interval.tick() => { - NSRunLoop::currentRunLoop() - .runUntilDate(&NSDate::dateWithTimeIntervalSinceNow(0.01)); - } - } - } - }); - }); - - Ok(Self { - event_rx: std::sync::Mutex::new(Some(event_rx)), - command_tx, - }) - } - - /// Take the event receiver for handling external control requests - /// - /// This can only be called once; subsequent calls return None - pub fn take_event_rx(&self) -> Option> { - self.event_rx.lock().ok()?.take() - } - - /// Update track metadata - pub fn set_metadata( - &self, - title: &str, - artists: &[String], - album: &str, - duration_ms: u32, - art_url: Option, - ) { - let _ = self.command_tx.send(MacMediaCommand::SetMetadata { - title: title.to_string(), - artists: artists.to_vec(), - album: album.to_string(), - duration_ms, - art_url, - }); - } - - /// Update playback status - pub fn set_playback_status(&self, is_playing: bool) { - let _ = self - .command_tx - .send(MacMediaCommand::SetPlaybackStatus(is_playing)); - } - - /// Update playback position - pub fn set_position(&self, position_ms: u64) { - let _ = self - .command_tx - .send(MacMediaCommand::SetPosition(position_ms)); - } - - /// Update volume (0-100) - kept for API parity with MPRIS - #[allow(dead_code)] - pub fn set_volume(&self, volume_percent: u8) { - let _ = self - .command_tx - .send(MacMediaCommand::SetVolume(volume_percent)); - } - - /// Mark playback as stopped - pub fn set_stopped(&self) { - let _ = self.command_tx.send(MacMediaCommand::SetStopped); - } -} - -/// Process a single Now Playing command, updating the info center state. -/// Must be called from the dedicated macOS media thread that owns `info_center`. -async fn handle_now_playing_command(cmd: &MacMediaCommand, info_center: &MPNowPlayingInfoCenter) { - match cmd { - MacMediaCommand::SetMetadata { - title, - artists, - album, - duration_ms, - art_url, - } => { - let artwork = match art_url.as_deref() { - Some(url) => fetch_artwork_from_url(url).await, - None => None, - }; - - unsafe { - let dict: objc2::rc::Retained> = - NSMutableDictionary::new(); - - let title_ns = NSString::from_str(title); - dict.insert(MPMediaItemPropertyTitle, &*title_ns); - - let artist_ns = NSString::from_str(&artists.join(", ")); - dict.insert(MPMediaItemPropertyArtist, &*artist_ns); - - let album_ns = NSString::from_str(album); - dict.insert(MPMediaItemPropertyAlbumTitle, &*album_ns); - - let duration = NSNumber::numberWithDouble(f64::from(*duration_ms) / 1000.0); - dict.insert(MPMediaItemPropertyPlaybackDuration, &*duration); - - let rate = NSNumber::numberWithDouble(1.0); - dict.insert(MPNowPlayingInfoPropertyPlaybackRate, &*rate); - - if let Some(artwork) = artwork.as_ref() { - dict.insert(MPMediaItemPropertyArtwork, &**artwork); - } - - info_center.setNowPlayingInfo(Some(&dict)); - } - } - MacMediaCommand::SetPlaybackStatus(is_playing) => unsafe { - let state = if *is_playing { - MPNowPlayingPlaybackState::Playing - } else { - MPNowPlayingPlaybackState::Paused - }; - info_center.setPlaybackState(state); - - // Update playback rate in the existing nowPlayingInfo so macOS - // knows whether to advance the elapsed time counter. - if let Some(existing) = info_center.nowPlayingInfo() { - let dict: objc2::rc::Retained> = - NSMutableDictionary::dictionaryWithDictionary(&existing); - let rate = NSNumber::numberWithDouble(if *is_playing { 1.0 } else { 0.0 }); - dict.insert(MPNowPlayingInfoPropertyPlaybackRate, &*rate); - info_center.setNowPlayingInfo(Some(&dict)); - } - }, - MacMediaCommand::SetPosition(position_ms) => unsafe { - // Update elapsed playback time in the existing nowPlayingInfo dict - if let Some(existing) = info_center.nowPlayingInfo() { - let dict: objc2::rc::Retained> = - NSMutableDictionary::dictionaryWithDictionary(&existing); - let elapsed = NSNumber::numberWithDouble(*position_ms as f64 / 1000.0); - dict.insert(MPNowPlayingInfoPropertyElapsedPlaybackTime, &*elapsed); - info_center.setNowPlayingInfo(Some(&dict)); - } - }, - MacMediaCommand::SetVolume(_) => { - // Volume is not directly supported by Now Playing center - } - MacMediaCommand::SetStopped => unsafe { - info_center.setPlaybackState(MPNowPlayingPlaybackState::Stopped); - info_center.setNowPlayingInfo(None); - }, - } -} - -async fn fetch_artwork_from_url(art_url: &str) -> Option> { - let response = reqwest::get(art_url).await.ok()?; - if !response.status().is_success() { - return None; - } - - let bytes = response.bytes().await.ok()?; - if bytes.is_empty() { - return None; - } - - unsafe { - let data = NSData::dataWithBytes_length(bytes.as_ptr().cast(), bytes.len()); - let image = NSImage::initWithData(NSImage::alloc(), &data)?; - let image_for_handler = image.clone(); - let request_handler = - RcBlock::new(move |_requested_size| NonNull::from(image_for_handler.as_ref())); - - Some(MPMediaItemArtwork::initWithBoundsSize_requestHandler( - MPMediaItemArtwork::alloc(), - image.size(), - &request_handler, - )) - } -} diff --git a/src/infra/mcp/CLAUDE.md b/src/infra/mcp/CLAUDE.md deleted file mode 100644 index 64ee5a2f..00000000 --- a/src/infra/mcp/CLAUDE.md +++ /dev/null @@ -1,10 +0,0 @@ -### MCP server (feature `mcp-server`) - -`spotatui mcp` is a stdio relay to the running TUI over loopback TCP: the TUI -listens on 127.0.0.1 with a token published in a 0600 -`~/.config/spotatui/mcp.json`, gated on `behavior.mcp_enabled`. The relay -reconnects per line because the two processes have independent lifetimes. -`spotatui mcp` blocks - use `spotatui mcp status` to test. The protocol -(`src/infra/mcp/protocol.rs`) is hand-rolled: keep MCP *protocol* errors (unknown -tool, bad args) distinct from tool *execution* errors (`isError: true`) - clients -feed only the second back to the model. diff --git a/src/infra/mcp/control.rs b/src/infra/mcp/control.rs deleted file mode 100644 index 9bab75e0..00000000 --- a/src/infra/mcp/control.rs +++ /dev/null @@ -1,469 +0,0 @@ -//! The control channel: how `spotatui mcp` reaches the running TUI. -//! -//! MCP stdio servers are spawned by the *client*, but the music is playing in an -//! already-running TUI process. So the TUI listens on a loopback socket and -//! `spotatui mcp` relays between the agent's stdio and that socket. The spec -//! sanctions reusing the stdio framing over "Unix domain sockets, TCP -//! connections, or any similar channel", so the hop carries ordinary MCP -//! messages and the relay stays a line pump. -//! -//! ## Trust boundary -//! -//! The listener binds `127.0.0.1` only and requires a shared secret as the first -//! line of every connection. Both the port and the secret live in -//! `~/.config/spotatui/mcp.json`, written `0600` inside a directory that is -//! already `0700` on unix and carries an auto-written `.gitignore` — the same -//! protection the Spotify token cache relies on. A local process able to read -//! that file is already inside the trust boundary. -//! -//! It is **opt-in**: nothing listens unless `behavior.mcp_enabled` is set. -//! Opening a control socket that can drive playback and read listening history -//! is a security-posture change, so it should be a deliberate act. - -use super::executor::AppExecutor; -use super::server; -use crate::core::app::App; -use crate::infra::network::IoEvent; -use anyhow::{Context, Result}; -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; -use std::sync::mpsc::Sender; -use std::sync::Arc; -use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; -use tokio::net::{TcpListener, TcpStream}; -use tokio::sync::Mutex; - -const HANDSHAKE_FILE: &str = "mcp.json"; -/// Overrides the handshake file location so a test does not write into the -/// user's own config directory. Compiled in **only** under `cfg(test)`: in a -/// release build the path always derives from the config directory, so no -/// inherited environment can move the token file out of the `0700` directory -/// that protects it, or point a relay at a port and token of its choosing. -#[cfg(test)] -const HANDSHAKE_PATH_ENV: &str = "SPOTATUI_MCP_HANDSHAKE_PATH"; -/// A connecting relay has to present its token immediately; without a deadline a -/// silent connection would hold a task open forever. -const HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); -/// A token line is around 50 bytes. The cap is what stops a pre-authentication -/// connection from growing an unbounded `String`: the handshake deadline bounds -/// how *long* a client may write for, not how much. -const MAX_HANDSHAKE_BYTES: u64 = 4096; - -/// What the TUI publishes so a relay can find and authenticate to it. -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] -pub struct Handshake { - pub port: u16, - pub token: String, - /// PID of the TUI that wrote this. Reported by `spotatui mcp status` so a file - /// left behind by a killed process can be traced back to it; reachability - /// itself is decided by the connection probe, not by this. - pub pid: u32, -} - -pub fn handshake_path() -> Result { - #[cfg(test)] - if let Ok(path) = std::env::var(HANDSHAKE_PATH_ENV) { - if !path.trim().is_empty() { - return Ok(PathBuf::from(path)); - } - } - let dir = crate::core::user_config::default_app_config_dir() - .context("could not determine the spotatui config directory")?; - Ok(dir.join(HANDSHAKE_FILE)) -} - -/// Read the published handshake, if a TUI has written one. -pub fn read_handshake() -> Result { - let path = handshake_path()?; - let raw = std::fs::read_to_string(&path) - .with_context(|| format!("no MCP control file at {}", path.display()))?; - serde_json::from_str(&raw) - .with_context(|| format!("malformed MCP control file at {}", path.display())) -} - -fn write_handshake(handshake: &Handshake) -> Result<()> { - let path = handshake_path()?; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - let body = serde_json::to_string_pretty(handshake)?; - // Owner-only, and owner-only *before* the token is in it: creating at the - // umask default and chmod-ing afterwards leaves a window where any local user - // can read a capability to drive the player. - #[cfg(unix)] - { - use std::io::Write; - use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; - let mut file = std::fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .mode(0o600) - .open(&path)?; - // `mode` only applies when the file is created; an older run may have left a - // world-readable one behind. - file.set_permissions(std::fs::Permissions::from_mode(0o600))?; - file.write_all(body.as_bytes())?; - } - #[cfg(not(unix))] - std::fs::write(&path, body)?; - Ok(()) -} - -/// Remove the published handshake. Called on shutdown so a relay does not chase -/// a socket that is gone. -pub fn clear_handshake() { - if let Ok(path) = handshake_path() { - let _ = std::fs::remove_file(path); - } -} - -fn generate_token() -> Result { - // 128 bits straight from the OS CSPRNG, hex-encoded. `SysRng` rather than the - // thread RNG so the entropy source is the one the comment claims, with no - // userspace state to reason about for a value that authenticates a socket. - use rand::rngs::SysRng; - use rand::TryRng; - let mut rng = SysRng; - let high = rng - .try_next_u64() - .context("could not read from the system random source")?; - let low = rng - .try_next_u64() - .context("could not read from the system random source")?; - Ok(format!("{high:016x}{low:016x}")) -} - -/// Bind the control listener and serve connections until the process exits. -/// -/// Returns the bound port so the caller can report it. Each accepted connection -/// gets its own task and its own [`server::Session`], so the per-connection era -/// latch cannot leak between two different agents talking to us at once. -pub async fn spawn_listener(app: Arc>, io_tx: Sender) -> Result { - let listener = TcpListener::bind(("127.0.0.1", 0)) - .await - .context("could not bind the MCP control socket on 127.0.0.1")?; - let port = listener.local_addr()?.port(); - let token = generate_token()?; - - write_handshake(&Handshake { - port, - token: token.clone(), - pid: std::process::id(), - }) - .context("could not publish the MCP control file")?; - - tokio::spawn(async move { - loop { - let (stream, peer) = match listener.accept().await { - Ok(accepted) => accepted, - Err(e) => { - log::warn!("MCP: accept failed: {e}"); - // A transient error is fine to retry at once, but a persistent one - // (EMFILE, for instance) returns immediately every time, so retrying - // with no delay spins a core and fills the log for the life of the - // process. Back off enough to make that harmless. - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - continue; - } - }; - // Defence in depth: the bind is already loopback-only, but a bound socket - // is worth re-checking rather than assuming. - if !peer.ip().is_loopback() { - log::warn!("MCP: refusing non-loopback connection from {peer}"); - continue; - } - let app = Arc::clone(&app); - let io_tx = io_tx.clone(); - let token = token.clone(); - tokio::spawn(async move { - if let Err(e) = serve_connection(stream, app, io_tx, token, HANDSHAKE_TIMEOUT).await { - log::debug!("MCP: connection ended: {e}"); - } - }); - } - }); - - Ok(port) -} - -/// Authenticate one connection, then hand it to the protocol loop. -/// -/// `handshake_timeout` is a parameter rather than a constant so the deadline is -/// testable without manipulating the clock. -async fn serve_connection( - stream: TcpStream, - app: Arc>, - io_tx: Sender, - expected_token: String, - handshake_timeout: std::time::Duration, -) -> Result<()> { - let (read_half, mut write_half) = stream.into_split(); - let mut reader = BufReader::new(read_half); - - // The token line comes before any MCP traffic. This is our own transport, so a - // non-MCP handshake line is fine here; the stdio channel the relay presents to - // the agent stays pure MCP. - // - // Read through a cap rather than straight from the reader: this runs *before* - // the token check, so any local process could otherwise open a connection and - // write newline-free bytes for the whole timeout, growing one `String` per - // connection with no credential and no limit on connections. - let mut first_line = String::new(); - let read = tokio::time::timeout( - handshake_timeout, - (&mut reader) - .take(MAX_HANDSHAKE_BYTES) - .read_line(&mut first_line), - ) - .await; - match read { - Ok(Ok(0)) => anyhow::bail!("client closed before handshake"), - // The cap was reached without a newline, so this is not a handshake line. - Ok(Ok(n)) if n as u64 == MAX_HANDSHAKE_BYTES && !first_line.ends_with('\n') => { - anyhow::bail!("handshake line too long") - } - Ok(Ok(_)) => {} - Ok(Err(e)) => return Err(e.into()), - Err(_) => anyhow::bail!("handshake timed out"), - } - - let presented = serde_json::from_str::(&first_line) - .ok() - .and_then(|value| { - value - .get("token") - .and_then(|token| token.as_str()) - .map(str::to_string) - }); - - let authorised = presented.as_deref().is_some_and(|token| { - // Length-independent comparison is overkill for a loopback socket, but - // constant-time-ish beats an early-exit compare and costs nothing. - token.len() == expected_token.len() - && token - .bytes() - .zip(expected_token.bytes()) - .fold(0u8, |acc, (a, b)| acc | (a ^ b)) - == 0 - }); - - if !authorised { - let _ = write_half - .write_all(b"{\"error\":\"unauthorized\"}\n") - .await; - anyhow::bail!("bad MCP control token"); - } - - let executor = AppExecutor::new(app, io_tx); - server::serve(reader, write_half, &executor).await?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn generated_tokens_are_long_and_distinct() { - let a = generate_token().unwrap(); - let b = generate_token().unwrap(); - assert_eq!(a.len(), 32, "128 bits, hex-encoded"); - assert_ne!(a, b); - assert!(a.chars().all(|c| c.is_ascii_hexdigit())); - } - - #[test] - fn handshake_round_trips_through_json() { - let handshake = Handshake { - port: 51234, - token: "deadbeef".into(), - pid: 42, - }; - let encoded = serde_json::to_string(&handshake).unwrap(); - let decoded: Handshake = serde_json::from_str(&encoded).unwrap(); - assert_eq!(handshake, decoded); - } - - #[tokio::test] - async fn a_bad_token_closes_the_connection() { - let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); - let port = listener.local_addr().unwrap().port(); - let (io_tx, _io_rx) = std::sync::mpsc::channel(); - let app = Arc::new(Mutex::new(App::default())); - - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - serve_connection( - stream, - app, - io_tx, - "correct-token".to_string(), - HANDSHAKE_TIMEOUT, - ) - .await - }); - - let mut client = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); - client - .write_all(b"{\"token\":\"wrong-token\"}\n") - .await - .unwrap(); - - // The server rejects and hangs up rather than serving any tools. - let result = server.await.unwrap(); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("token")); - } - - #[tokio::test] - async fn a_good_token_opens_the_protocol() { - let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); - let port = listener.local_addr().unwrap().port(); - // Keep the receiver alive so app-only calls are not reported as shut down. - let (io_tx, _io_rx) = std::sync::mpsc::channel(); - let app = Arc::new(Mutex::new(App::default())); - - tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let _ = serve_connection( - stream, - app, - io_tx, - "correct-token".to_string(), - HANDSHAKE_TIMEOUT, - ) - .await; - }); - - let stream = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); - let (read_half, mut write_half) = stream.into_split(); - let mut reader = BufReader::new(read_half).lines(); - - write_half - .write_all(b"{\"token\":\"correct-token\"}\n") - .await - .unwrap(); - let request = serde_json::json!({ - "jsonrpc": "2.0", "id": 1, "method": "server/discover", - "params": {"_meta": { - super::super::protocol::META_PROTOCOL_VERSION: super::super::protocol::PROTOCOL_MODERN, - super::super::protocol::META_CLIENT_CAPABILITIES: {}, - }} - }); - write_half - .write_all(format!("{request}\n").as_bytes()) - .await - .unwrap(); - - let line = reader.next_line().await.unwrap().expect("a response"); - let response: serde_json::Value = serde_json::from_str(&line).unwrap(); - assert_eq!( - response["result"]["resultType"], - serde_json::json!("complete") - ); - } - - /// The full startup seam: `spawn_listener` binds, publishes a handshake, and - /// the `status` probe reaches it — the path the runtime takes at boot, which no - /// other test exercises end to end. - #[tokio::test] - async fn spawn_listener_publishes_a_handshake_the_status_probe_can_use() { - let dir = std::env::temp_dir().join(format!("spotatui-mcp-e2e-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("mcp.json"); - // Serialised against the other env-var tests by being the only one to use - // this variable, and cleaned up below. - unsafe { std::env::set_var(HANDSHAKE_PATH_ENV, &path) }; - - let (io_tx, _io_rx) = std::sync::mpsc::channel(); - let app = Arc::new(Mutex::new(App::default())); - let port = spawn_listener(app, io_tx) - .await - .expect("listener should bind"); - - let published = read_handshake().expect("a handshake should be published"); - assert_eq!(published.port, port); - assert_eq!(published.pid, std::process::id()); - assert_eq!(published.token.len(), 32); - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; - assert_eq!(mode, 0o600, "the token file must be owner-only"); - } - - // The probe the setup docs tell an agent to run. - let version = super::super::status::probe_for_test(&published) - .await - .expect("the probe should reach the listener"); - assert_eq!(version, super::super::protocol::PROTOCOL_MODERN); - - clear_handshake(); - assert!(read_handshake().is_err(), "shutdown should unpublish it"); - unsafe { std::env::remove_var(HANDSHAKE_PATH_ENV) }; - let _ = std::fs::remove_dir_all(&dir); - } - - #[tokio::test] - async fn a_silent_client_is_dropped_rather_than_held_open() { - // Regression guard: without the handshake deadline a connection that never - // speaks would pin a task for the life of the process. - let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); - let port = listener.local_addr().unwrap().port(); - let (io_tx, _io_rx) = std::sync::mpsc::channel(); - let app = Arc::new(Mutex::new(App::default())); - - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - serve_connection( - stream, - app, - io_tx, - "t".to_string(), - std::time::Duration::from_millis(50), - ) - .await - }); - - // Connected and held open, but never writes a handshake line. - let _client = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); - - let result = tokio::time::timeout(std::time::Duration::from_secs(5), server) - .await - .expect("the deadline must fire rather than hanging") - .unwrap(); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("timed out")); - } - - #[tokio::test] - async fn a_handshake_line_that_never_ends_is_rejected_rather_than_buffered() { - // Regression guard: the deadline bounds how long an unauthenticated client - // may write, not how much. Without the cap, a local process could hold a - // connection open writing newline-free bytes and grow a `String` per - // connection, before presenting any token. - let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); - let port = listener.local_addr().unwrap().port(); - let (io_tx, _io_rx) = std::sync::mpsc::channel(); - let app = Arc::new(Mutex::new(App::default())); - - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - serve_connection(stream, app, io_tx, "t".to_string(), HANDSHAKE_TIMEOUT).await - }); - - let mut client = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); - let flood = vec![b'a'; MAX_HANDSHAKE_BYTES as usize * 2]; - // The write may fail once the server hangs up mid-flood, which is the point. - let _ = client.write_all(&flood).await; - - let result = tokio::time::timeout(std::time::Duration::from_secs(5), server) - .await - .expect("the cap must fire well inside the handshake deadline") - .unwrap(); - let error = result.unwrap_err().to_string(); - assert!(error.contains("too long"), "{error}"); - } -} diff --git a/src/infra/mcp/executor.rs b/src/infra/mcp/executor.rs deleted file mode 100644 index d9bf4e43..00000000 --- a/src/infra/mcp/executor.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! How an MCP tool call gets run. -//! -//! [`AppExecutor`] and the [`ToolExecutor`] trait live in -//! [`crate::infra::dj::exec`], because the in-TUI DJ's agent loop drives the same -//! tools against the same player. Only the no-player stand-in is MCP's own. - -pub use crate::infra::dj::exec::{AppExecutor, ToolExecutor}; - -use crate::infra::dj::tools::{DjToolCall, ToolOutcome}; - -/// Stands in when there is no running player to talk to. -pub struct OfflineExecutor { - reason: String, -} - -impl OfflineExecutor { - pub fn new(reason: impl Into) -> Self { - Self { - reason: reason.into(), - } - } -} - -impl ToolExecutor for OfflineExecutor { - async fn call(&self, _call: DjToolCall) -> ToolOutcome { - // An execution error rather than a protocol error: this is exactly the - // "actionable feedback the model can act on" case, and the agent can tell - // the user to start spotatui. - ToolOutcome::error(format!( - "spotatui is not available: {}. Start spotatui and enable \ - `behavior.mcp_enabled` in its config, then try again.", - self.reason - )) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn offline_executor_reports_an_actionable_error() { - let executor = OfflineExecutor::new("no control socket found"); - let outcome = executor.call(DjToolCall::GetNowPlaying).await; - assert!(outcome.is_error); - assert!(outcome.text.contains("no control socket found")); - // Tells the agent what the user has to do, not just that it failed. - assert!(outcome.text.contains("mcp_enabled")); - } -} diff --git a/src/infra/mcp/mod.rs b/src/infra/mcp/mod.rs deleted file mode 100644 index ff9affe5..00000000 --- a/src/infra/mcp/mod.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! MCP server: drive spotatui from a coding agent. -//! -//! ```text -//! Claude Code ──stdio(MCP)──> `spotatui mcp` ──loopback TCP──> running TUI -//! (relay) (server + App) -//! ``` -//! -//! The point of this module is that the DJ works **without an API key**: the -//! agent you already have installed and authenticated does the thinking, and -//! spotatui just exposes the player. The tools themselves live in -//! [`crate::infra::dj::tools`], shared with the in-TUI DJ. -//! -//! * [`protocol`] — wire shapes, versions, error codes, dual-era result shaping -//! * [`server`] — the JSON-RPC loop and request dispatch -//! * [`executor`] — how a tool call reaches the live player (or reports that it -//! cannot) -//! * [`control`] — the TUI-side listener and its token file -//! * [`relay`] — the `spotatui mcp` subcommand -//! -//! Written against protocol revision `2026-07-28`, which removed the -//! `initialize` handshake and made MCP stateless. Because a legacy client -//! against a modern-only server fails outright, the server also answers -//! `initialize` — see [`protocol::Era`]. - -pub mod control; -pub mod executor; -pub mod protocol; -pub mod relay; -pub mod server; -pub mod status; - -pub use control::{clear_handshake, spawn_listener}; -pub use relay::run as run_relay; -pub use status::run as run_status; diff --git a/src/infra/mcp/protocol.rs b/src/infra/mcp/protocol.rs deleted file mode 100644 index 39a4514c..00000000 --- a/src/infra/mcp/protocol.rs +++ /dev/null @@ -1,418 +0,0 @@ -//! MCP wire protocol: versions, error codes, `_meta` keys, and result shaping. -//! -//! Hand-rolled rather than taken from an SDK, for two reasons. The -//! `2026-07-28` revision is new enough that no Rust SDK implements it yet, and -//! spotatui specifically needs to be **dual-era** — see [`Era`] — which is a -//! server-side concern the SDKs do not expose. The surface is small enough that -//! newline-delimited JSON-RPC over `serde_json` is the cheaper option, and it -//! keeps this feature free of new dependencies. -//! -//! Reference: - -use serde_json::{json, Map, Value}; - -/// The revision this server prefers. Stateless, per-request metadata, no -/// handshake. -pub const PROTOCOL_MODERN: &str = "2026-07-28"; - -/// Every revision this server will serve, newest first. -/// -/// The legacy entries are not decoration. Per the spec's compatibility matrix a -/// *legacy client against a modern-only server fails outright*, and the coding -/// agents this feature exists to serve (Claude Code, Codex, Gemini CLI) shipped -/// against these revisions. Dropping them would make the MCP server useless in -/// exactly the place it is meant to be used. -pub const SUPPORTED_VERSIONS: &[&str] = &[ - PROTOCOL_MODERN, - "2025-11-25", - "2025-06-18", - "2025-03-26", - "2024-11-05", -]; - -/// Version reported to a legacy client that did not name one we recognise. -pub const PROTOCOL_LEGACY_FALLBACK: &str = "2025-06-18"; - -pub const SERVER_NAME: &str = "spotatui"; -pub const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION"); - -/// Freshness hint on cacheable list results. The tool set is a compile-time -/// constant, so it is safe to let clients hold it for a long while. -pub const LIST_TTL_MS: u64 = 3_600_000; -/// `private`, not `public`: these results describe one user's player and -/// listening history, so a shared intermediary must not cache them. -pub const CACHE_SCOPE: &str = "private"; - -// `_meta` keys reserved by the specification. -pub const META_PROTOCOL_VERSION: &str = "io.modelcontextprotocol/protocolVersion"; -pub const META_CLIENT_CAPABILITIES: &str = "io.modelcontextprotocol/clientCapabilities"; -pub const META_CLIENT_INFO: &str = "io.modelcontextprotocol/clientInfo"; -pub const META_SERVER_INFO: &str = "io.modelcontextprotocol/serverInfo"; - -// JSON-RPC standard codes. -pub const PARSE_ERROR: i64 = -32700; -pub const INVALID_REQUEST: i64 = -32600; -pub const METHOD_NOT_FOUND: i64 = -32601; -pub const INVALID_PARAMS: i64 = -32602; -// MCP-allocated codes. The `-32020..=-32099` band is reserved for the spec, and -// these were renumbered out of `-3200x` in this revision — emitting the old -// values would now be a protocol violation. -pub const MISSING_REQUIRED_CLIENT_CAPABILITY: i64 = -32021; -pub const UNSUPPORTED_PROTOCOL_VERSION: i64 = -32022; - -/// Which protocol generation a single connection is speaking. -/// -/// The spec makes this a property of how the client *opens*: a request carrying -/// modern per-request `_meta` is served statelessly, an `initialize` request -/// selects legacy semantics scoped to the process. It is latched per connection -/// rather than per process because the TUI serves several agents at once. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub enum Era { - /// Nothing has arrived yet that settles the question. - #[default] - Unknown, - Modern, - /// Legacy, carrying the version negotiated at `initialize`. - Legacy(String), -} - -impl Era { - pub fn is_legacy(&self) -> bool { - matches!(self, Self::Legacy(_)) - } -} - -/// Server capabilities. -/// -/// `tools` only, and deliberately without `listChanged`: the tool set is a -/// compile-time constant, so there is nothing to notify about and no -/// `subscriptions/listen` stream to implement. -pub fn capabilities() -> Value { - json!({ "tools": {} }) -} - -pub fn server_info() -> Value { - json!({ "name": SERVER_NAME, "version": SERVER_VERSION }) -} - -/// Guidance handed to the model alongside the tool list. -pub fn instructions() -> &'static str { - "Controls the user's spotatui music player. To act as their DJ: call \ - get_listening_history first to learn their taste, then search_tracks to \ - confirm a track exists, then queue_tracks. queue_tracks reports anything it \ - could not find, so read the result rather than assuming every track was \ - queued. Prefer queue_tracks over play_now unless the user asked to hear \ - something immediately. search_tracks marks each result as owned when the user \ - already has it (Liked Songs, or a playlist they own or collaborate on): when \ - they asked for something new, pick the ones marked new, or pass exclude_owned \ - to queue_tracks to have it skip the rest for you." -} - -/// A JSON-RPC id. Kept as a `Value` because the spec allows string or number and -/// requires echoing it back unchanged. -pub type Id = Value; - -pub fn error_response(id: Option<&Id>, code: i64, message: impl Into) -> Value { - json!({ - "jsonrpc": "2.0", - "id": id.cloned().unwrap_or(Value::Null), - "error": { "code": code, "message": message.into() } - }) -} - -pub fn error_response_with_data( - id: Option<&Id>, - code: i64, - message: impl Into, - data: Value, -) -> Value { - json!({ - "jsonrpc": "2.0", - "id": id.cloned().unwrap_or(Value::Null), - "error": { "code": code, "message": message.into(), "data": data } - }) -} - -/// The `UnsupportedProtocolVersionError` a modern server owes a client whose -/// requested version it cannot serve. -pub fn unsupported_version_response(id: Option<&Id>, requested: &str) -> Value { - error_response_with_data( - id, - UNSUPPORTED_PROTOCOL_VERSION, - "Unsupported protocol version", - json!({ "supported": SUPPORTED_VERSIONS, "requested": requested }), - ) -} - -/// Wrap a result body for the connection's era. -/// -/// Modern results carry `resultType` and a `_meta.serverInfo`; legacy results -/// must not, since those fields did not exist in those revisions. -pub fn result_response(id: Option<&Id>, era: &Era, mut body: Map) -> Value { - if !era.is_legacy() { - body.insert("resultType".to_string(), json!("complete")); - // `or_insert_with` hands back whatever is already there, so a future caller - // that put a non-object under `_meta` would panic a connection task here. - // Overwrite it instead: the field is ours to shape. - let entry = body.entry("_meta".to_string()).or_insert_with(|| json!({})); - if !entry.is_object() { - *entry = json!({}); - } - if let Some(meta) = entry.as_object_mut() { - meta.insert(META_SERVER_INFO.to_string(), server_info()); - } - } - json!({ - "jsonrpc": "2.0", - "id": id.cloned().unwrap_or(Value::Null), - "result": Value::Object(body) - }) -} - -/// Add the `CacheableResult` fields required on list-shaped results. -pub fn add_cache_hints(body: &mut Map) { - body.insert("ttlMs".to_string(), json!(LIST_TTL_MS)); - body.insert("cacheScope".to_string(), json!(CACHE_SCOPE)); -} - -/// The `DiscoverResult` body for `server/discover`. -pub fn discover_body() -> Map { - let mut body = Map::new(); - body.insert("supportedVersions".to_string(), json!(SUPPORTED_VERSIONS)); - body.insert("capabilities".to_string(), capabilities()); - body.insert("instructions".to_string(), json!(instructions())); - add_cache_hints(&mut body); - body -} - -/// The legacy `initialize` result body. -pub fn initialize_body(negotiated: &str) -> Map { - let mut body = Map::new(); - body.insert("protocolVersion".to_string(), json!(negotiated)); - body.insert("capabilities".to_string(), capabilities()); - body.insert("serverInfo".to_string(), server_info()); - body.insert("instructions".to_string(), json!(instructions())); - body -} - -/// Pick the version to negotiate with a legacy client. -/// -/// Legacy clients have no fall-forward path, so echoing back a version they -/// asked for is the only outcome that leaves them working; when we do not -/// recognise it at all, name one we do rather than failing the handshake. -pub fn negotiate_legacy(requested: Option<&str>) -> String { - match requested { - Some(version) if SUPPORTED_VERSIONS.contains(&version) => version.to_string(), - _ => PROTOCOL_LEGACY_FALLBACK.to_string(), - } -} - -/// Extract `params._meta`. -pub fn meta(params: Option<&Value>) -> Option<&Map> { - params?.get("_meta")?.as_object() -} - -/// Whether a request carries the per-request metadata that identifies a modern -/// client. Used to settle [`Era`] on the first substantive request. -pub fn looks_modern(params: Option<&Value>) -> bool { - meta(params) - .and_then(|meta| meta.get(META_PROTOCOL_VERSION)) - .and_then(Value::as_str) - .is_some() -} - -/// Validation outcome for a modern request's `_meta`. -pub enum MetaCheck { - Ok { - version: String, - }, - /// A required field is absent, so the request is malformed. - Malformed(&'static str), - UnsupportedVersion(String), - MissingCapabilities, -} - -/// Check the `_meta` fields the spec marks required on every modern request. -pub fn check_meta(params: Option<&Value>) -> MetaCheck { - let Some(meta) = meta(params) else { - return MetaCheck::Malformed( - "requests must carry params._meta with io.modelcontextprotocol/protocolVersion and \ - io.modelcontextprotocol/clientCapabilities", - ); - }; - let Some(version) = meta.get(META_PROTOCOL_VERSION).and_then(Value::as_str) else { - return MetaCheck::Malformed( - "params._meta['io.modelcontextprotocol/protocolVersion'] is required", - ); - }; - if !SUPPORTED_VERSIONS.contains(&version) { - return MetaCheck::UnsupportedVersion(version.to_string()); - } - // Required, and load-bearing rather than ceremonial: the spec forbids relying - // on a capability the client has not declared. - if meta.get(META_CLIENT_CAPABILITIES).is_none() { - return MetaCheck::MissingCapabilities; - } - MetaCheck::Ok { - version: version.to_string(), - } -} - -/// The `MissingRequiredClientCapabilityError` response. -pub fn missing_capabilities_response(id: Option<&Id>) -> Value { - error_response_with_data( - id, - MISSING_REQUIRED_CLIENT_CAPABILITY, - "Missing required client capabilities", - json!({ "requiredCapabilities": [META_CLIENT_CAPABILITIES] }), - ) -} - -/// A `tools/call` result. `content` is always text; `structuredContent` mirrors -/// it as JSON when the tool has a machine-readable form. -pub fn tool_result_body( - text: &str, - structured: Option, - is_error: bool, -) -> Map { - let mut body = Map::new(); - body.insert( - "content".to_string(), - json!([{ "type": "text", "text": text }]), - ); - body.insert("isError".to_string(), json!(is_error)); - if let Some(structured) = structured { - body.insert("structuredContent".to_string(), structured); - } - body -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn modern_results_carry_result_type_and_server_info() { - let response = result_response(Some(&json!(1)), &Era::Modern, Map::new()); - let result = &response["result"]; - assert_eq!(result["resultType"], json!("complete")); - assert_eq!( - result["_meta"][META_SERVER_INFO]["name"], - json!(SERVER_NAME) - ); - } - - #[test] - fn legacy_results_omit_modern_only_fields() { - // `resultType` did not exist in the legacy revisions; emitting it to a - // legacy client is at best noise and at worst a validation failure. - let response = result_response( - Some(&json!(1)), - &Era::Legacy("2025-06-18".into()), - Map::new(), - ); - let result = &response["result"]; - assert!(result.get("resultType").is_none()); - assert!(result.get("_meta").is_none()); - } - - #[test] - fn discover_advertises_every_supported_version() { - let body = discover_body(); - let versions = body["supportedVersions"].as_array().unwrap(); - assert_eq!(versions[0], json!(PROTOCOL_MODERN)); - assert!(versions.len() > 1, "legacy versions must be advertised too"); - // server/discover is cacheable, so it owes the CacheableResult fields. - assert_eq!(body["ttlMs"], json!(LIST_TTL_MS)); - assert_eq!(body["cacheScope"], json!(CACHE_SCOPE)); - } - - #[test] - fn capabilities_omit_list_changed() { - // Claiming listChanged would oblige us to serve subscriptions/listen. - let caps = capabilities(); - assert!(caps["tools"].as_object().unwrap().is_empty()); - } - - #[test] - fn meta_check_accepts_a_well_formed_modern_request() { - let params = json!({"_meta": { - META_PROTOCOL_VERSION: PROTOCOL_MODERN, - META_CLIENT_CAPABILITIES: {}, - }}); - assert!(matches!( - check_meta(Some(¶ms)), - MetaCheck::Ok { version } if version == PROTOCOL_MODERN - )); - } - - #[test] - fn meta_check_rejects_missing_fields() { - assert!(matches!(check_meta(None), MetaCheck::Malformed(_))); - assert!(matches!( - check_meta(Some(&json!({}))), - MetaCheck::Malformed(_) - )); - let no_caps = json!({"_meta": {META_PROTOCOL_VERSION: PROTOCOL_MODERN}}); - assert!(matches!( - check_meta(Some(&no_caps)), - MetaCheck::MissingCapabilities - )); - } - - #[test] - fn meta_check_reports_unsupported_versions() { - let params = json!({"_meta": { - META_PROTOCOL_VERSION: "1900-01-01", - META_CLIENT_CAPABILITIES: {}, - }}); - assert!(matches!( - check_meta(Some(¶ms)), - MetaCheck::UnsupportedVersion(v) if v == "1900-01-01" - )); - } - - #[test] - fn unsupported_version_error_uses_the_renumbered_code() { - let response = unsupported_version_response(Some(&json!(1)), "1900-01-01"); - assert_eq!(response["error"]["code"], json!(-32022)); - assert_eq!(response["error"]["data"]["requested"], json!("1900-01-01")); - assert!(response["error"]["data"]["supported"] - .as_array() - .unwrap() - .contains(&json!(PROTOCOL_MODERN))); - } - - #[test] - fn legacy_negotiation_echoes_known_versions() { - assert_eq!(negotiate_legacy(Some("2025-06-18")), "2025-06-18"); - assert_eq!(negotiate_legacy(Some("2025-11-25")), "2025-11-25"); - // Unknown or absent: name something we do speak rather than fail, since a - // legacy client cannot fall forward. - assert_eq!( - negotiate_legacy(Some("1999-01-01")), - PROTOCOL_LEGACY_FALLBACK - ); - assert_eq!(negotiate_legacy(None), PROTOCOL_LEGACY_FALLBACK); - } - - #[test] - fn looks_modern_only_when_protocol_version_present() { - assert!(!looks_modern(None)); - assert!(!looks_modern(Some(&json!({})))); - assert!(!looks_modern(Some(&json!({"_meta": {}})))); - assert!(looks_modern(Some( - &json!({"_meta": {META_PROTOCOL_VERSION: PROTOCOL_MODERN}}) - ))); - } - - #[test] - fn tool_result_marks_errors_explicitly() { - let ok = tool_result_body("done", None, false); - assert_eq!(ok["isError"], json!(false)); - assert_eq!(ok["content"][0]["type"], json!("text")); - - let failed = tool_result_body("nope", None, true); - assert_eq!(failed["isError"], json!(true)); - } -} diff --git a/src/infra/mcp/relay.rs b/src/infra/mcp/relay.rs deleted file mode 100644 index ef56762d..00000000 --- a/src/infra/mcp/relay.rs +++ /dev/null @@ -1,1069 +0,0 @@ -//! `spotatui mcp`: the stdio MCP server an agent spawns. -//! -//! Mostly a **line pump** — the real server lives in the TUI process, where -//! `App` is, and both hops speak the same newline-delimited JSON-RPC framing so -//! nothing has to be translated. -//! -//! Which end serves a given line is decided per line, not once at startup. The -//! agent spawns this process when its session opens, which is routinely before -//! the user has started spotatui, and the user may restart spotatui while the -//! session is still going. So each line connects if it can and is forwarded, and -//! falls back to answering here with an actionable `isError` when it cannot. -//! Deciding once meant the first of those orderings stranded every tool for the -//! rest of the session, behind an error telling the user to start the player -//! they had just started. - -use super::control::{self, Handshake}; -use super::executor::OfflineExecutor; -use super::protocol as proto; -use super::server; -use anyhow::Result; -use std::time::Duration; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::net::TcpStream; - -/// How long a forwarded request may go unanswered before the relay gives up on -/// the socket. -/// -/// Deliberately generous rather than snappy: `queue_tracks(exclude_owned)` -/// crawls the whole playlist library inline before it answers, and the serial -/// lane it runs on is head-of-line blocking, so a short deadline would turn a -/// slow-but-working call into a false failure. It exists only to bound the -/// unbounded case — a stopped process or a half-open connection, where nothing -/// is ever coming and the agent's whole MCP session would otherwise hang. -const UPSTREAM_RESPONSE_TIMEOUT: Duration = Duration::from_secs(300); -/// The handshake replay is answered from memory the moment it lands, so it gets -/// its own, much shorter deadline: waiting five minutes to discover a socket is -/// dead would strand every line behind it. -const HANDSHAKE_REPLAY_TIMEOUT: Duration = Duration::from_secs(15); - -/// Entry point for the `mcp` subcommand. -/// -/// Never returns an error for "no player running" — that is a normal state and -/// is reported through the protocol instead. -pub async fn run() -> Result<()> { - let mut relay = Relay::default(); - let mut from_agent = BufReader::new(tokio::io::stdin()).lines(); - let mut stdout = tokio::io::stdout(); - - // Per the spec, exit when stdin reaches EOF: the primary graceful-shutdown - // signal for stdio, and the only portable one. - while let Some(line) = from_agent.next_line().await? { - if line.trim().is_empty() { - continue; - } - if let Some(response) = relay.handle(&line).await { - stdout.write_all(response.as_bytes()).await?; - stdout.write_all(b"\n").await?; - stdout.flush().await?; - } - } - Ok(()) -} - -/// The live half of the relay: a socket to the TUI, plus what it takes to -/// rebuild that socket. -struct Upstream { - reader: tokio::io::Lines>, - writer: tokio::net::tcp::OwnedWriteHalf, - /// Whether a line has come back from this socket that proves the token was - /// accepted. - /// - /// The control channel answers a *good* token with silence and a bad one with - /// one non-MCP line, so nothing distinguishes the two until the socket first - /// speaks. Checked once rather than on every response: one well-formed line - /// settles it for the life of the connection. - token_checked: bool, -} - -/// The control channel's answer to a bad token, which is one line followed by a -/// hang-up. It is not MCP traffic and must never reach the agent as the response -/// to its request; a JSON-RPC error response is distinguishable because its -/// `error` is an object, not a bare string. -fn control_rejection(line: &str) -> Option { - match serde_json::from_str::(line) - .ok()? - .get("error")? - .as_str()? - { - "unauthorized" => Some( - "spotatui rejected the control token; the control file is stale, so restart spotatui" - .to_string(), - ), - _ => None, - } -} - -/// Whether `reply` is the JSON-RPC response to `request`. -/// -/// The handshake replay is the one line the relay reads on its own account, so -/// nothing downstream would notice a socket that answered with something else. -/// A control file outliving the process that wrote it is how that happens: -/// shutdown unpublishes it, a crash does not, and by the time the next relay -/// reads it the port may belong to anything. Taking that process's first line -/// for the handshake answer would mark its socket healthy and hand the agent -/// its next one as a response. -fn answers_request(reply: &str, request: &str) -> bool { - let Ok(reply) = serde_json::from_str::(reply) else { - return false; - }; - if reply.get("jsonrpc").and_then(serde_json::Value::as_str) != Some("2.0") - || (reply.get("result").is_none() && reply.get("error").is_none()) - { - return false; - } - // Matched on id whenever the request carries one, which `initialize` always - // does. One without an id is a notification the server never answers, so - // there would be no reply here to match in the first place. - match serde_json::from_str::(request) - .ok() - .and_then(|request| request.get("id").cloned()) - .filter(|id| !id.is_null()) - { - Some(id) => reply.get("id") == Some(&id), - None => true, - } -} - -/// Why a forward failed, which is what decides whether it may be sent again. -/// -/// The distinction is the whole point: MCP tool calls are not idempotent, so -/// "retry on any error" turns one dropped socket into a batch queued twice. -enum ForwardError { - /// The line never reached spotatui. Replaying it cannot duplicate anything. - Unsent(anyhow::Error), - /// spotatui had the line when the connection dropped. Whether it acted on it - /// is unknowable from here, so it must not be replayed. - Sent(anyhow::Error), -} - -/// What to tell the agent about a request the relay could not complete. -/// -/// A delivered-then-lost request has to say so: MCP tool calls are not -/// idempotent, and an agent that reads a bare "lost the connection" will retry -/// the batch that may already have been queued. -fn offline_reason(error: &ForwardError) -> String { - match error { - ForwardError::Unsent(e) => format!("lost the connection to spotatui ({e})"), - ForwardError::Sent(e) => format!( - "lost the connection to spotatui after the request was sent ({e}); it may or may not have \ - been applied, so check the current state before retrying" - ), - } -} - -impl std::fmt::Display for ForwardError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ForwardError::Unsent(e) | ForwardError::Sent(e) => write!(f, "{e}"), - } - } -} - -/// How the relay finds the running TUI. Injected rather than hardcoded so tests -/// can point at a fake listener without writing to the user's config directory -/// or mutating the process-wide handshake-path variable — which is shared, and -/// so would make the tests order-dependent on each other. -type Locate = Box Result + Send>; - -/// Relays each line to the TUI, connecting on demand. -/// -/// The connection is established per line rather than once at startup, because -/// the two processes have independent lifetimes and neither order is unusual: -/// an agent typically spawns this server when its session opens, which may be -/// long before the user starts spotatui — and the user may restart spotatui -/// halfway through a session. Deciding once meant either state stranded the -/// tools for the rest of the session with an error telling the user to do the -/// thing they had just done. -struct Relay { - upstream: Option, - locate: Locate, - /// The client's `initialize` line, replayed on every new connection. - /// - /// A legacy client handshakes once and its later requests carry no `_meta`. - /// The TUI latches the era per connection, so a reconnect that skipped the - /// replay would reject those requests for missing the fields the handshake - /// exists to make unnecessary. - handshake: Option, - /// Why the last connection attempt failed, quoted to the agent verbatim. - offline_reason: String, - /// Only used while offline; upstream answers carry the TUI's own session. - session: server::Session, - /// A field rather than the constant so the deadline is testable without - /// waiting it out or manipulating the clock. - response_timeout: Duration, -} - -impl Default for Relay { - fn default() -> Self { - Self { - upstream: None, - locate: Box::new(control::read_handshake), - handshake: None, - offline_reason: String::new(), - session: server::Session::default(), - response_timeout: UPSTREAM_RESPONSE_TIMEOUT, - } - } -} - -impl Relay { - /// Handle one line from the agent, returning the line to write back. - async fn handle(&mut self, line: &str) -> Option { - let parsed = serde_json::from_str::(line).ok(); - // Mirrors the server's own rule for what it will answer, because guessing - // wrong desynchronises the stream for good: a reply we never read is left in - // the socket, and every later request then returns the previous one's - // response, under the wrong id. Only a well-formed notification — an object - // with a method and no id — goes unanswered. Everything else, including a - // parse failure or a JSON value that is not an object at all, comes back as - // an error response. - let expects_response = match &parsed { - Some(serde_json::Value::Object(message)) => { - message.get("id").is_some_and(|id| !id.is_null()) - || message - .get("method") - .and_then(serde_json::Value::as_str) - .is_none() - } - _ => true, - }; - let is_initialize = parsed - .as_ref() - .and_then(|message| message.get("method")) - .and_then(serde_json::Value::as_str) - == Some("initialize"); - - let response = self.dispatch(line, expects_response).await; - - // Remembered *after* dispatch, never before: on the connection this line is - // forwarded over, the handshake is this very line, and replaying it as well - // would hand the TUI two initializes and leave a reply nobody reads. - if is_initialize { - self.handshake = Some(line.to_string()); - } - response - } - - async fn dispatch(&mut self, line: &str, expects_response: bool) -> Option { - if self.ensure_upstream().await { - match self.forward(line, expects_response).await { - Ok(response) => return response, - Err(ForwardError::Unsent(e)) => { - // The TUI went away before it heard us. Rebuild once and retry before - // falling back, so a restart costs one line rather than the session. - // Safe to replay precisely because the line never landed. - log::debug!("MCP relay: upstream failed before the send ({e}); reconnecting"); - self.upstream = None; - if self.ensure_upstream().await { - match self.forward(line, expects_response).await { - Ok(response) => return response, - // The retry is where the line can *become* delivered, so its own - // variant decides the wording. Reporting a plain "lost the - // connection" here is what would send the agent back to retry a - // batch that had in fact been queued. - Err(retry_error) => { - self.upstream = None; - self.offline_reason = offline_reason(&retry_error); - return self.answer_offline(line).await; - } - } - } - // Left as `ensure_upstream` set it. The reconnect is the step that - // failed, and its reason ("is spotatui running with - // behavior.mcp_enabled set?") is the one the user can act on; - // overwriting it with the earlier send error hands the agent the - // staler half of the story. - log::debug!("MCP relay: reconnect failed after {e}"); - } - Err(sent @ ForwardError::Sent(_)) => { - // Deliberately *not* retried. spotatui had the request when the socket - // died, so it may have acted on it; re-forwarding `queue_tracks` would - // queue the same batch twice. An honest "may or may not have applied" - // is recoverable by an agent, a silent duplicate is not. - log::debug!("MCP relay: upstream failed after the send ({sent}); not replaying"); - self.upstream = None; - self.offline_reason = offline_reason(&sent); - } - } - } - - self.answer_offline(line).await - } - - /// Connect if not already connected, replaying the handshake on a new socket. - async fn ensure_upstream(&mut self) -> bool { - if self.upstream.is_some() { - return true; - } - let stream = match connect(&self.locate).await { - Ok(stream) => stream, - Err(reason) => { - self.offline_reason = reason; - return false; - } - }; - let (read_half, writer) = stream.into_split(); - let mut upstream = Upstream { - reader: BufReader::new(read_half).lines(), - writer, - token_checked: false, - }; - if let Some(handshake) = self.handshake.clone() { - // The reply is the TUI's answer to a handshake the agent already had - // answered, so it is consumed here rather than forwarded. - if let Err(e) = send_line(&mut upstream, &handshake).await { - self.offline_reason = format!("could not replay the handshake to spotatui ({e})"); - return false; - } - // Deadlined like every other upstream read: a socket that accepts the - // replay and never answers would otherwise block here forever, and this - // runs before any of the agent's own lines get a chance. - let replay = - tokio::time::timeout(HANDSHAKE_REPLAY_TIMEOUT, upstream.reader.next_line()).await; - match replay { - Ok(Ok(Some(reply))) => { - // The rejection line arrives exactly here on a stale control file, and - // taking it for a handshake answer would mark a socket the server has - // already hung up on as healthy. - if let Some(reason) = control_rejection(&reply) { - self.offline_reason = reason; - return false; - } - if !answers_request(&reply, &handshake) { - self.offline_reason = "the handshake was answered by something that is not spotatui; \ - the control file may name a port another process now holds" - .to_string(); - return false; - } - upstream.token_checked = true; - } - Ok(_) => { - self.offline_reason = "spotatui closed the connection during the handshake".to_string(); - return false; - } - Err(_) => { - self.offline_reason = - "spotatui did not answer the handshake in time; it may be stopped or wedged" - .to_string(); - return false; - } - } - } - self.upstream = Some(upstream); - true - } - - /// Write one line upstream and read its answer. - async fn forward( - &mut self, - line: &str, - expects_response: bool, - ) -> std::result::Result, ForwardError> { - let response_timeout = self.response_timeout; - let upstream = self - .upstream - .as_mut() - .ok_or_else(|| ForwardError::Unsent(anyhow::anyhow!("not connected")))?; - send_line(upstream, line) - .await - .map_err(ForwardError::Unsent)?; - if !expects_response { - return Ok(None); - } - // Past this point the request is in spotatui's hands, so every failure is - // `Sent`: it may already have queued the tracks. That includes the deadline - // expiring, which is why it maps to the same variant — and why the socket is - // then dropped rather than reused, since a late answer arriving on it would - // be read as the *next* request's response and desynchronise the stream for - // the rest of the session. - let read = tokio::time::timeout(response_timeout, upstream.reader.next_line()).await; - match read { - Ok(Ok(Some(response))) => { - // With no handshake to replay, this is the connection's first line, so - // it is where a rejected token surfaces. `Unsent`, not `Sent`: the - // control layer refuses before the MCP server ever reads the request, so - // nothing was applied and a replay cannot duplicate anything. - if !upstream.token_checked { - if let Some(reason) = control_rejection(&response) { - return Err(ForwardError::Unsent(anyhow::anyhow!("{reason}"))); - } - upstream.token_checked = true; - } - Ok(Some(response)) - } - Ok(Ok(None)) => Err(ForwardError::Sent(anyhow::anyhow!( - "spotatui closed the connection" - ))), - Ok(Err(e)) => Err(ForwardError::Sent(e.into())), - Err(_) => Err(ForwardError::Sent(anyhow::anyhow!( - "spotatui did not answer within {}s", - response_timeout.as_secs() - ))), - } - } - - /// Serve the protocol ourselves, reporting that the player is not reachable. - async fn answer_offline(&mut self, line: &str) -> Option { - let executor = OfflineExecutor::new(self.offline_reason.clone()); - // A handshake forwarded upstream never reached this session, so its era is - // still unknown while the client believes it has negotiated one. A legacy - // client's later requests carry no `_meta`, and would be rejected here for - // exactly the fields the handshake exists to make unnecessary — turning the - // player going away into a protocol error rather than the actionable - // "spotatui is not available" this path exists to give. Replay it first. - if self.session.era == proto::Era::Unknown { - if let Some(handshake) = self.handshake.clone() { - let _ = server::handle_line(&mut self.session, &handshake, &executor).await; - } - } - server::handle_line(&mut self.session, line, &executor) - .await - .map(|response| response.to_string()) - } -} - -async fn send_line(upstream: &mut Upstream, line: &str) -> Result<()> { - upstream.writer.write_all(line.as_bytes()).await?; - upstream.writer.write_all(b"\n").await?; - upstream.writer.flush().await?; - Ok(()) -} - -/// Locate and authenticate to the running TUI. -/// -/// The `Err` payload is a human-readable reason, surfaced verbatim to the agent. -async fn connect(locate: &Locate) -> std::result::Result { - let handshake: Handshake = locate().map_err(|e| { - // The common cases are "not running" and "mcp_enabled is off"; both look - // identical from here, so say so rather than guessing. - format!("{e} (is spotatui running with behavior.mcp_enabled set?)") - })?; - - let mut stream = TcpStream::connect(("127.0.0.1", handshake.port)) - .await - .map_err(|e| { - format!( - "could not connect to spotatui on 127.0.0.1:{} ({e}); the control file may be stale", - handshake.port - ) - })?; - - let hello = serde_json::json!({ "token": handshake.token }); - stream - .write_all(format!("{hello}\n").as_bytes()) - .await - .map_err(|e| format!("could not send the control handshake: {e}"))?; - - Ok(stream) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::infra::mcp::protocol as proto; - use serde_json::json; - use std::sync::{Arc, Mutex as StdMutex}; - use tokio::net::TcpListener; - - #[tokio::test] - async fn offline_mode_still_serves_a_valid_protocol() { - // The important property: an agent that spawns us with no player running - // gets a working MCP server, not a crash. - let executor = OfflineExecutor::new("no control file"); - let input = format!( - "{}\n{}\n{}\n", - json!({"jsonrpc": "2.0", "id": 1, "method": "server/discover", "params": {"_meta": { - proto::META_PROTOCOL_VERSION: proto::PROTOCOL_MODERN, - proto::META_CLIENT_CAPABILITIES: {}, - }}}), - json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {"_meta": { - proto::META_PROTOCOL_VERSION: proto::PROTOCOL_MODERN, - proto::META_CLIENT_CAPABILITIES: {}, - }}}), - json!({"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { - "_meta": { - proto::META_PROTOCOL_VERSION: proto::PROTOCOL_MODERN, - proto::META_CLIENT_CAPABILITIES: {}, - }, - "name": "get_now_playing", - "arguments": {} - }}), - ); - let mut output = Vec::new(); - server::serve(input.as_bytes(), &mut output, &executor) - .await - .unwrap(); - - let text = String::from_utf8(output).unwrap(); - let responses: Vec = text - .lines() - .map(|line| serde_json::from_str(line).unwrap()) - .collect(); - assert_eq!(responses.len(), 3); - // discover and tools/list work fine offline — only execution fails. - assert!(responses[0]["result"]["supportedVersions"].is_array()); - assert!(responses[1]["result"]["tools"].is_array()); - assert_eq!(responses[2]["result"]["isError"], json!(true)); - assert!(responses[2]["result"]["content"][0]["text"] - .as_str() - .unwrap() - .contains("not available")); - } - - #[tokio::test] - async fn offline_mode_also_works_for_a_legacy_client() { - // Claude Code / Codex as shipped today open with `initialize`; offline mode - // must not be modern-only or it will look broken to them. - let executor = OfflineExecutor::new("no control file"); - let input = format!( - "{}\n{}\n", - json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": "2025-06-18", "capabilities": {}}}), - json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}), - ); - let mut output = Vec::new(); - server::serve(input.as_bytes(), &mut output, &executor) - .await - .unwrap(); - let responses: Vec = String::from_utf8(output) - .unwrap() - .lines() - .map(|line| serde_json::from_str(line).unwrap()) - .collect(); - assert_eq!( - responses[0]["result"]["protocolVersion"], - json!("2025-06-18") - ); - assert!(responses[1]["result"]["tools"].is_array()); - } - - /// A stand-in TUI: authenticates like the real listener, echoes every request - /// back, and keeps accepting new connections so a reconnect can be observed. - /// - /// Lines land in the returned buffer, shared rather than returned by the task - /// because the task never finishes on its own. - fn fake_tui(listener: TcpListener, token: String) -> Arc>> { - let seen = Arc::new(StdMutex::new(Vec::new())); - let sink = Arc::clone(&seen); - tokio::spawn(async move { - loop { - let Ok((stream, _)) = listener.accept().await else { - break; - }; - let sink = Arc::clone(&sink); - let token = token.clone(); - tokio::spawn(async move { - let (read_half, mut write_half) = stream.into_split(); - let mut lines = BufReader::new(read_half).lines(); - let Ok(Some(first)) = lines.next_line().await else { - return; - }; - assert_eq!( - serde_json::from_str::(&first).unwrap()["token"], - json!(token), - "the relay must present the token before any MCP traffic" - ); - while let Ok(Some(line)) = lines.next_line().await { - let id = serde_json::from_str::(&line) - .ok() - .and_then(|message| message.get("id").cloned()) - .unwrap_or(serde_json::Value::Null); - sink.lock().unwrap().push(line); - let response = json!({"jsonrpc": "2.0", "id": id, "result": {"echoed": true}}); - if write_half - .write_all(format!("{response}\n").as_bytes()) - .await - .is_err() - { - return; - } - } - }); - } - }); - seen - } - - /// A stand-in TUI that takes the request and then dies without answering — - /// the one failure the relay must not paper over by sending it again. - /// - /// Keeps accepting, so a replay onto a fresh connection would show up in the - /// buffer rather than simply failing to connect. - fn fake_tui_that_dies_after_reading( - listener: TcpListener, - token: String, - ) -> Arc>> { - let seen = Arc::new(StdMutex::new(Vec::new())); - let sink = Arc::clone(&seen); - tokio::spawn(async move { - loop { - let Ok((stream, _)) = listener.accept().await else { - break; - }; - let sink = Arc::clone(&sink); - let token = token.clone(); - tokio::spawn(async move { - let (read_half, _write_half) = stream.into_split(); - let mut lines = BufReader::new(read_half).lines(); - let Ok(Some(first)) = lines.next_line().await else { - return; - }; - assert_eq!( - serde_json::from_str::(&first).unwrap()["token"], - json!(token) - ); - if let Ok(Some(line)) = lines.next_line().await { - sink.lock().unwrap().push(line); - } - // Drop both halves without writing a response. - }); - } - }); - seen - } - - /// A stand-in TUI that reads the request and then simply never answers, the - /// way a stopped process or a half-open connection does. Holds the socket open - /// rather than dropping it, so only a deadline can end the wait. - fn fake_tui_that_never_answers(listener: TcpListener, token: String) { - tokio::spawn(async move { - loop { - let Ok((stream, _)) = listener.accept().await else { - break; - }; - let token = token.clone(); - tokio::spawn(async move { - let (read_half, _write_half) = stream.into_split(); - let mut lines = BufReader::new(read_half).lines(); - let Ok(Some(first)) = lines.next_line().await else { - return; - }; - assert_eq!( - serde_json::from_str::(&first).unwrap()["token"], - json!(token) - ); - // Read everything, answer nothing, and keep both halves alive. - while let Ok(Some(_)) = lines.next_line().await {} - std::future::pending::<()>().await; - }); - } - }); - } - - /// A stand-in TUI whose token never matches: it answers with the control - /// channel's own rejection line and hangs up, exactly as `serve_connection` - /// does. - fn fake_tui_that_rejects_the_token(listener: TcpListener) { - tokio::spawn(async move { - loop { - let Ok((stream, _)) = listener.accept().await else { - break; - }; - tokio::spawn(async move { - let (read_half, mut write_half) = stream.into_split(); - let mut lines = BufReader::new(read_half).lines(); - if lines.next_line().await.is_err() { - return; - } - let _ = write_half - .write_all(b"{\"error\":\"unauthorized\"}\n") - .await; - }); - } - }); - } - - /// A stand-in that takes the token line and then answers the replayed - /// handshake with `reply`, whatever that is. Holds the socket open afterwards, - /// so a rejection has to come from validating the line rather than a hang-up. - fn fake_tui_answering_with(listener: TcpListener, reply: String) { - tokio::spawn(async move { - loop { - let Ok((stream, _)) = listener.accept().await else { - break; - }; - let reply = reply.clone(); - tokio::spawn(async move { - let (read_half, mut write_half) = stream.into_split(); - let mut lines = BufReader::new(read_half).lines(); - for _ in 0..2 { - if !matches!(lines.next_line().await, Ok(Some(_))) { - return; - } - } - let _ = write_half.write_all(format!("{reply}\n").as_bytes()).await; - std::future::pending::<()>().await; - }); - } - }); - } - - /// A relay pointed at `port`, with no dependence on any shared path or env. - fn relay_for(port: u16, token: &str) -> Relay { - let token = token.to_string(); - Relay { - locate: Box::new(move || { - Ok(Handshake { - port, - token: token.clone(), - pid: std::process::id(), - }) - }), - ..Relay::default() - } - } - - /// A relay that can never find a player. - fn relay_with_no_player() -> Relay { - Relay { - locate: Box::new(|| anyhow::bail!("no MCP control file")), - ..Relay::default() - } - } - - async fn wait_for_lines(seen: &Arc>>, count: usize) -> Vec { - for _ in 0..200 { - let lines = seen.lock().unwrap().clone(); - if lines.len() >= count { - return lines; - } - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - seen.lock().unwrap().clone() - } - - fn modern_call() -> String { - json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { - "_meta": { - proto::META_PROTOCOL_VERSION: proto::PROTOCOL_MODERN, - proto::META_CLIENT_CAPABILITIES: {}, - }, - "name": "get_now_playing", "arguments": {} - }}) - .to_string() - } - - #[tokio::test] - async fn a_relay_that_started_before_the_player_connects_once_it_appears() { - // The regression this exists for: the relay used to decide pump-vs-offline - // once at startup, so an agent that spawned it before the user opened - // spotatui got "not available" for the rest of the session, from an error - // telling the user to do the thing they had already done. - let mut relay = relay_with_no_player(); - let offline: serde_json::Value = - serde_json::from_str(&relay.handle(&modern_call()).await.unwrap()).unwrap(); - assert_eq!(offline["result"]["isError"], json!(true)); - - // The player starts. The very next call must reach it, with no restart. - let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); - let port = listener.local_addr().unwrap().port(); - let seen = fake_tui(listener, "tok".to_string()); - relay.locate = Box::new(move || { - Ok(Handshake { - port, - token: "tok".to_string(), - pid: 1, - }) - }); - - let online: serde_json::Value = - serde_json::from_str(&relay.handle(&modern_call()).await.unwrap()).unwrap(); - assert_eq!( - online["result"]["echoed"], - json!(true), - "the call must be served by the player, not answered offline" - ); - assert_eq!(wait_for_lines(&seen, 1).await.len(), 1); - } - - #[tokio::test] - async fn the_handshake_is_replayed_after_a_reconnect_but_not_before() { - // A legacy client handshakes once; its later requests carry no `_meta`, so - // a rebuilt connection that skipped the replay would reject them. Replaying - // on the *first* connection would instead send initialize twice. - let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); - let port = listener.local_addr().unwrap().port(); - let seen = fake_tui(listener, "tok".to_string()); - let mut relay = relay_for(port, "tok"); - - let init = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": "2025-06-18"}}) - .to_string(); - relay.handle(&init).await.unwrap(); - assert_eq!( - wait_for_lines(&seen, 1).await.len(), - 1, - "the first connection must carry initialize exactly once" - ); - - // Drop the socket, as a spotatui restart would. - relay.upstream = None; - let listed = json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}).to_string(); - relay.handle(&listed).await.unwrap(); - - let lines = wait_for_lines(&seen, 3).await; - assert_eq!(lines.len(), 3, "got {lines:?}"); - assert!(lines[0].contains("initialize")); - assert!( - lines[1].contains("initialize"), - "the handshake must be replayed before the forwarded call: {lines:?}" - ); - assert!(lines[2].contains("tools/list")); - } - - #[tokio::test] - async fn a_request_lost_after_delivery_is_reported_rather_than_sent_twice() { - // MCP tool calls are not idempotent: if spotatui took `queue_tracks` and then - // died, replaying it would queue the batch a second time. The relay cannot - // know whether it was applied, so it has to say exactly that. - let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); - let port = listener.local_addr().unwrap().port(); - let seen = fake_tui_that_dies_after_reading(listener, "tok".to_string()); - let mut relay = relay_for(port, "tok"); - - let response: serde_json::Value = - serde_json::from_str(&relay.handle(&modern_call()).await.unwrap()).unwrap(); - - // Waits for a *second* line, which is what gives a replay time to show up: - // asking for one would return the instant the first arrived and pass either - // way. - let lines = wait_for_lines(&seen, 2).await; - assert_eq!( - lines.len(), - 1, - "the request must not be forwarded a second time: {lines:?}" - ); - let text = response.to_string(); - assert!( - text.contains("may or may not have been applied"), - "the agent has to be told it is unknown, not that nothing happened: {text}" - ); - } - - #[tokio::test] - async fn a_rejected_control_token_becomes_an_offline_error_not_a_response() { - // The control channel answers a bad token with one non-MCP line and hangs - // up. Forwarded as-is it becomes the agent's "response" to a request it is - // still waiting on, under no id it recognises. - let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); - let port = listener.local_addr().unwrap().port(); - fake_tui_that_rejects_the_token(listener); - let mut relay = relay_for(port, "stale-token"); - - let response: serde_json::Value = - serde_json::from_str(&relay.handle(&modern_call()).await.unwrap()).unwrap(); - - assert!( - response["result"]["isError"] == json!(true), - "the rejection must be reported, not relayed: {response}" - ); - let text = response.to_string(); - assert!( - text.contains("control token"), - "the reason has to name the stale control file: {text}" - ); - // Nothing reached the server, so the agent must not be warned off retrying. - assert!( - !text.contains("may or may not have been applied"), - "a refused token applied nothing: {text}" - ); - } - - #[tokio::test] - async fn the_handshake_replay_does_not_accept_a_rejection_as_a_valid_reply() { - // The same line arriving one read earlier. Taken for a handshake answer, it - // marked a socket the server had already hung up on as healthy. - let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); - let port = listener.local_addr().unwrap().port(); - fake_tui_that_rejects_the_token(listener); - let mut relay = relay_for(port, "stale-token"); - relay.handshake = Some( - json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": "2025-06-18"}}) - .to_string(), - ); - - assert!(!relay.ensure_upstream().await, "the socket is not usable"); - assert!( - relay.offline_reason.contains("control token"), - "{}", - relay.offline_reason - ); - } - - #[tokio::test] - async fn a_handshake_answered_by_something_other_than_spotatui_is_not_trusted() { - // A crash leaves the control file behind (only shutdown unpublishes it), so - // the port in it can end up belonging to an unrelated process. Its first - // line used to pass as the handshake answer, which marked the socket healthy - // and sent the agent whatever came next as a JSON-RPC response. - for reply in [ - // Not JSON-RPC at all. - json!({"hello": "not spotatui"}).to_string(), - // Well-formed, but answering a request nobody here sent. - json!({"jsonrpc": "2.0", "id": 99, "result": {}}).to_string(), - ] { - let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); - let port = listener.local_addr().unwrap().port(); - fake_tui_answering_with(listener, reply.clone()); - let mut relay = relay_for(port, "tok"); - relay.handshake = Some( - json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": "2025-06-18"}}) - .to_string(), - ); - - assert!( - !relay.ensure_upstream().await, - "{reply} must not pass as the handshake answer" - ); - assert!( - relay.offline_reason.contains("not spotatui"), - "{}", - relay.offline_reason - ); - } - } - - #[tokio::test] - async fn a_player_that_takes_the_request_and_never_answers_does_not_hang_the_session() { - // `run` handles one agent line at a time, so an upstream read with no - // deadline blocks every later tool call too — a stopped spotatui would take - // the agent's whole MCP session down with it. The deadline has to end the - // wait, and it has to be reported as `Sent`: the request did land, so the - // agent must not be told nothing happened. - let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); - let port = listener.local_addr().unwrap().port(); - fake_tui_that_never_answers(listener, "tok".to_string()); - let mut relay = Relay { - response_timeout: std::time::Duration::from_millis(100), - ..relay_for(port, "tok") - }; - - let response: serde_json::Value = serde_json::from_str( - &tokio::time::timeout( - std::time::Duration::from_secs(5), - relay.handle(&modern_call()), - ) - .await - .expect("the deadline must fire rather than hanging") - .unwrap(), - ) - .unwrap(); - - assert_eq!(response["result"]["isError"], json!(true)); - let text = response.to_string(); - assert!( - text.contains("may or may not have been applied"), - "a delivered-then-unanswered request has to say so: {text}" - ); - assert!( - relay.upstream.is_none(), - "the socket must be dropped, or a late answer would be read as the next request's" - ); - } - - #[tokio::test] - async fn a_player_that_restarts_mid_session_costs_one_line_not_the_session() { - let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); - let port = listener.local_addr().unwrap().port(); - let seen = fake_tui(listener, "tok".to_string()); - let mut relay = relay_for(port, "tok"); - - relay.handle(&modern_call()).await.unwrap(); - // Kill the socket underneath, exactly as a restart does. - relay.upstream = None; - let after: serde_json::Value = - serde_json::from_str(&relay.handle(&modern_call()).await.unwrap()).unwrap(); - assert_eq!( - after["result"]["echoed"], - json!(true), - "the call after a restart must be served, not answered offline" - ); - assert_eq!(wait_for_lines(&seen, 2).await.len(), 2); - } - - #[tokio::test] - async fn a_line_the_server_will_answer_never_leaves_a_reply_unread() { - // A JSON value that is not an object has no `id`, but the server still - // answers it (there is no method, so: invalid request). Treating it as a - // notification would leave that reply in the socket, and every later - // request would then read the *previous* request's response — an off-by-one - // that never recovers. - let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); - let port = listener.local_addr().unwrap().port(); - let _seen = fake_tui(listener, "tok".to_string()); - let mut relay = relay_for(port, "tok"); - - for line in ["[]", "\"a string\"", "5", "{not json", "{\"id\": 7}"] { - relay.handle(line).await; - } - - // The stream must still be aligned: this call's answer, under this call's id. - let response: serde_json::Value = - serde_json::from_str(&relay.handle(&modern_call()).await.unwrap()).unwrap(); - assert_eq!( - response["id"], - json!(1), - "the response must be this request's, not a stale one: {response}" - ); - } - - #[tokio::test] - async fn a_legacy_client_gets_the_offline_error_not_a_protocol_error() { - // The exact story the reconnect fix exists for: Claude Code handshakes - // (forwarded upstream, so the local session never saw it), the player then - // goes away. Its next request carries no `_meta`, and answering it from a - // session that never saw the handshake would reject it for missing the very - // fields the handshake exists to make unnecessary. - let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); - let port = listener.local_addr().unwrap().port(); - let _seen = fake_tui(listener, "tok".to_string()); - let mut relay = relay_for(port, "tok"); - - let init = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": "2025-06-18"}}) - .to_string(); - relay.handle(&init).await.unwrap(); - - // The player goes away for good. - relay.upstream = None; - relay.locate = Box::new(|| anyhow::bail!("no MCP control file")); - - let call = json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", - "params": {"name": "get_now_playing", "arguments": {}}}) - .to_string(); - let response: serde_json::Value = - serde_json::from_str(&relay.handle(&call).await.unwrap()).unwrap(); - assert!( - response.get("error").is_none(), - "a legacy client must not be told its request shape is wrong: {response}" - ); - assert_eq!(response["result"]["isError"], json!(true)); - assert!(response["result"]["content"][0]["text"] - .as_str() - .unwrap() - .contains("not available")); - } - - #[tokio::test] - async fn notifications_are_forwarded_without_waiting_for_a_reply() { - // The old pump was bidirectional, so a notification cost nothing. A - // request/response loop that waited on one would hang the whole session. - let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); - let port = listener.local_addr().unwrap().port(); - let seen = fake_tui(listener, "tok".to_string()); - let mut relay = relay_for(port, "tok"); - - let notification = json!({"jsonrpc": "2.0", "method": "notifications/initialized"}).to_string(); - let answered = tokio::time::timeout( - std::time::Duration::from_secs(5), - relay.handle(¬ification), - ) - .await - .expect("must not block waiting for a response that never comes"); - assert!(answered.is_none(), "a notification must never be answered"); - assert_eq!(wait_for_lines(&seen, 1).await.len(), 1); - } -} diff --git a/src/infra/mcp/server.rs b/src/infra/mcp/server.rs deleted file mode 100644 index 4ca69829..00000000 --- a/src/infra/mcp/server.rs +++ /dev/null @@ -1,643 +0,0 @@ -//! The MCP request handler and the newline-delimited JSON-RPC loop. -//! -//! Generic over the byte stream and over how tools actually run, because the -//! same code serves two very different situations: -//! -//! * inside the running TUI, over a loopback TCP connection, with a real -//! [`ToolExecutor`] that can touch `App` and Spotify; -//! * inside `spotatui mcp` when no TUI is running, over stdio, with an executor -//! that only reports that the player is not up. -//! -//! The spec explicitly allows the second case: the stdio framing — one -//! newline-delimited JSON-RPC message per line over a reliable bidirectional -//! byte stream — "works unchanged over Unix domain sockets, TCP connections, or -//! any similar channel". - -use super::executor::ToolExecutor; -use super::protocol::{self as proto, Era, MetaCheck}; -use crate::infra::dj::tools::{self, ToolCallError}; -use serde_json::{json, Map, Value}; -use tokio::io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader}; - -/// Per-connection state. The protocol is stateless by design, so this holds only -/// what is genuinely per-connection: the era latch, plus identity captured for -/// diagnostics (never for behaviour — the spec is explicit that `clientInfo` is -/// self-reported and must not drive decisions). -#[derive(Default)] -pub struct Session { - pub era: Era, - /// Protocol version the client last declared, for logs. - pub negotiated_version: Option, - /// `name/version` the client self-reported, for logs. - pub client: Option, -} - -/// Run the protocol loop until the input stream ends. -/// -/// Exits on EOF, which the spec names as the primary graceful-shutdown signal -/// for stdio and the only portable one. -pub async fn serve(input: R, mut output: W, executor: &E) -> std::io::Result<()> -where - R: tokio::io::AsyncRead + Unpin, - W: AsyncWrite + Unpin, - E: ToolExecutor, -{ - let mut lines = BufReader::new(input).lines(); - let mut session = Session::default(); - - while let Some(line) = lines.next_line().await? { - if line.trim().is_empty() { - continue; - } - if let Some(response) = handle_line(&mut session, &line, executor).await { - // One message per line, and never an embedded newline: `to_string` on a - // `Value` is compact, so this holds by construction. - output.write_all(response.to_string().as_bytes()).await?; - output.write_all(b"\n").await?; - output.flush().await?; - } - } - Ok(()) -} - -/// Handle one line, returning the response to write, or `None` for -/// notifications (which must never be answered). -pub async fn handle_line( - session: &mut Session, - line: &str, - executor: &E, -) -> Option { - let message: Value = match serde_json::from_str(line) { - Ok(message) => message, - Err(e) => { - return Some(proto::error_response( - None, - proto::PARSE_ERROR, - format!("Parse error: {e}"), - )) - } - }; - - let id = message.get("id").filter(|id| !id.is_null()).cloned(); - let Some(method) = message.get("method").and_then(Value::as_str) else { - return Some(proto::error_response( - id.as_ref(), - proto::INVALID_REQUEST, - "Request must include a method", - )); - }; - let params = message.get("params"); - - // Notifications carry no id and must not be answered. - if id.is_none() { - handle_notification(session, method); - return None; - } - - Some(handle_request(session, method, params, id.as_ref(), executor).await) -} - -fn handle_notification(_session: &mut Session, method: &str) { - match method { - // Legacy handshake completion. Nothing to do — the era was already latched - // by `initialize`. - "notifications/initialized" | "initialized" => {} - // Every tool call here completes in one step, so there is nothing to abort; - // acknowledged so it is not mistaken for an unimplemented method. - "notifications/cancelled" => {} - other => log::debug!("MCP: ignoring notification {other}"), - } -} - -async fn handle_request( - session: &mut Session, - method: &str, - params: Option<&Value>, - id: Option<&Value>, - executor: &E, -) -> Value { - match method { - // Modern discovery. Also the backward-compatibility probe a dual-era client - // sends first, so answering it settles the era as modern. - "server/discover" => { - if let Some(response) = settle_modern(session, params, id) { - return response; - } - proto::result_response(id, &session.era, proto::discover_body()) - } - - // Legacy handshake. Its presence *is* the signal to serve legacy semantics - // for the rest of this connection. - "initialize" => { - let requested = params - .and_then(|params| params.get("protocolVersion")) - .and_then(Value::as_str); - let negotiated = proto::negotiate_legacy(requested); - session.era = Era::Legacy(negotiated.clone()); - session.negotiated_version = Some(negotiated.clone()); - // Legacy clients put identity at the top level rather than in `_meta`. - session.client = params - .and_then(|params| params.get("clientInfo")) - .and_then(implementation_label); - log::debug!( - "MCP: legacy handshake, serving protocol {negotiated} to {}", - describe(session) - ); - // Built before the era is consulted for shaping, so pass Legacy directly. - proto::result_response(id, &session.era, proto::initialize_body(&negotiated)) - } - - "tools/list" => { - if let Some(response) = require_era(session, params, id) { - return response; - } - let mut body = Map::new(); - body.insert("tools".to_string(), json!(tool_descriptors())); - if !session.era.is_legacy() { - // `CacheableResult` is required on list results in the modern revision. - proto::add_cache_hints(&mut body); - } - proto::result_response(id, &session.era, body) - } - - "tools/call" => { - if let Some(response) = require_era(session, params, id) { - return response; - } - // A missing `name` is a malformed request, not a call to a tool called - // "". Defaulting it to empty reported `METHOD_NOT_FOUND: Unknown tool: `, - // which tells the client nothing about what to fix. - let Some(name) = params - .and_then(|params| params.get("name")) - .and_then(Value::as_str) - else { - return proto::error_response(id, proto::INVALID_PARAMS, "tools/call requires params.name"); - }; - let args = params - .and_then(|params| params.get("arguments")) - .cloned() - .unwrap_or(Value::Null); - - // Per the spec, a bad request shape is a *protocol* error while a tool - // that ran and failed is a result with `isError: true`. Keeping these - // apart matters: clients feed execution errors back to the model for - // self-correction, but not protocol errors. - let call = match tools::parse_call(name, &args) { - Ok(call) => call, - Err(ToolCallError::UnknownTool(name)) => { - return proto::error_response( - id, - proto::METHOD_NOT_FOUND, - format!("Unknown tool: {name}"), - ) - } - Err(e @ ToolCallError::InvalidArguments(_)) => { - return proto::error_response(id, proto::INVALID_PARAMS, e.to_string()) - } - }; - - let outcome = executor.call(call).await; - proto::result_response( - id, - &session.era, - proto::tool_result_body(&outcome.text, outcome.structured, outcome.is_error), - ) - } - - // Not implemented, deliberately: `prompts` and `resources` are not - // advertised in our capabilities, and `ping` / `logging/setLevel` were - // removed from the protocol in this revision. - other => proto::error_response( - id, - proto::METHOD_NOT_FOUND, - format!("Method not found: {other}"), - ), - } -} - -/// Validate a modern request and latch [`Era::Modern`], or return the error. -fn settle_modern( - session: &mut Session, - params: Option<&Value>, - id: Option<&Value>, -) -> Option { - match proto::check_meta(params) { - MetaCheck::Ok { version } => { - if session.era != Era::Modern || session.negotiated_version.as_deref() != Some(&version) { - log::debug!("MCP: serving protocol {version} to {}", describe(session)); - } - session.era = Era::Modern; - session.negotiated_version = Some(version); - note_client(session, params); - None - } - MetaCheck::UnsupportedVersion(requested) => { - Some(proto::unsupported_version_response(id, &requested)) - } - MetaCheck::MissingCapabilities => Some(proto::missing_capabilities_response(id)), - MetaCheck::Malformed(detail) => Some(proto::error_response(id, proto::INVALID_PARAMS, detail)), - } -} - -/// Decide how to serve a substantive request, settling the era if needed. -/// -/// A legacy connection has already handshaken, so its requests carry no `_meta` -/// and must not be rejected for that. A connection that has not handshaken must -/// present modern `_meta` — which is also what gives a legacy client that -/// skipped `initialize` an actionable error naming the versions we speak. -fn require_era(session: &mut Session, params: Option<&Value>, id: Option<&Value>) -> Option { - if session.era.is_legacy() { - return None; - } - if session.era == Era::Unknown && !proto::looks_modern(params) { - return Some(proto::error_response_with_data( - id, - proto::INVALID_PARAMS, - "Requests must carry params._meta['io.modelcontextprotocol/protocolVersion'] and \ - ['io.modelcontextprotocol/clientCapabilities'], or open with an initialize handshake", - json!({ "supported": proto::SUPPORTED_VERSIONS }), - )); - } - settle_modern(session, params, id) -} - -/// Record the client's self-reported identity from a modern request's `_meta`. -fn note_client(session: &mut Session, params: Option<&Value>) { - if session.client.is_some() { - return; - } - session.client = proto::meta(params) - .and_then(|meta| meta.get(proto::META_CLIENT_INFO)) - .and_then(implementation_label); -} - -/// `"name/version"` from an MCP `Implementation` object. -fn implementation_label(value: &Value) -> Option { - let name = value.get("name").and_then(Value::as_str)?; - let version = value.get("version").and_then(Value::as_str).unwrap_or("?"); - Some(format!("{name}/{version}")) -} - -fn describe(session: &Session) -> &str { - session - .client - .as_deref() - .unwrap_or("an unidentified client") -} - -/// The tool list, in the fixed order [`tools::TOOLS`] declares. -fn tool_descriptors() -> Vec { - tools::TOOLS - .iter() - .map(|tool| { - json!({ - "name": tool.name, - "title": tool.title, - "description": tool.description, - "inputSchema": (tool.schema)(), - "annotations": { - "title": tool.title, - "readOnlyHint": tool.read_only, - // Nothing here destroys data; the worst case is a queue the user has - // to clear. Skipping and playing are not idempotent, though. - "destructiveHint": false, - "idempotentHint": tool.read_only, - "openWorldHint": true, - }, - }) - }) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::infra::dj::tools::{DjToolCall, ToolOutcome}; - - /// Records what it was asked to run and echoes a canned answer. - struct FakeExecutor; - - impl ToolExecutor for FakeExecutor { - async fn call(&self, call: DjToolCall) -> ToolOutcome { - ToolOutcome::ok(format!("ran {}", call.tool_name())) - } - } - - fn modern_meta() -> Value { - json!({ - proto::META_PROTOCOL_VERSION: proto::PROTOCOL_MODERN, - proto::META_CLIENT_CAPABILITIES: {}, - proto::META_CLIENT_INFO: {"name": "test", "version": "1"}, - }) - } - - async fn exchange(session: &mut Session, message: Value) -> Value { - handle_line(session, &message.to_string(), &FakeExecutor) - .await - .expect("expected a response") - } - - #[tokio::test] - async fn modern_discover_then_tools_list() { - let mut session = Session::default(); - let discover = exchange( - &mut session, - json!({"jsonrpc": "2.0", "id": 1, "method": "server/discover", - "params": {"_meta": modern_meta()}}), - ) - .await; - assert_eq!(discover["result"]["resultType"], json!("complete")); - assert_eq!(session.era, Era::Modern); - assert!(discover["result"]["supportedVersions"] - .as_array() - .unwrap() - .contains(&json!(proto::PROTOCOL_MODERN))); - - let listed = exchange( - &mut session, - json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list", - "params": {"_meta": modern_meta()}}), - ) - .await; - let result = &listed["result"]; - assert_eq!(result["resultType"], json!("complete")); - // CacheableResult fields are mandatory on list results now. - assert_eq!(result["ttlMs"], json!(proto::LIST_TTL_MS)); - assert_eq!(result["cacheScope"], json!(proto::CACHE_SCOPE)); - assert_eq!( - result["_meta"][proto::META_SERVER_INFO]["name"], - json!("spotatui") - ); - let tools = result["tools"].as_array().unwrap(); - assert_eq!(tools.len(), crate::infra::dj::tools::TOOLS.len()); - assert_eq!(tools[0]["name"], json!("get_listening_history")); - } - - #[tokio::test] - async fn legacy_initialize_latches_and_shapes_results() { - let mut session = Session::default(); - let init = exchange( - &mut session, - json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { - "protocolVersion": "2025-06-18", - "capabilities": {}, - "clientInfo": {"name": "legacy", "version": "1"} - }}), - ) - .await; - assert_eq!(init["result"]["protocolVersion"], json!("2025-06-18")); - assert!(init["result"].get("resultType").is_none()); - assert_eq!(session.era, Era::Legacy("2025-06-18".into())); - - // A legacy client's later requests carry no `_meta` at all, and must still - // be served rather than rejected for the missing fields. - let listed = exchange( - &mut session, - json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}), - ) - .await; - assert!(listed["result"]["tools"].is_array()); - assert!(listed["result"].get("resultType").is_none()); - assert!(listed["result"].get("ttlMs").is_none()); - } - - #[tokio::test] - async fn era_latch_survives_across_requests_on_one_connection() { - let mut session = Session::default(); - exchange( - &mut session, - json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": "2025-11-25"}}), - ) - .await; - for id in 2..5 { - let response = exchange( - &mut session, - json!({"jsonrpc": "2.0", "id": id, "method": "tools/list"}), - ) - .await; - assert!(response["result"].get("resultType").is_none(), "id {id}"); - } - assert_eq!(session.era, Era::Legacy("2025-11-25".into())); - } - - #[tokio::test] - async fn unknown_legacy_version_still_negotiates() { - // A legacy client has no fall-forward path, so failing the handshake would - // leave it permanently broken. - let mut session = Session::default(); - let init = exchange( - &mut session, - json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": "1999-01-01"}}), - ) - .await; - assert_eq!( - init["result"]["protocolVersion"], - json!(proto::PROTOCOL_LEGACY_FALLBACK) - ); - } - - #[tokio::test] - async fn modern_request_without_meta_is_rejected_with_invalid_params() { - let mut session = Session::default(); - let response = exchange( - &mut session, - json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}), - ) - .await; - assert_eq!(response["error"]["code"], json!(proto::INVALID_PARAMS)); - // Name the versions we speak, so a confused client can recover. - assert!(response["error"]["data"]["supported"].is_array()); - } - - #[tokio::test] - async fn unsupported_version_is_reported_with_32022() { - let mut session = Session::default(); - let response = exchange( - &mut session, - json!({"jsonrpc": "2.0", "id": 1, "method": "server/discover", "params": {"_meta": { - proto::META_PROTOCOL_VERSION: "1900-01-01", - proto::META_CLIENT_CAPABILITIES: {}, - }}}), - ) - .await; - assert_eq!(response["error"]["code"], json!(-32022)); - } - - #[tokio::test] - async fn missing_client_capabilities_is_reported_with_32021() { - let mut session = Session::default(); - let response = exchange( - &mut session, - json!({"jsonrpc": "2.0", "id": 1, "method": "server/discover", "params": {"_meta": { - proto::META_PROTOCOL_VERSION: proto::PROTOCOL_MODERN, - }}}), - ) - .await; - assert_eq!(response["error"]["code"], json!(-32021)); - } - - #[tokio::test] - async fn tool_call_runs_and_reports() { - let mut session = Session::default(); - let response = exchange( - &mut session, - json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { - "_meta": modern_meta(), - "name": "get_now_playing", - "arguments": {} - }}), - ) - .await; - assert_eq!(response["result"]["isError"], json!(false)); - assert_eq!( - response["result"]["content"][0]["text"], - json!("ran get_now_playing") - ); - } - - #[tokio::test] - async fn unknown_tool_is_a_protocol_error_not_an_is_error_result() { - let mut session = Session::default(); - let response = exchange( - &mut session, - json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { - "_meta": modern_meta(), - "name": "make_coffee", - "arguments": {} - }}), - ) - .await; - assert_eq!(response["error"]["code"], json!(proto::METHOD_NOT_FOUND)); - assert!(response.get("result").is_none()); - } - - #[tokio::test] - async fn a_call_with_no_tool_name_is_invalid_params_not_an_unknown_tool() { - // Defaulting the missing name to "" reported `Unknown tool: ` under - // METHOD_NOT_FOUND, which names nothing and points the client at the wrong - // problem: the request shape is what is wrong, not the tool table. - let mut session = Session::default(); - let response = exchange( - &mut session, - json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { - "_meta": modern_meta(), - "arguments": {} - }}), - ) - .await; - assert_eq!(response["error"]["code"], json!(proto::INVALID_PARAMS)); - assert!(response["error"]["message"] - .as_str() - .unwrap() - .contains("params.name")); - } - - #[tokio::test] - async fn bad_tool_arguments_are_invalid_params() { - let mut session = Session::default(); - let response = exchange( - &mut session, - json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { - "_meta": modern_meta(), - "name": "search_tracks", - "arguments": {} - }}), - ) - .await; - assert_eq!(response["error"]["code"], json!(proto::INVALID_PARAMS)); - } - - #[tokio::test] - async fn notifications_are_never_answered() { - let mut session = Session::default(); - for method in ["notifications/initialized", "notifications/cancelled"] { - let message = json!({"jsonrpc": "2.0", "method": method}); - assert!( - handle_line(&mut session, &message.to_string(), &FakeExecutor) - .await - .is_none(), - "{method} must not get a response" - ); - } - } - - #[tokio::test] - async fn malformed_json_is_a_parse_error() { - let mut session = Session::default(); - let response = handle_line(&mut session, "{not json", &FakeExecutor) - .await - .unwrap(); - assert_eq!(response["error"]["code"], json!(proto::PARSE_ERROR)); - assert_eq!(response["id"], Value::Null); - } - - #[tokio::test] - async fn method_is_required() { - let mut session = Session::default(); - let response = handle_line(&mut session, &json!({"id": 1}).to_string(), &FakeExecutor) - .await - .unwrap(); - assert_eq!(response["error"]["code"], json!(proto::INVALID_REQUEST)); - } - - #[tokio::test] - async fn removed_methods_report_method_not_found() { - // `ping` and `logging/setLevel` were removed in 2026-07-28; we advertise - // neither prompts nor resources. - let mut session = Session::default(); - session.era = Era::Modern; - for method in ["ping", "logging/setLevel", "prompts/list", "resources/list"] { - let response = exchange( - &mut session, - json!({"jsonrpc": "2.0", "id": 9, "method": method, "params": {"_meta": modern_meta()}}), - ) - .await; - assert_eq!( - response["error"]["code"], - json!(proto::METHOD_NOT_FOUND), - "{method}" - ); - } - } - - #[tokio::test] - async fn serve_pumps_a_whole_conversation_over_a_stream() { - let input = format!( - "{}\n{}\n", - json!({"jsonrpc": "2.0", "id": 1, "method": "server/discover", "params": {"_meta": modern_meta()}}), - json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {"_meta": modern_meta()}}), - ); - let mut output = Vec::new(); - serve(input.as_bytes(), &mut output, &FakeExecutor) - .await - .unwrap(); - - let text = String::from_utf8(output).unwrap(); - let lines: Vec<_> = text.lines().collect(); - assert_eq!(lines.len(), 2, "one response line per request"); - for line in &lines { - // Never an embedded newline, and always valid JSON on its own line. - let parsed: Value = serde_json::from_str(line).unwrap(); - assert_eq!(parsed["jsonrpc"], json!("2.0")); - } - } - - #[test] - fn descriptors_expose_read_only_hints_that_match_the_tool_table() { - for descriptor in tool_descriptors() { - let name = descriptor["name"].as_str().unwrap(); - let spec = crate::infra::dj::tools::spec(name).unwrap(); - assert_eq!( - descriptor["annotations"]["readOnlyHint"], - json!(spec.read_only), - "{name}" - ); - assert!( - descriptor["inputSchema"]["type"] == json!("object"), - "{name}" - ); - } - } -} diff --git a/src/infra/mcp/status.rs b/src/infra/mcp/status.rs deleted file mode 100644 index 180e1e9f..00000000 --- a/src/infra/mcp/status.rs +++ /dev/null @@ -1,353 +0,0 @@ -//! `spotatui mcp status`: a safe, machine-checkable probe of the MCP setup. -//! -//! This exists because of a trap. `spotatui mcp` **is the server** — it reads -//! JSON-RPC from stdin and blocks forever. An AI agent told to "test the MCP -//! server" will run it, hang, and eventually be killed, having learned nothing. -//! `status` gives the agent something it can actually run: it checks each step of -//! the setup, prints one line per check, and sets a **non-zero exit code** when -//! the setup is not ready. -//! -//! Used by `docs/mcp-setup.md`, which is written for an agent to follow. - -use super::control::{self, Handshake}; -use super::protocol as proto; -use serde_json::json; -use std::time::Duration; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::net::TcpStream; - -const PROBE_TIMEOUT: Duration = Duration::from_secs(5); - -/// One line of the report. -struct Check { - ok: bool, - label: &'static str, - detail: String, -} - -impl Check { - fn pass(label: &'static str, detail: impl Into) -> Self { - Self { - ok: true, - label, - detail: detail.into(), - } - } - fn fail(label: &'static str, detail: impl Into) -> Self { - Self { - ok: false, - label, - detail: detail.into(), - } - } -} - -/// Run every check and render the report. Returns the rendered report (the -/// caller prints it - this is library code, the CLI owns stdout) and the -/// process exit code: `0` when the MCP setup is fully working, `1` otherwise. -pub async fn run(as_json: bool) -> (String, i32) { - let mut checks = Vec::new(); - - // 1. Is the feature even compiled in? Reaching this code proves it is, which is - // worth stating explicitly so an agent can distinguish "wrong binary" from - // "not configured". - checks.push(Check::pass( - "binary", - format!( - "spotatui {} with the mcp-server feature", - proto::SERVER_VERSION - ), - )); - - // 2. Has a running TUI published a control file? - let handshake = control::read_handshake(); - match &handshake { - Ok(handshake) => checks.push(Check::pass( - "control-file", - format!( - "found (port {}, pid {}) at {}", - handshake.port, - handshake.pid, - control::handshake_path() - .map(|path| path.display().to_string()) - .unwrap_or_else(|_| "".into()) - ), - )), - Err(e) => checks.push(Check::fail( - "control-file", - format!( - "{e}. Start spotatui and set `behavior.mcp_enabled: true` in {}, then restart it.", - // The resolved path, not a hard-coded `~/.config`: with an absolute - // XDG_CONFIG_HOME the agent would otherwise be sent to edit a file - // spotatui never reads, and this check would keep failing. - control::handshake_path() - .ok() - .and_then(|path| path.parent().map(|dir| dir.join("config.yml"))) - .map(|path| path.display().to_string()) - .unwrap_or_else(|| "the spotatui config directory".into()) - ), - )), - } - - // 3. Can we complete the handshake and get a protocol answer back? This is the - // check that actually proves end-to-end reachability. - if let Ok(handshake) = &handshake { - match probe(handshake).await { - Ok(version) => checks.push(Check::pass( - "connection", - format!("handshake accepted; server speaks protocol {version}"), - )), - Err(e) => checks.push(Check::fail( - "connection", - format!("{e}. The control file may be stale — restart spotatui."), - )), - } - } - - let ready = checks.iter().all(|check| check.ok); - - let report = if as_json { - render_json(&checks, ready) - } else { - render_text(&checks, ready) - }; - - (report, i32::from(!ready)) -} - -fn render_json(checks: &[Check], ready: bool) -> String { - let payload = json!({ - "ready": ready, - "checks": checks.iter().map(|check| json!({ - "name": check.label, - "ok": check.ok, - "detail": check.detail, - })).collect::>(), - "register_command": "claude mcp add spotatui -- spotatui mcp", - }); - serde_json::to_string_pretty(&payload).unwrap_or_default() -} - -fn render_text(checks: &[Check], ready: bool) -> String { - let mut lines: Vec = checks - .iter() - .map(|check| { - format!( - "{} {:<14} {}", - if check.ok { "ok " } else { "FAIL" }, - check.label, - check.detail - ) - }) - .collect(); - lines.push(String::new()); - if ready { - lines.push("MCP is ready. Register it with your client, e.g.:".into()); - lines.push(" claude mcp add spotatui -- spotatui mcp".into()); - } else { - lines.push("MCP is not ready yet — see the failing check(s) above.".into()); - lines.push("Full instructions: docs/mcp-setup.md".into()); - } - lines.join("\n") -} - -/// Connect, authenticate, and ask the server which protocol it speaks. -async fn probe(handshake: &Handshake) -> Result { - let connect = TcpStream::connect(("127.0.0.1", handshake.port)); - let stream = tokio::time::timeout(PROBE_TIMEOUT, connect) - .await - .map_err(|_| "connection timed out".to_string())? - .map_err(|e| format!("could not connect on port {}: {e}", handshake.port))?; - - let (read_half, mut write_half) = stream.into_split(); - let mut lines = BufReader::new(read_half).lines(); - - let hello = json!({ "token": handshake.token }); - write_half - .write_all(format!("{hello}\n").as_bytes()) - .await - .map_err(|e| format!("could not send the handshake: {e}"))?; - - // A modern `server/discover`, which every version of this server answers. - let request = json!({ - "jsonrpc": "2.0", - "id": "status-probe", - "method": "server/discover", - "params": { "_meta": { - proto::META_PROTOCOL_VERSION: proto::PROTOCOL_MODERN, - proto::META_CLIENT_CAPABILITIES: {}, - proto::META_CLIENT_INFO: { "name": "spotatui-mcp-status", "version": proto::SERVER_VERSION }, - }} - }); - write_half - .write_all(format!("{request}\n").as_bytes()) - .await - .map_err(|e| format!("could not send the probe: {e}"))?; - - let line = tokio::time::timeout(PROBE_TIMEOUT, lines.next_line()) - .await - .map_err(|_| "the server did not answer in time".to_string())? - .map_err(|e| format!("could not read the answer: {e}"))? - .ok_or_else(|| "the server closed the connection (bad token?)".to_string())?; - - let response: serde_json::Value = - serde_json::from_str(&line).map_err(|e| format!("unreadable answer: {e}"))?; - if let Some(error) = response.get("error") { - return Err(format!("server returned an error: {error}")); - } - response - .get("result") - .and_then(|result| result.get("supportedVersions")) - .and_then(|versions| versions.as_array()) - .and_then(|versions| versions.first()) - .and_then(|version| version.as_str()) - .map(str::to_string) - .ok_or_else(|| "answer did not include supportedVersions".to_string()) -} - -/// The probe, for the control-channel round-trip test in the sibling module. -#[cfg(test)] -pub(super) async fn probe_for_test(handshake: &Handshake) -> Result { - probe(handshake).await -} - -#[cfg(test)] -mod tests { - use super::*; - use tokio::net::TcpListener; - - #[test] - fn text_report_ends_with_the_register_hint_when_ready() { - let checks = vec![Check::pass( - "binary", - "spotatui with the mcp-server feature", - )]; - let report = render_text(&checks, true); - assert!(report.starts_with("ok binary"), "{report}"); - assert!( - report.ends_with( - "\n\nMCP is ready. Register it with your client, e.g.:\n claude mcp add spotatui -- spotatui mcp" - ), - "{report}" - ); - } - - #[test] - fn text_report_points_at_the_failing_check_when_not_ready() { - let checks = vec![ - Check::pass("binary", "x"), - Check::fail("control-file", "missing"), - ]; - let report = render_text(&checks, false); - assert!(report.contains("FAIL control-file"), "{report}"); - assert!( - report.ends_with("Full instructions: docs/mcp-setup.md"), - "{report}" - ); - } - - #[test] - fn json_report_carries_ready_and_every_check() { - let checks = vec![Check::fail("control-file", "missing")]; - let report = render_json(&checks, false); - let value: serde_json::Value = serde_json::from_str(&report).unwrap(); - assert_eq!(value["ready"], false); - assert_eq!(value["checks"][0]["name"], "control-file"); - assert_eq!(value["checks"][0]["ok"], false); - assert_eq!(value["checks"][0]["detail"], "missing"); - } - - #[tokio::test] - async fn probe_reports_the_protocol_when_the_server_answers() { - let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); - let port = listener.local_addr().unwrap().port(); - - tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let (read_half, mut write_half) = stream.into_split(); - let mut lines = BufReader::new(read_half).lines(); - // Handshake, then the discover request. - let _token = lines.next_line().await.unwrap(); - let _request = lines.next_line().await.unwrap(); - let response = json!({ - "jsonrpc": "2.0", "id": "status-probe", - "result": {"resultType": "complete", "supportedVersions": [proto::PROTOCOL_MODERN]} - }); - write_half - .write_all(format!("{response}\n").as_bytes()) - .await - .unwrap(); - }); - - let handshake = Handshake { - port, - token: "t".into(), - pid: 1, - }; - assert_eq!(probe(&handshake).await.unwrap(), proto::PROTOCOL_MODERN); - } - - #[tokio::test] - async fn probe_reports_a_closed_connection_as_a_bad_token() { - let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); - let port = listener.local_addr().unwrap().port(); - tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - // Accept and hang up, which is what a token mismatch looks like. - drop(stream); - }); - - let handshake = Handshake { - port, - token: "wrong".into(), - pid: 1, - }; - let err = probe(&handshake).await.unwrap_err(); - assert!(err.contains("closed") || err.contains("could not"), "{err}"); - } - - #[tokio::test] - async fn probe_fails_fast_when_nothing_is_listening() { - // Bind then drop, so the port is almost certainly free. - let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); - let port = listener.local_addr().unwrap().port(); - drop(listener); - - let handshake = Handshake { - port, - token: "t".into(), - pid: 1, - }; - let err = probe(&handshake).await.unwrap_err(); - assert!(err.contains("could not connect"), "{err}"); - } - - #[tokio::test] - async fn probe_surfaces_a_protocol_error_rather_than_claiming_success() { - let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); - let port = listener.local_addr().unwrap().port(); - tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let (read_half, mut write_half) = stream.into_split(); - let mut lines = BufReader::new(read_half).lines(); - let _ = lines.next_line().await; - let _ = lines.next_line().await; - let response = json!({ - "jsonrpc": "2.0", "id": "status-probe", - "error": {"code": -32022, "message": "Unsupported protocol version"} - }); - write_half - .write_all(format!("{response}\n").as_bytes()) - .await - .unwrap(); - }); - - let handshake = Handshake { - port, - token: "t".into(), - pid: 1, - }; - let err = probe(&handshake).await.unwrap_err(); - assert!(err.contains("Unsupported protocol version"), "{err}"); - } -} diff --git a/src/infra/media_metadata.rs b/src/infra/media_metadata.rs deleted file mode 100644 index d2f65920..00000000 --- a/src/infra/media_metadata.rs +++ /dev/null @@ -1,880 +0,0 @@ -#![cfg_attr( - not(any( - feature = "discord-rpc", - all(feature = "mpris", target_os = "linux"), - all(feature = "macos-media", target_os = "macos"), - all(feature = "windows-media", target_os = "windows") - )), - allow(dead_code) -)] - -use crate::core::app::{App, NativePlaybackOrigin, NativeTrackKind}; -use rspotify::model::{PlayableItem, RepeatState}; -use rspotify::prelude::Id; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum PlaybackItemKind { - Track, - Episode, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum PlaybackSource { - NativeContext, - NativeRawList, - ExternalDevice, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct PlaybackMetadata { - pub title: String, - pub artists: Vec, - pub album: String, - pub image_url: Option, - pub duration_ms: u32, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct PlaybackSnapshot { - pub metadata: PlaybackMetadata, - pub item_kind: PlaybackItemKind, - pub item_id: Option, - pub item_uri: Option, - pub context_uri: Option, - pub source: PlaybackSource, - pub progress_ms: u128, - pub is_playing: bool, - /// True only for internet-radio live streams (no track duration, ICY metadata). - pub is_live: bool, - pub shuffle: bool, - pub repeat: Option, -} - -impl PlaybackSnapshot { - pub fn primary_artist(&self) -> String { - self.metadata.artists.join(", ") - } -} - -pub fn current_playback_snapshot(app: &App) -> Option { - // A non-Spotify decoded source (local / subsonic / internet-radio / youtube) - // owns the audio sink while its `*_playback` field is `Some`. Starting such a - // source only *pauses* librespot and never clears the Spotify context, so - // without this branch the snapshot (window title, Discord RPC, and the - // MPRIS/macOS fallback path) would keep showing the stale paused Spotify - // track. Progress and play-state are read live from the owning source's - // player, so they stay correct regardless of librespot's frozen position. - #[cfg(feature = "audio-decode")] - if let Some(snapshot) = source_playback_snapshot(app) { - return Some(snapshot); - } - - let context = app.current_playback_context.as_ref(); - let use_native_metadata = app.is_streaming_active && app.native_track_info.is_some(); - - let (metadata, item_kind, item_id, item_uri) = if use_native_metadata { - let native_info = app.native_track_info.as_ref()?; - let item_kind = match native_info.kind { - NativeTrackKind::Track => PlaybackItemKind::Track, - NativeTrackKind::Episode => PlaybackItemKind::Episode, - }; - let item_id = app.last_track_id.clone(); - let item_uri = item_id - .as_deref() - .map(|id| playback_uri_for_item_kind(item_kind, id)); - ( - PlaybackMetadata { - title: native_info.name.clone(), - artists: native_info.artists.clone(), - album: native_info.album.clone(), - // Prefer the art librespot handed us with this very track. The polled - // context is a per-track race: after a skip it still holds the previous - // item for seconds, and for a natively queued track it never reports the - // right one at all, so reading art from it pinned the *previous* album's - // cover (#402). Fall back to the context item only when librespot gave - // us no covers, which keeps the old behavior for that corner. - image_url: native_info - .image_url - .clone() - .or_else(|| image_url_from_context_item(context.and_then(|ctx| ctx.item.as_ref()))), - duration_ms: native_info.duration_ms, - }, - item_kind, - item_id, - item_uri, - ) - } else { - metadata_and_identity_from_context_item(context.and_then(|ctx| ctx.item.as_ref()))? - }; - - let is_playing = if use_native_metadata { - app - .native_is_playing - .unwrap_or_else(|| context.map(|context| context.is_playing).unwrap_or(false)) - } else { - context.map(|context| context.is_playing).unwrap_or(false) - }; - // The native queue slot has no shuffle/repeat of its own: it plays an explicit - // list over a *suspended* context, and the playbar hides both indicators. A - // queued *decoded* track is blanked in `source_playback_snapshot`; a queued - // *Spotify* track reaches this path instead (a suspended Spotify context sets - // no `*_playback`), so blank it here too. Otherwise MPRIS would advertise — and - // let a client change — the suspended context's modes for a track the user has - // no shuffle/repeat control over, disagreeing with the keyboard, which no-ops. - let queue_owns_playback = app.queue_owns_playback(); - let shuffle = !queue_owns_playback - && context - .map(|context| context.shuffle_state) - .unwrap_or(app.runtime_state.shuffle_enabled); - let repeat = if queue_owns_playback { - None - } else { - context.map(|context| context.repeat_state) - }; - let context_uri = context - .and_then(|ctx| ctx.context.as_ref()) - .map(|context| context.uri.clone()); - let source = if app.is_streaming_active { - match app.native_playback_origin.unwrap_or_else(|| { - if context_uri.is_some() { - NativePlaybackOrigin::Context - } else { - NativePlaybackOrigin::RawList - } - }) { - NativePlaybackOrigin::Context => PlaybackSource::NativeContext, - NativePlaybackOrigin::RawList => PlaybackSource::NativeRawList, - } - } else { - PlaybackSource::ExternalDevice - }; - - Some(PlaybackSnapshot { - metadata, - item_kind, - item_id, - item_uri, - context_uri, - source, - progress_ms: app.song_progress_ms, - is_playing, - is_live: false, - shuffle, - repeat, - }) -} - -/// Build a [`PlaybackSnapshot`] for whichever non-Spotify decoded source -/// currently owns playback (at most one `*_playback` is `Some`). Metadata comes -/// from the source's stored track info; progress and play-state are read live -/// from its player. Returns `None` when no such source is active. -#[cfg(feature = "audio-decode")] -fn source_playback_snapshot(app: &App) -> Option { - // The native queue slot playing a decoded track wins over every per-source - // context: it is what is actually audible, and it drives the playbar / MPRIS / - // cover art / lyrics via the shared track-change detector. - #[cfg(feature = "audio-decode-queue")] - if let Some(crate::infra::queue::QueueNowPlaying::Decoded(d)) = app.queue_now.as_ref() { - let mut snapshot = source_snapshot( - d.track.name.clone(), - d.track.artists.clone(), - d.track.album.clone(), - d.track.duration_ms as u32, - d.track.uri.clone(), - d.track.image_url.clone(), - d.player.position().as_millis(), - !d.player.is_paused(), - app, - ); - // The native queue ignores the decoded shuffle/repeat modes (they belong to - // the suspended source resumed once the queue drains), so don't advertise - // them — mirroring the radio override and the playbar's hidden controls. - snapshot.shuffle = false; - snapshot.repeat = None; - return Some(snapshot); - } - - // A queued *Spotify* track owns the sink (a decoded one returned above): every - // `*_playback` below is then a suspended context, not what is audible, so - // reporting one would publish the wrong track *and* its stale shuffle/repeat. - // Fall through to the Spotify snapshot, which describes the queued track and - // blanks the modes for a queue slot. Mirrors the same filter the runner applies - // to its dedicated local-playback path. - if app.queue_owns_playback() { - return None; - } - - #[cfg(feature = "local-files")] - if let Some(local) = app.local_playback.as_ref() { - return Some(source_snapshot( - local.name.clone(), - vec![local.artists.clone()], - local.album.clone(), - local.duration_ms as u32, - local.queue.get(local.index).cloned(), - // Local files carry embedded art (read via `extract_embedded_cover`), not - // a URL, so the snapshot's `image_url` stays `None`. - None, - local.player.position().as_millis(), - !local.player.is_paused(), - app, - )); - } - - #[cfg(feature = "subsonic")] - if let Some(subsonic) = app.subsonic_playback.as_ref() { - let track = subsonic.tracks.get(subsonic.index)?; - return Some(source_snapshot( - track.name.clone(), - track.artists.clone(), - track.album.clone(), - track.duration_ms as u32, - track.uri.clone(), - track.image_url.clone(), - subsonic.player.position().as_millis(), - !subsonic.player.is_paused(), - app, - )); - } - - #[cfg(feature = "qobuz")] - if let Some(qobuz) = app.qobuz_playback.as_ref() { - let track = qobuz.tracks.get(qobuz.index)?; - return Some(source_snapshot( - track.name.clone(), - track.artists.clone(), - track.album.clone(), - track.duration_ms as u32, - track.uri.clone(), - track.image_url.clone(), - qobuz.player.position().as_millis(), - !qobuz.player.is_paused(), - app, - )); - } - - #[cfg(feature = "youtube")] - if let Some(youtube) = app.youtube_playback.as_ref() { - let track = youtube.tracks.get(youtube.index)?; - return Some(source_snapshot( - track.name.clone(), - track.artists.clone(), - track.album.clone(), - track.duration_ms as u32, - track.uri.clone(), - track.image_url.clone(), - youtube.player.position().as_millis(), - !youtube.player.is_paused(), - app, - )); - } - - #[cfg(feature = "internet-radio")] - if let Some(radio) = app.radio_playback.as_ref() { - // Prefer the live ICY "Artist - Title" when the stream provides it; fall - // back to the station's own tags. A live stream has no track duration. - let artists = radio.now_playing_title().unwrap_or_else(|| { - if radio.station.artists.is_empty() { - radio.station.album.clone() - } else { - radio.station.artists.join(", ") - } - }); - let mut snapshot = source_snapshot( - radio.station.name.clone(), - vec![artists], - radio.station.album.clone(), - 0, - radio.station.uri.clone(), - // Live radio streams carry no per-track cover art. - None, - radio.player.position().as_millis(), - !radio.player.is_paused(), - app, - ); - snapshot.is_live = true; - // Radio is an infinite stream with no queue: repeat/shuffle don't apply. - snapshot.shuffle = false; - snapshot.repeat = None; - return Some(snapshot); - } - - None -} - -/// Assemble a [`PlaybackSnapshot`] from a decoded source's fields. `image_url` -/// is a directly-fetchable cover-art URL when the source provides one (Subsonic -/// getCoverArt, YouTube thumbnail) and `None` otherwise (local files carry -/// embedded art fetched separately; radio has none). Sources are always treated -/// as a single track; shuffle/repeat come from the player-global decoded state -/// (the radio caller overrides them since a live stream has no queue). -#[cfg(feature = "audio-decode")] -#[allow(clippy::too_many_arguments)] -fn source_snapshot( - title: String, - artists: Vec, - album: String, - duration_ms: u32, - item_uri: Option, - image_url: Option, - progress_ms: u128, - is_playing: bool, - app: &App, -) -> PlaybackSnapshot { - // The decoded repeat is source-neutral; translate to the Spotify-shaped - // `RepeatState` here at the snapshot boundary. - use crate::infra::queue::RepeatMode; - let repeat = match app.decoded_repeat { - RepeatMode::Off => RepeatState::Off, - RepeatMode::Context => RepeatState::Context, - RepeatMode::Track => RepeatState::Track, - }; - PlaybackSnapshot { - metadata: PlaybackMetadata { - title, - artists, - album, - image_url, - duration_ms, - }, - item_kind: PlaybackItemKind::Track, - item_id: None, - item_uri, - context_uri: None, - source: PlaybackSource::ExternalDevice, - progress_ms, - is_playing, - is_live: false, - shuffle: app.decoded_shuffle, - repeat: Some(repeat), - } -} - -fn metadata_and_identity_from_context_item( - item: Option<&PlayableItem>, -) -> Option<( - PlaybackMetadata, - PlaybackItemKind, - Option, - Option, -)> { - match item? { - PlayableItem::Track(track) => { - let item_id = track.id.as_ref().map(|id| id.id().to_string()); - Some(( - PlaybackMetadata { - title: track.name.clone(), - artists: track.artists.iter().map(|a| a.name.clone()).collect(), - album: track.album.name.clone(), - image_url: track.album.images.first().map(|image| image.url.clone()), - duration_ms: track.duration.num_milliseconds() as u32, - }, - PlaybackItemKind::Track, - item_id.clone(), - item_id - .as_deref() - .map(|id| playback_uri_for_item_kind(PlaybackItemKind::Track, id)), - )) - } - PlayableItem::Episode(episode) => { - let item_id = Some(episode.id.id().to_string()); - Some(( - PlaybackMetadata { - title: episode.name.clone(), - artists: vec![episode.show.name.clone()], - album: String::new(), - image_url: episode.images.first().map(|image| image.url.clone()), - duration_ms: episode.duration.num_milliseconds() as u32, - }, - PlaybackItemKind::Episode, - item_id.clone(), - item_id - .as_deref() - .map(|id| playback_uri_for_item_kind(PlaybackItemKind::Episode, id)), - )) - } - PlayableItem::Unknown(_) => None, - } -} - -fn playback_uri_for_item_kind(item_kind: PlaybackItemKind, id: &str) -> String { - match item_kind { - PlaybackItemKind::Track => format!("spotify:track:{id}"), - PlaybackItemKind::Episode => format!("spotify:episode:{id}"), - } -} - -fn image_url_from_context_item(item: Option<&PlayableItem>) -> Option { - match item? { - PlayableItem::Track(track) => track.album.images.first().map(|image| image.url.clone()), - PlayableItem::Episode(episode) => episode.images.first().map(|image| image.url.clone()), - PlayableItem::Unknown(_) => None, - } -} - -/// Static metadata for an active local-files playback session, decoupled from -/// the live [`crate::infra::local::LocalPlaybackState`] (which embeds an audio -/// device that cannot be constructed in a headless test). Used to drive the OS -/// media integrations (MPRIS / macOS Now Playing) when a local file owns the -/// playback session. -/// -/// Compiled only when both the local-files source and an OS media integration -/// that consumes it (MPRIS on Linux, Now Playing on macOS) are enabled, so -/// other combos (e.g. local-files + discord-rpc alone) stay free of dead-code -/// warnings under `-D warnings`. -#[cfg(all( - feature = "local-files", - any( - all(feature = "mpris", target_os = "linux"), - all(feature = "macos-media", target_os = "macos") - ) -))] -#[derive(Clone, Debug, PartialEq)] -pub struct LocalMediaMetadata { - pub title: String, - pub artists: Vec, - pub album: String, - pub duration_ms: u32, -} - -/// Choose the metadata the OS media integration should display. -/// -/// When a local file owns the playback session (`local` is `Some`), its static -/// metadata wins so media keys / Now Playing follow local playback instead of -/// the stale Spotify context. Otherwise the Spotify-derived `spotify` metadata -/// (if any) is used. -/// -/// This is a pure function over plain fields so it is unit-testable without a -/// live D-Bus connection or an audio device. -#[cfg(all( - feature = "local-files", - any( - all(feature = "mpris", target_os = "linux"), - all(feature = "macos-media", target_os = "macos") - ) -))] -pub fn select_media_metadata( - local: Option, - spotify: Option, -) -> Option { - match local { - Some(local) => Some(PlaybackMetadata { - title: local.title, - artists: local.artists, - album: local.album, - image_url: None, - duration_ms: local.duration_ms, - }), - None => spotify, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::app::{NativePlaybackOrigin, NativeTrackInfo, NativeTrackKind}; - use chrono::{Duration, Utc}; - use rspotify::model::{ - context::{Actions, CurrentPlaybackContext}, - idtypes::{EpisodeId, ShowId}, - show::{FullEpisode, SimplifiedShow}, - track::FullTrack, - CurrentlyPlayingType, Device, DeviceType, Image, PlayableItem, RepeatState, SimplifiedAlbum, - SimplifiedArtist, Type, - }; - use std::{collections::HashMap, sync::mpsc::channel, time::SystemTime}; - - fn app() -> App { - let (tx, _rx) = channel(); - App::new( - tx, - crate::core::user_config::UserConfig::new(), - Some(SystemTime::now()), - ) - } - - #[allow(deprecated)] - fn playback_context(item: PlayableItem, is_playing: bool) -> CurrentPlaybackContext { - CurrentPlaybackContext { - device: Device { - id: Some("device-1".to_string()), - is_active: true, - is_private_session: false, - is_restricted: false, - name: "Desk Speaker".to_string(), - _type: DeviceType::Computer, - volume_percent: Some(42), - }, - repeat_state: RepeatState::Off, - shuffle_state: true, - context: None, - timestamp: Utc::now(), - progress: None, - is_playing, - item: Some(item), - currently_playing_type: CurrentlyPlayingType::Track, - actions: Actions::default(), - } - } - - #[allow(deprecated)] - fn track() -> FullTrack { - FullTrack { - album: SimplifiedAlbum { - name: "Album".to_string(), - images: vec![Image { - height: Some(640), - url: "https://example.com/cover.jpg".to_string(), - width: Some(640), - }], - ..Default::default() - }, - artists: vec![SimplifiedArtist { - name: "Artist".to_string(), - ..Default::default() - }], - available_markets: Vec::new(), - disc_number: 1, - duration: Duration::milliseconds(181_000), - explicit: false, - external_ids: HashMap::new(), - external_urls: HashMap::new(), - href: None, - id: None, - is_local: false, - is_playable: Some(true), - linked_from: None, - restrictions: None, - name: "Track".to_string(), - popularity: 50, - preview_url: None, - track_number: 1, - r#type: Type::Track, - } - } - - #[allow(deprecated)] - fn episode() -> FullEpisode { - FullEpisode { - audio_preview_url: None, - description: "Description".to_string(), - duration: Duration::milliseconds(2_400_000), - explicit: false, - external_urls: HashMap::new(), - href: "https://example.com/episode".to_string(), - id: EpisodeId::from_id("0zTOsY4qQhZQ6JcZx7aG4P") - .unwrap() - .into_static(), - images: vec![Image { - height: Some(640), - url: "https://example.com/episode.jpg".to_string(), - width: Some(640), - }], - is_externally_hosted: false, - is_playable: true, - language: "en".to_string(), - languages: vec!["en".to_string()], - name: "Episode".to_string(), - release_date: "2024-01-01".to_string(), - release_date_precision: rspotify::model::DatePrecision::Day, - resume_point: None, - show: SimplifiedShow { - available_markets: Vec::new(), - copyrights: Vec::new(), - description: "Show description".to_string(), - explicit: false, - external_urls: HashMap::new(), - href: "https://example.com/show".to_string(), - id: ShowId::from_id("6mD5pBAZpHeQOdT0bFvB1V") - .unwrap() - .into_static(), - images: Vec::new(), - is_externally_hosted: None, - languages: vec!["en".to_string()], - media_type: "audio".to_string(), - name: "Show".to_string(), - publisher: "Publisher".to_string(), - }, - r#type: Type::Episode, - } - } - - #[test] - fn extracts_native_track_info() { - let mut app = app(); - app.song_progress_ms = 12_000; - app.is_streaming_active = true; - app.native_is_playing = Some(true); - app.native_track_info = Some(NativeTrackInfo { - name: "Native Track".to_string(), - artists: vec!["Native Artist".to_string()], - album: "Native Album".to_string(), - duration_ms: 123_000, - kind: NativeTrackKind::Track, - image_url: None, - }); - app.native_playback_origin = Some(NativePlaybackOrigin::RawList); - - let snapshot = current_playback_snapshot(&app).unwrap(); - - assert_eq!(snapshot.metadata.title, "Native Track"); - assert_eq!(snapshot.metadata.artists, vec!["Native Artist"]); - assert_eq!(snapshot.metadata.album, "Native Album"); - assert_eq!(snapshot.metadata.duration_ms, 123_000); - assert_eq!(snapshot.item_kind, PlaybackItemKind::Track); - assert_eq!(snapshot.source, PlaybackSource::NativeRawList); - assert_eq!(snapshot.progress_ms, 12_000); - assert!(snapshot.is_playing); - } - - /// #410: a native collaboration must reach the snapshot as a *structured* - /// artist list, not a single joined string, so the LRCLIB lookup can fall back - /// to the primary artist. `primary_artist()` still joins it for display, so the - /// playbar/window title are unchanged. - #[test] - fn native_collaboration_keeps_structured_artist_list() { - let mut app = app(); - app.is_streaming_active = true; - app.native_is_playing = Some(true); - app.native_track_info = Some(NativeTrackInfo { - name: "Take Me Back".to_string(), - artists: vec!["Kygo".to_string(), "Max McNown".to_string()], - album: "Take Me Back".to_string(), - duration_ms: 201_000, - kind: NativeTrackKind::Track, - image_url: None, - }); - app.native_playback_origin = Some(NativePlaybackOrigin::RawList); - - let snapshot = current_playback_snapshot(&app).unwrap(); - - assert_eq!(snapshot.metadata.artists, vec!["Kygo", "Max McNown"]); - assert_eq!(snapshot.primary_artist(), "Kygo, Max McNown"); - } - - /// #402: after a skip (and for the whole of a natively queued track) the - /// polled context still holds the *previous* item, so its cover art belongs to - /// the previous album. librespot's own `TrackChanged` art must win. - #[test] - fn native_track_art_wins_over_a_stale_context_item() { - let mut app = app(); - app.is_streaming_active = true; - app.native_track_info = Some(NativeTrackInfo { - name: "Native Track".to_string(), - artists: vec!["Native Artist".to_string()], - album: "Native Album".to_string(), - duration_ms: 123_000, - kind: NativeTrackKind::Track, - image_url: Some("https://i.scdn.co/image/current".to_string()), - }); - // The context lags on the previous track, whose art is a different album's. - app.current_playback_context = Some(playback_context(PlayableItem::Track(track()), true)); - - let snapshot = current_playback_snapshot(&app).unwrap(); - - assert_eq!( - snapshot.metadata.image_url.as_deref(), - Some("https://i.scdn.co/image/current"), - "the stale context item's art must not override librespot's" - ); - } - - /// Some payloads carry no covers at all (notably local files, whose art is - /// embedded in the file rather than on the CDN). The context item then stays - /// the only art source, so the pre-#402 fallback must survive. The context - /// here describes the *same* track that is playing, which is the only case - /// where trusting it is right. - #[test] - fn native_track_falls_back_to_context_art_without_librespot_covers() { - let mut app = app(); - app.is_streaming_active = true; - app.native_track_info = Some(NativeTrackInfo { - name: "Track".to_string(), - artists: vec!["Artist".to_string()], - album: "Album".to_string(), - duration_ms: 123_000, - kind: NativeTrackKind::Track, - image_url: None, - }); - app.current_playback_context = Some(playback_context(PlayableItem::Track(track()), true)); - - let snapshot = current_playback_snapshot(&app).unwrap(); - - assert_eq!( - snapshot.metadata.image_url.as_deref(), - Some("https://example.com/cover.jpg") - ); - } - - /// Boundary of the #402 fix: the librespot art is only consulted while native - /// metadata is authoritative. Once `get_current_playback` decides the API has - /// caught up it clears `native_track_info`, and the snapshot goes back to the - /// context item wholesale. That handoff is correct when the API really did - /// catch up, but note that `api_confirms_native_info_is_current` accepts a - /// bare *title* match (see `playback.rs`), so two consecutive same-titled - /// tracks still hand over early and show the previous album's art. - #[test] - fn context_art_is_used_once_native_info_is_cleared() { - let mut app = app(); - app.is_streaming_active = true; - app.native_track_info = None; - app.current_playback_context = Some(playback_context(PlayableItem::Track(track()), true)); - - let snapshot = current_playback_snapshot(&app).unwrap(); - - assert_eq!( - snapshot.metadata.image_url.as_deref(), - Some("https://example.com/cover.jpg") - ); - } - - #[test] - fn ignores_stale_native_play_state_for_api_metadata() { - let mut app = app(); - app.native_is_playing = Some(false); - app.current_playback_context = Some(playback_context(PlayableItem::Track(track()), true)); - - let snapshot = current_playback_snapshot(&app).unwrap(); - - assert_eq!(snapshot.metadata.title, "Track"); - assert!(snapshot.is_playing); - } - - #[test] - fn ignores_stale_native_metadata_when_streaming_is_inactive() { - let mut app = app(); - app.native_is_playing = Some(false); - app.native_track_info = Some(NativeTrackInfo { - name: "Native Track".to_string(), - artists: vec!["Native Artist".to_string()], - album: "Native Album".to_string(), - duration_ms: 123_000, - kind: NativeTrackKind::Track, - image_url: None, - }); - app.current_playback_context = Some(playback_context(PlayableItem::Track(track()), true)); - - let snapshot = current_playback_snapshot(&app).unwrap(); - - assert_eq!(snapshot.metadata.title, "Track"); - assert!(snapshot.is_playing); - } - - #[test] - fn extracts_spotify_track() { - let mut app = app(); - app.song_progress_ms = 32_000; - app.current_playback_context = Some(playback_context(PlayableItem::Track(track()), true)); - - let snapshot = current_playback_snapshot(&app).unwrap(); - - assert_eq!(snapshot.metadata.title, "Track"); - assert_eq!(snapshot.metadata.artists, vec!["Artist"]); - assert_eq!(snapshot.metadata.album, "Album"); - assert_eq!( - snapshot.metadata.image_url.as_deref(), - Some("https://example.com/cover.jpg") - ); - assert_eq!(snapshot.metadata.duration_ms, 181_000); - assert!(snapshot.shuffle); - assert!(!snapshot.is_live); - } - - #[test] - fn extracts_spotify_episode() { - let mut app = app(); - app.current_playback_context = Some(playback_context(PlayableItem::Episode(episode()), false)); - - let snapshot = current_playback_snapshot(&app).unwrap(); - - assert_eq!(snapshot.metadata.title, "Episode"); - assert_eq!(snapshot.metadata.artists, vec!["Show"]); - assert_eq!(snapshot.metadata.album, ""); - assert_eq!( - snapshot.metadata.image_url.as_deref(), - Some("https://example.com/episode.jpg") - ); - assert_eq!(snapshot.metadata.duration_ms, 2_400_000); - assert!(!snapshot.is_playing); - } - - #[test] - fn empty_playback_has_no_snapshot() { - let app = app(); - - assert_eq!(current_playback_snapshot(&app), None); - } - - #[cfg(all( - feature = "local-files", - any( - all(feature = "mpris", target_os = "linux"), - all(feature = "macos-media", target_os = "macos") - ) - ))] - #[test] - fn local_metadata_is_selected_when_local_active() { - use super::{select_media_metadata, LocalMediaMetadata}; - - let local = LocalMediaMetadata { - title: "Local Song".to_string(), - artists: vec!["Local Artist".to_string()], - album: "Local Album".to_string(), - duration_ms: 200_000, - }; - let spotify = PlaybackMetadata { - title: "Spotify Song".to_string(), - artists: vec!["Spotify Artist".to_string()], - album: "Spotify Album".to_string(), - image_url: Some("https://example.com/cover.jpg".to_string()), - duration_ms: 181_000, - }; - - let selected = select_media_metadata(Some(local), Some(spotify)).unwrap(); - - assert_eq!(selected.title, "Local Song"); - assert_eq!(selected.artists, vec!["Local Artist"]); - assert_eq!(selected.album, "Local Album"); - assert_eq!(selected.duration_ms, 200_000); - // Local sessions carry no album art URL. - assert_eq!(selected.image_url, None); - } - - #[cfg(all( - feature = "local-files", - any( - all(feature = "mpris", target_os = "linux"), - all(feature = "macos-media", target_os = "macos") - ) - ))] - #[test] - fn spotify_metadata_is_selected_when_local_inactive() { - use super::select_media_metadata; - - let spotify = PlaybackMetadata { - title: "Spotify Song".to_string(), - artists: vec!["Spotify Artist".to_string()], - album: "Spotify Album".to_string(), - image_url: Some("https://example.com/cover.jpg".to_string()), - duration_ms: 181_000, - }; - - let selected = select_media_metadata(None, Some(spotify.clone())).unwrap(); - - assert_eq!(selected, spotify); - } - - #[cfg(all( - feature = "local-files", - any( - all(feature = "mpris", target_os = "linux"), - all(feature = "macos-media", target_os = "macos") - ) - ))] - #[test] - fn no_metadata_when_neither_source_active() { - use super::select_media_metadata; - - assert_eq!(select_media_metadata(None, None), None); - } -} diff --git a/src/infra/mod.rs b/src/infra/mod.rs deleted file mode 100644 index 61c107e9..00000000 --- a/src/infra/mod.rs +++ /dev/null @@ -1,34 +0,0 @@ -pub mod audio; -#[cfg(feature = "discord-rpc")] -pub mod discord_rpc; -#[cfg(feature = "dj-core")] -pub mod dj; -// The collector half (spawned by the frontend launch) has no caller in a -// headless build; the stats half still serves the CLI. Every tui-enabled CI -// leg lints this module in full. -#[cfg_attr(not(feature = "tui"), allow(dead_code))] -pub mod history; -#[cfg(feature = "local-files")] -pub mod local; -#[cfg(all(feature = "macos-media", target_os = "macos"))] -pub mod macos_media; -#[cfg(feature = "mcp-server")] -pub mod mcp; -pub mod media_metadata; -#[cfg(all(feature = "mpris", target_os = "linux"))] -pub mod mpris; -pub mod network; -#[cfg(feature = "streaming")] -pub mod player; -#[cfg(feature = "qobuz")] -pub mod qobuz; -pub mod queue; -#[cfg(feature = "internet-radio")] -pub mod radio; -pub mod redirect_uri; -#[cfg(feature = "scripting")] -pub mod scripting; -#[cfg(feature = "subsonic")] -pub mod subsonic; -#[cfg(feature = "youtube")] -pub mod youtube; diff --git a/src/infra/mpris.rs b/src/infra/mpris.rs deleted file mode 100644 index 6474cbee..00000000 --- a/src/infra/mpris.rs +++ /dev/null @@ -1,324 +0,0 @@ -//! MPRIS D-Bus interface for desktop media control integration -//! -//! Exposes spotatui as a controllable media player via D-Bus, enabling: -//! - Media key support (play/pause, next, previous) -//! - Desktop environment integration (GNOME, KDE, etc.) -//! - playerctl command-line control -//! -//! This module is only available on Linux with the `mpris` feature enabled. - -use anyhow::Result; -use mpris_server::{Metadata, PlaybackStatus, Player, Time}; -use std::thread; -use tokio::sync::mpsc; - -/// Events that can be received from external MPRIS clients (e.g., media keys, playerctl) -#[derive(Debug, Clone)] -pub enum MprisEvent { - PlayPause, - Play, - Pause, - Next, - Previous, - Stop, - Seek(i64), // Relative offset in microseconds - SetPosition(i64), // Absolute position in microseconds - SetShuffle(bool), - SetLoopStatus(LoopStatusEvent), - SetVolume(u8), // 0-100 -} - -/// Loop status from MPRIS (matches mpris_server::LoopStatus) -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum LoopStatusEvent { - None, - Track, - Playlist, -} - -/// Commands to send TO the MPRIS server to update its state -#[derive(Debug, Clone)] -pub enum MprisCommand { - Metadata { - title: String, - artists: Vec, - album: String, - duration_ms: u32, - art_url: Option, - }, - PlaybackStatus(bool), // true = playing, false = paused - Position(u64), // position in milliseconds (silent update) - Seeked(u64), // position in milliseconds (emits Seeked signal to notify clients) - Volume(u8), // 0-100 - Shuffle(bool), // shuffle state - LoopStatus(LoopStatusEvent), // loop/repeat state - Stopped, -} - -/// Manager for the MPRIS D-Bus server -pub struct MprisManager { - event_rx: std::sync::Mutex>>, - command_tx: mpsc::UnboundedSender, -} - -impl MprisManager { - /// Create and start the MPRIS server - /// - /// Registers spotatui as `org.mpris.MediaPlayer2.spotatui` on D-Bus - /// The MPRIS server runs in a dedicated thread with its own runtime - /// because player.run() returns a !Send future that requires LocalSet - pub fn new() -> Result { - let (event_tx, event_rx) = mpsc::unbounded_channel(); - let (command_tx, mut command_rx) = mpsc::unbounded_channel::(); - - // Spawn MPRIS server in a dedicated thread with its own LocalSet runtime - // This is required because mpris_server::Player uses Rc internally (not Send) - thread::spawn(move || { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("Failed to create MPRIS runtime"); - - let local = tokio::task::LocalSet::new(); - local.block_on(&rt, async move { - // Build the MPRIS player - let player = match Player::builder("spotatui") - .identity("spotatui") - .desktop_entry("spotatui") - .can_play(true) - .can_pause(true) - .can_go_next(true) - .can_go_previous(true) - .can_seek(true) - .can_control(true) - .can_quit(false) - .can_raise(false) - .can_set_fullscreen(false) - // Enable shuffle and loop status support - .shuffle(false) // Initial state: shuffle off - .loop_status(mpris_server::LoopStatus::None) // Initial state: no repeat - .build() - .await - { - Ok(p) => p, - Err(e) => { - log::error!("Failed to build MPRIS player: {}", e); - return; - } - }; - - // Set up event handlers for external control requests - let tx = event_tx.clone(); - player.connect_play_pause(move |_player| { - let _ = tx.send(MprisEvent::PlayPause); - }); - - let tx = event_tx.clone(); - player.connect_play(move |_player| { - let _ = tx.send(MprisEvent::Play); - }); - - let tx = event_tx.clone(); - player.connect_pause(move |_player| { - let _ = tx.send(MprisEvent::Pause); - }); - - let tx = event_tx.clone(); - player.connect_next(move |_player| { - let _ = tx.send(MprisEvent::Next); - }); - - let tx = event_tx.clone(); - player.connect_previous(move |_player| { - let _ = tx.send(MprisEvent::Previous); - }); - - let tx = event_tx.clone(); - player.connect_stop(move |_player| { - let _ = tx.send(MprisEvent::Stop); - }); - - let tx = event_tx.clone(); - player.connect_seek(move |_player, offset| { - let _ = tx.send(MprisEvent::Seek(offset.as_micros())); - }); - - let tx = event_tx.clone(); - player.connect_set_position(move |_player, _track_id, position| { - let _ = tx.send(MprisEvent::SetPosition(position.as_micros())); - }); - - let tx = event_tx.clone(); - player.connect_set_shuffle(move |_player, shuffle| { - let _ = tx.send(MprisEvent::SetShuffle(shuffle)); - }); - - let tx = event_tx.clone(); - player.connect_set_volume(move |_player, volume| { - let percent = (volume * 100.0).round().clamp(0.0, 100.0) as u8; - let _ = tx.send(MprisEvent::SetVolume(percent)); - }); - - let tx = event_tx.clone(); - player.connect_set_loop_status(move |_player, loop_status| { - use mpris_server::LoopStatus; - let status = match loop_status { - LoopStatus::None => LoopStatusEvent::None, - LoopStatus::Track => LoopStatusEvent::Track, - LoopStatus::Playlist => LoopStatusEvent::Playlist, - }; - let _ = tx.send(MprisEvent::SetLoopStatus(status)); - }); - - // Spawn the player event loop - tokio::task::spawn_local(player.run()); - - // Handle commands from the main application - while let Some(cmd) = command_rx.recv().await { - match cmd { - MprisCommand::Metadata { - title, - artists, - album, - duration_ms, - art_url, - } => { - let mut builder = Metadata::builder() - .title(&title) - .artist(artists.iter().map(|s| s.as_str()).collect::>()) - .album(&album) - .length(Time::from_millis(duration_ms as i64)); - - if let Some(url) = &art_url { - builder = builder.art_url(url); - } - - let metadata = builder.build(); - - if let Err(e) = player.set_metadata(metadata).await { - log::warn!("MPRIS: Failed to set metadata: {}", e); - } - } - - MprisCommand::PlaybackStatus(is_playing) => { - let status = if is_playing { - PlaybackStatus::Playing - } else { - PlaybackStatus::Paused - }; - if let Err(e) = player.set_playback_status(status).await { - log::warn!("MPRIS: Failed to set playback status: {}", e); - } - } - MprisCommand::Position(position_ms) => { - // Silent position update (for regular playback progress) - player.set_position(Time::from_millis(position_ms as i64)); - } - MprisCommand::Seeked(position_ms) => { - // Update position AND emit Seeked signal so clients know to refresh - let time = Time::from_millis(position_ms as i64); - player.set_position(time); - if let Err(e) = player.seeked(time).await { - log::warn!("MPRIS: Failed to emit Seeked signal: {}", e); - } - } - MprisCommand::Volume(volume_percent) => { - let volume = (volume_percent as f64) / 100.0; - if let Err(e) = player.set_volume(volume).await { - log::warn!("MPRIS: Failed to set volume: {}", e); - } - } - MprisCommand::Shuffle(shuffle) => { - if let Err(e) = player.set_shuffle(shuffle).await { - log::warn!("MPRIS: Failed to set shuffle: {}", e); - } - } - MprisCommand::LoopStatus(loop_status) => { - use mpris_server::LoopStatus; - let status = match loop_status { - LoopStatusEvent::None => LoopStatus::None, - LoopStatusEvent::Track => LoopStatus::Track, - LoopStatusEvent::Playlist => LoopStatus::Playlist, - }; - if let Err(e) = player.set_loop_status(status).await { - log::warn!("MPRIS: Failed to set loop status: {}", e); - } - } - MprisCommand::Stopped => { - if let Err(e) = player.set_playback_status(PlaybackStatus::Stopped).await { - log::warn!("MPRIS: Failed to set stopped status: {}", e); - } - } - } - } - }); - }); - - Ok(Self { - event_rx: std::sync::Mutex::new(Some(event_rx)), - command_tx, - }) - } - - /// Take the event receiver for handling external control requests - /// - /// This can only be called once; subsequent calls return None - pub fn take_event_rx(&self) -> Option> { - self.event_rx.lock().ok()?.take() - } - - /// Update track metadata - pub fn set_metadata( - &self, - title: &str, - artists: &[String], - album: &str, - duration_ms: u32, - art_url: Option, - ) { - let _ = self.command_tx.send(MprisCommand::Metadata { - title: title.to_string(), - artists: artists.to_vec(), - album: album.to_string(), - duration_ms, - art_url, - }); - } - - /// Update playback status - pub fn set_playback_status(&self, is_playing: bool) { - let _ = self - .command_tx - .send(MprisCommand::PlaybackStatus(is_playing)); - } - - /// Update playback position (silent, no signal emitted) - pub fn set_position(&self, position_ms: u64) { - let _ = self.command_tx.send(MprisCommand::Position(position_ms)); - } - - /// Update position AND emit Seeked signal (use when position jumps due to seeking) - pub fn emit_seeked(&self, position_ms: u64) { - let _ = self.command_tx.send(MprisCommand::Seeked(position_ms)); - } - - /// Update volume (0-100) - pub fn set_volume(&self, volume_percent: u8) { - let _ = self.command_tx.send(MprisCommand::Volume(volume_percent)); - } - - /// Mark playback as stopped - pub fn set_stopped(&self) { - let _ = self.command_tx.send(MprisCommand::Stopped); - } - - /// Update shuffle state - pub fn set_shuffle(&self, shuffle: bool) { - let _ = self.command_tx.send(MprisCommand::Shuffle(shuffle)); - } - - /// Update loop/repeat status - pub fn set_loop_status(&self, status: LoopStatusEvent) { - let _ = self.command_tx.send(MprisCommand::LoopStatus(status)); - } -} diff --git a/src/infra/network/dj.rs b/src/infra/network/dj.rs deleted file mode 100644 index d297c902..00000000 --- a/src/infra/network/dj.rs +++ /dev/null @@ -1,1244 +0,0 @@ -//! Network-lane handlers for the DJ. -//! -//! These run on the **serial** IoEvent lane, which is where the real Spotify -//! client lives. The service lane deliberately constructs its `Network` with -//! `None` for the client (`runtime/`), so anything that has to resolve a track -//! name to a URI belongs here rather than there. - -use super::Network; -#[cfg(any(feature = "mcp-server", feature = "ai-dj"))] -use crate::core::action::{Action, ActionOutcome}; -use crate::core::plugin_api::TrackInfo; -use crate::infra::dj::resolve::{self, ResolveReport}; -#[cfg(any(feature = "mcp-server", feature = "ai-dj"))] -use crate::infra::dj::tools::{DjToolCall, QueueItem, ToolOutcome}; -#[cfg(any(feature = "mcp-server", feature = "ai-dj"))] -use crate::infra::dj::MAX_BATCH; -use crate::infra::dj::{library, DjLibrary, DjLine, DjSuggestion}; -use crate::infra::network::IoEvent; -use rspotify::model::track::FullTrack; -#[cfg(any(feature = "mcp-server", feature = "ai-dj"))] -use serde_json::json; -use std::collections::HashSet; -#[cfg(any(feature = "mcp-server", feature = "ai-dj"))] -use tokio::sync::oneshot; - -/// The tool handlers behind `DjToolCall`, shared by both front doors. -#[cfg(any(feature = "mcp-server", feature = "ai-dj"))] -impl Network { - /// Run a DJ tool call that needs the catalogue, and answer the waiting caller. - /// - /// The responder is a `oneshot`, so every path must send exactly once — - /// dropping it silently would leave an MCP client waiting out its timeout with - /// no idea why. - pub async fn run_dj_tool_call( - &mut self, - call: DjToolCall, - responder: oneshot::Sender, - ) { - let outcome = self.dj_tool_outcome(call).await; - // The receiver is gone if the MCP client hung up mid-call; that is normal, - // not an error worth surfacing. - let _ = responder.send(outcome); - } - - async fn dj_tool_outcome(&mut self, call: DjToolCall) -> ToolOutcome { - // This variant bypasses the generic auth gate so we can say something - // useful here instead of the caller seeing a dropped channel. - if self.spotify.is_none() { - return ToolOutcome::error( - "spotatui has no Spotify session, so the catalogue is unavailable. Log in from the \ - spotatui UI, then try again.", - ); - } - - match call { - DjToolCall::SearchTracks { query, limit } => self.dj_search(&query, limit).await, - DjToolCall::QueueTracks { - items, - exclude_owned, - extra_skip_keys, - } => self.dj_queue(items, exclude_owned, extra_skip_keys).await, - DjToolCall::PlayNow { uri } => self.dj_play_now(uri).await, - // Everything else is answered without the network, before the event is - // ever dispatched (`tools::execute_app_only`). - other => ToolOutcome::error(format!( - "{} does not need the catalogue and should not have been routed here", - other.tool_name() - )), - } - } - - async fn dj_search(&self, query: &str, limit: usize) -> ToolOutcome { - let params = vec![ - ("q", query.to_string()), - ("type", "track".to_string()), - ("limit", limit.to_string()), - ("offset", "0".to_string()), - ]; - let response = match self - .spotify_get_typed::("search", ¶ms) - .await - { - Ok(response) => response, - // Reported as an execution error, not the modal error page: an agent can - // act on this, and a failed search must not hijack the user's screen. - Err(e) => return ToolOutcome::error(format!("Search failed: {e}")), - }; - - let tracks: Vec = response - .tracks - .items - .iter() - .filter(|track| track.id.is_some()) - .map(TrackInfo::from) - .collect(); - - if tracks.is_empty() { - return ToolOutcome::ok(format!("No tracks found for \"{query}\"")); - } - - // Ownership rides along with the results rather than behind a separate tool: - // an agent already searches to confirm a track exists before queueing it, so - // this arrives exactly when it is choosing, with no extra round trip. Marking - // beats filtering here — the agent can still queue something the listener - // owns when that is genuinely the right answer. - let ownership = self.track_ownership(&tracks).await; - let owned = |track: &TrackInfo| { - track - .id - .as_ref() - .is_some_and(|id| ownership.owned.contains(id)) - }; - - let mut rendered = tracks - .iter() - .map(|track| { - format!( - "{} — {} [{}] [{}]", - track.name, - track.artists.join(", "), - track.uri.as_deref().unwrap_or("no uri"), - if owned(track) { "owned" } else { "new" } - ) - }) - .collect::>() - .join("\n"); - if !ownership.complete { - // Said out loud rather than passed off as complete: a silently partial - // answer is how this feature would look broken. - rendered.push_str( - "\n\nNote: the playlist index is still being built, so [owned] reflects Liked Songs only \ - for this search. Search again for the complete answer.", - ); - } - - let structured = json!({ - "ownership_complete": ownership.complete, - "tracks": tracks.iter().map(|track| json!({ - "title": track.name, - "artist": track.artists.join(", "), - "album": track.album, - "uri": track.uri, - "duration_ms": track.duration_ms, - "owned": owned(track), - })).collect::>() - }); - ToolOutcome::with_data(rendered, structured) - } - - async fn dj_queue( - &mut self, - items: Vec, - exclude_owned: bool, - extra_skip_keys: Vec, - ) -> ToolOutcome { - // Split the two input shapes: exact URIs are looked up as a batch, names go - // through the fuzzy resolver that drops anything it cannot confidently match. - let mut uris = Vec::new(); - let mut named: Vec = Vec::new(); - for item in items { - match item { - QueueItem::Uri(uri) => uris.push(uri), - QueueItem::Named(suggestion) => named.push(suggestion), - } - } - - // The one path that crawls inline. `exclude_owned` is a guarantee the caller - // opted into, so a cold index has to be paid for here rather than deferred - // the way `search_tracks` defers it — and a crawl that fails is refused - // outright, because queueing unfiltered would silently break the contract - // while looking like success. - // - // Deliberately before the resolve rather than after it: the name gate lives - // *inside* `resolve_suggestions` and needs these keys to skip a search it can - // already rule out. The cost of ordering it this way is one wasted crawl in - // the case where nothing resolves at all, once per process. - let library_keys = if exclude_owned { - match self.dj_library_index().await { - Some(library) => library.keys, - None => { - return ToolOutcome::error( - "Could not read your playlists, so exclude_owned cannot be honoured and nothing was \ - queued. Try again in a moment, or call queue_tracks without exclude_owned to queue \ - these anyway.", - ) - } - } - } else { - HashSet::new() - }; - - let mut report = ResolveReport::default(); - if !uris.is_empty() { - let (resolved, missing) = self.tracks_for_uris(&uris).await; - report.resolved.extend(resolved); - report.unresolved.extend(missing); - } - - if !named.is_empty() { - let skip_keys = { - let app = self.app.lock().await; - let mut keys = app.dj_skip_keys(); - keys.extend(extra_skip_keys); - keys - }; - let youtube = self.dj_youtube_resolver().await; - // The library set is empty unless the caller asked for `exclude_owned`: an - // agent told to queue a named track was told to queue *that* track, and - // dropping it because the listener owns it would answer a question nobody - // asked. When it did ask, this is the cheap gate — it rejects on the name - // before a catalogue search is paid for. - let named_report = resolve::resolve_suggestions( - self, - &named, - &resolve::SkipSets { - session: &skip_keys, - library: &library_keys, - }, - MAX_BATCH.saturating_sub(report.resolved.len()), - youtube.as_ref(), - ) - .await; - report.resolved.extend(named_report.resolved); - report.unresolved.extend(named_report.unresolved); - report.duplicates.extend(named_report.duplicates); - report.in_library.extend(named_report.in_library); - } - - // The exact gate, on the resolved ID. It catches what the name gate cannot — - // a title written differently enough to normalise apart — and it is the only - // gate that sees the `uri` entries at all, which never went through a name - // lookup. Nothing is substituted for a rejected track: the caller named - // these, so coming back short is the honest answer. - if exclude_owned { - self.reject_owned_tracks(&mut report).await; - } - - if report.resolved.is_empty() { - return ToolOutcome::error(format!( - "Nothing was queued.\n{}", - nothing_queued_detail(&report) - )); - } - - let queued_labels: Vec = report - .resolved - .iter() - .map(|track| format!("{} — {}", track.name, track.artists.join(", "))) - .collect(); - - // Read before the take: `summary()` counts `resolved`, and taking it first is - // what made every queue report "queued 0". - let summary = report.summary(); - let accepted = { - let mut app = self.app.lock().await; - match app.apply(Action::QueueTracks(std::mem::take(&mut report.resolved))) { - ActionOutcome::Queued { accepted } => accepted, - ActionOutcome::Applied | ActionOutcome::SettingsSaved { .. } => 0, - } - }; - // After the block, not inside it: the network layer's helper takes the lock - // itself. "DJ:", not "MCP:", because this handler serves both front doors and - // `DjToolCall` does not carry which one asked. - self.show_status_message(format!("DJ: {summary}"), 5).await; - - let mut text = format!("Queued {accepted} track(s):\n{}", queued_labels.join("\n")); - // Report the misses explicitly: MCP clients feed execution detail back to - // the model, which is how it learns not to invent that track again. - if !report.unresolved.is_empty() { - text.push_str(&format!( - "\n\nNot found (skipped): {}", - report.unresolved.join("; ") - )); - } - if !report.in_library.is_empty() { - // Its own line, and its own words. "Already queued" is false for a track - // they own and never queued, and "not found" would tell the model a real - // track does not exist — which is how it learns the wrong lesson. - text.push_str(&format!( - "\nAlready in your library (skipped by exclude_owned): {}", - report.in_library.join("; ") - )); - } - if !report.duplicates.is_empty() { - text.push_str(&format!( - "\nAlready queued, playing, or recently played (skipped): {}", - report.duplicates.join("; ") - )); - } - ToolOutcome::with_data( - text, - json!({ - "queued": queued_labels, - "not_found": report.unresolved, - "in_library": report.in_library, - "duplicates": report.duplicates, - }), - ) - } - - /// Interrupt playback with one track, after confirming it exists. - /// - /// On the network lane rather than the app-only lane purely for that - /// confirmation. Dispatching straight to `StartPlayback` is what the caller - /// ultimately wants, but doing it blind meant a URI the catalogue does not - /// have stopped playback while the tool reported it was playing — the one - /// answer an agent cannot recover from, because it has no reason to re-check. - /// - /// Deliberately not filtered by `exclude_owned`: the caller asked for this - /// exact track right now, and owning it is not a reason to refuse. - async fn dj_play_now(&self, uri: String) -> ToolOutcome { - // Only Spotify URIs can be checked from here. The opaque schemes are minted - // by their own sources and have no catalogue to look them up in, so they go - // through with wording that does not overclaim. - if !uri.starts_with("spotify:track:") { - // Well-formed is not the same as playable *here*: the router that would - // consume this scheme is gated on its own feature, and without it the event - // reaches nothing while the caller is told playback started. - if let Some(feature) = crate::core::queue::missing_source_feature(&uri) { - return ToolOutcome::error(format!( - "Nothing is playing that: this spotatui was built without the `{feature}` feature, so it \ - has no source that can play {uri}. Playback was left alone." - )); - } - self.dj_start_playback(&uri).await; - return ToolOutcome::ok(format!("Sent {uri} to the player")); - } - - let (resolved, _missing) = self.tracks_for_uris(std::slice::from_ref(&uri)).await; - let Some(track) = resolved.into_iter().next() else { - return ToolOutcome::error(format!( - "Nothing is playing that: {uri} is not in the catalogue, so playback was left alone. \ - Use search_tracks to get a URI that exists." - )); - }; - - let label = format!("{} — {}", track.name, track.artists.join(", ")); - self.dj_start_playback(&uri).await; - ToolOutcome::with_data( - format!("Playing {label}"), - json!({"uri": uri, "title": track.name, "artist": track.artists.join(", ")}), - ) - } - - async fn dj_start_playback(&self, uri: &str) { - let mut app = self.app.lock().await; - app.apply(Action::PlayUris { - uris: vec![uri.to_string()], - offset: Some(0), - }); - } - - /// Whether the listener already has each of these search results. - /// - /// The two halves cost wildly different amounts, so they are treated - /// differently. Liked Songs is one exact `me/library/contains` for the whole - /// page, so it is always checked. Playlists have no such lookup — the only way - /// to know is the crawl — so this reads the cached index and, when it is cold, - /// *asks* for the crawl as its own event instead of running it here. - /// - /// That deferral is the point. `search_tracks` is what an agent calls between - /// deciding and queueing, and the crawl is seconds of pagination on the serial - /// lane; running it inline would stall every other event behind an agent's - /// search, which is exactly the bug `ai_dj::open` exists to avoid in the TUI. So - /// the first search of a cold session answers from Liked Songs alone and says - /// so, and every search after it is complete. - async fn track_ownership(&self, tracks: &[TrackInfo]) -> Ownership { - let ids: Vec = tracks.iter().filter_map(|track| track.id.clone()).collect(); - if ids.is_empty() { - return Ownership { - owned: HashSet::new(), - complete: true, - }; - } - - let mut owned = library::liked_among(self, &ids).await; - let playlist_ids = { - let app = self.app.lock().await; - app.dj.library.as_ref().map(|library| library.ids.clone()) - }; - - match playlist_ids { - Some(playlist_ids) => { - owned.extend(ids.into_iter().filter(|id| playlist_ids.contains(id))); - Ownership { - owned, - complete: true, - } - } - None => { - self.request_library_index().await; - Ownership { - owned, - complete: false, - } - } - } - } - - /// Ask for the playlist crawl on the serial lane, unless it has already run or - /// is running. - /// - /// A channel send, never a call. This runs *on* the serial lane, so the crawl - /// queues behind the tool call that asked for it rather than extending it; the - /// channel is unbounded, so the send cannot block the handler it is inside. - async fn request_library_index(&self) { - let app = self.app.lock().await; - if app.dj.library.is_some() || app.dj.library_indexing { - return; - } - match app.io_tx_clone() { - Some(sender) => { - let _ = sender.send(IoEvent::DjIndexLibrary); - } - // Shutdown only, and unreachable in practice: `App::new` always sets the - // channel, `close_io_channel` is the sole path that clears it, and - // `mcp::spawn_listener` refuses to start without it — so an MCP call cannot - // arrive before it exists. - None => log::debug!("DJ: no event channel, so the library index was not requested"), - } - } -} - -/// Why a `queue_tracks` call queued nothing at all. -/// -/// Every bucket gets named, and each in its own words. Folding "already yours" -/// into "could not find" would teach the model that a real track does not exist, -/// and folding it into "already queued" describes something that never happened. -/// One reason per line, matching the success path's layout. Deliberately not -/// `"; "` between reasons: that is already the separator *within* a bucket, and a -/// track label is `"Title — Artist"`, so neither semicolons nor dashes can -/// separate the groups unambiguously. -#[cfg(any(feature = "mcp-server", feature = "ai-dj"))] -fn nothing_queued_detail(report: &ResolveReport) -> String { - let mut reasons = Vec::new(); - if !report.unresolved.is_empty() { - reasons.push(format!("Could not find: {}", report.unresolved.join("; "))); - } - if !report.in_library.is_empty() { - reasons.push(format!( - "Already in your library: {}", - report.in_library.join("; ") - )); - } - if !report.duplicates.is_empty() { - reasons.push(format!( - "Already queued, playing, or recently played: {}", - report.duplicates.join("; ") - )); - } - if reasons.is_empty() { - return "Every track was already queued or playing.".to_string(); - } - reasons.join("\n") -} - -/// What `search_tracks` was able to work out about ownership for one page of -/// results. -#[cfg(any(feature = "mcp-server", feature = "ai-dj"))] -struct Ownership { - /// Track IDs the listener already has. - owned: HashSet, - /// Whether both halves were consulted. `false` means the playlist index was - /// still cold and only Liked Songs was checked — reported to the caller rather - /// than passed off as a complete answer. - complete: bool, -} - -/// Helpers shared by both front doors. -impl Network { - /// Look up exact Spotify URIs so the queue shows real titles rather than raw - /// URIs. Non-Spotify URIs are queued as-is, since only their own source can - /// describe them. - #[cfg_attr(not(any(feature = "mcp-server", feature = "ai-dj")), allow(dead_code))] - async fn tracks_for_uris(&self, uris: &[String]) -> (Vec, Vec) { - let mut resolved = Vec::new(); - let mut missing = Vec::new(); - - let spotify_ids: Vec<&str> = uris - .iter() - .filter_map(|uri| uri.strip_prefix("spotify:track:")) - .collect(); - - if !spotify_ids.is_empty() { - // One `tracks/{id}` per track: the batch `tracks?ids=` endpoint is gone - // for Development Mode apps. - let this: &Self = self; - let lookups = spotify_ids.iter().map(|id| async move { - this - .spotify_get_typed::(&format!("tracks/{id}"), &[]) - .await - }); - for (id, result) in spotify_ids - .iter() - .zip(futures::future::join_all(lookups).await) - { - match result { - Ok(track) => resolved.push(TrackInfo::from(&track)), - Err(e) => { - log::debug!("DJ: track lookup failed for {id}: {e}"); - missing.push(format!("spotify:track:{id}")); - } - } - } - } - - for uri in uris.iter().filter(|uri| !uri.starts_with("spotify:track:")) { - if uri.starts_with("radio:") { - // A live stream is not a finite track; `add_track_to_native_queue` - // rejects these, so report rather than silently dropping. - missing.push(format!("{uri} (a radio stream, not a track)")); - continue; - } - // Only the schemes we can actually play get through. The rest — free - // text, `https://open.spotify.com/...` links, `spotify:album:` and - // friends — used to be fabricated into a `TrackInfo` named after the raw - // string and reported as queued, which told the caller its request had - // succeeded and left an unplayable row in the user's queue. - // - // Reported with the reason attached rather than a bare "could not find": - // these are the wrong *kind* of URI, so a retry with different spelling - // cannot help, and the caller should be able to tell that apart from a - // track that genuinely is not in the catalogue. - if !crate::core::queue::is_playable_track_uri(uri) { - missing.push(format!("{uri} (not a playable track URI)")); - continue; - } - // Same silent-drop class as `play_now`: the scheme is right, but this build - // has no router that would consume it, so queueing it would report success - // for a row that can never play. - if let Some(feature) = crate::core::queue::missing_source_feature(uri) { - missing.push(format!( - "{uri} (this spotatui was built without the `{feature}` feature)" - )); - continue; - } - resolved.push(TrackInfo { - uri: Some(uri.clone()), - name: uri.clone(), - artists: Vec::new(), - album: String::new(), - duration_ms: 0, - id: None, - album_id: None, - artist_refs: Vec::new(), - is_playable: true, - is_local: uri.starts_with("file:"), - track_number: 0, - explicit: false, - image_url: None, - }); - } - - (resolved, missing) - } - - /// Build the YouTube fallback resolver, when that source is compiled in. - async fn dj_youtube_resolver(&self) -> Option { - #[cfg(feature = "youtube")] - { - Some(resolve::YouTubeResolver::new(&self.app).await) - } - #[cfg(not(feature = "youtube"))] - { - None - } - } - - /// Serial lane: crawl the listener's own playlists for the avoid-library - /// filter, and cache the result on `App`. - /// - /// Idempotent by way of `library_indexing`: the in-TUI toggle, the first turn, - /// and an MCP `search_tracks` can all ask for this, and a second crawl would be - /// pure cost. - pub async fn dj_index_library(&mut self) { - let already = { - let mut app = self.app.lock().await; - let already = app.dj.library_indexing || app.dj.library.is_some(); - if !already { - app.dj.library_indexing = true; - } - already - }; - if already { - return; - } - - let outcome = self.build_dj_library().await; - let mut app = self.app.lock().await; - app.dj.library_indexing = false; - match outcome { - Ok(library) => { - let summary = library.summary(); - app.dj.library = Some(library); - app.dj.push_line(DjLine::system(summary.clone())); - app.set_status_message(format!("DJ: {summary}"), 5); - } - Err(e) => { - log::warn!("DJ: could not index the library: {e}"); - let message = format!("DJ: could not read your playlists ({e}); recommending unfiltered"); - app.dj.push_line(DjLine::system(message.clone())); - app.set_error_status_message(message, 8); - } - } - } - - /// Run the crawl, resolving the listener's own account ID first. - async fn build_dj_library(&self) -> anyhow::Result { - if self.spotify.is_none() { - anyhow::bail!("no Spotify session"); - } - let cached = { - self - .app - .lock() - .await - .user - .as_ref() - .map(|user| user.id.clone()) - }; - let owner_id = match cached { - Some(id) => id, - // Not fetched yet (or a source other than Spotify was browsed first). One - // cheap call rather than filtering against nothing. - None => { - #[derive(serde::Deserialize)] - struct Me { - id: String, - } - self.spotify_get_typed::("me", &[]).await?.id - } - }; - library::build_index(self, &owner_id).await - } - - /// The avoid-library index, crawling for it inline if it is not cached yet. - /// - /// `None` means the crawl itself failed, and the two callers want opposite - /// things from that: the in-TUI DJ carries on unfiltered, because handing back - /// an empty queue is worse than handing back a track you own, while - /// `queue_tracks(exclude_owned)` refuses, because it was asked for a guarantee. - /// - /// Costs the caller a few seconds of pagination when it is cold, which is why - /// every path that can warm it earlier does: `ai_dj::open`, the in-TUI toggle, - /// and `search_tracks` all dispatch `DjIndexLibrary` instead of waiting for - /// this. - async fn dj_library_index(&mut self) -> Option { - if let Some(library) = { self.app.lock().await.dj.library.clone() } { - return Some(library); - } - self.dj_index_library().await; - self.app.lock().await.dj.library.clone() - } - - /// Drop resolved tracks the listener already owns, moving them to the - /// `in_library` bucket. - /// - /// This is the exact gate, and it catches what the name-based one cannot: the - /// title was written differently enough to normalise apart, but search landed - /// on the very copy sitting in their playlist. Two sources of truth: the - /// crawled playlist IDs, and one `me/library/contains` call for Liked Songs, - /// which needs no index at all. - async fn reject_owned_tracks(&mut self, report: &mut ResolveReport) { - let ids: Vec = report - .resolved - .iter() - .filter_map(|track| track.id.clone()) - .collect(); - if ids.is_empty() { - return; - } - - let playlist_ids = { - let app = self.app.lock().await; - app - .dj - .library - .as_ref() - .map(|library| library.ids.clone()) - .unwrap_or_default() - }; - let liked = library::liked_among(self, &ids).await; - - let mut kept = Vec::with_capacity(report.resolved.len()); - for track in std::mem::take(&mut report.resolved) { - let owned = track - .id - .as_ref() - .is_some_and(|id| liked.contains(id) || playlist_ids.contains(id)); - if owned { - report - .in_library - .push(format!("{} — {}", track.name, track.artists.join(", "))); - } else { - kept.push(track); - } - } - report.resolved = kept; - } -} - -/// Tests for the shared tool handlers, which exist under either front door. -#[cfg(all(test, any(feature = "mcp-server", feature = "ai-dj")))] -mod tests { - use super::*; - use crate::core::app::App; - use crate::core::config::ClientConfig; - use std::sync::Arc; - use tokio::sync::Mutex; - - fn temp_token_cache_path() -> std::path::PathBuf { - std::env::temp_dir().join("spotatui-dj-test-token-cache.json") - } - - async fn unauthenticated_network() -> Network { - let app = Arc::new(Mutex::new(App::default())); - Network::new(None, ClientConfig::new(), &app, temp_token_cache_path()) - } - - #[tokio::test] - async fn an_unauthenticated_call_answers_instead_of_dropping_the_channel() { - // The whole reason this event bypasses the auth gate: the caller is an MCP - // client waiting on a oneshot, and a dropped channel tells it nothing. - let mut network = unauthenticated_network().await; - let (tx, rx) = oneshot::channel(); - network - .run_dj_tool_call( - DjToolCall::SearchTracks { - query: "nude".into(), - limit: 5, - }, - tx, - ) - .await; - let outcome = rx.await.expect("the responder must always be used"); - assert!(outcome.is_error); - assert!(outcome.text.contains("no Spotify session")); - } - - #[tokio::test] - async fn app_only_calls_routed_here_by_mistake_are_reported_not_ignored() { - let mut network = unauthenticated_network().await; - let (tx, rx) = oneshot::channel(); - // Reaches the auth check first in this fixture, which still answers; the - // point is that the responder is always consumed. - network.run_dj_tool_call(DjToolCall::SkipTrack, tx).await; - assert!(rx.await.is_ok()); - } - - #[tokio::test] - async fn uris_that_are_not_tracks_are_reported_rather_than_fabricated() { - // These reach no network: the point is that they never become a TrackInfo. - // They used to be turned into one named after the raw string, pushed into - // the user's queue as unplayable rows, and counted as queued — so the - // caller was told its request had succeeded. - let network = unauthenticated_network().await; - let uris: Vec = [ - "not-a-uri", - "spotify:album:1DFixLWuPkv3KT3TnV35m3", - "spotify:playlist:37i9dQZF1DXcBWIGoYBM5M", - "https://open.spotify.com/track/7o2AeQZzfCERsRmOM86EcB", - ] - .iter() - .map(|uri| uri.to_string()) - .collect(); - - let (resolved, missing) = network.tracks_for_uris(&uris).await; - assert!( - resolved.is_empty(), - "nothing here names a track: {resolved:?}" - ); - assert_eq!(missing.len(), uris.len()); - for report in &missing { - // Says *why*, so the model does not retry a spelling fix that cannot - // work: these are the wrong kind of URI, not a track that is missing. - assert!( - report.contains("not a playable track URI"), - "the reason has to travel with the report: {report}" - ); - } - - // A radio stream is a finite-track failure of its own kind, and says so. - let (_, missing) = network - .tracks_for_uris(&["radio:https://example.com/s.aac".to_string()]) - .await; - assert!(missing[0].contains("radio stream"), "{missing:?}"); - } - - #[tokio::test] - async fn opaque_source_uris_pass_through_when_their_source_is_compiled_in() { - // The pass-through exists for sources with no catalogue to check against; - // tightening the gate must not break them. But "no catalogue to check" is not - // "always playable": the router for each of these schemes is `#[cfg]`-gated, - // and a build without it would queue a row that can never play while telling - // the agent it succeeded. - let network = unauthenticated_network().await; - let uris: Vec = ["file:/music/a.flac", "subsonic:track:1", "youtube:abc"] - .iter() - .map(|uri| uri.to_string()) - .collect(); - let (resolved, missing) = network.tracks_for_uris(&uris).await; - let compiled_in = uris - .iter() - .filter(|uri| crate::core::queue::missing_source_feature(uri).is_none()) - .count(); - assert_eq!(resolved.len(), compiled_in, "{resolved:?}"); - assert_eq!(missing.len(), uris.len() - compiled_in, "{missing:?}"); - // Rejections name the feature, so the agent can tell "this build cannot" from - // "this track does not exist". - assert!( - missing.iter().all(|m| m.contains("built without the")), - "{missing:?}" - ); - } - - #[tokio::test] - async fn exclude_owned_refuses_rather_than_queueing_unfiltered() { - // The guarantee is the whole reason the flag exists. With no Spotify session - // the playlist crawl cannot run, and queueing the batch anyway would look - // like success while doing the opposite of what was asked. - let mut network = unauthenticated_network().await; - let outcome = network - .dj_queue( - vec![QueueItem::Uri("spotify:track:abc".to_string())], - /* exclude_owned */ true, - Vec::new(), - ) - .await; - assert!(outcome.is_error); - assert!( - outcome.text.contains("exclude_owned cannot be honoured"), - "the caller has to learn the filter did not run: {}", - outcome.text - ); - // Names the way out, so an agent can recover without guessing. - assert!(outcome.text.contains("without exclude_owned")); - } - - #[test] - fn nothing_queued_names_every_bucket_in_its_own_words() { - let owned = ResolveReport { - in_library: vec!["Firestone — Kygo".into()], - ..ResolveReport::default() - }; - let detail = nothing_queued_detail(&owned); - assert!(detail.contains("Already in your library: Firestone — Kygo")); - assert!( - !detail.contains("Could not find"), - "a track they own exists; saying otherwise teaches the model something false" - ); - assert!( - !detail.contains("Already queued"), - "it was never queued, so that explains nothing" - ); - - let mixed = ResolveReport { - unresolved: vec!["Ghost Song".into(), "Second Ghost".into()], - in_library: vec!["Firestone — Kygo".into()], - duplicates: vec!["Nightcall".into()], - ..ResolveReport::default() - }; - let detail = nothing_queued_detail(&mixed); - for expected in [ - "Could not find: Ghost Song; Second Ghost", - "Already in your library: Firestone — Kygo", - "Already queued, playing, or recently played: Nightcall", - ] { - assert!( - detail.contains(expected), - "{expected} missing from {detail}" - ); - } - // One reason per line. `; ` already separates entries *inside* a bucket and - // `—` sits inside every track label, so reusing either as the group separator - // would make a multi-entry bucket unreadable. - assert_eq!( - detail.lines().count(), - 3, - "each reason needs its own line: {detail}" - ); - - // The fallback still says something, rather than trailing off after the dash. - assert!(!nothing_queued_detail(&ResolveReport::default()).is_empty()); - } - - #[tokio::test] - async fn a_radio_uri_is_reported_as_a_stream_rather_than_queued_as_a_track() { - // A live stream is not a finite track, and `add_track_to_native_queue` - // rejects it — so queueing one would leave a row that can never advance. - // The reason has to say *stream*: told "not found", the model learns the - // station does not exist and goes looking for another spelling. - let network = unauthenticated_network().await; - let (resolved, missing) = network.tracks_for_uris(&["radio:somafm".to_string()]).await; - assert!(resolved.is_empty(), "{resolved:?}"); - assert_eq!(missing.len(), 1); - assert!( - missing[0].contains("a radio stream, not a track"), - "{missing:?}" - ); - } - - /// `play_now`'s URI gate lives in `tools::parse_call`, which is the only - /// producer of `DjToolCall::PlayNow`, so `dj_play_now` never sees a URI that - /// is not a single playable track. This pins that the two stay joined up: - /// widening the parse gate without widening the lane's build-capability check - /// is what would let a URI through to a router that does not exist. - #[test] - fn play_now_only_ever_reaches_the_lane_with_a_playable_track_uri() { - for uri in [ - "not-a-uri", - "spotify:album:1DFixLWuPkv3KT3TnV35m3", - "https://open.spotify.com/track/7o2AeQZzfCERsRmOM86EcB", - "radio:somafm", - ] { - assert!( - crate::infra::dj::tools::parse_call("play_now", &json!({"uri": uri})).is_err(), - "{uri} must not reach the network lane at all" - ); - } - } -} - -// --------------------------------------------------------------------------- -// The in-TUI DJ -// --------------------------------------------------------------------------- - -#[cfg(feature = "ai-dj")] -mod in_tui { - use super::*; - use crate::infra::dj::agent::Turn; - use crate::infra::dj::brain::DjBrain; - use crate::infra::dj::exec::AppExecutor; - use crate::infra::dj::session; - use crate::infra::dj::{AskDjRequest, DjState, QUEUE_LOW_WATER}; - use std::sync::Arc; - - impl Network { - /// Service lane: run one DJ turn. - /// - /// The turn loop lives in `dj::agent`; this assembles what it needs and - /// reports how it ended. Tool calls that need the catalogue go back down the - /// serial lane from inside the loop, because this lane's `Network` has no - /// Spotify client. - /// - /// Writes no `you` line: the handler already pushed what the listener typed, - /// synchronously, so it is on screen while this lane is still waiting on the - /// brain. See `AskDjRequest::extra_instruction`. - pub async fn ask_dj(&mut self, request: AskDjRequest) { - let outcome = self.run_turn(&request).await; - let mut app = self.app.lock().await; - // Whether this turn is still the current one. Read before `finish_turn` - // purely for clarity — that call does not touch `turn_seq`. - let owned = app.dj.turn_seq == request.turn_seq; - // Only if this turn still owns the flag: an abandoned turn finishing would - // otherwise clear the flag its replacement had already set, and the refill - // tick gates on exactly that flag. - app.dj.finish_turn(request.turn_seq); - match outcome { - Ok(turn) => { - // The listener's words only become the standing auto-queue direction - // when the turn actually did something. Otherwise "what did I play last - // week?" would silently steer every later refill. A vibe the model set - // itself is more considered than the raw sentence, so it wins. - if turn.acted && !turn.abandoned && !turn.vibe_set { - if let Some(vibe) = request.vibe_on_success { - app.dj.vibe = Some(vibe); - } - } - // A refill happens while the listener is on another screen, so the - // transcript is not a surface they can see. The interactive case is - // deliberately left transcript-only. - if request.must_act && !turn.abandoned { - if turn.acted { - app.set_status_message("DJ: topped up the queue".to_string(), 5); - } else { - // A turn that had to queue and did not is a failure, not a quiet - // success. A vibe shift has just *emptied* the queue, and with - // auto-queue off nothing retries — so saying nothing leaves the - // listener with silence and no explanation. - let message = "DJ: nothing was queued that time. Ask again, or try a different \ - direction."; - app.dj.push_line(DjLine::system(message)); - app.set_error_status_message(message.to_string(), 6); - } - } - } - Err(e) => { - log::warn!("DJ: turn failed: {e}"); - // Silent when the turn has been superseded. An agent CLI hits its - // timeout a minute and a half after the listener moved on, and a - // failure toast for a turn they abandoned would land over the - // conversation they are now having. - if !owned { - return; - } - // Naming the backend matters when several are configurable: "DJ error" - // alone does not say whether to check the CLI or the key. - let backend = session::build_brain(&app.user_config.behavior) - .map(|brain| brain.label()) - .unwrap_or("DJ"); - let message = format!("DJ error ({backend}): {e}"); - app.dj.push_line(DjLine::system(message.clone())); - // A toast, never `handle_error` — that pushes a modal error page over - // whatever the user was doing. - app.set_error_status_message(message, 8); - } - } - } - - async fn run_turn( - &mut self, - request: &AskDjRequest, - ) -> anyhow::Result { - let (brain, io_tx): (DjBrain, _) = { - let app = self.app.lock().await; - ( - session::build_brain(&app.user_config.behavior)?, - app.io_tx_clone(), - ) - }; - let Some(io_tx) = io_tx else { - anyhow::bail!("no event channel available"); - }; - let context = session::turn_context(&self.app, request.extra_instruction.as_deref()).await?; - // Silent: the DJ transcript already shows every call, so the MCP front - // door's status-bar announcements would be duplicated and misattributed. - let executor = AppExecutor::silent(Arc::clone(&self.app), io_tx); - - Turn { - app: &self.app, - brain: &brain, - context: &context, - executor: &executor, - generation: request.generation, - must_act: request.must_act, - } - .run() - .await - } - - /// Service lane: refill the queue because it is running low. - pub async fn dj_top_up(&mut self, generation: u64, turn_seq: u64) { - // Cheap early bail before spending a brain call on a stale request. - { - let mut app = self.app.lock().await; - if app.dj.generation != generation || !app.dj.auto_queue { - log::debug!("DJ: dropping a stale top-up (generation {generation})"); - app.dj.finish_turn(turn_seq); - return; - } - } - self - .ask_dj(AskDjRequest { - extra_instruction: None, - generation, - // Nobody is watching a refill, so it may not stop to ask anything. - must_act: true, - vibe_on_success: None, - turn_seq, - }) - .await; - } - } - - /// Whether the queue is low enough to want a refill. - /// - /// Free function so the tick can ask without constructing a `Network`. - /// - /// `external_spotify_device` gates the whole thing, because `queue_len` is - /// only a meaningful measure of runway when queued tracks actually land in - /// the native queue. On an external Connect device - /// `App::add_track_to_native_queue` diverts every Spotify track to the Web - /// API queue instead, so `native_queue` stays empty no matter how much the DJ - /// queues — and refilling on it would add a fresh batch per brain call, - /// forever. The remote queue's depth is not cheaply observable, so the DJ - /// waits until the native queue feeds playback again. - pub fn wants_top_up(queue_len: usize, dj: &DjState, external_spotify_device: bool) -> bool { - !external_spotify_device && dj.auto_queue && !dj.thinking && queue_len <= QUEUE_LOW_WATER - } - - #[cfg(test)] - mod tests { - use super::*; - use crate::core::app::App; - - /// A `Network` with no Spotify client, plus the `App` it shares. - async fn network_with_app() -> (Network, std::sync::Arc>) { - let (tx, rx) = std::sync::mpsc::channel(); - // Leaked deliberately: dropping the receiver would make every dispatch fail - // as "shutting down", which is not the state under test. - std::mem::forget(rx); - let app = std::sync::Arc::new(tokio::sync::Mutex::new(App::new( - tx, - crate::core::user_config::UserConfig::new(), - Some(std::time::SystemTime::now()), - ))); - let network = Network::new( - None, - crate::core::config::ClientConfig::new(), - &app, - std::env::temp_dir().join("spotatui-dj-turn-test-cache.json"), - ); - (network, app) - } - - /// A stub agent CLI that answers with words and never calls a tool. - #[cfg(unix)] - fn say_only_stub() -> std::path::PathBuf { - use std::os::unix::fs::PermissionsExt; - static STUB: std::sync::OnceLock = std::sync::OnceLock::new(); - STUB - .get_or_init(|| { - let dir = - std::env::temp_dir().join(format!("spotatui-turn-stub-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("say-only"); - std::fs::write( - &path, - "#!/bin/sh\ncat >/dev/null\necho '{\"say\":\"What sort of mood?\",\"tool_calls\":[]}'\n", - ) - .unwrap(); - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); - path - }) - .clone() - } - - #[cfg(unix)] - #[tokio::test] - async fn a_refill_that_queues_nothing_says_so_rather_than_going_quiet() { - // A vibe shift drops the DJ's queued tracks *first*, so a turn that then - // queues nothing leaves the listener in silence. With auto-queue off there - // is no tick to retry it either, so this message is the only signal. - let (mut network, app) = network_with_app().await; - let turn_seq = { - let mut app = app.lock().await; - app.user_config.behavior.dj_agent_command = - vec![say_only_stub().to_string_lossy().to_string()]; - app.user_config.behavior.dj_agent_prompt_via = Some("stdin".to_string()); - app.dj.begin_turn(crate::infra::dj::TurnKind::Ask) - }; - - network - .ask_dj(AskDjRequest { - extra_instruction: None, - generation: 0, - must_act: true, - vibe_on_success: None, - turn_seq, - }) - .await; - - let app = app.lock().await; - assert!(!app.dj.thinking, "the flag has to be released either way"); - let transcript: Vec<&str> = app - .dj - .transcript - .iter() - .map(|line| line.text.as_str()) - .collect(); - assert!( - transcript.iter().any(|t| t.contains("nothing was queued")), - "{transcript:?}" - ); - assert!( - app.status_message_is_error(), - "and it is reported as a failure" - ); - } - - #[test] - fn top_up_is_wanted_only_when_enabled_idle_and_low() { - let mut dj = DjState { - auto_queue: true, - ..DjState::default() - }; - assert!(wants_top_up(0, &dj, false), "an empty queue wants a refill"); - assert!( - wants_top_up(QUEUE_LOW_WATER, &dj, false), - "at the watermark" - ); - assert!(!wants_top_up(QUEUE_LOW_WATER + 1, &dj, false), "above it"); - - dj.thinking = true; - assert!( - !wants_top_up(0, &dj, false), - "must not stack refills while one is in flight" - ); - - dj.thinking = false; - dj.auto_queue = false; - assert!(!wants_top_up(0, &dj, false), "DJ off means no refill"); - } - - #[test] - fn no_refill_while_an_external_spotify_device_owns_playback() { - // On an external Connect device, queued Spotify tracks are diverted to the - // Web API queue and `native_queue` stays empty — so a length of 0 is not - // "out of runway", and refilling on it queues a batch per brain call - // forever. - let dj = DjState { - auto_queue: true, - ..DjState::default() - }; - assert!( - !wants_top_up(0, &dj, true), - "an empty native queue proves nothing on a remote device" - ); - assert!( - wants_top_up(0, &dj, false), - "and refills resume once playback is back on this device" - ); - } - - #[tokio::test] - async fn a_stale_top_up_releases_the_progress_flag_before_bailing() { - // The `finish_turn` in that bail is load-bearing, not tidiness: the - // dispatcher already set `thinking`, and `wants_top_up` gates on it, so a - // bail that skipped it would stop auto-queue for the rest of the session - // with nothing on screen to explain why. - let (mut network, app) = network_with_app().await; - let turn_seq = { - let mut app = app.lock().await; - app.dj.auto_queue = true; - let turn_seq = app.dj.begin_turn(crate::infra::dj::TurnKind::Refill); - // The listener moved on while the refill was in flight. - app.dj.bump_generation(); - turn_seq - }; - - network.dj_top_up(0, turn_seq).await; - - let app = app.lock().await; - assert!( - !app.dj.thinking, - "a dropped refill must not wedge auto-queue" - ); - assert!(wants_top_up(0, &app.dj, false)); - } - } -} - -#[cfg(feature = "ai-dj")] -pub use in_tui::wants_top_up; diff --git a/src/infra/network/friends.rs b/src/infra/network/friends.rs deleted file mode 100644 index 83b0b08a..00000000 --- a/src/infra/network/friends.rs +++ /dev/null @@ -1,349 +0,0 @@ -use crate::core::app::{FriendEntry, FriendNowPlaying, FriendSearchResult}; -use crate::infra::network::Network; -use anyhow::Result; -use log::{info, warn}; -use serde::Deserialize; - -const FRIENDS_URL: &str = "https://spotatui.com/api/friends"; -const PROFILE_URL: &str = "https://spotatui.com/api/profile"; -const USERS_SEARCH_URL: &str = "https://spotatui.com/api/users/search"; - -// ── Response shapes from the spotatui.com API ───────────────────────────────── - -#[derive(Debug, Deserialize)] -struct NowPlayingData { - title: String, - artists: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct PublicUserData { - id: String, - name: String, - is_online: bool, - now_playing: Option, - listening_ms: Option, - total_listens: Option, -} - -#[derive(Debug, Deserialize)] -struct FriendsResponse { - friends: Vec, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ProfileResponse { - friend_code: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct SearchUserData { - id: String, - name: String, - #[serde(default)] - is_following: bool, -} - -#[derive(Debug, Deserialize)] -struct UsersSearchResponse { - users: Vec, -} - -// ── Read sync token from App state ──────────────────────────────────────────── - -/// Read the sync token by briefly locking the app. -/// Call this before any `.await` branches that need the token. -async fn read_sync_token(network: &Network) -> Option { - let app = network.app.lock().await; - app.user_config.behavior.sync_token.clone() -} - -// ── Actual HTTP functions ───────────────────────────────────────────────────── - -async fn fetch_profile(token: &str) -> Result { - let client = crate::infra::network::requests::shared_http_client(); - let resp = client - .get(PROFILE_URL) - .header("Authorization", format!("Bearer {}", token)) - .send() - .await?; - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return Err(anyhow::anyhow!( - "profile fetch failed ({}): {}", - status, - body - )); - } - - let data: ProfileResponse = resp.json().await?; - Ok(data.friend_code) -} - -async fn fetch_friends(token: &str) -> Result> { - let client = crate::infra::network::requests::shared_http_client(); - let resp = client - .get(FRIENDS_URL) - .header("Authorization", format!("Bearer {}", token)) - .send() - .await?; - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return Err(anyhow::anyhow!( - "friends fetch failed ({}): {}", - status, - body - )); - } - - let data: FriendsResponse = resp.json().await?; - let entries = data - .friends - .into_iter() - .map(|u| FriendEntry { - id: u.id, - name_lower: u.name.to_lowercase(), - name: u.name, - is_online: u.is_online, - now_playing: u.now_playing.map(|np| FriendNowPlaying { - title: np.title, - artists: np.artists, - }), - listening_ms: u.listening_ms.unwrap_or(0), - total_listens: u.total_listens.unwrap_or(0), - }) - .collect(); - Ok(entries) -} - -async fn post_add_friend_by_code(token: &str, friend_code: &str) -> Result<()> { - let body = serde_json::json!({ "friendCode": friend_code }); - post_friend_request(token, body).await -} - -async fn post_add_friend_by_user_id(token: &str, user_id: &str) -> Result<()> { - let body = serde_json::json!({ "userId": user_id }); - post_friend_request(token, body).await -} - -async fn post_friend_request(token: &str, body: serde_json::Value) -> Result<()> { - let client = crate::infra::network::requests::shared_http_client(); - let resp = client - .post(FRIENDS_URL) - .header("Authorization", format!("Bearer {}", token)) - .json(&body) - .send() - .await?; - - if !resp.status().is_success() { - let status = resp.status(); - let text = resp.text().await.unwrap_or_default(); - // Try to parse a JSON error message - let msg = serde_json::from_str::(&text) - .ok() - .and_then(|v| v["error"].as_str().map(String::from)) - .unwrap_or(text); - return Err(anyhow::anyhow!("{} (HTTP {})", msg, status)); - } - - Ok(()) -} - -async fn delete_friend(token: &str, user_id: &str) -> Result<()> { - let client = crate::infra::network::requests::shared_http_client(); - let body = serde_json::json!({ "userId": user_id }); - let resp = client - .delete(FRIENDS_URL) - .header("Authorization", format!("Bearer {}", token)) - .json(&body) - .send() - .await?; - - if !resp.status().is_success() { - let status = resp.status(); - let text = resp.text().await.unwrap_or_default(); - let msg = serde_json::from_str::(&text) - .ok() - .and_then(|v| v["error"].as_str().map(String::from)) - .unwrap_or(text); - return Err(anyhow::anyhow!("{} (HTTP {})", msg, status)); - } - - Ok(()) -} - -async fn fetch_user_search(token: &str, query: &str) -> Result> { - if query.trim().is_empty() { - return Ok(vec![]); - } - - let client = crate::infra::network::requests::shared_http_client(); - let resp = client - .get(USERS_SEARCH_URL) - .header("Authorization", format!("Bearer {}", token)) - .query(&[("q", query)]) - .send() - .await?; - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return Err(anyhow::anyhow!("user search failed ({}): {}", status, body)); - } - - let data: UsersSearchResponse = resp.json().await?; - let results = data - .users - .into_iter() - .map(|u| FriendSearchResult { - id: u.id, - name: u.name, - is_following: u.is_following, - }) - .collect(); - Ok(results) -} - -// ── Convenience wrappers that extract the sync token before the async work ──── -// -// These free functions are called from the IoEvent match in `Network::handle_network_event`. -// Each one grabs the token from App state, then delegates to the proper HTTP helper. - -pub async fn handle_get_friend_code(network: &mut Network) { - let token = match read_sync_token(network).await { - Some(t) => t, - None => return, - }; - match fetch_profile(&token).await { - Ok(code) => { - let mut app = network.app.lock().await; - app.friend_code = Some(code); - } - Err(e) => warn!("friends: failed to fetch friend code: {}", e), - } -} - -pub async fn handle_get_friends(network: &mut Network) { - let token = match read_sync_token(network).await { - Some(t) => t, - None => { - let mut app = network.app.lock().await; - app.friends_loading = false; - return; - } - }; - { - let mut app = network.app.lock().await; - app.friends_loading = true; - } - match fetch_friends(&token).await { - Ok(friends) => { - let mut app = network.app.lock().await; - let len = friends.len(); - app.friends = friends; - app.friends_loading = false; - if app.view.friend_selected_index >= len && len > 0 { - app.view.friend_selected_index = len - 1; - } - info!("friends: loaded {} friends", len); - } - Err(e) => { - let mut app = network.app.lock().await; - app.friends_loading = false; - warn!("friends: failed to load friends: {}", e); - } - } -} - -pub async fn handle_add_friend_by_code(network: &mut Network, friend_code: String) { - let token = match read_sync_token(network).await { - Some(t) => t, - None => return, - }; - match post_add_friend_by_code(&token, &friend_code).await { - Ok(_) => { - handle_get_friends(network).await; - network - .show_status_message(format!("Added friend (code: {})", friend_code), 4) - .await; - } - Err(e) => { - network - .show_status_message(format!("Could not add friend: {}", e), 5) - .await; - } - } -} - -pub async fn handle_add_friend_by_user_id(network: &mut Network, user_id: String) { - let token = match read_sync_token(network).await { - Some(t) => t, - None => return, - }; - match post_add_friend_by_user_id(&token, &user_id).await { - Ok(_) => { - handle_get_friends(network).await; - network - .show_status_message("Added friend".to_string(), 4) - .await; - } - Err(e) => { - network - .show_status_message(format!("Could not add friend: {}", e), 5) - .await; - } - } -} - -pub async fn handle_unfollow_friend(network: &mut Network, user_id: String) { - let token = match read_sync_token(network).await { - Some(t) => t, - None => return, - }; - match delete_friend(&token, &user_id).await { - Ok(_) => { - handle_get_friends(network).await; - network - .show_status_message("Unfollowed".to_string(), 3) - .await; - } - Err(e) => { - network - .show_status_message(format!("Failed to unfollow: {}", e), 5) - .await; - } - } -} - -pub async fn handle_search_friend_users(network: &mut Network, query: String) { - let token = match read_sync_token(network).await { - Some(t) => t, - None => return, - }; - match fetch_user_search(&token, &query).await { - Ok(results) => { - let mut app = network.app.lock().await; - let current_query: String = app.view.friend_user_search_input.iter().collect(); - if current_query == query { - app.friend_user_search_results = results; - app.view.friend_user_search_selected = 0; - } - } - Err(e) => { - let mut app = network.app.lock().await; - let current_query: String = app.view.friend_user_search_input.iter().collect(); - if current_query == query { - app.friend_user_search_results.clear(); - app.view.friend_user_search_selected = 0; - } - warn!("friends: user search failed: {}", e); - } - } -} diff --git a/src/infra/network/ids.rs b/src/infra/network/ids.rs deleted file mode 100644 index 54022b07..00000000 --- a/src/infra/network/ids.rs +++ /dev/null @@ -1,226 +0,0 @@ -//! Tolerant parsing of dispatch-layer string ids/URIs into rspotify id-types. -//! -//! `IoEvent` variants carry plain `String` ids/URIs — the multi-source dispatch -//! contract (handlers and UI never touch `rspotify::model`). The Spotify network -//! boundary is the one place allowed to reconstruct the strongly-typed rspotify -//! ids, and it does so here. -//! -//! Parsing is deliberately tolerant: a malformed or wrong-type id yields `None` -//! and the caller silently no-ops. This preserves the pre-refactor handler -//! behaviour, where each dispatch site guarded on `if let Ok(id) = ..from_id(..)` -//! and simply skipped the action for an unparseable id. - -use rspotify::model::idtypes::{ - AlbumId, ArtistId, EpisodeId, PlayContextId, PlayableId, PlaylistId, ShowId, TrackId, UserId, -}; -use rspotify::prelude::*; - -/// Generate a tolerant single-id parser for a monomorphic rspotify id-type. -/// -/// Accepts either a bare base62 id or a full `spotify:{type}:{id}` URI (via -/// [`from_id_or_uri`]); returns `None` when the string is neither a valid id nor -/// a URI of the matching type. -macro_rules! mono_id { - ($(#[$doc:meta])* $fn_name:ident => $id_ty:ident) => { - $(#[$doc])* - pub(crate) fn $fn_name(id_or_uri: &str) -> Option<$id_ty<'static>> { - $id_ty::from_id_or_uri(id_or_uri) - .ok() - .map(|id| id.into_static()) - } - }; -} - -mono_id!( - /// Parse a single track id/URI. - track_id => TrackId -); -mono_id!( - /// Parse a single album id/URI. - album_id => AlbumId -); -mono_id!( - /// Parse a single artist id/URI. - artist_id => ArtistId -); -mono_id!( - /// Parse a single playlist id/URI. - playlist_id => PlaylistId -); -mono_id!( - /// Parse a single show id/URI. - show_id => ShowId -); -mono_id!( - /// Parse a single user id/URI. - user_id => UserId -); - -/// Parse a slice of track ids/URIs, dropping any that fail to parse. -pub(crate) fn track_ids(items: &[String]) -> Vec> { - items.iter().filter_map(|s| track_id(s)).collect() -} - -/// Parse a slice of album ids/URIs, dropping any that fail to parse. -pub(crate) fn album_ids(items: &[String]) -> Vec> { - items.iter().filter_map(|s| album_id(s)).collect() -} - -/// Parse a slice of artist ids/URIs, dropping any that fail to parse. -pub(crate) fn artist_ids(items: &[String]) -> Vec> { - items.iter().filter_map(|s| artist_id(s)).collect() -} - -/// Parse a slice of show ids/URIs, dropping any that fail to parse. -pub(crate) fn show_ids(items: &[String]) -> Vec> { - items.iter().filter_map(|s| show_id(s)).collect() -} - -/// Parse a playable id/URI into a [`PlayableId`] (track or episode). -/// -/// The URI scheme selects the variant (`spotify:track:` vs `spotify:episode:`). -/// A bare base62 id is ambiguous, so it falls back to a track id — matching the -/// historical dispatch sites, which only ever built `PlayableId::Track` from a -/// bare id. -pub(crate) fn playable_id(uri_or_id: &str) -> Option> { - if let Ok(id) = TrackId::from_uri(uri_or_id) { - return Some(PlayableId::Track(id.into_static())); - } - if let Ok(id) = EpisodeId::from_uri(uri_or_id) { - return Some(PlayableId::Episode(id.into_static())); - } - TrackId::from_id(uri_or_id) - .ok() - .map(|id| PlayableId::Track(id.into_static())) -} - -/// Parse a slice of playable ids/URIs, dropping any that fail to parse. -pub(crate) fn playable_ids(items: &[String]) -> Vec> { - items.iter().filter_map(|s| playable_id(s)).collect() -} - -/// Collect base62 id strings from rspotify tracks for a saved-tracks-contains -/// check. Tracks without a Spotify id (local files) are skipped. -pub(crate) fn track_check_ids<'a, 'b: 'a>( - ids: impl IntoIterator>>, -) -> Vec { - ids - .into_iter() - .flatten() - .map(|id| id.id().to_string()) - .collect() -} - -/// Parse a context URI into a [`PlayContextId`] (artist/album/playlist/show). -/// -/// Requires a full `spotify:` URI: a bare base62 id is ambiguous across context -/// types, so it returns `None`. Dispatch sites pass the domain `uri` field. -pub(crate) fn play_context_id(uri: &str) -> Option> { - if let Ok(id) = ArtistId::from_uri(uri) { - return Some(PlayContextId::Artist(id.into_static())); - } - if let Ok(id) = AlbumId::from_uri(uri) { - return Some(PlayContextId::Album(id.into_static())); - } - if let Ok(id) = PlaylistId::from_uri(uri) { - return Some(PlayContextId::Playlist(id.into_static())); - } - if let Ok(id) = ShowId::from_uri(uri) { - return Some(PlayContextId::Show(id.into_static())); - } - None -} - -#[cfg(test)] -mod tests { - use super::*; - - // 22-char base62 id (matches the convention used elsewhere in the tests). - const BARE: &str = "0000000000000000000001"; - - #[test] - fn play_context_id_sniffs_each_context_type_from_its_uri() { - assert!(matches!( - play_context_id(&format!("spotify:album:{BARE}")), - Some(PlayContextId::Album(_)) - )); - assert!(matches!( - play_context_id(&format!("spotify:artist:{BARE}")), - Some(PlayContextId::Artist(_)) - )); - assert!(matches!( - play_context_id(&format!("spotify:playlist:{BARE}")), - Some(PlayContextId::Playlist(_)) - )); - assert!(matches!( - play_context_id(&format!("spotify:show:{BARE}")), - Some(PlayContextId::Show(_)) - )); - } - - #[test] - fn play_context_id_rejects_bare_id_and_non_context_uri() { - // A bare id is ambiguous across context types. - assert!(play_context_id(BARE).is_none()); - // A track is a playable, not a play context. - assert!(play_context_id(&format!("spotify:track:{BARE}")).is_none()); - } - - #[test] - fn playable_id_sniffs_track_vs_episode_from_uri() { - assert!(matches!( - playable_id(&format!("spotify:track:{BARE}")), - Some(PlayableId::Track(_)) - )); - assert!(matches!( - playable_id(&format!("spotify:episode:{BARE}")), - Some(PlayableId::Episode(_)) - )); - } - - #[test] - fn playable_id_treats_bare_id_as_track() { - assert!(matches!(playable_id(BARE), Some(PlayableId::Track(_)))); - } - - #[test] - fn mono_parser_accepts_id_or_matching_uri_and_rejects_wrong_type() { - assert!(track_id(BARE).is_some()); - assert!(track_id(&format!("spotify:track:{BARE}")).is_some()); - assert!(album_id(&format!("spotify:album:{BARE}")).is_some()); - // A wrong-type URI must not parse as a track (no fallback to from_id). - assert!(track_id(&format!("spotify:album:{BARE}")).is_none()); - } - - #[test] - fn track_check_ids_collects_base62_ids_and_skips_local_files() { - let one = TrackId::from_id("0000000000000000000001").unwrap(); - let two = TrackId::from_id("0000000000000000000002").unwrap(); - // `None` stands in for a local-file track with no Spotify id. - let items = vec![Some(&one), None, Some(&two)]; - assert_eq!( - track_check_ids(items.into_iter()), - vec![ - "0000000000000000000001".to_string(), - "0000000000000000000002".to_string(), - ] - ); - } - - #[test] - fn track_check_ids_empty_input_yields_empty_vec() { - let items: Vec> = Vec::new(); - assert!(track_check_ids(items.into_iter()).is_empty()); - } - - #[test] - fn collection_parser_drops_unparseable_entries() { - let ids = vec![ - format!("spotify:track:{BARE}"), - BARE.to_string(), - "not a valid id!".to_string(), - ]; - // Only the two well-formed entries survive. - assert_eq!(track_ids(&ids).len(), 2); - } -} diff --git a/src/infra/network/library.rs b/src/infra/network/library.rs deleted file mode 100644 index 45287fd6..00000000 --- a/src/infra/network/library.rs +++ /dev/null @@ -1,1926 +0,0 @@ -use super::mapping::{map_page, playlist_items_page}; -use super::requests::{ - spotify_api_request_json_for_with_refresh, spotify_get_typed_compat_for_with_refresh, -}; -use super::{IoEvent, Network}; -use crate::core::app::{ - ActiveBlock, App, PlaylistFolder, PlaylistFolderItem, PlaylistFolderNode, PlaylistFolderNodeType, - RouteId, -}; -use crate::core::plugin_api::{PlaylistInfo, ShowInfo, TrackInfo}; -use crate::core::source::Source; -use anyhow::anyhow; -use reqwest::Method; -use rspotify::model::{ - idtypes::{AlbumId, LibraryId, PlaylistId, ShowId, TrackId, UserId}, - page::Page, - playlist::{PlaylistItem, SimplifiedPlaylist}, - track::SavedTrack, - PlayableItem, -}; -use rspotify::{prelude::*, AuthCodePkceSpotify}; -use serde_json::json; -use std::sync::Arc; -use std::time::Duration; -use tokio::sync::Mutex; - -#[cfg(feature = "streaming")] -use crate::infra::player::StreamingPlayer; - -// Spotify's `me/library` endpoints (contains, save, remove) accept at most 40 -// uris per request; anything larger fails with a 400 "Too many uris". -const LIBRARY_CONTAINS_MAX_URIS: usize = 40; - -#[cfg(test)] -fn next_saved_tracks_offset(page: &Page) -> Option { - page.next.as_ref().map(|_| page.offset + page.limit) -} - -fn uri_batches(uris: &[String]) -> impl Iterator { - uris.chunks(LIBRARY_CONTAINS_MAX_URIS) -} - -fn populate_liked_song_ids_from_saved_tracks( - liked_song_ids_set: &mut std::collections::HashSet, - page: &Page, -) { - for item in &page.items { - if let Some(track_id) = &item.track.id { - liked_song_ids_set.insert(track_id.id().to_string()); - } - } -} - -fn playlist_track_search_terms(query: &str) -> Vec { - query - .split_whitespace() - .map(|term| term.to_lowercase()) - .filter(|term| !term.is_empty()) - .collect() -} - -fn playlist_track_search_haystack(track: &rspotify::model::track::FullTrack) -> String { - let mut haystack = format!("{} {}", track.name, track.album.name); - for artist in &track.artists { - haystack.push(' '); - haystack.push_str(&artist.name); - } - haystack.to_lowercase() -} - -fn playlist_track_matches_terms( - track: &rspotify::model::track::FullTrack, - terms: &[String], -) -> bool { - let haystack = playlist_track_search_haystack(track); - terms.iter().all(|term| haystack.contains(term)) -} - -pub async fn prefetch_saved_tracks_page_task( - spotify: AuthCodePkceSpotify, - app: Arc>, - token_cache_path: std::path::PathBuf, - limit: u32, - mut offset: u32, - generation: u64, -) { - loop { - let should_fetch = { - let mut app = app.lock().await; - app.saved_tracks_prefetch_generation == generation - && app - .library - .saved_tracks - .page_index_for_offset(offset) - .is_none() - && app.saved_tracks_prefetch_in_flight.insert(offset) - }; - - if !should_fetch { - return; - } - - let query = vec![("limit", limit.to_string()), ("offset", offset.to_string())]; - let Ok(page) = spotify_get_typed_compat_for_with_refresh::>( - &spotify, - "me/tracks", - &query, - &token_cache_path, - &app, - ) - .await - else { - let mut app_guard = app.lock().await; - app_guard.saved_tracks_prefetch_in_flight.remove(&offset); - return; - }; - - if page.items.is_empty() { - let mut app_guard = app.lock().await; - app_guard.saved_tracks_prefetch_in_flight.remove(&offset); - return; - } - - let next_offset = page.next.as_ref().map(|_| page.offset + page.limit); - let mut app_guard = app.lock().await; - app_guard.saved_tracks_prefetch_in_flight.remove(&offset); - if app_guard.saved_tracks_prefetch_generation != generation { - return; - } - - populate_liked_song_ids_from_saved_tracks(&mut app_guard.liked_song_ids_set, &page); - let domain_page = - crate::infra::network::mapping::map_page(&page, |st| TrackInfo::from(&st.track)); - app_guard - .library - .saved_tracks - .upsert_page_by_offset(domain_page); - app_guard.set_saved_tracks_to_table_continuous(); - let Some(candidate_next_offset) = next_offset else { - return; - }; - let should_prefetch_next = app_guard - .library - .saved_tracks - .page_index_for_offset(candidate_next_offset) - .is_none() - && !app_guard - .saved_tracks_prefetch_in_flight - .contains(&candidate_next_offset); - drop(app_guard); - - if should_prefetch_next { - offset = candidate_next_offset; - } else { - return; - } - } -} - -pub async fn prefetch_playlist_tracks_page_task( - spotify: AuthCodePkceSpotify, - app: Arc>, - token_cache_path: std::path::PathBuf, - limit: u32, - playlist_id: PlaylistId<'static>, - mut offset: u32, - generation: u64, -) { - loop { - let should_fetch = { - let mut app = app.lock().await; - app.playlist_tracks_prefetch_generation == generation - && app.is_playlist_track_table_active_for(&playlist_id) - && app - .playlist_track_pages - .page_index_for_offset(offset) - .is_none() - && app.playlist_tracks_prefetch_in_flight.insert(offset) - }; - - if !should_fetch { - return; - } - - let path = format!("playlists/{}/items", playlist_id.id()); - let query = vec![("limit", limit.to_string()), ("offset", offset.to_string())]; - let Ok(page) = spotify_get_typed_compat_for_with_refresh::>( - &spotify, - &path, - &query, - &token_cache_path, - &app, - ) - .await - else { - let mut app_guard = app.lock().await; - app_guard.playlist_tracks_prefetch_in_flight.remove(&offset); - return; - }; - - if page.items.is_empty() { - let mut app_guard = app.lock().await; - app_guard.playlist_tracks_prefetch_in_flight.remove(&offset); - return; - } - - let next_offset = page.next.as_ref().map(|_| page.offset + page.limit); - let mut app_guard = app.lock().await; - app_guard.playlist_tracks_prefetch_in_flight.remove(&offset); - if app_guard.playlist_tracks_prefetch_generation != generation - || !app_guard.is_playlist_track_table_active_for(&playlist_id) - { - return; - } - - app_guard - .playlist_track_pages - .upsert_page_by_offset(playlist_items_page(&page)); - app_guard.set_playlist_tracks_to_table_continuous(); - let Some(candidate_next_offset) = next_offset else { - return; - }; - let should_prefetch_next = app_guard - .playlist_track_pages - .page_index_for_offset(candidate_next_offset) - .is_none() - && !app_guard - .playlist_tracks_prefetch_in_flight - .contains(&candidate_next_offset); - drop(app_guard); - - if should_prefetch_next { - offset = candidate_next_offset; - } else { - return; - } - } -} - -pub trait LibraryNetwork { - async fn get_current_user_playlists(&mut self); - async fn get_playlist_tracks(&mut self, playlist_id: PlaylistId<'static>, playlist_offset: u32); - async fn search_playlist_tracks(&mut self, playlist_id: PlaylistId<'static>, query: String); - async fn get_current_user_saved_tracks(&mut self, offset: Option); - async fn get_current_user_saved_albums(&mut self, offset: Option); - async fn current_user_saved_albums_contains(&mut self, album_ids: Vec>); - async fn current_user_saved_album_delete(&mut self, album_id: AlbumId<'static>); - async fn current_user_saved_album_add(&mut self, album_id: AlbumId<'static>); - async fn current_user_saved_shows_contains(&mut self, show_ids: Vec>); - async fn current_user_saved_shows_delete(&mut self, show_id: ShowId<'static>); - async fn current_user_saved_shows_add(&mut self, show_id: ShowId<'static>); - async fn get_current_user_saved_shows(&mut self, offset: Option); - async fn user_follow_playlist( - &mut self, - playlist_owner_id: UserId<'static>, - playlist_id: PlaylistId<'static>, - is_public: Option, - ); - async fn user_unfollow_playlist( - &mut self, - user_id: UserId<'static>, - playlist_id: PlaylistId<'static>, - ); - async fn add_track_to_playlist( - &mut self, - playlist_id: PlaylistId<'static>, - track_id: TrackId<'static>, - ); - async fn remove_track_from_playlist_at_position( - &mut self, - playlist_id: PlaylistId<'static>, - track_id: TrackId<'static>, - position: usize, - ); - async fn toggle_save_track(&mut self, track_id: rspotify::model::idtypes::PlayableId<'static>); - async fn current_user_saved_tracks_contains(&mut self, ids: Vec>); - async fn fetch_all_playlist_tracks_and_sort(&mut self, playlist_id: PlaylistId<'static>); - async fn create_new_playlist(&mut self, name: String, track_ids: Vec>); -} - -// Private helper methods -impl Network { - pub(crate) async fn library_contains_uris(&self, uris: &[String]) -> anyhow::Result> { - if uris.is_empty() { - return Ok(Vec::new()); - } - - let mut all_results = Vec::with_capacity(uris.len()); - for batch in uri_batches(uris) { - let batch_results = spotify_get_typed_compat_for_with_refresh::>( - self.spotify(), - "me/library/contains", - &[("uris", batch.join(","))], - &self.token_cache_path, - &self.app, - ) - .await?; - all_results.extend(batch_results); - } - - Ok(all_results) - } - - /// Resolve liked state for bare track ids inline and merge it: the CLI's - /// synchronous path. The `IoEvent` handler defers to the detached worker - /// instead (it must not block the TUI's serial pump), which the CLI cannot - /// await for a result. Failures propagate so the CLI reports them instead - /// of acting on stale membership. - pub(crate) async fn resolve_liked_state_now(&mut self, ids: &[String]) -> anyhow::Result<()> { - let uris: Vec = ids.iter().map(|id| format!("spotify:track:{id}")).collect(); - let is_saved_vec = self.library_contains_uris(&uris).await?; - let mut app = self.app.lock().await; - for (i, id) in ids.iter().enumerate() { - match is_saved_vec.get(i) { - Some(true) => { - app.liked_song_ids_set.insert(id.clone()); - } - Some(false) => { - app.liked_song_ids_set.remove(id); - } - None => {} - } - } - Ok(()) - } - - pub(super) async fn library_save_uris(&self, uris: &[String]) -> anyhow::Result<()> { - for batch in uri_batches(uris) { - let query = vec![("uris", batch.join(","))]; - spotify_api_request_json_for_with_refresh( - self.spotify(), - Method::PUT, - "me/library", - &query, - Some(json!({ "uris": batch })), - &self.token_cache_path, - &self.app, - ) - .await?; - } - Ok(()) - } - - pub(super) async fn library_remove_uris(&self, uris: &[String]) -> anyhow::Result<()> { - for batch in uri_batches(uris) { - let query = vec![("uris", batch.join(","))]; - spotify_api_request_json_for_with_refresh( - self.spotify(), - Method::DELETE, - "me/library", - &query, - Some(json!({ "uris": batch })), - &self.token_cache_path, - &self.app, - ) - .await?; - } - Ok(()) - } - - pub fn spawn_saved_tracks_prefetch(&self, offset: u32, generation: u64) { - let spotify = self.spotify().clone(); - let app = self.app.clone(); - let token_cache_path = self.token_cache_path.clone(); - let large_search_limit = self.large_search_limit; - tokio::spawn(async move { - prefetch_saved_tracks_page_task( - spotify, - app, - token_cache_path, - large_search_limit, - offset, - generation, - ) - .await; - }); - } - - pub fn spawn_playlist_tracks_prefetch( - &self, - playlist_id: PlaylistId<'static>, - offset: u32, - generation: u64, - ) { - let spotify = self.spotify().clone(); - let app = self.app.clone(); - let token_cache_path = self.token_cache_path.clone(); - let large_search_limit = self.large_search_limit; - tokio::spawn(async move { - prefetch_playlist_tracks_page_task( - spotify, - app, - token_cache_path, - large_search_limit, - playlist_id, - offset, - generation, - ) - .await; - }); - } -} - -/// Detached body of `fetch_all_playlist_tracks_and_sort`. On a page failure -/// the error routes through `App::handle_error` exactly as the inline version -/// did. -/// Single detached worker draining [`App::liked_lookup_pending`] in 40-uri -/// batches, merging each batch into `liked_song_ids_set` as it lands. Runs off -/// the IoEvent pump: a big playlist's liked-state sweep is 8-15 batches at -/// 1-2s each, which used to head-of-line-block playback controls (a -/// skip-to-queued-track waited ~20s behind it, #386). One worker at a time -/// (`liked_lookup_worker_running`), so rapid navigation coalesces into the -/// deduped pending set instead of parallel request floods. -async fn liked_lookup_worker_task( - spotify: AuthCodePkceSpotify, - app: Arc>, - token_cache_path: std::path::PathBuf, -) { - const BATCH: usize = 40; - const MAX_CONSECUTIVE_FAILURES: u32 = 3; - let mut consecutive_failures = 0u32; - loop { - let (batch, epoch) = { - let mut guard = app.lock().await; - if guard.liked_lookup_pending.is_empty() { - guard.liked_lookup_worker_running = false; - return; - } - let batch: Vec = guard - .liked_lookup_pending - .iter() - .take(BATCH) - .cloned() - .collect(); - for id in &batch { - guard.liked_lookup_pending.remove(id); - } - (batch, guard.liked_state_epoch) - }; - let uris: Vec = batch - .iter() - .map(|id| format!("spotify:track:{id}")) - .collect(); - let result = spotify_get_typed_compat_for_with_refresh::>( - &spotify, - "me/library/contains", - &[("uris", uris.join(","))], - &token_cache_path, - &app, - ) - .await; - let mut guard = app.lock().await; - match result { - Ok(is_saved_vec) => { - consecutive_failures = 0; - if guard.liked_state_epoch != epoch { - // A local like/unlike landed while this read was in flight; the - // response may predate it, so re-read instead of applying it. - guard.liked_lookup_pending.extend(batch); - continue; - } - for (i, id) in batch.into_iter().enumerate() { - match is_saved_vec.get(i) { - Some(true) => { - guard.liked_song_ids_set.insert(id); - } - Some(false) => { - guard.liked_song_ids_set.remove(&id); - } - None => {} - } - } - } - Err(_) if consecutive_failures + 1 < MAX_CONSECUTIVE_FAILURES => { - // Transient failure: back off and retry from THIS worker, so ids - // enqueued while the request was in flight (which saw the running - // flag and did not spawn) are not stranded without a worker. - guard.liked_lookup_pending.extend(batch); - consecutive_failures += 1; - drop(guard); - tokio::time::sleep(std::time::Duration::from_secs(u64::from( - 2 * consecutive_failures, - ))) - .await; - } - Err(e) => { - // Persistently failing: stop rather than hammer the endpoint. The ids - // stay pending and the next dispatch restarts the worker. - guard.liked_lookup_pending.extend(batch); - guard.liked_lookup_worker_running = false; - guard.set_status_message(format!("Could not check liked track state: {e}"), 5); - return; - } - } - } -} - -async fn fetch_all_playlist_tracks_and_sort_task( - spotify: AuthCodePkceSpotify, - app: Arc>, - token_cache_path: std::path::PathBuf, - playlist_id: PlaylistId<'static>, -) { - let playlist_id_string = playlist_id.id().to_string(); - let mut all_tracks = Vec::new(); - let mut offset = 0u32; - let limit = 50u32; - let path = format!("playlists/{}/items", playlist_id.id()); - - loop { - { - let mut app = app.lock().await; - if !app.is_playlist_track_table_active_for(&playlist_id) { - app - .playlist_sort_fetch_in_flight - .remove(&playlist_id_string); - return; - } - } - let query = vec![("limit", limit.to_string()), ("offset", offset.to_string())]; - match spotify_get_typed_compat_for_with_refresh::>( - &spotify, - &path, - &query, - &token_cache_path, - &app, - ) - .await - { - Ok(page) => { - if page.items.is_empty() { - break; - } - - for item in page.items { - if let Some(PlayableItem::Track(full_track)) = item.item { - all_tracks.push(full_track); - } - } - - if page.next.is_none() { - break; - } - offset += limit; - } - Err(e) => { - let mut app = app.lock().await; - app - .playlist_sort_fetch_in_flight - .remove(&playlist_id_string); - app.handle_error(anyhow!(e)); - return; - } - } - } - - // Apply sort if any - let mut app = app.lock().await; - app - .playlist_sort_fetch_in_flight - .remove(&playlist_id_string); - - use crate::core::sort::Sorter; - let sorter = Sorter::new(app.playlist_sort); - sorter.sort_tracks(&mut all_tracks); - let _ = app.apply_sorted_playlist_tracks_if_current(&playlist_id, all_tracks); -} - -/// Background half of `get_current_user_playlists`: fetch the remaining pages -/// and the librespot rootlist folder structure, then publish the complete list -/// (unless a newer refresh superseded this one). A page failure here keeps the -/// already-published pages instead of error-routing the UI. -#[allow(clippy::too_many_arguments)] -async fn finish_playlists_fetch( - spotify: AuthCodePkceSpotify, - app: Arc>, - token_cache_path: std::path::PathBuf, - mut all_playlists: Vec, - has_more: bool, - limit: u32, - generation: u64, - original_selection: (Option, usize, Option), - page_one_selection: (Option, usize, Option), - previous_complete: ( - Vec, - Option>, - Vec, - ), -) { - let mut pagination_error = None; - if has_more { - let mut offset = limit; - loop { - { - let app = app.lock().await; - if app.playlist_refresh_generation != generation { - return; - } - } - let mut attempts = 0u8; - let page = loop { - match spotify_get_typed_compat_for_with_refresh::>( - &spotify, - "me/playlists", - &[("limit", limit.to_string()), ("offset", offset.to_string())], - &token_cache_path, - &app, - ) - .await - { - Ok(page) => break Some(page), - Err(e) if attempts < 2 => { - attempts += 1; - log::warn!("playlist page fetch failed at offset {offset}; retry {attempts}/2: {e}"); - tokio::time::sleep(Duration::from_millis(250 * u64::from(attempts))).await; - } - Err(e) => { - pagination_error = Some(e.to_string()); - break None; - } - } - }; - let Some(page) = page else { break }; - if page.items.is_empty() { - break; - } - all_playlists.extend(page.items.iter().map(PlaylistInfo::from_simplified)); - if page.next.is_none() { - break; - } - offset += limit; - } - } - - if let Some(error) = pagination_error { - log::warn!("playlist refresh incomplete: {error}"); - let mut app = app.lock().await; - if app.playlist_refresh_generation != generation { - return; - } - let (previous_playlists, previous_nodes, previous_items) = previous_complete; - if !previous_playlists.is_empty() { - app.all_playlists = previous_playlists; - app._playlist_folder_nodes = previous_nodes; - app.playlist_folder_items = previous_items; - reconcile_playlist_selection( - &mut app, - original_selection.0.as_deref(), - original_selection.1, - original_selection.2, - ); - } - app.set_status_message("Playlist refresh incomplete; kept the previous list.", 8); - return; - } - - #[cfg(feature = "streaming")] - let folder_nodes = { - let streaming_player = { - let app = app.lock().await; - app.streaming_player.clone() - }; - fetch_rootlist_folders(streaming_player).await - }; - #[cfg(not(feature = "streaming"))] - let folder_nodes: Option> = None; - - let folder_items = if let Some(ref nodes) = folder_nodes { - structurize_playlist_folders(nodes, &all_playlists) - } else { - build_flat_playlist_items(&all_playlists) - }; - - let mut app = app.lock().await; - if app.playlist_refresh_generation != generation { - return; - } - // Restore the original selection only if the user has not navigated since - // page 1 was published. Otherwise their newer choice wins. - let current_selection = ( - app.get_selected_playlist_id(), - app.current_playlist_folder_id, - app.view.selected_playlist_index, - ); - let preferred = if current_selection == page_one_selection { - original_selection - } else { - current_selection - }; - - app.all_playlists = all_playlists; - app - .plugin_data_generations - .bump(crate::core::app::PluginDataKind::Playlists); - app._playlist_folder_nodes = folder_nodes; - app.playlist_folder_items = folder_items; - - reconcile_playlist_selection(&mut app, preferred.0.as_deref(), preferred.1, preferred.2); - - maybe_prompt_community_pin(&mut app); -} - -/// Offer the one-time community-pin hide prompt once the full playlist list is -/// known. A user who already follows the community playlist never sees it (no -/// pin to explain); if they later unfollow, the pin appears for the first time -/// and this can fire then — intended, not a bug. -fn maybe_prompt_community_pin(app: &mut App) { - if app.active_source == Source::Spotify - && !app.runtime_state.community_pin_prompt_shown - && app.user_config.behavior.pin_community_playlist - && !app.follows_community_playlist() - && app.get_current_route().id != RouteId::CommunityPinPrompt - { - // Mark shown at push time, not only on dismiss, so paths that close the - // prompt without going through its handler (the back key, mouse-interactive - // layout dismissal) can never make it reappear. - app.mark_community_pin_prompt_shown(); - app.push_navigation_stack(RouteId::CommunityPinPrompt, ActiveBlock::CommunityPinPrompt); - } -} - -impl LibraryNetwork for Network { - async fn get_current_user_playlists(&mut self) { - let original_selection = { - let app = self.app.lock().await; - ( - app.get_selected_playlist_id(), - app.current_playlist_folder_id, - app.view.selected_playlist_index, - ) - }; - - let limit = 50u32; - - // Page 1 first: publish it immediately so the sidebar is usable, then - // fetch the remaining pages and the rootlist folder structure in a - // background task instead of blocking the pump for the whole pagination - // (each page pays the pacing floor; 500 playlists used to block startup - // dispatches for seconds). - // - // Always use the compat path: parses raw JSON to serde_json::Value first, - // which silently deduplicates keys (last-wins). This handles the known Spotify - // API bug where "items" appears twice in the same JSON object. - let first_page = match spotify_get_typed_compat_for_with_refresh::>( - self.spotify(), - "me/playlists", - &[("limit", limit.to_string()), ("offset", "0".to_string())], - &self.token_cache_path, - &self.app, - ) - .await - { - Ok(page) => page, - Err(e) => { - let mut app = self.app.lock().await; - app.pending_playlist_track_search = None; - drop(app); - self.handle_error(anyhow!(e)).await; - return; - } - }; - - // Convert to source-agnostic domain types at the network boundary. - let first_items: Vec = first_page - .items - .iter() - .map(PlaylistInfo::from_simplified) - .collect(); - let has_more = first_page.next.is_some() && !first_page.items.is_empty(); - let mapped_first = map_page(&first_page, PlaylistInfo::from_simplified); - - let (generation, page_one_selection, previous_complete) = { - let mut app = self.app.lock().await; - let had_previous_complete = !app.all_playlists.is_empty(); - // Snapshot the existing complete list only when background pagination can - // fail partway (`has_more`) and there is something worth restoring. A - // single-page refresh or a first-ever load never uses this, so skip the - // clone in the common case. - let previous_complete = if has_more && had_previous_complete { - ( - app.all_playlists.clone(), - app._playlist_folder_nodes.clone(), - app.playlist_folder_items.clone(), - ) - } else { - (Vec::new(), None, Vec::new()) - }; - app.playlist_refresh_generation = app.playlist_refresh_generation.wrapping_add(1); - app.playlists = Some(mapped_first); - let selected_is_in_first_page = original_selection.0.as_ref().is_some_and(|selected| { - first_items - .iter() - .any(|playlist| playlist.id.as_deref() == Some(selected.as_str())) - }); - if !had_previous_complete || original_selection.0.is_none() || selected_is_in_first_page { - app.all_playlists = first_items.clone(); - app - .plugin_data_generations - .bump(crate::core::app::PluginDataKind::Playlists); - app.playlist_folder_items = build_flat_playlist_items(&first_items); - reconcile_playlist_selection( - &mut app, - original_selection.0.as_deref(), - original_selection.1, - original_selection.2, - ); - } - let page_one_selection = ( - app.get_selected_playlist_id(), - app.current_playlist_folder_id, - app.view.selected_playlist_index, - ); - ( - app.playlist_refresh_generation, - page_one_selection, - previous_complete, - ) - }; - - let spotify = self.spotify().clone(); - let app = Arc::clone(&self.app); - let token_cache_path = self.token_cache_path.clone(); - tokio::spawn(async move { - finish_playlists_fetch( - spotify, - app, - token_cache_path, - first_items, - has_more, - limit, - generation, - original_selection, - page_one_selection, - previous_complete, - ) - .await; - }); - } - - async fn get_playlist_tracks(&mut self, playlist_id: PlaylistId<'static>, playlist_offset: u32) { - let generation = { - let app = self.app.lock().await; - app.playlist_tracks_prefetch_generation - }; - - let path = format!("playlists/{}/items", playlist_id.id()); - match spotify_get_typed_compat_for_with_refresh::>( - self.spotify(), - &path, - &[ - ("limit", self.large_search_limit.to_string()), - ("offset", playlist_offset.to_string()), - ], - &self.token_cache_path, - &self.app, - ) - .await - { - Ok(playlist_tracks) => { - let mut app = self.app.lock().await; - app - .playlist_tracks_prefetch_in_flight - .remove(&playlist_offset); - if app.pending_playlist_open.as_deref() == Some(playlist_id.id()) { - app.pending_playlist_open = None; - } - if app.playlist_tracks_prefetch_generation != generation - || !app.is_playlist_track_table_active_for(&playlist_id) - { - return; - } - - let playlist_tracks_index = app - .playlist_track_pages - .upsert_page_by_offset(playlist_items_page(&playlist_tracks)); - app.set_playlist_tracks_to_table_continuous(); - - let next_offset = app.next_missing_playlist_tracks_offset(playlist_tracks_index); - let generation = app.playlist_tracks_prefetch_generation; - // Navigation happens in the handler when the user opens the playlist - // (`App::open_playlist_tracks`), not here on response arrival. - drop(app); - - if let Some(next_offset) = next_offset { - self.spawn_playlist_tracks_prefetch(playlist_id, next_offset, generation); - } - } - Err(e) => { - let mut app = self.app.lock().await; - app - .playlist_tracks_prefetch_in_flight - .remove(&playlist_offset); - if app.pending_playlist_open.as_deref() == Some(playlist_id.id()) { - app.pending_playlist_open = None; - } - drop(app); - self.handle_error(anyhow!(e)).await; - } - } - } - - async fn search_playlist_tracks(&mut self, playlist_id: PlaylistId<'static>, query: String) { - let terms = playlist_track_search_terms(&query); - if terms.is_empty() { - let mut app = self.app.lock().await; - app.clear_playlist_track_filter(); - return; - } - - let limit = self.large_search_limit; - let mut offset = 0u32; - let mut matches = Vec::new(); - - loop { - let path = format!("playlists/{}/items", playlist_id.id()); - let page = match spotify_get_typed_compat_for_with_refresh::>( - self.spotify(), - &path, - &[("limit", limit.to_string()), ("offset", offset.to_string())], - &self.token_cache_path, - &self.app, - ) - .await - { - Ok(page) => page, - Err(e) => { - self.handle_error(anyhow!(e)).await; - return; - } - }; - - if page.items.is_empty() { - break; - } - - for (index, item) in page.items.iter().enumerate() { - if let Some(PlayableItem::Track(track)) = item.item.as_ref() { - if playlist_track_matches_terms(track, &terms) { - matches.push((track.clone(), page.offset as usize + index)); - } - } - } - - if page.next.is_none() { - break; - } - offset = page.offset.saturating_add(page.limit); - } - - let match_count = matches.len(); - let mut app = self.app.lock().await; - if app.apply_playlist_track_search_results(&playlist_id, query.clone(), matches) { - app.set_status_message( - format!("{match_count} playlist tracks match \"{query}\""), - 3, - ); - } else { - app.pending_playlist_track_search = None; - } - } - - async fn get_current_user_saved_tracks(&mut self, offset: Option) { - let requested_offset = offset.unwrap_or(0); - let generation = { - let app = self.app.lock().await; - app.saved_tracks_prefetch_generation - }; - - let mut query = vec![("limit", self.large_search_limit.to_string())]; - if let Some(offset) = offset { - query.push(("offset", offset.to_string())); - } - - match spotify_get_typed_compat_for_with_refresh::>( - self.spotify(), - "me/tracks", - &query, - &self.token_cache_path, - &self.app, - ) - .await - { - Ok(saved_tracks) => { - let mut app = self.app.lock().await; - app - .saved_tracks_prefetch_in_flight - .remove(&requested_offset); - if app.saved_tracks_prefetch_generation != generation { - return; - } - - populate_liked_song_ids_from_saved_tracks(&mut app.liked_song_ids_set, &saved_tracks); - let domain_page = - crate::infra::network::mapping::map_page(&saved_tracks, |st| TrackInfo::from(&st.track)); - let saved_tracks_index = app.library.saved_tracks.upsert_page_by_offset(domain_page); - app.set_saved_tracks_to_table_continuous(); - app - .plugin_data_generations - .bump(crate::core::app::PluginDataKind::SavedTracks); - - let next_offset = app.next_missing_saved_tracks_offset(saved_tracks_index); - let generation = app.saved_tracks_prefetch_generation; - drop(app); - - if let Some(next_offset) = next_offset { - self.spawn_saved_tracks_prefetch(next_offset, generation); - } - } - Err(e) => { - let mut app = self.app.lock().await; - app - .saved_tracks_prefetch_in_flight - .remove(&requested_offset); - drop(app); - self.handle_error(anyhow!(e)).await; - } - } - } - - async fn get_current_user_saved_albums(&mut self, offset: Option) { - let mut query = vec![("limit", self.large_search_limit.to_string())]; - if let Some(offset) = offset { - query.push(("offset", offset.to_string())); - } - - match spotify_get_typed_compat_for_with_refresh::>( - self.spotify(), - "me/albums", - &query, - &self.token_cache_path, - &self.app, - ) - .await - { - Ok(saved_albums) => { - let mut app = self.app.lock().await; - if !saved_albums.items.is_empty() { - let domain_page = crate::infra::network::mapping::map_page( - &saved_albums, - crate::infra::network::mapping::saved_album_info, - ); - app.library.saved_albums.add_pages(domain_page); - } - // Bump even on an empty page: completion is the signal plugin data - // requests wait on, and an empty library never writes a page. - app - .plugin_data_generations - .bump(crate::core::app::PluginDataKind::SavedAlbums); - } - Err(e) => { - self.handle_error(anyhow!(e)).await; - } - } - } - - async fn current_user_saved_albums_contains(&mut self, album_ids: Vec>) { - let uris: Vec = album_ids - .iter() - .map(|id| format!("spotify:album:{}", id.id())) - .collect(); - - match self.library_contains_uris(&uris).await { - Ok(is_saved_vec) => { - let mut app = self.app.lock().await; - for (i, id) in album_ids.iter().enumerate() { - if let Some(is_saved) = is_saved_vec.get(i) { - if *is_saved { - app.saved_album_ids_set.insert(id.id().to_string()); - } else if app.saved_album_ids_set.contains(id.id()) { - app.saved_album_ids_set.remove(id.id()); - } - }; - } - } - Err(e) => { - self.handle_error(anyhow!(e)).await; - } - } - } - - async fn current_user_saved_album_delete(&mut self, album_id: AlbumId<'static>) { - let uris = vec![format!("spotify:album:{}", album_id.id())]; - match self.library_remove_uris(&uris).await { - Ok(_) => { - let mut app = self.app.lock().await; - app.saved_album_ids_set.remove(album_id.id()); - // Reload saved albums to refresh UI - // dispatching event would require loop access, but we can't from here easily unless we return IoEvent - // For now, assume optimistic update is handled or manually remove - } - Err(e) => self.handle_error(anyhow!(e)).await, - } - } - - async fn current_user_saved_album_add(&mut self, album_id: AlbumId<'static>) { - let uris = vec![format!("spotify:album:{}", album_id.id())]; - match self.library_save_uris(&uris).await { - Ok(_) => { - let mut app = self.app.lock().await; - app.saved_album_ids_set.insert(album_id.id().to_string()); - } - Err(e) => self.handle_error(anyhow!(e)).await, - } - } - - async fn current_user_saved_shows_contains(&mut self, show_ids: Vec>) { - let uris: Vec = show_ids - .iter() - .map(|id| format!("spotify:show:{}", id.id())) - .collect(); - match self.library_contains_uris(&uris).await { - Ok(is_saved_vec) => { - let mut app = self.app.lock().await; - for (i, id) in show_ids.iter().enumerate() { - if let Some(is_saved) = is_saved_vec.get(i) { - if *is_saved { - app.saved_show_ids_set.insert(id.id().to_string()); - } else if app.saved_show_ids_set.contains(id.id()) { - app.saved_show_ids_set.remove(id.id()); - } - }; - } - } - Err(e) => { - self.handle_error(anyhow!(e)).await; - } - } - } - - async fn current_user_saved_shows_delete(&mut self, show_id: ShowId<'static>) { - let uris = vec![format!("spotify:show:{}", show_id.id())]; - match self.library_remove_uris(&uris).await { - Ok(_) => { - let mut app = self.app.lock().await; - app.saved_show_ids_set.remove(show_id.id()); - } - Err(e) => self.handle_error(anyhow!(e)).await, - } - } - - async fn current_user_saved_shows_add(&mut self, show_id: ShowId<'static>) { - let uris = vec![format!("spotify:show:{}", show_id.id())]; - match self.library_save_uris(&uris).await { - Ok(_) => { - let mut app = self.app.lock().await; - app.saved_show_ids_set.insert(show_id.id().to_string()); - } - Err(e) => self.handle_error(anyhow!(e)).await, - } - } - - async fn get_current_user_saved_shows(&mut self, offset: Option) { - let mut query = vec![("limit", self.large_search_limit.to_string())]; - if let Some(offset) = offset { - query.push(("offset", offset.to_string())); - } - - match spotify_get_typed_compat_for_with_refresh::>( - self.spotify(), - "me/shows", - &query, - &self.token_cache_path, - &self.app, - ) - .await - { - Ok(saved_shows) => { - let mut app = self.app.lock().await; - if !saved_shows.items.is_empty() { - let domain_page = - crate::infra::network::mapping::map_page(&saved_shows, |s| ShowInfo::from(&s.show)); - app.library.saved_shows.add_pages(domain_page); - } - // Bump even on an empty page (see saved-albums note above). - app - .plugin_data_generations - .bump(crate::core::app::PluginDataKind::SavedShows); - } - Err(e) => { - self.handle_error(anyhow!(e)).await; - } - } - } - - async fn user_follow_playlist( - &mut self, - _playlist_owner_id: UserId<'static>, - playlist_id: PlaylistId<'static>, - _is_public: Option, - ) { - match self - .spotify() - .library_add([LibraryId::Playlist(playlist_id)]) - .await - { - Ok(_) => { - // Optimistic update handled in handler or next refresh - } - Err(e) => self.handle_error(anyhow!(e)).await, - } - } - - async fn user_unfollow_playlist( - &mut self, - _user_id: UserId<'static>, - playlist_id: PlaylistId<'static>, - ) { - match self - .spotify() - .library_remove([LibraryId::Playlist(playlist_id)]) - .await - { - Ok(_) => { - // Handled - } - Err(e) => self.handle_error(anyhow!(e)).await, - } - } - - async fn add_track_to_playlist( - &mut self, - playlist_id: PlaylistId<'static>, - track_id: TrackId<'static>, - ) { - match self - .spotify() - .playlist_add_items(playlist_id.clone(), vec![PlayableId::Track(track_id)], None) - .await - { - Ok(_) => { - let status_message = { - let mut app = self.app.lock().await; - let playlist_name = app - .all_playlists - .iter() - .find(|playlist| playlist.id.as_deref() == Some(playlist_id.id())) - .map(|playlist| playlist.name.clone()); - - if app.is_current_route_playlist_track_table_for(&playlist_id) { - let playlist_offset = app.playlist_offset; - app.dispatch(IoEvent::GetPlaylistItems( - playlist_id.id().to_string(), - playlist_offset, - )); - } - - playlist_name - .map(|name| format!("Added to {}", name)) - .unwrap_or_else(|| "Added to playlist".to_string()) - }; - self.show_status_message(status_message, 3).await; - } - Err(e) => self.handle_error(anyhow!(e)).await, - } - } - - async fn remove_track_from_playlist_at_position( - &mut self, - playlist_id: PlaylistId<'static>, - track_id: TrackId<'static>, - position: usize, - ) { - let body = json!({ - "items": [{ - "uri": format!("spotify:track:{}", track_id.id()), - "positions": [position] - }] - }); - - match spotify_api_request_json_for_with_refresh( - self.spotify(), - Method::DELETE, - &format!("playlists/{}/items", playlist_id.id()), - &[], - Some(body), - &self.token_cache_path, - &self.app, - ) - .await - { - Ok(_) => { - self - .show_status_message("Removed from playlist".to_string(), 3) - .await; - } - Err(e) => self.handle_error(anyhow!(e)).await, - } - } - - async fn toggle_save_track(&mut self, track_id: rspotify::model::idtypes::PlayableId<'static>) { - let id_str = match &track_id { - PlayableId::Track(id) => id.id(), - PlayableId::Episode(id) => id.id(), - }; - let uri = match &track_id { - PlayableId::Track(id) => format!("spotify:track:{}", id.id()), - PlayableId::Episode(id) => format!("spotify:episode:{}", id.id()), - }; - - let is_liked = { - let app = self.app.lock().await; - app.liked_song_ids_set.contains(id_str) - }; - - if is_liked { - if let Err(e) = self.library_remove_uris(&[uri]).await { - self.handle_error(anyhow!(e)).await; - } else { - let mut app = self.app.lock().await; - app.liked_song_ids_set.remove(id_str); - app.liked_state_epoch = app.liked_state_epoch.wrapping_add(1); - } - } else if let Err(e) = self.library_save_uris(&[uri]).await { - self.handle_error(anyhow!(e)).await; - } else { - let mut app = self.app.lock().await; - app.liked_song_ids_set.insert(id_str.to_string()); - app.liked_state_epoch = app.liked_state_epoch.wrapping_add(1); - } - } - - async fn current_user_saved_tracks_contains(&mut self, ids: Vec>) { - // Enqueue and let the single detached worker resolve: these lookups are - // UI garnish and must never head-of-line-block playback events on the - // serial pump (see `liked_lookup_worker_task`). - let spawn = { - let mut app = self.app.lock().await; - for id in &ids { - app.liked_lookup_pending.insert(id.id().to_string()); - } - if app.liked_lookup_pending.is_empty() || app.liked_lookup_worker_running { - false - } else { - app.liked_lookup_worker_running = true; - true - } - }; - if !spawn { - return; - } - let spotify = self.spotify().clone(); - let app = Arc::clone(&self.app); - let token_cache_path = self.token_cache_path.clone(); - tokio::spawn(async move { - liked_lookup_worker_task(spotify, app, token_cache_path).await; - }); - } - - async fn fetch_all_playlist_tracks_and_sort(&mut self, playlist_id: PlaylistId<'static>) { - // The full pagination pays the pacing floor per page (a 3,000-track - // playlist is 60 pages, 15s+), so it runs detached instead of - // head-of-line-blocking playback controls and polling on the serial pump. - // `apply_sorted_playlist_tracks_if_current` already guards a stale result. - { - let mut app = self.app.lock().await; - if !app - .playlist_sort_fetch_in_flight - .insert(playlist_id.id().to_string()) - { - return; - } - } - let spotify = self.spotify().clone(); - let app = Arc::clone(&self.app); - let token_cache_path = self.token_cache_path.clone(); - tokio::spawn(async move { - fetch_all_playlist_tracks_and_sort_task(spotify, app, token_cache_path, playlist_id).await; - }); - } - - async fn create_new_playlist(&mut self, name: String, track_ids: Vec>) { - // Use raw API call to avoid rspotify deserializing FullPlaylist, which crashes when - // Spotify returns a duplicate "items" key in the response (known API migration bug). - let create_body = json!({ - "name": name, - "public": false, - "collaborative": false, - "description": "Created with spotatui" - }); - let playlist_value = match spotify_api_request_json_for_with_refresh( - self.spotify(), - Method::POST, - "me/playlists", - &[], - Some(create_body), - &self.token_cache_path, - &self.app, - ) - .await - { - Ok(v) => v, - Err(e) => { - self.handle_error(e).await; - return; - } - }; - - let playlist_id_str = match playlist_value.get("id").and_then(|v| v.as_str()) { - Some(id) => id.to_string(), - None => { - self - .show_status_message("Playlist created but could not get its ID".to_string(), 4) - .await; - return; - } - }; - - let playlist_id = match PlaylistId::from_id(playlist_id_str) { - Ok(id) => id.into_static(), - Err(e) => { - self.handle_error(anyhow!(e)).await; - return; - } - }; - - if !track_ids.is_empty() { - let items: Vec = track_ids - .iter() - .map(|id| rspotify::model::idtypes::PlayableId::Track(id.clone())) - .collect(); - if let Err(e) = self - .spotify() - .playlist_add_items(playlist_id, items, None) - .await - { - self.handle_error(anyhow!(e)).await; - return; - } - } - - // Refresh playlists - { - let mut app = self.app.lock().await; - app.dispatch(IoEvent::GetPlaylists); - } - - let status = format!("Playlist \"{}\" created!", name); - self.show_status_message(status, 4).await; - } -} - -#[cfg(test)] -mod tests { - use super::*; - use chrono::{Duration as ChronoDuration, Utc}; - use rspotify::model::{artist::SimplifiedArtist, track::FullTrack}; - use std::collections::{HashMap, HashSet}; - - #[allow(deprecated)] - fn full_track(id: &str) -> FullTrack { - FullTrack { - album: rspotify::model::album::SimplifiedAlbum { - name: "Album".to_string(), - ..Default::default() - }, - artists: vec![SimplifiedArtist { - name: "Artist".to_string(), - ..Default::default() - }], - available_markets: Vec::new(), - disc_number: 1, - duration: ChronoDuration::milliseconds(180_000), - explicit: false, - external_ids: HashMap::new(), - external_urls: HashMap::new(), - href: None, - id: Some(TrackId::from_id(id).unwrap().into_static()), - is_local: false, - is_playable: Some(true), - linked_from: None, - restrictions: None, - name: format!("Track {id}"), - popularity: 50, - preview_url: None, - track_number: 1, - r#type: rspotify::model::Type::Track, - } - } - - fn saved_track(id: &str) -> SavedTrack { - SavedTrack { - added_at: Utc::now(), - track: full_track(id), - } - } - - fn saved_tracks_page(offset: u32, limit: u32, has_next: bool) -> Page { - let ids = match offset { - 0 => vec!["0000000000000000000001", "0000000000000000000002"], - 20 => vec!["0000000000000000000003", "0000000000000000000004"], - 40 => vec!["0000000000000000000005", "0000000000000000000006"], - _ => vec!["0000000000000000000007", "0000000000000000000008"], - }; - - Page { - href: "https://example.com/me/tracks".to_string(), - items: ids.into_iter().map(saved_track).collect(), - limit, - next: has_next.then(|| "https://example.com/me/tracks?next".to_string()), - offset, - previous: None, - total: 60, - } - } - - #[test] - fn reconcile_restores_selection_against_folders_first_order() { - use crate::core::app::{App, PlaylistFolder, PlaylistFolderItem}; - use crate::core::test_helpers::playlist_info; - - // Root order: P0, folderA, P1, folderB. Restore selection to P1. - let mut app = App::default(); - // Keep this test about folder-first ordering; the community pin is covered - // by its own tests. - app.user_config.behavior.pin_community_playlist = false; - app.all_playlists = vec![ - playlist_info("00000000000000000000p0", "P0", "me", false), - playlist_info("00000000000000000000p1", "P1", "me", false), - ]; - app.playlist_folder_items = vec![ - PlaylistFolderItem::Playlist { - index: 0, - current_id: 0, - }, - PlaylistFolderItem::Folder(PlaylistFolder { - name: "A".to_string(), - current_id: 0, - target_id: 1, - }), - PlaylistFolderItem::Playlist { - index: 1, - current_id: 0, - }, - PlaylistFolderItem::Folder(PlaylistFolder { - name: "B".to_string(), - current_id: 0, - target_id: 2, - }), - ]; - app.user_config.behavior.group_folders_first = true; - - reconcile_playlist_selection(&mut app, Some("00000000000000000000p1"), 0, None); - - // Sorted display view is [A, B, P0, P1]; P1 is display index 3, which maps - // to sidebar row 4 (row 0 is the leading "+ Add Playlist" entry). - let idx = app - .view - .selected_playlist_index - .expect("selection restored"); - assert_eq!(idx, 4); - assert!(matches!( - app.get_playlist_display_item_at(idx - 1), - Some(PlaylistFolderItem::Playlist { index: 1, .. }) - )); - } - - #[test] - fn community_pin_prompt_marks_shown_at_push_time() { - let dir = tempfile::tempdir().unwrap(); - // Trigger conditions: Spotify source, flag false, toggle on, not following. - let mut app = App::default(); - app.state_path = Some(dir.path().join("state.yml")); - assert!(app.active_source == Source::Spotify); - assert!(app.user_config.behavior.pin_community_playlist); - assert!(!app.follows_community_playlist()); - assert!(!app.runtime_state.community_pin_prompt_shown); - - maybe_prompt_community_pin(&mut app); - - // Shown is marked at push time so a non-handler dismissal can't reappear it. - assert!(app.runtime_state.community_pin_prompt_shown); - assert_eq!(app.get_current_route().id, RouteId::CommunityPinPrompt); - } - - #[test] - fn next_saved_tracks_offset_uses_page_limit() { - let page = saved_tracks_page(20, 20, true); - assert_eq!(next_saved_tracks_offset(&page), Some(40)); - } - - #[test] - fn next_saved_tracks_offset_returns_none_without_next_link() { - let page = saved_tracks_page(20, 20, false); - assert_eq!(next_saved_tracks_offset(&page), None); - } - - #[test] - fn uri_batches_split_large_contains_requests() { - let uris = (0..120) - .map(|index| format!("spotify:track:{index:022}")) - .collect::>(); - let batches = uri_batches(&uris) - .map(|batch| batch.len()) - .collect::>(); - - assert_eq!(batches, vec![40, 40, 40]); - } - - #[test] - fn populate_liked_song_ids_from_saved_tracks_uses_raw_track_ids() { - let page = saved_tracks_page(0, 20, false); - let mut liked_song_ids_set = HashSet::new(); - - populate_liked_song_ids_from_saved_tracks(&mut liked_song_ids_set, &page); - - assert!(liked_song_ids_set.contains("0000000000000000000001")); - assert!(liked_song_ids_set.contains("0000000000000000000002")); - assert!(!liked_song_ids_set.contains("spotify:track:0000000000000000000001")); - } - - #[test] - fn playlist_track_filter_matches_title_artist_album_case_insensitively() { - let mut track = full_track("0000000000000000000001"); - track.name = "Midnight City".to_string(); - track.artists[0].name = "M83".to_string(); - track.album.name = "Hurry Up".to_string(); - - assert!(playlist_track_matches_terms( - &track, - &playlist_track_search_terms("midnight") - )); - assert!(playlist_track_matches_terms( - &track, - &playlist_track_search_terms("m83") - )); - assert!(playlist_track_matches_terms( - &track, - &playlist_track_search_terms("hurry") - )); - assert!(playlist_track_matches_terms( - &track, - &playlist_track_search_terms("MIDNIGHT m83") - )); - } - - #[test] - fn playlist_track_filter_requires_every_query_term() { - let mut track = full_track("0000000000000000000001"); - track.name = "Midnight City".to_string(); - track.artists[0].name = "M83".to_string(); - track.album.name = "Hurry Up".to_string(); - - assert!(playlist_track_matches_terms( - &track, - &playlist_track_search_terms("city hurry") - )); - assert!(!playlist_track_matches_terms( - &track, - &playlist_track_search_terms("city missing") - )); - } -} - -#[cfg(feature = "streaming")] -async fn fetch_rootlist_folders( - streaming_player: Option>, -) -> Option> { - let player = streaming_player?; - let session = player.session(); - - let bytes = match session.spclient().get_rootlist(0, Some(100_000)).await { - Ok(bytes) => bytes, - Err(_) => return None, - }; - - use protobuf::Message; - let selected: librespot_protocol::playlist4_external::SelectedListContent = - Message::parse_from_bytes(&bytes).ok()?; - - let contents = selected.contents.as_ref()?; - Some(parse_rootlist_items(&contents.items)) -} - -fn build_flat_playlist_items(playlists: &[PlaylistInfo]) -> Vec { - playlists - .iter() - .enumerate() - .map(|(index, _)| PlaylistFolderItem::Playlist { - index, - current_id: 0, - }) - .collect() -} - -fn reconcile_playlist_selection( - app: &mut App, - preferred_playlist_id: Option<&str>, - preferred_folder_id: usize, - preferred_selected_index: Option, -) { - if app.playlist_folder_items.is_empty() { - app.current_playlist_folder_id = 0; - app.view.selected_playlist_index = None; - return; - } - - let folder_has_visible = |folder_id: usize, app: &App| { - app.playlist_folder_items.iter().any(|item| match item { - PlaylistFolderItem::Folder(folder) => folder.current_id == folder_id, - PlaylistFolderItem::Playlist { current_id, .. } => *current_id == folder_id, - PlaylistFolderItem::CommunityPin => false, - }) - }; - - app.current_playlist_folder_id = if folder_has_visible(preferred_folder_id, app) { - preferred_folder_id - } else { - 0 - }; - - if let Some(playlist_id) = preferred_playlist_id { - let visible_playlist_index = app - .get_playlist_display_items() - .into_iter() - .enumerate() - .find_map(|(display_idx, item)| match item { - PlaylistFolderItem::Playlist { index, .. } => app - .all_playlists - .get(*index) - .filter(|playlist| playlist.id.as_deref() == Some(playlist_id)) - .map(|_| display_idx), - PlaylistFolderItem::Folder(_) | PlaylistFolderItem::CommunityPin => None, - }); - - if let Some(display_idx) = visible_playlist_index { - // Sidebar rows are offset by the leading "+ Add Playlist" row. - app.view.selected_playlist_index = Some(display_idx + 1); - return; - } - - let mut target_folder: Option = None; - for item in &app.playlist_folder_items { - if let PlaylistFolderItem::Playlist { index, current_id } = item { - if let Some(playlist) = app.all_playlists.get(*index) { - if playlist.id.as_deref() == Some(playlist_id) { - target_folder = Some(*current_id); - break; - } - } - } - } - - if let Some(folder_id) = target_folder { - app.current_playlist_folder_id = folder_id; - let display_idx = app - .get_playlist_display_items() - .into_iter() - .enumerate() - .find_map(|(idx, item)| match item { - PlaylistFolderItem::Playlist { index, .. } => app - .all_playlists - .get(*index) - .filter(|playlist| playlist.id.as_deref() == Some(playlist_id)) - .map(|_| idx), - PlaylistFolderItem::Folder(_) | PlaylistFolderItem::CommunityPin => None, - }); - if let Some(idx) = display_idx { - // Sidebar rows are offset by the leading "+ Add Playlist" row. - app.view.selected_playlist_index = Some(idx + 1); - return; - } - } - } - - // Sidebar rows run 0..=count: row 0 is "+ Add Playlist", items follow, so a - // preferred row clamps to `count` (the last item), not `count - 1`. - let visible_count = app.get_playlist_display_count(); - if visible_count == 0 { - app.current_playlist_folder_id = 0; - let root_count = app.get_playlist_display_count(); - app.view.selected_playlist_index = if root_count == 0 { - None - } else { - Some(preferred_selected_index.unwrap_or(0).min(root_count)) - }; - return; - } - - app.view.selected_playlist_index = Some(preferred_selected_index.unwrap_or(0).min(visible_count)); -} - -#[cfg(feature = "streaming")] -fn parse_rootlist_items( - items: &[librespot_protocol::playlist4_external::Item], -) -> Vec { - let mut root: Vec = Vec::new(); - let mut stack: Vec> = Vec::new(); - let mut name_stack: Vec<(String, String)> = Vec::new(); - - for item in items { - let uri = item.uri(); - - if let Some(rest) = uri.strip_prefix("spotify:start-group:") { - let (group_id, name) = match rest.find(':') { - Some(pos) => (rest[..pos].to_string(), rest[pos + 1..].to_string()), - None => (rest.to_string(), String::new()), - }; - name_stack.push((group_id, name)); - stack.push(std::mem::take(&mut root)); - root = Vec::new(); - } else if uri.starts_with("spotify:end-group:") { - if let Some((group_id, name)) = name_stack.pop() { - let children = std::mem::take(&mut root); - root = stack.pop().unwrap_or_default(); - root.push(PlaylistFolderNode { - name: Some(name), - node_type: PlaylistFolderNodeType::Folder, - uri: format!("spotify:folder:{}", group_id), - children, - }); - } - } else { - root.push(PlaylistFolderNode { - name: None, - node_type: PlaylistFolderNodeType::Playlist, - uri: uri.to_string(), - children: Vec::new(), - }); - } - } - - while let Some((group_id, name)) = name_stack.pop() { - let children = std::mem::take(&mut root); - root = stack.pop().unwrap_or_default(); - root.push(PlaylistFolderNode { - name: Some(name), - node_type: PlaylistFolderNodeType::Folder, - uri: format!("spotify:folder:{}", group_id), - children, - }); - } - - root -} - -fn structurize_playlist_folders( - nodes: &[PlaylistFolderNode], - playlists: &[PlaylistInfo], -) -> Vec { - use std::collections::{HashMap, HashSet}; - - let playlist_map: HashMap = playlists - .iter() - .enumerate() - .filter_map(|(idx, playlist)| playlist.id.clone().map(|id| (id, idx))) - .collect(); - - let mut items: Vec = Vec::new(); - let mut next_folder_id: usize = 1; - let mut used_playlist_indices: HashSet = HashSet::new(); - - fn walk( - nodes: &[PlaylistFolderNode], - current_folder_id: usize, - items: &mut Vec, - next_folder_id: &mut usize, - playlist_map: &std::collections::HashMap, - used_playlist_indices: &mut std::collections::HashSet, - ) { - for node in nodes { - match node.node_type { - PlaylistFolderNodeType::Folder => { - let folder_id = *next_folder_id; - *next_folder_id += 1; - - let name = node.name.as_deref().unwrap_or("Unnamed Folder").to_string(); - - items.push(PlaylistFolderItem::Folder(PlaylistFolder { - name: name.clone(), - current_id: current_folder_id, - target_id: folder_id, - })); - - items.push(PlaylistFolderItem::Folder(PlaylistFolder { - name: format!("\u{2190} {}", name), - current_id: folder_id, - target_id: current_folder_id, - })); - - walk( - &node.children, - folder_id, - items, - next_folder_id, - playlist_map, - used_playlist_indices, - ); - } - PlaylistFolderNodeType::Playlist => { - let playlist_id = node - .uri - .strip_prefix("spotify:playlist:") - .unwrap_or(&node.uri); - - if let Some(&index) = playlist_map.get(playlist_id) { - items.push(PlaylistFolderItem::Playlist { - index, - current_id: current_folder_id, - }); - used_playlist_indices.insert(index); - } - } - } - } - } - - walk( - nodes, - 0, - &mut items, - &mut next_folder_id, - &playlist_map, - &mut used_playlist_indices, - ); - - for (index, _) in playlists.iter().enumerate() { - if !used_playlist_indices.contains(&index) { - items.push(PlaylistFolderItem::Playlist { - index, - current_id: 0, - }); - } - } - - items -} diff --git a/src/infra/network/mapping.rs b/src/infra/network/mapping.rs deleted file mode 100644 index 62772dc0..00000000 --- a/src/infra/network/mapping.rs +++ /dev/null @@ -1,555 +0,0 @@ -//! rspotify → domain mapping layer. -//! -//! This is the **only** place (besides the pre-existing helpers in -//! `core::plugin_api`, which relocate here in a later slice) where rspotify -//! model types are converted into the source-agnostic domain types from -//! [`crate::core::plugin_api`]. Keeping every conversion behind this boundary -//! is what makes the end-state criterion — *zero `rspotify::model` imports -//! outside `src/infra/network/`* — achievable. -//! -//! Conventions: -//! - We never read fields rspotify marks `#[deprecated]` (Spotify removed them), -//! so this module stays clean under `-D warnings`. -//! - URIs/ids are produced via the [`Id`] trait: `.id()` → base62, `.uri()` → -//! full `spotify:…` URI. - -// Conversions are consumed by the per-screen migration slices, not yet by the -// binary itself. -#![allow(dead_code)] - -use crate::core::pagination::{CursorPaged, Paged}; -use crate::core::plugin_api::{ - AlbumInfo, ArtistInfo, ArtistRef, EpisodeInfo, PlayableInfo, PlaylistInfo, ResumePointInfo, - SavedAlbumInfo, SearchResults, ShowInfo, TrackInfo, -}; -use rspotify::model::{ - album::{FullAlbum, SimplifiedAlbum}, - artist::{FullArtist, SimplifiedArtist}, - page::{CursorBasedPage, Page}, - playlist::SimplifiedPlaylist, - show::{FullEpisode, FullShow, SimplifiedEpisode, SimplifiedShow}, - track::{FullTrack, SimplifiedTrack}, - PlayableItem, -}; -use rspotify::prelude::Id; - -/// First image URL of an `images` list, if any. -fn first_image(images: &[rspotify::model::image::Image]) -> Option { - images.first().map(|img| img.url.clone()) -} - -/// Convert a chrono `Duration` to non-negative milliseconds. -fn duration_ms(duration: chrono::Duration) -> u64 { - duration.num_milliseconds().max(0) as u64 -} - -/// A single artist reference (id + display name). -pub fn artist_ref(artist: &SimplifiedArtist) -> ArtistRef { - ArtistRef { - id: artist.id.as_ref().map(|id| id.id().to_string()), - name: artist.name.clone(), - } -} - -fn artist_refs(artists: &[SimplifiedArtist]) -> Vec { - artists.iter().map(artist_ref).collect() -} - -// --- Artists --------------------------------------------------------------- - -impl From<&FullArtist> for ArtistInfo { - fn from(a: &FullArtist) -> Self { - ArtistInfo { - id: Some(a.id.id().to_string()), - uri: Some(a.id.uri()), - name: a.name.clone(), - image_url: first_image(&a.images), - } - } -} - -impl From<&SimplifiedArtist> for ArtistInfo { - fn from(a: &SimplifiedArtist) -> Self { - ArtistInfo { - id: a.id.as_ref().map(|id| id.id().to_string()), - uri: a.id.as_ref().map(|id| id.uri()), - name: a.name.clone(), - image_url: None, - } - } -} - -// --- Tracks ---------------------------------------------------------------- - -impl From<&FullTrack> for TrackInfo { - fn from(t: &FullTrack) -> Self { - TrackInfo { - uri: t.id.as_ref().map(|id| id.uri()), - name: t.name.clone(), - artists: t.artists.iter().map(|a| a.name.clone()).collect(), - album: t.album.name.clone(), - duration_ms: duration_ms(t.duration), - id: t.id.as_ref().map(|id| id.id().to_string()), - album_id: t.album.id.as_ref().map(|id| id.id().to_string()), - artist_refs: artist_refs(&t.artists), - is_playable: t.is_playable.unwrap_or(true), - is_local: t.is_local, - track_number: t.track_number, - explicit: t.explicit, - // Spotify cover art is resolved separately from album images, not here. - image_url: None, - } - } -} - -impl From<&SimplifiedTrack> for TrackInfo { - fn from(t: &SimplifiedTrack) -> Self { - TrackInfo { - uri: t.id.as_ref().map(|id| id.uri()), - name: t.name.clone(), - artists: t.artists.iter().map(|a| a.name.clone()).collect(), - album: t - .album - .as_ref() - .map(|al| al.name.clone()) - .unwrap_or_default(), - duration_ms: duration_ms(t.duration), - id: t.id.as_ref().map(|id| id.id().to_string()), - album_id: t - .album - .as_ref() - .and_then(|al| al.id.as_ref()) - .map(|id| id.id().to_string()), - artist_refs: artist_refs(&t.artists), - is_playable: t.is_playable.unwrap_or(true), - is_local: t.is_local, - track_number: t.track_number, - explicit: t.explicit, - // Spotify cover art is resolved separately from album images, not here. - image_url: None, - } - } -} - -// --- Albums ---------------------------------------------------------------- - -impl From<&SimplifiedAlbum> for AlbumInfo { - fn from(a: &SimplifiedAlbum) -> Self { - AlbumInfo { - id: a.id.as_ref().map(|id| id.id().to_string()), - uri: a.id.as_ref().map(|id| id.uri()), - name: a.name.clone(), - artists: artist_refs(&a.artists), - album_type: a.album_type.clone(), - release_date: a.release_date.clone(), - total_tracks: None, - image_url: first_image(&a.images), - tracks: Vec::new(), - } - } -} - -impl From<&FullAlbum> for AlbumInfo { - fn from(a: &FullAlbum) -> Self { - let album_id = a.id.id().to_string(); - // Child tracks come back as SimplifiedTrack without their parent album set; - // backfill the album name/id from this full album so each row is renderable. - let tracks = a - .tracks - .items - .iter() - .map(|t| { - let mut info = TrackInfo::from(t); - if info.album.is_empty() { - info.album = a.name.clone(); - } - if info.album_id.is_none() { - info.album_id = Some(album_id.clone()); - } - info - }) - .collect(); - AlbumInfo { - id: Some(album_id), - uri: Some(a.id.uri()), - name: a.name.clone(), - artists: artist_refs(&a.artists), - // `AlbumType` derives strum `IntoStaticStr` with snake_case, so this - // yields the same wire string ("album"/"single"/"appears_on"/ - // "compilation") as the raw `SimplifiedAlbum::album_type` API field — - // keeping both mapping paths consistent. - album_type: Some(<&'static str>::from(a.album_type).to_string()), - release_date: Some(a.release_date.clone()), - total_tracks: Some(a.tracks.total), - image_url: first_image(&a.images), - tracks, - } - } -} - -// --- Shows / episodes ------------------------------------------------------ - -impl From<&SimplifiedShow> for ShowInfo { - // `publisher` is `#[deprecated]` in rspotify (a field Spotify dropped), but the - // podcast list still renders it as the "by …" attribution, so we read it here - // behind the boundary rather than dropping a column. - #[allow(deprecated)] - fn from(s: &SimplifiedShow) -> Self { - ShowInfo { - id: Some(s.id.id().to_string()), - uri: Some(s.id.uri()), - name: s.name.clone(), - description: s.description.clone(), - publisher: s.publisher.clone(), - image_url: first_image(&s.images), - } - } -} - -impl From<&FullShow> for ShowInfo { - #[allow(deprecated)] - fn from(s: &FullShow) -> Self { - ShowInfo { - id: Some(s.id.id().to_string()), - uri: Some(s.id.uri()), - name: s.name.clone(), - description: s.description.clone(), - publisher: s.publisher.clone(), - image_url: first_image(&s.images), - } - } -} - -/// Map rspotify's `ResumePoint` (chrono `Duration` position) to the domain -/// [`ResumePointInfo`] (ms). -fn resume_point_info(rp: &rspotify::model::show::ResumePoint) -> ResumePointInfo { - ResumePointInfo { - fully_played: rp.fully_played, - resume_position_ms: duration_ms(rp.resume_position), - } -} - -impl From<&SimplifiedEpisode> for EpisodeInfo { - fn from(e: &SimplifiedEpisode) -> Self { - EpisodeInfo { - id: Some(e.id.id().to_string()), - uri: Some(e.id.uri()), - name: e.name.clone(), - // SimplifiedEpisode carries no parent show; it is only ever listed within - // a show's own episode view, where the show name is shown separately. - show_name: String::new(), - duration_ms: duration_ms(e.duration), - description: e.description.clone(), - release_date: e.release_date.clone(), - is_playable: e.is_playable, - resume_point: e.resume_point.as_ref().map(resume_point_info), - image_url: first_image(&e.images), - } - } -} - -impl From<&FullEpisode> for EpisodeInfo { - fn from(e: &FullEpisode) -> Self { - EpisodeInfo { - id: Some(e.id.id().to_string()), - uri: Some(e.id.uri()), - name: e.name.clone(), - show_name: e.show.name.clone(), - duration_ms: duration_ms(e.duration), - description: e.description.clone(), - release_date: e.release_date.clone(), - is_playable: e.is_playable, - resume_point: e.resume_point.as_ref().map(resume_point_info), - image_url: first_image(&e.images), - } - } -} - -/// Map a rspotify `SavedAlbum` to the domain [`SavedAlbumInfo`], preserving -/// `added_at` (as an RFC 3339 UTC string) for the "Date Added" sort. -pub fn saved_album_info(sa: &rspotify::model::album::SavedAlbum) -> SavedAlbumInfo { - SavedAlbumInfo { - album: AlbumInfo::from(&sa.album), - added_at: sa.added_at.to_rfc3339(), - } -} - -// --- Playable items (queue / playback context) ----------------------------- - -/// Map a rspotify `PlayableItem` to a domain [`PlayableInfo`]. The `Unknown` -/// (unparseable) variant yields `None`. -pub fn playable_info(item: &PlayableItem) -> Option { - match item { - PlayableItem::Track(t) => Some(PlayableInfo::Track(TrackInfo::from(t))), - PlayableItem::Episode(e) => Some(PlayableInfo::Episode(EpisodeInfo::from(e))), - PlayableItem::Unknown(_) => None, - } -} - -/// Convert a page of `PlaylistItem` into a domain page of -/// `(absolute_position, PlayableInfo)`. Each item's inner `track` -/// (`Option`) is mapped via [`playable_info`]; items that are -/// `None` or `Unknown` are dropped from the result, but their slot is still -/// counted so the surviving items keep their **absolute playlist position** -/// (`page.offset + raw slot index`). That position drives removal-by-position -/// and play-from-here offsets, so it must reflect the real playlist index, not -/// the compacted one. Paging metadata is copied straight from the source page. -pub fn playlist_items_page( - page: &Page, -) -> Paged<(u32, PlayableInfo)> { - Paged { - items: page - .items - .iter() - .enumerate() - .filter_map(|(idx, item)| { - let info = item.item.as_ref().and_then(playable_info)?; - Some((page.offset + idx as u32, info)) - }) - .collect(), - offset: page.offset, - limit: page.limit, - total: page.total, - next: page.next.clone(), - previous: page.previous.clone(), - } -} - -// --- Search ---------------------------------------------------------------- - -/// Assemble domain [`SearchResults`] from whichever rspotify result pages are -/// present (the `App` holds each category as an independent `Option>`). -pub fn search_results_from_pages( - tracks: Option<&Page>, - albums: Option<&Page>, - artists: Option<&Page>, - playlists: Option<&Page>, - shows: Option<&Page>, -) -> SearchResults { - SearchResults { - tracks: tracks - .map(|p| p.items.iter().map(TrackInfo::from).collect()) - .unwrap_or_default(), - albums: albums - .map(|p| p.items.iter().map(AlbumInfo::from).collect()) - .unwrap_or_default(), - artists: artists - .map(|p| p.items.iter().map(ArtistInfo::from).collect()) - .unwrap_or_default(), - playlists: playlists - .map(|p| p.items.iter().map(PlaylistInfo::from_simplified).collect()) - .unwrap_or_default(), - shows: shows - .map(|p| p.items.iter().map(ShowInfo::from).collect()) - .unwrap_or_default(), - } -} - -// --- Pagination ------------------------------------------------------------ - -/// Convert an rspotify offset-based [`Page`] into a domain -/// [`Paged`], mapping each item with `f`. Drops the API `href`. -/// -/// Pass a **closure**, not a bare `From::from` fn-item — e.g. -/// `map_page(&page, |t| TrackInfo::from(t))`. A bare `TrackInfo::from` trips -/// Rust's higher-ranked-lifetime inference (`Fn` not general enough). -pub fn map_page(page: &Page, f: impl Fn(&U) -> T) -> Paged -where - U: serde::de::DeserializeOwned, -{ - Paged { - items: page.items.iter().map(f).collect(), - offset: page.offset, - limit: page.limit, - total: page.total, - next: page.next.clone(), - previous: page.previous.clone(), - } -} - -/// Convert an rspotify [`CursorBasedPage`] into a domain -/// [`CursorPaged`], mapping each item with `f`. Pass a closure (see -/// [`map_page`]). -pub fn map_cursor_page(page: &CursorBasedPage, f: impl Fn(&U) -> T) -> CursorPaged { - CursorPaged { - items: page.items.iter().map(f).collect(), - limit: page.limit, - next: page.next.clone(), - cursor_after: page.cursors.as_ref().and_then(|c| c.after.clone()), - total: page.total, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::test_helpers::full_track; - use rspotify::model::{album::SimplifiedAlbum, artist::SimplifiedArtist, idtypes::ArtistId}; - - fn artist_with_id(id: &str, name: &str) -> SimplifiedArtist { - SimplifiedArtist { - id: Some(ArtistId::from_id(id).unwrap().into_static()), - name: name.to_string(), - ..Default::default() - } - } - - #[test] - fn full_track_maps_core_and_extended_fields() { - let ft = full_track("4uLU6hMCjMI75M1A2tKUQC", "Never Gonna Give You Up"); - let info = TrackInfo::from(&ft); - - // Scripting-contract fields (must keep working for api_version = 4). - assert_eq!(info.name, "Never Gonna Give You Up"); - assert_eq!(info.artists, vec!["Test Artist".to_string()]); - assert_eq!(info.album, "Test Album"); - assert_eq!(info.duration_ms, 180_000); - assert_eq!( - info.uri.as_deref(), - Some("spotify:track:4uLU6hMCjMI75M1A2tKUQC") - ); - - // Extended fields. - assert_eq!(info.id.as_deref(), Some("4uLU6hMCjMI75M1A2tKUQC")); - assert_eq!(info.album_id, None); // test fixture album has no id - assert_eq!(info.artist_refs.len(), 1); - assert_eq!(info.artist_refs[0].name, "Test Artist"); - assert!(info.is_playable); - assert!(!info.is_local); - assert_eq!(info.track_number, 1); - assert!(!info.explicit); - } - - #[test] - fn simplified_artist_maps_id_and_uri() { - let artist = artist_with_id("2WX2uTcsvV5OnS0inACecP", "Survive Said The Prophet"); - let info = ArtistInfo::from(&artist); - assert_eq!(info.id.as_deref(), Some("2WX2uTcsvV5OnS0inACecP")); - assert_eq!( - info.uri.as_deref(), - Some("spotify:artist:2WX2uTcsvV5OnS0inACecP") - ); - assert_eq!(info.name, "Survive Said The Prophet"); - assert_eq!(info.image_url, None); - } - - #[test] - fn simplified_album_maps_without_tracks() { - let album = SimplifiedAlbum { - name: "Inhuman".to_string(), - album_type: Some("album".to_string()), - artists: vec![artist_with_id("2WX2uTcsvV5OnS0inACecP", "SSTP")], - ..Default::default() - }; - let info = AlbumInfo::from(&album); - assert_eq!(info.name, "Inhuman"); - assert_eq!(info.album_type.as_deref(), Some("album")); - assert_eq!(info.artists.len(), 1); - assert_eq!(info.artists[0].name, "SSTP"); - assert_eq!(info.total_tracks, None); - assert!(info.tracks.is_empty()); - } - - #[test] - fn search_aggregator_collects_present_categories() { - let tracks = Page { - href: String::new(), - items: vec![full_track("4uLU6hMCjMI75M1A2tKUQC", "A")], - limit: 1, - next: None, - offset: 0, - previous: None, - total: 1, - }; - let results = search_results_from_pages(Some(&tracks), None, None, None, None); - assert_eq!(results.tracks.len(), 1); - assert_eq!(results.tracks[0].name, "A"); - assert!(results.albums.is_empty()); - assert!(results.artists.is_empty()); - assert!(results.playlists.is_empty()); - assert!(results.shows.is_empty()); - } - - #[test] - fn map_page_converts_items_and_preserves_paging() { - let page = Page { - href: "https://api/x".to_string(), - items: vec![ - full_track("4uLU6hMCjMI75M1A2tKUQC", "A"), - full_track("1301WleyT98MSxVHPZCA6M", "B"), - ], - limit: 20, - next: Some("https://api/x?offset=20".to_string()), - offset: 0, - previous: None, - total: 42, - }; - let mapped = map_page(&page, |t| TrackInfo::from(t)); - assert_eq!(mapped.items.len(), 2); - assert_eq!(mapped.items[0].name, "A"); - assert_eq!(mapped.items[1].name, "B"); - assert_eq!(mapped.offset, 0); - assert_eq!(mapped.limit, 20); - assert_eq!(mapped.total, 42); - assert!(mapped.has_next()); - } - - #[test] - fn track_info_serializes_with_api_v4_contract_keys() { - // Plugins built against api_version = 4 read these exact keys; growth must - // only ever ADD keys, never remove/rename these. - let info = TrackInfo::from(&full_track("4uLU6hMCjMI75M1A2tKUQC", "A")); - let json = serde_json::to_value(&info).unwrap(); - let obj = json.as_object().unwrap(); - for key in ["uri", "name", "artists", "album", "duration_ms"] { - assert!(obj.contains_key(key), "missing contract key `{key}`"); - } - } - - #[test] - #[allow(deprecated)] // PlaylistItem struct literal must set the deprecated `track` field - fn playlist_items_page_keeps_absolute_position_when_slots_are_dropped() { - use rspotify::model::playlist::PlaylistItem; - - // A `None` slot before the second track must still advance the index so the - // surviving track keeps its true playlist position (removal-by-position - // depends on it). - let track_item = |id: &str| PlaylistItem { - added_at: None, - added_by: None, - is_local: false, - track: None, - item: Some(PlayableItem::Track(full_track(id, id))), - }; - let empty_item = PlaylistItem { - added_at: None, - added_by: None, - is_local: false, - track: None, - item: None, - }; - - let page = Page { - href: String::new(), - items: vec![ - track_item("4uLU6hMCjMI75M1A2tKUQC"), // raw index 0 - empty_item, // raw index 1 (dropped) - track_item("1301WleyT98MSxVHPZCA6M"), // raw index 2 - ], - limit: 20, - next: None, - offset: 40, - previous: None, - total: 43, - }; - - let mapped = playlist_items_page(&page); - // Two playables survive; positions reflect raw slots offset by the page offset. - assert_eq!(mapped.items.len(), 2); - assert_eq!(mapped.items[0].0, 40); - assert_eq!(mapped.items[1].0, 42); - // Paging metadata is copied verbatim. - assert_eq!(mapped.offset, 40); - assert_eq!(mapped.total, 43); - } -} diff --git a/src/infra/network/metadata.rs b/src/infra/network/metadata.rs deleted file mode 100644 index f7530288..00000000 --- a/src/infra/network/metadata.rs +++ /dev/null @@ -1,522 +0,0 @@ -use super::{ids, IoEvent, Network}; -use crate::core::app::{ - ActiveBlock, Artist, ArtistBlock, EpisodeTableContext, RouteId, ScrollableResultPages, - SelectedFullShow, SelectedShow, -}; -use crate::core::plugin_api::{AlbumInfo, ArtistInfo, EpisodeInfo, ShowInfo, TrackInfo}; -use crate::infra::network::mapping::map_page; -use anyhow::anyhow; -use rspotify::model::{ - album::{FullAlbum, SimplifiedAlbum}, - artist::FullArtist, - enums::Country, - idtypes::{AlbumId, ArtistId, ShowId, TrackId}, - page::{CursorBasedPage, Page}, - track::{FullTrack, SimplifiedTrack}, -}; -use rspotify::prelude::*; -use serde::Deserialize; - -/// Minimal id-keyed TTL cache for metadata responses, so re-entering the same -/// album/artist from a list within the TTL doesn't pay a fresh round trip plus -/// the pacing tax. Deliberately tiny: no LRU crate, oldest-entry eviction at -/// capacity, entries expire on read. -pub(super) struct MetadataTtlCache { - entries: std::collections::HashMap, -} - -const METADATA_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(300); -const METADATA_CACHE_CAP: usize = 32; - -impl Default for MetadataTtlCache { - fn default() -> Self { - Self { - entries: std::collections::HashMap::new(), - } - } -} - -impl MetadataTtlCache { - pub(super) fn get(&mut self, key: &str) -> Option { - match self.entries.get(key) { - Some((stored_at, value)) if stored_at.elapsed() < METADATA_CACHE_TTL => Some(value.clone()), - Some(_) => { - self.entries.remove(key); - None - } - None => None, - } - } - - pub(super) fn put(&mut self, key: String, value: T) { - if self.entries.len() >= METADATA_CACHE_CAP && !self.entries.contains_key(&key) { - if let Some(oldest) = self - .entries - .iter() - .min_by_key(|(_, (stored_at, _))| *stored_at) - .map(|(k, _)| k.clone()) - { - self.entries.remove(&oldest); - } - } - self.entries.insert(key, (std::time::Instant::now(), value)); - } -} - -/// The raw responses `get_artist` assembles, cached as one unit. -#[derive(Clone)] -pub(super) struct CachedArtistData { - top_tracks: Vec, - related_artists: Vec, - album_items: Vec, -} - -#[derive(Deserialize)] -struct ArtistTopTracksResponse { - tracks: Vec, -} - -#[derive(Deserialize)] -struct RelatedArtistsResponse { - artists: Vec, -} - -#[derive(Deserialize)] -struct FollowedArtistsResponse { - artists: CursorBasedPage, -} - -fn country_code(country: Country) -> String { - let code: &'static str = country.into(); - code.to_string() -} - -/// Fetch an album's tracklist from `offset` onward, following pagination until -/// the API reports no further page. Spotify caps each response at 50 tracks, -/// so albums longer than that need this loop to come back complete. -pub(super) async fn fetch_album_tracks_from( - network: &Network, - album_id: &str, - mut offset: u32, -) -> anyhow::Result> { - let path = format!("albums/{}/tracks", album_id); - let limit = 50u32; - let mut items = Vec::new(); - loop { - let page = network - .spotify_get_typed::>( - &path, - &[("limit", limit.to_string()), ("offset", offset.to_string())], - ) - .await?; - let has_next = page.next.is_some(); - items.extend(page.items); - if !has_next { - break; - } - offset += limit; - } - Ok(items) -} - -pub trait MetadataNetwork { - async fn get_artist( - &mut self, - artist_id: ArtistId<'static>, - input_artist_name: String, - country: Option, - ); - async fn get_album_tracks(&mut self, album: Box); - async fn get_album(&mut self, album_id: AlbumId<'static>); - async fn get_show_episodes(&mut self, show: Box); - async fn get_show(&mut self, show_id: ShowId<'static>); - async fn get_current_show_episodes(&mut self, show_id: ShowId<'static>, offset: Option); - async fn get_followed_artists(&mut self, after: Option>); - async fn user_unfollow_artists(&mut self, artist_ids: Vec>); - async fn user_follow_artists(&mut self, artist_ids: Vec>); - async fn user_artist_check_follow(&mut self, artist_ids: Vec>); - #[allow(dead_code)] - async fn get_album_for_track(&mut self, track_id: TrackId<'static>); -} - -impl MetadataNetwork for Network { - async fn get_artist( - &mut self, - artist_id: ArtistId<'static>, - input_artist_name: String, - country: Option, - ) { - let artist_id_str = artist_id.id().to_string(); - let cache_key = format!("{}:{:?}", artist_id_str, country); - - let cached = self.artist_cache.get(&cache_key); - let CachedArtistData { - top_tracks, - related_artists, - album_items, - } = if let Some(data) = cached { - data - } else { - let artist_path = format!("artists/{}", artist_id.id()); - let top_tracks_path = format!("{}/top-tracks", artist_path); - let related_artists_path = format!("{}/related-artists", artist_path); - let mut top_tracks_query = Vec::new(); - if let Some(country) = country { - top_tracks_query.push(("market", country_code(country))); - } - - let (top_tracks_res, related_artists_res) = tokio::join!( - self.spotify_get_typed::(&top_tracks_path, &top_tracks_query), - self.spotify_get_typed::(&related_artists_path, &[]) - ); - - let top_tracks = match top_tracks_res { - Ok(res) => res.tracks, - Err(e) => { - self.handle_error(anyhow!(e)).await; - return; - } - }; - let related_artists = match related_artists_res { - Ok(res) => res.artists, - Err(e) => { - self.handle_error(anyhow!(e)).await; - return; - } - }; - - let mut album_items = Vec::new(); - let mut offset = 0u32; - let limit = 50u32; - loop { - let mut query = vec![("limit", limit.to_string()), ("offset", offset.to_string())]; - if let Some(country) = country { - query.push(("market", country_code(country))); - } - let page = match self - .spotify_get_typed::>(&format!("{}/albums", artist_path), &query) - .await - { - Ok(page) => page, - Err(e) => { - self.handle_error(anyhow!(e)).await; - return; - } - }; - let next = page.next.is_some(); - album_items.extend(page.items); - if !next { - break; - } - offset += limit; - } - - let data = CachedArtistData { - top_tracks, - related_artists, - album_items, - }; - self.artist_cache.put(cache_key, data.clone()); - data - }; - - // Convert rspotify types to domain types before storing — conversion stays - // inside infra/network per the multi-source boundary contract. - let albums_rspotify_page = Page { - items: album_items, - href: String::new(), - limit: 50, - next: None, - offset: 0, - previous: None, - total: 0, - }; - let albums = map_page(&albums_rspotify_page, |a| AlbumInfo::from(a)); - let domain_related_artists: Vec = - related_artists.iter().map(ArtistInfo::from).collect(); - let domain_top_tracks: Vec = top_tracks.iter().map(TrackInfo::from).collect(); - - let mut app = self.app.lock().await; - - // Check if the top tracks are liked. - let track_check = ids::track_check_ids(top_tracks.iter().map(|t| t.id.as_ref())); - if !track_check.is_empty() { - app.dispatch(IoEvent::CurrentUserSavedTracksContains(track_check)); - } - - // Check if the artist's albums are saved. - let album_check: Vec = albums_rspotify_page - .items - .iter() - .filter_map(|album| album.id.as_ref().map(|id| id.id().to_string())) - .collect(); - if !album_check.is_empty() { - app.dispatch(IoEvent::CurrentUserSavedAlbumsContains(album_check)); - } - - // Check if the related artists are followed. - let follow_check: Vec = related_artists - .iter() - .map(|artist| artist.id.id().to_string()) - .collect(); - if !follow_check.is_empty() { - app.dispatch(IoEvent::UserArtistFollowCheck(follow_check)); - } - - app.artist = Some(Artist { - artist_id: artist_id_str, - artist_name: input_artist_name, - albums, - related_artists: domain_related_artists, - top_tracks: domain_top_tracks, - selected_album_index: 0, - selected_related_artist_index: 0, - selected_top_track_index: 0, - artist_selected_block: ArtistBlock::TopTracks, - artist_hovered_block: ArtistBlock::TopTracks, - }); - app.push_navigation_stack(RouteId::Artist, ActiveBlock::ArtistBlock); - } - - async fn get_album_tracks(&mut self, album: Box) { - let album_id = album.id.clone(); - if let Some(id) = album_id { - let fetched = match self.album_tracks_cache.get(id.id()) { - Some(tracks) => Ok(tracks), - None => match fetch_album_tracks_from(self, id.id(), 0).await { - Ok(tracks) => { - self - .album_tracks_cache - .put(id.id().to_string(), tracks.clone()); - Ok(tracks) - } - Err(e) => Err(e), - }, - }; - match fetched { - Ok(track_items) => { - let album_info = crate::core::plugin_api::AlbumInfo::from(album.as_ref()); - // Check if these tracks are liked (before `track_items` is consumed). - let track_check = ids::track_check_ids(track_items.iter().map(|t| t.id.as_ref())); - let total = track_items.len() as u32; - let tracks = Page { - items: track_items, - href: String::new(), - limit: total, - next: None, - offset: 0, - previous: None, - total, - }; - let tracks_domain = map_page(&tracks, |t| crate::core::plugin_api::TrackInfo::from(t)); - let mut app = self.app.lock().await; - if !track_check.is_empty() { - app.dispatch(IoEvent::CurrentUserSavedTracksContains(track_check)); - } - app.selected_album_simplified = Some(crate::core::app::SelectedAlbum { - album: album_info, - tracks: tracks_domain, - selected_index: 0, - }); - app.album_table_context = crate::core::app::AlbumTableContext::Simplified; - app.push_navigation_stack(RouteId::AlbumTracks, ActiveBlock::AlbumTracks); - } - Err(e) => self.handle_error(anyhow!(e)).await, - } - } - } - - async fn get_album(&mut self, album_id: AlbumId<'static>) { - let fetched = if let Some(album) = self.album_cache.get(album_id.id()) { - Ok(album) - } else { - self - .spotify_get_typed::(&format!("albums/{}", album_id.id()), &[]) - .await - }; - match fetched { - Ok(mut album) => { - // A full album embeds only the first page of its tracklist (50 tracks - // max); fetch the rest so longer albums render completely. - if album.tracks.next.is_some() { - let offset = album.tracks.items.len() as u32; - match fetch_album_tracks_from(self, album_id.id(), offset).await { - Ok(rest) => { - album.tracks.items.extend(rest); - // Mark the tracklist complete so a cache hit doesn't re-page. - album.tracks.next = None; - } - Err(e) => { - self.handle_error(anyhow!(e)).await; - return; - } - } - } - self - .album_cache - .put(album_id.id().to_string(), album.clone()); - let album_info = crate::core::plugin_api::AlbumInfo::from(&album); - let mut app = self.app.lock().await; - // Check if these tracks are liked. - let track_check = ids::track_check_ids(album.tracks.items.iter().map(|t| t.id.as_ref())); - if !track_check.is_empty() { - app.dispatch(IoEvent::CurrentUserSavedTracksContains(track_check)); - } - app.selected_album_full = Some(crate::core::app::SelectedFullAlbum { - album: album_info, - selected_index: 0, - }); - app.album_table_context = crate::core::app::AlbumTableContext::Full; - app.push_navigation_stack(RouteId::AlbumTracks, ActiveBlock::AlbumTracks); - } - Err(e) => self.handle_error(anyhow!(e)).await, - } - } - - async fn get_show_episodes(&mut self, show: Box) { - let Some(show_id) = show.id.as_deref().and_then(|id| ShowId::from_id(id).ok()) else { - return; - }; - let path = format!("shows/{}/episodes", show_id.id()); - let query = vec![ - ("limit", self.large_search_limit.to_string()), - ("offset", "0".to_string()), - ]; - match self - .spotify_get_typed::>(&path, &query) - .await - { - Ok(episodes) => { - if !episodes.items.is_empty() { - let domain_page = map_page(&episodes, |e| EpisodeInfo::from(e)); - let mut app = self.app.lock().await; - app.library.show_episodes = ScrollableResultPages::new(); - app.library.show_episodes.add_pages(domain_page); - - app.selected_show_simplified = Some(SelectedShow { show: *show }); - - app.episode_table_context = EpisodeTableContext::Simplified; - - app.push_navigation_stack(RouteId::PodcastEpisodes, ActiveBlock::EpisodeTable); - } - } - Err(e) => { - self.handle_error(anyhow!(e)).await; - } - } - } - - async fn get_show(&mut self, show_id: ShowId<'static>) { - let path = format!("shows/{}", show_id.id()); - match self - .spotify_get_typed::(&path, &[]) - .await - { - Ok(show) => { - let selected_show = SelectedFullShow { - show: ShowInfo::from(&show), - }; - - let mut app = self.app.lock().await; - - app.selected_show_full = Some(selected_show); - - app.episode_table_context = EpisodeTableContext::Full; - app.push_navigation_stack(RouteId::PodcastEpisodes, ActiveBlock::EpisodeTable); - } - Err(e) => { - self.handle_error(anyhow!(e)).await; - } - } - } - - async fn get_current_show_episodes(&mut self, show_id: ShowId<'static>, offset: Option) { - let path = format!("shows/{}/episodes", show_id.id()); - let mut query = vec![("limit", self.large_search_limit.to_string())]; - if let Some(offset) = offset { - query.push(("offset", offset.to_string())); - } - - match self - .spotify_get_typed::>(&path, &query) - .await - { - Ok(episodes) => { - if !episodes.items.is_empty() { - let domain_page = map_page(&episodes, |e| EpisodeInfo::from(e)); - let mut app = self.app.lock().await; - app.library.show_episodes.add_pages(domain_page); - } - } - Err(e) => { - self.handle_error(anyhow!(e)).await; - } - } - } - - async fn get_followed_artists(&mut self, after: Option>) { - let limit = self.large_search_limit; - let mut query = vec![("type", "artist".to_string()), ("limit", limit.to_string())]; - if let Some(after) = after.as_ref() { - query.push(("after", after.id().to_string())); - } - - match self - .spotify_get_typed::("me/following", &query) - .await - { - Ok(res) => { - let domain_page = - crate::infra::network::mapping::map_cursor_page(&res.artists, |a| ArtistInfo::from(a)); - let mut app = self.app.lock().await; - app.library.saved_artists.add_pages(domain_page); - } - Err(e) => self.handle_error(anyhow!(e)).await, - } - } - - async fn user_unfollow_artists(&mut self, artist_ids: Vec>) { - let uris: Vec = artist_ids.iter().map(|id| id.uri()).collect(); - if let Err(e) = self.library_remove_uris(&uris).await { - self.handle_error(e).await; - } - } - - async fn user_follow_artists(&mut self, artist_ids: Vec>) { - let uris: Vec = artist_ids.iter().map(|id| id.uri()).collect(); - if let Err(e) = self.library_save_uris(&uris).await { - self.handle_error(e).await; - } - } - - async fn user_artist_check_follow(&mut self, artist_ids: Vec>) { - let uris: Vec = artist_ids.iter().map(|id| id.uri()).collect(); - match self.library_contains_uris(&uris).await { - Ok(is_following) => { - let mut app = self.app.lock().await; - for (id, is_following) in artist_ids.iter().zip(is_following) { - if is_following { - app.followed_artist_ids_set.insert(id.id().to_string()); - } - } - } - // Bookkeeping for the follow marker: a failed check must not take over - // the screen. - Err(e) => log::debug!("artist follow check failed: {e}"), - } - } - - async fn get_album_for_track(&mut self, track_id: TrackId<'static>) { - match self - .spotify_get_typed::(&format!("tracks/{}", track_id.id()), &[]) - .await - { - Ok(track) => { - // FullTrack.album is SimplifiedAlbum (not Option) in rspotify 0.14 - let album = track.album; - self.get_album_tracks(Box::new(album)).await; - } - Err(e) => self.handle_error(anyhow!(e)).await, - } - } -} diff --git a/src/infra/network/mod.rs b/src/infra/network/mod.rs deleted file mode 100644 index 173dd856..00000000 --- a/src/infra/network/mod.rs +++ /dev/null @@ -1,2428 +0,0 @@ -#[cfg(any(feature = "mcp-server", feature = "ai-dj"))] -pub mod dj; -pub mod friends; -pub mod ids; -pub mod library; -pub mod mapping; -pub mod metadata; -pub mod native_shuffle; -pub mod playback; -pub mod recommend; -pub mod requests; -pub mod search; -pub mod spotify_source; -pub mod sync; -pub mod user; -pub mod utils; - -use crate::core::app::{App, PlaybackOwner, SPOTIFY_NOT_CONNECTED_STATUS}; -use crate::core::auth; -use crate::core::config::{ClientConfig, NCSPOT_CLIENT_ID}; -use crate::core::plugin_api::{ShowInfo, TrackInfo}; -use crate::infra::redirect_uri::{bind_callback_listener, serve_spotify_callback}; -use anyhow::anyhow; -use rspotify::model::{ - album::SimplifiedAlbum, - enums::{Country, RepeatState}, -}; -use rspotify::prelude::Id; -// `parse_response_code` / `request_token` for the in-TUI login live on this trait. -use rspotify::clients::OAuthClient; -use rspotify::AuthCodePkceSpotify; -use std::path::PathBuf; -use std::sync::Arc; -use std::time::Duration; -use tokio::sync::Mutex; - -// Re-export traits -use self::library::LibraryNetwork; -use self::metadata::MetadataNetwork; -use self::playback::PlaybackNetwork; -use self::recommend::RecommendationNetwork; -use self::search::SearchNetwork; -use self::user::UserNetwork; -use self::utils::UtilsNetwork; - -// Most variants are constructed by TUI handlers, so a headless build counts -// them as dead; every tui-enabled CI leg lints the enum in full. Never `cfg` -// a variant out for this - a build leg missing a feature would then compile -// clean while the feature is silently absent. -#[cfg_attr(not(feature = "tui"), allow(dead_code))] -pub enum IoEvent { - GetCurrentPlayback, - /// After a track transition (e.g., EndOfTrack), ensure we don't end up paused on the next item. - /// The payload is the previous track identifier (either base62 id or a `spotify:track:` URI). - #[allow(dead_code)] - EnsurePlaybackContinues(String), - RefreshAuthentication, - GetPlaylists, - GetDevices, - /// Refresh the device list without opening the device picker (plugin data reads). - #[cfg_attr(not(feature = "scripting"), allow(dead_code))] - GetDevicesSilent, - GetSearchResults(String, Option), - /// Playlist id/URI, page offset. - GetPlaylistItems(String, u32), - /// Playlist id/URI, query. - SearchPlaylistTracks(String, String), - GetCurrentSavedTracks(Option), - /// Context URI (album/artist/playlist), specific playable URIs, start offset. - StartPlayback(Option, Option>, Option), - /// Restore a generation-guarded native playback snapshot after rebuilding - /// the librespot backend. This is a direct Spirc load and does not use the Web API. - #[cfg(feature = "streaming")] - RestoreNativePlayback(u64), - /// Replay the published native Spotify queue slot after recovery. Routed by - /// the native queue before the Spotify network handler. - #[cfg(feature = "streaming")] - ReplayPublishedSpotifyQueueSlot, - UpdateSearchLimits(u32, u32), - Seek(u32), - NextTrack, - PreviousTrack, - ForcePreviousTrack, - Shuffle(bool), // desired shuffle state - Repeat(RepeatState), - PausePlayback, - ChangeVolume(u8), - /// Artist id/URI, display name, market. - GetArtist(String, String, Option), - GetAlbumTracks(Box), - /// Seed artist ids/URIs, seed track ids/URIs, first seed track, market. - GetRecommendationsForSeed( - Option>, - Option>, - Box>, - Option, - ), - GetCurrentUserSavedAlbums(Option), - CurrentUserSavedAlbumsContains(Vec), - CurrentUserSavedAlbumDelete(String), - CurrentUserSavedAlbumAdd(String), - UserUnfollowArtists(Vec), - UserFollowArtists(Vec), - /// Owner user id, playlist id/URI, public flag. - UserFollowPlaylist(String, String, Option), - /// Owner user id, playlist id/URI. - UserUnfollowPlaylist(String, String), - /// Playlist id/URI, track id/URI. - AddTrackToPlaylist(String, String), - /// Playlist id/URI, track id/URI, position. - RemoveTrackFromPlaylistAtPosition(String, String, usize), - GetUser, - /// Playable URI (track or episode) to toggle in saved tracks. - ToggleSaveTrack(String), - /// Track id/URI, market. - GetRecommendationsForTrackId(String, Option), - GetRecentlyPlayed, - /// Refresh recently-played data without opening the screen (plugin data reads). - #[cfg_attr(not(feature = "scripting"), allow(dead_code))] - GetRecentlyPlayedSilent, - /// Pagination cursor: artist id/URI to fetch after. - GetFollowedArtists(Option), - UserArtistFollowCheck(Vec), - GetAlbum(String), - TransferPlaybackToDevice(String, bool), - #[allow(dead_code)] - AutoSelectStreamingDevice(String, bool, bool), // Auto-select a device by name (used for native streaming): (device name, persist_device_id, yield_to_external_playback) - GetAlbumForTrack(String), - CurrentUserSavedTracksContains(Vec), - GetCurrentUserSavedShows(Option), - CurrentUserSavedShowsContains(Vec), - CurrentUserSavedShowDelete(String), - CurrentUserSavedShowAdd(String), - GetShowEpisodes(Box), - GetShow(String), - GetCurrentShowEpisodes(String, Option), - /// Playable URI (track or episode) to enqueue. - AddItemToQueue(String), - GetQueue, - /// Advance the native cross-source queue: play the next queued item, or resume - /// the suspended per-source context when the queue drains. Consumed by - /// `infra::queue::dispatch::route_queue_event` (wired first in the pump); it - /// never reaches the Spotify network handler. - AdvanceNativeQueue, - /// Give up the native queue slot without playing anything else: run the same - /// teardown a drained queue does (stop the slot, resume the suspended - /// context). Dispatched by the driver's tick when the slot's output device is - /// gone and no replacement will open. Consumed by - /// `infra::queue::dispatch::route_queue_event`; it never reaches the Spotify - /// network handler. Only the decoded queue slot can lose a device, so in a - /// build without a queueable decoded source nothing dispatches it. - #[cfg_attr(not(feature = "audio-decode-queue"), allow(dead_code))] - FinishNativeQueue, - /// Replay the current track of the active decoded source (repeat-one). Consumed - /// by the per-source routers (`route_local_event` / `route_subsonic_event` / - /// `route_youtube_event`); it never reaches the Spotify network handler. Only - /// dispatched by the runner tick under a decoded source feature. - #[cfg_attr(not(feature = "audio-decode-queue"), allow(dead_code))] - ReplayCurrentTrack, - /// Resume a suspended native-streaming Spotify context after the native queue - /// drains (context URI, resume-track URI). Re-loads the context on the native - /// device via the existing `start_playback` machinery. `allow(dead_code)`: - /// only constructed under `streaming`, but the handler arm is unconditional. - #[allow(dead_code)] - ResumeSpotifyContext(Option, Option), - /// Toggle the native-Spotify client-side shuffle session on/off: reorder the - /// app-owned track list and reload it into Spirc (building a session - /// mid-playback when possible; falls back to Spirc shuffle otherwise). - #[cfg_attr(not(feature = "streaming"), allow(dead_code))] - ToggleNativeShuffleSession(bool), - /// Re-randomize the shuffle session for a fresh repeat-all lap (dispatched - /// when the last track wraps back to the first). - #[cfg_attr(not(feature = "streaming"), allow(dead_code))] - ReshuffleNativeShuffleLap, - /// Resume the suspended shuffle session at the given index once the native - /// queue drains (`None` = session exhausted, finish instead). The `u64` is the - /// session generation the resume was snapshotted from; the handler applies the - /// index only while the live session still matches, so a session replaced - /// mid-drain cannot inherit a stale index. - #[cfg_attr(not(feature = "streaming"), allow(dead_code))] - ResumeNativeShuffleSession(Option, u64), - IncrementGlobalSongCount, - FetchGlobalSongCount, - FetchAnnouncements, - GetLyrics(String, Vec, f64), - /// Get user's top tracks for Discover feature (with time range) - GetUserTopTracks(crate::core::app::DiscoverTimeRange), - /// Get Top Artists Mix - fetches top artists and their top tracks - GetTopArtistsMix, - /// Fetch all playlist tracks and apply sorting - FetchAllPlaylistTracksAndSort(String), - /// Start hosting a listening party - StartParty(sync::ControlMode), - /// Join an existing listening party by code - JoinParty { - code: String, - name: String, - }, - /// Update the host control mode in the relay - SetPartyControlMode(sync::ControlMode), - /// Leave the current listening party - LeaveParty, - /// Broadcast current playback state to party guests (host only) - SyncPlayback, - /// Send a playback command to the party host (guest only, Phase 2) - #[allow(dead_code)] - PartyPlaybackCommand(sync::PlaybackAction), - /// Search tracks to add to a new playlist - SearchTracksForPlaylist(String), - /// Create a new playlist: playlist name, track ids/URIs. - CreateNewPlaylist(String, Vec), - /// Fetch the current user's own friend code from spotatui.com - GetFriendCode, - /// Fetch the current user's friends list from spotatui.com - GetFriends, - /// Add a friend by their 6-character friend code - AddFriendByCode(String), - /// Add a friend by their spotatui.com user ID - AddFriendById(String), - /// Unfollow a friend by their spotatui.com user ID - UnfollowFriend(String), - /// Search spotatui.com users by display name or friend code - SearchFriendUsers(String), - /// Aggregate local listening history for the Stats screen - LoadListeningStats(crate::infra::history::RecapPeriod), - /// Export the shareable HTML recap and open it in the browser - GenerateRecap(crate::infra::history::RecapPeriod), - /// List the folders under the configured local music directory (handled by - /// `infra::local::dispatch`; a no-op on the Spotify network). - GetLocalPlaylists, - /// List the audio files in a local folder, identified by its `file://` URI. - /// The URI is only read by `infra::local::dispatch` (the `local-files` - /// feature); without it the event is an inert no-op. - #[cfg_attr(not(feature = "local-files"), allow(dead_code))] - GetLocalTracks(String), - /// List the user's Subsonic server playlists (handled by - /// `infra::subsonic::dispatch`; a no-op on the Spotify network). - GetSubsonicPlaylists, - /// List the tracks of a Subsonic playlist, identified by its - /// `subsonic:playlist:` URI. Only read by `infra::subsonic::dispatch` (the - /// `subsonic` feature); without it the event is an inert no-op. - #[cfg_attr(not(feature = "subsonic"), allow(dead_code))] - GetSubsonicTracks(String), - /// Run a Subsonic catalog search and populate `app.search_results`. Only read - /// by `infra::subsonic::dispatch`; an inert no-op without the `subsonic` feature. - #[cfg_attr(not(feature = "subsonic"), allow(dead_code))] - GetSubsonicSearchResults(String), - /// List the Qobuz sidebar rows: favorites, playlists, albums (handled by - /// `infra::qobuz::dispatch`; a no-op on the Spotify network). - GetQobuzPlaylists, - /// List the tracks of a Qobuz sidebar row by its `qobuz:` URI. Only read by - /// `infra::qobuz::dispatch`; without the feature the event is an inert no-op. - #[cfg_attr(not(feature = "qobuz"), allow(dead_code))] - GetQobuzTracks(String), - /// Run a Qobuz catalog search and populate `app.search_results`. Only read - /// by `infra::qobuz::dispatch`; an inert no-op without the `qobuz` feature. - #[cfg_attr(not(feature = "qobuz"), allow(dead_code))] - GetQobuzSearchResults(String), - /// Start the in-TUI Qobuz browser login (handled by `infra::qobuz::dispatch`). - #[cfg_attr(not(feature = "qobuz"), allow(dead_code))] - QobuzLogin, - /// Load the configured internet-radio stations into the sidebar (handled by - /// `infra::radio::dispatch`; a no-op on the Spotify network). - GetRadioStations, - /// Search the radio-browser.info directory and populate `app.search_results`. - /// Only read by `infra::radio::dispatch`; an inert no-op without the - /// `internet-radio` feature. - #[cfg_attr(not(feature = "internet-radio"), allow(dead_code))] - GetRadioSearchResults(String), - /// Run a YouTube search (via yt-dlp) and populate `app.search_results`. - /// Only read by `infra::youtube::dispatch`; an inert no-op without the - /// `youtube` feature. - #[cfg_attr(not(feature = "youtube"), allow(dead_code))] - GetYouTubeSearchResults(String), - /// Load the local YouTube playlists file into the sidebar (handled by - /// `infra::youtube::dispatch`; a no-op on the Spotify network). - GetYouTubePlaylists, - /// Open a local YouTube playlist's tracks in the shared track table, - /// identified by its `youtube:playlist:` URI. - #[cfg_attr(not(feature = "youtube"), allow(dead_code))] - GetYouTubeTracks(String), - /// Create a local YouTube playlist with the given name. - #[cfg_attr(not(feature = "youtube"), allow(dead_code))] - CreateYouTubePlaylist(String), - /// Delete the local YouTube playlist with the given `youtube:playlist:` URI. - #[cfg_attr(not(feature = "youtube"), allow(dead_code))] - DeleteYouTubePlaylist(String), - /// Add a video (bare id or `youtube:` URI; metadata resolved from the browse - /// views) to a local YouTube playlist (URI or bare id). - #[cfg_attr(not(feature = "youtube"), allow(dead_code))] - AddTrackToYouTubePlaylist(String, String), - /// Remove a video (bare id or `youtube:` URI) from a local YouTube playlist. - #[cfg_attr(not(feature = "youtube"), allow(dead_code))] - RemoveTrackFromYouTubePlaylist(String, String), - /// Start an in-TUI Spotify OAuth login: open the browser and spawn the callback - /// server. Dispatched from the `d` source picker when Spotify is unconfigured. - /// Runs without a Spotify session (bypasses the auth gate). - BeginSpotifyLogin, - /// Complete an in-TUI Spotify login with the OAuth callback URL received by the - /// spawned server. Bypasses the auth gate (there is no session yet). - CompleteSpotifyLogin(String), - /// Abandon an in-flight in-TUI Spotify login (callback server timed out or - /// failed), clearing the pending state so the user can retry. - CancelSpotifyLogin, - /// Fetch and decode the current track's cover art (album-art URL, source - /// thumbnail, or a local file's embedded picture). Dispatched by the shared - /// track-change detector; handled off the `App` lock so the render loop never - /// blocks on the download/decode. Source-agnostic and independent of Spotify - /// auth. - #[cfg(feature = "art-decode")] - FetchCoverArt(crate::core::art::CoverArtRequest), - /// A DJ tool call that needs the live Spotify client, plus the channel to - /// answer on. - /// - /// Runs on the **serial** lane, not the service lane: resolving a track name - /// to a URI needs the real Spotify client, and the service lane deliberately - /// builds its `Network` with `None` for it. It does bypass the auth *gate* so - /// the handler can answer an unauthenticated caller with a useful message - /// rather than silently dropping the response channel. - /// - /// Boxed because the payload is much larger than the other variants. - #[cfg(any(feature = "mcp-server", feature = "ai-dj"))] - DjToolCall( - Box<( - crate::infra::dj::tools::DjToolCall, - tokio::sync::oneshot::Sender, - )>, - ), - /// Ask the in-TUI DJ's brain for tracks. Optionally carries the listener's - /// words; `None` means "keep the queue going in the current direction". - /// - /// Runs on the **service** lane: a brain call can take a minute or more (an - /// agent CLI is a subprocess with real startup cost), and parking that on the - /// serial pump would freeze every other event behind it. It touches only - /// `self.app` plus its own HTTP client / subprocess, which is exactly the - /// service-lane contract. - #[cfg(feature = "ai-dj")] - AskDj(Box), - /// Top the queue up because it is running low. - /// - /// Fields, in order: the DJ generation this refill was dispatched for, so a - /// stale one can be dropped; and the turn sequence from `DjState::begin_turn`, - /// so only this refill may clear the progress flag. Both are `u64`, so a - /// transposition would compile and then discard the wrong turn's flag. - #[cfg(feature = "ai-dj")] - DjTopUp(u64, u64), - /// Crawl the listener's own playlists for the avoid-library filter. - /// - /// Serial lane: it needs the real Spotify client. Dispatched when the filter is - /// switched on, so the index is usually warm by the time the first batch comes - /// back from the brain; the resolve step builds it inline if it is not. - /// - /// The MCP front door dispatches it too, from `search_tracks`, so that marking - /// results as owned never crawls *inside* a latency-sensitive tool call. Hence - /// the gate is both features rather than `ai-dj` alone. - #[cfg(any(feature = "mcp-server", feature = "ai-dj"))] - DjIndexLibrary, -} - -/// An in-flight in-TUI Spotify login. Holds the exact PKCE client that generated -/// the authorize URL: PKCE stores the `code_verifier` inside the instance, so the -/// token exchange in `complete_spotify_login` MUST run on this same client. -struct PendingLogin { - spotify: AuthCodePkceSpotify, - token_cache_path: PathBuf, -} - -pub struct Network { - /// The authenticated Spotify client, or `None` when spotatui was launched - /// against a free source (YouTube/Subsonic/Radio/Local) without a Spotify - /// session. Spotify-bound `IoEvent`s early-return at the auth gate in - /// `handle_network_event` when this is `None`; the `spotify()` accessor is - /// only reached from handlers that run behind that gate. In-TUI login - /// (`CompleteSpotifyLogin`) fills this in live. - pub spotify: Option, - pub large_search_limit: u32, - pub small_search_limit: u32, - pub client_config: ClientConfig, - pub app: Arc>, - #[cfg(feature = "streaming")] - native_idle_recovery: playback::NativeIdleRecoveryState, - pub party_connection: Option, - pub party_incoming_rx: Option>, - pub token_cache_path: PathBuf, - /// In-flight in-TUI Spotify login, if any (see `begin_spotify_login`). - pending_login: Option, - /// How many playback polls in a row came back 401. Spotify's player service - /// intermittently rejects a valid token (issue #395) and the poll runs once a - /// second against an external device, so a lone 401 is noise; only a sustained - /// run means the session is actually broken and deserves the error route. - /// Reset by every poll the API answers. - consecutive_playback_auth_failures: u8, - /// TTL caches so re-visiting the same artist/album skips the round trip + - /// pacing tax (see `metadata::MetadataTtlCache`). - artist_cache: metadata::MetadataTtlCache, - album_cache: metadata::MetadataTtlCache, - album_tracks_cache: metadata::MetadataTtlCache>, - /// The `Retry-After` window every Spotify call checks: the shared gate in - /// production, a private one in tests. - rate_gate: requests::ForcedRefreshGate, - /// Spotify-bound events held back while that window is open. Only the pump - /// sets `defers_rate_limited`; the CLI has no pump to block and waits inline. - deferred: Vec, - pub(crate) defers_rate_limited: bool, -} - -/// A held-back event and the playback owner it was addressed to. -struct Deferred { - event: IoEvent, - owner: PlaybackOwner, -} - -/// Whether `queued`, the owner at deferral time, still holds the sink under -/// `now`. librespot and an external device are one Spotify owner: a poll can -/// flip between them inside one window. -fn owner_still_owns(queued: PlaybackOwner, now: PlaybackOwner) -> bool { - use PlaybackOwner::{NativeSpotify, Spotify}; - queued == now - || matches!( - (queued, now), - (Spotify | NativeSpotify, Spotify | NativeSpotify) - ) -} - -impl Network { - #[cfg(feature = "streaming")] - pub fn new( - spotify: Option, - client_config: ClientConfig, - app: &Arc>, - token_cache_path: PathBuf, - ) -> Self { - Network { - spotify, - large_search_limit: 50, - small_search_limit: 4, - client_config, - app: Arc::clone(app), - native_idle_recovery: playback::NativeIdleRecoveryState::default(), - party_connection: None, - party_incoming_rx: None, - token_cache_path, - pending_login: None, - consecutive_playback_auth_failures: 0, - artist_cache: Default::default(), - album_cache: Default::default(), - album_tracks_cache: Default::default(), - rate_gate: requests::shared_forced_refresh_gate().clone(), - deferred: Vec::new(), - defers_rate_limited: false, - } - } - - #[cfg(not(feature = "streaming"))] - pub fn new( - spotify: Option, - client_config: ClientConfig, - app: &Arc>, - token_cache_path: PathBuf, - ) -> Self { - Network { - spotify, - large_search_limit: 50, - small_search_limit: 4, - client_config, - app: Arc::clone(app), - party_connection: None, - party_incoming_rx: None, - token_cache_path, - pending_login: None, - consecutive_playback_auth_failures: 0, - artist_cache: Default::default(), - album_cache: Default::default(), - album_tracks_cache: Default::default(), - rate_gate: requests::shared_forced_refresh_gate().clone(), - deferred: Vec::new(), - defers_rate_limited: false, - } - } - - /// Borrow the authenticated Spotify client. Only call this from handlers that - /// run behind the auth gate in `handle_network_event`: that gate early-returns - /// for every Spotify-bound event when `self.spotify` is `None`, so a handler - /// reached past it is guaranteed a live client. The `expect` documents (and - /// enforces at runtime) that invariant; it is unreachable in normal operation. - fn spotify(&self) -> &AuthCodePkceSpotify { - self - .spotify - .as_ref() - .expect("Spotify client present: the auth gate rejects Spotify events when it is None") - } - - /// True for `IoEvent`s whose handlers never call [`Network::spotify`], so they - /// run even without a Spotify session (free-source launch). Keep this in sync - /// with the handlers: an event listed here MUST NOT reach the `spotify()` - /// accessor. Covered here: auth refresh (a no-op when there is no client), - /// source-agnostic services (telemetry, announcements, LRCLIB lyrics, cover - /// art), the spotatui.com friends/party features (their own HTTP/relay - /// clients), pure-state updates, and the per-source browse events that the - /// source dispatchers handle upstream (only reaching here as no-ops when their - /// feature is disabled). - fn event_bypasses_spotify_auth(io_event: &IoEvent) -> bool { - #[cfg(feature = "art-decode")] - if matches!(io_event, IoEvent::FetchCoverArt(_)) { - return true; - } - #[cfg(feature = "streaming")] - if matches!( - io_event, - IoEvent::RestoreNativePlayback(_) | IoEvent::ReplayPublishedSpotifyQueueSlot - ) { - return true; - } - // Bypasses the gate but NOT onto the service lane: the handler needs the real - // Spotify client, and answers an unauthenticated caller itself so whichever - // front door asked — an MCP client or the in-TUI DJ — gets a diagnosable - // error instead of a dropped channel. - #[cfg(any(feature = "mcp-server", feature = "ai-dj"))] - if matches!(io_event, IoEvent::DjToolCall(_)) { - return true; - } - // The brain call needs no Spotify session at all. The tool calls the loop - // makes from inside it do, and those go back through `DjToolCall` above. - #[cfg(feature = "ai-dj")] - if matches!(io_event, IoEvent::AskDj(_) | IoEvent::DjTopUp(..)) { - return true; - } - matches!( - io_event, - IoEvent::RefreshAuthentication - | IoEvent::BeginSpotifyLogin - | IoEvent::CompleteSpotifyLogin(_) - | IoEvent::CancelSpotifyLogin - | IoEvent::FetchGlobalSongCount - | IoEvent::IncrementGlobalSongCount - | IoEvent::FetchAnnouncements - | IoEvent::GetLyrics(..) - | IoEvent::UpdateSearchLimits(..) - | IoEvent::GetFriendCode - | IoEvent::GetFriends - | IoEvent::AddFriendByCode(_) - | IoEvent::AddFriendById(_) - | IoEvent::UnfollowFriend(_) - | IoEvent::SearchFriendUsers(_) - | IoEvent::LoadListeningStats(_) - | IoEvent::GenerateRecap(_) - | IoEvent::StartParty(_) - | IoEvent::JoinParty { .. } - | IoEvent::SetPartyControlMode(_) - | IoEvent::LeaveParty - | IoEvent::SyncPlayback - | IoEvent::PartyPlaybackCommand(_) - | IoEvent::GetLocalPlaylists - | IoEvent::GetLocalTracks(_) - | IoEvent::GetSubsonicPlaylists - | IoEvent::GetSubsonicTracks(_) - | IoEvent::GetSubsonicSearchResults(_) - | IoEvent::GetQobuzPlaylists - | IoEvent::GetQobuzTracks(_) - | IoEvent::GetQobuzSearchResults(_) - | IoEvent::QobuzLogin - | IoEvent::GetRadioStations - | IoEvent::GetRadioSearchResults(_) - | IoEvent::GetYouTubeSearchResults(_) - | IoEvent::GetYouTubePlaylists - | IoEvent::GetYouTubeTracks(_) - | IoEvent::CreateYouTubePlaylist(_) - | IoEvent::DeleteYouTubePlaylist(_) - | IoEvent::AddTrackToYouTubePlaylist(..) - | IoEvent::RemoveTrackFromYouTubePlaylist(..) - ) - } - - /// Events that run on the concurrent service lane in `start_tokio`: a strict - /// subset of [`Self::event_bypasses_spotify_auth`] whose handlers touch only - /// `self.app` (plus their own HTTP clients / `spawn_blocking`) — never the - /// Spotify client, API pacing, or `Network` state (party connection, pending - /// login, search limits) — so they can execute on a detached task with a - /// throwaway `Network` instead of head-of-line-blocking the serial pump. - /// Keep in sync with the handlers: an event listed here MUST NOT read or - /// write anything on `Network` besides `app`. - pub fn runs_on_service_lane(io_event: &IoEvent) -> bool { - if !Self::event_bypasses_spotify_auth(io_event) { - return false; - } - #[cfg(feature = "art-decode")] - if matches!(io_event, IoEvent::FetchCoverArt(_)) { - return true; - } - #[cfg(feature = "ai-dj")] - if matches!(io_event, IoEvent::AskDj(_) | IoEvent::DjTopUp(..)) { - return true; - } - matches!( - io_event, - IoEvent::FetchGlobalSongCount - | IoEvent::IncrementGlobalSongCount - | IoEvent::FetchAnnouncements - | IoEvent::GetLyrics(..) - | IoEvent::GetFriendCode - | IoEvent::GetFriends - | IoEvent::AddFriendByCode(_) - | IoEvent::AddFriendById(_) - | IoEvent::UnfollowFriend(_) - | IoEvent::SearchFriendUsers(_) - | IoEvent::LoadListeningStats(_) - | IoEvent::GenerateRecap(_) - ) - } - - /// Events that drive whoever owns the sink. One held back by a rate-limit - /// window is dropped at the flush when the owner changed meanwhile: replayed, - /// it would reach the player that owned the sink when it was queued. A new - /// transport event goes here too. - pub fn event_is_transport(io_event: &IoEvent) -> bool { - matches!( - io_event, - IoEvent::StartPlayback(..) - | IoEvent::PausePlayback - | IoEvent::NextTrack - | IoEvent::PreviousTrack - | IoEvent::ForcePreviousTrack - | IoEvent::Seek(_) - | IoEvent::Shuffle(_) - | IoEvent::Repeat(_) - | IoEvent::ChangeVolume(_) - | IoEvent::EnsurePlaybackContinues(_) - | IoEvent::ResumeSpotifyContext(..) - | IoEvent::TransferPlaybackToDevice(..) - | IoEvent::AutoSelectStreamingDevice(..) - | IoEvent::ToggleNativeShuffleSession(_) - | IoEvent::ReshuffleNativeShuffleLap - | IoEvent::ResumeNativeShuffleSession(..) - ) - } - - #[allow(clippy::cognitive_complexity)] - pub async fn handle_network_event(&mut self, io_event: IoEvent) { - let pending_playlist_id = match &io_event { - IoEvent::GetPlaylistItems(id, _) => Some(id.clone()), - _ => None, - }; - // Events whose handlers never touch the Spotify client run regardless of - // whether a Spotify session exists (see `event_bypasses_spotify_auth`). - // Everything else is Spotify-bound: when launched against a free source with - // no Spotify session, point the user at the in-TUI login path instead of - // failing loudly; otherwise ensure the token is fresh before proceeding. - let bypass_auth = Self::event_bypasses_spotify_auth(&io_event); - - if !bypass_auth { - if self.spotify.is_none() { - self - .show_status_message(SPOTIFY_NOT_CONNECTED_STATUS.to_string(), 6) - .await; - let mut app = self.app.lock().await; - app.is_loading = false; - app.is_volume_change_in_flight = false; - if pending_playlist_id - .as_deref() - .is_some_and(|id| app.pending_playlist_open.as_deref() == Some(id)) - { - app.pending_playlist_open = None; - } - return; - } - if let Some(left) = self.rate_gate.rate_limit_remaining().await { - if self.defers_rate_limited { - if self.deferred.is_empty() { - let secs = left.as_secs().max(1); - self - .show_status_message(format!("Spotify rate limit: waiting {secs}s"), secs) - .await; - } - let owner = self.app.lock().await.playback_owner(); - self.deferred.push(Deferred { - event: io_event, - owner, - }); - return; - } - tokio::time::sleep(left).await; - } - if !self.ensure_authentication_fresh(false).await { - if let Some(id) = pending_playlist_id.as_deref() { - let mut app = self.app.lock().await; - if app.pending_playlist_open.as_deref() == Some(id) { - app.pending_playlist_open = None; - } - } - return; - } - } - - match io_event { - IoEvent::RefreshAuthentication => { - self.refresh_authentication().await; - } - IoEvent::EnsurePlaybackContinues(previous_track_id) => { - self.ensure_playback_continues(previous_track_id).await; - } - IoEvent::GetPlaylists => { - self.get_current_user_playlists().await; - } - IoEvent::GetUser => { - self.get_user().await; - } - IoEvent::GetDevices => { - self.get_devices(true).await; - } - IoEvent::GetDevicesSilent => { - self.get_devices(false).await; - } - IoEvent::GetCurrentPlayback => { - self.get_current_playback().await; - } - IoEvent::GetSearchResults(search_term, country) => { - self.get_search_results(search_term, country).await; - } - - IoEvent::GetPlaylistItems(playlist_id, playlist_offset) => { - if let Some(id) = ids::playlist_id(&playlist_id) { - self.get_playlist_tracks(id, playlist_offset).await; - } else { - let mut app = self.app.lock().await; - if app.pending_playlist_open.as_deref() == Some(playlist_id.as_str()) { - app.pending_playlist_open = None; - } - app.set_status_message("Invalid playlist identifier.", 5); - } - } - IoEvent::SearchPlaylistTracks(playlist_id, query) => { - if let Some(id) = ids::playlist_id(&playlist_id) { - self.search_playlist_tracks(id, query).await; - } - } - IoEvent::GetCurrentSavedTracks(offset) => { - self.get_current_user_saved_tracks(offset).await; - } - IoEvent::StartPlayback(context_uri, uris, offset) => { - let context = context_uri.as_deref().and_then(ids::play_context_id); - let uris = uris.map(|v| ids::playable_ids(&v)); - self.start_playback(context, uris, offset).await; - } - #[cfg(feature = "streaming")] - IoEvent::RestoreNativePlayback(generation) => { - self.restore_native_playback(generation).await; - } - IoEvent::UpdateSearchLimits(large_search_limit, small_search_limit) => { - self.large_search_limit = large_search_limit; - self.small_search_limit = small_search_limit; - } - IoEvent::Seek(position_ms) => { - self.seek(position_ms).await; - } - IoEvent::NextTrack => { - self.next_track().await; - } - IoEvent::PreviousTrack => { - self.previous_track().await; - } - IoEvent::ForcePreviousTrack => { - self.force_previous_track().await; - } - IoEvent::Repeat(repeat_state) => { - self.repeat(repeat_state).await; - } - IoEvent::PausePlayback => { - self.pause_playback().await; - } - IoEvent::ChangeVolume(volume) => { - self.change_volume(volume).await; - } - IoEvent::GetArtist(artist_id, input_artist_name, country) => { - if let Some(id) = ids::artist_id(&artist_id) { - self.get_artist(id, input_artist_name, country).await; - } - } - IoEvent::GetAlbumTracks(album) => { - self.get_album_tracks(album).await; - } - IoEvent::GetRecommendationsForSeed(seed_artists, seed_tracks, first_track, country) => { - let seed_artists = seed_artists.map(|v| ids::artist_ids(&v)); - let seed_tracks = seed_tracks.map(|v| ids::track_ids(&v)); - self - .get_recommendations_for_seed(seed_artists, seed_tracks, first_track, country) - .await; - } - IoEvent::GetCurrentUserSavedAlbums(offset) => { - self.get_current_user_saved_albums(offset).await; - } - IoEvent::CurrentUserSavedAlbumsContains(album_ids) => { - self - .current_user_saved_albums_contains(ids::album_ids(&album_ids)) - .await; - } - IoEvent::CurrentUserSavedAlbumDelete(album_id) => { - if let Some(id) = ids::album_id(&album_id) { - self.current_user_saved_album_delete(id).await; - } - } - IoEvent::CurrentUserSavedAlbumAdd(album_id) => { - if let Some(id) = ids::album_id(&album_id) { - self.current_user_saved_album_add(id).await; - } - } - IoEvent::UserUnfollowArtists(artist_ids) => { - self - .user_unfollow_artists(ids::artist_ids(&artist_ids)) - .await; - } - IoEvent::UserFollowArtists(artist_ids) => { - self.user_follow_artists(ids::artist_ids(&artist_ids)).await; - } - IoEvent::UserFollowPlaylist(playlist_owner_id, playlist_id, is_public) => { - if let (Some(owner), Some(id)) = ( - ids::user_id(&playlist_owner_id), - ids::playlist_id(&playlist_id), - ) { - self.user_follow_playlist(owner, id, is_public).await; - } - } - IoEvent::UserUnfollowPlaylist(user_id, playlist_id) => { - if let (Some(owner), Some(id)) = (ids::user_id(&user_id), ids::playlist_id(&playlist_id)) { - self.user_unfollow_playlist(owner, id).await; - } - } - IoEvent::AddTrackToPlaylist(playlist_id, track_id) => { - if let (Some(pid), Some(tid)) = (ids::playlist_id(&playlist_id), ids::track_id(&track_id)) { - self.add_track_to_playlist(pid, tid).await; - } - } - IoEvent::RemoveTrackFromPlaylistAtPosition(playlist_id, track_id, position) => { - if let (Some(pid), Some(tid)) = (ids::playlist_id(&playlist_id), ids::track_id(&track_id)) { - self - .remove_track_from_playlist_at_position(pid, tid, position) - .await; - } - } - - IoEvent::ToggleSaveTrack(uri) => { - if let Some(id) = ids::playable_id(&uri) { - self.toggle_save_track(id).await; - } - } - IoEvent::GetRecommendationsForTrackId(track_id, country) => { - if let Some(id) = ids::track_id(&track_id) { - self.get_recommendations_for_track_id(id, country).await; - } - } - IoEvent::GetRecentlyPlayed => { - self.get_recently_played(true).await; - } - IoEvent::GetRecentlyPlayedSilent => { - self.get_recently_played(false).await; - } - IoEvent::GetFollowedArtists(after) => { - self - .get_followed_artists(after.and_then(|s| ids::artist_id(&s))) - .await; - } - IoEvent::UserArtistFollowCheck(artist_ids) => { - self - .user_artist_check_follow(ids::artist_ids(&artist_ids)) - .await; - } - IoEvent::GetAlbum(album_id) => { - if let Some(id) = ids::album_id(&album_id) { - self.get_album(id).await; - } - } - IoEvent::TransferPlaybackToDevice(device_id, persist_device_id) => { - self - .transfert_playback_to_device(device_id, persist_device_id) - .await; - } - #[cfg(feature = "streaming")] - IoEvent::AutoSelectStreamingDevice(device_name, persist_device_id, yield_to_external) => { - self - .auto_select_streaming_device(device_name, persist_device_id, yield_to_external) - .await; - } - #[cfg(not(feature = "streaming"))] - IoEvent::AutoSelectStreamingDevice(..) => {} // No-op without native streaming - IoEvent::GetAlbumForTrack(track_id) => { - if let Some(id) = ids::track_id(&track_id) { - self.get_album_for_track(id).await; - } - } - IoEvent::Shuffle(shuffle_state) => { - self.shuffle(shuffle_state).await; - } - IoEvent::CurrentUserSavedTracksContains(track_ids) => { - self - .current_user_saved_tracks_contains(ids::track_ids(&track_ids)) - .await; - } - IoEvent::GetCurrentUserSavedShows(offset) => { - self.get_current_user_saved_shows(offset).await; - } - IoEvent::CurrentUserSavedShowsContains(show_ids) => { - self - .current_user_saved_shows_contains(ids::show_ids(&show_ids)) - .await; - } - IoEvent::CurrentUserSavedShowDelete(show_id) => { - if let Some(id) = ids::show_id(&show_id) { - self.current_user_saved_shows_delete(id).await; - } - } - IoEvent::CurrentUserSavedShowAdd(show_id) => { - if let Some(id) = ids::show_id(&show_id) { - self.current_user_saved_shows_add(id).await; - } - } - IoEvent::GetShowEpisodes(show) => { - self.get_show_episodes(show).await; - } - IoEvent::GetShow(show_id) => { - if let Some(id) = ids::show_id(&show_id) { - self.get_show(id).await; - } - } - IoEvent::GetCurrentShowEpisodes(show_id, offset) => { - if let Some(id) = ids::show_id(&show_id) { - self.get_current_show_episodes(id, offset).await; - } - } - IoEvent::AddItemToQueue(uri) => { - if let Some(id) = ids::playable_id(&uri) { - self.add_item_to_queue(id).await; - } - } - IoEvent::GetQueue => { - self.get_queue().await; - } - // Consumed by the queue router before it reaches the network; only lands - // here if the router somehow let it through. No Spotify work to do. - IoEvent::AdvanceNativeQueue | IoEvent::FinishNativeQueue => {} - #[cfg(feature = "streaming")] - IoEvent::ReplayPublishedSpotifyQueueSlot => {} - // Consumed by a per-source router when a decoded source owns playback; only - // lands here otherwise (e.g. Spotify is playing). No Spotify work to do. - IoEvent::ReplayCurrentTrack => {} - IoEvent::ResumeSpotifyContext(context_uri, resume_track_uri) => { - self - .resume_spotify_context(context_uri, resume_track_uri) - .await; - } - #[cfg(feature = "streaming")] - IoEvent::ToggleNativeShuffleSession(on) => { - self.toggle_native_shuffle_session(on).await; - } - #[cfg(feature = "streaming")] - IoEvent::ReshuffleNativeShuffleLap => { - self.reshuffle_native_shuffle_lap().await; - } - #[cfg(feature = "streaming")] - IoEvent::ResumeNativeShuffleSession(resume_index, generation) => { - self - .resume_native_shuffle_session(resume_index, generation) - .await; - } - // Only constructed under `streaming`; inert otherwise. - #[cfg(not(feature = "streaming"))] - IoEvent::ToggleNativeShuffleSession(_) - | IoEvent::ReshuffleNativeShuffleLap - | IoEvent::ResumeNativeShuffleSession(_, _) => {} - IoEvent::IncrementGlobalSongCount => { - self.increment_global_song_count().await; - } - IoEvent::FetchGlobalSongCount => { - self.fetch_global_song_count().await; - } - IoEvent::FetchAnnouncements => { - self.fetch_announcements().await; - } - IoEvent::GetLyrics(track, artists, duration) => { - self.get_lyrics(track, artists, duration).await; - } - #[cfg(feature = "art-decode")] - IoEvent::FetchCoverArt(request) => { - self.fetch_cover_art(request).await; - } - #[cfg(any(feature = "mcp-server", feature = "ai-dj"))] - IoEvent::DjToolCall(payload) => { - let (call, responder) = *payload; - self.run_dj_tool_call(call, responder).await; - } - #[cfg(feature = "ai-dj")] - IoEvent::AskDj(request) => { - self.ask_dj(*request).await; - } - #[cfg(feature = "ai-dj")] - IoEvent::DjTopUp(generation, turn_seq) => { - self.dj_top_up(generation, turn_seq).await; - } - #[cfg(any(feature = "mcp-server", feature = "ai-dj"))] - IoEvent::DjIndexLibrary => { - self.dj_index_library().await; - } - IoEvent::GetUserTopTracks(time_range) => { - self.get_user_top_tracks(time_range).await; - } - IoEvent::GetTopArtistsMix => { - self.get_top_artists_mix().await; - } - IoEvent::FetchAllPlaylistTracksAndSort(playlist_id) => { - if let Some(id) = ids::playlist_id(&playlist_id) { - self.fetch_all_playlist_tracks_and_sort(id).await; - } - } - IoEvent::StartParty(control_mode) => { - self.start_party(control_mode).await; - } - IoEvent::JoinParty { code, name } => { - self.join_party(code, name).await; - } - IoEvent::SetPartyControlMode(control_mode) => { - self.set_party_control_mode(control_mode).await; - } - IoEvent::LeaveParty => { - self.leave_party().await; - } - IoEvent::SyncPlayback => { - self.sync_playback().await; - } - IoEvent::PartyPlaybackCommand(action) => { - self.party_playback_command(action).await; - } - IoEvent::SearchTracksForPlaylist(query) => { - self.search_tracks_for_playlist(query).await; - } - IoEvent::CreateNewPlaylist(name, track_ids) => { - self - .create_new_playlist(name, ids::track_ids(&track_ids)) - .await; - } - IoEvent::GetFriendCode => { - friends::handle_get_friend_code(self).await; - } - IoEvent::GetFriends => { - friends::handle_get_friends(self).await; - } - IoEvent::AddFriendByCode(code) => { - friends::handle_add_friend_by_code(self, code).await; - } - IoEvent::AddFriendById(user_id) => { - friends::handle_add_friend_by_user_id(self, user_id).await; - } - IoEvent::UnfollowFriend(user_id) => { - friends::handle_unfollow_friend(self, user_id).await; - } - IoEvent::SearchFriendUsers(query) => { - friends::handle_search_friend_users(self, query).await; - } - IoEvent::LoadListeningStats(period) => { - self.load_listening_stats(period).await; - } - IoEvent::GenerateRecap(period) => { - self.generate_recap(period).await; - } - IoEvent::BeginSpotifyLogin => { - self.begin_spotify_login().await; - } - IoEvent::CompleteSpotifyLogin(callback_url) => { - self.complete_spotify_login(callback_url).await; - } - IoEvent::CancelSpotifyLogin => { - self.cancel_spotify_login().await; - } - // Local-files browse events are handled by infra::local::dispatch before - // reaching the network; they only arrive here when the feature is off. - IoEvent::GetLocalPlaylists | IoEvent::GetLocalTracks(_) => {} - // Subsonic browse/search events are handled by infra::subsonic::dispatch - // before reaching the network; they only arrive here when the feature is off. - IoEvent::GetSubsonicPlaylists - | IoEvent::GetSubsonicTracks(_) - | IoEvent::GetSubsonicSearchResults(_) => {} - // Qobuz browse/search/login events are handled by infra::qobuz::dispatch - // before reaching the network; they only arrive here when the feature is off. - IoEvent::GetQobuzPlaylists - | IoEvent::GetQobuzTracks(_) - | IoEvent::GetQobuzSearchResults(_) - | IoEvent::QobuzLogin => {} - // Radio browse/search events are handled by infra::radio::dispatch before - // reaching the network; they only arrive here when the feature is off. - IoEvent::GetRadioStations | IoEvent::GetRadioSearchResults(_) => {} - // YouTube search/playlist events are handled by infra::youtube::dispatch - // before reaching the network; they only arrive here when the feature is - // off. - IoEvent::GetYouTubeSearchResults(_) - | IoEvent::GetYouTubePlaylists - | IoEvent::GetYouTubeTracks(_) - | IoEvent::CreateYouTubePlaylist(_) - | IoEvent::DeleteYouTubePlaylist(_) - | IoEvent::AddTrackToYouTubePlaylist(..) - | IoEvent::RemoveTrackFromYouTubePlaylist(..) => {} - }; - - { - let mut app = self.app.lock().await; - app.is_loading = false; - } - } - - async fn handle_error(&mut self, e: anyhow::Error) { - let rate_limited = requests::is_rate_limited_error(&e); - // The shared client id's window is shared by every user; only an app of - // the user's own gets out of it. - let e = if rate_limited && self.client_config.client_id == NCSPOT_CLIENT_ID { - anyhow!("{e}. Shared client ID: run spotatui --reconfigure-auth to use your own app") - } else { - e - }; - let mut app = self.app.lock().await; - // The first hit of a window under the pump: every later event is held - // back, so a status message is enough. The CLI keeps its exit signal. - if self.defers_rate_limited && rate_limited { - app.set_error_status_message(e.to_string(), 8); - return; - } - app.handle_error(e); - } - - /// When the pump must flush the held-back events: the window's end. - pub(crate) async fn deferred_deadline(&self) -> Option { - if self.deferred.is_empty() { - return None; - } - let left = self.rate_gate.rate_limit_remaining().await; - Some(std::time::Instant::now() + left.unwrap_or_default()) - } - - /// Re-send the held-back events on the pump's channel in their original - /// order, so the claim gate and the source routers see them; a window that - /// is still open holds them back again. A transport event whose owner - /// changed since it was queued is dropped instead. - pub(crate) async fn flush_deferred(&mut self) { - let (io_tx, owner_now) = { - let app = self.app.lock().await; - (app.io_tx_clone(), app.playback_owner()) - }; - let Some(io_tx) = io_tx else { - return; - }; - for Deferred { event, owner } in std::mem::take(&mut self.deferred) { - if Self::event_is_transport(&event) && !owner_still_owns(owner, owner_now) { - log::debug!("deferred transport event dropped: the sink changed hands"); - let mut app = self.app.lock().await; - app.is_loading = false; - if matches!(event, IoEvent::ChangeVolume(_)) { - app.cancel_volume_change(); - } - continue; - } - let _ = io_tx.send(event); - } - } - - /// Aggregate local listening history for the Stats screen. Reads the - /// listens file off the runtime threads; never touches Spotify. - async fn load_listening_stats(&mut self, period: crate::infra::history::RecapPeriod) { - use crate::infra::history; - - let result = tokio::task::spawn_blocking(move || { - let listens = history::load_listens()?; - let filtered = history::filter_listens_for_period(&listens, period); - Ok::<_, anyhow::Error>(( - history::build_stats_data(&filtered), - history::compute_streaks(&listens), - )) - }) - .await - .map_err(anyhow::Error::from) - .and_then(|result| result); - - let mut app = self.app.lock().await; - match result { - Ok((stats, streaks)) => { - app.listening_streaks = Some(streaks); - // Cycling periods quickly can race two loads; only the response for - // the currently selected period may land. - if app.stats_period == period { - app.stats_data = Some(stats); - app.stats_loading = false; - } - } - Err(error) => { - app.stats_loading = false; - app.handle_error(anyhow!("failed to load listening history: {}", error)); - } - } - } - - /// Export the shareable HTML recap off the runtime threads and open it in - /// the browser; reads only local history, never Spotify. - async fn generate_recap(&mut self, period: crate::infra::history::RecapPeriod) { - use crate::infra::history; - - let result = tokio::task::spawn_blocking(move || { - let output_path = history::recap_output_path()?; - let count = history::export_history_recap(period, &output_path)?; - Ok::<_, anyhow::Error>((output_path, count)) - }) - .await - .map_err(anyhow::Error::from) - .and_then(|result| result); - - match result { - Ok((output_path, count)) => { - if let Err(e) = open::that_detached(&output_path) { - log::warn!("failed to open recap in browser: {}", e); - } - let mut app = self.app.lock().await; - app.set_status_message( - format!( - "Listening recap generated at {} ({} listens)", - output_path.display(), - count - ), - 5, - ); - } - Err(error) => { - let mut app = self.app.lock().await; - app.set_status_message(format!("Failed to generate recap: {}", error), 5); - } - } - } - - async fn show_status_message(&self, message: String, ttl_secs: u64) { - self.app.lock().await.set_status_message(message, ttl_secs); - } - - /// The timed refresh. It fires inside the refresh margin, so `force` is not - /// needed; a stale timer event after another path refreshed is a no-op - /// instead of a second rotation. - async fn refresh_authentication(&mut self) { - self.ensure_authentication_fresh(false).await; - } - - async fn ensure_authentication_fresh(&mut self, force: bool) -> bool { - // No Spotify session (free-source launch): there is no token to refresh. - // Spotify-bound events never reach here in that state because the auth gate - // in `handle_network_event` rejects them first; the only caller that can hit - // this branch is `RefreshAuthentication`, for which a no-op is correct. - let Some(spotify) = self.spotify.as_ref() else { - let mut app = self.app.lock().await; - app.auth_refresh_in_progress = false; - return false; - }; - match auth::refresh_token_and_cache(spotify, &self.token_cache_path, force).await { - Ok(expiry) => { - let mut app = self.app.lock().await; - app.spotify_token_expiry = Some(expiry); - app.auth_refresh_in_progress = false; - app.note_spotify_refresh_succeeded(); - true - } - Err(e) => { - { - let mut app = self.app.lock().await; - app.auth_refresh_in_progress = false; - app.is_loading = false; - app.note_spotify_refresh_failed(); - } - self.handle_error(anyhow!(e)).await; - false - } - } - } - - /// Start an in-TUI Spotify OAuth login: build the PKCE client, open the browser, - /// and spawn a callback server that reports back via `CompleteSpotifyLogin` - /// (or `CancelSpotifyLogin` on timeout/failure). Non-blocking: the UI keeps - /// rendering while the browser round-trips. - async fn begin_spotify_login(&mut self) { - if self.spotify.is_some() { - // Already connected — nothing to log into. - return; - } - if self.pending_login.is_some() { - self - .show_status_message("Spotify login already in progress...".to_string(), 4) - .await; - return; - } - - let config_paths = match self.client_config.get_or_build_paths() { - Ok(paths) => paths, - Err(e) => { - self - .show_status_message(format!("Spotify login setup failed: {e}"), 8) - .await; - return; - } - }; - - let (spotify, authorize_url, port, token_cache_path) = - match auth::prepare_interactive_login(&self.client_config, &config_paths) { - Ok(prepared) => prepared, - Err(e) => { - self - .show_status_message(format!("Spotify login setup failed: {e}"), 8) - .await; - return; - } - }; - - // Bound before the browser opens, see `bind_callback_listener`. - let Ok(listener) = bind_callback_listener(port).await else { - self - .show_status_message( - format!( - "Spotify login failed: could not listen on 127.0.0.1:{port} for the browser callback" - ), - 8, - ) - .await; - return; - }; - - log::info!("[login] authorize URL: {authorize_url}"); - if let Err(e) = open::that_detached(&authorize_url) { - log::warn!("[login] failed to open browser automatically: {e}"); - self - .show_status_message( - format!("Open this URL in your browser to log in to Spotify: {authorize_url}"), - 30, - ) - .await; - } else { - self - .show_status_message("Opening browser to log in to Spotify...".to_string(), 12) - .await; - } - - // Keep the PKCE client on `self`: PKCE stores the code_verifier inside it and - // the token exchange in `complete_spotify_login` must reuse this instance. - self.pending_login = Some(PendingLogin { - spotify, - token_cache_path, - }); - - // Drive the callback server off a spawned task so the pump/UI stay responsive. - // The task carries only the sender + listener, never the client. - let io_tx = self.app.lock().await.io_tx_clone(); - let Some(io_tx) = io_tx else { - return; - }; - tokio::spawn(async move { - let overall_timeout = Duration::from_secs(180); - match tokio::time::timeout(overall_timeout, serve_spotify_callback(listener)).await { - Ok(Ok(url)) => { - let _ = io_tx.send(IoEvent::CompleteSpotifyLogin(url)); - } - Ok(Err(())) => { - log::warn!("[login] callback server failed"); - let _ = io_tx.send(IoEvent::CancelSpotifyLogin); - } - Err(_) => { - log::warn!( - "[login] login timed out after {}s", - overall_timeout.as_secs() - ); - let _ = io_tx.send(IoEvent::CancelSpotifyLogin); - } - } - }); - } - - /// Finish an in-TUI Spotify login from the OAuth callback URL. The token - /// exchange runs on the SAME PKCE client that produced the authorize URL. - /// Native streaming still requires a restart (its init happens pre-TUI). - async fn complete_spotify_login(&mut self, callback_url: String) { - let Some(pending) = self.pending_login.take() else { - return; - }; - let PendingLogin { - spotify, - token_cache_path, - } = pending; - - let Some(code) = spotify.parse_response_code(&callback_url) else { - self - .show_status_message("Spotify login failed: invalid callback URL.".to_string(), 8) - .await; - return; - }; - - if let Err(e) = spotify.request_token(&code).await { - self - .show_status_message(format!("Spotify login failed: {e}"), 8) - .await; - return; - } - - if let Err(e) = auth::save_token_to_file(&spotify, &token_cache_path).await { - log::warn!("[login] failed to cache token after login: {e}"); - } - let expiry = auth::token_expiry(&spotify).await.ok(); - - // A brand-new token family is in play, so the previous session's - // forced-refresh cooldown carries no information about it. - requests::reset_forced_refresh_cooldown().await; - - self.spotify = Some(spotify); - self.token_cache_path = token_cache_path; - { - let mut app = self.app.lock().await; - app.spotify_token_expiry = expiry; - app.spotify_connected = true; - if app.active_source == crate::core::source::Source::Spotify { - app.persist_active_source(); - } - // Load Spotify data now that a session exists. - app.dispatch(IoEvent::GetUser); - app.dispatch(IoEvent::GetPlaylists); - app.dispatch(IoEvent::GetCurrentPlayback); - } - self - .show_status_message( - "Spotify connected. Restart spotatui to enable native playback.".to_string(), - 10, - ) - .await; - } - - /// Clear an abandoned in-TUI login (callback timed out or failed) so the user - /// can retry. - async fn cancel_spotify_login(&mut self) { - if self.pending_login.take().is_some() { - self - .show_status_message( - "Spotify login timed out. Press `d` and pick Spotify to try again.".to_string(), - 6, - ) - .await; - } - } - - async fn start_party(&mut self, control_mode: sync::ControlMode) { - // The event bypasses the auth gate, so the handler carries the requirement - // itself: the relay drives Spotify playback, and opening the socket first - // would leave a live party the drain has to close again. - if self.spotify.is_none() { - self - .show_status_message(SPOTIFY_NOT_CONNECTED_STATUS.to_string(), 6) - .await; - return; - } - { - let mut app = self.app.lock().await; - app.party_status = sync::PartyStatus::Connecting; - } - - let relay_url = { - let app = self.app.lock().await; - app.user_config.behavior.relay_server_url.clone() - }; - - let mode_str = match &control_mode { - sync::ControlMode::HostOnly => "host_only", - sync::ControlMode::SharedControl => "shared_control", - }; - - match sync::connect_to_relay(&relay_url, "create", &[("control_mode", mode_str)]).await { - Ok((conn, read)) => { - let (incoming_tx, incoming_rx) = tokio::sync::mpsc::unbounded_channel(); - tokio::spawn(sync::start_party_reader(read, incoming_tx)); - self.party_connection = Some(conn); - self.party_incoming_rx = Some(incoming_rx); - - let mut app = self.app.lock().await; - app.party_status = sync::PartyStatus::Hosting; - app.party_session = Some(sync::PartySession { - role: sync::PartyRole::Host, - code: String::new(), - guests: Vec::new(), - control_mode, - host_name: "Host".to_string(), - }); - } - Err(e) => { - let mut app = self.app.lock().await; - app.party_status = sync::PartyStatus::Disconnected; - app.handle_error(anyhow!("Failed to start party: {}", e)); - } - } - } - - async fn join_party(&mut self, code: String, name: String) { - // Same requirement as `start_party`. - if self.spotify.is_none() { - self - .show_status_message(SPOTIFY_NOT_CONNECTED_STATUS.to_string(), 6) - .await; - return; - } - { - let mut app = self.app.lock().await; - app.party_status = sync::PartyStatus::Connecting; - } - - let relay_url = { - let app = self.app.lock().await; - app.user_config.behavior.relay_server_url.clone() - }; - - match sync::connect_to_relay(&relay_url, "join", &[("code", &code), ("name", &name)]).await { - Ok((conn, read)) => { - let (incoming_tx, incoming_rx) = tokio::sync::mpsc::unbounded_channel(); - tokio::spawn(sync::start_party_reader(read, incoming_tx)); - self.party_connection = Some(conn); - self.party_incoming_rx = Some(incoming_rx); - - let mut app = self.app.lock().await; - app.party_status = sync::PartyStatus::Joined; - app.party_session = Some(sync::PartySession { - role: sync::PartyRole::Guest, - code: code.to_uppercase(), - guests: Vec::new(), - control_mode: sync::ControlMode::default(), - host_name: String::new(), - }); - } - Err(e) => { - let mut app = self.app.lock().await; - app.party_status = sync::PartyStatus::Disconnected; - app.handle_error(anyhow!("Failed to join party: {}", e)); - } - } - } - - async fn leave_party(&mut self) { - if let Some(conn) = &mut self.party_connection { - conn.close().await; - } - self.party_connection = None; - self.party_incoming_rx = None; - - let mut app = self.app.lock().await; - app.party_status = sync::PartyStatus::Disconnected; - app.party_session = None; - } - - async fn sync_playback(&mut self) { - let sync_state = { - let app = self.app.lock().await; - let session = match &app.party_session { - Some(s) if s.role == sync::PartyRole::Host => s, - _ => return, - }; - let _ = session; - // Publish only what a guest can follow: the same owner rule as the - // command relay, and a Spotify URI (a native `spotify:local:` track has none). - if party_yields_to_local_playback(&app) { - return; - } - let Some(snapshot) = crate::infra::media_metadata::current_playback_snapshot(&app) else { - return; - }; - let Some(track_uri) = snapshot - .item_uri - .and_then(|uri| ids::playable_id(&uri).map(|id| id.uri())) - else { - return; - }; - - sync::SyncMessage::SyncState { - track_uri, - position_ms: snapshot.progress_ms as u64, - is_playing: snapshot.is_playing, - timestamp: sync::now_ms(), - } - }; - - if let Some(conn) = &mut self.party_connection { - if let Err(e) = conn.send(&sync_state).await { - log::error!("Failed to send sync state: {}", e); - } - } - } - - async fn set_party_control_mode(&mut self, control_mode: sync::ControlMode) { - let control_mode = match control_mode { - sync::ControlMode::HostOnly => "host_only", - sync::ControlMode::SharedControl => "shared_control", - }; - - let msg = sync::SyncMessage::SetControlMode { - control_mode: control_mode.to_string(), - }; - - if let Some(conn) = &mut self.party_connection { - if let Err(e) = conn.send(&msg).await { - log::error!("Failed to send control mode update: {}", e); - } - } - } - - async fn party_playback_command(&mut self, action: sync::PlaybackAction) { - let msg = sync::SyncMessage::PlaybackCommand { action, from: None }; - if let Some(conn) = &mut self.party_connection { - if let Err(e) = conn.send(&msg).await { - log::error!("Failed to send playback command: {}", e); - } - } - } - - pub async fn process_party_messages(&mut self) { - // Every relay handler below drives the Spotify client; without a session - // there is nothing to sync and `spotify()` would panic the pump. Close the - // party instead of returning, so an unread receiver cannot grow without a - // bound. The guard keeps the common no-party drain to one comparison. - if self.spotify.is_none() { - if self.party_connection.is_some() || self.party_incoming_rx.is_some() { - self.leave_party().await; - self - .show_status_message( - "Listening Party ended: Spotify is not connected.".to_string(), - 6, - ) - .await; - } - return; - } - let messages: Vec = { - match &mut self.party_incoming_rx { - Some(rx) => { - let mut msgs = Vec::new(); - while let Ok(msg) = rx.try_recv() { - msgs.push(msg); - } - msgs - } - None => return, - } - }; - - let mut latest_state = None; - for msg in messages { - match msg { - sync::SyncMessage::RoomCreated { code, .. } => { - let mut app = self.app.lock().await; - if let Some(session) = &mut app.party_session { - session.code = code; - } - } - sync::SyncMessage::JoinedRoom { host_name } => { - let mut app = self.app.lock().await; - if let Some(session) = &mut app.party_session { - session.host_name = host_name; - } - } - sync::SyncMessage::GuestJoined { name } => { - let mut app = self.app.lock().await; - if let Some(session) = &mut app.party_session { - if !session.guests.contains(&name) { - session.guests.push(name.clone()); - } - } - app.set_status_message(format!("{} joined the party", name), 3); - } - sync::SyncMessage::GuestLeft { name } => { - let mut app = self.app.lock().await; - if let Some(session) = &mut app.party_session { - if let Some(pos) = session.guests.iter().position(|g| g == &name) { - session.guests.remove(pos); - } - } - app.set_status_message(format!("{} left the party", name), 3); - } - sync::SyncMessage::SetControlMode { control_mode } => { - let mut app = self.app.lock().await; - if let Some(session) = &mut app.party_session { - session.control_mode = match control_mode.as_str() { - "shared_control" => sync::ControlMode::SharedControl, - _ => sync::ControlMode::HostOnly, - }; - } - } - // Only the newest host state in a drain counts: each earlier one - // would start the track again before the pump ran the first start. - state @ sync::SyncMessage::SyncState { .. } => latest_state = Some(state), - sync::SyncMessage::PlaybackCommand { action, .. } => { - self.handle_incoming_playback_command(action).await; - } - sync::SyncMessage::RoomClosed => { - self.party_connection = None; - let mut app = self.app.lock().await; - app.party_status = sync::PartyStatus::Disconnected; - app.party_session = None; - app.set_status_message("Party ended".to_string(), 5); - } - sync::SyncMessage::Error { message } => { - self.party_connection = None; - self.party_incoming_rx = None; - let mut app = self.app.lock().await; - app.party_status = sync::PartyStatus::Disconnected; - app.party_session = None; - app.handle_error(anyhow!("Party: {}", message)); - } - _ => {} - } - } - if let Some(sync::SyncMessage::SyncState { - track_uri, - position_ms, - is_playing, - timestamp, - }) = latest_state - { - self - .handle_incoming_sync_state(track_uri, position_ms, is_playing, timestamp) - .await; - } - } - - async fn handle_incoming_sync_state( - &mut self, - track_uri: String, - position_ms: u64, - is_playing: bool, - timestamp: u64, - ) { - // The canonical URI: a bare id would compare unequal to the current - // track's URI and restart it on every state. - let Some(track_uri) = ids::playable_id(&track_uri).map(|id| id.uri()) else { - return; - }; - let mut app = self.app.lock().await; - let follows_host = matches!( - &app.party_session, - Some(s) if s.role == sync::PartyRole::Guest - ) && !party_yields_to_local_playback(&app); - if !follows_host { - return; - } - - // Latency compensation: estimate how much time passed since the host sent this state - let now = sync::now_ms(); - let transit_ms = if now > timestamp { - (now - timestamp).min(5000) // cap at 5s to avoid wild jumps from clock skew - } else { - 0 - }; - let compensated_position = if is_playing { - position_ms.saturating_add(transit_ms) - } else { - position_ms - }; - - let (current_uri, current_is_playing, current_progress) = { - let uri = match &app.current_playback_context { - Some(ctx) => match &ctx.item { - Some(rspotify::model::PlayableItem::Track(t)) => { - t.id.as_ref().map(|id| id.uri()).unwrap_or_default() - } - Some(rspotify::model::PlayableItem::Episode(e)) => e.id.uri(), - Some(_) | None => String::new(), - }, - None => String::new(), - }; - let playing = app - .current_playback_context - .as_ref() - .map(|c| c.is_playing) - .unwrap_or(false); - let progress = app.song_progress_ms as u64; - (uri, playing, progress) - }; - - // Track change takes priority - let switched_track = current_uri != track_uri; - if switched_track { - app.start_playback_uris(vec![track_uri], None); - } - - // Play/pause sync - // After a track switch, explicitly apply host pause state since starting playback may - // begin playing even when host is paused. - if (switched_track && !is_playing) || (!switched_track && current_is_playing != is_playing) { - if is_playing { - app.dispatch(IoEvent::StartPlayback(None, None, None)); - } else { - app.dispatch(IoEvent::PausePlayback); - } - } - - // Position drift correction (>3s triggers seek) - let drift = current_progress.abs_diff(compensated_position); - - if drift > 3000 && !switched_track { - if let Ok(position_ms) = u32::try_from(compensated_position) { - app.dispatch(IoEvent::Seek(position_ms)); - } - } - } - - async fn handle_incoming_playback_command(&mut self, action: sync::PlaybackAction) { - let mut app = self.app.lock().await; - let relays = matches!( - &app.party_session, - Some(s) if s.role == sync::PartyRole::Host - ) && !party_yields_to_local_playback(&app); - if !relays { - return; - } - - // Plain Spotify events, not the `App` key chains: a host's Next through - // `App::next_track` would hand the sink to its own queue and lock the - // party out. - match action { - sync::PlaybackAction::Play => app.dispatch(IoEvent::StartPlayback(None, None, None)), - sync::PlaybackAction::Pause => app.dispatch(IoEvent::PausePlayback), - sync::PlaybackAction::NextTrack => app.dispatch(IoEvent::NextTrack), - sync::PlaybackAction::PrevTrack => app.dispatch(IoEvent::PreviousTrack), - sync::PlaybackAction::Seek { position_ms } => { - if let Ok(position_ms) = u32::try_from(position_ms) { - app.dispatch(IoEvent::Seek(position_ms)); - } - } - sync::PlaybackAction::PlayTrack { uri } => { - if let Some(uri) = ids::playable_id(&uri).map(|id| id.uri()) { - app.start_playback_uris(vec![uri], None); - } - } - } - - // Queued behind the command on the serial pump; the 2 s tick repeats it. - app.dispatch(IoEvent::SyncPlayback); - } -} - -/// The party follows Spotify transport only. Coarser than the transport -/// guard on purpose: a queued Spotify track keeps librespot, but a guest must -/// not drive the host's queue slot. -fn party_yields_to_local_playback(app: &App) -> bool { - matches!( - app.playback_owner(), - PlaybackOwner::Decoded | PlaybackOwner::Queue - ) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::app::App; - use crate::core::config::ClientConfig; - use crate::core::user_config::UserConfig; - use chrono::{TimeDelta, Utc}; - use rspotify::{Config, Credentials, OAuth, Token}; - use std::time::SystemTime; - - async fn spotify_with_token(token: Token) -> AuthCodePkceSpotify { - let spotify = AuthCodePkceSpotify::with_config( - Credentials::new_pkce("test_client_id"), - OAuth { - redirect_uri: "http://localhost:8888/callback".to_string(), - ..Default::default() - }, - Config::default(), - ); - - let mut token_lock = spotify.token.lock().await.expect("Failed to lock token"); - *token_lock = Some(token); - drop(token_lock); - - spotify - } - - fn temp_token_cache_path() -> PathBuf { - std::env::temp_dir().join(format!( - "spotatui_network_test_token_{}.json", - rand::random::() - )) - } - - #[test] - fn every_service_lane_event_bypasses_spotify_auth() { - use crate::infra::history::RecapPeriod; - let events = [ - IoEvent::FetchGlobalSongCount, - IoEvent::IncrementGlobalSongCount, - IoEvent::FetchAnnouncements, - IoEvent::GetLyrics(String::new(), Vec::new(), 0.0), - IoEvent::GetFriendCode, - IoEvent::GetFriends, - IoEvent::AddFriendByCode(String::new()), - IoEvent::AddFriendById(String::new()), - IoEvent::UnfollowFriend(String::new()), - IoEvent::SearchFriendUsers(String::new()), - IoEvent::LoadListeningStats(RecapPeriod::SevenDays), - IoEvent::GenerateRecap(RecapPeriod::SevenDays), - ]; - for event in events { - assert!(Network::runs_on_service_lane(&event)); - assert!(Network::event_bypasses_spotify_auth(&event)); - } - - // The DJ's two service-lane events, which the array above cannot hold: they - // only exist under `ai-dj`. They are the whole reason this drift matters — - // a brain call left on the serial pump blocks every other event for minutes. - #[cfg(feature = "ai-dj")] - for event in [ - IoEvent::AskDj(Box::new(crate::infra::dj::AskDjRequest { - extra_instruction: None, - generation: 0, - must_act: false, - turn_seq: 0, - vibe_on_success: None, - })), - IoEvent::DjTopUp(0, 0), - ] { - assert!(Network::runs_on_service_lane(&event)); - assert!(Network::event_bypasses_spotify_auth(&event)); - } - } - - /// The queue router normally consumes this event first. If that invariant is - /// ever broken, its network fallback is still a no-op and must not trigger a - /// token refresh or login prompt before being discarded. - #[cfg(feature = "streaming")] - #[test] - fn queue_slot_replay_fallback_bypasses_auth_but_stays_serial() { - let event = IoEvent::ReplayPublishedSpotifyQueueSlot; - assert!(Network::event_bypasses_spotify_auth(&event)); - assert!( - !Network::runs_on_service_lane(&event), - "the queue router owns replay ordering on the serial event pump" - ); - } - - /// `DjToolCall` is the one event that bypasses the auth gate *without* moving - /// onto the service lane, so it gets its own assertion. - /// - /// Both halves matter. It must stay on the serial lane because resolving a - /// track name needs the real Spotify client, and the service lane builds its - /// `Network` with `None` for it. It must bypass the gate so the handler can - /// answer an unauthenticated caller with a diagnosable message instead of - /// dropping the `oneshot`. Both front doors block on that channel: an MCP - /// client through the server, and the in-TUI DJ through its own tool loop. - #[cfg(any(feature = "mcp-server", feature = "ai-dj"))] - #[test] - fn dj_tool_calls_bypass_auth_but_stay_on_the_serial_lane() { - let (tx, _rx) = tokio::sync::oneshot::channel(); - let event = IoEvent::DjToolCall(Box::new(( - crate::infra::dj::tools::DjToolCall::GetNowPlaying, - tx, - ))); - assert!(Network::event_bypasses_spotify_auth(&event)); - assert!( - !Network::runs_on_service_lane(&event), - "the service lane has no Spotify client, so the resolver could not run there" - ); - } - - /// The queue router consumes both native-queue control events before the - /// network sees them, so neither is classified as a bypass or a lane move. - #[test] - fn native_queue_control_events_stay_serial_and_behind_the_auth_gate() { - for event in [IoEvent::AdvanceNativeQueue, IoEvent::FinishNativeQueue] { - assert!(!Network::runs_on_service_lane(&event)); - assert!(!Network::event_bypasses_spotify_auth(&event)); - } - } - - #[tokio::test] - async fn pre_event_auth_failure_clears_loading_state() { - let expired_token_without_refresh = Token { - access_token: "expired_access_token".to_string(), - refresh_token: None, - expires_in: TimeDelta::seconds(3600), - expires_at: Some(Utc::now() - TimeDelta::seconds(60)), - scopes: Default::default(), - }; - let spotify = spotify_with_token(expired_token_without_refresh).await; - let token_cache_path = temp_token_cache_path(); - let (io_tx, _io_rx) = std::sync::mpsc::channel(); - let app = Arc::new(Mutex::new(App::new( - io_tx, - UserConfig::new(), - Some(SystemTime::now() - Duration::from_secs(60)), - ))); - - { - let mut app = app.lock().await; - app.is_loading = true; - app.auth_refresh_in_progress = true; - } - - let mut network = Network::new( - Some(spotify), - ClientConfig::new(), - &app, - token_cache_path.clone(), - ); - network.handle_network_event(IoEvent::GetUser).await; - - let app = app.lock().await; - assert!(!app.is_loading); - assert!(!app.auth_refresh_in_progress); - - let _ = std::fs::remove_file(token_cache_path); - } - - #[tokio::test] - async fn auth_gate_clears_pending_playlist_open() { - let (io_tx, _io_rx) = std::sync::mpsc::channel(); - let app = Arc::new(Mutex::new(App::new( - io_tx, - UserConfig::new(), - Some(SystemTime::now()), - ))); - app.lock().await.pending_playlist_open = Some("playlist-id".to_string()); - let mut network = Network::new(None, ClientConfig::new(), &app, temp_token_cache_path()); - - network - .handle_network_event(IoEvent::GetPlaylistItems("playlist-id".to_string(), 0)) - .await; - - assert!(app.lock().await.pending_playlist_open.is_none()); - } - - fn session_free_network(app: &Arc>) -> Network { - Network::new(None, ClientConfig::new(), app, temp_token_cache_path()) - } - - /// A pump-mode network with a session and a private, open rate-limit window. - async fn rate_limited_pump_network(app: &Arc>) -> Network { - let mut network = session_free_network(app); - network.spotify = Some(AuthCodePkceSpotify::new( - Credentials::default(), - OAuth::default(), - )); - network.defers_rate_limited = true; - network.rate_gate = requests::ForcedRefreshGate::default(); - network - .rate_gate - .rate_limit_for(Duration::from_secs(30)) - .await; - network - } - - /// An app with a session whose pump channel the test can read. - fn app_with_a_session_and_channel() -> (Arc>, std::sync::mpsc::Receiver) { - let (io_tx, io_rx) = std::sync::mpsc::channel(); - let app = App::new(io_tx, UserConfig::new(), Some(SystemTime::now())); - (Arc::new(Mutex::new(app)), io_rx) - } - - #[tokio::test] - async fn a_rate_limit_window_holds_spotify_bound_events_back_in_order() { - let (app, io_rx) = app_with_a_session_and_channel(); - let mut network = rate_limited_pump_network(&app).await; - - network.handle_network_event(IoEvent::GetPlaylists).await; - network.handle_network_event(IoEvent::GetUser).await; - assert!(network.deferred_deadline().await.is_some()); - network.flush_deferred().await; - - assert!(network.deferred.is_empty()); - assert!(matches!(io_rx.try_recv(), Ok(IoEvent::GetPlaylists))); - assert!(matches!(io_rx.try_recv(), Ok(IoEvent::GetUser))); - assert!(io_rx.try_recv().is_err()); - let app = app.lock().await; - assert!(app - .status_message() - .is_some_and(|m| m.contains("rate limit"))); - } - - #[cfg(feature = "streaming")] - #[tokio::test] - async fn a_held_back_transport_event_is_dropped_when_the_sink_changed_hands() { - let (app, io_rx) = app_with_a_session_and_channel(); - let mut network = rate_limited_pump_network(&app).await; - - network - .handle_network_event(IoEvent::StartPlayback( - Some("spotify:album:parked".to_string()), - None, - None, - )) - .await; - network.handle_network_event(IoEvent::Seek(5_000)).await; - network - .handle_network_event(IoEvent::ToggleSaveTrack("t".to_string())) - .await; - app.lock().await.queue_now = Some(crate::infra::queue::QueueNowPlaying::Spotify { - track: crate::core::test_helpers::queued_track("spotify:track:queued", "Queued"), - }); - network.flush_deferred().await; - - assert!(matches!(io_rx.try_recv(), Ok(IoEvent::ToggleSaveTrack(_)))); - assert!(io_rx.try_recv().is_err()); - assert!(!app.lock().await.is_loading); - } - - #[cfg(feature = "streaming")] - #[tokio::test] - async fn a_dropped_volume_change_releases_the_volume_latches() { - let (app, io_rx) = app_with_a_session_and_channel(); - let mut network = rate_limited_pump_network(&app).await; - { - let mut app = app.lock().await; - app.pending_volume = Some(50); - app.last_dispatched_volume = Some(50); - app.is_volume_change_in_flight = true; - } - - network - .handle_network_event(IoEvent::ChangeVolume(50)) - .await; - app.lock().await.queue_now = Some(crate::infra::queue::QueueNowPlaying::Spotify { - track: crate::core::test_helpers::queued_track("spotify:track:queued", "Queued"), - }); - network.flush_deferred().await; - - assert!(io_rx.try_recv().is_err()); - let app = app.lock().await; - assert!(!app.is_volume_change_in_flight); - assert!(app.pending_volume.is_none()); - assert!(app.last_dispatched_volume.is_none()); - } - - #[test] - fn librespot_and_an_external_device_are_one_spotify_owner_for_the_replay() { - use PlaybackOwner::{Decoded, NativeSpotify, Queue, Spotify}; - assert!(owner_still_owns(Spotify, NativeSpotify)); - assert!(owner_still_owns(NativeSpotify, Spotify)); - assert!(owner_still_owns(Queue, Queue)); - assert!(!owner_still_owns(Spotify, Queue)); - assert!(!owner_still_owns(Spotify, Decoded)); - } - - #[tokio::test] - async fn a_rate_limit_error_under_the_pump_is_a_status_message_not_the_error_page() { - let app = app_without_a_session(); - let mut network = rate_limited_pump_network(&app).await; - - network - .handle_error(anyhow!( - "Spotify API 429 Too Many Requests failed: retry in 17s" - )) - .await; - - let app = app.lock().await; - assert!(app.api_error().is_empty()); - assert!(app.status_message_is_error()); - } - - #[tokio::test] - async fn a_rate_limit_on_the_shared_client_id_names_the_way_out() { - let app = app_without_a_session(); - let mut network = rate_limited_pump_network(&app).await; - network.client_config.client_id = NCSPOT_CLIENT_ID.to_string(); - - network - .handle_error(anyhow!( - "Spotify API 429 Too Many Requests failed: retry in 17s" - )) - .await; - - let app = app.lock().await; - assert!(app - .status_message() - .is_some_and(|m| m.contains("retry in 17s") && m.contains("--reconfigure-auth"))); - } - - #[tokio::test] - async fn a_rate_limit_error_without_the_pump_keeps_the_cli_exit_signal() { - let app = app_without_a_session(); - let mut network = session_free_network(&app); - - network - .handle_error(anyhow!( - "Spotify API 429 Too Many Requests failed: retry in 17s" - )) - .await; - - assert!(!app.lock().await.api_error().is_empty()); - } - - fn app_without_a_session() -> Arc> { - let (io_tx, _io_rx) = std::sync::mpsc::channel(); - Arc::new(Mutex::new(App::new(io_tx, UserConfig::new(), None))) - } - - #[tokio::test] - async fn start_party_without_a_session_opens_no_relay() { - let app = app_without_a_session(); - let mut network = session_free_network(&app); - - network.start_party(sync::ControlMode::HostOnly).await; - - assert!(network.party_connection.is_none()); - assert!(network.party_incoming_rx.is_none()); - let app = app.lock().await; - assert_eq!(app.party_status, sync::PartyStatus::Disconnected); - assert_eq!(app.status_message(), Some(SPOTIFY_NOT_CONNECTED_STATUS)); - } - - #[tokio::test] - async fn join_party_without_a_session_opens_no_relay() { - let app = app_without_a_session(); - let mut network = session_free_network(&app); - - network - .join_party("ABC123".to_string(), "Guest".to_string()) - .await; - - assert!(network.party_connection.is_none()); - assert!(network.party_incoming_rx.is_none()); - let app = app.lock().await; - assert_eq!(app.party_status, sync::PartyStatus::Disconnected); - assert_eq!(app.status_message(), Some(SPOTIFY_NOT_CONNECTED_STATUS)); - } - - #[tokio::test] - async fn process_party_messages_closes_a_party_that_outlived_its_session() { - let app = app_without_a_session(); - let mut network = session_free_network(&app); - let (_tx, rx) = tokio::sync::mpsc::unbounded_channel(); - network.party_incoming_rx = Some(rx); - { - let mut app = app.lock().await; - app.party_status = sync::PartyStatus::Hosting; - app.party_session = Some(party_session(sync::PartyRole::Host)); - } - - network.process_party_messages().await; - - assert!(network.party_incoming_rx.is_none()); - let app = app.lock().await; - assert_eq!(app.party_status, sync::PartyStatus::Disconnected); - assert!(app.party_session.is_none()); - } - - fn party_session(role: sync::PartyRole) -> sync::PartySession { - sync::PartySession { - role, - code: "ABC123".to_string(), - guests: Vec::new(), - control_mode: sync::ControlMode::HostOnly, - host_name: "Host".to_string(), - } - } - - fn app_with_a_session() -> (Arc>, std::sync::mpsc::Receiver) { - let (io_tx, io_rx) = std::sync::mpsc::channel(); - let app = App::new(io_tx, UserConfig::new(), Some(SystemTime::now())); - (Arc::new(Mutex::new(app)), io_rx) - } - - /// A party member whose Spotify client is never called: the relay dispatches. - async fn party_network(app: &Arc>, role: sync::PartyRole) -> Network { - let mut network = session_free_network(app); - network.spotify = Some(AuthCodePkceSpotify::new( - Credentials::default(), - OAuth::default(), - )); - app.lock().await.party_session = Some(party_session(role)); - network - } - - async fn relay(network: &mut Network, message: sync::SyncMessage) { - let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); - tx.send(message).unwrap(); - network.party_incoming_rx = Some(rx); - network.process_party_messages().await; - } - - const HOST_TRACK: &str = "spotify:track:0000000000000000000001"; - - fn host_state() -> sync::SyncMessage { - sync::SyncMessage::SyncState { - track_uri: HOST_TRACK.to_string(), - position_ms: 0, - is_playing: true, - timestamp: sync::now_ms(), - } - } - - fn guest_pause() -> sync::SyncMessage { - sync::SyncMessage::PlaybackCommand { - action: sync::PlaybackAction::Pause, - from: None, - } - } - - #[tokio::test] - async fn a_guest_follows_the_host_through_a_dispatched_start() { - let (app, rx) = app_with_a_session(); - let mut network = party_network(&app, sync::PartyRole::Guest).await; - - relay(&mut network, host_state()).await; - - assert!(matches!( - rx.try_recv(), - Ok(IoEvent::StartPlayback(None, Some(uris), None)) if uris == [HOST_TRACK] - )); - assert!(rx.try_recv().is_err()); - } - - #[tokio::test] - async fn a_bare_track_id_from_the_host_starts_the_full_uri() { - let (app, rx) = app_with_a_session(); - let mut network = party_network(&app, sync::PartyRole::Guest).await; - - let mut state = host_state(); - if let sync::SyncMessage::SyncState { track_uri, .. } = &mut state { - *track_uri = "0000000000000000000001".to_string(); - } - relay(&mut network, state).await; - - assert!(matches!( - rx.try_recv(), - Ok(IoEvent::StartPlayback(None, Some(uris), None)) if uris == [HOST_TRACK] - )); - assert!(rx.try_recv().is_err()); - } - - #[tokio::test] - async fn two_host_states_in_one_drain_start_the_track_once() { - let (app, rx) = app_with_a_session(); - let mut network = party_network(&app, sync::PartyRole::Guest).await; - - let (tx, incoming) = tokio::sync::mpsc::unbounded_channel(); - tx.send(host_state()).unwrap(); - let mut newest = host_state(); - if let sync::SyncMessage::SyncState { track_uri, .. } = &mut newest { - *track_uri = "spotify:track:0000000000000000000002".to_string(); - } - tx.send(newest).unwrap(); - network.party_incoming_rx = Some(incoming); - network.process_party_messages().await; - - assert!(matches!( - rx.try_recv(), - Ok(IoEvent::StartPlayback(None, Some(uris), None)) - if uris == ["spotify:track:0000000000000000000002"] - )); - assert!(rx.try_recv().is_err()); - } - - #[tokio::test] - async fn a_guest_ignores_a_host_state_it_cannot_play() { - let (app, rx) = app_with_a_session(); - let mut network = party_network(&app, sync::PartyRole::Guest).await; - - let mut state = host_state(); - if let sync::SyncMessage::SyncState { track_uri, .. } = &mut state { - *track_uri = "qobuz:track:1".to_string(); - } - relay(&mut network, state).await; - - assert!(rx.try_recv().is_err()); - } - - #[tokio::test] - async fn an_oversized_party_seek_is_dropped() { - let (app, rx) = app_with_a_session(); - let mut network = party_network(&app, sync::PartyRole::Host).await; - - relay( - &mut network, - sync::SyncMessage::PlaybackCommand { - action: sync::PlaybackAction::Seek { - position_ms: u64::MAX, - }, - from: None, - }, - ) - .await; - - assert!(matches!(rx.try_recv(), Ok(IoEvent::SyncPlayback))); - assert!(rx.try_recv().is_err()); - - app.lock().await.party_session.as_mut().unwrap().role = sync::PartyRole::Guest; - let mut state = host_state(); - if let sync::SyncMessage::SyncState { - position_ms, - timestamp, - .. - } = &mut state - { - *position_ms = u64::MAX; - *timestamp = 0; - } - relay(&mut network, state).await; - - assert!(matches!( - rx.try_recv(), - Ok(IoEvent::StartPlayback(None, Some(_), None)) - )); - assert!(rx.try_recv().is_err()); - } - - #[tokio::test] - async fn a_host_relays_a_guest_command_then_broadcasts() { - let (app, rx) = app_with_a_session(); - let mut network = party_network(&app, sync::PartyRole::Host).await; - - relay(&mut network, guest_pause()).await; - - assert!(matches!(rx.try_recv(), Ok(IoEvent::PausePlayback))); - assert!(matches!(rx.try_recv(), Ok(IoEvent::SyncPlayback))); - assert!(rx.try_recv().is_err()); - } - - #[cfg(feature = "streaming")] - #[tokio::test] - async fn the_relay_yields_to_the_native_queue_slot() { - let (app, rx) = app_with_a_session(); - app.lock().await.queue_now = Some(crate::infra::queue::QueueNowPlaying::Spotify { - track: crate::core::test_helpers::queued_track("spotify:track:queued", "Queued"), - }); - let mut network = party_network(&app, sync::PartyRole::Guest).await; - - relay(&mut network, host_state()).await; - assert!(rx.try_recv().is_err()); - - app.lock().await.party_session.as_mut().unwrap().role = sync::PartyRole::Host; - relay(&mut network, guest_pause()).await; - assert!(rx.try_recv().is_err()); - } -} diff --git a/src/infra/network/native_shuffle.rs b/src/infra/network/native_shuffle.rs deleted file mode 100644 index c1ec720f..00000000 --- a/src/infra/network/native_shuffle.rs +++ /dev/null @@ -1,1146 +0,0 @@ -//! Client-side ("Spotify-style") shuffle for native streaming playback. -//! -//! When shuffle is on and a Spotify context (playlist / album / Liked Songs) -//! starts on the native streaming device, the app fetches the context's track -//! list, shuffles it once (selected track first) and loads the flat URI list -//! into Spirc via `LoadRequest::from_tracks` — instead of delegating shuffle to -//! Spirc/Spotify, which regenerate the shuffle order on every context reload -//! (so a queue suspend/resume could repeat already-played tracks). -//! -//! The app-owned play order lives in -//! [`App::native_spotify_shuffle`](crate::core::app::App::native_spotify_shuffle). -//! Playback starts instantly from the tracks already on hand; a background -//! fetch completes the full context (capped) and swaps it in with a single -//! reload that preserves the playback position. -//! -//! The pure order/merge helpers at the top are unconditional so the slim -//! (non-`streaming`) CI build still compiles and runs their tests. - -#[cfg(feature = "streaming")] -use super::requests::spotify_get_typed_compat_for_with_refresh; -#[cfg(feature = "streaming")] -use super::Network; -#[cfg(feature = "streaming")] -use crate::core::app::{App, NativeSpotifyShuffleSession, TrackTableContext}; -#[cfg(feature = "streaming")] -use crate::infra::player::StreamingPlayer; -#[cfg(feature = "streaming")] -use anyhow::anyhow; -#[cfg(feature = "streaming")] -use librespot_connect::{LoadRequest, LoadRequestOptions, PlayingTrack}; -#[cfg(feature = "streaming")] -use log::{info, warn}; -#[cfg(feature = "streaming")] -use rspotify::model::{ - idtypes::{PlayContextId, PlayableId, PlaylistId}, - page::Page, - playlist::PlaylistItem, - track::SavedTrack, - PlayableItem, -}; -#[cfg(feature = "streaming")] -use rspotify::{prelude::*, AuthCodePkceSpotify}; -#[cfg(feature = "streaming")] -use std::path::Path; -#[cfg(feature = "streaming")] -use std::sync::Arc; -#[cfg(feature = "streaming")] -use std::time::Instant; -#[cfg(feature = "streaming")] -use tokio::sync::Mutex; - -/// Full-context fetch cap. Beyond this the shuffle covers the first -/// `MAX_NATIVE_SHUFFLE_TRACKS` tracks of the context (with a status message) -/// rather than stalling playback behind an unbounded pagination walk. -#[cfg_attr(not(feature = "streaming"), allow(dead_code))] -pub(crate) const MAX_NATIVE_SHUFFLE_TRACKS: usize = 3000; - -/// Fisher-Yates shuffle with the track at `first` moved to the front — the -/// same permutation shape as the decoded sources' `shuffle_in_place`, so the -/// selected/current track keeps playing and everything else follows once. -#[cfg_attr(not(feature = "streaming"), allow(dead_code))] -pub(crate) fn shuffled_order(mut uris: Vec, first: usize) -> Vec { - use rand::seq::SliceRandom; - if first < uris.len() { - uris.swap(0, first); - } - if uris.len() > 1 { - uris[1..].shuffle(&mut rand::rng()); - } - uris -} - -/// Merge a completed full-context fetch into a partially-loaded play order: -/// the already-played prefix (`order[..=index]`) is preserved verbatim, and -/// every fetched track not accounted for by that prefix follows in a fresh -/// shuffled order. Duplicate-safe: the prefix consumes fetched occurrences one -/// for one. -#[cfg_attr(not(feature = "streaming"), allow(dead_code))] -pub(crate) fn merge_full_fetch(order: &[String], index: usize, fetched: &[String]) -> Vec { - fold_full_context(order, index, fetched, true) -} - -/// Shared body of [`merge_full_fetch`]: preserve the already-played prefix -/// (`order[..=index]`) verbatim and append every fetched track not accounted -/// for by that prefix. `shuffle_rest` randomizes the appended tail (shuffle on) -/// or keeps it in fetched (natural) order (shuffle off). Duplicate-safe: the -/// prefix consumes fetched occurrences one for one. Keeping the prefix intact -/// leaves the currently-playing track fixed at `index`, so callers can anchor -/// on that index instead of a duplicate-ambiguous URI lookup. -#[cfg_attr(not(feature = "streaming"), allow(dead_code))] -pub(crate) fn fold_full_context( - order: &[String], - index: usize, - fetched: &[String], - shuffle_rest: bool, -) -> Vec { - use rand::seq::SliceRandom; - use std::collections::HashMap; - - let prefix_end = (index + 1).min(order.len()); - let prefix = &order[..prefix_end]; - let mut consumed: HashMap<&str, usize> = HashMap::new(); - for uri in prefix { - *consumed.entry(uri.as_str()).or_default() += 1; - } - let mut rest: Vec = Vec::with_capacity(fetched.len().saturating_sub(prefix.len())); - for uri in fetched { - if let Some(count) = consumed.get_mut(uri.as_str()) { - if *count > 0 { - *count -= 1; - continue; - } - } - rest.push(uri.clone()); - } - if shuffle_rest { - rest.shuffle(&mut rand::rng()); - } - let mut merged = prefix.to_vec(); - merged.extend(rest); - merged -} - -/// Index of the `nth` (1-based) occurrence of `uri` in `list`, or the last -/// occurrence when there are fewer than `nth` (and `None` when absent). Used to -/// re-anchor a duplicated track onto the matching copy — by its rank among the -/// tracks played so far — instead of always the first occurrence. -#[cfg_attr(not(feature = "streaming"), allow(dead_code))] -pub(crate) fn nth_occurrence(list: &[String], uri: &str, nth: usize) -> Option { - let mut seen = 0; - let mut last = None; - for (i, u) in list.iter().enumerate() { - if u == uri { - seen += 1; - last = Some(i); - if seen >= nth.max(1) { - return Some(i); - } - } - } - last -} - -/// Whether two URI lists hold the same tracks (order-insensitive, -/// duplicate-aware) — used to skip a pointless reload when the initially -/// loaded tracks already were the whole context. -#[cfg_attr(not(feature = "streaming"), allow(dead_code))] -pub(crate) fn same_track_multiset(a: &[String], b: &[String]) -> bool { - if a.len() != b.len() { - return false; - } - let mut a: Vec<&str> = a.iter().map(String::as_str).collect(); - let mut b: Vec<&str> = b.iter().map(String::as_str).collect(); - a.sort_unstable(); - b.sort_unstable(); - a == b -} - -/// Load the session's play order into Spirc as a flat track list. Spirc-side -/// shuffle is forced off first: the list itself carries the order, and a -/// `shuffling_context` Spirc would re-shuffle it on load. `start_playing` -/// preserves the current play/pause state across the reload so, e.g., toggling -/// shuffle while paused does not resume playback. -#[cfg(feature = "streaming")] -fn load_session_tracks( - player: &StreamingPlayer, - order: Vec, - index: usize, - seek_ms: u32, - start_playing: bool, -) -> anyhow::Result<()> { - let _ = player.set_shuffle(false); - let options = LoadRequestOptions { - start_playing, - seek_to: seek_ms, - context_options: None, - playing_track: u32::try_from(index).ok().map(PlayingTrack::Index), - }; - player.load(LoadRequest::from_tracks(order, options)) -} - -/// Drop a stale reload confirmation after a failed `load_session_tracks`: no -/// `TrackChanged` follows a load that never landed, so a later genuine one must -/// not be mistaken for the reload's confirmation (which would pin or move -/// `session.index` onto the wrong duplicate occurrence). A no-op when the error -/// path already cleared the whole session. -#[cfg(feature = "streaming")] -async fn clear_pending_reload(app: &Arc>) { - if let Some(session) = app.lock().await.native_spotify_shuffle.as_mut() { - session.pending_reload_index = None; - } -} - -/// What the background task should fetch to complete the session's context. -#[cfg(feature = "streaming")] -enum FullFetch { - Playlist(PlaylistId<'static>), - SavedTracks, -} - -/// The shuffleable playback target classified out of a `StartPlayback` -/// request. Anything else (artist/show contexts, raw search/recommendation -/// lists) keeps the pre-existing shuffle behavior. -#[cfg(feature = "streaming")] -enum ShuffleTarget { - Playlist { - id: PlaylistId<'static>, - selected_uri: String, - }, - Album { - id: rspotify::model::idtypes::AlbumId<'static>, - selected: usize, - }, - SavedTracks { - list: Vec, - selected: usize, - }, -} - -#[cfg(feature = "streaming")] -impl Network { - /// Intercept a native `StartPlayback` when shuffle is on and the target is a - /// shuffleable Spotify context. Returns `true` when this path handled the - /// request (playback started, or failed with an error surfaced); `false` - /// falls back to the pre-existing ContextApi / direct-load routes. - pub(super) async fn try_start_native_shuffled_playback( - &mut self, - player: &Arc, - context_id: &Option>, - uris: &Option>>, - offset: Option, - ) -> bool { - let target = match (context_id, uris) { - (Some(PlayContextId::Playlist(id)), maybe_uris) => { - // The playlist Enter path passes the selected track as a single-item - // URI list; without it there is nothing to pin first, so fall back. - let Some(selected_uri) = maybe_uris.as_ref().and_then(|v| v.first()).map(|u| u.uri()) - else { - return false; - }; - ShuffleTarget::Playlist { - id: id.clone(), - selected_uri, - } - } - (Some(PlayContextId::Album(id)), _) => ShuffleTarget::Album { - id: id.clone(), - selected: offset.unwrap_or(0), - }, - (None, Some(list)) => { - // Raw URI lists are only shuffleable when they stand in for Liked - // Songs (the full library is fetchable); search/recommendation lists - // have no larger backing context. - let is_saved_tracks = { - let app = self.app.lock().await; - matches!( - app.track_table.context, - Some(TrackTableContext::SavedTracks) - ) - }; - if !is_saved_tracks || list.is_empty() { - return false; - } - ShuffleTarget::SavedTracks { - list: list.iter().map(|u| u.uri()).collect(), - selected: offset.unwrap_or(0), - } - } - _ => return false, - }; - - let (original, order, fetch) = match target { - ShuffleTarget::Playlist { id, selected_uri } => ( - vec![selected_uri.clone()], - vec![selected_uri], - Some(FullFetch::Playlist(id)), - ), - ShuffleTarget::Album { id, selected } => { - // Albums are small: fetch the whole track list up front (one page for - // almost every album) so the first load is already complete. - let tracks = match super::metadata::fetch_album_tracks_from(self, id.id(), 0).await { - Ok(tracks) => tracks, - Err(e) => { - info!("native shuffle: album track fetch failed, falling back: {e}"); - return false; - } - }; - let uris: Vec = tracks - .iter() - .filter_map(|t| t.id.as_ref().map(|id| id.uri())) - .collect(); - if uris.is_empty() { - return false; - } - let selected = selected.min(uris.len() - 1); - (uris.clone(), shuffled_order(uris, selected), None) - } - ShuffleTarget::SavedTracks { list, selected } => { - let selected = selected.min(list.len() - 1); - ( - list.clone(), - shuffled_order(list, selected), - Some(FullFetch::SavedTracks), - ) - } - }; - - let generation = { - let mut app = self.app.lock().await; - let generation = app.next_native_shuffle_generation(); - app.native_spotify_shuffle = Some(NativeSpotifyShuffleSession { - order: order.clone(), - original, - index: 0, - shuffled: true, - fetch_complete: fetch.is_none(), - fetch_failed: false, - generation, - // The load below plays index 0; confirm that on the first TrackChanged. - pending_reload_index: Some(0), - pending_manual_skip: None, - }); - // Park the ORIGINAL request so zombie-session recovery replays it - // through this same interception, and arm the load watchdog like the - // direct-load route does. - app.park_start_playback( - context_id.as_ref().map(|c| c.uri()), - uris - .as_ref() - .map(|list| list.iter().map(|u| u.uri()).collect()), - offset, - ); - app.native_load_watchdog = Some(Instant::now()); - if let Some(ctx) = &mut app.current_playback_context { - ctx.is_playing = true; - ctx.shuffle_state = true; - } - app.runtime_state.shuffle_enabled = true; - app.schedule_state_save(crate::core::state::PersistedRuntimeState::shuffle_enabled( - true, - )); - generation - }; - - info!( - "starting native playback via client-side shuffle ({} tracks loaded)", - order.len() - ); - if let Err(e) = load_session_tracks(player, order, 0, 0, true) { - let mut app = self.app.lock().await; - app.clear_native_shuffle_session(); - app.handle_error(anyhow!("Failed to start native playback: {}", e)); - return true; - } - if let Some(kind) = fetch { - self.spawn_full_context_fetch(kind, generation); - } - true - } - - /// Handler for `IoEvent::ToggleNativeShuffleSession`: reorder the app-owned - /// list and reload it. With no session and shuffle turning on mid-playback, - /// build one from the current context when possible; otherwise fall back to - /// Spirc shuffle (previous behavior). - pub(super) async fn toggle_native_shuffle_session(&mut self, on: bool) { - enum Action { - Reload(Arc, Vec, usize, u32, bool), - Build { - context_uri: String, - current_uri: String, - }, - Spirc(Arc), - Nothing, - } - - let action = { - let mut guard = self.app.lock().await; - let player = guard.streaming_player.clone(); - let seek_ms = u32::try_from(guard.song_progress_ms).unwrap_or(u32::MAX); - let is_playing = guard.native_shuffle_is_playing(); - if let Some(session) = guard.native_spotify_shuffle.as_mut() { - if session.shuffled == on { - Action::Nothing - } else if !session.fetch_complete { - // The full context is still being fetched, so the app-owned - // `original` is incomplete: reordering now would truncate playback to - // the handful of loaded tracks. Flip the flag and let the - // fetch-completion path apply it against the whole context. Leave - // `pending_reload_index` intact — the initial load's confirmation may - // still be in flight, and this path issues no new reload. - session.shuffled = on; - Action::Nothing - } else { - let current_uri = session.order.get(session.index).cloned(); - if on { - let first = current_uri - .as_ref() - .and_then(|uri| session.original.iter().position(|x| x == uri)) - .unwrap_or(0); - session.order = shuffled_order(session.original.clone(), first); - session.index = 0; - } else { - session.order = session.original.clone(); - session.index = current_uri - .as_ref() - .and_then(|uri| session.order.iter().position(|x| x == uri)) - .unwrap_or(0); - } - session.shuffled = on; - match player { - Some(player) => { - session.pending_reload_index = Some(session.index); - Action::Reload( - player, - session.order.clone(), - session.index, - seek_ms, - is_playing, - ) - } - None => Action::Nothing, - } - } - } else if on { - let context_uri = guard - .current_playback_context - .as_ref() - .and_then(|ctx| ctx.context.as_ref()) - .map(|c| c.uri.clone()); - let current_uri = guard - .current_playback_context - .as_ref() - .and_then(|ctx| ctx.item.as_ref()) - .and_then(|item| match item { - PlayableItem::Track(t) => t.id.as_ref().map(|id| id.uri()), - _ => None, - }); - match (context_uri, current_uri, player) { - (Some(context_uri), Some(current_uri), Some(_)) => Action::Build { - context_uri, - current_uri, - }, - (_, _, Some(player)) => Action::Spirc(player), - _ => Action::Nothing, - } - } else { - match player { - Some(player) => Action::Spirc(player), - None => Action::Nothing, - } - } - }; - - match action { - Action::Reload(player, order, index, seek_ms, start_playing) => { - if let Err(e) = load_session_tracks(&player, order, index, seek_ms, start_playing) { - clear_pending_reload(&self.app).await; - self - .app - .lock() - .await - .handle_error(anyhow!("Failed to apply shuffle: {}", e)); - } - } - Action::Build { - context_uri, - current_uri, - } => { - self - .build_shuffle_session_from_current(context_uri, current_uri) - .await; - } - Action::Spirc(player) => { - let _ = player.set_shuffle(on); - } - Action::Nothing => {} - } - } - - /// Shuffle was turned on mid-playback with no session: build one around the - /// currently playing track. Playlists stay lazy (the fetch-completion reload - /// swaps the full shuffled list in); albums load fully right away. Contexts - /// the session can't cover fall back to Spirc shuffle. - #[cfg(feature = "streaming")] - async fn build_shuffle_session_from_current(&mut self, context_uri: String, current_uri: String) { - match super::ids::play_context_id(&context_uri) { - Some(PlayContextId::Playlist(id)) => { - let generation = { - let mut app = self.app.lock().await; - let generation = app.next_native_shuffle_generation(); - app.native_spotify_shuffle = Some(NativeSpotifyShuffleSession { - order: vec![current_uri.clone()], - original: vec![current_uri], - index: 0, - shuffled: true, - fetch_complete: false, - fetch_failed: false, - generation, - // No reload here: Spirc keeps playing its own context. - pending_reload_index: None, - pending_manual_skip: None, - }); - generation - }; - // No reload yet: Spirc keeps playing its current context, and the - // fetch completion swaps in the full shuffled list (single rebuffer). - self.spawn_full_context_fetch(FullFetch::Playlist(id), generation); - } - Some(PlayContextId::Album(id)) => { - let tracks = match super::metadata::fetch_album_tracks_from(self, id.id(), 0).await { - Ok(tracks) => tracks, - Err(e) => { - info!("native shuffle: album track fetch failed, falling back: {e}"); - self.fallback_spirc_shuffle(true).await; - return; - } - }; - let uris: Vec = tracks - .iter() - .filter_map(|t| t.id.as_ref().map(|id| id.uri())) - .collect(); - let Some(first) = uris.iter().position(|uri| *uri == current_uri) else { - self.fallback_spirc_shuffle(true).await; - return; - }; - let reload = { - let mut app = self.app.lock().await; - let generation = app.next_native_shuffle_generation(); - let order = shuffled_order(uris.clone(), first); - app.native_spotify_shuffle = Some(NativeSpotifyShuffleSession { - order: order.clone(), - original: uris, - index: 0, - shuffled: true, - fetch_complete: true, - fetch_failed: false, - generation, - // The reload below plays index 0. - pending_reload_index: Some(0), - pending_manual_skip: None, - }); - let seek_ms = u32::try_from(app.song_progress_ms).unwrap_or(u32::MAX); - let is_playing = app.native_shuffle_is_playing(); - app - .streaming_player - .clone() - .map(|p| (p, order, seek_ms, is_playing)) - }; - if let Some((player, order, seek_ms, is_playing)) = reload { - if let Err(e) = load_session_tracks(&player, order, 0, seek_ms, is_playing) { - let mut app = self.app.lock().await; - app.clear_native_shuffle_session(); - app.handle_error(anyhow!("Failed to apply shuffle: {}", e)); - } - } - } - _ => self.fallback_spirc_shuffle(true).await, - } - } - - #[cfg(feature = "streaming")] - async fn fallback_spirc_shuffle(&self, on: bool) { - if let Some(player) = { self.app.lock().await.streaming_player.clone() } { - let _ = player.set_shuffle(on); - } - } - - /// Handler for `IoEvent::ReshuffleNativeShuffleLap`: a repeat-all lap - /// completed — re-randomize the order (current track first) so the next lap - /// plays in a fresh order, like Spotify. - pub(super) async fn reshuffle_native_shuffle_lap(&mut self) { - let reload = { - let mut guard = self.app.lock().await; - let player = guard.streaming_player.clone(); - let seek_ms = u32::try_from(guard.song_progress_ms).unwrap_or(0); - let is_playing = guard.native_shuffle_is_playing(); - let Some(session) = guard.native_spotify_shuffle.as_mut() else { - return; - }; - if !session.shuffled || session.order.len() < 2 { - return; - } - let current_uri = session.order.get(session.index).cloned(); - let first = current_uri - .as_ref() - .and_then(|uri| session.original.iter().position(|x| x == uri)) - .unwrap_or(0); - session.order = shuffled_order(session.original.clone(), first); - session.index = 0; - session.pending_reload_index = Some(0); - player.map(|p| (p, session.order.clone(), seek_ms, is_playing)) - }; - if let Some((player, order, seek_ms, is_playing)) = reload { - if let Err(e) = load_session_tracks(&player, order, 0, seek_ms, is_playing) { - warn!("native shuffle: lap reshuffle reload failed: {e}"); - clear_pending_reload(&self.app).await; - } - } - } - - /// Handler for `IoEvent::ResumeNativeShuffleSession`: the native queue - /// drained — reload the session's unchanged play order at the resume index. - /// This is the path that used to reload (and reshuffle) the whole context. - pub(super) async fn resume_native_shuffle_session( - &mut self, - resume_index: Option, - generation: u64, - ) { - let action = { - let mut guard = self.app.lock().await; - // The suspend snapshotted a specific session; a session replaced while the - // queue drained bumps the generation, so a stale resume must not touch it. - let session_matches = guard - .native_spotify_shuffle - .as_ref() - .is_some_and(|s| s.generation == generation); - match resume_index { - Some(index) if session_matches => { - let player = guard.streaming_player.clone(); - match guard.native_spotify_shuffle.as_mut() { - Some(session) if index < session.order.len() => { - session.index = index; - session.pending_reload_index = Some(index); - player.map(|p| (p, session.order.clone(), index)) - } - _ => { - guard.set_status_message("Queue finished", 3); - None - } - } - } - Some(_) => { - // The stored index belongs to a session that is no longer active. If a - // newer session replaced it, leave that one playing untouched; - // otherwise the session is simply gone, so note the queue finished. - if guard.native_spotify_shuffle.is_none() { - guard.set_status_message("Queue finished", 3); - } - None - } - None => { - // Session exhausted under repeat-off: nothing left to resume. Only tear - // down the session we suspended — a newer one must be left running. - if session_matches { - guard.clear_native_shuffle_session(); - } - guard.set_status_message("Queue finished", 3); - None - } - } - }; - if let Some((player, order, index)) = action { - player.activate(); - // The queue drained, so resume playback regardless of prior pause state. - if let Err(e) = load_session_tracks(&player, order, index, 0, true) { - clear_pending_reload(&self.app).await; - self - .app - .lock() - .await - .handle_error(anyhow!("Failed to resume playback: {}", e)); - } - } - } - - /// Complete the session's context off the IoEvent pump (pagination can take - /// many round trips). Completion merges into whatever the session looks like - /// by then; the generation stamp discards results for a replaced session. - #[cfg(feature = "streaming")] - fn spawn_full_context_fetch(&self, kind: FullFetch, generation: u64) { - let spotify = self.spotify().clone(); - let app = Arc::clone(&self.app); - let token_cache_path = self.token_cache_path.clone(); - tokio::spawn(async move { - // A failed fetch strands a lazily-seeded session on its single seed - // track (nothing to advance into), so retry transient API failures - // before declaring it failed for good. - const FETCH_ATTEMPTS: u32 = 3; - let mut attempt = 0u32; - let result = loop { - let result = match &kind { - FullFetch::Playlist(id) => { - fetch_playlist_uris(&spotify, &token_cache_path, &app, id).await - } - FullFetch::SavedTracks => fetch_saved_track_uris(&spotify, &token_cache_path, &app).await, - }; - attempt += 1; - match result { - Err(e) if attempt < FETCH_ATTEMPTS => { - info!("native shuffle: context fetch attempt {attempt} failed, retrying: {e}"); - tokio::time::sleep(std::time::Duration::from_secs(attempt as u64)).await; - } - result => break result, - } - }; - finish_full_context_fetch(&app, generation, result).await; - }); - } -} - -/// Walk a paginated Spotify collection at `path`, mapping each item to a track -/// URI via `extract` (items yielding `None` are skipped), in native pagination -/// order and capped at [`MAX_NATIVE_SHUFFLE_TRACKS`]. Returns `(uris, -/// truncated)`, where `truncated` is true when the cap cut the list short. -#[cfg(feature = "streaming")] -async fn paginate_uris( - spotify: &AuthCodePkceSpotify, - token_cache_path: &Path, - app: &Arc>, - path: &str, - extract: impl Fn(&Item) -> Option, -) -> anyhow::Result<(Vec, bool)> -where - Item: serde::de::DeserializeOwned, -{ - let limit = 50u32; - let mut offset = 0u32; - let mut uris = Vec::new(); - loop { - let page = spotify_get_typed_compat_for_with_refresh::>( - spotify, - path, - &[("limit", limit.to_string()), ("offset", offset.to_string())], - token_cache_path, - app, - ) - .await?; - let has_next = page.next.is_some(); - for item in &page.items { - if let Some(uri) = extract(item) { - uris.push(uri); - } - } - if uris.len() >= MAX_NATIVE_SHUFFLE_TRACKS { - let truncated = uris.len() > MAX_NATIVE_SHUFFLE_TRACKS || has_next; - uris.truncate(MAX_NATIVE_SHUFFLE_TRACKS); - return Ok((uris, truncated)); - } - if !has_next { - break; - } - offset = page.offset.saturating_add(page.limit); - } - Ok((uris, false)) -} - -/// All track URIs of a playlist, in playlist order, capped at -/// [`MAX_NATIVE_SHUFFLE_TRACKS`]. Returns `(uris, truncated)`. Episodes and -/// unplayable items are skipped. -#[cfg(feature = "streaming")] -async fn fetch_playlist_uris( - spotify: &AuthCodePkceSpotify, - token_cache_path: &Path, - app: &Arc>, - playlist_id: &PlaylistId<'static>, -) -> anyhow::Result<(Vec, bool)> { - let path = format!("playlists/{}/items", playlist_id.id()); - paginate_uris( - spotify, - token_cache_path, - app, - &path, - |item: &PlaylistItem| match item.item.as_ref() { - Some(PlayableItem::Track(track)) => track.id.as_ref().map(|id| id.uri()), - _ => None, - }, - ) - .await -} - -/// All Liked Songs track URIs, newest first (the library's native order), -/// capped at [`MAX_NATIVE_SHUFFLE_TRACKS`]. Returns `(uris, truncated)`. -#[cfg(feature = "streaming")] -async fn fetch_saved_track_uris( - spotify: &AuthCodePkceSpotify, - token_cache_path: &Path, - app: &Arc>, -) -> anyhow::Result<(Vec, bool)> { - paginate_uris( - spotify, - token_cache_path, - app, - "me/tracks", - |item: &SavedTrack| item.track.id.as_ref().map(|id| id.uri()), - ) - .await -} - -/// Re-anchor the currently-playing track when the play order is being replaced -/// by `target`: find the occurrence in `target` matching the current track's -/// rank among the tracks played so far (`order[..=index]`), so a duplicated -/// track lands on the matching copy rather than always the first. Falls back to -/// index 0 when the current track is absent from `target`. -#[cfg(feature = "streaming")] -fn reanchor_by_rank(order: &[String], index: usize, target: &[String]) -> usize { - let index = index.min(order.len().saturating_sub(1)); - order - .get(index) - .and_then(|uri| { - let rank = order[..=index].iter().filter(|u| *u == uri).count(); - nth_occurrence(target, uri, rank) - }) - .unwrap_or(0) -} - -/// Fold a finished full-context fetch into the live session and reload Spirc -/// once, anchored on the actually-playing track with the position preserved. -#[cfg(feature = "streaming")] -async fn finish_full_context_fetch( - app: &Arc>, - generation: u64, - result: anyhow::Result<(Vec, bool)>, -) { - let reload = { - let mut guard = app.lock().await; - let player = guard.streaming_player.clone(); - let seek_ms = u32::try_from(guard.song_progress_ms).unwrap_or(u32::MAX); - let start_playing = guard.native_shuffle_is_playing(); - // The session may be suspended behind a queued track; folding the context - // in is fine, but reloading Spirc would hijack the sink from the queue. - let queue_active = guard.queue_owns_playback() || guard.queue_suspended.is_some(); - // The resume point the suspend already computed (same-track handoff vs a - // plain advance), captured so the fold can remap it rather than clobber it. - let suspended_resume: Option> = match &guard.queue_suspended { - Some(crate::core::queue::SuspendedContext::SpotifyShuffled { resume_index, .. }) => { - Some(*resume_index) - } - _ => None, - }; - let mut status: Option = None; - // When the session is suspended behind the queue, the new play order is - // still folded in but Spirc is not reloaded; this carries the re-pointed - // resume index out to `queue_suspended` once the session borrow ends. - let mut resume_update: Option> = None; - // Carries the seed-only fetch failure out of the session borrow: a - // suspension taken while the fetch was still in flight stored a shuffled - // resume into the seed-only order and must be converted to the context - // route (mirror of the suspend-time fallback). - let mut convert_suspend_to_context = false; - let reload = { - let Some(session) = guard.native_spotify_shuffle.as_mut() else { - return; - }; - if session.generation != generation { - return; - } - session.fetch_complete = true; - match result { - Err(e) => { - session.fetch_failed = true; - info!( - "native shuffle: full-context fetch failed; shuffle covers the {} loaded tracks: {e}", - session.order.len() - ); - // A seed-only session can't shuffle anything; tell the user instead - // of silently degrading (the queue-suspend path now falls back to - // the context route, see `suspend_native_spotify_context_for_queue`). - if session.order.len() <= 1 { - status = Some("Shuffle: failed to load the context's track list".to_string()); - convert_suspend_to_context = true; - } - // A shuffle-off deferred to fetch completion still needs applying, or - // state stays inconsistent (`shuffled == false` yet the shuffled order - // is loaded, and a repeat shuffle-off is a no-op, and a queue drain - // resumes shuffled). Fall back to the available (partial) natural order - // so the toggle is honored — whether or not the queue owns the sink. - if session.shuffled || session.order == session.original { - None - } else { - let old_index = session.index.min(session.order.len().saturating_sub(1)); - let restored = session.original.clone(); - // Anchor on the current track's rank so a duplicate doesn't jump. - let new_index = reanchor_by_rank(&session.order, old_index, &restored); - session.order = restored; - session.index = new_index; - if queue_active { - // Suspended behind the queue: remap the resume index, don't reload. - let same_track = matches!(suspended_resume, Some(Some(j)) if j <= old_index); - let resume = if same_track { - Some(new_index) - } else { - (new_index + 1 < session.order.len()).then_some(new_index + 1) - }; - resume_update = Some(resume); - None - } else { - session.pending_reload_index = Some(new_index); - Some((session.order.clone(), new_index)) - } - } - } - Ok((fetched, truncated)) => { - if fetched.is_empty() { - None - } else if queue_active { - // The queue owns the sink (session suspended). Fold the full - // context into the app-owned order so the resume plays the whole - // thing, but never reload over the queued track. The already-played - // prefix is preserved, so `index` still points at the suspended - // track and the resume math is unambiguous even with duplicates. - let index = session.index.min(session.order.len().saturating_sub(1)); - let new_order = fold_full_context(&session.order, index, &fetched, session.shuffled); - // Remap, don't clobber. The played prefix is preserved verbatim, so - // any stored resume index within it (`j <= index`) maps to itself — - // covering a same-track handoff (`j == index`) and a repeat-context - // last-to-first wrap (`j == 0`) alike. A resume past the prefix (a - // plain advance, or `None` when the partial order looked exhausted) - // continues at the first not-yet-played track. - let resume = match suspended_resume { - Some(Some(j)) if j <= index => Some(j), - _ => (index + 1 < new_order.len()).then_some(index + 1), - }; - session.original = fetched; - session.order = new_order; - session.index = index; - resume_update = Some(resume); - None - } else if !session.shuffled { - // Shuffle is off (toggled off while the fetch ran): restore the - // context's natural order and continue from the current track, so - // Next follows the real tracklist. Anchor on the occurrence matching - // the current track's rank among the tracks played so far, so a - // duplicated track isn't reloaded at an earlier copy. - let index = reanchor_by_rank(&session.order, session.index, &fetched); - let changed = session.order != fetched; - session.original = fetched.clone(); - session.order = fetched; - session.index = index; - if changed { - session.pending_reload_index = Some(index); - Some((session.order.clone(), index)) - } else { - None - } - } else if same_track_multiset(&session.order, &fetched) { - // The initial load already held the whole context; keep its - // shuffle rather than re-rolling and reloading for nothing. - session.original = fetched; - None - } else { - // The played prefix is preserved, so the currently-playing track - // stays fixed at `session.index`; anchor on it directly rather than - // a duplicate-ambiguous URI lookup. - let index = session.index.min(session.order.len().saturating_sub(1)); - let merged = merge_full_fetch(&session.order, index, &fetched); - session.original = fetched; - session.order = merged; - session.index = index; - if truncated { - status = Some(format!( - "Large context: shuffling the first {MAX_NATIVE_SHUFFLE_TRACKS} tracks" - )); - } - session.pending_reload_index = Some(index); - Some((session.order.clone(), index)) - } - } - } - }; - if let Some(message) = status { - guard.set_status_message(message, 4); - } - // Re-point the suspended resume index into the folded-in order so the - // queue drains back into the full context, not the partial one. - if let Some(resume) = resume_update { - if let Some(crate::core::queue::SuspendedContext::SpotifyShuffled { resume_index, .. }) = - guard.queue_suspended.as_mut() - { - *resume_index = resume; - } - } - // The seed-only fetch failed while this session's suspension already sat - // behind the queue: its shuffled resume dead-ends, so hand the drain to - // the context route instead. - if convert_suspend_to_context { - guard.convert_shuffled_suspension_to_context(Some(generation)); - } - reload.and_then(|(order, index)| player.map(|p| (p, order, index, seek_ms, start_playing))) - }; - if let Some((player, order, index, seek_ms, start_playing)) = reload { - if let Err(e) = load_session_tracks(&player, order, index, seek_ms, start_playing) { - warn!("native shuffle: reload after full fetch failed: {e}"); - clear_pending_reload(app).await; - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn uris(ids: &[&str]) -> Vec { - ids.iter().map(|id| format!("spotify:track:{id}")).collect() - } - - /// A queue suspension taken while the seed-only context fetch was still in - /// flight stores a shuffled resume; when the fetch then fails for good, the - /// failure handler must convert that suspension to the context route so the - /// drain does not dead-end in "Queue finished". - #[cfg(feature = "streaming")] - #[test] - fn failed_fetch_converts_a_suspended_seed_session_to_the_context() { - use crate::core::app::NativeSpotifyShuffleSession; - use crate::core::queue::SuspendedContext; - - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap(); - rt.block_on(async { - let (tx, _rx) = std::sync::mpsc::channel(); - let mut app = App::new( - tx, - crate::core::user_config::UserConfig::new(), - Some(std::time::SystemTime::now()), - ); - app.native_spotify_shuffle = Some(NativeSpotifyShuffleSession { - order: uris(&["seed"]), - original: uris(&["seed"]), - index: 0, - shuffled: true, - fetch_complete: false, - fetch_failed: false, - generation: 9, - pending_reload_index: None, - pending_manual_skip: None, - }); - // The suspend computed from the seed-only order: an exhausted resume. - app.queue_suspended = Some(SuspendedContext::SpotifyShuffled { - resume_index: None, - generation: 9, - context_uri: Some("spotify:playlist:suspended".to_string()), - resume_track_uri: None, - }); - let app = Arc::new(Mutex::new(app)); - - finish_full_context_fetch(&app, 9, Err(anyhow!("fetch failed"))).await; - - let guard = app.lock().await; - match &guard.queue_suspended { - Some(SuspendedContext::Spotify { context_uri, .. }) => { - assert_eq!( - context_uri.as_deref(), - Some("spotify:playlist:suspended"), - "the context captured at suspension time must survive the conversion" - ); - } - other => panic!("expected a context suspension, got {other:?}"), - } - assert!( - guard - .native_spotify_shuffle - .as_ref() - .is_some_and(|s| s.fetch_failed), - "the session must be marked failed for the suspend fallback" - ); - }); - } - - #[test] - fn shuffled_order_moves_selected_to_front_and_keeps_every_track() { - let original = uris(&["a", "b", "c", "d", "e"]); - let shuffled = shuffled_order(original.clone(), 3); - assert_eq!(shuffled[0], "spotify:track:d"); - let mut a = original.clone(); - let mut b = shuffled.clone(); - a.sort(); - b.sort(); - assert_eq!(a, b, "shuffle must be a permutation"); - } - - #[test] - fn shuffled_order_handles_out_of_range_and_tiny_lists() { - assert!(shuffled_order(Vec::new(), 0).is_empty()); - assert_eq!(shuffled_order(uris(&["a"]), 5), uris(&["a"])); - } - - #[test] - fn merge_preserves_played_prefix_and_covers_the_rest_once() { - // Initial lazy session: only the selected track was loaded and played. - let order = uris(&["d"]); - let fetched = uris(&["a", "b", "c", "d", "e"]); - let merged = merge_full_fetch(&order, 0, &fetched); - assert_eq!(merged[0], "spotify:track:d"); - assert_eq!(merged.len(), 5); - let mut rest = merged[1..].to_vec(); - rest.sort(); - assert_eq!(rest, uris(&["a", "b", "c", "e"])); - } - - #[test] - fn nth_occurrence_resolves_by_duplicate_rank() { - let list = uris(&["a", "b", "a", "c", "a"]); - assert_eq!(nth_occurrence(&list, "spotify:track:a", 1), Some(0)); - assert_eq!(nth_occurrence(&list, "spotify:track:a", 2), Some(2)); - assert_eq!(nth_occurrence(&list, "spotify:track:a", 3), Some(4)); - // Fewer than `nth` copies falls back to the last occurrence. - assert_eq!(nth_occurrence(&list, "spotify:track:a", 9), Some(4)); - assert_eq!(nth_occurrence(&list, "spotify:track:z", 1), None); - } - - #[test] - fn fold_with_natural_tail_keeps_context_order_for_shuffle_off() { - // Shuffle toggled off mid-fetch: the played prefix stays put and the rest - // follows in the fetched (natural) order, not a re-shuffle. - let order = uris(&["c"]); - let fetched = uris(&["a", "b", "c", "d", "e"]); - let folded = fold_full_context(&order, 0, &fetched, false); - assert_eq!(folded, uris(&["c", "a", "b", "d", "e"])); - } - - #[test] - fn merge_is_duplicate_safe() { - // "d" appears twice in the playlist; the played copy consumes exactly one. - let order = uris(&["d", "b"]); - let fetched = uris(&["a", "b", "d", "d"]); - let merged = merge_full_fetch(&order, 1, &fetched); - assert_eq!(&merged[..2], &uris(&["d", "b"])[..]); - assert_eq!(merged.len(), 4); - let mut rest = merged[2..].to_vec(); - rest.sort(); - assert_eq!(rest, uris(&["a", "d"])); - } - - #[test] - fn merge_drops_played_tracks_no_longer_in_the_context() { - // A played track was removed from the playlist between load and fetch: - // the prefix keeps it (it already played), the rest covers the fetch. - let order = uris(&["x", "b"]); - let merged = merge_full_fetch(&order, 1, &uris(&["a", "b", "c"])); - assert_eq!(&merged[..2], &uris(&["x", "b"])[..]); - let mut rest = merged[2..].to_vec(); - rest.sort(); - assert_eq!(rest, uris(&["a", "c"])); - } - - #[test] - fn same_track_multiset_is_order_insensitive_but_count_sensitive() { - assert!(same_track_multiset( - &uris(&["a", "b", "b"]), - &uris(&["b", "a", "b"]) - )); - assert!(!same_track_multiset(&uris(&["a", "b"]), &uris(&["a", "a"]))); - assert!(!same_track_multiset(&uris(&["a"]), &uris(&["a", "a"]))); - } -} diff --git a/src/infra/network/playback.rs b/src/infra/network/playback.rs deleted file mode 100644 index 8b3a274e..00000000 --- a/src/infra/network/playback.rs +++ /dev/null @@ -1,3529 +0,0 @@ -use super::{IoEvent, Network}; -use crate::core::app::PlaybackOwner; -#[cfg(feature = "streaming")] -use crate::core::{ - app::{App, NativePlaybackOrigin, NativePlaybackRecoverySnapshot}, - config::ClientConfig, -}; -#[cfg(feature = "streaming")] -use crate::infra::player::{select_native, PlaybackBackend}; -use anyhow::anyhow; -use chrono::TimeDelta; -use log::{info, warn}; -use reqwest::Method; -#[cfg(feature = "streaming")] -use rspotify::model::device::DevicePayload; -use rspotify::model::{ - context::CurrentUserQueue, - enums::RepeatState, - idtypes::{PlayContextId, PlayableId}, - PlayableItem, -}; -use rspotify::prelude::*; -use serde_json::{json, Value}; -#[cfg(feature = "streaming")] -use std::time::Duration; -use std::time::Instant; - -#[cfg(feature = "streaming")] -use librespot_connect::{ - LoadContextOptions, LoadRequest, LoadRequestOptions, Options as LoadOptions, PlayingTrack, -}; -use std::sync::Arc; - -const MAX_API_PLAYBACK_URIS: usize = 100; - -/// Playback polls that must fail 401 back-to-back before the full-screen error -/// route is shown. Below the threshold the poll simply tries again on its next -/// tick, which is what actually recovers the intermittent player-service 401s -/// of issue #395 (users were pressing Esc and finding playback untouched). -const MAX_CONSECUTIVE_PLAYBACK_AUTH_FAILURES: u8 = 4; - -#[cfg(feature = "streaming")] -const MAX_NATIVE_IDLE_RECOVERY_ATTEMPTS: u8 = 2; - -#[cfg(feature = "streaming")] -const NATIVE_IDLE_RECOVERY_RETRY_INTERVAL: Duration = Duration::from_secs(5); - -pub trait PlaybackNetwork { - async fn get_current_playback(&mut self); - async fn start_playback( - &mut self, - context_id: Option>, - uris: Option>>, - offset: Option, - ); - #[cfg(feature = "streaming")] - async fn restore_native_playback(&mut self, generation: u64); - async fn pause_playback(&mut self); - async fn next_track(&mut self); - async fn previous_track(&mut self); - async fn force_previous_track(&mut self); - async fn seek(&mut self, position_ms: u32); - async fn shuffle(&mut self, shuffle_state: bool); - async fn repeat(&mut self, repeat_state: RepeatState); - async fn change_volume(&mut self, volume: u8); - async fn transfert_playback_to_device(&mut self, device_id: String, persist_device_id: bool); - #[cfg(feature = "streaming")] - async fn auto_select_streaming_device( - &mut self, - device_name: String, - persist_device_id: bool, - yield_to_external_playback: bool, - ); - async fn ensure_playback_continues(&mut self, previous_track_id: String); - /// Resume a native-Spotify context suspended under the native queue, targeting - /// the resume track via an offset URI. Falls back to playing just the track - /// when there is no context, and to a "Queue finished" status when neither is - /// known. - async fn resume_spotify_context( - &mut self, - context_uri: Option, - resume_track_uri: Option, - ); - #[allow(dead_code)] - async fn add_item_to_queue(&mut self, item: PlayableId<'static>); - async fn get_queue(&mut self); - /// Fetch, decode and store the current track's cover art (off the `App` lock). - #[cfg(feature = "art-decode")] - async fn fetch_cover_art(&mut self, request: crate::core::art::CoverArtRequest); -} - -fn trim_api_playback_uris( - track_uris: Vec>, - offset: Option, -) -> (Vec>, Option) { - if track_uris.len() <= MAX_API_PLAYBACK_URIS { - return (track_uris, offset); - } - - let selected_index = offset.unwrap_or(0).min(track_uris.len().saturating_sub(1)); - let preferred_history = MAX_API_PLAYBACK_URIS / 5; - let mut start = selected_index.saturating_sub(preferred_history); - let end = (start + MAX_API_PLAYBACK_URIS).min(track_uris.len()); - - if end - start < MAX_API_PLAYBACK_URIS { - start = end.saturating_sub(MAX_API_PLAYBACK_URIS); - } - - // Spotify rejects oversized URI payloads, so URI-list playback is capped - // to a window that still contains the selected track. - let trimmed_uris = track_uris[start..end] - .iter() - .map(PlayableId::clone_static) - .collect::>(); - - (trimmed_uris, Some(selected_index - start)) -} - -fn api_playback_offset_json( - context_uris: Option<&[PlayableId<'static>]>, - offset: Option, -) -> Option { - if let Some(first_uri) = context_uris.and_then(|uris| uris.first()) { - return Some(json!({ "uri": first_uri.uri() })); - } - - offset.map(|index| json!({ "position": index })) -} - -fn api_playback_body( - context_id: Option<&PlayContextId<'static>>, - uris: Option<&[PlayableId<'static>]>, - offset: Option, -) -> Option { - match (context_id, uris) { - (Some(context), track_uris) => { - let mut body = json!({ "context_uri": context.uri() }); - if let Some(offset) = api_playback_offset_json(track_uris, offset) { - body["offset"] = offset; - } - Some(body) - } - (None, Some(track_uris)) => { - let mut body = json!({ - "uris": track_uris.iter().map(|uri| uri.uri()).collect::>() - }); - if let Some(offset) = api_playback_offset_json(None, offset) { - body["offset"] = offset; - } - Some(body) - } - (None, None) => None, - } -} - -fn playable_item_id(item: &PlayableItem) -> Option { - match item { - PlayableItem::Track(track) => track.id.as_ref().map(|id| id.id().to_string()), - PlayableItem::Episode(episode) => Some(episode.id.id().to_string()), - PlayableItem::Unknown(_) => None, - } -} - -fn playable_item_name(item: &PlayableItem) -> Option<&str> { - match item { - PlayableItem::Track(track) => Some(&track.name), - PlayableItem::Episode(episode) => Some(&episode.name), - PlayableItem::Unknown(_) => None, - } -} - -fn spotify_item_identity(value: &str) -> &str { - value.rsplit(':').next().unwrap_or(value) -} - -#[cfg(feature = "streaming")] -#[derive(Debug, PartialEq, Eq)] -enum NativePlaybackRoute { - ContextApi { device_id: String }, - NativeLoad, -} - -#[cfg(feature = "streaming")] -#[derive(Clone, Copy, Debug, Default)] -struct NativeActivationContext { - player_connected: bool, - current_device_id_present: bool, - current_device_is_confirmed_native: bool, - current_device_name_matches_native: bool, - native_has_fresh_activity: bool, - saved_device_matches_native: bool, - saved_external_confirmed_available: bool, -} - -#[cfg(feature = "streaming")] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum NativeDevicePreferenceUpdate { - Persist, - KeepExistingPreference, -} - -#[cfg(feature = "streaming")] -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -enum NativeIdleRecoveryPhase { - #[default] - Armed, - Idle { - attempts: u8, - last_attempt: Instant, - }, -} - -#[cfg(feature = "streaming")] -#[derive(Debug, Default)] -pub(super) struct NativeIdleRecoveryState { - player_instance: Option, - phase: NativeIdleRecoveryPhase, -} - -#[cfg(feature = "streaming")] -impl NativeIdleRecoveryState { - fn observe_player_instance(&mut self, player_instance: Option) { - if self.player_instance != player_instance { - self.player_instance = player_instance; - self.phase = NativeIdleRecoveryPhase::Armed; - } - } - - fn should_attempt_idle_recovery(&mut self, now: Instant) -> bool { - match self.phase { - NativeIdleRecoveryPhase::Armed => { - self.phase = NativeIdleRecoveryPhase::Idle { - attempts: 1, - last_attempt: now, - }; - true - } - NativeIdleRecoveryPhase::Idle { - attempts, - last_attempt, - } if attempts < MAX_NATIVE_IDLE_RECOVERY_ATTEMPTS - && now.duration_since(last_attempt) >= NATIVE_IDLE_RECOVERY_RETRY_INTERVAL => - { - self.phase = NativeIdleRecoveryPhase::Idle { - attempts: attempts + 1, - last_attempt: now, - }; - true - } - NativeIdleRecoveryPhase::Idle { .. } => false, - } - } - - fn settle_current_episode(&mut self, now: Instant) { - self.phase = NativeIdleRecoveryPhase::Idle { - attempts: MAX_NATIVE_IDLE_RECOVERY_ATTEMPTS, - last_attempt: now, - }; - } -} - -#[cfg(feature = "streaming")] -fn should_activate_native_for_playback(context: NativeActivationContext) -> bool { - if !context.player_connected { - return false; - } - - let current_device_is_stale_native_name = - context.current_device_name_matches_native && !context.native_has_fresh_activity; - let current_device_is_usable_external = context.current_device_id_present - && !context.current_device_is_confirmed_native - && !current_device_is_stale_native_name; - - if current_device_is_usable_external { - return false; - } - - if context.saved_device_matches_native { - return true; - } - - !context.saved_external_confirmed_available -} - -#[cfg(feature = "streaming")] -fn should_yield_native_recovery( - yield_to_external_playback: bool, - current_is_playing: bool, - current_device_id: Option<&str>, - native_device_id: &str, -) -> bool { - yield_to_external_playback && current_is_playing && current_device_id != Some(native_device_id) -} - -#[cfg(feature = "streaming")] -fn clear_native_recovery_intent_for_external_playback(app: &mut App) { - app.is_streaming_active = false; - app.native_activation_pending = false; - app.last_device_activation = None; - app.clear_native_playback_recovery(); - if app.queue_now_is_spotify() { - app.queue_now = None; - app.spotify_queue_guard_reloads = 0; - } -} - -#[cfg(feature = "streaming")] -fn native_device_preference_update( - saved_device_id: Option<&str>, - explicit_persist: bool, - saved_device_matches_native: bool, -) -> NativeDevicePreferenceUpdate { - if explicit_persist || saved_device_id.is_none() || saved_device_matches_native { - NativeDevicePreferenceUpdate::Persist - } else { - NativeDevicePreferenceUpdate::KeepExistingPreference - } -} - -#[cfg(feature = "streaming")] -fn native_idle_device_preference_update( - saved_device_id: Option<&str>, - saved_device_matches_native: bool, -) -> Option { - let update = native_device_preference_update(saved_device_id, false, saved_device_matches_native); - (update != NativeDevicePreferenceUpdate::KeepExistingPreference).then_some(update) -} - -#[cfg(feature = "streaming")] -fn saved_device_matches_native_player( - saved_device_id: Option<&str>, - native_device_id: Option<&str>, - devices: Option<&DevicePayload>, - native_device_name: &str, -) -> bool { - saved_device_id.is_some_and(|saved| { - native_device_id == Some(saved) - || devices.is_some_and(|payload| { - payload.devices.iter().any(|device| { - device.id.as_deref() == Some(saved) - && device.name.eq_ignore_ascii_case(native_device_name) - }) - }) - }) -} - -#[cfg(feature = "streaming")] -fn persist_native_device_id_if_needed( - client_config: &mut ClientConfig, - app: &mut App, - native_device_id: &str, - update: NativeDevicePreferenceUpdate, -) { - if update == NativeDevicePreferenceUpdate::KeepExistingPreference { - return; - } - - if client_config.device_id.as_deref() == Some(native_device_id) { - return; - } - - if let Err(e) = client_config.set_device_id(native_device_id.to_string()) { - app.handle_error(anyhow!(e)); - } -} - -#[cfg(feature = "streaming")] -fn reconcile_native_idle_device_if_preferred( - client_config: &mut ClientConfig, - app: &mut App, - player: &crate::infra::player::StreamingPlayer, - recovery: &mut NativeIdleRecoveryState, -) { - if !player.is_connected() { - return; - } - - let native_device_id = player.device_id(); - let saved_device_matches_native = saved_device_matches_native_player( - client_config.device_id.as_deref(), - Some(&native_device_id), - app.devices.as_ref(), - player.device_name(), - ); - let Some(native_preference_update) = native_idle_device_preference_update( - client_config.device_id.as_deref(), - saved_device_matches_native, - ) else { - return; - }; - - let now = Instant::now(); - if recovery.should_attempt_idle_recovery(now) { - let _ = player.transfer(None); - player.activate(); - app.last_device_activation = Some(now); - } - - app.mark_native_streaming_device_available( - native_device_id.clone(), - player.device_name().to_string(), - player.get_volume(), - ); - persist_native_device_id_if_needed( - client_config, - app, - &native_device_id, - native_preference_update, - ); -} - -#[cfg(feature = "streaming")] -fn spotify_payload_confirms_native_device(payload: &DevicePayload, native_device_id: &str) -> bool { - payload - .devices - .iter() - .any(|device| device.id.as_deref() == Some(native_device_id)) -} - -fn is_no_active_device_error(e: &anyhow::Error) -> bool { - let text = e.to_string().to_ascii_lowercase(); - text.contains("no_active_device") || text.contains("no active device") -} - -/// A decoded source (or a decoded queue item) owns the sink: every Spotify -/// transport call is refused, since the paused librespot and the Web API -/// device are both the wrong player. A queued Spotify track keeps Native. -async fn decoded_source_owns_playback(network: &Network) -> bool { - let refused = network.app.lock().await.active_decoded_source(); - if refused { - log::debug!("Spotify transport refused: a decoded source owns playback"); - } - refused -} - -/// Whether a native backend is positioned to claim a failed player command: -/// an available player (`start_playback` activates it on `NO_ACTIVE_DEVICE`), -/// or a backend/activation still materializing (the `suppressed_*` handlers -/// classify on the original error). -async fn native_can_claim_player_command(network: &Network) -> bool { - #[cfg(feature = "streaming")] - { - let app = network.app.lock().await; - app - .streaming_player - .as_ref() - .is_some_and(|p| p.is_available()) - || app.native_backend_pending - || app.native_activation_pending - } - #[cfg(not(feature = "streaming"))] - { - let _ = network; - false - } -} - -/// The saved device to retry a player command on, or `None` to surface the -/// original error. Sending `device_id` with `me/player/*` both targets and -/// activates the device, which a bare command cannot do — without it every -/// command 404s once all devices are idle, the steady state of CLI mode -/// (#464). Never retries while a native backend could claim the command. -fn saved_device_retry( - error_is_no_active_device: bool, - saved_device_id: Option<&str>, - native_can_claim: bool, -) -> Option { - if !error_is_no_active_device || native_can_claim { - return None; - } - saved_device_id.map(str::to_string) -} - -/// Run a player command against the Web API, retrying once on the saved -/// device (`client_config.device_id`) when Spotify rejects the bare command -/// with `NO_ACTIVE_DEVICE`. A failed retry surfaces the *original* error so -/// downstream classification (native fallback, transient suppression) is -/// unchanged. -async fn player_command_with_saved_device_retry( - network: &Network, - method: Method, - path: &str, - query: &[(&str, String)], - body: Option, -) -> anyhow::Result { - let result = network - .spotify_api_request_json(method.clone(), path, query, body.clone()) - .await; - let Err(e) = result else { - return result; - }; - - let Some(device_id) = saved_device_retry( - is_no_active_device_error(&e), - network.client_config.device_id.as_deref(), - native_can_claim_player_command(network).await, - ) else { - return Err(e); - }; - - info!("{path}: no active device; retrying on the saved device {device_id}"); - let mut query = query.to_vec(); - query.push(("device_id", device_id)); - match network - .spotify_api_request_json(method, path, &query, body) - .await - { - Ok(value) => Ok(value), - Err(retry_err) => { - warn!("saved-device retry failed: {retry_err}"); - Err(e) - } - } -} - -/// While a native backend is expected to materialize (recovery in flight, or -/// the deferred startup init still running), a transport command that 404s -/// with NO_ACTIVE_DEVICE is expected noise — surface a status message instead -/// of routing to the full-screen Error. Returns true when the error was -/// handled that way. -#[cfg(feature = "streaming")] -async fn suppressed_no_device_error_while_pending(network: &Network, e: &anyhow::Error) -> bool { - if !is_no_active_device_error(e) { - return false; - } - let mut app = network.app.lock().await; - if !app.native_backend_pending { - return false; - } - app.set_status_message("Reconnecting native streaming…", 5); - true -} - -#[cfg(feature = "streaming")] -fn is_restriction_violated_error(e: &anyhow::Error) -> bool { - let text = e.to_string().to_ascii_lowercase(); - text.contains("403") && text.contains("restriction violated") -} - -/// De-escalate a `403 Restriction violated` to a status message while the -/// native session is still settling; a steady-state restriction stays fatal. -#[cfg(feature = "streaming")] -async fn suppressed_restriction_violation_while_pending( - network: &Network, - e: &anyhow::Error, -) -> bool { - if !is_restriction_violated_error(e) { - return false; - } - { - let app = network.app.lock().await; - if !(app.native_backend_pending || app.native_activation_pending) { - return false; - } - } - network - .show_status_message("Native streaming is still settling…".to_string(), 5) - .await; - true -} - -/// Transient native-session errors (`NO_ACTIVE_DEVICE` 404 or `Restriction -/// violated` 403) a command can hit while the native backend is coming up. -#[cfg(feature = "streaming")] -async fn suppressed_transient_native_command_error(network: &Network, e: &anyhow::Error) -> bool { - suppressed_no_device_error_while_pending(network, e).await - || suppressed_restriction_violation_while_pending(network, e).await -} - -fn api_confirms_native_info_is_current( - native_name: &str, - item: &PlayableItem, - last_track_id: Option<&str>, -) -> bool { - if playable_item_name(item) == Some(native_name) { - return true; - } - - playable_item_id(item) - .as_deref() - .is_some_and(|api_id| Some(api_id) == last_track_id) -} - -#[cfg(feature = "streaming")] -#[derive(Clone, Copy, Debug)] -struct StaleApiItemContext { - native_info_present: bool, - api_item_present: bool, - api_confirms_native_info: bool, - native_track_id_present: bool, - api_item_matches_native_track: bool, - native_streaming_was_active: bool, - native_activation_pending: bool, - api_device_is_native: bool, -} - -#[cfg(feature = "streaming")] -fn stale_api_item_should_preserve_native_context(context: StaleApiItemContext) -> bool { - context.api_item_present - && !context.api_confirms_native_info - && (context.native_info_present - || (context.native_track_id_present && !context.api_item_matches_native_track)) - && (context.native_streaming_was_active - || context.native_activation_pending - || context.api_device_is_native) -} - -/// Get the currently active streaming player, if any. -/// Note: This logic is duplicated in `main.rs` as `active_streaming_player()`. -/// Both are identical; the difference is input type (Network vs. App Arc). -/// A future refactor could consolidate to a shared location like `src/core/app.rs`. -#[cfg(feature = "streaming")] -async fn current_streaming_player( - network: &Network, -) -> Option> { - let app = network.app.lock().await; - app.streaming_player.clone() -} - -#[cfg(feature = "streaming")] -async fn is_native_streaming_active_for_playback(network: &Network) -> bool { - let app = network.app.lock().await; - let streaming_player = app.streaming_player.clone(); - let player_connected = streaming_player.as_ref().is_some_and(|p| p.is_available()); - - if !player_connected { - return false; - } - - let native_device_name = streaming_player - .as_ref() - .map(|p| p.device_name().to_lowercase()); - - // If no context yet (e.g., at startup), use the app state flag which is - // set when the native streaming device is activated/selected. - let Some(ref ctx) = app.current_playback_context else { - return app.is_streaming_active; - }; - - // First, check if the current playback device matches the native streaming device ID - if let (Some(current_id), Some(native_id)) = - (ctx.device.id.as_ref(), app.native_device_id.as_ref()) - { - if current_id == native_id { - return true; - } - } - - // Fallback: strict name match (case-insensitive), but only while we have - // fresh native activity or a recent explicit activation. After a recovery, - // Spotify can keep returning the old "spotatui" device while the new native - // player is connected but stopped/not active. - if let Some(native_name) = native_device_name.as_ref() { - let current_device_name = ctx.device.name.to_lowercase(); - if current_device_name == native_name.as_str() && app.has_fresh_native_activity() { - return true; - } - } - - // The user explicitly selected the native device very recently; honor that - // intent even when the API context hasn't caught up yet (the brief pre-poll - // window). `is_streaming_active` is re-derived from real Spotify state on the - // next poll, so this cannot reintroduce the #254 device hijack. (#282) - if app.is_streaming_active - && app - .last_device_activation - .is_some_and(|instant| instant.elapsed() < Duration::from_secs(5)) - { - return true; - } - - // No match - not the active device - false -} - -/// Resolve the transport backend for a *symmetric* playback operation -/// (pause/next/previous/seek/shuffle/repeat/volume). -/// -/// Native streaming is chosen only when it is the active device *and* a player -/// handle is present; otherwise the operation falls through to the Spotify Web -/// API. This wraps the existing selection logic without changing it: the two -/// awaited lookups happen in the same order as the original inline -/// `if is_native_streaming_active_for_playback(..).await { if let Some(player) = -/// current_streaming_player(..).await { .. } }` guard, so behaviour is identical. -#[cfg(feature = "streaming")] -async fn symmetric_playback_backend(network: &Network) -> PlaybackBackend { - let is_native_active = is_native_streaming_active_for_playback(network).await; - // Only look up the player when native is active, mirroring the original - // short-circuit (`if is_native { if let Some(player) ... }`) so the Connect - // path does not take the app lock the inline code never acquired. - let player = if is_native_active { - current_streaming_player(network).await - } else { - None - }; - if select_native(is_native_active, player.is_some()) { - // `select_native` guarantees `player` is `Some` here. - PlaybackBackend::Native(player.expect("player present when native selected")) - } else { - PlaybackBackend::Connect - } -} - -/// Resolve the transport backend for `start_playback`. -/// -/// Unlike the symmetric operations, native streaming is selected when it is -/// already active *or* when the activation heuristics say it should be -/// activated for this playback. The `||` short-circuit and "fetch the player -/// only when native applies" ordering are preserved exactly; a true predicate -/// with a missing player still falls through to the Web API. -#[cfg(feature = "streaming")] -async fn start_playback_backend(network: &Network) -> PlaybackBackend { - let is_native_active = is_native_streaming_active_for_playback(network).await - || should_activate_native_streaming_for_playback(network).await; - let player = if is_native_active { - current_streaming_player(network).await - } else { - None - }; - if select_native(is_native_active, player.is_some()) { - PlaybackBackend::Native(player.expect("player present when native selected")) - } else { - PlaybackBackend::Connect - } -} - -/// Resolve the transport backend for `transfert_playback_to_device`. -/// -/// The native player is selected only when the *transfer target* `device_id` -/// refers to the native streaming device, identified either by matching a -/// cached device whose name equals the native device name, or by matching the -/// recorded `native_device_id`. This mirrors the previous inline -/// `is_native_transfer` computation exactly; an unrelated target falls through -/// to the Web API transfer. -#[cfg(feature = "streaming")] -async fn transfer_playback_backend(network: &Network, device_id: &str) -> PlaybackBackend { - let player = current_streaming_player(network).await; - let is_native_transfer = if let Some(ref player) = player { - let native_name = player.device_name().to_lowercase(); - let app = network.app.lock().await; - let matches_cached_device = app.devices.as_ref().is_some_and(|payload| { - payload - .devices - .iter() - .any(|d| d.id.as_deref() == Some(device_id) && d.name.to_lowercase() == native_name) - }); - matches_cached_device || app.native_device_id.as_deref() == Some(device_id) - } else { - false - }; - - if select_native(is_native_transfer, player.is_some()) { - PlaybackBackend::Native(player.expect("player present when native selected")) - } else { - PlaybackBackend::Connect - } -} - -#[cfg(feature = "streaming")] -async fn should_activate_native_streaming_for_playback(network: &Network) -> bool { - let saved_device_id = network.client_config.device_id.as_deref(); - let app = network.app.lock().await; - let Some(player) = app.streaming_player.as_ref() else { - return false; - }; - - if !player.is_available() { - return false; - } - - let native_name = player.device_name(); - let native_device_id = app.native_device_id.as_deref(); - let current_device = app.current_playback_context.as_ref().map(|ctx| &ctx.device); - let current_device_id = current_device.and_then(|device| device.id.as_deref()); - let current_device_name_matches_native = - current_device.is_some_and(|device| device.name.eq_ignore_ascii_case(native_name)); - let native_has_fresh_activity = app.has_fresh_native_activity(); - - let saved_device_matches_native = saved_device_matches_native_player( - saved_device_id, - native_device_id, - app.devices.as_ref(), - native_name, - ); - - let saved_external_confirmed_available = saved_device_id.is_some_and(|saved| { - app.devices.as_ref().is_some_and(|payload| { - payload.devices.iter().any(|device| { - device.id.as_deref() == Some(saved) && !device.name.eq_ignore_ascii_case(native_name) - }) - }) - }); - - should_activate_native_for_playback(NativeActivationContext { - player_connected: true, - current_device_id_present: current_device_id.is_some(), - current_device_is_confirmed_native: native_device_id - .is_some_and(|id| current_device_id == Some(id)), - current_device_name_matches_native, - native_has_fresh_activity, - saved_device_matches_native, - saved_external_confirmed_available, - }) -} - -#[cfg(feature = "streaming")] -async fn request_native_streaming_recovery_if_disconnected(network: &Network) -> bool { - let mut app = network.app.lock().await; - app.request_native_streaming_recovery_if_disconnected(true) -} - -#[cfg(feature = "streaming")] -async fn requested_native_playback_origin( - network: &Network, - context_id: &Option>, - uris: &Option>>, -) -> NativePlaybackOrigin { - if context_id.is_some() { - return NativePlaybackOrigin::Context; - } - - if uris.is_some() { - return NativePlaybackOrigin::RawList; - } - - let app = network.app.lock().await; - if let Some(origin) = app.native_playback_origin { - return origin; - } - - if app - .current_playback_context - .as_ref() - .and_then(|ctx| ctx.context.as_ref()) - .is_some() - { - NativePlaybackOrigin::Context - } else { - NativePlaybackOrigin::RawList - } -} - -#[cfg(feature = "streaming")] -async fn resolve_native_playback_route( - network: &Network, - context_id: &Option>, -) -> NativePlaybackRoute { - if context_id.is_none() { - return NativePlaybackRoute::NativeLoad; - } - - let app = network.app.lock().await; - match app.native_device_id.clone() { - Some(device_id) => NativePlaybackRoute::ContextApi { device_id }, - None => NativePlaybackRoute::NativeLoad, - } -} - -/// Start a context on the native device through the Web API (`me/player/play` -/// with `device_id`), mirroring shuffle state. The direct spirc load is the -/// primary route (#386); this covers a load the player rejected outright and -/// the watchdog-recovery replay of a load that was accepted but silently -/// failed to resolve its context. -#[cfg(feature = "streaming")] -async fn start_native_context_via_api( - network: &mut Network, - device_id: String, - context: PlayContextId<'static>, - uris: Option<&[PlayableId<'static>]>, - offset: Option, - desired_shuffle_state: bool, -) { - let body = api_playback_body(Some(&context), uris, offset); - match network - .spotify_api_request_json( - Method::PUT, - "me/player/play", - &[("device_id", device_id.clone())], - body, - ) - .await - { - Ok(_) => { - // Playback started; a rejected shuffle toggle is non-fatal and must not - // be recorded as applied (native polling won't self-correct it). - let shuffle_applied = match network - .spotify_api_request_json( - Method::PUT, - "me/player/shuffle", - &[ - ("state", desired_shuffle_state.to_string()), - ("device_id", device_id), - ], - None, - ) - .await - { - Ok(_) => true, - Err(e) => { - warn!("native start succeeded but shuffle toggle was rejected: {e}"); - network - .show_status_message("Playback started; couldn't apply shuffle.".to_string(), 6) - .await; - false - } - }; - - let mut app = network.app.lock().await; - app.instant_since_last_current_playback_poll = Instant::now() - Duration::from_secs(6); - if let Some(ctx) = &mut app.current_playback_context { - ctx.is_playing = true; - if shuffle_applied { - ctx.shuffle_state = desired_shuffle_state; - } - } - if shuffle_applied { - app.runtime_state.shuffle_enabled = desired_shuffle_state; - app.schedule_state_save(crate::core::state::PersistedRuntimeState::shuffle_enabled( - desired_shuffle_state, - )); - } - // Keep the recovery chain alive: if the API accepted the start but the - // native device never emits a player event, the watchdog fires again and - // the bounded attempt counter eventually drops the request with a - // message instead of leaving it parked forever. - app.park_start_playback( - Some(context.uri()), - uris.map(|list| list.iter().map(|u| u.uri()).collect()), - offset, - ); - app.native_load_watchdog = Some(Instant::now()); - app.dispatch(IoEvent::GetCurrentPlayback); - } - Err(e) => { - if suppressed_restriction_violation_while_pending(network, &e).await { - return; - } - let mut app = network.app.lock().await; - // Both routes failed for this request; drop the parked copy so an - // unrelated later recovery can't replay it. - app.pending_start_playback = None; - app.handle_error(anyhow!("Failed to start native playback: {}", e)); - } - } -} - -#[cfg(feature = "streaming")] -fn native_load_request( - context_id: Option>, - uris: Option>>, - offset: Option, -) -> Option { - let mut options = LoadRequestOptions { - start_playing: true, - seek_to: 0, - context_options: None, - playing_track: None, - }; - - match (context_id, uris) { - (Some(context), Some(track_uris)) => { - if let Some(first_uri) = track_uris.first() { - options.playing_track = Some(PlayingTrack::Uri(first_uri.uri())); - } else if let Some(i) = offset.and_then(|i| u32::try_from(i).ok()) { - options.playing_track = Some(PlayingTrack::Index(i)); - } - Some(LoadRequest::from_context_uri(context.uri(), options)) - } - (Some(context), None) => { - if let Some(i) = offset.and_then(|i| u32::try_from(i).ok()) { - options.playing_track = Some(PlayingTrack::Index(i)); - } - Some(LoadRequest::from_context_uri(context.uri(), options)) - } - (None, Some(track_uris)) => { - if let Some(i) = offset.and_then(|i| u32::try_from(i).ok()) { - options.playing_track = Some(PlayingTrack::Index(i)); - } - let uris = track_uris.into_iter().map(|u| u.uri()).collect::>(); - Some(LoadRequest::from_tracks(uris, options)) - } - (None, None) => None, - } -} - -#[cfg(feature = "streaming")] -fn native_restore_load_request(snapshot: &NativePlaybackRecoverySnapshot) -> Option { - let mut options = LoadRequestOptions { - start_playing: snapshot.desired_playing, - seek_to: snapshot.restore_position_ms(), - context_options: Some(LoadContextOptions::Options(LoadOptions { - shuffle: snapshot.shuffle, - repeat: matches!(snapshot.repeat, RepeatState::Context | RepeatState::Track), - repeat_track: snapshot.repeat == RepeatState::Track, - })), - playing_track: snapshot - .expected_track_uri() - .map(|uri| PlayingTrack::Uri(uri.to_string())), - }; - - if options.playing_track.is_none() { - options.playing_track = snapshot - .offset - .and_then(|i| u32::try_from(i).ok()) - .map(PlayingTrack::Index); - } - - if let Some(context_uri) = snapshot.context_uri.clone() { - Some(LoadRequest::from_context_uri(context_uri, options)) - } else { - let uris = snapshot.uris.clone()?; - (!uris.is_empty()).then(|| LoadRequest::from_tracks(uris, options)) - } -} - -impl PlaybackNetwork for Network { - async fn get_current_playback(&mut self) { - // When using native streaming, the Spotify API returns stale server-side state - // that doesn't reflect recent local changes (volume, shuffle, repeat, play/pause). - // We need to preserve these local states and restore them after getting the API response. - #[cfg(feature = "streaming")] - let streaming_player = current_streaming_player(self).await; - #[cfg(feature = "streaming")] - self.native_idle_recovery.observe_player_instance( - streaming_player - .as_ref() - .filter(|player| player.is_available()) - .map(|player| Arc::as_ptr(player) as usize), - ); - #[cfg(feature = "streaming")] - // Check if native streaming is active by examining the pre-fetched player - // (avoids redundant lock call from is_native_streaming_active) - let local_state: Option<(Option, bool, rspotify::model::RepeatState, Option)> = - if streaming_player.as_ref().is_some_and(|p| p.is_available()) { - let app = self.app.lock().await; - if let Some(ref ctx) = app.current_playback_context { - let volume = streaming_player.as_ref().map(|p| p.get_volume()); - Some(( - volume, - ctx.shuffle_state, - ctx.repeat_state, - app.native_is_playing, - )) - } else { - None - } - } else { - None - }; - - let context = self - .spotify_get_typed::>( - "me/player", - &[("additional_types", "episode,track".to_string())], - ) - .await; - - let mut app = self.app.lock().await; - - // Cover-art download (network + synchronous image decode) must NOT happen - // in this handler: art is fetched by the shared track-change detector - // (see `core/driver/`), which dispatches `IoEvent::FetchCoverArt` off the - // `App` lock for every source. - match context { - #[allow(unused_mut)] - Ok(Some(mut c)) => { - app.instant_since_last_current_playback_poll = Instant::now(); - self.consecutive_playback_auth_failures = 0; - - // Detect whether the native spotatui streaming device is the active Spotify device. - #[cfg(feature = "streaming")] - let is_native_device = streaming_player.as_ref().is_some_and(|p| { - if let (Some(current_id), Some(native_id)) = - (c.device.id.as_ref(), app.native_device_id.as_ref()) - { - return current_id == native_id; - } - - let native_name = p.device_name().to_lowercase(); - c.device.name.to_lowercase() == native_name && app.has_fresh_native_activity() - }); - - #[cfg(feature = "streaming")] - if is_native_device && app.native_device_id.is_none() { - if let Some(id) = c.device.id.clone() { - app.native_device_id = Some(id); - } - } - - #[cfg(feature = "streaming")] - let native_streaming_was_active = app.is_streaming_active; - #[cfg(feature = "streaming")] - let native_activation_was_pending = app.native_activation_pending; - let native_track_id_before_api = app.last_track_id.clone(); - #[cfg(feature = "streaming")] - let native_track_id_present = native_track_id_before_api.is_some(); - #[cfg(feature = "streaming")] - let api_item_matches_native_track = c - .item - .as_ref() - .and_then(playable_item_id) - .as_deref() - .is_some_and(|api_id| Some(api_id) == native_track_id_before_api.as_deref()); - let api_item_confirms_native_info = app - .native_track_info - .as_ref() - .zip(c.item.as_ref()) - .is_some_and(|(native_info, item)| { - api_confirms_native_info_is_current( - &native_info.name, - item, - native_track_id_before_api.as_deref(), - ) - }); - #[cfg(feature = "streaming")] - let stale_api_item_for_native = - stale_api_item_should_preserve_native_context(StaleApiItemContext { - native_info_present: app.native_track_info.is_some(), - api_item_present: c.item.is_some(), - api_confirms_native_info: api_item_confirms_native_info, - native_track_id_present, - api_item_matches_native_track, - native_streaming_was_active, - native_activation_pending: native_activation_was_pending, - api_device_is_native: is_native_device, - }); - #[cfg(not(feature = "streaming"))] - let stale_api_item_for_native = - app.native_track_info.is_some() && c.item.is_some() && !api_item_confirms_native_info; - - // Process track info before storing context (avoids cloning) - if !stale_api_item_for_native { - if let Some(ref item) = c.item { - match item { - PlayableItem::Track(track) => { - if let Some(ref track_id) = track.id { - let track_id_str = track_id.id().to_string(); - - // Check if this is a new track - if app.last_track_id.as_ref() != Some(&track_id_str) { - if app.user_config.behavior.enable_global_song_count { - app.dispatch(IoEvent::IncrementGlobalSongCount); - } - - // Lyrics (and cover art) are now driven by the shared - // track-change detector in the UI tick, which works for every - // source — see `core/driver/`. No per-source dispatch here. - - app.dispatch(IoEvent::CurrentUserSavedTracksContains(vec![ - track_id_str.clone() - ])); - } - - app.last_track_id = Some(track_id_str); - }; - } - PlayableItem::Episode(_episode) => { /*should map this to following the podcast show*/ - } - _ => {} - } - }; - } - - // Preserve local streaming states (API returns stale server-side state) - #[cfg(feature = "streaming")] - if is_native_device { - if let Some((volume, shuffle, repeat, native_is_playing)) = local_state { - if let Some(vol) = volume { - c.device.volume_percent = Some(vol.into()); - } - c.shuffle_state = shuffle; - c.repeat_state = repeat; - // Preserve play/pause state from native player events when available. - if let Some(is_playing) = native_is_playing { - c.is_playing = is_playing; - } - } - } - - // Check if Spotify finally caught up to the user's volume change. - // If the API now returns what the user asked for, we can clear pending_volume - // and let the API take over again. If not, this response is stale — ignore it. - if let Some(pending) = app.pending_volume { - let api_vol = c.device.volume_percent.unwrap_or(0) as u8; - if api_vol == pending { - app.pending_volume = None; - app.last_dispatched_volume = None; - } else { - // API hasn't caught up yet — keep showing the user's intended value - if let Some(ctx) = app.current_playback_context.as_ref() { - c.device.volume_percent = ctx.device.volume_percent; - } - } - } - - // On first load with native streaming AND native device is active, - // override API shuffle with saved preference. - #[cfg(feature = "streaming")] - if local_state.is_none() && is_native_device { - c.shuffle_state = app.runtime_state.shuffle_enabled; - // Proactively set native shuffle on first load to keep backend in sync - if let Some(ref player) = streaming_player { - let _ = player.set_shuffle(app.runtime_state.shuffle_enabled); - } - } - - if !stale_api_item_for_native { - // Cover art (Spotify album/episode image) is fetched by the shared - // track-change detector in `core/driver/`, from the snapshot's image URL. - app.current_playback_context = Some(c); - } - - // Update is_streaming_active based on whether the current device matches native streaming - #[cfg(feature = "streaming")] - { - if stale_api_item_for_native { - app.is_streaming_active = true; - app.native_activation_pending = false; - } else { - app.is_streaming_active = is_native_device; - } - - if is_native_device { - app.native_activation_pending = false; - } - } - - // Keep native metadata authoritative while the native player is active. - // Spotify's playback endpoint can lag behind librespot by several seconds - // and report a different item; TrackChanged/Stopped events own this field. - #[cfg(feature = "streaming")] - if app.native_track_info.is_some() - && !stale_api_item_for_native - && (!is_native_device || api_item_confirms_native_info) - { - app.native_track_info = None; - } - } - Ok(None) => { - #[cfg(feature = "streaming")] - if let Some(player) = streaming_player.as_ref() { - reconcile_native_idle_device_if_preferred( - &mut self.client_config, - &mut app, - player, - &mut self.native_idle_recovery, - ); - } - app.instant_since_last_current_playback_poll = Instant::now(); - // Idle is still a poll the API answered: the session is fine. - self.consecutive_playback_auth_failures = 0; - } - Err(e) => { - app.is_fetching_current_playback = false; - - let err = anyhow!(e); - - if err.to_string().contains("429") - || err.to_string().contains("Too Many Requests") - || err.to_string().contains("Too many requests") - { - app.set_status_message( - "Spotify rate limit hit. Retrying automatically; please wait a few seconds.", - 6, - ); - app.instant_since_last_current_playback_poll = Instant::now(); - return; - } - - if err - .to_string() - .to_lowercase() - .contains("error sending request for url") - || err.to_string().contains("connection reset") - || err.to_string().contains("connection refused") - || err.to_string().contains("timed out") - || err.to_string().contains("temporary failure") - || err.to_string().contains("dns") - { - app.set_status_message( - "Temporary Spotify network error while polling playback; retrying automatically.", - 5, - ); - app.instant_since_last_current_playback_poll = Instant::now(); - return; - } - - if err.to_string().contains("504") - || err.to_string().contains("503") - || err.to_string().contains("502") - || err.to_string().contains("Gateway Timeout") - || err.to_string().contains("Service Unavailable") - || err.to_string().contains("Bad Gateway") - { - app.set_status_message( - "Spotify server temporarily unavailable (5xx); retrying automatically.", - 10, - ); - app.instant_since_last_current_playback_poll = Instant::now(); - return; - } - - // A 401 here is usually not a broken session: Spotify's player service - // intermittently rejects a valid token (issue #395, typically right at a - // track transition) and the very next poll succeeds with the same token. - // Escalating the first one to the full-screen error route interrupted - // playback the user could see was fine. Tolerate a short run, then - // surface it — a genuinely dead session fails every poll. - if err.to_string().contains("401") - || err.to_string().to_lowercase().contains("unauthorized") - { - self.consecutive_playback_auth_failures = - self.consecutive_playback_auth_failures.saturating_add(1); - - log::warn!( - "playback poll rejected with 401 ({}/{}): {}", - self.consecutive_playback_auth_failures, - MAX_CONSECUTIVE_PLAYBACK_AUTH_FAILURES, - err - ); - - if self.consecutive_playback_auth_failures < MAX_CONSECUTIVE_PLAYBACK_AUTH_FAILURES { - // Stay quiet on the first one (the self-healing case); only say - // something once it starts looking persistent. - if self.consecutive_playback_auth_failures > 1 { - app.set_status_message( - "Spotify keeps rejecting the playback poll; retrying automatically.".to_string(), - 5, - ); - } - app.instant_since_last_current_playback_poll = Instant::now(); - return; - } - - // Sustained failure: surface it, and start counting again so a still - // broken session re-reports periodically instead of every poll. - self.consecutive_playback_auth_failures = 0; - } - - // 404 = no active device/player; treat as idle, not an error - if err.to_string().contains("404") || err.to_string().contains("Not Found") { - app.current_playback_context = None; - // The API answered, so the token is good. - self.consecutive_playback_auth_failures = 0; - #[cfg(feature = "streaming")] - if let Some(player) = streaming_player.as_ref() { - reconcile_native_idle_device_if_preferred( - &mut self.client_config, - &mut app, - player, - &mut self.native_idle_recovery, - ); - } - app.instant_since_last_current_playback_poll = Instant::now(); - app.is_fetching_current_playback = false; - return; - } - - app.handle_error(err); - return; - } - } - - app.seek_ms.take(); - app.is_fetching_current_playback = false; - } - - /// Fetch and decode the current track's cover art, then store it. Runs entirely - /// off the `App` lock (the download/decode is the slow part and must never hold - /// the render loop's mutex, #142); the guard is only re-acquired at the end to - /// store the finished image and update the status. Cover art is non-essential, - /// so a failure only logs and flips the status to `Failed` (never surfaces a - /// blocking error). - #[cfg(feature = "art-decode")] - async fn fetch_cover_art(&mut self, request: crate::core::art::CoverArtRequest) { - use crate::core::art::{fetch_and_decode, CoverArtRequest, CoverArtStatus}; - - let key = request.key().to_string(); - - // Skip the download/decode when we already hold art for this exact key - // (e.g. consecutive tracks that share an album cover): just mark it loaded. - { - let mut app = self.app.lock().await; - if app.desired_cover_art_key.as_deref() != Some(key.as_str()) { - return; - } - if app.cover_art.key() == Some(key.as_str()) { - app.cover_art.status = CoverArtStatus::Loaded; - return; - } - } - - let result = match request { - CoverArtRequest::Url(url) => fetch_and_decode(&url).await, - #[cfg(feature = "local-files")] - CoverArtRequest::LocalFile { path, .. } => { - // Tag read + image decode are blocking; keep them off the async runtime. - match tokio::task::spawn_blocking(move || { - crate::infra::local::extract_embedded_cover(&path) - }) - .await - { - Ok(inner) => inner, - Err(join_err) => Err(anyhow!(join_err)), - } - } - }; - - // Dominant-color extraction for the adaptive theme: one pass over a 64x64 - // thumbnail, but still done here, off the App lock, like the decode above. - let palette = result - .as_ref() - .ok() - .and_then(crate::core::cover_theme::extract_palette); - - let mut app = self.app.lock().await; - if app.desired_cover_art_key.as_deref() != Some(key.as_str()) { - return; - } - match result { - Ok(img) => { - app.store_cover_art(key, img, palette); - app.cover_art.status = CoverArtStatus::Loaded; - } - Err(err) => { - log::warn!("cover art load failed: {err}"); - // Drop any stale art so the pane shows the "unavailable" placeholder - // rather than the previous track's image. - app.clear_cover_art(); - app.cover_art.status = CoverArtStatus::Failed; - } - } - } - - async fn start_playback( - &mut self, - context_id: Option>, - uris: Option>>, - offset: Option, - ) { - // An explicit Spotify start that reached this handler passed every source - // router and the pump's prelude: Spotify takes the sink, so the decoded - // claim ends here. A bare resume never releases it. - #[cfg(feature = "audio-decode")] - if context_id.is_some() || uris.is_some() { - self.app.lock().await.release_decoded_sink_claim(); - } - if decoded_source_owns_playback(self).await { - return; - } - let (uris, offset) = if context_id.is_none() { - match uris { - Some(track_uris) => { - let (trimmed_uris, trimmed_offset) = trim_api_playback_uris(track_uris, offset); - (Some(trimmed_uris), trimmed_offset) - } - None => (None, offset), - } - } else { - (uris, offset) - }; - - let desired_shuffle_state = { - let app = self.app.lock().await; - app - .current_playback_context - .as_ref() - .map(|ctx| ctx.shuffle_state) - .unwrap_or(app.runtime_state.shuffle_enabled) - }; - - // Any explicit new playback target invalidates the app-owned shuffle - // session; the interception below re-creates one when it applies. A bare - // resume (no context, no uris) keeps the session. - #[cfg(feature = "streaming")] - if context_id.is_some() || uris.is_some() { - self.app.lock().await.clear_native_shuffle_session(); - } - - // Check if we should use native streaming for playback - #[cfg(feature = "streaming")] - if request_native_streaming_recovery_if_disconnected(self).await { - // Park the request instead of dropping it: the recovery handler replays - // it once the new session and device selection are in place, so the - // press that detected the disconnect still plays. - let mut app = self.app.lock().await; - app.park_start_playback( - context_id.as_ref().map(|c| c.uri()), - uris - .as_ref() - .map(|list| list.iter().map(|u| u.uri()).collect()), - offset, - ); - return; - } - - #[cfg(feature = "streaming")] - if let PlaybackBackend::Native(player) = start_playback_backend(self).await { - let requested_origin = requested_native_playback_origin(self, &context_id, &uris).await; - let activation_time = Instant::now(); - let native_device_id = player.device_id(); - let (should_transfer, native_preference_update) = { - let app = self.app.lock().await; - let saved_device_matches_native = saved_device_matches_native_player( - self.client_config.device_id.as_deref(), - Some(&native_device_id), - app.devices.as_ref(), - player.device_name(), - ); - let activation_pending = app.native_activation_pending; - let recent_activation = app - .last_device_activation - .is_some_and(|instant| instant.elapsed() < Duration::from_secs(5)); - let should_transfer = if activation_pending { - !recent_activation - } else { - !app.is_streaming_active && !recent_activation - }; - - ( - should_transfer, - native_device_preference_update( - self.client_config.device_id.as_deref(), - false, - saved_device_matches_native, - ), - ) - }; - - if should_transfer { - // A failed transfer on a zombie session used to vanish silently; the - // load watchdog below is the recovery net, but keep the evidence. - if let Err(e) = player.transfer(None) { - warn!("native transfer failed: {e}"); - } - } - - player.activate(); - self - .native_idle_recovery - .settle_current_episode(activation_time); - { - let mut app = self.app.lock().await; - app.is_streaming_active = true; - app.last_device_activation = Some(activation_time); - app.native_activation_pending = false; - app.native_playback_origin = Some(requested_origin); - app.native_device_id = Some(native_device_id.clone()); - persist_native_device_id_if_needed( - &mut self.client_config, - &mut app, - &native_device_id, - native_preference_update, - ); - if context_id.is_none() && uris.is_none() { - app.set_native_playback_intent(true); - } else { - let repeat = app - .current_playback_context - .as_ref() - .map_or(RepeatState::Off, |ctx| ctx.repeat_state); - app.record_native_playback_request( - context_id.as_ref().map(|context| context.uri()), - uris - .as_ref() - .map(|items| items.iter().map(|item| item.uri()).collect()), - offset, - true, - desired_shuffle_state, - repeat, - ); - } - } - // Client-side shuffle: when shuffle is on and the target is a playlist, - // album, or Liked Songs, the app owns the play order (a pre-shuffled - // flat list loaded via `from_tracks`) instead of delegating shuffle to - // Spirc/Spotify — see `native_shuffle`. - if desired_shuffle_state - && self - .try_start_native_shuffled_playback(&player, &context_id, &uris, offset) - .await - { - return; - } - - let native_route = resolve_native_playback_route(self, &context_id).await; - - // For resume playback (no context, no uris) - if context_id.is_none() && uris.is_none() { - let can_resume_direct_native = { - let app = self.app.lock().await; - app.native_track_info.is_some() || app.last_track_id.is_some() - }; - - if can_resume_direct_native { - info!("starting native resume playback via direct player route"); - player.play(); - let mut app = self.app.lock().await; - if let Some(ctx) = &mut app.current_playback_context { - ctx.is_playing = true; - } - } else { - info!( - "starting native resume playback via Spotify API on device {}", - native_device_id - ); - match self - .spotify_api_request_json( - Method::PUT, - "me/player/play", - &[("device_id", native_device_id.clone())], - None, - ) - .await - { - Ok(_) => { - let mut app = self.app.lock().await; - app.native_device_id = Some(native_device_id); - if let Some(ctx) = &mut app.current_playback_context { - ctx.is_playing = true; - } - app.dispatch(IoEvent::GetCurrentPlayback); - } - Err(e) => { - let mut app = self.app.lock().await; - app.set_status_message( - format!("No playback to resume on {}.", player.device_name()), - 4, - ); - info!("native resume via Spotify API failed: {}", e); - } - } - } - return; - } - - // Keep the string form for the load watchdog: if the session turns out - // to be a zombie (load accepted, no player event follows), recovery - // replays this exact request. - let parked_context = context_id.as_ref().map(|c| c.uri()); - let parked_uris = uris - .as_ref() - .map(|list| list.iter().map(|u| u.uri()).collect::>()); - - // Native context starts take the direct spirc load first - // (`from_context_uri`): context resolution happens inside librespot's - // connect task, so a slow or degraded Spotify session cannot stall the - // serial IoEvent pump the way a `me/player/play` round trip can (one - // stalled call blocks every queued playback command for the full - // timeout-and-retry cycle, #386). The watchdog covers silent failures; - // the Web API context route remains as fallback for a load the player - // rejects outright. - let api_fallback = match (&native_route, &context_id) { - (NativePlaybackRoute::ContextApi { device_id }, Some(context)) => { - Some((device_id.clone(), context.clone())) - } - _ => None, - }; - - // A watchdog-recovery replay means a direct load of this same request - // was already accepted once and failed silently (the context never - // resolved); take the Web API context route this time instead of - // looping through the same silent failure until the request is dropped. - let retry_via_api = { - let app = self.app.lock().await; - app.pending_start_playback.as_ref().is_some_and(|pending| { - pending.recovery_attempts > 0 - && pending.context_uri == parked_context - && pending.uris == parked_uris - && pending.offset == offset - }) - }; - if retry_via_api { - if let Some((device_id, context)) = api_fallback.clone() { - info!( - "recovery replay: starting native playback via Spotify context route on device {device_id}" - ); - start_native_context_via_api( - self, - device_id, - context, - uris.as_deref(), - offset, - desired_shuffle_state, - ) - .await; - return; - } - } - - let Some(request) = native_load_request(context_id, uris.clone(), offset) else { - return; - }; - - info!("starting native playback via direct load route"); - match player.load(request) { - Ok(()) => { - let _ = player.set_shuffle(desired_shuffle_state); - // Optimistic UI update; the watchdog corrects it if no player event - // ever confirms the load (zombie session). - let mut app = self.app.lock().await; - app.park_start_playback(parked_context, parked_uris, offset); - app.native_load_watchdog = Some(Instant::now()); - if let Some(ctx) = &mut app.current_playback_context { - ctx.is_playing = true; - ctx.shuffle_state = desired_shuffle_state; - } - app.runtime_state.shuffle_enabled = desired_shuffle_state; - app.schedule_state_save(crate::core::state::PersistedRuntimeState::shuffle_enabled( - desired_shuffle_state, - )); - } - Err(load_err) => { - let Some((device_id, context)) = api_fallback else { - let mut app = self.app.lock().await; - app.handle_error(anyhow!("Failed to start native playback: {}", load_err)); - return; - }; - info!( - "direct native load failed ({load_err}); falling back to Spotify context route on device {device_id}" - ); - start_native_context_via_api( - self, - device_id, - context, - uris.as_deref(), - offset, - desired_shuffle_state, - ) - .await; - } - } - return; - } - - let body = api_playback_body(context_id.as_ref(), uris.as_deref(), offset); - let result = - player_command_with_saved_device_retry(self, Method::PUT, "me/player/play", &[], body).await; - - match result { - Ok(_) => { - let shuffle_applied = match self - .spotify_api_request_json( - Method::PUT, - "me/player/shuffle", - &[("state", desired_shuffle_state.to_string())], - None, - ) - .await - { - Ok(_) => true, - Err(e) => { - log::warn!("start succeeded but shuffle toggle was rejected: {e}"); - self - .show_status_message("Playback started; couldn't apply shuffle.".to_string(), 6) - .await; - false - } - }; - - let mut app = self.app.lock().await; - if let Some(ctx) = &mut app.current_playback_context { - ctx.is_playing = true; - if shuffle_applied { - ctx.shuffle_state = desired_shuffle_state; - } - } - if shuffle_applied { - app.runtime_state.shuffle_enabled = desired_shuffle_state; - app.schedule_state_save(crate::core::state::PersistedRuntimeState::shuffle_enabled( - desired_shuffle_state, - )); - } - } - Err(e) => { - #[cfg(feature = "streaming")] - if is_no_active_device_error(&e) { - if let Some(player) = current_streaming_player(self).await { - if player.is_available() { - let requested_origin = - requested_native_playback_origin(self, &context_id, &uris).await; - let activation_time = Instant::now(); - let native_device_id = player.device_id(); - player.activate(); - self - .native_idle_recovery - .settle_current_episode(activation_time); - { - let mut app = self.app.lock().await; - let saved_device_matches_native = saved_device_matches_native_player( - self.client_config.device_id.as_deref(), - Some(&native_device_id), - app.devices.as_ref(), - player.device_name(), - ); - let native_preference_update = native_device_preference_update( - self.client_config.device_id.as_deref(), - false, - saved_device_matches_native, - ); - app.is_streaming_active = true; - app.native_activation_pending = false; - app.native_playback_origin = Some(requested_origin); - app.native_device_id = Some(native_device_id.clone()); - app.last_device_activation = Some(activation_time); - app.instant_since_last_current_playback_poll = - activation_time - Duration::from_secs(6); - persist_native_device_id_if_needed( - &mut self.client_config, - &mut app, - &native_device_id, - native_preference_update, - ); - } - - let parked_context = context_id.as_ref().map(|c| c.uri()); - let parked_uris = uris - .as_ref() - .map(|list| list.iter().map(|u| u.uri()).collect::>()); - if let Some(request) = native_load_request(context_id, uris, offset) { - info!("default Spotify playback had no active device; falling back to native load"); - if let Err(load_err) = player.load(request) { - let mut app = self.app.lock().await; - app.handle_error(anyhow!("Failed to start native playback: {}", load_err)); - } else { - let _ = player.set_shuffle(desired_shuffle_state); - let mut app = self.app.lock().await; - let repeat = app - .current_playback_context - .as_ref() - .map_or(RepeatState::Off, |ctx| ctx.repeat_state); - app.record_native_playback_request( - parked_context.clone(), - parked_uris.clone(), - offset, - true, - desired_shuffle_state, - repeat, - ); - // Same zombie-session net as the direct load route. - app.park_start_playback(parked_context, parked_uris, offset); - app.native_load_watchdog = Some(Instant::now()); - if let Some(ctx) = &mut app.current_playback_context { - ctx.is_playing = true; - ctx.shuffle_state = desired_shuffle_state; - } - app.runtime_state.shuffle_enabled = desired_shuffle_state; - app.schedule_state_save( - crate::core::state::PersistedRuntimeState::shuffle_enabled( - desired_shuffle_state, - ), - ); - } - return; - } - - info!( - "default Spotify resume had no active device; retrying on native device {}", - native_device_id - ); - match self - .spotify_api_request_json( - Method::PUT, - "me/player/play", - &[("device_id", native_device_id.clone())], - None, - ) - .await - { - Ok(_) => { - let mut app = self.app.lock().await; - if let Some(ctx) = &mut app.current_playback_context { - ctx.is_playing = true; - } - app.dispatch(IoEvent::GetCurrentPlayback); - } - Err(resume_err) => { - let mut app = self.app.lock().await; - app.set_status_message( - format!("No playback to resume on {}.", player.device_name()), - 4, - ); - info!("native resume fallback failed: {}", resume_err); - } - } - return; - } - } - - // No usable backend right now, but one may materialize shortly - // (recovery in flight, or deferred startup init still running): - // park the request for replay instead of routing to the - // full-screen error, which is what made every press during the - // recovery window cost a paced round trip ending on Error. - let mut app = self.app.lock().await; - if app.native_backend_pending { - app.park_start_playback( - context_id.as_ref().map(|c| c.uri()), - uris - .as_ref() - .map(|list| list.iter().map(|u| u.uri()).collect()), - offset, - ); - app.set_status_message("Reconnecting native streaming; playback will resume.", 6); - return; - } - drop(app); - } - - // De-escalate a native-settling restriction violation; don't park it - // (no replay-owner guarantee). External plays succeed rather than 403. - #[cfg(feature = "streaming")] - if suppressed_restriction_violation_while_pending(self, &e).await { - return; - } - - let mut app = self.app.lock().await; - app.handle_error(e); - } - } - } - - #[cfg(feature = "streaming")] - async fn restore_native_playback(&mut self, generation: u64) { - if decoded_source_owns_playback(self).await { - warn!("native restore {generation} skipped: a decoded source owns playback"); - return; - } - let (player, snapshot) = { - let mut app = self.app.lock().await; - if app.pending_start_playback.is_some() { - warn!("native restore {generation} skipped: a parked StartPlayback owns the replay"); - return; - } - // A stale RestoreNativePlayback (e.g. queued before a handoff cleared - // the snapshot) must not escalate into a reselecting recovery (#437). - if app.native_playback_restore_generation() != Some(generation) { - warn!( - "native restore {generation} skipped: snapshot generation is now {:?}", - app.native_playback_restore_generation() - ); - return; - } - let Some(player) = app - .streaming_player - .as_ref() - .filter(|player| player.is_connected()) - .cloned() - else { - warn!("native restore {generation} skipped: no connected player; forcing recovery"); - app.force_native_streaming_recovery(true); - return; - }; - let Some(snapshot) = app.begin_native_playback_restore(generation) else { - warn!("native restore {generation} skipped: the snapshot moved on mid-restore"); - return; - }; - app.native_load_watchdog = Some(Instant::now()); - app.set_status_message("Native connection restored; restoring playback.", 6); - (player, snapshot) - }; - - let Some(request) = native_restore_load_request(&snapshot) else { - warn!( - "native restore {generation} skipped: the snapshot carries no loadable context or uris" - ); - let mut app = self.app.lock().await; - if app.native_playback_restore_generation() == Some(generation) { - app.native_restore_pending = None; - app.native_load_watchdog = None; - app.set_status_message( - "Native connection recovered, but there was no playback state to restore.", - 8, - ); - } - return; - }; - - info!( - "restoring native playback generation {} track {:?} position {} playing {}", - generation, - snapshot.expected_track_uri(), - snapshot.restore_position_ms(), - snapshot.desired_playing - ); - player.activate(); - if let Err(e) = player.load(request) { - warn!( - "failed to issue native playback restore generation {}: {}", - generation, e - ); - let mut app = self.app.lock().await; - if app.native_playback_restore_generation() == Some(generation) { - app.native_restore_pending = None; - app.native_load_watchdog = None; - app.set_status_message("Native playback restore failed; reconnecting again.", 8); - app.force_native_streaming_recovery(true); - } - return; - } - - let mut app = self.app.lock().await; - if app.native_playback_restore_generation() == Some(generation) { - app.is_streaming_active = true; - app.native_is_playing = Some(snapshot.desired_playing); - app.song_progress_ms = snapshot.restore_position_ms() as u128; - if let Some(ctx) = &mut app.current_playback_context { - ctx.is_playing = snapshot.desired_playing; - ctx.shuffle_state = snapshot.shuffle; - ctx.repeat_state = snapshot.repeat; - } - } - } - - async fn pause_playback(&mut self) { - if decoded_source_owns_playback(self).await { - return; - } - #[cfg(feature = "streaming")] - { - let mut app = self.app.lock().await; - app.pending_start_playback = None; - app.native_load_watchdog = None; - } - // Check if using native streaming - #[cfg(feature = "streaming")] - if let PlaybackBackend::Native(player) = symmetric_playback_backend(self).await { - player.pause(); - // Update UI state immediately - let mut app = self.app.lock().await; - app.set_native_playback_intent(false); - app.native_is_playing = Some(false); - if let Some(ctx) = &mut app.current_playback_context { - ctx.is_playing = false; - } - return; - } - - match player_command_with_saved_device_retry(self, Method::PUT, "me/player/pause", &[], None) - .await - { - Ok(_) => { - let mut app = self.app.lock().await; - if let Some(ctx) = &mut app.current_playback_context { - ctx.is_playing = false; - } - } - Err(e) => { - #[cfg(feature = "streaming")] - if suppressed_transient_native_command_error(self, &e).await { - return; - } - let mut app = self.app.lock().await; - app.handle_error(anyhow!(e)); - } - } - } - - async fn next_track(&mut self) { - if decoded_source_owns_playback(self).await { - return; - } - #[cfg(feature = "streaming")] - { - let mut app = self.app.lock().await; - app.pending_start_playback = None; - app.native_load_watchdog = None; - } - #[cfg(feature = "streaming")] - if let PlaybackBackend::Native(player) = symmetric_playback_backend(self).await { - player.next(); - return; - } - - if let Err(e) = - player_command_with_saved_device_retry(self, Method::POST, "me/player/next", &[], None).await - { - #[cfg(feature = "streaming")] - if suppressed_transient_native_command_error(self, &e).await { - return; - } - let mut app = self.app.lock().await; - app.handle_error(anyhow!(e)); - } - } - - async fn previous_track(&mut self) { - if decoded_source_owns_playback(self).await { - return; - } - #[cfg(feature = "streaming")] - { - let mut app = self.app.lock().await; - app.pending_start_playback = None; - app.native_load_watchdog = None; - } - #[cfg(feature = "streaming")] - if let PlaybackBackend::Native(player) = symmetric_playback_backend(self).await { - player.prev(); - return; - } - - if let Err(e) = - player_command_with_saved_device_retry(self, Method::POST, "me/player/previous", &[], None) - .await - { - #[cfg(feature = "streaming")] - if suppressed_transient_native_command_error(self, &e).await { - return; - } - let mut app = self.app.lock().await; - app.handle_error(anyhow!(e)); - } - } - - async fn force_previous_track(&mut self) { - if decoded_source_owns_playback(self).await { - return; - } - #[cfg(feature = "streaming")] - if let PlaybackBackend::Native(player) = symmetric_playback_backend(self).await { - player.prev(); - // The second prev (which actually skips back once the position has reset - // to 0) runs on a detached task so the intentional 500ms gap doesn't - // block every other IoEvent on the serial pump. It re-checks that the - // same librespot still owns the sink after the gap. - let app = Arc::clone(&self.app); - tokio::spawn(async move { - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - let still_owns = { - let app = app.lock().await; - app.playback_owner() == PlaybackOwner::NativeSpotify - && app - .streaming_player - .as_ref() - .is_some_and(|current| Arc::ptr_eq(current, &player)) - }; - if still_owns { - player.prev(); - } - }); - return; - } - - // First previous_track restarts the current track (if past Spotify's ~3s - // threshold). After a short delay the second call actually skips to the - // previous track, since the position is now back at 0. - if let Err(e) = - player_command_with_saved_device_retry(self, Method::POST, "me/player/previous", &[], None) - .await - { - #[cfg(feature = "streaming")] - if suppressed_transient_native_command_error(self, &e).await { - return; - } - let mut app = self.app.lock().await; - app.handle_error(anyhow!(e)); - return; - } - - // Re-dispatch the second call after the delay instead of sleeping on the - // pump: a plain PreviousTrack with the position back at 0 skips to the - // previous track, which is exactly the second half of the double-press - // semantics. - // The re-send is dropped when the sink changed hands during the gap. - let app = Arc::clone(&self.app); - tokio::spawn(async move { - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - let app = app.lock().await; - if app.playback_owner() != PlaybackOwner::Spotify { - return; - } - if let Some(io_tx) = app.io_tx_clone() { - let _ = io_tx.send(IoEvent::PreviousTrack); - } - }); - } - - async fn seek(&mut self, position_ms: u32) { - if decoded_source_owns_playback(self).await { - return; - } - #[cfg(feature = "streaming")] - if let PlaybackBackend::Native(player) = symmetric_playback_backend(self).await { - player.seek(position_ms); - self - .app - .lock() - .await - .set_native_recovery_position(position_ms); - return; - } - - if let Err(e) = player_command_with_saved_device_retry( - self, - Method::PUT, - "me/player/seek", - &[("position_ms", position_ms.to_string())], - None, - ) - .await - { - #[cfg(feature = "streaming")] - if suppressed_transient_native_command_error(self, &e).await { - return; - } - let mut app = self.app.lock().await; - app.handle_error(anyhow!(e)); - } - } - - async fn shuffle(&mut self, shuffle_state: bool) { - if decoded_source_owns_playback(self).await { - return; - } - #[cfg(feature = "streaming")] - if let PlaybackBackend::Native(player) = symmetric_playback_backend(self).await { - let _ = player.set_shuffle(shuffle_state); - let mut app = self.app.lock().await; - app.set_native_recovery_shuffle(shuffle_state); - if let Some(ctx) = &mut app.current_playback_context { - ctx.shuffle_state = shuffle_state; - } - app.runtime_state.shuffle_enabled = shuffle_state; - app.schedule_state_save(crate::core::state::PersistedRuntimeState::shuffle_enabled( - shuffle_state, - )); - return; - } - - match player_command_with_saved_device_retry( - self, - Method::PUT, - "me/player/shuffle", - &[("state", shuffle_state.to_string())], - None, - ) - .await - { - Ok(_) => { - let mut app = self.app.lock().await; - if let Some(ctx) = &mut app.current_playback_context { - ctx.shuffle_state = shuffle_state; - } - app.runtime_state.shuffle_enabled = shuffle_state; - app.schedule_state_save(crate::core::state::PersistedRuntimeState::shuffle_enabled( - shuffle_state, - )); - } - Err(e) => { - #[cfg(feature = "streaming")] - if suppressed_transient_native_command_error(self, &e).await { - return; - } - let mut app = self.app.lock().await; - app.handle_error(anyhow!(e)); - } - } - } - - async fn repeat(&mut self, repeat_state: RepeatState) { - if decoded_source_owns_playback(self).await { - return; - } - #[cfg(feature = "streaming")] - if let PlaybackBackend::Native(player) = symmetric_playback_backend(self).await { - let _ = player.set_repeat(repeat_state); - let mut app = self.app.lock().await; - app.set_native_recovery_repeat(repeat_state); - if let Some(ctx) = &mut app.current_playback_context { - ctx.repeat_state = repeat_state; - } - return; - } - - let repeat_state_param: &'static str = repeat_state.into(); - match player_command_with_saved_device_retry( - self, - Method::PUT, - "me/player/repeat", - &[("state", repeat_state_param.to_string())], - None, - ) - .await - { - Ok(_) => { - let mut app = self.app.lock().await; - if let Some(ctx) = &mut app.current_playback_context { - ctx.repeat_state = repeat_state; - } - } - Err(e) => { - #[cfg(feature = "streaming")] - if suppressed_transient_native_command_error(self, &e).await { - return; - } - let mut app = self.app.lock().await; - app.handle_error(anyhow!(e)); - } - } - } - - /// Sends the volume change to Spotify, either through the native streaming - /// player or the Web API depending on which device is active. - /// - /// On success we clear the in-flight flag but keep `pending_volume` around. - /// It only gets cleared when `get_current_playback` comes back with a matching - /// volume — that's our signal that Spotify actually caught up. - /// - /// On error we bail and clear everything so the UI falls back to whatever - /// the API last reported. - async fn change_volume(&mut self, volume: u8) { - if decoded_source_owns_playback(self).await { - return; - } - #[cfg(feature = "streaming")] - if let PlaybackBackend::Native(player) = symmetric_playback_backend(self).await { - player.set_volume(volume); - let mut app = self.app.lock().await; - if let Some(ctx) = &mut app.current_playback_context { - ctx.device.volume_percent = Some(volume.into()); - } - app.runtime_state.volume_percent = volume.min(100); - app.schedule_state_save(crate::core::state::PersistedRuntimeState::volume_percent( - volume.min(100), - )); - app.is_volume_change_in_flight = false; - app.last_dispatched_volume = Some(volume); - // Keep pending_volume set — cleared when API confirms the value matches - return; - } - - match player_command_with_saved_device_retry( - self, - Method::PUT, - "me/player/volume", - &[("volume_percent", volume.to_string())], - None, - ) - .await - { - Ok(_) => { - let mut app = self.app.lock().await; - if let Some(ctx) = &mut app.current_playback_context { - ctx.device.volume_percent = Some(volume.into()); - } - app.runtime_state.volume_percent = volume.min(100); - app.schedule_state_save(crate::core::state::PersistedRuntimeState::volume_percent( - volume.min(100), - )); - app.is_volume_change_in_flight = false; - app.last_dispatched_volume = Some(volume); - // Keep pending_volume set — cleared when get_current_playback confirms - } - Err(e) => { - self.app.lock().await.cancel_volume_change(); - #[cfg(feature = "streaming")] - if suppressed_transient_native_command_error(self, &e).await { - return; - } - let mut app = self.app.lock().await; - app.handle_error(anyhow!(e)); - } - } - } - - async fn transfert_playback_to_device(&mut self, device_id: String, persist_device_id: bool) { - #[cfg(feature = "streaming")] - let backend = transfer_playback_backend(self, &device_id).await; - // Only the hand-over to librespot touches the local sink; an external - // device stays a valid target. - #[cfg(feature = "streaming")] - if matches!(backend, PlaybackBackend::Native(_)) && decoded_source_owns_playback(self).await { - self - .show_status_message("Another source owns playback".to_string(), 4) - .await; - return; - } - // A device change moves playback off the session's `from_tracks` load; - // the app-owned shuffle order no longer describes what plays. - #[cfg(feature = "streaming")] - self.app.lock().await.clear_native_shuffle_session(); - #[cfg(feature = "streaming")] - if let PlaybackBackend::Native(player) = backend { - let activation_time = Instant::now(); - let native_device_id = player.device_id(); - let _ = player.transfer(None); - player.activate(); - self - .native_idle_recovery - .settle_current_episode(activation_time); - let mut app = self.app.lock().await; - let saved_device_matches_native = saved_device_matches_native_player( - self.client_config.device_id.as_deref(), - Some(&native_device_id), - app.devices.as_ref(), - player.device_name(), - ); - let native_preference_update = native_device_preference_update( - self.client_config.device_id.as_deref(), - persist_device_id, - saved_device_matches_native, - ); - app.is_streaming_active = true; - app.native_activation_pending = true; - app.native_playback_origin = None; - app.native_device_id = Some(native_device_id.clone()); - // Drop the stale previous-device context so playback routing follows the - // native intent (is_streaming_active) until the next poll repopulates it - // — mirrors the non-native transfer branch below. Without this, the first - // play can leak to the official Spotify client / 404 (#282). - app.current_playback_context = None; - app.last_device_activation = Some(activation_time); - app.instant_since_last_current_playback_poll = activation_time - Duration::from_secs(6); - persist_native_device_id_if_needed( - &mut self.client_config, - &mut app, - &native_device_id, - native_preference_update, - ); - return; - } - - if let Err(e) = self - .spotify_api_request_json( - Method::PUT, - "me/player", - &[], - Some(json!({ - "device_ids": [device_id.clone()], - "play": true - })), - ) - .await - { - let mut app = self.app.lock().await; - app.handle_error(anyhow!(e)); - } else { - let mut app = self.app.lock().await; - if persist_device_id { - // Update via client_config helper to save to file - if let Err(e) = self.client_config.set_device_id(device_id) { - app.handle_error(anyhow!(e)); - } - } - app.current_playback_context = None; - - #[cfg(feature = "streaming")] - { - // If transferring away from native, update flag - app.is_streaming_active = false; - app.native_playback_origin = None; - app.clear_native_playback_recovery(); - } - } - } - - #[cfg(feature = "streaming")] - async fn auto_select_streaming_device( - &mut self, - device_name: String, - persist_device_id: bool, - yield_to_external_playback: bool, - ) { - if let Some(player) = current_streaming_player(self).await { - let activation_time = Instant::now(); - let native_device_id = player.device_id(); - let (should_transfer, external_playback_active, native_preference_update) = { - let app = self.app.lock().await; - let saved_device_matches_native = saved_device_matches_native_player( - self.client_config.device_id.as_deref(), - Some(&native_device_id), - app.devices.as_ref(), - player.device_name(), - ); - let recent_activation = app - .last_device_activation - .is_some_and(|instant| instant.elapsed() < Duration::from_secs(5)); - // Recovery re-registration only (startup passes false): if the - // refreshed context shows another device actively playing, register - // idle instead of transferring playback off it. - let external_playback_active = app.current_playback_context.as_ref().is_some_and(|ctx| { - should_yield_native_recovery( - yield_to_external_playback, - ctx.is_playing, - ctx.device.id.as_deref(), - &native_device_id, - ) - }); - ( - !app.native_activation_pending - && !app.is_streaming_active - && !recent_activation - && !external_playback_active, - external_playback_active, - native_device_preference_update( - self.client_config.device_id.as_deref(), - persist_device_id, - saved_device_matches_native, - ), - ) - }; - - { - let mut app = self.app.lock().await; - if external_playback_active { - clear_native_recovery_intent_for_external_playback(&mut app); - app.set_status_message("Playback remains on another Spotify device.", 8); - } else { - app.is_streaming_active = true; - app.native_activation_pending = true; - app.last_device_activation = Some(activation_time); - app.instant_since_last_current_playback_poll = activation_time - Duration::from_secs(6); - } - } - - if !external_playback_active { - if should_transfer { - let _ = player.transfer(None); - } - player.activate(); - } - self - .native_idle_recovery - .settle_current_episode(activation_time); - - { - let mut app = self.app.lock().await; - app.is_streaming_active = !external_playback_active; - app.native_activation_pending = false; - app.native_device_id = Some(native_device_id.clone()); - if external_playback_active { - app.last_device_activation = None; - } else { - app.last_device_activation = Some(activation_time); - app.instant_since_last_current_playback_poll = activation_time - Duration::from_secs(6); - } - persist_native_device_id_if_needed( - &mut self.client_config, - &mut app, - &native_device_id, - native_preference_update, - ); - } - - for _ in 0..2 { - tokio::time::sleep(Duration::from_millis(200)).await; - - match self - .spotify_get_typed::("me/player/devices", &[]) - .await - { - Ok(payload) => { - let native_confirmed = - spotify_payload_confirms_native_device(&payload, &native_device_id); - let name_seen = payload - .devices - .iter() - .any(|device| device.name.eq_ignore_ascii_case(&device_name)); - - if native_confirmed || name_seen { - let mut app = self.app.lock().await; - app.devices = Some(payload); - app - .plugin_data_generations - .bump(crate::core::app::PluginDataKind::Devices); - } - - if native_confirmed { - return; - } - } - Err(_) => continue, - } - } - } - } - - async fn ensure_playback_continues(&mut self, previous_track_id: String) { - #[cfg(feature = "streaming")] - let native_active = is_native_streaming_active_for_playback(self).await; - #[cfg(feature = "streaming")] - info!("continuation for {previous_track_id}: handling, native_active={native_active}"); - #[cfg(feature = "streaming")] - if native_active { - let (transition_advanced, raw_next, raw_list_exhausted) = { - let app = self.app.lock().await; - ( - app.native_transition_has_advanced(&previous_track_id), - app.native_raw_list_next_request(&previous_track_id), - app.native_raw_list_playback_exhausted(&previous_track_id), - ) - }; - if transition_advanced { - info!("continuation for {previous_track_id}: playback already advanced"); - return; - } - if let Some(next) = raw_next { - info!("continuation for {previous_track_id}: starting next raw-list track"); - let uris = next - .uris - .map(|items| crate::infra::network::ids::playable_ids(&items)); - self.start_playback(None, uris, next.offset).await; - return; - } - if raw_list_exhausted { - // A raw URI list with repeat off has no track after the one that just - // ended, so stopping is the correct outcome. Record stopped intent so - // the progress watchdog does not read the silence as a stall and - // rebuild the backend, and so a later recovery cannot replay the - // final track. - info!("continuation for {previous_track_id}: raw uri list exhausted; staying stopped"); - self.app.lock().await.set_native_playback_intent(false); - return; - } - } - - // Check if we are paused/stopped - let context = self - .spotify_get_typed::>("me/player", &[]) - .await; - - if let Ok(Some(ctx)) = context { - if !ctx.is_playing { - let current_id = ctx.item.as_ref().and_then(|item| match item { - PlayableItem::Track(t) => t.id.as_ref().map(|id| id.id().to_string()), - PlayableItem::Episode(e) => Some(e.id.id().to_string()), - _ => None, - }); - let still_on_finished_track = current_id.as_deref().is_some_and(|current| { - spotify_item_identity(current) == spotify_item_identity(&previous_track_id) - }) && ctx - .progress - .map(|d: TimeDelta| d.num_milliseconds()) - .unwrap_or(0) - == 0; - - if still_on_finished_track { - log::info!( - "continuation for {previous_track_id}: still on finished track, skipping ahead" - ); - #[cfg(feature = "streaming")] - if native_active { - let native_device_id = self.app.lock().await.native_device_id.clone(); - let Some(native_device_id) = native_device_id else { - let mut app = self.app.lock().await; - app.force_native_streaming_recovery(true); - return; - }; - if let Err(e) = self - .spotify_api_request_json( - Method::POST, - "me/player/next", - &[("device_id", native_device_id)], - None, - ) - .await - { - warn!("failed to advance stalled native playback via Web API: {e}"); - self.app.lock().await.force_native_streaming_recovery(true); - } - return; - } - - self.next_track().await; - } else if current_id.is_some() { - // Spirc may already have selected the next item but left it paused. - // Resume that item instead of issuing another skip and losing a track. - log::info!( - "continuation for {previous_track_id}: resuming the already-selected next item" - ); - self.start_playback(None, None, None).await; - } else { - warn!( - "continuation for {previous_track_id}: paused with no current item; nothing to resume" - ); - } - } else { - info!("continuation for {previous_track_id}: the API already reports playback running"); - } - } else { - warn!( - "continuation for {previous_track_id}: no playback context from the API; nothing to resume" - ); - } - } - - async fn resume_spotify_context( - &mut self, - context_uri: Option, - resume_track_uri: Option, - ) { - use crate::infra::network::ids; - let context = context_uri.as_deref().and_then(ids::play_context_id); - let track = resume_track_uri.as_deref().and_then(ids::playable_id); - - // Reuse the existing `start_playback` machinery (device activation/transfer - // included). Passing the resume track as a single-item `uris` alongside the - // context yields an offset-by-uri start (see `api_playback_offset_json` / - // `native_load_request`), i.e. the context resumes at that track. A plain - // `spirc.play()` can't do this after a direct `player.load`, which is why - // the context is re-loaded here. - match (context, track) { - (Some(context), Some(track)) => { - self - .start_playback(Some(context), Some(vec![track]), None) - .await; - } - (Some(context), None) => { - self.start_playback(Some(context), None, None).await; - } - (None, Some(track)) => { - self.start_playback(None, Some(vec![track]), None).await; - } - (None, None) => { - let mut app = self.app.lock().await; - app.set_status_message("Queue finished", 3); - } - } - } - - async fn add_item_to_queue(&mut self, item: PlayableId<'static>) { - match self - .spotify_api_request_json( - Method::POST, - "me/player/queue", - &[("uri", item.uri())], - None, - ) - .await - { - Ok(_) => { - let mut app = self.app.lock().await; - app.set_status_message("Added to queue".to_string(), 3); - } - Err(e) => { - #[cfg(feature = "streaming")] - if suppressed_transient_native_command_error(self, &e).await { - return; - } - let mut app = self.app.lock().await; - app.handle_error(anyhow!(e)); - } - } - } - - async fn get_queue(&mut self) { - match self - .spotify_get_typed::("me/player/queue", &[]) - .await - { - Ok(q) => { - use crate::core::app::QueueState; - use crate::infra::network::mapping; - let domain_queue = QueueState { - currently_playing: q - .currently_playing - .as_ref() - .and_then(mapping::playable_info), - queue: q.queue.iter().filter_map(mapping::playable_info).collect(), - }; - let mut app = self.app.lock().await; - app.queue = Some(domain_queue); - app - .plugin_data_generations - .bump(crate::core::app::PluginDataKind::Queue); - } - Err(e) => { - let mut app = self.app.lock().await; - app.queue = None; - // Bump on failure too: completion (not success) is what plugin data - // requests wait on. - app - .plugin_data_generations - .bump(crate::core::app::PluginDataKind::Queue); - app.set_status_message("Could not load queue (no active device?)".to_string(), 3); - log::warn!("get_queue failed: {}", e); - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use rspotify::model::{ - artist::SimplifiedArtist, idtypes::TrackId, track::FullTrack, SimplifiedAlbum, - }; - #[cfg(feature = "streaming")] - use rspotify::model::{device::Device, DeviceType}; - use rspotify::prelude::Id; - use std::collections::HashMap; - - fn playable_track(id: &str) -> PlayableId<'static> { - PlayableId::Track(TrackId::from_id(id).unwrap().into_static()) - } - - #[test] - fn no_active_device_error_matches_spotify_404_payload() { - let e = anyhow!( - "{}", - r#"Spotify API 404 Not Found failed: { - "error" : { - "status" : 404, - "message" : "Player command failed: No active device found", - "reason" : "NO_ACTIVE_DEVICE" - } -}"# - ); - assert!(is_no_active_device_error(&e)); - assert!(!is_no_active_device_error(&anyhow!( - "Spotify API 404 Not Found failed: Device not found" - ))); - } - - #[test] - fn saved_device_retry_needs_the_error_and_a_device_and_no_native_claim() { - assert_eq!( - saved_device_retry(true, Some("dev1"), false), - Some("dev1".to_string()) - ); - assert_eq!(saved_device_retry(true, Some("dev1"), true), None); - assert_eq!(saved_device_retry(true, None, false), None); - assert_eq!(saved_device_retry(false, Some("dev1"), false), None); - } - - #[allow(deprecated)] - fn full_track(id: &str, name: &str) -> PlayableItem { - PlayableItem::Track(FullTrack { - album: SimplifiedAlbum { - name: "Album".to_string(), - ..Default::default() - }, - artists: vec![SimplifiedArtist { - name: "Artist".to_string(), - ..Default::default() - }], - available_markets: Vec::new(), - disc_number: 1, - duration: TimeDelta::milliseconds(180_000), - explicit: false, - external_ids: HashMap::new(), - external_urls: HashMap::new(), - href: None, - id: Some(TrackId::from_id(id).unwrap().into_static()), - is_local: false, - is_playable: Some(true), - linked_from: None, - restrictions: None, - name: name.to_string(), - popularity: 50, - preview_url: None, - track_number: 1, - r#type: rspotify::model::Type::Track, - }) - } - - #[cfg(feature = "streaming")] - #[allow(deprecated)] - fn playback_device(id: &str, name: &str) -> Device { - Device { - id: Some(id.to_string()), - is_active: false, - is_private_session: false, - is_restricted: false, - name: name.to_string(), - _type: DeviceType::Computer, - volume_percent: Some(50), - } - } - - #[cfg(feature = "streaming")] - fn queued_track(uri: &str) -> crate::core::plugin_api::TrackInfo { - crate::core::test_helpers::queued_track(uri, "Queued") - } - - #[test] - fn trim_api_playback_uris_leaves_small_requests_unchanged() { - let uris = vec![ - playable_track("0000000000000000000001"), - playable_track("0000000000000000000002"), - ]; - - let (trimmed, offset) = trim_api_playback_uris(uris.clone(), Some(1)); - - assert_eq!(trimmed, uris); - assert_eq!(offset, Some(1)); - } - - #[test] - fn trim_api_playback_uris_keeps_selected_track_inside_window() { - let uris = (0..150) - .map(|index| playable_track(&format!("{index:022}"))) - .collect::>(); - - let (trimmed, offset) = trim_api_playback_uris(uris.clone(), Some(60)); - - assert_eq!(trimmed.len(), MAX_API_PLAYBACK_URIS); - assert_eq!(offset, Some(20)); - assert_eq!(trimmed[offset.unwrap()].uri(), uris[60].uri()); - } - - #[test] - fn trim_api_playback_uris_slides_window_near_end() { - let uris = (0..150) - .map(|index| playable_track(&format!("{index:022}"))) - .collect::>(); - - let (trimmed, offset) = trim_api_playback_uris(uris.clone(), Some(149)); - - assert_eq!(trimmed.len(), MAX_API_PLAYBACK_URIS); - assert_eq!(offset, Some(99)); - assert_eq!(trimmed[offset.unwrap()].uri(), uris[149].uri()); - } - - #[cfg(feature = "streaming")] - #[test] - fn native_restore_request_preserves_paused_position_and_options() { - let snapshot = NativePlaybackRecoverySnapshot { - generation: 7, - context_uri: Some("spotify:playlist:context".to_string()), - uris: None, - offset: Some(3), - current_track_uri: Some("spotify:track:current".to_string()), - loading_track_uri: None, - track_duration_ms: Some(60_000), - position_ms: 42_000, - desired_playing: false, - shuffle: true, - repeat: RepeatState::Track, - recovery_attempts: 1, - }; - - let request = native_restore_load_request(&snapshot).unwrap(); - - assert!(!request.start_playing); - assert_eq!(request.seek_to, 42_000); - assert!(matches!( - request.playing_track.as_ref(), - Some(PlayingTrack::Uri(uri)) if uri == "spotify:track:current" - )); - assert!(matches!( - request.context_options.as_ref(), - Some(LoadContextOptions::Options(options)) - if options.shuffle && options.repeat && options.repeat_track - )); - } - - #[cfg(feature = "streaming")] - #[test] - fn native_restore_request_clamps_position_to_track_duration() { - let snapshot = NativePlaybackRecoverySnapshot { - generation: 8, - context_uri: None, - uris: Some(vec!["spotify:track:finished".to_string()]), - offset: Some(0), - current_track_uri: Some("spotify:track:finished".to_string()), - loading_track_uri: None, - track_duration_ms: Some(389_094), - position_ms: 395_562, - desired_playing: true, - shuffle: false, - repeat: RepeatState::Off, - recovery_attempts: 1, - }; - - let request = native_restore_load_request(&snapshot).unwrap(); - - assert_eq!(request.seek_to, 389_093); - } - - #[test] - fn api_playback_offset_uses_track_uri_for_context_playback() { - let uris = vec![ - playable_track("0000000000000000000001"), - playable_track("0000000000000000000002"), - ]; - - let offset = api_playback_offset_json(Some(&uris), Some(1)); - - assert_eq!( - offset, - Some(json!({ "uri": "spotify:track:0000000000000000000001" })) - ); - } - - #[test] - fn api_playback_offset_uses_position_for_uri_list_playback() { - let offset = api_playback_offset_json(None, Some(1)); - - assert_eq!(offset, Some(json!({ "position": 1 }))); - } - - #[test] - fn api_playback_offset_falls_back_to_position_when_context_has_no_uri() { - let offset = api_playback_offset_json(None, Some(3)); - - assert_eq!(offset, Some(json!({ "position": 3 }))); - } - - #[test] - fn api_confirms_native_info_when_names_match() { - let item = full_track("0000000000000000000001", "Current Song"); - - assert!(api_confirms_native_info_is_current( - "Current Song", - &item, - Some("different-id") - )); - } - - #[test] - fn api_confirms_native_info_when_current_id_matches_even_if_name_differs() { - let item = full_track("0000000000000000000001", "Stranger Thing"); - - assert!(api_confirms_native_info_is_current( - "Greater Together", - &item, - Some("0000000000000000000001") - )); - } - - #[test] - fn api_does_not_confirm_stale_api_item_for_different_native_track() { - let item = full_track("0000000000000000000001", "Old API Song"); - - assert!(!api_confirms_native_info_is_current( - "New Native Song", - &item, - Some("0000000000000000000002") - )); - } - - #[cfg(feature = "streaming")] - #[test] - fn no_active_device_error_matches_spotify_no_device_signals() { - assert!(is_no_active_device_error(&anyhow!( - "{}", - r#"Spotify API 404 Not Found failed: {"error":{"reason":"NO_ACTIVE_DEVICE"}}"# - ))); - assert!(is_no_active_device_error(&anyhow!( - "Spotify API 404 Not Found failed: No active device found" - ))); - } - - #[cfg(feature = "streaming")] - #[test] - fn no_active_device_error_does_not_match_generic_not_found() { - assert!(!is_no_active_device_error(&anyhow!( - "Spotify API 404 Not Found failed: playlist not found" - ))); - assert!(!is_no_active_device_error(&anyhow!( - "Spotify API 404 failed for https://example.test/404" - ))); - } - - #[cfg(feature = "streaming")] - #[test] - fn restriction_violated_error_matches_player_command_signal() { - assert!(is_restriction_violated_error(&anyhow!( - "{}", - r#"Spotify API 403 Forbidden failed: {"error":{"status":403,"message":"Player command failed: Restriction violated","reason":"UNKNOWN"}}"# - ))); - } - - #[cfg(feature = "streaming")] - #[test] - fn restriction_violated_error_does_not_match_other_forbidden() { - assert!(!is_restriction_violated_error(&anyhow!( - "Spotify API 403 Forbidden failed: Premium required" - ))); - } - - #[cfg(feature = "streaming")] - #[test] - fn restriction_violated_error_requires_403_status() { - // The phrase alone must not classify a non-403 error as a player-command - // restriction violation. - assert!(!is_restriction_violated_error(&anyhow!( - "Some unrelated failure: restriction violated on resource" - ))); - } - - #[cfg(feature = "streaming")] - #[test] - fn native_device_preference_update_persists_when_no_saved_device() { - assert_eq!( - native_device_preference_update(None, false, false), - NativeDevicePreferenceUpdate::Persist - ); - } - - #[cfg(feature = "streaming")] - #[test] - fn native_device_preference_update_persists_when_explicitly_requested() { - assert_eq!( - native_device_preference_update(Some("phone-device"), true, false), - NativeDevicePreferenceUpdate::Persist - ); - } - - #[cfg(feature = "streaming")] - #[test] - fn native_device_preference_update_keeps_existing_saved_device_for_fallback() { - assert_eq!( - native_device_preference_update(Some("phone-device"), false, false), - NativeDevicePreferenceUpdate::KeepExistingPreference - ); - } - - #[cfg(feature = "streaming")] - #[test] - fn native_device_preference_update_refreshes_saved_native_device() { - assert_eq!( - native_device_preference_update(Some("old-native-device"), false, true), - NativeDevicePreferenceUpdate::Persist - ); - } - - #[cfg(feature = "streaming")] - #[test] - fn idle_poll_exposes_native_device_when_it_is_preferred() { - assert_eq!( - native_idle_device_preference_update(None, false), - Some(NativeDevicePreferenceUpdate::Persist) - ); - assert_eq!( - native_idle_device_preference_update(Some("old-native-device"), true), - Some(NativeDevicePreferenceUpdate::Persist) - ); - } - - #[cfg(feature = "streaming")] - #[test] - fn idle_poll_preserves_saved_external_device() { - assert_eq!( - native_idle_device_preference_update(Some("phone-device"), false), - None - ); - } - - #[cfg(feature = "streaming")] - #[test] - fn idle_recovery_is_limited_to_two_spaced_attempts() { - let mut recovery = NativeIdleRecoveryState::default(); - recovery.observe_player_instance(Some(1)); - let started_at = Instant::now(); - - assert!(recovery.should_attempt_idle_recovery(started_at)); - assert!(!recovery.should_attempt_idle_recovery( - started_at + NATIVE_IDLE_RECOVERY_RETRY_INTERVAL - Duration::from_millis(1) - )); - assert!(recovery.should_attempt_idle_recovery(started_at + NATIVE_IDLE_RECOVERY_RETRY_INTERVAL)); - assert!(!recovery.should_attempt_idle_recovery( - started_at + NATIVE_IDLE_RECOVERY_RETRY_INTERVAL + NATIVE_IDLE_RECOVERY_RETRY_INTERVAL - )); - } - - #[cfg(feature = "streaming")] - #[test] - fn stale_api_playback_does_not_rearm_idle_recovery() { - let mut recovery = NativeIdleRecoveryState::default(); - recovery.observe_player_instance(Some(1)); - let started_at = Instant::now(); - - assert!(recovery.should_attempt_idle_recovery(started_at)); - assert!(recovery.should_attempt_idle_recovery(started_at + NATIVE_IDLE_RECOVERY_RETRY_INTERVAL)); - recovery.observe_player_instance(Some(1)); - assert!( - !recovery.should_attempt_idle_recovery(started_at + NATIVE_IDLE_RECOVERY_RETRY_INTERVAL * 2) - ); - } - - #[cfg(feature = "streaming")] - #[test] - fn replacement_player_rearms_idle_recovery() { - let mut recovery = NativeIdleRecoveryState::default(); - recovery.observe_player_instance(Some(1)); - let started_at = Instant::now(); - recovery.settle_current_episode(started_at); - - recovery.observe_player_instance(Some(2)); - - assert!(recovery.should_attempt_idle_recovery(started_at + Duration::from_millis(1))); - } - - #[cfg(feature = "streaming")] - #[test] - fn explicit_activation_settles_current_idle_episode() { - let mut recovery = NativeIdleRecoveryState::default(); - recovery.observe_player_instance(Some(1)); - let started_at = Instant::now(); - - recovery.settle_current_episode(started_at); - - assert!( - !recovery.should_attempt_idle_recovery(started_at + NATIVE_IDLE_RECOVERY_RETRY_INTERVAL) - ); - } - - #[cfg(feature = "streaming")] - #[test] - fn spotify_payload_confirms_native_device_by_id() { - let payload = DevicePayload { - devices: vec![playback_device("native-device", "spotatui")], - }; - - assert!(spotify_payload_confirms_native_device( - &payload, - "native-device" - )); - } - - #[cfg(feature = "streaming")] - #[test] - fn spotify_payload_does_not_confirm_stale_native_name_with_different_id() { - let payload = DevicePayload { - devices: vec![playback_device("stale-device", "spotatui")], - }; - - assert!(!spotify_payload_confirms_native_device( - &payload, - "native-device" - )); - } - - #[cfg(feature = "streaming")] - #[test] - fn native_activation_uses_native_when_no_current_device_or_saved_device() { - assert!(should_activate_native_for_playback( - NativeActivationContext { - player_connected: true, - ..Default::default() - }, - )); - } - - #[cfg(feature = "streaming")] - #[test] - fn native_activation_uses_native_when_saved_device_is_unavailable() { - assert!(should_activate_native_for_playback( - NativeActivationContext { - player_connected: true, - saved_external_confirmed_available: false, - ..Default::default() - }, - )); - } - - #[cfg(feature = "streaming")] - #[test] - fn native_activation_keeps_confirmed_external_device() { - assert!(!should_activate_native_for_playback( - NativeActivationContext { - player_connected: true, - current_device_id_present: true, - ..Default::default() - }, - )); - } - - #[cfg(feature = "streaming")] - #[test] - fn native_activation_keeps_confirmed_saved_external_device() { - assert!(!should_activate_native_for_playback( - NativeActivationContext { - player_connected: true, - saved_external_confirmed_available: true, - ..Default::default() - }, - )); - } - - #[cfg(feature = "streaming")] - #[test] - fn native_activation_uses_native_for_saved_native_device() { - assert!(should_activate_native_for_playback( - NativeActivationContext { - player_connected: true, - saved_device_matches_native: true, - saved_external_confirmed_available: true, - ..Default::default() - }, - )); - } - - #[cfg(feature = "streaming")] - #[test] - fn native_activation_uses_native_for_stale_native_name_match() { - assert!(should_activate_native_for_playback( - NativeActivationContext { - player_connected: true, - current_device_id_present: true, - current_device_name_matches_native: true, - native_has_fresh_activity: false, - ..Default::default() - }, - )); - } - - #[cfg(feature = "streaming")] - #[test] - fn native_activation_ignores_disconnected_player() { - assert!(!should_activate_native_for_playback( - NativeActivationContext { - player_connected: false, - saved_device_matches_native: true, - ..Default::default() - }, - )); - } - - #[cfg(feature = "streaming")] - #[test] - fn recovery_yields_to_an_active_external_device() { - assert!(should_yield_native_recovery( - true, - true, - Some("phone-device"), - "native-device" - )); - assert!(!should_yield_native_recovery( - true, - true, - Some("native-device"), - "native-device" - )); - } - - #[cfg(feature = "streaming")] - #[test] - fn startup_and_paused_external_playback_do_not_trigger_recovery_yield() { - assert!(!should_yield_native_recovery( - false, - true, - Some("phone-device"), - "native-device" - )); - assert!(!should_yield_native_recovery( - true, - false, - Some("phone-device"), - "native-device" - )); - } - - #[cfg(feature = "streaming")] - #[test] - fn yielding_clears_stale_native_intent_but_keeps_explicit_playback() { - use crate::infra::queue::QueueNowPlaying; - - let mut app = App::default(); - app.record_native_playback_request( - None, - Some(vec!["spotify:track:recover".to_string()]), - Some(0), - true, - false, - RepeatState::Off, - ); - app.park_start_playback(Some("spotify:playlist:explicit".to_string()), None, None); - app.queue_now = Some(QueueNowPlaying::Spotify { - track: queued_track("spotify:track:queued"), - }); - app.spotify_queue_guard_reloads = 2; - app.is_streaming_active = true; - app.native_activation_pending = true; - app.last_device_activation = Some(Instant::now()); - - clear_native_recovery_intent_for_external_playback(&mut app); - - assert!(!app.is_streaming_active); - assert!(!app.native_activation_pending); - assert!(app.last_device_activation.is_none()); - assert!(app.native_playback_recovery.is_none()); - assert!(app.queue_now.is_none()); - assert_eq!(app.spotify_queue_guard_reloads, 0); - assert!(app.pending_start_playback.is_some()); - } - - #[cfg(feature = "streaming")] - #[test] - fn stale_api_item_keeps_native_metadata_when_native_was_active() { - assert!(stale_api_item_should_preserve_native_context( - StaleApiItemContext { - native_info_present: true, - api_item_present: true, - api_confirms_native_info: false, - native_track_id_present: true, - api_item_matches_native_track: false, - native_streaming_was_active: true, - native_activation_pending: false, - api_device_is_native: false, - }, - )); - } - - #[cfg(feature = "streaming")] - #[test] - fn stale_api_item_keeps_native_metadata_during_activation() { - assert!(stale_api_item_should_preserve_native_context( - StaleApiItemContext { - native_info_present: true, - api_item_present: true, - api_confirms_native_info: false, - native_track_id_present: true, - api_item_matches_native_track: false, - native_streaming_was_active: false, - native_activation_pending: true, - api_device_is_native: false, - }, - )); - } - - #[cfg(feature = "streaming")] - #[test] - fn stale_api_item_keeps_native_context_before_native_metadata_arrives() { - assert!(stale_api_item_should_preserve_native_context( - StaleApiItemContext { - native_info_present: false, - api_item_present: true, - api_confirms_native_info: false, - native_track_id_present: true, - api_item_matches_native_track: false, - native_streaming_was_active: true, - native_activation_pending: false, - api_device_is_native: false, - }, - )); - } - - #[cfg(feature = "streaming")] - #[test] - fn stale_native_metadata_clears_after_playback_leaves_native_device() { - assert!(!stale_api_item_should_preserve_native_context( - StaleApiItemContext { - native_info_present: true, - api_item_present: true, - api_confirms_native_info: false, - native_track_id_present: true, - api_item_matches_native_track: false, - native_streaming_was_active: false, - native_activation_pending: false, - api_device_is_native: false, - }, - )); - } - - #[cfg(feature = "streaming")] - #[test] - fn confirmed_api_item_no_longer_keeps_native_metadata() { - assert!(!stale_api_item_should_preserve_native_context( - StaleApiItemContext { - native_info_present: true, - api_item_present: true, - api_confirms_native_info: true, - native_track_id_present: true, - api_item_matches_native_track: true, - native_streaming_was_active: true, - native_activation_pending: false, - api_device_is_native: true, - }, - )); - } - - #[cfg(feature = "streaming")] - #[test] - fn matching_api_item_without_native_metadata_can_update_context() { - assert!(!stale_api_item_should_preserve_native_context( - StaleApiItemContext { - native_info_present: false, - api_item_present: true, - api_confirms_native_info: false, - native_track_id_present: true, - api_item_matches_native_track: true, - native_streaming_was_active: true, - native_activation_pending: false, - api_device_is_native: false, - }, - )); - } - - #[cfg(feature = "streaming")] - #[test] - fn api_item_without_native_track_id_can_update_context() { - assert!(!stale_api_item_should_preserve_native_context( - StaleApiItemContext { - native_info_present: false, - api_item_present: true, - api_confirms_native_info: false, - native_track_id_present: false, - api_item_matches_native_track: false, - native_streaming_was_active: true, - native_activation_pending: false, - api_device_is_native: false, - }, - )); - } - - /// With neither a context uri nor a resume track, resuming has nothing to do: - /// it reports the queue as finished rather than issuing a playback request. - #[tokio::test] - async fn resume_spotify_context_with_nothing_known_finishes_the_queue() { - use crate::core::app::App; - use crate::core::config::ClientConfig; - use crate::core::user_config::UserConfig; - use std::sync::mpsc::channel; - use std::time::SystemTime; - - let (io_tx, _rx) = channel(); - let app = std::sync::Arc::new(tokio::sync::Mutex::new(App::new( - io_tx, - UserConfig::new(), - Some(SystemTime::now()), - ))); - // No Spotify client is needed: the both-None arm never reaches `spotify()`. - let mut network = Network::new( - None, - ClientConfig::new(), - &app, - std::env::temp_dir().join("spotatui_resume_context_test.json"), - ); - - network.resume_spotify_context(None, None).await; - - let guard = app.lock().await; - assert_eq!(guard.status_message(), Some("Queue finished")); - } -} diff --git a/src/infra/network/recommend.rs b/src/infra/network/recommend.rs deleted file mode 100644 index 2356e907..00000000 --- a/src/infra/network/recommend.rs +++ /dev/null @@ -1,148 +0,0 @@ -use super::{ids, IoEvent, Network}; -use crate::core::app::{ActiveBlock, RouteId, TrackTableContext}; -use crate::core::plugin_api::TrackInfo; -use anyhow::anyhow; -use rspotify::model::{ - enums::Country, - idtypes::{ArtistId, TrackId}, - track::{FullTrack, SimplifiedTrack}, -}; -use rspotify::prelude::*; -use serde::Deserialize; - -#[derive(Deserialize)] -struct RecommendationsResponse { - tracks: Vec, -} - -#[derive(Deserialize)] -struct TracksResponse { - tracks: Vec, -} - -fn country_code(country: Country) -> String { - let code: &'static str = country.into(); - code.to_string() -} - -pub trait RecommendationNetwork { - async fn get_recommendations_for_seed( - &mut self, - seed_artists: Option>>, - seed_tracks: Option>>, - first_track: Box>, - country: Option, - ); - async fn get_recommendations_for_track_id( - &mut self, - track_id: TrackId<'static>, - country: Option, - ); -} - -impl RecommendationNetwork for Network { - async fn get_recommendations_for_seed( - &mut self, - seed_artists: Option>>, - seed_tracks: Option>>, - first_track: Box>, - country: Option, - ) { - let limit = self.large_search_limit; - let mut query = vec![("limit", limit.to_string())]; - if let Some(country) = country { - query.push(("market", country_code(country))); - } - if let Some(seed_artists) = seed_artists.as_ref() { - query.push(( - "seed_artists", - seed_artists - .iter() - .map(|id| id.id().to_string()) - .collect::>() - .join(","), - )); - } - if let Some(seed_tracks) = seed_tracks.as_ref() { - query.push(( - "seed_tracks", - seed_tracks - .iter() - .map(|id| id.id().to_string()) - .collect::>() - .join(","), - )); - } - - match self - .spotify_get_typed::("recommendations", &query) - .await - { - Ok(recommendations) => { - // Convert SimplifiedTrack to FullTrack (best effort) - // SimplifiedTrack doesn't have album field which FullTrack needs. - // This is tricky. Recommendations usually return SimplifiedTracks. - // We probably need to fetch FullTracks or fake it. - // For now, let's map what we can and use a dummy album or fail. - // Actually, we can fetch the full tracks using the IDs. - let track_ids: Vec = recommendations - .tracks - .iter() - .filter_map(|t| t.id.clone()) - .collect(); - - let ids = track_ids - .iter() - .map(|id| id.id().to_string()) - .collect::>() - .join(","); - let full_tracks = if ids.is_empty() { - Vec::new() - } else { - match self - .spotify_get_typed::("tracks", &[("ids", ids)]) - .await - { - Ok(res) => res.tracks, - Err(e) => { - self.handle_error(anyhow!(e)).await; - return; - } - } - }; - - let mut app = self.app.lock().await; - // Check if these tracks are liked (only the rspotify recommendations, - // not the prepended domain-layer seed track). - let track_check = ids::track_check_ids(full_tracks.iter().map(|t| t.id.as_ref())); - if !track_check.is_empty() { - app.dispatch(IoEvent::CurrentUserSavedTracksContains(track_check)); - } - app.track_table.tracks = full_tracks.iter().map(TrackInfo::from).collect(); - - // Prepend the seed track if available so user knows context - if let Some(track) = *first_track { - app.track_table.tracks.insert(0, track); - } - app.track_table.context = Some(TrackTableContext::RecommendedTracks); - app.push_navigation_stack(RouteId::Recommendations, ActiveBlock::TrackTable); - } - Err(e) => { - self.handle_error(anyhow!(e)).await; - } - } - } - - async fn get_recommendations_for_track_id( - &mut self, - track_id: TrackId<'static>, - country: Option, - ) { - let seed_tracks = Some(vec![track_id.clone()]); - let first_track: Box> = Box::new(None); - - self - .get_recommendations_for_seed(None, seed_tracks, first_track, country) - .await; - } -} diff --git a/src/infra/network/requests.rs b/src/infra/network/requests.rs deleted file mode 100644 index 4284fca6..00000000 --- a/src/infra/network/requests.rs +++ /dev/null @@ -1,1208 +0,0 @@ -use super::Network; -use crate::core::{app::App, auth}; -use anyhow::anyhow; -use log::warn; -use reqwest::header::CONTENT_LENGTH; -use reqwest::Method; -use rspotify::AuthCodePkceSpotify; -use serde::de::DeserializeOwned; -use serde_json::{json, Value}; -use std::{ - future::Future, - path::Path, - sync::Arc, - sync::OnceLock, - time::{Duration, Instant}, -}; -use tokio::sync::Mutex; - -// Leaky-bucket pacing state: the theoretical arrival time (GCRA "TAT") of the -// next request. Sustained throughput is one request per SPOTIFY_API_MIN_INTERVAL, -// with up to SPOTIFY_API_BURST requests allowed to start at once so the -// concurrent fan-outs (search joins five queries, the artist page two) aren't -// artificially staggered. -static SPOTIFY_API_PACING: OnceLock>> = OnceLock::new(); -const SPOTIFY_API_MIN_INTERVAL: Duration = Duration::from_millis(250); -const SPOTIFY_API_BURST: u32 = 5; -const SPOTIFY_API_BASE_URL: &str = "https://api.spotify.com/v1/"; - -static SHARED_HTTP_CLIENT: OnceLock = OnceLock::new(); - -/// How long one forced token refresh suppresses the next one. -/// -/// A 401 forces a full `POST /api/token`, which under PKCE also *rotates the -/// refresh token*. Spotify's player service intermittently answers a perfectly -/// valid token with 401 (issue #395), and the playback poll runs once a second -/// against an external device, so force-refreshing on every 401 mints a whole -/// new token family every few seconds — pure churn, and a lost rotated refresh -/// token logs the user out. Inside the cooldown the request is retried with the -/// token already in hand instead. -const FORCED_REFRESH_COOLDOWN: Duration = Duration::from_secs(30); - -/// Delay before the single post-401 retry. Without it the retry lands inside -/// the same ~1s server-side window that produced the 401 (a track transition, -/// or a freshly minted token that has not propagated yet) and fails -/// identically, turning a recoverable blip into a surfaced error. -const UNAUTHORIZED_RETRY_BACKOFF: Duration = Duration::from_secs(1); - -/// Longest `Retry-After` honored: a bound on the window, so a bogus header -/// value can neither park the app for a day nor overflow the deadline. -const MAX_RETRY_AFTER: Duration = Duration::from_secs(600); - -/// Longest response body echoed into the diagnostics log. -const MAX_LOGGED_BODY_CHARS: usize = 512; - -/// Cooldown state for forced token refreshes triggered by a 401 response. -/// -/// Deliberately long-lived and shared: every request is a fresh call into -/// [`spotify_api_request_json_for_base_with_refresh`], so per-call state would -/// reset on each poll and never suppress anything. -#[derive(Clone)] -pub struct ForcedRefreshGate { - last_forced_refresh: Arc>>, - cooldown: Duration, - /// End of the last `Retry-After` window: no request goes out before it. - rate_limited_until: Arc>>, -} - -impl ForcedRefreshGate { - pub fn new(cooldown: Duration) -> Self { - Self { - last_forced_refresh: Arc::new(Mutex::new(None)), - cooldown, - rate_limited_until: Arc::new(Mutex::new(None)), - } - } - - /// Time left in the `Retry-After` window, if one is open. - pub(crate) async fn rate_limit_remaining(&self) -> Option { - let until = (*self.rate_limited_until.lock().await)?; - until.checked_duration_since(Instant::now()) - } - - /// Open (or extend) the window: a shorter `Retry-After` that lands during a - /// longer one must not cut the longer one short. - pub(crate) async fn rate_limit_for(&self, window: Duration) { - let until = Instant::now() + window.min(MAX_RETRY_AFTER); - let mut slot = self.rate_limited_until.lock().await; - *slot = Some(slot.map_or(until, |current| current.max(until))); - } - - /// Claims the right to force a refresh now. Returns `false` when one already - /// happened within the cooldown, meaning the caller should retry with the - /// token it already holds rather than rotating the token family again. - async fn try_begin(&self) -> bool { - let mut last_forced_refresh = self.last_forced_refresh.lock().await; - let now = Instant::now(); - match *last_forced_refresh { - Some(previous) if now.duration_since(previous) < self.cooldown => false, - _ => { - *last_forced_refresh = Some(now); - true - } - } - } - - async fn reset(&self) { - *self.last_forced_refresh.lock().await = None; - } -} - -impl Default for ForcedRefreshGate { - fn default() -> Self { - Self::new(FORCED_REFRESH_COOLDOWN) - } -} - -/// A non-2xx answer from Spotify, with the status kept so a caller can tell a -/// rejected token from a request that merely failed. `Display` is the plain -/// text every other caller already matches on. -#[derive(Debug)] -pub struct SpotifyApiError { - pub status: reqwest::StatusCode, - pub(crate) body: String, - pub(crate) detail: Option, -} - -impl std::fmt::Display for SpotifyApiError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "Spotify API {} failed: {}", self.status, self.body)?; - match &self.detail { - Some(detail) => write!(f, " ({detail})"), - None => Ok(()), - } - } -} - -impl std::error::Error for SpotifyApiError {} - -/// The process-wide gate used by every real Spotify request (one Spotify -/// session per process). Tests drive the base helper with their own instance. -pub(crate) fn shared_forced_refresh_gate() -> &'static ForcedRefreshGate { - static GATE: OnceLock = OnceLock::new(); - GATE.get_or_init(ForcedRefreshGate::default) -} - -/// Forget the last forced refresh, so the next 401 may force one immediately. -/// Called when a brand-new token family enters play (in-TUI login), where the -/// previous session's cooldown carries no information. -pub async fn reset_forced_refresh_cooldown() { - shared_forced_refresh_gate().reset().await; -} - -/// Age of the access token now attached to a request, derived from -/// `expires_at - expires_in`. Diagnostics only: a 401 on a token minted seconds -/// ago points at propagation lag or a server-side reject, not at expiry. -fn token_age(token: &rspotify::Token) -> Option { - let issued_at = token.expires_at? - token.expires_in; - (chrono::Utc::now() - issued_at).to_std().ok() -} - -fn truncate_for_log(body: &str) -> String { - let trimmed = body.trim(); - if trimmed.chars().count() <= MAX_LOGGED_BODY_CHARS { - return trimmed.to_string(); - } - let head: String = trimmed.chars().take(MAX_LOGGED_BODY_CHARS).collect(); - format!("{head}… (truncated)") -} - -/// Returns the process-wide shared [`reqwest::Client`]. -/// -/// A `reqwest::Client` owns a connection pool and is meant to be built once and -/// reused; building one per request (the previous behavior) discarded keep-alive -/// connections and forced a fresh TLS handshake on every call. The client is -/// internally reference-counted, so the returned `&'static` reference is shared -/// cheaply across all request paths (Spotify API, friends relay, telemetry, -/// lyrics). -pub fn shared_http_client() -> &'static reqwest::Client { - SHARED_HTTP_CLIENT.get_or_init(|| { - // Spotify API (and the friends relay / lyrics / telemetry paths that share - // this client) all return bounded, non-streaming responses, so a blanket - // request timeout is safe here. Without one, a post-connect read stall - // (captive portal, half-open TCP, an edge that accepts then sends nothing) - // makes `send()`/`text()` await forever on the serial IoEvent pump and - // freezes the whole app until it is killed. An explicit connect timeout - // additionally bounds the TCP/TLS handshake. - reqwest::Client::builder() - .connect_timeout(Duration::from_secs(10)) - .timeout(Duration::from_secs(30)) - .build() - .unwrap_or_else(|_| reqwest::Client::new()) - }) -} - -fn response_is_json(response: &reqwest::Response) -> bool { - response - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .is_some_and(|value| { - let value = value.to_ascii_lowercase(); - value.contains("/json") || value.contains("+json") - }) -} - -pub async fn pace_spotify_api_call() { - // Reserve a start slot under the lock, then sleep OUTSIDE it. The previous - // implementation held the lock across the sleep, which serialized every - // "concurrent" tokio::join! call site into 250ms-apart starts (adding ~1s of - // pure pacing to every search). - let burst_allowance = SPOTIFY_API_MIN_INTERVAL * (SPOTIFY_API_BURST - 1); - let start_at = { - let pacing_lock = SPOTIFY_API_PACING.get_or_init(|| Mutex::new(None)); - let mut theoretical_arrival = pacing_lock.lock().await; - let now = Instant::now(); - // Clamp to `now` so idle time never banks more than one burst of credit. - let tat = theoretical_arrival.map_or(now, |t| t.max(now)); - *theoretical_arrival = Some(tat + SPOTIFY_API_MIN_INTERVAL); - // A call may start up to `burst_allowance` ahead of its theoretical slot. - tat.checked_sub(burst_allowance).map_or(now, |t| t.max(now)) - }; - - let now = Instant::now(); - if start_at > now { - tokio::time::sleep(start_at - now).await; - } -} - -pub async fn spotify_api_request_json_for_with_refresh( - spotify: &AuthCodePkceSpotify, - method: Method, - path: &str, - query: &[(&str, String)], - body: Option, - token_cache_path: &Path, - app: &Arc>, -) -> anyhow::Result { - spotify_api_request_json_for_base_with_refresh( - spotify, - SpotifyApiRequest { - base_url: SPOTIFY_API_BASE_URL, - method, - path, - query, - body, - }, - |force| async move { - match auth::refresh_token_and_cache(spotify, token_cache_path, force).await { - Ok(expiry) => { - let mut app = app.lock().await; - app.spotify_token_expiry = Some(expiry); - app.auth_refresh_in_progress = false; - Ok(Some(expiry)) - } - Err(e) => { - let mut app = app.lock().await; - app.auth_refresh_in_progress = false; - app.is_loading = false; - Err(e) - } - } - }, - shared_forced_refresh_gate(), - ) - .await -} - -/// One Spotify Web API call: everything about *what* to send, kept together so -/// the shared helper's signature stays readable next to its refresh closure and -/// retry policy. -struct SpotifyApiRequest<'a> { - base_url: &'a str, - method: Method, - path: &'a str, - query: &'a [(&'a str, String)], - body: Option, -} - -async fn spotify_api_request_json_for_base_with_refresh( - spotify: &AuthCodePkceSpotify, - request: SpotifyApiRequest<'_>, - mut refresh_token: F, - forced_refresh_gate: &ForcedRefreshGate, -) -> anyhow::Result -where - F: FnMut(bool) -> Fut, - Fut: Future>>, -{ - let SpotifyApiRequest { - base_url, - method, - path, - query, - body, - } = request; - - refresh_token(false).await?; - - let mut url = reqwest::Url::parse(base_url)?.join(path)?; - if !query.is_empty() { - let mut qp = url.query_pairs_mut(); - for (k, v) in query { - qp.append_pair(k, v); - } - } - - // Inside a `Retry-After` window every call fails here, at once and with no - // request: a sleep would park the serial pump and every event behind it. - if let Some(left) = forced_refresh_gate.rate_limit_remaining().await { - return Err( - SpotifyApiError { - status: reqwest::StatusCode::TOO_MANY_REQUESTS, - body: format!( - "rate limited by Spotify, retry in {}s", - left.as_secs().max(1) - ), - detail: None, - } - .into(), - ); - } - - let client = shared_http_client(); - let mut attempt: u8 = 0; - let max_attempts: u8 = 4; - let mut attempted_unauthorized_recovery = false; - - loop { - let (access_token, access_token_age) = { - let token_lock = spotify.token.lock().await.expect("Failed to lock token"); - let token = token_lock - .as_ref() - .ok_or_else(|| anyhow!("No access token available"))?; - (token.access_token.clone(), token_age(token)) - }; - - pace_spotify_api_call().await; - - let mut request = client - .request(method.clone(), url.clone()) - .header("Authorization", format!("Bearer {}", access_token)) - .header("Content-Type", "application/json"); - - if let Some(payload) = body.clone() { - request = request.json(&payload); - } else if matches!( - method, - Method::POST | Method::PUT | Method::DELETE | Method::PATCH - ) { - // Some Spotify mutation endpoints reject bodyless requests unless the - // transport explicitly declares an empty body with Content-Length: 0. - request = request.header(CONTENT_LENGTH, "0").body(Vec::new()); - } - - let response = match request.send().await { - Ok(response) => response, - Err(e) => { - if attempt + 1 < max_attempts && (e.is_connect() || e.is_timeout() || e.is_request()) { - let backoff_secs = 1 + u64::from(attempt); - tokio::time::sleep(Duration::from_secs(backoff_secs)).await; - attempt += 1; - continue; - } - return Err(anyhow!("Spotify API request failed: {}", e)); - } - }; - if response.status().is_success() { - let should_parse_json = response_is_json(&response); - let response_body = response.text().await?; - if response_body.trim().is_empty() { - return Ok(Value::Null); - } - if should_parse_json { - return Ok(serde_json::from_str(&response_body)?); - } - return Ok(Value::Null); - } - - let status = response.status(); - let retry_after_secs = response - .headers() - .get("retry-after") - .and_then(|h| h.to_str().ok()) - .and_then(|v| v.parse::().ok()) - .unwrap_or(1); - // `text()` consumes the response, so everything the branches below need - // (status, retry-after, body) is captured here, once. - let body = response.text().await.unwrap_or_default(); - - // Diagnostics for every non-2xx: which endpoint, which status, what Spotify - // actually said, and how old the token we attached was. The token *value* is - // never logged. This is what separates "the token really expired" from - // "Spotify rejected a valid token" in a user-supplied log (issue #395). - warn!( - "Spotify API {} {} -> {} (attempt {}/{}, token age {}): {}", - method, - url.path(), - status, - attempt + 1, - max_attempts, - access_token_age.map_or_else( - || "unknown".to_string(), - |age| format!("{}s", age.as_secs()) - ), - truncate_for_log(&body) - ); - - if status == reqwest::StatusCode::UNAUTHORIZED && !attempted_unauthorized_recovery { - // One-shot: whichever recovery path runs below, a second 401 falls through - // to the error return instead of looping. - attempted_unauthorized_recovery = true; - - if forced_refresh_gate.try_begin().await { - match refresh_token(true).await { - Ok(Some(_)) => { - tokio::time::sleep(UNAUTHORIZED_RETRY_BACKOFF).await; - continue; - } - Ok(None) => { - return Err( - SpotifyApiError { - status, - body, - detail: Some("token refresh unavailable for this request".to_string()), - } - .into(), - ); - } - Err(refresh_err) => { - return Err( - SpotifyApiError { - status, - body, - detail: Some(format!("token refresh failed: {refresh_err}")), - } - .into(), - ); - } - } - } - - // A forced refresh already ran within the cooldown, so the token in hand - // is freshly minted and this 401 is coming from Spotify's side. Retry with - // it after a backoff instead of rotating the token family yet again. - warn!("401 within the forced-refresh cooldown; retrying with the current token"); - tokio::time::sleep(UNAUTHORIZED_RETRY_BACKOFF).await; - continue; - } - - if status == reqwest::StatusCode::TOO_MANY_REQUESTS { - let window = retry_after_secs.max(1).min(MAX_RETRY_AFTER.as_secs()); - forced_refresh_gate - .rate_limit_for(Duration::from_secs(window)) - .await; - // The raw body went to the log above; the user sees the wait instead. - return Err( - SpotifyApiError { - status, - body: format!("rate limited by Spotify, retry in {window}s"), - detail: None, - } - .into(), - ); - } - - return Err( - SpotifyApiError { - status, - body, - detail: None, - } - .into(), - ); - } -} - -impl Network { - pub async fn spotify_api_request_json( - &self, - method: Method, - path: &str, - query: &[(&str, String)], - body: Option, - ) -> anyhow::Result { - spotify_api_request_json_for_with_refresh( - self.spotify(), - method, - path, - query, - body, - &self.token_cache_path, - &self.app, - ) - .await - } - - pub async fn spotify_get_typed( - &self, - path: &str, - query: &[(&str, String)], - ) -> anyhow::Result { - let mut value = self - .spotify_api_request_json(Method::GET, path, query, None) - .await?; - normalize_spotify_payload(&mut value); - Ok(serde_json::from_value(value)?) - } -} - -pub fn normalize_spotify_payload(value: &mut Value) { - match value { - Value::Object(map) => { - if let Some(Value::Array(items)) = map.get_mut("items") { - items.retain(|item| !item.is_null()); - } - - if map.contains_key("snapshot_id") - && map.contains_key("owner") - && map.contains_key("id") - && !map.contains_key("tracks") - { - if let Some(items_obj) = map.get("items").cloned() { - map.insert("tracks".to_string(), items_obj); - } else { - map.insert("tracks".to_string(), json!({ "href": "", "total": 0 })); - } - } - - if map.contains_key("added_at") && !map.contains_key("track") { - if let Some(item_obj) = map.get("item").cloned() { - map.insert("track".to_string(), item_obj); - } - } - - if map.contains_key("album") - && map.contains_key("artists") - && map.contains_key("track_number") - && map.contains_key("duration_ms") - { - map - .entry("available_markets".to_string()) - .or_insert_with(|| json!([])); - map - .entry("external_ids".to_string()) - .or_insert_with(|| json!({})); - map.entry("linked_from".to_string()).or_insert(Value::Null); - map - .entry("popularity".to_string()) - .or_insert_with(|| json!(0)); - } - - if map.contains_key("media_type") - && map.contains_key("languages") - && map.contains_key("description") - && map.contains_key("name") - { - map - .entry("available_markets".to_string()) - .or_insert_with(|| json!([])); - map - .entry("publisher".to_string()) - .or_insert_with(|| json!("")); - } - - if map.contains_key("album_type") - && map.contains_key("artists") - && map.contains_key("images") - && map.contains_key("name") - { - if map.contains_key("tracks") { - map - .entry("available_markets".to_string()) - .or_insert(Value::Null); - map - .entry("external_ids".to_string()) - .or_insert_with(|| json!({})); - map - .entry("popularity".to_string()) - .or_insert_with(|| json!(0)); - map.entry("label".to_string()).or_insert(Value::Null); - } else { - map - .entry("available_markets".to_string()) - .or_insert_with(|| json!([])); - } - } - - let looks_like_artist = map - .get("type") - .and_then(Value::as_str) - .is_some_and(|t| t == "artist") - || (map.contains_key("external_urls") - && map.contains_key("name") - && map.contains_key("id") - && (map.contains_key("genres") || map.contains_key("images"))); - - if looks_like_artist { - map.entry("href".to_string()).or_insert_with(|| json!("")); - map.entry("genres".to_string()).or_insert_with(|| json!([])); - map.entry("images".to_string()).or_insert_with(|| json!([])); - map - .entry("followers".to_string()) - .or_insert_with(|| json!({ "href": null, "total": 0 })); - map - .entry("popularity".to_string()) - .or_insert_with(|| json!(0)); - } - - for child in map.values_mut() { - normalize_spotify_payload(child); - } - } - Value::Array(values) => { - values.retain(|item| !item.is_null()); - for child in values.iter_mut() { - normalize_spotify_payload(child); - } - } - _ => {} - } -} - -pub fn is_rate_limited_error(e: &anyhow::Error) -> bool { - let text = e.to_string(); - text.contains("429") || text.contains("Too Many Requests") || text.contains("Too many requests") -} - -#[allow(dead_code)] -pub fn is_transient_network_error(e: &anyhow::Error) -> bool { - let text = e.to_string().to_lowercase(); - text.contains("error sending request for url") - || text.contains("connection reset") - || text.contains("connection refused") - || text.contains("timed out") - || text.contains("temporary failure") - || text.contains("dns") -} - -pub async fn spotify_get_typed_compat_for_with_refresh( - spotify: &AuthCodePkceSpotify, - path: &str, - query: &[(&str, String)], - token_cache_path: &Path, - app: &Arc>, -) -> anyhow::Result { - let mut value = spotify_api_request_json_for_with_refresh( - spotify, - Method::GET, - path, - query, - None, - token_cache_path, - app, - ) - .await?; - normalize_spotify_payload(&mut value); - Ok(serde_json::from_value(value)?) -} - -/// The boot-time token check: the same pacing, `Retry-After` retries, and 401 -/// recovery as every other call, for the one request made before an `App` -/// exists to report refresh state to. -pub async fn spotify_get_typed_before_app( - spotify: &AuthCodePkceSpotify, - path: &str, - token_cache_path: &Path, -) -> anyhow::Result { - let mut value = spotify_api_request_json_for_base_with_refresh( - spotify, - SpotifyApiRequest { - base_url: SPOTIFY_API_BASE_URL, - method: Method::GET, - path, - query: &[], - body: None, - }, - |force| async move { - auth::refresh_token_and_cache(spotify, token_cache_path, force) - .await - .map(Some) - }, - // Its own gate: nothing else uses this client yet, and a fallback - // candidate must not inherit the previous candidate's 401 cooldown. - &ForcedRefreshGate::default(), - ) - .await?; - normalize_spotify_payload(&mut value); - Ok(serde_json::from_value(value)?) -} - -#[cfg(test)] -mod tests { - use super::*; - use chrono::{TimeDelta, Utc}; - use rspotify::{Config, Credentials, OAuth, Token}; - use std::{ - sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, - }, - time::SystemTime, - }; - use tokio::{ - io::{AsyncReadExt, AsyncWriteExt}, - net::TcpListener, - }; - - async fn spotify_with_access_token(access_token: &str) -> AuthCodePkceSpotify { - let spotify = AuthCodePkceSpotify::with_config( - Credentials::new_pkce("test_client_id"), - OAuth { - redirect_uri: "http://localhost:8888/callback".to_string(), - ..Default::default() - }, - Config::default(), - ); - - let mut token_lock = spotify.token.lock().await.expect("Failed to lock token"); - *token_lock = Some(Token { - access_token: access_token.to_string(), - refresh_token: Some("refresh_token".to_string()), - expires_in: TimeDelta::seconds(3600), - expires_at: Some(Utc::now() + TimeDelta::seconds(3600)), - scopes: Default::default(), - }); - drop(token_lock); - - spotify - } - - async fn read_http_request(stream: &mut tokio::net::TcpStream) -> String { - let mut buf = vec![0; 4096]; - let n = stream.read(&mut buf).await.unwrap(); - String::from_utf8_lossy(&buf[..n]).to_string() - } - - #[tokio::test] - async fn retries_once_with_refreshed_token_after_401() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}/v1/", listener.local_addr().unwrap()); - let seen_authorization = Arc::new(Mutex::new(Vec::::new())); - let seen_authorization_for_server = Arc::clone(&seen_authorization); - - let server = tokio::spawn(async move { - for status in ["401 Unauthorized", "200 OK"] { - let (mut stream, _) = listener.accept().await.unwrap(); - let request = read_http_request(&mut stream).await; - if let Some(header) = request - .lines() - .find(|line| line.to_ascii_lowercase().starts_with("authorization:")) - { - seen_authorization_for_server - .lock() - .await - .push(header.to_ascii_lowercase()); - } - - let body = if status.starts_with("200") { - r#"{"ok":true}"# - } else { - r#"{"error":"expired"}"# - }; - let response = format!( - "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", - body.len() - ); - stream.write_all(response.as_bytes()).await.unwrap(); - } - }); - - let spotify = spotify_with_access_token("old_access").await; - let refresh_calls = Arc::new(AtomicUsize::new(0)); - let refresh_calls_for_closure = Arc::clone(&refresh_calls); - let spotify_for_closure = spotify.clone(); - - let started_at = Instant::now(); - let result = spotify_api_request_json_for_base_with_refresh( - &spotify, - SpotifyApiRequest { - base_url: &base_url, - method: Method::GET, - path: "me", - query: &[], - body: None, - }, - move |force| { - let spotify = spotify_for_closure.clone(); - let refresh_calls = Arc::clone(&refresh_calls_for_closure); - async move { - refresh_calls.fetch_add(1, Ordering::SeqCst); - if force { - let mut token_lock = spotify.token.lock().await.expect("Failed to lock token"); - let token = token_lock.as_mut().unwrap(); - token.access_token = "new_access".to_string(); - } - Ok(Some(SystemTime::now() + Duration::from_secs(3600))) - } - }, - &ForcedRefreshGate::default(), - ) - .await - .unwrap(); - let elapsed = started_at.elapsed(); - - server.await.unwrap(); - - assert_eq!(result, json!({ "ok": true })); - assert_eq!(refresh_calls.load(Ordering::SeqCst), 2); - assert_eq!( - *seen_authorization.lock().await, - vec![ - "authorization: bearer old_access".to_string(), - "authorization: bearer new_access".to_string() - ] - ); - // The retry must not land inside the window that produced the 401. - assert!( - elapsed >= UNAUTHORIZED_RETRY_BACKOFF, - "post-refresh retry was not backed off (took {elapsed:?})" - ); - } - - /// Issue #395: the playback poll runs once a second, and Spotify's player - /// service can answer a valid token with 401. Only the first 401 in a cooldown - /// window may force a (refresh-token-rotating) `POST /api/token`; the next one - /// retries with the token already in hand. - #[tokio::test] - async fn second_unauthorized_within_cooldown_retries_without_forcing_refresh() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}/v1/", listener.local_addr().unwrap()); - let seen_authorization = Arc::new(Mutex::new(Vec::::new())); - let seen_authorization_for_server = Arc::clone(&seen_authorization); - - let server = tokio::spawn(async move { - // Two calls, each answered 401 then 200 — the pattern a track transition - // produces across consecutive polls. - for status in ["401 Unauthorized", "200 OK", "401 Unauthorized", "200 OK"] { - let (mut stream, _) = listener.accept().await.unwrap(); - let request = read_http_request(&mut stream).await; - if let Some(header) = request - .lines() - .find(|line| line.to_ascii_lowercase().starts_with("authorization:")) - { - seen_authorization_for_server - .lock() - .await - .push(header.to_ascii_lowercase()); - } - - let body = if status.starts_with("200") { - r#"{"ok":true}"# - } else { - r#"{ "error" : { "status" : 401, "message" : "Access token missing" }}"# - }; - let response = format!( - "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", - body.len() - ); - stream.write_all(response.as_bytes()).await.unwrap(); - } - }); - - let spotify = spotify_with_access_token("old_access").await; - let forced_refresh_calls = Arc::new(AtomicUsize::new(0)); - let gate = ForcedRefreshGate::default(); - - for _ in 0..2 { - let forced_refresh_calls = Arc::clone(&forced_refresh_calls); - let spotify_for_closure = spotify.clone(); - let result = spotify_api_request_json_for_base_with_refresh( - &spotify, - SpotifyApiRequest { - base_url: &base_url, - method: Method::GET, - path: "me/player", - query: &[], - body: None, - }, - move |force| { - let spotify = spotify_for_closure.clone(); - let forced_refresh_calls = Arc::clone(&forced_refresh_calls); - async move { - if force { - forced_refresh_calls.fetch_add(1, Ordering::SeqCst); - let mut token_lock = spotify.token.lock().await.expect("Failed to lock token"); - let token = token_lock.as_mut().unwrap(); - token.access_token = "new_access".to_string(); - } - Ok(Some(SystemTime::now() + Duration::from_secs(3600))) - } - }, - &gate, - ) - .await - .unwrap(); - - assert_eq!(result, json!({ "ok": true })); - } - - server.await.unwrap(); - - assert_eq!( - forced_refresh_calls.load(Ordering::SeqCst), - 1, - "the second 401 must not mint another token family" - ); - assert_eq!( - *seen_authorization.lock().await, - vec![ - "authorization: bearer old_access".to_string(), - "authorization: bearer new_access".to_string(), - "authorization: bearer new_access".to_string(), - "authorization: bearer new_access".to_string(), - ] - ); - } - - /// An expired cooldown must let the next 401 force a refresh again, so a token - /// that really did go bad still recovers. - #[tokio::test] - async fn unauthorized_forces_refresh_again_once_cooldown_expires() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}/v1/", listener.local_addr().unwrap()); - - let server = tokio::spawn(async move { - for status in ["401 Unauthorized", "200 OK", "401 Unauthorized", "200 OK"] { - let (mut stream, _) = listener.accept().await.unwrap(); - let _request = read_http_request(&mut stream).await; - let body = if status.starts_with("200") { - r#"{"ok":true}"# - } else { - r#"{"error":"expired"}"# - }; - let response = format!( - "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", - body.len() - ); - stream.write_all(response.as_bytes()).await.unwrap(); - } - }); - - let spotify = spotify_with_access_token("access_token").await; - let forced_refresh_calls = Arc::new(AtomicUsize::new(0)); - // A zero cooldown is the "cooldown has elapsed" case. - let gate = ForcedRefreshGate::new(Duration::ZERO); - - for _ in 0..2 { - let forced_refresh_calls = Arc::clone(&forced_refresh_calls); - spotify_api_request_json_for_base_with_refresh( - &spotify, - SpotifyApiRequest { - base_url: &base_url, - method: Method::GET, - path: "me/player", - query: &[], - body: None, - }, - move |force| { - let forced_refresh_calls = Arc::clone(&forced_refresh_calls); - async move { - if force { - forced_refresh_calls.fetch_add(1, Ordering::SeqCst); - } - Ok(Some(SystemTime::now() + Duration::from_secs(3600))) - } - }, - &gate, - ) - .await - .unwrap(); - } - - server.await.unwrap(); - - assert_eq!(forced_refresh_calls.load(Ordering::SeqCst), 2); - } - - /// A second 401 inside the same call must surface the error instead of - /// looping: the recovery is one-shot regardless of which path it took. - #[tokio::test] - async fn repeated_unauthorized_in_one_call_gives_up_after_one_recovery() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}/v1/", listener.local_addr().unwrap()); - let request_count = Arc::new(AtomicUsize::new(0)); - let request_count_for_server = Arc::clone(&request_count); - - let server = tokio::spawn(async move { - for _ in 0..2 { - let (mut stream, _) = listener.accept().await.unwrap(); - let _request = read_http_request(&mut stream).await; - request_count_for_server.fetch_add(1, Ordering::SeqCst); - let body = r#"{ "error" : { "status" : 401, "message" : "Access token missing" }}"#; - let response = format!( - "HTTP/1.1 401 Unauthorized\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", - body.len() - ); - stream.write_all(response.as_bytes()).await.unwrap(); - } - }); - - let spotify = spotify_with_access_token("access_token").await; - - let error = spotify_api_request_json_for_base_with_refresh( - &spotify, - SpotifyApiRequest { - base_url: &base_url, - method: Method::GET, - path: "me/player", - query: &[], - body: None, - }, - |_force| async move { Ok(Some(SystemTime::now() + Duration::from_secs(3600))) }, - &ForcedRefreshGate::default(), - ) - .await - .unwrap_err(); - - server.await.unwrap(); - - assert_eq!(request_count.load(Ordering::SeqCst), 2); - // playback.rs classifies by substring, so the status must stay in the text. - assert!( - error.to_string().contains("401"), - "unexpected error text: {error}" - ); - } - - #[tokio::test] - async fn sends_content_length_zero_for_empty_mutation_requests() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}/v1/", listener.local_addr().unwrap()); - let seen_request = Arc::new(Mutex::new(String::new())); - let seen_request_for_server = Arc::clone(&seen_request); - - let server = tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.unwrap(); - let request = read_http_request(&mut stream).await; - *seen_request_for_server.lock().await = request; - - let body = r#"{"ok":true}"#; - let response = format!( - "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", - body.len() - ); - stream.write_all(response.as_bytes()).await.unwrap(); - }); - - let spotify = spotify_with_access_token("access_token").await; - - let result = spotify_api_request_json_for_base_with_refresh( - &spotify, - SpotifyApiRequest { - base_url: &base_url, - method: Method::PUT, - path: "me/player/shuffle", - query: &[("state", "true".to_string())], - body: None, - }, - |_force| async move { Ok(Some(SystemTime::now() + Duration::from_secs(3600))) }, - &ForcedRefreshGate::default(), - ) - .await - .unwrap(); - - server.await.unwrap(); - - let request = seen_request.lock().await.clone(); - assert_eq!(result, json!({ "ok": true })); - assert!(request.starts_with("PUT /v1/me/player/shuffle?state=true HTTP/1.1\r\n")); - assert!(request - .to_ascii_lowercase() - .contains("content-length: 0\r\n")); - } - - #[tokio::test] - async fn ignores_non_json_success_body_for_mutation_requests() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}/v1/", listener.local_addr().unwrap()); - - let server = tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.unwrap(); - let _request = read_http_request(&mut stream).await; - - let body = "OK"; - let response = format!( - "HTTP/1.1 200 OK\r\ncontent-type: text/plain\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", - body.len() - ); - stream.write_all(response.as_bytes()).await.unwrap(); - }); - - let spotify = spotify_with_access_token("access_token").await; - - let result = spotify_api_request_json_for_base_with_refresh( - &spotify, - SpotifyApiRequest { - base_url: &base_url, - method: Method::PUT, - path: "me/player/play", - query: &[], - body: None, - }, - |_force| async move { Ok(Some(SystemTime::now() + Duration::from_secs(3600))) }, - &ForcedRefreshGate::default(), - ) - .await - .unwrap(); - - server.await.unwrap(); - - assert_eq!(result, Value::Null); - } - - /// A 429 opens a `Retry-After` window on the gate: the call fails at once - /// (no sleep on the pump) and the next call fails without a request. - #[tokio::test] - async fn a_429_fails_at_once_and_blocks_the_next_call_locally() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}/v1/", listener.local_addr().unwrap()); - let server = tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.unwrap(); - let _ = read_http_request(&mut stream).await; - let body = r#"{"error":{"status":429}}"#; - let response = format!( - "HTTP/1.1 429 Too Many Requests\r\nretry-after: 30\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", - body.len() - ); - stream.write_all(response.as_bytes()).await.unwrap(); - }); - - let spotify = spotify_with_access_token("access").await; - let gate = ForcedRefreshGate::default(); - let request = || SpotifyApiRequest { - base_url: &base_url, - method: Method::GET, - path: "me/player", - query: &[], - body: None, - }; - let refresh = |_force| async move { Ok(Some(SystemTime::now() + Duration::from_secs(3600))) }; - - let started_at = Instant::now(); - let first = spotify_api_request_json_for_base_with_refresh(&spotify, request(), refresh, &gate) - .await - .unwrap_err(); - server.await.unwrap(); - let second = - spotify_api_request_json_for_base_with_refresh(&spotify, request(), refresh, &gate) - .await - .unwrap_err(); - - assert!( - started_at.elapsed() < Duration::from_secs(5), - "a 429 must not sleep" - ); - assert!(is_rate_limited_error(&first)); - assert!(is_rate_limited_error(&second)); - assert!(second.to_string().contains("retry in"), "{second}"); - } - - #[tokio::test] - async fn a_shorter_window_never_cuts_a_longer_one_short() { - let gate = ForcedRefreshGate::default(); - gate.rate_limit_for(Duration::from_secs(30)).await; - gate.rate_limit_for(Duration::from_secs(1)).await; - - let left = gate.rate_limit_remaining().await.unwrap(); - assert!(left > Duration::from_secs(20), "{left:?}"); - } - - #[tokio::test] - async fn an_extreme_retry_after_is_bounded_and_does_not_panic() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}/v1/", listener.local_addr().unwrap()); - let server = tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.unwrap(); - let _ = read_http_request(&mut stream).await; - let response = format!( - "HTTP/1.1 429 Too Many Requests\r\nretry-after: {}\r\ncontent-length: 0\r\nconnection: close\r\n\r\n", - u64::MAX - ); - stream.write_all(response.as_bytes()).await.unwrap(); - }); - - let spotify = spotify_with_access_token("access").await; - let gate = ForcedRefreshGate::default(); - let refresh = |_force| async move { Ok(Some(SystemTime::now() + Duration::from_secs(3600))) }; - let error = spotify_api_request_json_for_base_with_refresh( - &spotify, - SpotifyApiRequest { - base_url: &base_url, - method: Method::GET, - path: "me/player", - query: &[], - body: None, - }, - refresh, - &gate, - ) - .await - .unwrap_err(); - server.await.unwrap(); - - assert!(is_rate_limited_error(&error)); - let left = gate.rate_limit_remaining().await.unwrap(); - assert!(left <= MAX_RETRY_AFTER, "{left:?}"); - } -} diff --git a/src/infra/network/search.rs b/src/infra/network/search.rs deleted file mode 100644 index 10d39659..00000000 --- a/src/infra/network/search.rs +++ /dev/null @@ -1,233 +0,0 @@ -use super::{IoEvent, Network}; -use crate::core::plugin_api::{AlbumInfo, ArtistInfo, PlaylistInfo, ShowInfo, TrackInfo}; -use crate::infra::network::mapping::map_page; -use anyhow::anyhow; -use rspotify::model::{ - artist::FullArtist, enums::Country, page::Page, playlist::SimplifiedPlaylist, - show::SimplifiedShow, track::FullTrack, SimplifiedAlbum, -}; -use rspotify::prelude::*; -use serde::Deserialize; - -#[derive(Deserialize, Debug)] -pub struct ArtistSearchResponse { - artists: Page, -} - -/// `pub(crate)` so the DJ resolver can reuse the track-search shape: unlike the -/// handlers in this module, it needs a *returned* result rather than a write into -/// `App` (see `crate::infra::dj::resolve`). -#[derive(Deserialize, Debug)] -pub(crate) struct TrackSearchResponse { - pub(crate) tracks: Page, -} - -#[derive(Deserialize, Debug)] -struct AlbumSearchResponse { - albums: Page, -} - -#[derive(Deserialize, Debug)] -struct PlaylistSearchResponse { - playlists: Page, -} - -#[derive(Deserialize, Debug)] -struct ShowSearchResponse { - shows: Page, -} - -/// The `limit` ceiling of the search endpoint for Development Mode apps: new -/// apps since February 2026, existing apps since March 2026. The library -/// endpoints still take 50. -pub(crate) const SPOTIFY_SEARCH_LIMIT: u32 = 10; - -fn search_limit(requested: u32) -> String { - requested.min(SPOTIFY_SEARCH_LIMIT).to_string() -} - -pub trait SearchNetwork { - async fn get_search_results(&mut self, search_term: String, country: Option); - async fn search_tracks_for_playlist(&mut self, search_term: String); -} - -impl SearchNetwork for Network { - async fn get_search_results(&mut self, search_term: String, country: Option) { - // Don't pass market to search - when market is specified, Spotify doesn't return - // available_markets field, but rspotify 0.14 models require it for tracks/albums. - // We'll handle null playlist fields by searching playlists separately without requiring all fields. - let _country = country; - - let base_query = |search_type: &str| { - vec![ - ("q", search_term.clone()), - ("type", search_type.to_string()), - ("limit", search_limit(self.small_search_limit)), - ("offset", "0".to_string()), - ] - }; - - let track_query = base_query("track"); - let album_query = base_query("album"); - let playlist_query = base_query("playlist"); - let show_query = base_query("show"); - let artist_query = vec![ - ("q", search_term.clone()), - ("type", "artist".to_string()), - ("limit", search_limit(self.small_search_limit)), - ("offset", "0".to_string()), - ]; - - let (track_search, album_search, show_search, playlist_search, artist_search) = tokio::join!( - self.spotify_get_typed::("search", &track_query), - self.spotify_get_typed::("search", &album_query), - self.spotify_get_typed::("search", &show_query), - self.spotify_get_typed::("search", &playlist_query), - self.spotify_get_typed::("search", &artist_query) - ); - - let track_result = match track_search { - Ok(res) => Some(res.tracks), - Err(e) => { - self.handle_error(anyhow!(e)).await; - return; - } - }; - let album_result = match album_search { - Ok(res) => Some(res.albums), - Err(e) => { - self.handle_error(anyhow!(e)).await; - return; - } - }; - let show_result = match show_search { - Ok(res) => Some(res.shows), - Err(e) => { - self.handle_error(anyhow!(e)).await; - return; - } - }; - - let artist_result = artist_search.ok().map(|res| res.artists); - - // Handle playlist search separately since it can fail with null fields from Spotify API - // Silently ignore playlist errors - this is a known Spotify API issue - let playlist_result = playlist_search.ok().map(|res| res.playlists); - - let mut app = self.app.lock().await; - - // Extract ids for follow/saved checks from raw rspotify pages before conversion. - if let Some(ref track_results) = track_result { - let track_ids = track_results - .items - .iter() - .filter_map(|track| track.id.as_ref().map(|id| id.id().to_string())) - .collect(); - - // Check if these tracks are liked - app.dispatch(IoEvent::CurrentUserSavedTracksContains(track_ids)); - } - - if let Some(ref album_results) = album_result { - let artist_ids = album_results - .items - .iter() - .flat_map(|item| { - item - .artists - .iter() - .filter_map(|artist| artist.id.as_ref().map(|id| id.id().to_string())) - }) - .collect(); - - // Check if these artists are followed - app.dispatch(IoEvent::UserArtistFollowCheck(artist_ids)); - - let album_ids = album_results - .items - .iter() - .filter_map(|album| album.id.as_ref().map(|id| id.id().to_string())) - .collect(); - - // Check if these albums are saved - app.dispatch(IoEvent::CurrentUserSavedAlbumsContains(album_ids)); - } - - if let Some(ref show_results) = show_result { - let show_ids = show_results - .items - .iter() - .map(|show| show.id.id().to_string()) - .collect(); - - // check if these shows are saved - app.dispatch(IoEvent::CurrentUserSavedShowsContains(show_ids)); - } - - // Convert rspotify pages to domain Paged before storing on App. - app.search_results.tracks = track_result - .as_ref() - .map(|p| map_page(p, |t| TrackInfo::from(t))); - app.search_results.artists = artist_result - .as_ref() - .map(|p| map_page(p, |a| ArtistInfo::from(a))); - app.search_results.albums = album_result - .as_ref() - .map(|p| map_page(p, |a| AlbumInfo::from(a))); - app.search_results.playlists = playlist_result - .as_ref() - .map(|p| map_page(p, PlaylistInfo::from_simplified)); - app.search_results.shows = show_result - .as_ref() - .map(|p| map_page(p, |s| ShowInfo::from(s))); - - // A replaced page can be shorter than the previous one (or empty), which - // would otherwise leave a stale cursor pointing past the end of the new - // results (panic-1: unchecked `.items[selected_index]` indexing downstream). - app.clamp_search_cursors(); - app - .plugin_data_generations - .bump(crate::core::app::PluginDataKind::Search); - } - - async fn search_tracks_for_playlist(&mut self, search_term: String) { - let query = vec![ - ("q", search_term), - ("type", "track".to_string()), - ("limit", search_limit(self.large_search_limit)), - ("offset", "0".to_string()), - ]; - - let tracks = match self - .spotify_get_typed::("search", &query) - .await - { - Ok(res) => res - .tracks - .items - .iter() - .filter(|t| t.id.is_some()) - .map(TrackInfo::from) - .collect::>(), - Err(e) => { - self.handle_error(anyhow!(e)).await; - return; - } - }; - - let mut app = self.app.lock().await; - app.create_playlist_search_results = tracks; - app.view.create_playlist_selected_result = 0; - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn a_tall_terminal_search_limit_is_capped_at_the_spotify_ceiling() { - assert_eq!(search_limit(50), "10"); - assert_eq!(search_limit(4), "4"); - } -} diff --git a/src/infra/network/spotify_source.rs b/src/infra/network/spotify_source.rs deleted file mode 100644 index b40adf45..00000000 --- a/src/infra/network/spotify_source.rs +++ /dev/null @@ -1,412 +0,0 @@ -//! `SpotifySource`: implementation of the capability traits in -//! [`crate::core::source`] backed by the rspotify `AuthCodePkceSpotify` -//! client. -//! -//! This is **additive dead code** until the multi-source dispatch layer is -//! wired up in a later slice. All items are annotated with `#[allow(dead_code)]` -//! at the module level so the slim (no-streaming) CI build stays clean under -//! `-D warnings`. -//! -//! # Design -//! - Holds the rspotify client directly by value — no `App` reference. -//! - Uses rspotify's high-level typed `_manual` paginators; no custom HTTP. -//! - All API errors propagate via `?` (no silent swallowing). -//! - Pagination: methods that accept a single result page carry a -//! `// TODO(multi-source): paginate` comment. -//! -//! # Known limitations -//! - `playlists()` and `tracks()` call `current_user_playlists_manual` / -//! `playlist_items_manual`, which use rspotify's typed deserializer. That -//! path hits a known Spotify API bug where the response JSON contains a -//! duplicate `"items"` key, causing a deserializer crash. The existing -//! `Network` implementation works around this with a raw-JSON compat path -//! (see `library.rs`). Fix this before wiring `SpotifySource` live. -//! See: `src/infra/network/library.rs` → `get_current_user_playlists`. -//! - `search()` uses `search_multiple`, which deserializes all five result -//! types (tracks/albums/artists/playlists/shows) in a single -//! `convert_result` call. If Spotify returns null fields in any category -//! (the playlist type is a known offender — see `search.rs` comments), the -//! entire search call fails rather than degrading gracefully per-category. -//! The existing `Network::get_search_results` isolates each type so that a -//! playlist parse failure leaves tracks/albums/artists intact. Fix before -//! wiring `SpotifySource` live: either replicate the per-type isolation or -//! use a raw JSON fallback for the playlist category. - -#![allow(dead_code)] - -use anyhow::{anyhow, Result}; -use rspotify::{ - model::{ - enums::types::SearchType, - idtypes::{PlaylistId, TrackId}, - PlayableItem, - }, - prelude::{BaseClient, OAuthClient}, - AuthCodePkceSpotify, -}; - -use crate::core::{ - plugin_api::{AlbumInfo, ArtistInfo, PlaylistInfo, SearchResults, TrackInfo}, - source::{LibraryProvider, MediaSource, PlaylistWriter, Searcher}, -}; -use crate::infra::network::mapping; - -// --------------------------------------------------------------------------- -// Core struct -// --------------------------------------------------------------------------- - -/// A [`MediaSource`] (and optional capability traits) backed by the Spotify -/// Web API via `AuthCodePkceSpotify`. -pub struct SpotifySource { - spotify: AuthCodePkceSpotify, -} - -impl SpotifySource { - /// Construct a new `SpotifySource` from an already-authenticated client. - pub fn new(spotify: AuthCodePkceSpotify) -> Self { - Self { spotify } - } -} - -// --------------------------------------------------------------------------- -// MediaSource -// --------------------------------------------------------------------------- - -impl MediaSource for SpotifySource { - fn name(&self) -> &str { - "Spotify" - } - - fn scheme(&self) -> &str { - "spotify" - } - - /// Fetch all of the user's playlists, paginating until exhausted. - /// - /// # Known limitation - /// Uses rspotify's typed deserializer, which crashes on the duplicate - /// `"items"` key that Spotify's API sometimes returns. Fix before wiring - /// live. See module-level doc. - async fn playlists(&self) -> Result> { - let limit = 50u32; - let mut offset = 0u32; - let mut all_playlists = Vec::new(); - - loop { - let page = self - .spotify - .current_user_playlists_manual(Some(limit), Some(offset)) - .await - .map_err(|e| anyhow!(e))?; - - let is_last = page.next.is_none() || page.items.is_empty(); - for p in &page.items { - all_playlists.push(PlaylistInfo::from_simplified(p)); - } - if is_last { - break; - } - offset += limit; - } - - Ok(all_playlists) - } - - /// Fetch all tracks in a playlist, identified by its `spotify:playlist:…` URI. - /// - /// Episodes and unknown playable items are silently dropped (the trait - /// contract returns `Vec`, not a mixed-media list). - /// - /// # Known limitation - /// Uses rspotify's typed deserializer; see module-level doc for the - /// duplicate-key caveat. - async fn tracks(&self, playlist_uri: &str) -> Result> { - let playlist_id = playlist_id_from_uri(playlist_uri)?; - - let limit = 50u32; - let mut offset = 0u32; - let mut all_tracks = Vec::new(); - - loop { - let page = self - .spotify - .playlist_items_manual(playlist_id.as_ref(), None, None, Some(limit), Some(offset)) - .await - .map_err(|e| anyhow!(e))?; - - let is_last = page.next.is_none() || page.items.is_empty(); - for item in &page.items { - if let Some(PlayableItem::Track(track)) = item.item.as_ref() { - all_tracks.push(TrackInfo::from(track)); - } - } - if is_last { - break; - } - offset += limit; - } - - Ok(all_tracks) - } -} - -// --------------------------------------------------------------------------- -// Searcher -// --------------------------------------------------------------------------- - -impl Searcher for SpotifySource { - /// Search the Spotify catalog using a single batched API call for tracks, - /// albums, artists, playlists, and shows simultaneously. - /// - /// TODO(multi-source): paginate - first page only. Limit is 10, which is - /// the current Spotify API maximum for multi-type search as of 2026-02. - async fn search(&self, query: &str) -> Result { - let result = self - .spotify - .search_multiple( - query, - [ - SearchType::Track, - SearchType::Album, - SearchType::Artist, - SearchType::Playlist, - SearchType::Show, - ], - None, - None, - Some(10), - Some(0), - ) - .await - .map_err(|e| anyhow!(e))?; - - Ok(mapping::search_results_from_pages( - result.tracks.as_ref(), - result.albums.as_ref(), - result.artists.as_ref(), - result.playlists.as_ref(), - result.shows.as_ref(), - )) - } -} - -// --------------------------------------------------------------------------- -// LibraryProvider -// --------------------------------------------------------------------------- - -impl LibraryProvider for SpotifySource { - /// Fetch all saved (liked) tracks, paginating until exhausted. - async fn saved_tracks(&self) -> Result> { - let limit = 50u32; - let mut offset = 0u32; - let mut all_tracks = Vec::new(); - - loop { - let page = self - .spotify - .current_user_saved_tracks_manual(None, Some(limit), Some(offset)) - .await - .map_err(|e| anyhow!(e))?; - - let is_last = page.next.is_none() || page.items.is_empty(); - for saved in &page.items { - all_tracks.push(TrackInfo::from(&saved.track)); - } - if is_last { - break; - } - offset += limit; - } - - Ok(all_tracks) - } - - /// Fetch all saved albums, paginating until exhausted. - async fn saved_albums(&self) -> Result> { - let limit = 50u32; - let mut offset = 0u32; - let mut all_albums = Vec::new(); - - loop { - let page = self - .spotify - .current_user_saved_albums_manual(None, Some(limit), Some(offset)) - .await - .map_err(|e| anyhow!(e))?; - - let is_last = page.next.is_none() || page.items.is_empty(); - for saved in &page.items { - all_albums.push(AlbumInfo::from(&saved.album)); - } - if is_last { - break; - } - offset += limit; - } - - Ok(all_albums) - } - - /// Fetch followed artists. - /// - /// TODO(multi-source): paginate - first page only. The cursor-based - /// `current_user_followed_artists` API requires threading the `after` - /// cursor through successive calls; only the first page is returned here. - async fn saved_artists(&self) -> Result> { - let page = self - .spotify - .current_user_followed_artists(None, Some(50)) - .await - .map_err(|e| anyhow!(e))?; - - Ok(page.items.iter().map(ArtistInfo::from).collect()) - } -} - -// --------------------------------------------------------------------------- -// PlaylistWriter -// --------------------------------------------------------------------------- - -impl PlaylistWriter for SpotifySource { - /// Append `track_uris` to a playlist. Accepts both `spotify:track:…` URIs - /// and bare base62 track IDs. - async fn add_tracks(&self, playlist_uri: &str, track_uris: &[String]) -> Result<()> { - if track_uris.is_empty() { - return Ok(()); - } - let playlist_id = playlist_id_from_uri(playlist_uri)?; - let playable_ids: Result> = track_uris - .iter() - .map(|uri| { - TrackId::from_id_or_uri(uri) - .map(|id| rspotify::model::idtypes::PlayableId::Track(id.into_static())) - .map_err(|e| anyhow!("invalid track URI/id {:?}: {}", uri, e)) - }) - .collect(); - - self - .spotify - .playlist_add_items(playlist_id.as_ref(), playable_ids?, None) - .await - .map_err(|e| anyhow!(e))?; - - Ok(()) - } - - /// Remove all occurrences of `track_uris` from a playlist. Accepts both - /// `spotify:track:…` URIs and bare base62 track IDs. - async fn remove_tracks(&self, playlist_uri: &str, track_uris: &[String]) -> Result<()> { - if track_uris.is_empty() { - return Ok(()); - } - let playlist_id = playlist_id_from_uri(playlist_uri)?; - let playable_ids: Result> = track_uris - .iter() - .map(|uri| { - TrackId::from_id_or_uri(uri) - .map(|id| rspotify::model::idtypes::PlayableId::Track(id.into_static())) - .map_err(|e| anyhow!("invalid track URI/id {:?}: {}", uri, e)) - }) - .collect(); - - self - .spotify - .playlist_remove_all_occurrences_of_items(playlist_id.as_ref(), playable_ids?, None) - .await - .map_err(|e| anyhow!(e))?; - - Ok(()) - } -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/// Parse a Spotify playlist URI (`spotify:playlist:`) or bare base62 id -/// into a `PlaylistId`. Centralised here so both `MediaSource::tracks` and -/// `PlaylistWriter` share the same error path. -fn playlist_id_from_uri(uri_or_id: &str) -> Result> { - PlaylistId::from_id_or_uri(uri_or_id) - .map(|id| id.into_static()) - .map_err(|e| anyhow!("invalid playlist URI/id {:?}: {}", uri_or_id, e)) -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - use rspotify::prelude::Id; - - // --------------------------------------------------------------------------- - // MediaSource::name / scheme - // --------------------------------------------------------------------------- - - fn dummy_spotify() -> AuthCodePkceSpotify { - AuthCodePkceSpotify::with_config( - rspotify::Credentials::new_pkce("test_client_id"), - rspotify::OAuth { - redirect_uri: "http://localhost:8888/callback".to_string(), - ..Default::default() - }, - rspotify::Config::default(), - ) - } - - #[test] - fn name_returns_spotify() { - let src = SpotifySource::new(dummy_spotify()); - assert_eq!(src.name(), "Spotify"); - } - - #[test] - fn scheme_returns_spotify() { - let src = SpotifySource::new(dummy_spotify()); - assert_eq!(src.scheme(), "spotify"); - } - - // --------------------------------------------------------------------------- - // playlist_id_from_uri - // --------------------------------------------------------------------------- - - #[test] - fn playlist_id_from_full_uri() { - let id = playlist_id_from_uri("spotify:playlist:37i9dQZF1DXcBWIGoYBM5M").unwrap(); - assert_eq!(id.id(), "37i9dQZF1DXcBWIGoYBM5M"); - } - - #[test] - fn playlist_id_from_bare_id() { - let id = playlist_id_from_uri("37i9dQZF1DXcBWIGoYBM5M").unwrap(); - assert_eq!(id.id(), "37i9dQZF1DXcBWIGoYBM5M"); - } - - #[test] - fn playlist_id_from_invalid_returns_error() { - let result = playlist_id_from_uri("not-a-valid-id!!!@@@"); - assert!(result.is_err()); - } - - // --------------------------------------------------------------------------- - // Mapping round-trip: TrackInfo from FullTrack (via test_helpers) - // --------------------------------------------------------------------------- - - #[test] - fn track_info_from_full_track_round_trip() { - use crate::core::test_helpers::full_track; - - let ft = full_track("4uLU6hMCjMI75M1A2tKUQC", "Never Gonna Give You Up"); - let info = TrackInfo::from(&ft); - - assert_eq!(info.name, "Never Gonna Give You Up"); - assert_eq!( - info.uri.as_deref(), - Some("spotify:track:4uLU6hMCjMI75M1A2tKUQC") - ); - assert_eq!(info.id.as_deref(), Some("4uLU6hMCjMI75M1A2tKUQC")); - assert!(!info.artists.is_empty()); - } -} diff --git a/src/infra/network/sync.rs b/src/infra/network/sync.rs deleted file mode 100644 index c56e974c..00000000 --- a/src/infra/network/sync.rs +++ /dev/null @@ -1,195 +0,0 @@ -use futures::stream::{SplitSink, SplitStream}; -use futures::{SinkExt, StreamExt}; -use log::{error, info, warn}; -use serde::{Deserialize, Serialize}; -use std::time::{SystemTime, UNIX_EPOCH}; -use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream}; - -type WsStream = tokio_tungstenite::WebSocketStream>; - -#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ControlMode { - #[default] - HostOnly, - SharedControl, -} - -impl std::fmt::Display for ControlMode { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ControlMode::HostOnly => write!(f, "Host Only"), - ControlMode::SharedControl => write!(f, "Shared Control"), - } - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum PlaybackAction { - Play, - Pause, - NextTrack, - PrevTrack, - Seek { position_ms: u64 }, - PlayTrack { uri: String }, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum SyncMessage { - RoomCreated { - code: String, - control_mode: String, - }, - JoinedRoom { - host_name: String, - }, - GuestJoined { - name: String, - }, - GuestLeft { - name: String, - }, - SyncState { - track_uri: String, - position_ms: u64, - is_playing: bool, - timestamp: u64, - }, - PlaybackCommand { - action: PlaybackAction, - from: Option, - }, - SetControlMode { - control_mode: String, - }, - SetName { - name: String, - }, - Heartbeat, - Error { - message: String, - }, - RoomClosed, -} - -#[derive(Clone, Debug, PartialEq)] -pub enum PartyRole { - Host, - Guest, -} - -#[derive(Clone, Debug, PartialEq, Default)] -pub enum PartyStatus { - #[default] - Disconnected, - Connecting, - Hosting, - Joined, -} - -impl std::fmt::Display for PartyStatus { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - PartyStatus::Disconnected => write!(f, "Disconnected"), - PartyStatus::Connecting => write!(f, "Connecting..."), - PartyStatus::Hosting => write!(f, "Hosting"), - PartyStatus::Joined => write!(f, "Joined"), - } - } -} - -#[derive(Clone, Debug)] -pub struct PartySession { - pub role: PartyRole, - pub code: String, - pub guests: Vec, - pub control_mode: ControlMode, - pub host_name: String, -} - -pub struct PartyConnection { - write: SplitSink, -} - -impl PartyConnection { - pub async fn send(&mut self, msg: &SyncMessage) -> Result<(), String> { - let json = serde_json::to_string(msg).map_err(|e| e.to_string())?; - self - .write - .send(Message::Text(json.into())) - .await - .map_err(|e| e.to_string()) - } - - pub async fn close(&mut self) { - let _ = self.write.close().await; - } -} - -pub fn now_ms() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64 -} - -pub async fn connect_to_relay( - relay_url: &str, - action: &str, - params: &[(&str, &str)], -) -> Result<(PartyConnection, SplitStream), String> { - let mut url = url::Url::parse(relay_url).map_err(|e| format!("Invalid relay URL: {}", e))?; - url.query_pairs_mut().append_pair("action", action); - for (k, v) in params { - url.query_pairs_mut().append_pair(k, v); - } - - info!("Connecting to party relay: action={}", action); - let (ws_stream, _) = connect_async(url.as_str()) - .await - .map_err(|e| format!("WebSocket connection failed: {}", e))?; - - let (write, read) = ws_stream.split(); - Ok((PartyConnection { write }, read)) -} - -pub fn parse_sync_message(text: &str) -> Option { - match serde_json::from_str::(text) { - Ok(msg) => Some(msg), - Err(e) => { - warn!("Failed to parse sync message: {} — raw: {}", e, text); - None - } - } -} - -pub async fn start_party_reader( - mut read: SplitStream, - incoming_tx: tokio::sync::mpsc::UnboundedSender, -) { - while let Some(result) = read.next().await { - match result { - Ok(Message::Text(text)) => { - if let Some(msg) = parse_sync_message(&text) { - if incoming_tx.send(msg).is_err() { - break; - } - } - } - Ok(Message::Close(_)) => { - let _ = incoming_tx.send(SyncMessage::RoomClosed); - break; - } - Ok(_) => {} - Err(e) => { - error!("WebSocket read error: {}", e); - let _ = incoming_tx.send(SyncMessage::Error { - message: format!("Connection lost: {}", e), - }); - break; - } - } - } -} diff --git a/src/infra/network/user.rs b/src/infra/network/user.rs deleted file mode 100644 index d8c3f54d..00000000 --- a/src/infra/network/user.rs +++ /dev/null @@ -1,286 +0,0 @@ -use super::requests::is_rate_limited_error; -use super::{ids, IoEvent, Network}; -use crate::core::app::{ActiveBlock, DiscoverTimeRange, RouteId, UserInfo}; -use crate::core::plugin_api::TrackInfo; -use anyhow::anyhow; - -use crate::infra::network::mapping::map_cursor_page; -use rand::seq::SliceRandom; -use rspotify::model::{ - artist::FullArtist, - device::DevicePayload, - page::{CursorBasedPage, Page}, - playing::PlayHistory, - track::FullTrack, - user::PrivateUser, -}; -#[cfg(feature = "streaming")] -use rspotify::model::{enums::DeviceType, Device}; -use rspotify::prelude::*; -use serde::Deserialize; - -#[derive(Deserialize)] -struct ArtistTopTracksResponse { - tracks: Vec, -} - -#[cfg(feature = "streaming")] -fn include_native_streaming_device(app: &crate::core::app::App, payload: &mut DevicePayload) { - let Some(player) = app.streaming_player.as_ref() else { - return; - }; - - if !player.is_available() { - return; - } - - let device_name = player.device_name(); - let device_id = app - .native_device_id - .clone() - .unwrap_or_else(|| player.device_id()); - - if let Some(device) = payload - .devices - .iter_mut() - .find(|device| device.name.eq_ignore_ascii_case(device_name)) - { - if device.id.is_none() { - device.id = Some(device_id); - } - return; - } - - payload.devices.push(Device { - id: Some(device_id), - is_active: app.is_streaming_active, - is_private_session: false, - is_restricted: false, - name: device_name.to_string(), - _type: DeviceType::Computer, - volume_percent: Some(player.get_volume().into()), - }); -} - -pub trait UserNetwork { - async fn get_user(&mut self); - /// `navigate: false` refreshes the device list without opening the device - /// picker (used by plugin data reads). - async fn get_devices(&mut self, navigate: bool); - async fn get_user_top_tracks(&mut self, time_range: DiscoverTimeRange); - async fn get_top_artists_mix(&mut self); - /// `navigate: false` refreshes the data without opening the screen (used by - /// plugin data reads). - #[allow(dead_code)] - async fn get_recently_played(&mut self, navigate: bool); -} - -impl UserNetwork for Network { - async fn get_user(&mut self) { - match self.spotify_get_typed::("me", &[]).await { - Ok(user) => { - let mut app = self.app.lock().await; - // `PrivateUser::country` is deprecated upstream but still the only - // market signal available; mirror the existing read in `get_user_country`. - #[allow(deprecated)] - let country = user.country.map(|c| <&'static str>::from(c).to_string()); - app.user = Some(UserInfo { - id: user.id.id().to_string(), - display_name: user.display_name.clone(), - // Store the ISO 3166-1 alpha-2 code as a plain string so no rspotify - // type leaks into App state; `App::get_user_country` re-derives it. - country, - }); - } - Err(e) => { - let err = anyhow!(e); - if is_rate_limited_error(&err) { - let mut app = self.app.lock().await; - app.set_status_message( - "Spotify rate limit hit while loading profile. Retrying automatically.", - 6, - ); - return; - } - self.handle_error(err).await; - } - } - } - - async fn get_devices(&mut self, navigate: bool) { - match self - .spotify_get_typed::("me/player/devices", &[]) - .await - { - Ok(result) => { - let mut app = self.app.lock().await; - if navigate { - app.push_navigation_stack(RouteId::SelectedDevice, ActiveBlock::SelectDevice); - } - - #[cfg(feature = "streaming")] - let mut result = result; - #[cfg(feature = "streaming")] - { - let recovering = app.request_native_streaming_recovery_if_disconnected(true); - if !recovering { - include_native_streaming_device(&app, &mut result); - } - } - - app.view.selected_device_index = if result.devices.is_empty() { - None - } else { - app - .view - .selected_device_index - .filter(|index| *index < result.devices.len()) - .or(Some(0)) - }; - app.devices = Some(result); - app - .plugin_data_generations - .bump(crate::core::app::PluginDataKind::Devices); - } - Err(e) => { - self.handle_error(anyhow!(e)).await; - } - } - } - - async fn get_user_top_tracks(&mut self, time_range: DiscoverTimeRange) { - let range_str = match time_range { - DiscoverTimeRange::Short => "short_term", - DiscoverTimeRange::Medium => "medium_term", - DiscoverTimeRange::Long => "long_term", - }; - - // Set loading state - { - let mut app = self.app.lock().await; - app.discover_loading = true; - } - - match self - .spotify_get_typed::>( - "me/top/tracks", - &[ - ("time_range", range_str.to_string()), - ("limit", "50".to_string()), - ], - ) - .await - { - Ok(page) => { - let mut app = self.app.lock().await; - // Check if these tracks are liked. - let track_check = ids::track_check_ids(page.items.iter().map(|t| t.id.as_ref())); - if !track_check.is_empty() { - app.dispatch(IoEvent::CurrentUserSavedTracksContains(track_check)); - } - app.discover_top_tracks = page.items.iter().map(TrackInfo::from).collect(); - app.discover_loading = false; - } - Err(e) => { - let mut app = self.app.lock().await; - app.discover_loading = false; - app.handle_error(anyhow!(e)); - } - } - } - - async fn get_top_artists_mix(&mut self) { - // Set loading state - { - let mut app = self.app.lock().await; - app.discover_loading = true; - } - - // 1. Get top artists - let artists_res = self - .spotify_get_typed::>( - "me/top/artists", - &[("limit", "5".to_string())], // Get top 5 artists - ) - .await; - - let artists = match artists_res { - Ok(page) => page.items, - Err(e) => { - let mut app = self.app.lock().await; - app.discover_loading = false; - app.handle_error(anyhow!(e)); - return; - } - }; - - // 2. Get top tracks for each artist, concurrently — the pacing limiter - // allows a burst of 5, sized to exactly this fan-out shape. - let this: &Self = self; - let track_fetches = artists.iter().map(|artist| { - let path = format!("artists/{}/top-tracks", artist.id.id()); - async move { - this - .spotify_get_typed::(&path, &[]) - .await - } - }); - let mut all_tracks = Vec::new(); - for res in futures::future::join_all(track_fetches) - .await - .into_iter() - .flatten() - { - all_tracks.extend(res.tracks); - } - - // 3. Shuffle - { - let mut rng = rand::rng(); - all_tracks.shuffle(&mut rng); - } - - // 4. Update state - let mut app = self.app.lock().await; - // Check if these tracks are liked. - let track_check = ids::track_check_ids(all_tracks.iter().map(|t| t.id.as_ref())); - if !track_check.is_empty() { - app.dispatch(IoEvent::CurrentUserSavedTracksContains(track_check)); - } - app.discover_artists_mix = all_tracks.iter().map(TrackInfo::from).collect(); - app.discover_loading = false; - } - - async fn get_recently_played(&mut self, navigate: bool) { - let limit = self.large_search_limit; - match self - .spotify_get_typed::>( - "me/player/recently-played", - &[("limit", limit.to_string())], - ) - .await - { - Ok(recently_played) => { - let domain_page = map_cursor_page(&recently_played, |ph| TrackInfo::from(&ph.track)); - let mut app = self.app.lock().await; - // Check if these tracks are liked. - let track_check = - ids::track_check_ids(recently_played.items.iter().map(|ph| ph.track.id.as_ref())); - if !track_check.is_empty() { - app.dispatch(IoEvent::CurrentUserSavedTracksContains(track_check)); - } - app.recently_played = Some(domain_page); - app.sort_recently_played_items(); - app - .plugin_data_generations - .bump(crate::core::app::PluginDataKind::RecentlyPlayed); - if navigate { - app.push_navigation_stack(RouteId::RecentlyPlayed, ActiveBlock::RecentlyPlayed); - } - } - Err(e) => { - self.handle_error(anyhow!(e)).await; - } - } - } -} diff --git a/src/infra/network/utils.rs b/src/infra/network/utils.rs deleted file mode 100644 index 1940bcef..00000000 --- a/src/infra/network/utils.rs +++ /dev/null @@ -1,661 +0,0 @@ -use super::Network; -use crate::core::app::{Announcement, AnnouncementLevel, LyricsStatus}; -use chrono::{DateTime, Utc}; -use serde::{de::Error as _, Deserialize, Deserializer}; -use std::collections::HashSet; -use std::env; -use std::time::{Duration, Instant}; - -#[derive(Deserialize, Debug)] -#[allow(non_snake_case)] -struct LrcResponse { - syncedLyrics: Option, - plainLyrics: Option, - #[serde(default)] - duration: Option, -} - -impl LrcResponse { - fn has_lyrics(&self) -> bool { - let has_text = - |value: &Option| value.as_deref().is_some_and(|text| !text.trim().is_empty()); - has_text(&self.syncedLyrics) || has_text(&self.plainLyrics) - } -} - -#[derive(Deserialize, Debug)] -#[allow(dead_code)] -struct GlobalSongCountResponse { - #[serde(deserialize_with = "deserialize_global_song_count")] - count: u64, -} - -const TELEMETRY_ENDPOINT: &str = "https://spotatui-counter.spotatui.workers.dev"; - -fn deserialize_global_song_count<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - #[derive(Deserialize)] - #[serde(untagged)] - enum CountValue { - Number(u64), - String(String), - } - - match CountValue::deserialize(deserializer)? { - CountValue::Number(value) => Ok(value), - CountValue::String(value) => { - let sanitized = value.replace(',', ""); - sanitized - .parse::() - .map_err(|_| D::Error::custom("invalid global song count")) - } - } -} - -#[derive(Deserialize, Debug)] -struct AnnouncementFeedResponse { - #[allow(dead_code)] - version: Option, - #[serde(default)] - announcements: Vec, -} - -#[derive(Deserialize, Debug)] -struct AnnouncementRecord { - id: String, - title: Option, - body: String, - level: Option, - url: Option, - starts_at: Option, - ends_at: Option, -} - -pub trait UtilsNetwork { - async fn get_lyrics(&mut self, track: String, artists: Vec, duration: f64); - async fn increment_global_song_count(&mut self); - async fn fetch_global_song_count(&mut self); - async fn fetch_announcements(&mut self); -} - -impl UtilsNetwork for Network { - async fn get_lyrics(&mut self, track: String, artists: Vec, duration: f64) { - // The identity latch and the plugin-facing `lyrics_state_is_current` gate - // both key on the joined display credit (`primary_artist()`), so build the - // identity from the same join here. - let request_identity = (track.clone(), artists.join(", ")); - let client = super::requests::shared_http_client(); - - // Update state to loading - { - let mut app = self.app.lock().await; - if app.desired_lyrics_identity.as_ref() != Some(&request_identity) { - return; - } - app.lyrics_status = LyricsStatus::Loading; - app.lyrics = None; - app.lyrics_synced = false; - } - - let lrc_resp = fetch_lrclib_lyrics(client, &track, &artists, duration).await; - - let mut app = self.app.lock().await; - if app.desired_lyrics_identity.as_ref() != Some(&request_identity) { - return; - } - match lrc_resp { - Some(lrc_resp) => { - // Prefer timestamped ("synced") lyrics. If LRCLIB only has plain - // (unsynced) lyrics, still show them as static text rather than - // reporting "not found" — many tracks only have plain lyrics. - let synced = lrc_resp - .syncedLyrics - .as_deref() - .map(parse_synced_lyrics) - .unwrap_or_default(); - - if !synced.is_empty() { - app.lyrics = Some(synced); - app.lyrics_synced = true; - app.lyrics_status = LyricsStatus::Found; - } else if let Some(plain) = lrc_resp - .plainLyrics - .as_deref() - .filter(|text| !text.trim().is_empty()) - { - app.lyrics = Some(synthesize_plain_lyrics(plain, duration)); - app.lyrics_synced = false; - app.lyrics_status = LyricsStatus::Found; - } else { - app.lyrics_status = LyricsStatus::NotFound; - } - } - None => { - app.lyrics_status = LyricsStatus::NotFound; - } - } - app - .plugin_data_generations - .bump(crate::core::app::PluginDataKind::Lyrics); - } - - async fn increment_global_song_count(&mut self) { - let client = super::requests::shared_http_client(); - // Fire and forget - let _ = client - .post(TELEMETRY_ENDPOINT) - .header(reqwest::header::ACCEPT, "application/json") - .timeout(Duration::from_secs(5)) - .send() - .await; - } - - async fn fetch_global_song_count(&mut self) { - let client = super::requests::shared_http_client(); - match client - .get(TELEMETRY_ENDPOINT) - .header(reqwest::header::ACCEPT, "application/json") - .timeout(Duration::from_secs(5)) - .send() - .await - { - Ok(resp) => { - if let Ok(data) = resp.json::().await { - let mut app = self.app.lock().await; - app.global_song_count = Some(data.count); - app.global_song_count_failed = false; - } else { - let mut app = self.app.lock().await; - app.global_song_count_failed = true; - } - } - Err(_) => { - let mut app = self.app.lock().await; - app.global_song_count_failed = true; - } - } - } - - async fn fetch_announcements(&mut self) { - const MAX_ANNOUNCEMENT_FEED_BYTES: usize = 256 * 1024; - const ANNOUNCEMENTS_ENV_KEY: &str = "SPOTATUI_ANNOUNCEMENTS_URL"; - const DEFAULT_ANNOUNCEMENTS_URL: &str = - "https://raw.githubusercontent.com/LargeModGames/spotatui/main/announcements.json"; - - let (announcements_enabled, feed_url, seen_ids) = { - let app = self.app.lock().await; - ( - app.user_config.behavior.enable_announcements, - app.user_config.behavior.announcement_feed_url.clone(), - app.runtime_state.seen_announcement_ids.clone(), - ) - }; - - if !announcements_enabled { - return; - } - - let env_feed_url = env::var(ANNOUNCEMENTS_ENV_KEY) - .ok() - .map(|v| v.trim().to_string()) - .filter(|v| !v.is_empty()); - - let resolved_url = env_feed_url - .or(feed_url) - .filter(|url| !url.trim().is_empty()) - .unwrap_or_else(|| DEFAULT_ANNOUNCEMENTS_URL.to_string()); - - if !resolved_url.starts_with("https://") { - return; - } - - let client = super::requests::shared_http_client(); - - let response = match client - .get(&resolved_url) - .header(reqwest::header::ACCEPT, "application/json") - .timeout(Duration::from_secs(5)) - .send() - .await - { - Ok(response) => response, - Err(_) => return, - }; - - if !response.status().is_success() { - return; - } - - if response - .content_length() - .is_some_and(|length| length > MAX_ANNOUNCEMENT_FEED_BYTES as u64) - { - return; - } - - let body = match response.bytes().await { - Ok(bytes) if bytes.len() <= MAX_ANNOUNCEMENT_FEED_BYTES => bytes, - _ => return, - }; - - let feed: AnnouncementFeedResponse = match serde_json::from_slice(&body) { - Ok(feed) => feed, - Err(_) => return, - }; - - let now = Utc::now(); - let seen_ids = seen_ids.into_iter().collect::>(); - let mut feed_ids_seen = HashSet::new(); - let mut announcements = Vec::new(); - - for record in feed.announcements { - let id = record.id.trim().to_string(); - if id.is_empty() || seen_ids.contains(&id) || !feed_ids_seen.insert(id.clone()) { - continue; - } - - let body = record.body.trim().to_string(); - if body.is_empty() { - continue; - } - - let starts_at = match record.starts_at.as_deref().map(parse_announcement_datetime) { - Some(Some(value)) => Some(value), - Some(None) => continue, - None => None, - }; - - let ends_at = match record.ends_at.as_deref().map(parse_announcement_datetime) { - Some(Some(value)) => Some(value), - Some(None) => continue, - None => None, - }; - - if let Some(start) = starts_at { - if now < start { - continue; - } - } - - if let Some(end) = ends_at { - if now > end { - continue; - } - } - - let url = record - .url - .map(|url| url.trim().to_string()) - .filter(|url| !url.is_empty() && url.starts_with("https://")); - - announcements.push(Announcement { - id, - title: record - .title - .map(|title| title.trim().to_string()) - .filter(|title| !title.is_empty()) - .unwrap_or_else(|| "Announcement".to_string()), - body, - level: parse_announcement_level(record.level.as_deref()), - url, - received_at: Instant::now(), - }); - } - - if announcements.is_empty() { - return; - } - - let mut app = self.app.lock().await; - let had_active_announcement = app.active_announcement.is_some(); - app.enqueue_announcements(announcements); - - if !had_active_announcement && app.active_announcement.is_some() { - app.push_navigation_stack( - crate::core::app::RouteId::AnnouncementPrompt, - crate::core::app::ActiveBlock::AnnouncementPrompt, - ); - } - } -} - -/// Look up lyrics on LRCLIB. `/api/get` is an exact signature match (title + -/// artist + duration must all agree with LRCLIB's record, duration in whole -/// seconds), so it 404s on small metadata differences even when LRCLIB has the -/// song. When it misses, fall back to the fuzzy `/api/search` endpoint and pick -/// the best-matching result. -/// -/// Both endpoints are tried across every artist candidate (see -/// [`artist_query_candidates`]): all `/api/get` candidates first, then all -/// `/api/search` candidates, so an exact hit on the primary artist always beats -/// a fuzzy hit on the full multi-artist credit. -async fn fetch_lrclib_lyrics( - client: &reqwest::Client, - track: &str, - artists: &[String], - duration: f64, -) -> Option { - let candidates = artist_query_candidates(artists); - - for artist in &candidates { - if let Some(lrc_resp) = lrclib_get(client, track, artist, duration).await { - return Some(lrc_resp); - } - } - - for artist in &candidates { - if let Some(lrc_resp) = lrclib_search(client, track, artist, duration).await { - return Some(lrc_resp); - } - } - - None -} - -/// The LRCLIB artist strings to try, in order. LRCLIB indexes each track under a -/// single artist string, almost always the primary (first) credited artist. A -/// collaboration reaches here as the joined `"A, B"` credit that -/// `primary_artist()` builds for display, which matches neither `/api/get` -/// (exact) nor `/api/search`, so those tracks find no lyrics even when LRCLIB -/// has them (issue #410). Try the full credit first (correct for solo tracks and -/// acts whose own name contains a comma, e.g. "Earth, Wind & Fire"), then the -/// first credited artist alone. The first artist is taken from the structured -/// list, never by splitting the joined string, so a comma inside an artist name -/// is preserved. -fn artist_query_candidates(artists: &[String]) -> Vec { - let joined = artists.join(", "); - let mut candidates = vec![joined.clone()]; - if let Some(first) = artists.first() { - let first = first.trim(); - if !first.is_empty() && first != joined { - candidates.push(first.to_string()); - } - } - candidates -} - -/// Exact `/api/get` lookup for one artist string. Returns the record only when -/// the request succeeds and it actually carries lyrics. -async fn lrclib_get( - client: &reqwest::Client, - track: &str, - artist: &str, - duration: f64, -) -> Option { - let query = [ - ("track_name", track.to_string()), - ("artist_name", artist.to_string()), - ("duration", (duration.round() as u64).to_string()), - ]; - let resp = client - .get("https://lrclib.net/api/get") - .query(&query) - .send() - .await - .ok()?; - if !resp.status().is_success() { - return None; - } - let lrc_resp = resp.json::().await.ok()?; - lrc_resp.has_lyrics().then_some(lrc_resp) -} - -/// Fuzzy `/api/search` lookup for one artist string, reduced to the best hit via -/// [`pick_search_result`]. -async fn lrclib_search( - client: &reqwest::Client, - track: &str, - artist: &str, - duration: f64, -) -> Option { - let query = [ - ("track_name", track.to_string()), - ("artist_name", artist.to_string()), - ]; - let resp = client - .get("https://lrclib.net/api/search") - .query(&query) - .send() - .await - .ok()?; - if !resp.status().is_success() { - return None; - } - let results = resp.json::>().await.ok()?; - pick_search_result(results, duration) -} - -/// Pick the best `/api/search` hit: prefer synced lyrics over plain-only, then -/// the result whose duration is closest to the playing track's (so synced -/// timestamps line up). With an unknown duration (e.g. `0.0`), duration is -/// ignored. -fn pick_search_result(results: Vec, duration: f64) -> Option { - results - .into_iter() - .filter(LrcResponse::has_lyrics) - .min_by_key(|result| { - let synced_rank = if result - .syncedLyrics - .as_deref() - .is_some_and(|text| !text.trim().is_empty()) - { - 0u64 - } else { - 1 - }; - let duration_diff_ms = if duration > 0.0 { - result - .duration - .map(|d| ((d - duration).abs() * 1000.0) as u64) - .unwrap_or(u64::MAX) - } else { - 0 - }; - (synced_rank, duration_diff_ms) - }) -} - -/// Parse LRC-format synced lyrics (`[mm:ss.xx] text` lines) into `(ms, line)` -/// pairs. Lines without a valid leading timestamp are dropped, so a body of -/// plain (unsynced) lyrics parses to an empty vec. -fn parse_synced_lyrics(text: &str) -> Vec<(u128, String)> { - text - .lines() - .filter_map(|line| { - let idx = line.find(']')?; - if idx <= 1 || !line.starts_with('[') { - return None; - } - let timestamp = &line[1..idx]; - let content = line[idx + 1..].trim().to_string(); - - let parts: Vec<&str> = timestamp.split(':').collect(); - if parts.len() != 2 { - return None; - } - let mins = parts[0].parse::().unwrap_or(0); - let secs_parts: Vec<&str> = parts[1].split('.').collect(); - let secs = secs_parts[0].parse::().unwrap_or(0); - let ms = if secs_parts.len() > 1 { - // Handle 2- or 3-digit fractional seconds. - let ms_str = secs_parts[1]; - let ms_val = ms_str.parse::().unwrap_or(0); - if ms_str.len() == 2 { - ms_val * 10 - } else { - ms_val - } - } else { - 0 - }; - - let total_ms = (mins * 60 * 1000) + (secs * 1000) + ms; - Some((total_ms as u128, content)) - }) - .collect() -} - -/// Turn plain (unsynced) lyrics into `(ms, line)` pairs with synthetic, -/// evenly-spaced timestamps across the track duration. This lets the existing -/// synced-lyrics renderer display them as static text that scrolls approximately -/// in time. With an unknown duration (e.g. `0.0`), every line gets timestamp `0` -/// so the text simply renders from the top. -fn synthesize_plain_lyrics(text: &str, duration_secs: f64) -> Vec<(u128, String)> { - let lines: Vec = text.lines().map(|line| line.trim().to_string()).collect(); - let line_count = lines.len().max(1) as f64; - let total_ms = if duration_secs > 0.0 { - duration_secs * 1000.0 - } else { - 0.0 - }; - lines - .into_iter() - .enumerate() - .map(|(idx, line)| { - let ts = ((idx as f64 / line_count) * total_ms) as u128; - (ts, line) - }) - .collect() -} - -fn parse_announcement_datetime(value: &str) -> Option> { - DateTime::parse_from_rfc3339(value) - .ok() - .map(|dt| dt.with_timezone(&Utc)) -} - -fn parse_announcement_level(level: Option<&str>) -> AnnouncementLevel { - match level.map(str::trim).map(str::to_ascii_lowercase).as_deref() { - Some("critical") => AnnouncementLevel::Critical, - Some("warning") => AnnouncementLevel::Warning, - _ => AnnouncementLevel::Info, - } -} - -#[cfg(test)] -mod tests { - use super::{ - artist_query_candidates, parse_synced_lyrics, pick_search_result, synthesize_plain_lyrics, - LrcResponse, - }; - - #[test] - fn artist_candidates_single_artist_has_no_fallback() { - // A solo track queries once; nothing extra to try. - assert_eq!( - artist_query_candidates(&["Kygo".to_string()]), - vec!["Kygo".to_string()] - ); - } - - #[test] - fn artist_candidates_add_primary_artist_for_collaborations() { - // LRCLIB stores "Take Me Back" under "Kygo" alone, so the joined credit must - // fall back to the first artist (issue #410). - assert_eq!( - artist_query_candidates(&["Kygo".to_string(), "Max McNown".to_string()]), - vec!["Kygo, Max McNown".to_string(), "Kygo".to_string()] - ); - } - - #[test] - fn artist_candidates_preserve_comma_inside_artist_name() { - // Splitting the joined display string on ", " would corrupt an act whose own - // name contains a comma; the primary artist must come from the structured - // list instead. - assert_eq!( - artist_query_candidates(&["Earth, Wind & Fire".to_string(), "Guest".to_string()]), - vec![ - "Earth, Wind & Fire, Guest".to_string(), - "Earth, Wind & Fire".to_string(), - ] - ); - } - - #[test] - fn artist_candidates_empty_list_yields_single_empty_query() { - assert_eq!(artist_query_candidates(&[]), vec![String::new()]); - } - - #[test] - fn parses_timestamped_lyric_lines_and_drops_untimed_ones() { - let text = "[00:12.34] Hello\n[01:05.00] World\nno timestamp here"; - let parsed = parse_synced_lyrics(text); - assert_eq!( - parsed, - vec![ - (12_340u128, "Hello".to_string()), - (65_000, "World".to_string()) - ] - ); - } - - #[test] - fn plain_unsynced_lyrics_parse_to_empty_synced() { - // A body of plain lyrics (no timestamps) yields no synced lines, which is - // what triggers the plain-lyrics fallback in `get_lyrics`. - assert!(parse_synced_lyrics("just\nplain\nwords").is_empty()); - } - - #[test] - fn synthesizes_evenly_spaced_timestamps_across_duration() { - let parsed = synthesize_plain_lyrics("a\nb\nc\nd", 4.0); - assert_eq!( - parsed, - vec![ - (0u128, "a".to_string()), - (1_000, "b".to_string()), - (2_000, "c".to_string()), - (3_000, "d".to_string()), - ] - ); - } - - #[test] - fn synthesizes_zero_timestamps_when_duration_unknown() { - let parsed = synthesize_plain_lyrics("a\nb", 0.0); - assert_eq!(parsed, vec![(0u128, "a".to_string()), (0, "b".to_string())]); - } - - #[allow(non_snake_case)] - fn search_result( - syncedLyrics: Option<&str>, - plainLyrics: Option<&str>, - duration: Option, - ) -> LrcResponse { - LrcResponse { - syncedLyrics: syncedLyrics.map(str::to_string), - plainLyrics: plainLyrics.map(str::to_string), - duration, - } - } - - #[test] - fn search_prefers_synced_result_with_closest_duration() { - let results = vec![ - search_result(None, Some("plain only"), Some(200.0)), - search_result(Some("[00:01.00] far"), None, Some(300.0)), - search_result(Some("[00:01.00] close"), None, Some(201.0)), - ]; - let picked = pick_search_result(results, 200.0).unwrap(); - assert_eq!(picked.syncedLyrics.as_deref(), Some("[00:01.00] close")); - } - - #[test] - fn search_falls_back_to_plain_when_no_synced_result() { - let results = vec![ - search_result(None, None, Some(200.0)), - search_result(None, Some("plain"), Some(200.0)), - ]; - let picked = pick_search_result(results, 200.0).unwrap(); - assert_eq!(picked.plainLyrics.as_deref(), Some("plain")); - } - - #[test] - fn search_returns_none_when_no_result_has_lyrics() { - let results = vec![search_result(None, Some(" "), Some(200.0))]; - assert!(pick_search_result(results, 200.0).is_none()); - } -} diff --git a/src/infra/player/backend.rs b/src/infra/player/backend.rs deleted file mode 100644 index af43b1d0..00000000 --- a/src/infra/player/backend.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! Playback transport backend abstraction. -//! -//! Playback transport (play/pause/next/seek/shuffle/repeat/volume) can be served -//! two different ways depending on which Spotify device is active: -//! -//! * **Native** — the embedded librespot [`StreamingPlayer`], driving audio -//! locally and controlling Spotify Connect state directly. -//! * **Connect** — the Spotify Web API, controlling whatever remote device is -//! currently active. -//! -//! Historically every transport operation branched inline on -//! `is_streaming_active` (via the `network` playback helpers) to pick one of -//! these. [`PlaybackBackend`] consolidates that selection into a single value so -//! each operation dispatches once instead of re-deriving the branch. -//! -//! The selection predicate is intentionally *not* unified across operations: -//! simple symmetric operations select on "native is the active device", while -//! `start_playback` and `transfert_playback_to_device` keep their own -//! asymmetric activation logic in dedicated selector helpers. This type only -//! models the resolved choice plus the player handle needed by the native arm. - -use std::sync::Arc; - -use crate::infra::player::StreamingPlayer; - -/// The resolved transport backend for a single playback operation. -/// -/// `Native` carries the player handle so the caller does not need a second -/// lookup; `Connect` indicates the Spotify Web API path should be used. -pub enum PlaybackBackend { - /// Drive transport through the embedded librespot player. - Native(Arc), - /// Drive transport through the Spotify Web API. - Connect, -} - -/// Decide whether a symmetric transport operation should use the native player. -/// -/// This reproduces the original inline guard exactly: native streaming is used -/// only when it is the active device *and* a player handle is present. When the -/// player is absent (even if native streaming is nominally active) the operation -/// falls through to the Web API, matching the historical -/// `if is_native_active { if let Some(player) { .. } }` fallthrough. -pub fn select_native(is_native_active: bool, player_present: bool) -> bool { - is_native_active && player_present -} - -#[cfg(test)] -mod tests { - use super::select_native; - - #[test] - fn selects_native_only_when_active_and_player_present() { - assert!(select_native(true, true)); - } - - #[test] - fn falls_through_to_connect_when_player_absent() { - // Mirrors the historical `if let Some(player)` fallthrough: even when native - // streaming is active, a missing player routes to the Web API. - assert!(!select_native(true, false)); - } - - #[test] - fn uses_connect_when_native_not_active() { - assert!(!select_native(false, true)); - } - - #[test] - fn uses_connect_when_neither_condition_holds() { - assert!(!select_native(false, false)); - } -} diff --git a/src/infra/player/events.rs b/src/infra/player/events.rs deleted file mode 100644 index 01c6a25a..00000000 --- a/src/infra/player/events.rs +++ /dev/null @@ -1,1570 +0,0 @@ -use crate::core::app::{self, App, NativeTrackKind}; -use crate::core::config::ClientConfig; -#[cfg(all(feature = "macos-media", target_os = "macos"))] -use crate::infra::macos_media; -#[cfg(all(feature = "mpris", target_os = "linux"))] -use crate::infra::mpris; -use crate::infra::network::IoEvent; -use crate::infra::player::{ - get_default_cache_path, PlayerEvent, SessionDisconnectReason, StreamingConfig, - StreamingConnectionState, StreamingPlayer, -}; -use log::{info, warn}; -use std::sync::{ - atomic::{AtomicBool, AtomicU64, Ordering}, - Arc, -}; -use std::time::{Duration, Instant}; -use tokio::sync::Mutex; - -const STALLED_PLAYBACK_RECOVERY_TIMEOUT: Duration = Duration::from_secs(5); -const END_OF_TRACK_CONTINUATION_DELAY: Duration = Duration::from_millis(500); -const END_OF_TRACK_RECONNECT_TIMEOUT: Duration = Duration::from_secs(12); - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SessionDisconnectRecovery { - Stop, - RebuildIdle, - RestorePlayback, -} - -fn session_disconnect_recovery(reason: SessionDisconnectReason) -> SessionDisconnectRecovery { - match reason { - SessionDisconnectReason::ExternalDeviceHandoff => SessionDisconnectRecovery::RebuildIdle, - SessionDisconnectReason::UnexpectedShutdown => SessionDisconnectRecovery::RestorePlayback, - SessionDisconnectReason::LocalCommand => SessionDisconnectRecovery::Stop, - } -} - -#[derive(Clone, Default)] -pub struct StreamingRecoveryRequest { - pub reselect_device: bool, - pub restore_playback: bool, - pub continue_after_track: Option, -} - -/// Restore intent alone decides the replay: an idle-app queue never matches -/// the native context (`reselect_device` stays false), and a handoff teardown -/// must not replay at all (#437). -fn should_replay_published_queue_slot( - request: &StreamingRecoveryRequest, - queue_now_is_spotify: bool, -) -> bool { - request.restore_playback && queue_now_is_spotify -} - -fn recovery_needs_native_selection( - request: &StreamingRecoveryRequest, - replay_queue_slot: bool, -) -> bool { - request.reselect_device || replay_queue_slot -} - -/// Bundled context for player event handling tasks. -/// Groups all shared state and managers needed by event handlers. -pub struct PlayerEventContext { - pub player: Arc, - pub app: Arc>, - pub shared_position: Arc, - pub shared_is_playing: Arc, - pub recovery_tx: tokio::sync::mpsc::UnboundedSender, - #[cfg(all(feature = "mpris", target_os = "linux"))] - pub mpris_manager: Option>, - #[cfg(all(feature = "macos-media", target_os = "macos"))] - pub macos_media_manager: Option>, - #[cfg(all(feature = "windows-media", target_os = "windows"))] - pub windows_media_manager: Option>, -} - -pub struct StreamingRecoveryContext { - pub app: Arc>, - pub shared_position: Arc, - pub shared_is_playing: Arc, - pub recovery_rx: tokio::sync::mpsc::UnboundedReceiver, - pub recovery_tx: tokio::sync::mpsc::UnboundedSender, - pub client_config: ClientConfig, - pub redirect_uri: String, - #[cfg(all(feature = "mpris", target_os = "linux"))] - pub mpris_manager: Option>, - #[cfg(all(feature = "macos-media", target_os = "macos"))] - pub macos_media_manager: Option>, - #[cfg(all(feature = "windows-media", target_os = "windows"))] - pub windows_media_manager: Option>, -} - -pub fn spawn_streaming_recovery_handler(ctx: StreamingRecoveryContext) { - tokio::spawn(async move { - handle_streaming_recovery(ctx).await; - }); -} - -/// Compact recovery-snapshot summary for the log: enough to tell which restore -/// branch should have run and with what track/position, without dumping a whole -/// context's URI list. -fn recovery_snapshot_summary(app: &App) -> String { - match app.native_playback_recovery.as_ref() { - None => "snapshot=none".to_string(), - Some(s) => format!( - "snapshot gen={} current={:?} loading={:?} pos={} dur={:?} playing={} ctx={:?} uris={} offset={:?} attempts={}", - s.generation, - s.current_track_uri, - s.loading_track_uri, - s.position_ms, - s.track_duration_ms, - s.desired_playing, - s.context_uri, - s.uris.as_ref().map_or(0, |uris| uris.len()), - s.offset, - s.recovery_attempts, - ), - } -} - -/// Intent ORs across coalesced transport-drop requests, but a handoff -/// (`restore_playback == false`) stickily vetoes restore/reselect: restoring -/// over the user's handoff would steal playback back (#437). -fn merge_recovery_requests(request: &mut StreamingRecoveryRequest, next: StreamingRecoveryRequest) { - if !request.restore_playback || !next.restore_playback { - request.reselect_device = false; - request.restore_playback = false; - request.continue_after_track = None; - return; - } - request.reselect_device |= next.reselect_device; - if next.continue_after_track.is_some() { - request.continue_after_track = next.continue_after_track; - } -} - -async fn handle_streaming_recovery(mut ctx: StreamingRecoveryContext) { - while let Some(mut request) = ctx.recovery_rx.recv().await { - while let Ok(next_request) = ctx.recovery_rx.try_recv() { - merge_recovery_requests(&mut request, next_request); - } - - if active_streaming_player(&ctx.app).await.is_some() { - // A live player already exists (e.g. a queued duplicate request): the - // pending window is over, so replay anything parked against it. - let mut app = ctx.app.lock().await; - app.native_backend_pending = false; - info!( - "recovery over a live player: reselect={} continue_after={:?} {}", - request.reselect_device, - request.continue_after_track, - recovery_snapshot_summary(&app) - ); - if app.pending_start_playback.is_some() { - info!("recovery route: replay parked StartPlayback"); - app.replay_pending_start_playback(); - } else if let Some(previous_track_id) = request.continue_after_track { - if app.native_transition_has_advanced(&previous_track_id) { - if let Some(generation) = app.native_playback_restore_generation() { - info!("recovery route: RestoreNativePlayback({generation}) (transition advanced)"); - app.dispatch(IoEvent::RestoreNativePlayback(generation)); - } else { - warn!("recovery route: none - transition advanced but the snapshot is gone"); - } - } else { - info!("recovery route: EnsurePlaybackContinues({previous_track_id})"); - app.dispatch(IoEvent::EnsurePlaybackContinues(previous_track_id)); - } - } else if request.reselect_device { - if let Some(generation) = app.native_playback_restore_generation() { - info!("recovery route: RestoreNativePlayback({generation}) (reselect)"); - app.dispatch(IoEvent::RestoreNativePlayback(generation)); - } else { - warn!("recovery route: none - reselect requested but the snapshot is gone"); - } - } else { - info!("recovery route: none - no restore intent on the request"); - } - continue; - } - - let initial_volume = { - let app = ctx.app.lock().await; - app.runtime_state.volume_percent - }; - - let streaming_config = StreamingConfig { - device_name: ctx.client_config.streaming_device_name.clone(), - bitrate: ctx.client_config.streaming_bitrate, - audio_cache: ctx.client_config.streaming_audio_cache, - cache_path: get_default_cache_path(), - initial_volume, - }; - - info!("attempting native streaming recovery"); - - match StreamingPlayer::new_cache_only( - &ctx.client_config.client_id, - &ctx.redirect_uri, - streaming_config, - ) - .await - { - Ok(recovered_player) => { - let recovered_player = Arc::new(recovered_player); - { - let mut app = ctx.app.lock().await; - // A disconnected old player may still be referenced here; shut its - // spirc down before replacing it so it can't leave a ghost device (#297). - if let Some(old) = app.streaming_player.take() { - if !Arc::ptr_eq(&old, &recovered_player) { - old.shutdown(); - } - } - app.streaming_player = Some(Arc::clone(&recovered_player)); - app.native_backend_pending = false; - } - - spawn_player_event_handler(PlayerEventContext { - player: Arc::clone(&recovered_player), - app: Arc::clone(&ctx.app), - shared_position: Arc::clone(&ctx.shared_position), - shared_is_playing: Arc::clone(&ctx.shared_is_playing), - recovery_tx: ctx.recovery_tx.clone(), - #[cfg(all(feature = "mpris", target_os = "linux"))] - mpris_manager: ctx.mpris_manager.clone(), - #[cfg(all(feature = "macos-media", target_os = "macos"))] - macos_media_manager: ctx.macos_media_manager.clone(), - #[cfg(all(feature = "windows-media", target_os = "windows"))] - windows_media_manager: ctx.windows_media_manager.clone(), - }); - - let mut app = ctx.app.lock().await; - // Queue replay over an otherwise idle app also needs the serialized - // ownership check. Auto-selection can then clear the slot before the - // replay event if another device became active during the rebuild. - let replay_queue_slot = app.pending_start_playback.is_none() - && should_replay_published_queue_slot(&request, app.queue_now_is_spotify()); - info!( - "recovery rebuilt the backend: restore={} reselect={} continue_after={:?} replay_queue_slot={} {}", - request.restore_playback, - request.reselect_device, - request.continue_after_track, - replay_queue_slot, - recovery_snapshot_summary(&app) - ); - if recovery_needs_native_selection(&request, replay_queue_slot) { - app.dispatch(IoEvent::AutoSelectStreamingDevice( - ctx.client_config.streaming_device_name.clone(), - false, - true, - )); - } - // A new explicit playback request wins over restoring older intent. - // Both paths are queued after device selection on the serial IoEvent pump. - if app.pending_start_playback.is_some() { - info!("recovery route: replay parked StartPlayback"); - app.replay_pending_start_playback(); - } else if replay_queue_slot { - // The published queue slot outranks snapshot restore: its direct - // load was discarded with the replaced player, and for a queue - // playing over an idle app there is no snapshot whose TrackChanged - // would trigger the reload guard. - info!("recovery route: ReplayPublishedSpotifyQueueSlot"); - app.dispatch(IoEvent::ReplayPublishedSpotifyQueueSlot); - } else if let Some(previous_track_id) = request.continue_after_track { - if app.native_transition_has_advanced(&previous_track_id) { - if let Some(generation) = app.native_playback_restore_generation() { - info!("recovery route: RestoreNativePlayback({generation}) (transition advanced)"); - app.dispatch(IoEvent::RestoreNativePlayback(generation)); - } else { - warn!("recovery route: none - transition advanced but the snapshot is gone"); - } - } else { - info!("recovery route: EnsurePlaybackContinues({previous_track_id})"); - app.dispatch(IoEvent::EnsurePlaybackContinues(previous_track_id)); - } - } else if request.reselect_device { - if let Some(generation) = app.native_playback_restore_generation() { - info!("recovery route: RestoreNativePlayback({generation}) (reselect)"); - app.dispatch(IoEvent::RestoreNativePlayback(generation)); - } else { - warn!("recovery route: none - reselect requested but the snapshot is gone"); - app.set_status_message("Native streaming recovered.", 6); - } - } else if request.restore_playback { - // A handoff rebuild instead keeps the teardown's "moved to another - // device" message. - app.set_status_message("Native streaming recovered.", 6); - } - } - Err(e) => { - info!("native streaming recovery failed: {}", e); - let mut app = ctx.app.lock().await; - app.native_backend_pending = false; - app.native_restore_pending = None; - app.native_load_watchdog = None; - if app.pending_start_playback.take().is_some() { - app.set_status_message( - format!("Native recovery failed; playback request dropped: {}", e), - 8, - ); - } else { - app.set_status_message(format!("Native recovery failed: {}", e), 8); - } - } - } - } -} - -/// Get the currently active streaming player (if any). -pub async fn active_streaming_player(app: &Arc>) -> Option> { - let app_lock = app.lock().await; - app_lock - .streaming_player - .as_ref() - .filter(|player| player.is_available()) - .cloned() -} - -pub fn spawn_player_event_handler(ctx: PlayerEventContext) { - let event_rx = ctx.player.get_event_channel(); - info!("spawning native player event handler"); - - let player = ctx.player.clone(); - let app = Arc::clone(&ctx.app); - let shared_position = Arc::clone(&ctx.shared_position); - let shared_is_playing = Arc::clone(&ctx.shared_is_playing); - let recovery_tx = ctx.recovery_tx.clone(); - #[cfg(all(feature = "mpris", target_os = "linux"))] - let mpris_manager = ctx.mpris_manager.clone(); - #[cfg(all(feature = "macos-media", target_os = "macos"))] - let macos_media_manager = ctx.macos_media_manager.clone(); - #[cfg(all(feature = "windows-media", target_os = "windows"))] - let windows_media_manager = ctx.windows_media_manager.clone(); - - tokio::spawn(async move { - handle_player_events( - event_rx, - player, - app, - shared_position, - shared_is_playing, - recovery_tx, - #[cfg(all(feature = "mpris", target_os = "linux"))] - mpris_manager, - #[cfg(all(feature = "macos-media", target_os = "macos"))] - macos_media_manager, - #[cfg(all(feature = "windows-media", target_os = "windows"))] - windows_media_manager, - ) - .await; - }); -} - -/// Handle player events from librespot and update app state directly. -/// This bypasses the Spotify Web API for instant UI updates. -async fn handle_player_events( - mut event_rx: librespot_playback::player::PlayerEventChannel, - player: Arc, - app: Arc>, - shared_position: Arc, - shared_is_playing: Arc, - recovery_tx: tokio::sync::mpsc::UnboundedSender, - #[cfg(all(feature = "mpris", target_os = "linux"))] mpris_manager: Option< - Arc, - >, - #[cfg(all(feature = "macos-media", target_os = "macos"))] macos_media_manager: Option< - Arc, - >, - #[cfg(all(feature = "windows-media", target_os = "windows"))] windows_media_manager: Option< - Arc, - >, -) { - use chrono::TimeDelta; - - // Count consecutive failed (Unavailable) loads so we can escalate the message - // when an account is hit by the upstream Spotify audio-key block (#282). A - // single genuinely-unavailable track only trips the mild message and resets on - // the next successful Playing. - let mut consecutive_unavailable: u32 = 0; - const UNAVAILABLE_ESCALATION_THRESHOLD: u32 = 3; - - // The StreamingPlayer first reconnects its Session/Spirc in place, retaining - // the Player and buffered audio. Only replace the whole backend when that - // attempt fails or desired playback stops making progress. - let mut connection_state_rx = player.connection_state_receiver(); - let mut session_lost = false; - let mut fast_reconnect_failed = false; - let mut progress_watchdog_armed = false; - let mut transport_recovery_pending = false; - let mut pending_end_of_track = None; - let mut observed_connection_generation = 0_u64; - let mut audibly_playing = shared_is_playing.load(Ordering::Relaxed); - let mut last_position = shared_position.load(Ordering::Relaxed); - let mut last_progress_at = Instant::now(); - let mut watchdog = tokio::time::interval(Duration::from_secs(1)); - watchdog.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - let playback_transition_generation = Arc::new(AtomicU64::new(0)); - - loop { - let event = tokio::select! { - maybe_event = event_rx.recv() => match maybe_event { - Some(event) => event, - None => break, - }, - changed = connection_state_rx.changed() => { - if changed.is_err() { - return; - } - let connection_state = *connection_state_rx.borrow_and_update(); - match connection_state { - StreamingConnectionState::Reconnecting { generation } => { - observed_connection_generation = generation; - session_lost = true; - fast_reconnect_failed = false; - progress_watchdog_armed = true; - transport_recovery_pending = true; - last_progress_at = Instant::now(); - info!("native streaming fast reconnect generation {} started", generation); - let mut app = app.lock().await; - app.native_backend_pending = true; - app.set_status_message("Native streaming connection lost; reconnecting.", 6); - } - StreamingConnectionState::Connected { generation } - if session_lost || generation != observed_connection_generation => - { - observed_connection_generation = generation; - session_lost = false; - fast_reconnect_failed = false; - progress_watchdog_armed = true; - transport_recovery_pending = true; - last_progress_at = Instant::now(); - info!("native streaming fast reconnect generation {} completed", generation); - let mut app = app.lock().await; - app.native_backend_pending = false; - if app.native_load_watchdog.is_some() { - app.native_load_watchdog = Some(Instant::now()); - } - app.set_status_message("Native streaming connection recovered.", 5); - } - StreamingConnectionState::Connected { .. } => {} - StreamingConnectionState::Failed { generation } => { - session_lost = true; - fast_reconnect_failed = true; - progress_watchdog_armed = true; - transport_recovery_pending = true; - info!("native streaming fast reconnect generation {} failed", generation); - if audibly_playing { - continue; - } - request_full_streaming_recovery( - &app, - &player, - &shared_position, - &shared_is_playing, - &recovery_tx, - "Native streaming connection lost; attempting recovery.", - pending_end_of_track.clone(), - ) - .await; - return; - } - StreamingConnectionState::Shutdown => return, - } - continue; - } - _ = watchdog.tick(), if progress_watchdog_armed => { - let position = shared_position.load(Ordering::Relaxed); - if position != last_position { - last_position = position; - last_progress_at = Instant::now(); - } - let desired_playing = { - let app = app.lock().await; - app - .native_playback_recovery - .as_ref() - .map_or_else( - || shared_is_playing.load(Ordering::Relaxed), - |snapshot| snapshot.desired_playing, - ) - }; - if !desired_playing && !session_lost { - progress_watchdog_armed = false; - transport_recovery_pending = false; - continue; - } - if recovery_watchdog_should_escalate(desired_playing, last_progress_at.elapsed()) { - info!("native playback made no progress for {}s during session recovery; replacing backend", STALLED_PLAYBACK_RECOVERY_TIMEOUT.as_secs()); - request_full_streaming_recovery( - &app, - &player, - &shared_position, - &shared_is_playing, - &recovery_tx, - "Native streaming stalled; attempting recovery.", - pending_end_of_track.clone(), - ) - .await; - return; - } - continue; - } - }; - - if !is_current_streaming_player(&app, &player).await { - continue; - } - - match &event { - PlayerEvent::Playing { .. } => { - audibly_playing = true; - pending_end_of_track = None; - last_progress_at = Instant::now(); - if !session_lost { - progress_watchdog_armed = false; - transport_recovery_pending = false; - } - } - PlayerEvent::Paused { .. } | PlayerEvent::Stopped { .. } | PlayerEvent::EndOfTrack { .. } => { - audibly_playing = false; - } - PlayerEvent::Seeked { .. } - | PlayerEvent::TrackChanged { .. } - | PlayerEvent::Loading { .. } => { - last_progress_at = Instant::now(); - } - PlayerEvent::PositionChanged { position_ms, .. } => { - let position = u64::from(*position_ms); - if position != last_position { - last_position = position; - pending_end_of_track = None; - last_progress_at = Instant::now(); - if !session_lost { - progress_watchdog_armed = false; - transport_recovery_pending = false; - } - } - } - _ => {} - } - - match event { - PlayerEvent::Playing { - play_request_id: _, - track_id, - position_ms, - } => { - // While the native queue is mid-handoff or playing a *decoded* track, - // librespot must stay paused. The handoff pauses Spirc, but a - // self-advance load (or a stale-slot reissue) already in flight at that - // moment can complete afterwards and start audio over the queue slot — - // re-pause instead of accepting the state update. Librespot playing is - // legitimate here only when the slot itself is a Spotify track; with a - // decoded slot, or with a context suspended under the queue and no - // Spotify slot (the between-items window: the old slot is cleared, the - // next one not yet published), it never is. One-shot: a paused Spirc - // emits no further Playing events, so this can't ping-pong. - { - let stray_over_queue = { - let guard = app.lock().await; - let decoded_slot = { - #[cfg(feature = "audio-decode-queue")] - { - guard.queue_now_decoded_player().is_some() - } - // Without a queueable decoded source the slot can never be - // decoded (internet radio enables `audio-decode` but is never - // queued), so there is nothing to shadow librespot here. - #[cfg(not(feature = "audio-decode-queue"))] - { - false - } - }; - !guard.queue_now_is_spotify() && (decoded_slot || guard.queue_suspended.is_some()) - }; - if stray_over_queue { - player.pause(); - continue; - } - } - - // Playback is actually working: reset the failure streak. - consecutive_unavailable = 0; - shared_is_playing.store(true, Ordering::Relaxed); - let track_uri = track_id.to_string(); - - #[cfg(all(feature = "mpris", target_os = "linux"))] - if let Some(ref mpris) = mpris_manager { - mpris.set_playback_status(true); - } - - #[cfg(all(feature = "macos-media", target_os = "macos"))] - if let Some(ref macos_media) = macos_media_manager { - macos_media.set_playback_status(true); - } - - #[cfg(all(feature = "windows-media", target_os = "windows"))] - if let Some(ref windows_media) = windows_media_manager { - windows_media.set_playback_status(true); - } - - { - let mut app_lock = app.lock().await; - app_lock.native_is_playing = Some(true); - // A real Playing event proves the session is alive: disarm the load - // watchdog and drop any request parked for its potential recovery. - let restore_confirmed = - app_lock.observe_native_playback_state(track_uri.clone(), position_ms, true); - if app_lock.native_restore_pending.is_none() { - app_lock.native_load_watchdog = None; - } - app_lock.pending_start_playback = None; - app_lock.native_backend_pending = false; - if restore_confirmed { - app_lock.set_status_message("Native playback restored.", 5); - } - } - - if let Ok(mut app) = app.try_lock() { - app.song_progress_ms = position_ms as u128; - - if let Some(ref mut ctx) = app.current_playback_context { - ctx.is_playing = true; - ctx.progress = Some(TimeDelta::milliseconds(position_ms as i64)); - } - - app.instant_since_last_current_playback_poll = std::time::Instant::now(); - - let track_id_str = app::base62_id_of(&track_uri).to_string(); - if app.last_track_id.as_ref() != Some(&track_id_str) { - app.last_track_id = Some(track_id_str); - app.dispatch(IoEvent::GetCurrentPlayback); - } - if app.pending_stop_after_track { - app.pending_stop_after_track = false; - if let Some(ref mut ctx) = app.current_playback_context { - ctx.is_playing = false; - } - app.dispatch(IoEvent::PausePlayback); - } - } - } - PlayerEvent::Paused { - play_request_id: _, - track_id, - position_ms, - } => { - shared_is_playing.store(false, Ordering::Relaxed); - let track_uri = track_id.to_string(); - - #[cfg(all(feature = "mpris", target_os = "linux"))] - if let Some(ref mpris) = mpris_manager { - mpris.set_playback_status(false); - } - - #[cfg(all(feature = "macos-media", target_os = "macos"))] - if let Some(ref macos_media) = macos_media_manager { - macos_media.set_playback_status(false); - } - - #[cfg(all(feature = "windows-media", target_os = "windows"))] - if let Some(ref windows_media) = windows_media_manager { - windows_media.set_playback_status(false); - } - - { - let mut app_lock = app.lock().await; - app_lock.native_is_playing = Some(false); - let restore_confirmed = - app_lock.observe_native_playback_state(track_uri, position_ms, false); - if app_lock.native_restore_pending.is_none() { - app_lock.native_load_watchdog = None; - } - if restore_confirmed { - app_lock.set_status_message("Native playback restored in paused state.", 5); - } - } - - if let Ok(mut app) = app.try_lock() { - app.song_progress_ms = position_ms as u128; - - if let Some(ref mut ctx) = app.current_playback_context { - ctx.is_playing = false; - ctx.progress = Some(TimeDelta::milliseconds(position_ms as i64)); - } - app.instant_since_last_current_playback_poll = std::time::Instant::now(); - } - } - PlayerEvent::Seeked { - play_request_id: _, - track_id: _, - position_ms, - } => { - #[cfg(all(feature = "macos-media", target_os = "macos"))] - if let Some(ref macos_media) = macos_media_manager { - macos_media.set_position(position_ms as u64); - } - - #[cfg(all(feature = "windows-media", target_os = "windows"))] - if let Some(ref windows_media) = windows_media_manager { - windows_media.set_position(position_ms as u64); - } - - if let Ok(mut app) = app.try_lock() { - app.song_progress_ms = position_ms as u128; - app.seek_ms = None; - app.set_native_recovery_position(position_ms); - - if let Some(ref mut ctx) = app.current_playback_context { - ctx.progress = Some(TimeDelta::milliseconds(position_ms as i64)); - } - app.instant_since_last_current_playback_poll = std::time::Instant::now(); - } - } - PlayerEvent::TrackChanged { audio_item } => { - playback_transition_generation.fetch_add(1, Ordering::Relaxed); - use librespot_metadata::audio::UniqueFields; - - let (artists, album, kind) = match &audio_item.unique_fields { - UniqueFields::Track { artists, album, .. } => { - let artist_names: Vec = artists.0.iter().map(|a| a.name.clone()).collect(); - (artist_names, album.clone(), NativeTrackKind::Track) - } - UniqueFields::Episode { show_name, .. } => ( - vec![show_name.clone()], - String::new(), - NativeTrackKind::Episode, - ), - UniqueFields::Local { artists, album, .. } => { - let artist_vec = artists - .as_ref() - .map(|a| vec![a.clone()]) - .unwrap_or_default(); - let album_str = album.clone().unwrap_or_default(); - (artist_vec, album_str, NativeTrackKind::Track) - } - }; - - #[cfg(all(feature = "mpris", target_os = "linux"))] - if let Some(ref mpris) = mpris_manager { - mpris.set_metadata( - &audio_item.name, - &artists, - &album, - audio_item.duration_ms, - None, - ); - } - - #[cfg(all(feature = "macos-media", target_os = "macos"))] - if let Some(ref macos_media) = macos_media_manager { - macos_media.set_metadata( - &audio_item.name, - &artists, - &album, - audio_item.duration_ms, - None, - ); - } - - #[cfg(all(feature = "windows-media", target_os = "windows"))] - if let Some(ref windows_media) = windows_media_manager { - windows_media.set_metadata( - &audio_item.name, - &artists, - &album, - audio_item.duration_ms as u64, - None, - ); - } - - let mut app = app.lock().await; - // A TrackChanged proves the session is processing loads: disarm the - // zombie-session watchdog. - if app.native_restore_pending.is_none() { - app.native_load_watchdog = None; - } - app.pending_start_playback = None; - app.native_backend_pending = false; - app.native_track_info = Some(app::NativeTrackInfo { - name: audio_item.name.clone(), - artists: artists.clone(), - album: album.clone(), - duration_ms: audio_item.duration_ms, - kind, - // `covers` arrives widest-first: the pinned librespot fork sorts it - // descending by width in `get_covers` (metadata/src/audio/item.rs) - // before building the list, and the filter that follows preserves that - // order, so `.first()` is the largest. Each url is already a fully - // resolved `https://i.scdn.co/image/` — the same CDN form the - // Web API returns, so the cover-art key stays stable when the polled - // context later catches up and takes over. The empty-string filter - // matters: librespot substitutes into a session-supplied url template, - // so a blank template yields `Some("")`, which would defeat the - // fallback below instead of deferring to it. Local files legitimately - // carry no covers at all (their art is embedded in the file). - image_url: audio_item - .covers - .first() - .map(|cover| cover.url.clone()) - .filter(|url| !url.is_empty()), - }); - - app.song_progress_ms = 0; - // librespot 0.8's `SpotifyUri` Display is the full `spotify:track:` - // URI; app-side ids (shuffle index sync, the queue guard, - // `last_track_id`) are bare base62, so normalize at the event boundary. - // The restore observer keeps the full URI (it canonicalizes internally). - let playing_uri = audio_item.track_id.to_string(); - app.observe_native_track_changed(playing_uri.clone(), kind, audio_item.duration_ms); - let playing_id = app::base62_id_of(&playing_uri).to_string(); - app.last_track_id = Some(playing_id.clone()); - // Keep the client-side shuffle session pointed at what Spirc actually - // plays (also detects a completed repeat-all lap for the reshuffle). - app.sync_native_shuffle_index(&playing_id); - app.instant_since_last_current_playback_poll = std::time::Instant::now(); - app.dispatch(IoEvent::GetCurrentPlayback); - - // Spirc self-advance guard: a queued Spotify track plays via a direct - // `player.load` (no Spirc context), so Spirc can switch to the next - // context track on its own. If that happened, reissue the queued track - // (bounded). NOTE: pending the live experiment in the plan (Risk #1), - // this mitigation is unverified without a real Spotify session. - let reload_uri = app.spotify_queue_guard_reload_uri(&playing_id); - let slot_desired_playing = app.queue_slot_desired_playing; - let end_of_track_transition_advanced = pending_end_of_track - .as_deref() - .is_some_and(|previous| app.native_transition_has_advanced(previous)); - drop(app); - if end_of_track_transition_advanced { - pending_end_of_track = None; - } - if let Some(uri) = reload_uri { - info!("spirc advanced off the queued track; reissuing {}", uri); - reissue_published_queue_slot(&player, &uri, slot_desired_playing).await; - } - } - PlayerEvent::Stopped { .. } => { - #[cfg(all(feature = "mpris", target_os = "linux"))] - if let Some(ref mpris) = mpris_manager { - mpris.set_stopped(); - } - - #[cfg(all(feature = "macos-media", target_os = "macos"))] - if let Some(ref macos_media) = macos_media_manager { - macos_media.set_stopped(); - } - - #[cfg(all(feature = "windows-media", target_os = "windows"))] - if let Some(ref windows_media) = windows_media_manager { - windows_media.set_stopped(); - } - - if let Ok(mut app) = app.try_lock() { - if let Some(ref mut ctx) = app.current_playback_context { - ctx.is_playing = false; - } - app.song_progress_ms = 0; - app.last_track_id = None; - app.native_track_info = None; - } - - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - - if let Ok(mut app) = app.try_lock() { - app.dispatch(IoEvent::GetCurrentPlayback); - } - } - PlayerEvent::EndOfTrack { track_id, .. } => { - #[cfg(all(feature = "mpris", target_os = "linux"))] - if let Some(ref mpris) = mpris_manager { - mpris.set_stopped(); - } - - #[cfg(all(feature = "macos-media", target_os = "macos"))] - if let Some(ref macos_media) = macos_media_manager { - macos_media.set_stopped(); - } - - #[cfg(all(feature = "windows-media", target_os = "windows"))] - if let Some(ref windows_media) = windows_media_manager { - windows_media.set_stopped(); - } - - // Recovery and queue continuation compare against the bare Web API - // id, while librespot formats this event as a full Spotify URI. - let ended_uri = track_id.to_string(); - let previous_track_id = app::base62_id_of(&ended_uri).to_string(); - - // Full `lock().await`, not `try_lock`: this arm decides whether the - // native queue takes over, and a dropped decision here strands the - // queue (nothing advances, nothing continues). - let mut reissue_queue_slot: Option<(String, bool)> = None; - let mut end_decision = "ensure-playback-continues"; - let should_ensure_playback = { - let mut app = app.lock().await; - if let Some(ref mut ctx) = app.current_playback_context { - ctx.is_playing = false; - } - app.song_progress_ms = 0; - app.last_track_id = None; - app.native_track_info = None; - if app.user_config.behavior.stop_after_current_track { - end_decision = "stop-after-current"; - app.pending_stop_after_track = true; - app.set_native_playback_intent(false); - false - } else if let Some(uri) = app.spotify_queue_guard_reload_uri(&previous_track_id) { - // The published Spotify queue slot is not the track that just - // ended, so the slot never started (its direct load was deferred - // during an in-place reconnect and possibly discarded). Reissue it - // instead of letting the advance below consume the never-played - // item; the guard's reload budget bounds the retries. - end_decision = "reissue-queue-slot"; - reissue_queue_slot = Some((uri, app.queue_slot_desired_playing)); - false - } else if app.handle_native_spotify_track_end() { - end_decision = "native-queue-takeover"; - // The native queue takes priority: a queued Spotify track that just - // ended advances the queue; a context track that ended while items - // wait suspends the context (preempting Spirc's self-advance) and - // hands off to the queue. - false - } else if app.native_raw_list_playback_exhausted(&previous_track_id) { - end_decision = "raw-list-exhausted"; - // The raw URI list ran out with repeat off: stopping here is - // legitimate, so record stopped intent instead of arming the - // watchdog. This must happen under this lock, before the watchdog - // could arm: the serial network pump that runs - // `ensure_playback_continues` can lag past the stall window. - app.set_native_playback_intent(false); - false - } else { - // Neither the queue nor list exhaustion applies: fall back to the - // normal continue-playback path. - true - } - }; - info!("end of track {}: {}", previous_track_id, end_decision); - - if let Some((uri, desired_playing)) = reissue_queue_slot { - info!( - "queue slot did not start before the previous track ended; reissuing {}", - uri - ); - reissue_published_queue_slot(&player, &uri, desired_playing).await; - } - - if should_ensure_playback { - pending_end_of_track = Some(previous_track_id.clone()); - progress_watchdog_armed = true; - last_progress_at = Instant::now(); - spawn_end_of_track_continuation( - Arc::clone(&app), - Arc::clone(&player), - previous_track_id, - Arc::clone(&playback_transition_generation), - ); - } - } - PlayerEvent::VolumeChanged { volume } => { - let volume_percent = ((volume as f64 / 65535.0) * 100.0).round() as u8; - #[cfg(all(feature = "mpris", target_os = "linux"))] - if let Some(ref mpris) = mpris_manager { - mpris.set_volume(volume_percent); - } - - #[cfg(all(feature = "macos-media", target_os = "macos"))] - if let Some(ref macos_media) = macos_media_manager { - macos_media.set_volume(volume_percent); - } - - if let Ok(mut app) = app.try_lock() { - if let Some(pending) = app.pending_volume { - if volume_percent == pending { - app.pending_volume = None; - app.last_dispatched_volume = None; - } - } else { - if let Some(ref mut ctx) = app.current_playback_context { - ctx.device.volume_percent = Some(volume_percent as u32); - } - app.runtime_state.volume_percent = volume_percent.min(100); - let _ = app.save_runtime_state( - &crate::core::state::PersistedRuntimeState::volume_percent(volume_percent.min(100)), - ); - } - } - } - PlayerEvent::PositionChanged { - play_request_id: _, - track_id: _, - position_ms, - } => { - shared_position.store(position_ms as u64, Ordering::Relaxed); - if let Ok(mut app) = app.try_lock() { - app.set_native_recovery_position(position_ms); - } - - #[cfg(all(feature = "mpris", target_os = "linux"))] - if let Some(ref mpris) = mpris_manager { - mpris.set_position(position_ms as u64); - } - - #[cfg(all(feature = "macos-media", target_os = "macos"))] - if let Some(ref macos_media) = macos_media_manager { - macos_media.set_position(position_ms as u64); - } - - #[cfg(all(feature = "windows-media", target_os = "windows"))] - if let Some(ref windows_media) = windows_media_manager { - windows_media.set_position(position_ms as u64); - } - } - PlayerEvent::SessionDisconnected { reason, .. } => { - #[cfg(all(feature = "mpris", target_os = "linux"))] - if let Some(ref mpris) = mpris_manager { - mpris.set_stopped(); - } - - #[cfg(all(feature = "macos-media", target_os = "macos"))] - if let Some(ref macos_media) = macos_media_manager { - macos_media.set_stopped(); - } - - #[cfg(all(feature = "windows-media", target_os = "windows"))] - if let Some(ref windows_media) = windows_media_manager { - windows_media.set_stopped(); - } - - let recovery = session_disconnect_recovery(reason); - let (status_message, restore_playback) = match recovery { - SessionDisconnectRecovery::Stop => ("Native streaming stopped.", false), - SessionDisconnectRecovery::RebuildIdle => { - ("Playback moved to another Spotify device.", false) - } - SessionDisconnectRecovery::RestorePlayback => { - ("Native streaming disconnected; attempting recovery.", true) - } - }; - if let Some(request) = disconnect_streaming_player( - &app, - &player, - &shared_position, - &shared_is_playing, - status_message, - restore_playback, - ) - .await - { - if recovery == SessionDisconnectRecovery::Stop { - // No rebuild follows a Stop; release the pending latch so later - // requests can't park forever. (Presently unreachable: local - // shutdowns remove the player before this event, failing the - // ptr_eq guard above.) - app.lock().await.native_backend_pending = false; - } else { - let _ = recovery_tx.send(request); - } - } - return; - } - PlayerEvent::Unavailable { track_id, .. } => { - if unavailable_is_transport_related(transport_recovery_pending, player.connection_state()) { - info!( - "native playback unavailable for track {} while the session transport is down; deferring to connection recovery", - track_id - ); - continue; - } - - // librespot emits Unavailable when a track can't be loaded — including - // when Spotify rejects the audio key (`error audio key 0 1`), which makes - // decryption fail. This was previously dropped by the `_` arm, so the - // failure was completely silent (#282). Surface it to the user. - consecutive_unavailable += 1; - last_progress_at = Instant::now(); - - // Clear the ghost native track and the request/watchdog together so an - // unavailable track cannot be replayed as a supposed session failure. - { - let mut app = app.lock().await; - app.song_progress_ms = 0; - app.native_track_info = None; - app.native_load_watchdog = None; - app.native_restore_pending = None; - app.pending_start_playback = None; - app.native_backend_pending = false; - if let Some(ref mut ctx) = app.current_playback_context { - ctx.is_playing = false; - } - } - - info!( - "native playback unavailable (track {}, consecutive {})", - track_id, consecutive_unavailable - ); - - // Once several consecutive tracks fail to load (genuinely unavailable, - // e.g. region-locked or the audio-key block), halt playback instead of - // letting librespot auto-skip through the entire queue at machine speed; - // that stampede hammers Spotify and can get the account rate-limited. - if consecutive_unavailable >= UNAVAILABLE_ESCALATION_THRESHOLD { - player.pause(); - progress_watchdog_armed = false; - pending_end_of_track = None; - } - - // Emit on the threshold transitions only (== not >=) so we don't spam the - // same message on every auto-skip during an account-wide failure. - if consecutive_unavailable == 1 { - let mut app = app.lock().await; - app.set_status_message( - "Couldn't play this track natively (unavailable or blocked); skipping.", - 6, - ); - } else if consecutive_unavailable == UNAVAILABLE_ESCALATION_THRESHOLD { - let mut app = app.lock().await; - app.set_status_message( - "Several tracks in a row couldn't be played natively, so playback was stopped. They may be unavailable on your account or region. Press 'd' to switch to an official Spotify Connect device.", - 20, - ); - } - } - PlayerEvent::Loading { - track_id, - position_ms, - .. - } => { - let mut app = app.lock().await; - app.observe_native_loading(track_id.to_string(), position_ms); - if app.native_load_watchdog.is_some() { - app.native_load_watchdog = Some(std::time::Instant::now()); - } - } - PlayerEvent::Preloading { .. } => {} - _ => {} - } - - // A failed in-place reconnect owns no viable Spirc. Runs after event - // bookkeeping so EndOfTrack still records queue/stop-after-current state. - if session_lost && fast_reconnect_failed && !audibly_playing { - info!("buffered playback ended after fast reconnect failure; replacing backend"); - request_full_streaming_recovery( - &app, - &player, - &shared_position, - &shared_is_playing, - &recovery_tx, - "Native streaming connection lost; attempting recovery.", - pending_end_of_track.clone(), - ) - .await; - return; - } - } - - if matches!( - player.connection_state(), - StreamingConnectionState::Shutdown - ) { - return; - } - - request_full_streaming_recovery( - &app, - &player, - &shared_position, - &shared_is_playing, - &recovery_tx, - "Native streaming stopped; attempting recovery.", - pending_end_of_track, - ) - .await; -} - -fn recovery_watchdog_should_escalate(desired_playing: bool, stalled_for: Duration) -> bool { - desired_playing && stalled_for >= STALLED_PLAYBACK_RECOVERY_TIMEOUT -} - -/// Reissue the published Spotify queue slot, honoring its desired-playing -/// state: a slot the user paused is reloaded silently so a stale Spirc event -/// cannot restart it. -async fn reissue_published_queue_slot( - player: &Arc, - uri: &str, - desired_playing: bool, -) { - let result = if desired_playing { - player.play_uri(uri).await - } else { - player.load_uri_paused(uri).await - }; - if let Err(e) = result { - info!("failed to reissue queued Spotify track: {}", e); - } -} - -fn unavailable_is_transport_related( - transport_recovery_pending: bool, - connection_state: StreamingConnectionState, -) -> bool { - transport_recovery_pending - || !matches!(connection_state, StreamingConnectionState::Connected { .. }) -} - -async fn request_full_streaming_recovery( - app: &Arc>, - player: &Arc, - shared_position: &Arc, - shared_is_playing: &Arc, - recovery_tx: &tokio::sync::mpsc::UnboundedSender, - status_message: &str, - continue_after_track: Option, -) { - if let Some(mut request) = disconnect_streaming_player( - app, - player, - shared_position, - shared_is_playing, - status_message, - true, - ) - .await - { - request.continue_after_track = continue_after_track.or(request.continue_after_track); - let _ = recovery_tx.send(request); - } -} - -fn spawn_end_of_track_continuation( - app: Arc>, - player: Arc, - previous_track_id: String, - playback_transition_generation: Arc, -) { - let observed_transition_generation = playback_transition_generation.load(Ordering::Relaxed); - let mut connection_state_rx = player.connection_state_receiver(); - - tokio::spawn(async move { - let wait_for_stable_connection = async { - loop { - let connection_state = *connection_state_rx.borrow_and_update(); - match connection_state { - StreamingConnectionState::Connected { generation } => { - tokio::time::sleep(END_OF_TRACK_CONTINUATION_DELAY).await; - if matches!( - *connection_state_rx.borrow(), - StreamingConnectionState::Connected { - generation: current_generation - } if current_generation == generation - ) { - return true; - } - } - StreamingConnectionState::Reconnecting { .. } => { - if connection_state_rx.changed().await.is_err() { - return false; - } - } - StreamingConnectionState::Failed { .. } | StreamingConnectionState::Shutdown => { - return false; - } - } - } - }; - - if !tokio::time::timeout(END_OF_TRACK_RECONNECT_TIMEOUT, wait_for_stable_connection) - .await - .unwrap_or(false) - { - warn!( - "end-of-track continuation for {} abandoned: no stable connection within {}s", - previous_track_id, - END_OF_TRACK_RECONNECT_TIMEOUT.as_secs() - ); - return; - } - - // TrackChanged is proof that Spirc already advanced. The fallback must only - // run when no transition was observed, otherwise it skips twice. Loading - // alone is not sufficient: a dead connection can stall after that event. - if playback_transition_generation.load(Ordering::Relaxed) != observed_transition_generation - || !is_current_streaming_player(&app, &player).await - { - return; - } - - app - .lock() - .await - .dispatch(IoEvent::EnsurePlaybackContinues(previous_track_id)); - }); -} - -async fn is_current_streaming_player(app: &Arc>, player: &Arc) -> bool { - let app_lock = app.lock().await; - app_lock - .streaming_player - .as_ref() - .is_some_and(|current| Arc::ptr_eq(current, player)) -} - -fn current_playback_matches_native(app: &App, player: &StreamingPlayer) -> bool { - let Some(ctx) = app.current_playback_context.as_ref() else { - return app.is_streaming_active; - }; - - if let Some(native_id) = app.native_device_id.as_ref() { - if ctx.device.id.as_ref() == Some(native_id) { - return true; - } - } - - ctx.device.name.eq_ignore_ascii_case(player.device_name()) && app.has_fresh_native_activity() -} - -async fn disconnect_streaming_player( - app: &Arc>, - player: &Arc, - shared_position: &Arc, - shared_is_playing: &Arc, - status_message: &str, - restore_playback: bool, -) -> Option { - let mut app_lock = app.lock().await; - let current_player = app_lock.streaming_player.as_ref()?; - if !Arc::ptr_eq(current_player, player) { - return None; - } - - // Only a restoring teardown (unexpected shutdown / forced recovery) may - // reselect, and only while the API context still points at the native - // device. The flag also gates the queue-slot replay in the returned request. - let reselect_device = restore_playback && current_playback_matches_native(&app_lock, player); - let continue_after_track = if reselect_device { - let position_ms = u32::try_from(shared_position.load(Ordering::Relaxed)).unwrap_or(u32::MAX); - let is_playing = shared_is_playing.load(Ordering::Relaxed); - app_lock.prepare_native_playback_recovery(position_ms, is_playing); - let completed_track = app_lock - .native_playback_recovery - .as_ref() - .and_then(|snapshot| snapshot.completed_track_uri()) - .map(app::base62_id_of) - .map(str::to_string); - if let Some(track_id) = completed_track.as_deref() { - info!( - "native track {} completed while the transport was down; continuing after recovery", - track_id - ); - } - completed_track - } else { - // An intentional Spotify Connect transfer must not be undone by a later - // native recovery attempt. - app_lock.clear_native_playback_recovery(); - None - }; - - if !restore_playback { - // A handoff or local stop invalidates parked playback intent and the - // published queue slot; replaying either would steal playback back, and a - // leftover slot would ghost-own the playbar and transport (#437). - app_lock.pending_start_playback = None; - if app_lock.queue_now_is_spotify() { - app_lock.queue_now = None; - app_lock.spotify_queue_guard_reloads = 0; - } - } - - app_lock.streaming_player = None; - // Stop the old Connect session so it doesn't linger as a ghost device (#297). - player.shutdown(); - app_lock.is_streaming_active = false; - app_lock.native_activation_pending = false; - app_lock.native_device_id = None; - app_lock.native_is_playing = Some(false); - app_lock.native_track_info = None; - app_lock.native_playback_origin = None; - // Clearing the session below bumps its generation, which would turn a - // shuffled queue suspension into a silent no-op at resume time; convert it - // to a context snapshot first (while the cached context is still around). - app_lock.convert_shuffled_suspension_to_context(None); - app_lock.clear_native_shuffle_session(); - app_lock.song_progress_ms = 0; - app_lock.last_track_id = None; - app_lock.last_device_activation = None; - app_lock.seek_ms = None; - // The cached API context may still point at the stale native session; the - // dispatch below repopulates it if Spotify has already moved to another device. - app_lock.current_playback_context = None; - app_lock.set_status_message(status_message, 8); - app_lock.dispatch(IoEvent::GetCurrentPlayback); - - shared_position.store(0, Ordering::Relaxed); - shared_is_playing.store(false, Ordering::Relaxed); - - Some(StreamingRecoveryRequest { - reselect_device, - restore_playback, - continue_after_track, - }) -} - -#[cfg(test)] -mod tests { - use super::{ - merge_recovery_requests, recovery_needs_native_selection, recovery_watchdog_should_escalate, - session_disconnect_recovery, should_replay_published_queue_slot, - unavailable_is_transport_related, SessionDisconnectReason, SessionDisconnectRecovery, - StreamingConnectionState, StreamingRecoveryRequest, STALLED_PLAYBACK_RECOVERY_TIMEOUT, - }; - use std::time::Duration; - - #[test] - fn recovery_watchdog_waits_for_the_full_stall_window() { - assert!(!recovery_watchdog_should_escalate( - true, - STALLED_PLAYBACK_RECOVERY_TIMEOUT - Duration::from_millis(1) - )); - assert!(recovery_watchdog_should_escalate( - true, - STALLED_PLAYBACK_RECOVERY_TIMEOUT - )); - } - - #[test] - fn recovery_watchdog_never_rebuilds_explicitly_paused_playback() { - assert!(!recovery_watchdog_should_escalate( - false, - STALLED_PLAYBACK_RECOVERY_TIMEOUT * 2 - )); - } - - #[test] - fn unavailable_is_transport_related_until_recovery_progress_is_verified() { - assert!(unavailable_is_transport_related( - true, - StreamingConnectionState::Connected { generation: 1 } - )); - assert!(unavailable_is_transport_related( - false, - StreamingConnectionState::Reconnecting { generation: 2 } - )); - assert!(!unavailable_is_transport_related( - false, - StreamingConnectionState::Connected { generation: 2 } - )); - } - - #[test] - fn session_disconnect_recovery_respects_device_handoffs_and_local_shutdowns() { - assert_eq!( - session_disconnect_recovery(SessionDisconnectReason::ExternalDeviceHandoff), - SessionDisconnectRecovery::RebuildIdle - ); - assert_eq!( - session_disconnect_recovery(SessionDisconnectReason::UnexpectedShutdown), - SessionDisconnectRecovery::RestorePlayback - ); - assert_eq!( - session_disconnect_recovery(SessionDisconnectReason::LocalCommand), - SessionDisconnectRecovery::Stop - ); - } - - #[test] - fn queue_slot_replay_follows_restore_intent_not_reselect() { - // A queue playing over an idle app recovers with restore intent but no - // native context match (reselect stays false); the slot must still replay. - let idle_app_restore = StreamingRecoveryRequest { - restore_playback: true, - ..StreamingRecoveryRequest::default() - }; - assert!(should_replay_published_queue_slot(&idle_app_restore, true)); - assert!(!should_replay_published_queue_slot( - &idle_app_restore, - false - )); - - // A handoff rebuild never replays, regardless of reselect. - let handoff = StreamingRecoveryRequest::default(); - assert!(!should_replay_published_queue_slot(&handoff, true)); - let handoff_with_reselect = StreamingRecoveryRequest { - reselect_device: true, - ..StreamingRecoveryRequest::default() - }; - assert!(!should_replay_published_queue_slot( - &handoff_with_reselect, - true - )); - } - - #[test] - fn idle_app_queue_recovery_still_checks_device_ownership_before_replay() { - let idle_app_restore = StreamingRecoveryRequest { - restore_playback: true, - ..StreamingRecoveryRequest::default() - }; - - assert!(recovery_needs_native_selection(&idle_app_restore, true)); - assert!(!recovery_needs_native_selection(&idle_app_restore, false)); - } - - #[test] - fn recovery_request_merge_ors_intent_across_transport_drops() { - let mut request = StreamingRecoveryRequest { - restore_playback: true, - continue_after_track: Some("older".to_string()), - ..StreamingRecoveryRequest::default() - }; - merge_recovery_requests( - &mut request, - StreamingRecoveryRequest { - reselect_device: true, - restore_playback: true, - continue_after_track: None, - }, - ); - assert!(request.reselect_device); - assert!(request.restore_playback); - // The newest non-None continuation wins; None does not erase it. - assert_eq!(request.continue_after_track.as_deref(), Some("older")); - - merge_recovery_requests( - &mut request, - StreamingRecoveryRequest { - reselect_device: false, - restore_playback: true, - continue_after_track: Some("newer".to_string()), - }, - ); - assert!(request.reselect_device); - assert_eq!(request.continue_after_track.as_deref(), Some("newer")); - } - - #[test] - fn recovery_request_merge_handoff_vetoes_restore_intent() { - let restore = StreamingRecoveryRequest { - reselect_device: true, - restore_playback: true, - continue_after_track: Some("track".to_string()), - }; - let handoff = StreamingRecoveryRequest::default(); - - // Handoff queued behind a restore request. - let mut request = restore.clone(); - merge_recovery_requests(&mut request, handoff.clone()); - assert!(!request.reselect_device); - assert!(!request.restore_playback); - assert!(request.continue_after_track.is_none()); - - // Restore queued behind a handoff request. - let mut request = handoff; - merge_recovery_requests(&mut request, restore.clone()); - assert!(!request.reselect_device); - assert!(!request.restore_playback); - assert!(request.continue_after_track.is_none()); - - // The veto is sticky: a later restore request cannot resurrect intent. - merge_recovery_requests(&mut request, restore); - assert!(!request.reselect_device); - assert!(!request.restore_playback); - assert!(request.continue_after_track.is_none()); - } -} diff --git a/src/infra/player/mod.rs b/src/infra/player/mod.rs deleted file mode 100644 index 35305a63..00000000 --- a/src/infra/player/mod.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! Native Spotify playback using librespot -//! -//! This module provides native audio playback capabilities using the librespot library. -//! It registers spotatui as a Spotify Connect device and handles audio streaming. - -#[cfg(feature = "streaming")] -pub mod backend; -#[cfg(feature = "streaming")] -mod events; -#[cfg(feature = "streaming")] -mod streaming; - -#[cfg(feature = "streaming")] -pub use backend::{select_native, PlaybackBackend}; -#[cfg(feature = "streaming")] -pub use events::*; -#[cfg(feature = "streaming")] -pub use streaming::*; diff --git a/src/infra/player/streaming.rs b/src/infra/player/streaming.rs deleted file mode 100644 index 7697f827..00000000 --- a/src/infra/player/streaming.rs +++ /dev/null @@ -1,1616 +0,0 @@ -//! Streaming player implementation using librespot -//! -//! Handles authentication, session management, and audio playback with Spotify Connect. - -use anyhow::{anyhow, Context, Result}; -use librespot_connect::{ConnectConfig, LoadRequest, SavedPlaybackState, Spirc}; -use librespot_core::{ - authentication::Credentials, - cache::Cache, - config::{DeviceType, SessionConfig}, - error::ErrorKind, - session::Session, - spclient::TransferRequest, - SpotifyUri, -}; -use librespot_oauth::OAuthClientBuilder; -use librespot_playback::{ - audio_backend, - config::{AudioFormat, PlayerConfig}, - convert::Converter, - decoder::AudioPacket, - mixer::{softmixer::SoftMixer, Mixer, MixerConfig}, - player::{Player, PlayerEventChannel}, -}; -use log::{error, info, warn}; -use std::any::Any; -use std::panic::{catch_unwind, AssertUnwindSafe}; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, RwLock}; -use std::time::Instant; -use tokio::sync::Mutex; -use tokio::time::{timeout, Duration}; - -const FAST_SESSION_RECONNECT_TIMEOUT: Duration = Duration::from_secs(10); - -type SpircTaskHandle = tokio::task::JoinHandle>; - -struct ActiveConnection { - spirc: Spirc, - session: Session, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum SpircCommandMode { - Connected, - Reconnecting, - Failed, - Shutdown, -} - -enum DeferredPlayerCommand { - Load(LoadRequest), - Play, - Pause, - Stop, - Next, - Prev, - Seek(u32), - Shuffle(bool), - Repeat(bool), - RepeatTrack(bool), - Activate, - Transfer(Option), - DirectLoad(SpotifyUri, bool), -} - -impl DeferredPlayerCommand { - fn apply(self, spirc: &Spirc, player: &Player) -> std::result::Result<(), librespot_core::Error> { - match self { - Self::Load(request) => spirc.load(request), - // Play/Pause go to both the Player and Spirc: Spirc's handlers no-op - // whenever its play_status doesn't cover the current audio (any track - // direct-loaded via `player.load` leaves it Stopped, since Spirc drops - // events with a play_request_id it didn't issue), while the direct Player - // command always acts on whatever is actually loaded. The double-send is - // safe in both directions: the Player ignores commands invalid for its - // state, and Spirc re-syncs its status from the resulting Paused/Playing - // events. - Self::Play => { - player.play(); - spirc.play() - } - Self::Pause => { - player.pause(); - spirc.pause() - } - Self::Stop => { - player.stop(); - Ok(()) - } - Self::Next => spirc.next(), - Self::Prev => spirc.prev(), - Self::Seek(position_ms) => { - player.seek(position_ms); - Ok(()) - } - Self::Shuffle(shuffle) => spirc.shuffle(shuffle), - Self::Repeat(repeat) => spirc.repeat(repeat), - Self::RepeatTrack(repeat) => spirc.repeat_track(repeat), - Self::Activate => spirc.activate(), - Self::Transfer(request) => spirc.transfer(request), - Self::DirectLoad(uri, start_playing) => { - player.load(uri, start_playing, 0); - Ok(()) - } - } - } - - fn apply_to_buffered_player(&self, player: &Player) { - match self { - Self::Play => player.play(), - Self::Pause => player.pause(), - Self::Stop => player.stop(), - Self::Seek(position_ms) => player.seek(*position_ms), - _ => {} - } - } -} - -struct SpircCommandRouter { - mode: SpircCommandMode, - deferred: Vec, -} - -impl Default for SpircCommandRouter { - fn default() -> Self { - Self { - mode: SpircCommandMode::Connected, - deferred: Vec::new(), - } - } -} - -/// Runtime state of the embedded Spotify Connect connection. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum StreamingConnectionState { - Connected { generation: u64 }, - Reconnecting { generation: u64 }, - Failed { generation: u64 }, - Shutdown, -} - -impl StreamingConnectionState { - fn accepts_commands(self) -> bool { - matches!(self, Self::Connected { .. } | Self::Reconnecting { .. }) - } -} - -struct SpircSupervisorContext { - spirc_task: SpircTaskHandle, - connection: Arc>, - player: Arc, - mixer: Arc, - cache: Cache, - credentials: Credentials, - session_config: SessionConfig, - connect_config: ConnectConfig, - shutdown_requested: Arc, - shutdown_rx: tokio::sync::watch::Receiver, - connection_state_tx: tokio::sync::watch::Sender, - command_router: Arc>, -} - -fn spawn_spirc_supervisor(ctx: SpircSupervisorContext) { - tokio::spawn(run_spirc_supervisor(ctx)); -} - -async fn run_spirc_supervisor(mut ctx: SpircSupervisorContext) { - let mut generation = 0_u64; - - loop { - let saved_state = tokio::select! { - result = &mut ctx.spirc_task => match result { - Ok(saved) => saved, - Err(error) => { - warn!("Spirc task failed: {error}"); - let mut router = ctx - .command_router - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - router.mode = SpircCommandMode::Failed; - router.deferred.clear(); - drop(router); - let _ = ctx - .connection_state_tx - .send(StreamingConnectionState::Failed { generation }); - return; - } - }, - changed = ctx.shutdown_rx.changed() => { - if changed.is_err() || *ctx.shutdown_rx.borrow() { - let mut router = ctx - .command_router - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - router.mode = SpircCommandMode::Shutdown; - router.deferred.clear(); - let _ = ctx.connection_state_tx.send(StreamingConnectionState::Shutdown); - return; - } - continue; - } - }; - - if ctx.shutdown_requested.load(Ordering::Relaxed) { - let mut router = ctx - .command_router - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - router.mode = SpircCommandMode::Shutdown; - router.deferred.clear(); - let _ = ctx - .connection_state_tx - .send(StreamingConnectionState::Shutdown); - return; - } - - let Some(saved_state) = saved_state else { - warn!("Spirc task exited without recoverable playback state"); - let mut router = ctx - .command_router - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - router.mode = SpircCommandMode::Failed; - router.deferred.clear(); - drop(router); - let _ = ctx - .connection_state_tx - .send(StreamingConnectionState::Failed { generation }); - return; - }; - - generation = generation.wrapping_add(1); - info!( - "Spotify access-point session lost; starting fast reconnect generation {}", - generation - ); - ctx - .command_router - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .mode = SpircCommandMode::Reconnecting; - let _ = ctx - .connection_state_tx - .send(StreamingConnectionState::Reconnecting { generation }); - - let replacement_session = Session::new(ctx.session_config.clone(), Some(ctx.cache.clone())); - // Match librespot's standalone supervisor: the existing Player starts using - // the replacement Session before the restored Spirc begins processing events. - ctx.player.set_session(replacement_session.clone()); - ctx - .connection - .write() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .session = replacement_session.clone(); - - let reconnect = Spirc::with_saved_state( - ctx.connect_config.clone(), - replacement_session.clone(), - ctx.credentials.clone(), - Arc::clone(&ctx.player), - ctx.mixer.clone(), - Some(saved_state), - ); - - let reconnect_result = tokio::select! { - result = timeout(FAST_SESSION_RECONNECT_TIMEOUT, reconnect) => Some(result), - changed = ctx.shutdown_rx.changed() => { - if changed.is_err() || *ctx.shutdown_rx.borrow() { - replacement_session.shutdown(); - let mut router = ctx - .command_router - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - router.mode = SpircCommandMode::Shutdown; - router.deferred.clear(); - let _ = ctx.connection_state_tx.send(StreamingConnectionState::Shutdown); - return; - } - None - } - }; - - let Some(reconnect_result) = reconnect_result else { - continue; - }; - - match reconnect_result { - Ok(Ok((replacement_spirc, replacement_task))) => { - { - let mut router = ctx - .command_router - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - // shutdown() may have run while the reconnect was completing; it only - // reached the previous Spirc. Installing the replacement would revive - // the router and leak the new session as a ghost Connect device - // (#297), so tear it down instead. Holding the router lock for the - // install keeps shutdown() ordered before or after the whole swap. - if router.mode == SpircCommandMode::Shutdown { - drop(router); - let _ = replacement_spirc.shutdown(); - replacement_session.shutdown(); - // Drive the task so it processes the shutdown command and exits. - tokio::spawn(replacement_task); - return; - } - { - let mut connection = ctx - .connection - .write() - .unwrap_or_else(std::sync::PoisonError::into_inner); - *connection = ActiveConnection { - spirc: replacement_spirc, - session: replacement_session, - }; - } - router.mode = SpircCommandMode::Connected; - let commands = std::mem::take(&mut router.deferred); - let connection = ctx - .connection - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner); - for command in commands { - if let Err(error) = command.apply(&connection.spirc, &ctx.player) { - warn!("deferred native command failed after fast reconnect: {error:?}"); - } - } - } - ctx.spirc_task = tokio::spawn(replacement_task); - let _ = ctx - .connection_state_tx - .send(StreamingConnectionState::Connected { generation }); - info!( - "Spotify access-point session fast reconnect generation {} established", - generation - ); - } - Ok(Err(error)) => { - replacement_session.shutdown(); - let mut router = ctx - .command_router - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - router.mode = SpircCommandMode::Failed; - router.deferred.clear(); - drop(router); - warn!( - "Spotify access-point fast reconnect generation {} failed: {:?}", - generation, error - ); - let _ = ctx - .connection_state_tx - .send(StreamingConnectionState::Failed { generation }); - return; - } - Err(_) => { - replacement_session.shutdown(); - let mut router = ctx - .command_router - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - router.mode = SpircCommandMode::Failed; - router.deferred.clear(); - drop(router); - warn!( - "Spotify access-point fast reconnect generation {} timed out after {}s", - generation, - FAST_SESSION_RECONNECT_TIMEOUT.as_secs() - ); - let _ = ctx - .connection_state_tx - .send(StreamingConnectionState::Failed { generation }); - return; - } - } - } -} - -struct RecoveringSink { - inner: Option>, - make_sink: Box Box>, - error: Arc>>, - failed: bool, -} - -impl RecoveringSink { - fn new(make_sink: F, error: Arc>>) -> Self - where - F: Fn() -> Box + 'static, - { - Self { - inner: None, - make_sink: Box::new(make_sink), - error, - failed: false, - } - } - - fn payload_to_string(payload: Box) -> String { - match payload.downcast::() { - Ok(s) => *s, - Err(payload) => match payload.downcast::<&'static str>() { - Ok(s) => s.to_string(), - Err(_) => "unknown panic payload".to_string(), - }, - } - } - - fn panic_to_sink_error( - context: &'static str, - payload: Box, - ) -> audio_backend::SinkError { - let msg = Self::payload_to_string(payload); - audio_backend::SinkError::StateChange(format!("Audio backend panic in {context}: {msg}")) - } - - fn create_sink(&mut self) -> audio_backend::SinkResult<()> { - if self.inner.is_some() { - return Ok(()); - } - - let make_sink = &self.make_sink; - match catch_unwind(AssertUnwindSafe(make_sink)) { - Ok(sink) => { - self.inner = Some(sink); - Ok(()) - } - Err(payload) => { - let err = Self::panic_to_sink_error("open", payload); - error!("{err}"); - Err(err) - } - } - } - - fn with_inner(&mut self, context: &'static str, op: F) -> audio_backend::SinkResult - where - F: FnOnce(&mut dyn audio_backend::Sink) -> audio_backend::SinkResult, - { - self.create_sink()?; - - let Some(sink) = self.inner.as_mut() else { - return Err(audio_backend::SinkError::NotConnected( - "Audio sink unavailable".to_string(), - )); - }; - - match catch_unwind(AssertUnwindSafe(|| op(sink.as_mut()))) { - Ok(Ok(value)) => Ok(value), - Ok(Err(err)) => { - warn!("Audio backend {context} error: {err}"); - self.inner = None; - Err(err) - } - Err(payload) => { - let err = Self::panic_to_sink_error(context, payload); - error!("{err}"); - self.inner = None; - Err(err) - } - } - } -} - -impl audio_backend::Sink for RecoveringSink { - fn start(&mut self) -> audio_backend::SinkResult<()> { - self.failed = false; - match self.with_inner("start", |sink| sink.start()) { - Ok(()) => { - // A working sink supersedes any stale error recorded before there was - // a player to surface it; leaving it would report a live failure later. - *self.error.lock().unwrap_or_else(|e| e.into_inner()) = None; - } - Err(err) => { - *self.error.lock().unwrap_or_else(|e| e.into_inner()) = Some(err.to_string()); - self.failed = true; - } - } - // A sink error must not invalidate librespot's player thread. Keep consuming - // packets silently so the TUI can surface the failure and remain usable. - Ok(()) - } - - fn stop(&mut self) -> audio_backend::SinkResult<()> { - if self.inner.is_none() { - return Ok(()); - } - - // Avoid process exits in librespot when sink.stop() errors. - let _ = self.with_inner("stop", |sink| sink.stop()); - self.inner = None; - Ok(()) - } - - fn write( - &mut self, - packet: AudioPacket, - converter: &mut Converter, - ) -> audio_backend::SinkResult<()> { - // Unlike start(), write errors must propagate: a blocking sink.write() is - // librespot's only backpressure, and its packet-error path pauses playback - // without exiting the process. - if self.failed { - return Err(audio_backend::SinkError::NotConnected( - "Audio sink unavailable".to_string(), - )); - } - if let Err(err) = self.with_inner("write", |sink| sink.write(packet, converter)) { - *self.error.lock().unwrap_or_else(|e| e.into_inner()) = Some(err.to_string()); - self.failed = true; - return Err(err); - } - Ok(()) - } -} - -/// OAuth scopes required for streaming (based on spotify-player) -const STREAMING_SCOPES: [&str; 6] = [ - "streaming", - "user-read-playback-state", - "user-modify-playback-state", - "user-read-currently-playing", - "user-library-read", - "user-read-private", -]; - -/// spotify-player's client_id - known to work with librespot -/// Using this because librespot requires a client_id with specific permissions -/// that regular Spotify developer apps may not have. -const SPOTIFY_PLAYER_CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd"; - -/// spotify-player's redirect_uri - must match what's registered with their client_id -const SPOTIFY_PLAYER_REDIRECT_URI: &str = "http://127.0.0.1:8989/login"; - -/// Wait until `address` looks bindable, so the streaming consent is not opened -/// while the Web API callback server still holds the port. -/// -/// Advisory only, and deliberately so. librespot binds the port itself, so the -/// most this can do is probe and release; between that release and librespot's -/// own bind the port is unowned, which no amount of retrying can close. Returns -/// whether the port came free rather than `Result`, because the caller must not -/// treat "still busy" as fatal: librespot's bind is the authority and reports a -/// far better error than this probe can. -fn wait_for_oauth_callback_port(address: &str, max_wait: Duration, retry_delay: Duration) -> bool { - let deadline = Instant::now() + max_wait; - loop { - match std::net::TcpListener::bind(address) { - Ok(listener) => { - drop(listener); - return true; - } - Err(_) if Instant::now() < deadline => { - std::thread::sleep(retry_delay.min(deadline.saturating_duration_since(Instant::now()))); - } - Err(_) => return false, - } - } -} - -fn request_streaming_oauth_credentials() -> Result { - // The Web API and streaming OAuth clients both use port 8989. On a fresh - // profile their callback servers run back-to-back, so wait for the first - // listener to be fully released before librespot opens the second consent. - // Advisory: if it is still busy we go ahead anyway and let librespot's own - // bind produce the real error, rather than refusing to try at all. - if !wait_for_oauth_callback_port( - "127.0.0.1:8989", - Duration::from_secs(5), - Duration::from_millis(50), - ) { - warn!("OAuth callback port 127.0.0.1:8989 still busy; attempting the streaming login anyway"); - } - - let client_builder = OAuthClientBuilder::new( - SPOTIFY_PLAYER_CLIENT_ID, - SPOTIFY_PLAYER_REDIRECT_URI, - STREAMING_SCOPES.to_vec(), - ) - .open_in_browser(); - - let oauth_client = client_builder - .build() - .map_err(|e| anyhow!("Failed to build OAuth client: {:?}", e))?; - - let token = oauth_client - .get_access_token() - .map_err(|e| anyhow!("OAuth authentication failed: {:?}", e))?; - - Ok(Credentials::with_access_token(token.access_token)) -} - -/// Populate the reusable credential cache before the TUI enters raw mode. -/// Deferred player initialization and recovery can then remain cache-only. -pub fn ensure_streaming_credentials_cached( - onboarding: &dyn crate::core::onboarding::Onboarding, -) -> Result<()> { - let cache_path = get_default_cache_path(); - if let Some(path) = cache_path.as_ref() { - crate::core::paths::ensure_private_dir(path)?; - } - let cache = Cache::new(cache_path, None, None, None)?; - if cache.credentials().is_none() { - onboarding.info("Streaming authentication required - opening browser..."); - let credentials = request_streaming_oauth_credentials()?; - cache.save_credentials(&credentials); - } - Ok(()) -} - -pub fn streaming_credentials_are_cached() -> Result { - let cache_path = get_default_cache_path(); - if let Some(path) = cache_path.as_ref() { - crate::core::paths::ensure_private_dir(path)?; - } - Ok( - Cache::new(cache_path, None, None, None)? - .credentials() - .is_some(), - ) -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum StreamingAuthMode { - /// Default startup mode: use cache first, then fall back to browser OAuth. - InteractiveIfNeeded, - /// Recovery mode: only use cached credentials; never open a browser. - CacheOnly, -} - -fn resolve_streaming_credentials( - cache: &Cache, - auth_mode: StreamingAuthMode, -) -> Result<(Credentials, bool)> { - if let Some(cached_creds) = cache.credentials() { - info!("Using cached streaming credentials"); - return Ok((cached_creds, true)); - } - - match auth_mode { - StreamingAuthMode::InteractiveIfNeeded => Ok((request_streaming_oauth_credentials()?, false)), - StreamingAuthMode::CacheOnly => Err(anyhow!( - "No cached streaming credentials found (cache-only recovery mode)" - )), - } -} - -fn clear_cached_streaming_credentials(cache_path: &Option) { - let Some(credentials_path) = cache_path - .as_ref() - .map(|path| path.join("credentials.json")) - else { - return; - }; - - match std::fs::remove_file(&credentials_path) { - Ok(()) => { - info!( - "Cleared cached streaming credentials at {}", - credentials_path.display() - ); - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => { - warn!( - "Failed to clear cached streaming credentials at {}: {}", - credentials_path.display(), - e - ); - } - } -} - -/// Configuration for the streaming player -#[derive(Clone, Debug)] -pub struct StreamingConfig { - /// Name shown in Spotify Connect device list - pub device_name: String, - /// Audio bitrate (96, 160, 320) - pub bitrate: u16, - /// Enable audio caching - pub audio_cache: bool, - /// Cache directory path - pub cache_path: Option, - /// Initial volume (0-100) - pub initial_volume: u8, -} - -impl Default for StreamingConfig { - fn default() -> Self { - Self { - device_name: "spotatui".to_string(), - bitrate: 320, - audio_cache: false, - cache_path: None, - initial_volume: 100, - } - } -} - -/// Player state for tracking playback -#[allow(dead_code)] -#[derive(Clone, Debug, Default)] -pub struct PlayerState { - pub is_playing: bool, - pub track_id: Option, - pub position_ms: u32, - pub duration_ms: u32, - pub volume: u16, -} - -/// Streaming player that wraps librespot functionality -/// -/// This player registers as a Spotify Connect device and handles -/// native audio playback through the configured audio backend. -pub struct StreamingPlayer { - connection: Arc>, - command_router: Arc>, - #[allow(dead_code)] - player: Arc, - #[allow(dead_code)] - mixer: Arc, - config: StreamingConfig, - #[allow(dead_code)] - state: Arc>, - shutdown_requested: Arc, - shutdown_tx: tokio::sync::watch::Sender, - connection_state_tx: tokio::sync::watch::Sender, - connection_state_rx: tokio::sync::watch::Receiver, - audio_backend_error: Arc>>, -} - -#[allow(dead_code)] -impl StreamingPlayer { - /// Get the current librespot session (for API calls like rootlist). - pub fn session(&self) -> Session { - self.with_connection(|connection| connection.session.clone()) - } - - /// Create a new streaming player using librespot-oauth for authentication - /// - /// This will check for cached credentials first, and if not found, - /// will open a browser for OAuth authentication. - /// - /// # Arguments - /// * `client_id` - Spotify application client ID - /// * `redirect_uri` - OAuth redirect URI (must match Spotify app settings) - /// * `config` - Streaming configuration options - pub async fn new(_client_id: &str, _redirect_uri: &str, config: StreamingConfig) -> Result { - Self::new_with_auth_mode(config, StreamingAuthMode::InteractiveIfNeeded).await - } - - /// Create a new streaming player using ONLY cached credentials. - /// - /// This path is intended for runtime recovery flows where opening a browser - /// would be disruptive. - pub async fn new_cache_only( - _client_id: &str, - _redirect_uri: &str, - config: StreamingConfig, - ) -> Result { - Self::new_with_auth_mode(config, StreamingAuthMode::CacheOnly).await - } - - async fn new_with_auth_mode( - config: StreamingConfig, - auth_mode: StreamingAuthMode, - ) -> Result { - // Set up cache paths - let cache_path = config.cache_path.clone().or_else(get_default_cache_path); - let audio_cache_path = if config.audio_cache { - cache_path.as_ref().map(|p| p.join("audio")) - } else { - None - }; - - // Ensure cache directories exist - if let Some(ref path) = cache_path { - crate::core::paths::ensure_private_dir(path)?; - } - if let Some(ref path) = audio_cache_path { - std::fs::create_dir_all(path).ok(); - } - - let cache = Cache::new(cache_path.clone(), None, audio_cache_path, None)?; - - // Try to get credentials from cache first, then optionally fall back to OAuth. - let (mut credentials, mut used_cached_credentials) = - resolve_streaming_credentials(&cache, auth_mode)?; - - // Create session configuration using spotify-player's client_id - let mut session_config = SessionConfig { - client_id: SPOTIFY_PLAYER_CLIENT_ID.to_string(), - ..Default::default() - }; - // Reuse a persisted device id so every launch and recovery registers as the - // same Spotify Connect device instead of accumulating ghost entries (#297). - if let Some(device_id) = get_or_create_device_id(cache_path.as_deref())? { - session_config.device_id = device_id; - } - - // Create session (Spirc will handle connection) - let session = Session::new(session_config.clone(), Some(cache.clone())); - - // Set up player configuration - let player_config = PlayerConfig { - bitrate: match config.bitrate { - 96 => librespot_playback::config::Bitrate::Bitrate96, - 160 => librespot_playback::config::Bitrate::Bitrate160, - _ => librespot_playback::config::Bitrate::Bitrate320, - }, - // Enable periodic position updates for real-time playbar progress - position_update_interval: Some(std::time::Duration::from_secs(1)), - ..Default::default() - }; - - // Create mixer using SoftMixer directly (like spotify-player does) - let mixer = - Arc::new(SoftMixer::open(MixerConfig::default()).context("Failed to open SoftMixer")?); - - // Convert volume from 0-100 to 0-65535 - let volume_u16 = (f64::from(config.initial_volume.min(100)) / 100.0 * 65535.0).round() as u16; - mixer.set_volume(volume_u16); - - let requested_backend = std::env::var("SPOTATUI_STREAMING_AUDIO_BACKEND").ok(); - let requested_device = std::env::var("SPOTATUI_STREAMING_AUDIO_DEVICE").ok(); - - // Create audio backend - let backend = - audio_backend::find(requested_backend.clone()).ok_or_else(|| match requested_backend { - Some(name) => anyhow!( - "Unknown audio backend '{}'. Available backends: {}", - name, - audio_backend::BACKENDS - .iter() - .map(|(n, _)| *n) - .collect::>() - .join(", ") - ), - None => anyhow!("No audio backend available"), - })?; - - // Create player - let audio_backend_error = Arc::new(std::sync::Mutex::new(None)); - let sink_error = Arc::clone(&audio_backend_error); - let player = Player::new( - player_config, - session.clone(), - mixer.get_soft_volume(), - move || { - Box::new(RecoveringSink::new( - move || backend(requested_device.clone(), AudioFormat::default()), - Arc::clone(&sink_error), - )) - }, - ); - - // Create Connect configuration - let connect_config = ConnectConfig { - name: config.device_name.clone(), - device_type: DeviceType::Computer, - initial_volume: volume_u16, - is_group: false, - disable_volume: false, - volume_steps: 64, - }; - - info!("Initializing Spirc with device_id={}", session.device_id()); - - let init_timeout_secs = std::env::var("SPOTATUI_STREAMING_INIT_TIMEOUT_SECS") - .ok() - .and_then(|v| v.parse::().ok()) - .filter(|&v| v > 0) - .unwrap_or(30); - - let mut retried_with_fresh_credentials = false; - - // Create Spirc (Spotify Connect controller) - let (spirc, spirc_task) = loop { - let spirc_new = Spirc::new( - connect_config.clone(), - session.clone(), - credentials.clone(), - player.clone(), - mixer.clone(), - ); - - match timeout(Duration::from_secs(init_timeout_secs), spirc_new).await { - Ok(Ok(result)) => break result, - Ok(Err(e)) - if matches!(auth_mode, StreamingAuthMode::InteractiveIfNeeded) - && should_retry_with_fresh_credentials( - true, - used_cached_credentials, - retried_with_fresh_credentials, - ) => - { - warn!( - "Cached streaming credentials failed ({:?}); retrying with a fresh OAuth login", - e - ); - clear_cached_streaming_credentials(&cache_path); - credentials = request_streaming_oauth_credentials()?; - used_cached_credentials = false; - retried_with_fresh_credentials = true; - } - Ok(Err(e)) => { - // Only discard cached credentials when Spotify actually rejected them. - // A transient failure (network down, service unavailable) must NOT wipe - // valid credentials and force a browser re-auth next launch — same - // reasoning as the timeout arm below. - if matches!(auth_mode, StreamingAuthMode::CacheOnly) - && used_cached_credentials - && matches!( - e.kind, - ErrorKind::Unauthenticated | ErrorKind::PermissionDenied - ) - { - clear_cached_streaming_credentials(&cache_path); - } - warn!("Spirc creation error: {:?}", e); - return Err(anyhow!("Failed to create Spirc: {:?}", e)); - } - Err(_) => { - // Timeout means the network was slow, not that credentials are bad. - // Do NOT clear credentials, they may be valid for the next startup. - // Streaming is skipped for this session; main.rs falls back to Web API. - return Err(anyhow!( - "Spirc initialization timed out after {}s. Streaming skipped for this session. \ - Set SPOTATUI_STREAMING_INIT_TIMEOUT_SECS to adjust.", - init_timeout_secs - )); - } - } - }; - - let connection = Arc::new(RwLock::new(ActiveConnection { spirc, session })); - let command_router = Arc::new(std::sync::Mutex::new(SpircCommandRouter::default())); - let shutdown_requested = Arc::new(AtomicBool::new(false)); - let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); - let initial_connection_state = StreamingConnectionState::Connected { generation: 0 }; - let (connection_state_tx, connection_state_rx) = - tokio::sync::watch::channel(initial_connection_state); - - spawn_spirc_supervisor(SpircSupervisorContext { - spirc_task: tokio::spawn(spirc_task), - connection: Arc::clone(&connection), - player: Arc::clone(&player), - mixer: Arc::clone(&mixer), - cache, - credentials, - session_config, - connect_config, - shutdown_requested: Arc::clone(&shutdown_requested), - shutdown_rx, - connection_state_tx: connection_state_tx.clone(), - command_router: Arc::clone(&command_router), - }); - - info!("Streaming connection established!"); - - Ok(Self { - connection, - command_router, - player, - mixer, - config, - state: Arc::new(Mutex::new(PlayerState::default())), - shutdown_requested, - shutdown_tx, - connection_state_tx, - connection_state_rx, - audio_backend_error, - }) - } - - fn with_connection(&self, f: impl FnOnce(&ActiveConnection) -> T) -> T { - let connection = self - .connection - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner); - f(&connection) - } - - fn with_spirc(&self, f: impl FnOnce(&Spirc) -> T) -> T { - self.with_connection(|connection| f(&connection.spirc)) - } - - fn route_command(&self, command: DeferredPlayerCommand) -> Result<()> { - let mut router = self - .command_router - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - match router.mode { - SpircCommandMode::Connected => self - .with_connection(|connection| command.apply(&connection.spirc, &self.player)) - .map_err(|error| anyhow!("Native playback command failed: {error:?}")), - SpircCommandMode::Reconnecting => { - command.apply_to_buffered_player(&self.player); - router.deferred.push(command); - Ok(()) - } - SpircCommandMode::Failed => Err(anyhow!("Native streaming connection failed")), - SpircCommandMode::Shutdown => Err(anyhow!("Native streaming player is shut down")), - } - } - - /// Get the device name - pub fn device_name(&self) -> &str { - &self.config.device_name - } - - /// Get the Spotify Connect device id for this session. - pub fn device_id(&self) -> String { - self.with_connection(|connection| connection.session.device_id().to_string()) - } - - /// Check if the session is connected - pub fn is_connected(&self) -> bool { - matches!( - self.connection_state(), - StreamingConnectionState::Connected { .. } - ) && self.with_connection(|connection| !connection.session.is_invalid()) - && !self.player.is_invalid() - } - - pub fn is_recovering(&self) -> bool { - matches!( - self.connection_state(), - StreamingConnectionState::Reconnecting { .. } - ) - } - - /// Whether the native backend can accept commands now or queue them for its - /// in-place reconnect. - pub fn is_available(&self) -> bool { - self.connection_state().accepts_commands() - } - - pub fn connection_state(&self) -> StreamingConnectionState { - *self.connection_state_rx.borrow() - } - - pub fn take_audio_backend_error(&self) -> Option { - self - .audio_backend_error - .lock() - .unwrap_or_else(|e| e.into_inner()) - .take() - } - - /// Play a track by its Spotify URI (e.g., "spotify:track:xxxx") - pub async fn play_uri(&self, uri: &str) -> Result<()> { - let spotify_uri = - SpotifyUri::from_uri(uri).map_err(|e| anyhow!("Invalid Spotify URI '{}': {:?}", uri, e))?; - - self.route_command(DeferredPlayerCommand::DirectLoad(spotify_uri, true))?; - - let mut state = self.state.lock().await; - state.is_playing = true; - state.track_id = Some(uri.to_string()); - state.position_ms = 0; - - Ok(()) - } - - /// Load a track by URI without starting playback. Used to restore a paused - /// queue slot after recovery so the user's pause survives the rebuild. - pub async fn load_uri_paused(&self, uri: &str) -> Result<()> { - let spotify_uri = - SpotifyUri::from_uri(uri).map_err(|e| anyhow!("Invalid Spotify URI '{}': {:?}", uri, e))?; - - self.route_command(DeferredPlayerCommand::DirectLoad(spotify_uri, false))?; - - let mut state = self.state.lock().await; - state.is_playing = false; - state.track_id = Some(uri.to_string()); - state.position_ms = 0; - - Ok(()) - } - - /// Load a new playback context/tracks via Spotify Connect (Spirc). - /// - /// Prefer this over `player.load()` when you want Connect state (queue, context) - /// to stay consistent. - pub fn load(&self, request: LoadRequest) -> Result<()> { - self.route_command(DeferredPlayerCommand::Load(request)) - } - - /// Play a track by its Spotify ID (will be converted to URI) - pub async fn play_track(&self, track_id: &str) -> Result<()> { - let uri = format!("spotify:track:{}", track_id); - self.play_uri(&uri).await - } - - /// Hint the player to prefetch a track's audio so a subsequent - /// [`play_uri`](Self::play_uri) starts near-instantly. Used by the native - /// queue to warm its next Spotify item, matching the preloading Spirc does - /// within a context. Best-effort: an unparseable URI is silently ignored. - pub fn preload_uri(&self, uri: &str) { - if let Ok(spotify_uri) = SpotifyUri::from_uri(uri) { - self.player.preload(spotify_uri); - } - } - - /// Pause playback - pub fn pause(&self) { - if let Err(error) = self.route_command(DeferredPlayerCommand::Pause) { - warn!("native pause failed: {error}"); - } - } - - /// Resume playback - pub fn play(&self) { - if let Err(error) = self.route_command(DeferredPlayerCommand::Play) { - warn!("native play failed: {error}"); - } - } - - /// Stop playback - pub fn stop(&self) { - if let Err(error) = self.route_command(DeferredPlayerCommand::Stop) { - warn!("native stop failed: {error}"); - } - } - - /// Skip to the next track - pub fn next(&self) { - if let Err(error) = self.route_command(DeferredPlayerCommand::Next) { - warn!("native next failed: {error}"); - } - } - - /// Skip to the previous track - pub fn prev(&self) { - if let Err(error) = self.route_command(DeferredPlayerCommand::Prev) { - warn!("native previous failed: {error}"); - } - } - - /// Seek to a position in the current track (in milliseconds) - pub fn seek(&self, position_ms: u32) { - if let Err(error) = self.route_command(DeferredPlayerCommand::Seek(position_ms)) { - warn!("native seek failed: {error}"); - } - } - - /// Toggle shuffle mode via the underlying Spotify Connect session - pub fn set_shuffle(&self, shuffle: bool) -> Result<()> { - self.route_command(DeferredPlayerCommand::Shuffle(shuffle)) - } - - /// Set repeat mode via the underlying Spotify Connect session - /// Handles cycling between Off -> Context -> Track -> Off - pub fn set_repeat(&self, current_state: rspotify::model::enums::RepeatState) -> Result<()> { - use rspotify::model::enums::RepeatState; - - match current_state { - RepeatState::Off => { - self.route_command(DeferredPlayerCommand::Repeat(true))?; - self.route_command(DeferredPlayerCommand::RepeatTrack(false))?; - } - RepeatState::Context => { - self.route_command(DeferredPlayerCommand::RepeatTrack(true))?; - } - RepeatState::Track => { - self.route_command(DeferredPlayerCommand::Repeat(false))?; - self.route_command(DeferredPlayerCommand::RepeatTrack(false))?; - } - } - Ok(()) - } - - /// Set repeat mode directly to a specific state (for MPRIS) - pub fn set_repeat_mode(&self, target_state: rspotify::model::enums::RepeatState) -> Result<()> { - use rspotify::model::enums::RepeatState; - - match target_state { - RepeatState::Off => { - self.route_command(DeferredPlayerCommand::Repeat(false))?; - self.route_command(DeferredPlayerCommand::RepeatTrack(false))?; - } - RepeatState::Context => { - self.route_command(DeferredPlayerCommand::Repeat(true))?; - self.route_command(DeferredPlayerCommand::RepeatTrack(false))?; - } - RepeatState::Track => { - self.route_command(DeferredPlayerCommand::Repeat(true))?; - self.route_command(DeferredPlayerCommand::RepeatTrack(true))?; - } - } - Ok(()) - } - - /// Set the volume (0-100) - pub fn set_volume(&self, volume: u8) { - let volume_u16 = (f64::from(volume.min(100)) / 100.0 * 65535.0).round() as u16; - self.mixer.set_volume(volume_u16); - } - - /// Get the current volume (0-100) - pub fn get_volume(&self) -> u8 { - let volume_u16 = self.mixer.volume(); - ((volume_u16 as f64 / 65535.0) * 100.0).round() as u8 - } - - /// Get the current player state - pub async fn get_state(&self) -> PlayerState { - self.state.lock().await.clone() - } - - /// Check if the player is invalid (e.g., session disconnected) - pub fn is_invalid(&self) -> bool { - !self.is_connected() - } - - /// Activate the device (make it the active playback device) - pub fn activate(&self) { - // Not fatal (transfer() is the reliable route), but a failure here used to - // vanish entirely, hiding zombie sessions from the logs. - if let Err(error) = self.route_command(DeferredPlayerCommand::Activate) { - warn!("spirc activate failed: {error}"); - } - } - - /// Transfer playback to this device via Spotify Connect. - /// - /// This is the most reliable way to become the active device; `activate()` - /// can be a no-op when we're not currently active. - pub fn transfer(&self, request: Option) -> Result<()> { - self.route_command(DeferredPlayerCommand::Transfer(request)) - } - - /// Shutdown the player - pub fn shutdown(&self) { - self.shutdown_requested.store(true, Ordering::Relaxed); - { - let mut router = self - .command_router - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - router.mode = SpircCommandMode::Shutdown; - router.deferred.clear(); - } - let _ = self - .connection_state_tx - .send(StreamingConnectionState::Shutdown); - let _ = self.shutdown_tx.send(true); - let _ = self.with_spirc(Spirc::shutdown); - } - - pub fn connection_state_receiver( - &self, - ) -> tokio::sync::watch::Receiver { - self.connection_state_rx.clone() - } - - /// Get a channel to receive player events (track changes, play/pause, seek, etc.) - pub fn get_event_channel(&self) -> PlayerEventChannel { - self.player.get_player_event_channel() - } -} - -impl Drop for StreamingPlayer { - fn drop(&mut self) { - // Backstop: stop the spirc when the last reference drops so a replaced - // player can't linger as a ghost Connect device (#297). Idempotent. - self.shutdown_requested.store(true, Ordering::Relaxed); - { - let mut router = self - .command_router - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - router.mode = SpircCommandMode::Shutdown; - router.deferred.clear(); - } - let _ = self - .connection_state_tx - .send(StreamingConnectionState::Shutdown); - let _ = self.shutdown_tx.send(true); - let _ = self.with_spirc(Spirc::shutdown); - } -} - -// Re-export player events and their disconnect reason for use in other modules. -pub use librespot_playback::player::{PlayerEvent, SessionDisconnectReason}; - -/// Returns true when a Spirc init failure should be retried with fresh OAuth -/// credentials instead of cached ones. -fn should_retry_with_fresh_credentials( - auth_error: bool, - used_cached: bool, - already_retried: bool, -) -> bool { - auth_error && used_cached && !already_retried -} - -/// Stable Connect device id, persisted in the streaming cache dir so every -/// launch and every in-app recovery registers as the same device (#297). -fn get_or_create_device_id(cache_path: Option<&Path>) -> Result> { - let Some(cache_path) = cache_path else { - return Ok(None); - }; - crate::core::paths::ensure_private_dir(cache_path)?; - let id_file = cache_path.join("device_id"); - match std::fs::read_to_string(&id_file) { - Ok(existing) => { - let trimmed = existing.trim(); - if !trimmed.is_empty() { - return Ok(Some(trimmed.to_string())); - } - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(e).with_context(|| format!("reading {}", id_file.display())), - } - let id = new_device_id_string(); - std::fs::write(&id_file, &id).with_context(|| format!("writing {}", id_file.display()))?; - Ok(Some(id)) -} - -/// Hyphenated UUID-v4-shaped string, matching librespot's default device id format. -fn new_device_id_string() -> String { - use rand::Rng; - let mut b = [0u8; 16]; - rand::rng().fill_bytes(&mut b); - b[6] = (b[6] & 0x0f) | 0x40; - b[8] = (b[8] & 0x3f) | 0x80; - format!( - "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", - b[0], - b[1], - b[2], - b[3], - b[4], - b[5], - b[6], - b[7], - b[8], - b[9], - b[10], - b[11], - b[12], - b[13], - b[14], - b[15] - ) -} - -#[cfg(test)] -mod tests { - use super::{ - get_or_create_device_id, migrate_legacy_streaming_cache_if_unclaimed, new_device_id_string, - should_retry_with_fresh_credentials, wait_for_oauth_callback_port, RecoveringSink, - StreamingConnectionState, - }; - use librespot_playback::{audio_backend, convert::Converter, decoder::AudioPacket}; - use std::sync::Arc; - use std::time::Duration; - - struct StubSink { - start_result: fn() -> audio_backend::SinkResult<()>, - } - - impl audio_backend::Sink for StubSink { - fn start(&mut self) -> audio_backend::SinkResult<()> { - (self.start_result)() - } - - fn stop(&mut self) -> audio_backend::SinkResult<()> { - Ok(()) - } - - fn write( - &mut self, - _packet: AudioPacket, - _converter: &mut Converter, - ) -> audio_backend::SinkResult<()> { - Ok(()) - } - } - - fn failing_start() -> audio_backend::SinkResult<()> { - Err(audio_backend::SinkError::StateChange( - "stub backend failure".to_string(), - )) - } - - fn stub_recovering_sink( - start_result: fn() -> audio_backend::SinkResult<()>, - ) -> (RecoveringSink, Arc>>) { - let error = Arc::new(std::sync::Mutex::new(None)); - let sink = RecoveringSink::new( - move || Box::new(StubSink { start_result }) as Box, - Arc::clone(&error), - ); - (sink, error) - } - - #[test] - fn failing_backend_start_returns_ok_and_records_error() { - use audio_backend::Sink; - - let (mut sink, error) = stub_recovering_sink(failing_start); - - // The regression guard for #384: a failing backend must not propagate the - // error into librespot's player thread. - sink.start().expect("start() must fail softly"); - assert!(sink.failed); - let recorded = error.lock().unwrap().take(); - assert!(recorded.unwrap().contains("stub backend failure")); - } - - #[test] - fn successful_start_clears_stale_backend_error() { - use audio_backend::Sink; - - let (mut sink, error) = stub_recovering_sink(|| Ok(())); - *error.lock().unwrap() = Some("stale error from a previous sink".to_string()); - - sink.start().expect("start() should succeed"); - assert!(!sink.failed); - assert!(error.lock().unwrap().is_none()); - } - - #[test] - fn oauth_callback_port_waits_for_previous_listener_to_release() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let address = listener.local_addr().unwrap().to_string(); - let release = std::thread::spawn(move || { - std::thread::sleep(Duration::from_millis(100)); - drop(listener); - }); - - assert!( - wait_for_oauth_callback_port(&address, Duration::from_secs(1), Duration::from_millis(10)), - "callback port should become available after the first listener exits" - ); - release.join().unwrap(); - - std::net::TcpListener::bind(address).expect("callback port should remain available"); - } - - /// A port that never frees is reported, not fatal: the caller logs and lets - /// librespot's own bind produce the authoritative error. - #[test] - fn oauth_callback_port_reports_a_port_that_stays_busy() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let address = listener.local_addr().unwrap().to_string(); - - assert!( - !wait_for_oauth_callback_port( - &address, - Duration::from_millis(50), - Duration::from_millis(10) - ), - "a port held for the whole wait should report as unavailable" - ); - - drop(listener); - } - - #[test] - fn auth_failure_with_cached_creds_triggers_retry() { - assert!(should_retry_with_fresh_credentials(true, true, false)); - } - - #[test] - fn timeout_with_cached_creds_does_not_trigger_retry() { - assert!(!should_retry_with_fresh_credentials(false, true, false)); - } - - #[test] - fn auth_failure_with_fresh_creds_does_not_trigger_retry() { - assert!(!should_retry_with_fresh_credentials(true, false, false)); - } - - #[test] - fn timeout_with_fresh_creds_does_not_trigger_retry() { - assert!(!should_retry_with_fresh_credentials(false, false, false)); - } - - #[test] - fn auth_failure_already_retried_does_not_trigger_second_retry() { - assert!(!should_retry_with_fresh_credentials(true, true, true)); - } - - #[test] - fn success_never_triggers_retry() { - assert!(!should_retry_with_fresh_credentials(false, true, false)); - } - - #[test] - fn reconnecting_connection_remains_command_capable() { - assert!(StreamingConnectionState::Connected { generation: 0 }.accepts_commands()); - assert!(StreamingConnectionState::Reconnecting { generation: 1 }.accepts_commands()); - assert!(!StreamingConnectionState::Failed { generation: 1 }.accepts_commands()); - assert!(!StreamingConnectionState::Shutdown.accepts_commands()); - } - - #[test] - fn device_id_string_is_uuid_v4_shaped() { - let id = new_device_id_string(); - assert_eq!(id.len(), 36); - for (i, c) in id.char_indices() { - match i { - 8 | 13 | 18 | 23 => assert_eq!(c, '-'), - 14 => assert_eq!(c, '4'), - 19 => assert!(matches!(c, '8' | '9' | 'a' | 'b')), - _ => assert!(c.is_ascii_hexdigit()), - } - } - } - - #[test] - fn device_id_persists_across_calls() { - let dir = std::env::temp_dir().join(format!("spotatui_device_id_test_{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - - let first = get_or_create_device_id(Some(&dir)).unwrap().unwrap(); - let second = get_or_create_device_id(Some(&dir)).unwrap().unwrap(); - assert_eq!(first, second); - assert_eq!( - std::fs::read_to_string(dir.join("device_id")) - .unwrap() - .trim(), - first - ); - - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn device_id_none_without_cache_path() { - assert!(get_or_create_device_id(None).unwrap().is_none()); - } - - #[test] - fn device_id_errors_when_cache_path_setup_fails() { - let dir = tempfile::tempdir().unwrap(); - let cache_path = dir.path().join("not_a_directory"); - std::fs::write(&cache_path, "not a directory").unwrap(); - - let err = get_or_create_device_id(Some(&cache_path)).unwrap_err(); - assert!(err.to_string().contains("creating")); - } - - #[cfg(unix)] - #[test] - fn device_id_errors_when_persistence_write_fails() { - let dir = tempfile::tempdir().unwrap(); - let cache_path = dir.path().join("cache"); - std::fs::create_dir_all(&cache_path).unwrap(); - std::os::unix::fs::symlink( - dir.path().join("missing_parent").join("device_id"), - cache_path.join("device_id"), - ) - .unwrap(); - - let err = get_or_create_device_id(Some(&cache_path)).unwrap_err(); - assert!(err.to_string().contains("writing")); - } - - #[test] - fn legacy_streaming_cache_migration_preserves_connect_device_id() { - let dir = tempfile::tempdir().unwrap(); - let config_dir = dir.path().join("config"); - let cache_path = dir.path().join("cache").join("streaming_cache"); - let legacy_cache_path = config_dir.join("streaming_cache"); - std::fs::create_dir_all(&legacy_cache_path).unwrap(); - std::fs::write(legacy_cache_path.join("device_id"), "legacy-device-id\n").unwrap(); - std::fs::write( - legacy_cache_path.join("credentials.json"), - "legacy credentials", - ) - .unwrap(); - - assert!(migrate_legacy_streaming_cache_if_unclaimed(&config_dir, &cache_path).unwrap()); - - assert!(!legacy_cache_path.exists()); - assert_eq!( - get_or_create_device_id(Some(&cache_path)).unwrap().unwrap(), - "legacy-device-id" - ); - assert_eq!( - std::fs::read_to_string(cache_path.join("credentials.json")).unwrap(), - "legacy credentials" - ); - } -} - -/// Helper to get the default cache path for streaming -pub fn get_default_cache_path() -> Option { - let cache_path = crate::core::paths::app_cache_dir()?.join("streaming_cache"); - if let Some(config_dir) = crate::core::paths::app_config_dir() { - if let Err(error) = migrate_legacy_streaming_cache_if_unclaimed(&config_dir, &cache_path) { - log::warn!( - "failed to migrate legacy streaming cache from {} to {}: {error}", - config_dir.join("streaming_cache").display(), - cache_path.display() - ); - } - } - Some(cache_path) -} - -fn migrate_legacy_streaming_cache_if_unclaimed( - app_config_dir: &Path, - cache_path: &Path, -) -> Result { - crate::core::migrations::migrate_legacy_path_if_unclaimed( - &app_config_dir.join("streaming_cache"), - cache_path, - ) -} diff --git a/src/infra/qobuz/auth.rs b/src/infra/qobuz/auth.rs deleted file mode 100644 index 17641ab0..00000000 --- a/src/infra/qobuz/auth.rs +++ /dev/null @@ -1,527 +0,0 @@ -//! Bundle constants and the credentials file. -//! -//! The three constants the API needs (app id, signature secret, OAuth key) are -//! served to every visitor of the Qobuz web player; they are scraped from its -//! bundle at runtime, cached by bundle version, and never embedded here. Env -//! overrides skip the scrape when all three are set. - -use std::path::Path; - -use anyhow::{anyhow, Context, Result}; -use base64::engine::general_purpose::GeneralPurpose; -use base64::engine::{DecodePaddingMode, GeneralPurposeConfig}; -use base64::{alphabet, Engine}; -use regex::Regex; -use reqwest::Client; -use serde::{Deserialize, Serialize}; - -pub use crate::core::state::QobuzBundleCache; - -const LOGIN_URL: &str = "https://play.qobuz.com/login"; -const PLAY_ORIGIN: &str = "https://play.qobuz.com"; -const BUNDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120); - -const APP_ID_ENV: &str = "SPOTATUI_QOBUZ_APP_ID"; -const APP_SECRET_ENV: &str = "SPOTATUI_QOBUZ_APP_SECRET"; -const OAUTH_KEY_ENV: &str = "SPOTATUI_QOBUZ_OAUTH_KEY"; -/// Overrides the saved user token (the value of `X-User-Auth-Token`). -pub(super) const TOKEN_ENV: &str = "SPOTATUI_QOBUZ_TOKEN"; - -/// Base64 that accepts both alphabets, with or without padding. -const LENIENT_BASE64: GeneralPurpose = GeneralPurpose::new( - &alphabet::STANDARD, - GeneralPurposeConfig::new() - .with_decode_allow_trailing_bits(true) - .with_decode_padding_mode(DecodePaddingMode::Indifferent), -); - -/// Decode base64 or base64url, padded or not. -pub fn decode_base64(text: &str) -> Result> { - let standard: String = text - .trim_end_matches('=') - .chars() - .map(|c| match c { - '-' => '+', - '_' => '/', - other => other, - }) - .collect(); - LENIENT_BASE64 - .decode(standard) - .map_err(|e| anyhow!("base64 decode failed: {e}")) -} - -// --------------------------------------------------------------------------- -// Bundle constants -// --------------------------------------------------------------------------- - -/// The constants from the env overrides, when all three are set and non-empty. -fn constants_from_env_with( - app_id: Option, - app_secret: Option, - oauth_key: Option, -) -> Option { - let non_empty = |v: Option| v.filter(|s| !s.trim().is_empty()); - Some(QobuzBundleCache { - bundle_version: "env".to_string(), - app_id: non_empty(app_id)?, - app_secret: non_empty(app_secret)?, - oauth_key: non_empty(oauth_key)?, - }) -} - -fn constants_from_env() -> Option { - constants_from_env_with( - std::env::var(APP_ID_ENV).ok(), - std::env::var(APP_SECRET_ENV).ok(), - std::env::var(OAUTH_KEY_ENV).ok(), - ) -} - -/// The bundle path (`/resources//bundle.js`) from the login page. -fn bundle_path(login_html: &str) -> Option { - Regex::new(r#"src="(/resources/[^"/]+/bundle\.js)""#) - .ok()? - .captures(login_html) - .map(|c| c[1].to_string()) -} - -/// The version segment of a bundle path. -fn bundle_version(path: &str) -> Option { - path - .strip_prefix("/resources/")? - .strip_suffix("/bundle.js") - .map(str::to_string) -} - -fn capture(js: &str, pattern: &str, what: &str) -> Result { - Regex::new(pattern)? - .captures(js) - .map(|c| c[1].to_string()) - .ok_or_else(|| anyhow!("{what} not found in the Qobuz bundle")) -} - -/// Extract the three constants from the bundle source. -fn parse_bundle(js: &str, version: &str) -> Result { - let app_id = capture(js, r#"production:\{api:\{appId:"(\d+)""#, "app id")?; - // ASCII classes only: the captures are sliced by byte index further down. - let seed_re = Regex::new(r#"initialSeed\("([A-Za-z0-9+/=]+)",window\.utimezone\.([A-Za-z]+)\)"#)?; - let (seed, timezone) = seed_re - .captures_iter(js) - .last() - .map(|c| (c[1].to_string(), c[2].to_string())) - .ok_or_else(|| anyhow!("signature seed not found in the Qobuz bundle"))?; - let mut zone = timezone.chars(); - let zone_name: String = zone - .next() - .map(|c| c.to_uppercase().collect::()) - .unwrap_or_default() - + zone.as_str(); - let zone_re = Regex::new(&format!( - r#"name:"[A-Za-z_]+/{zone_name}",info:"([A-Za-z0-9+/=]+)",extras:"([A-Za-z0-9+/=]+)""# - ))?; - let caps = zone_re - .captures(js) - .ok_or_else(|| anyhow!("timezone block {zone_name} not found in the Qobuz bundle"))?; - let app_secret = secret_from_seed(&seed, &caps[1], &caps[2])?; - let oauth_key = capture(js, r#"authenticate\(\{privateKey:"([^"]+)""#, "OAuth key")?; - Ok(QobuzBundleCache { - bundle_version: version.to_string(), - app_id, - app_secret, - oauth_key, - }) -} - -/// The signature secret: base64 of `seed + info + extras` minus its last 44 chars. -fn secret_from_seed(seed: &str, info: &str, extras: &str) -> Result { - let joined = format!("{seed}{info}{extras}"); - let keep = joined - .len() - .checked_sub(44) - .ok_or_else(|| anyhow!("signature seed is too short"))?; - let secret = String::from_utf8(decode_base64(&joined[..keep])?) - .map_err(|_| anyhow!("signature secret is not text"))?; - let is_hex = secret.len() == 32 - && secret - .bytes() - .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')); - if !is_hex { - return Err(anyhow!("signature secret has an unexpected shape")); - } - let seed_prefix = decode_base64(&seed[..seed.len() / 4 * 4])?; - if !secret.as_bytes().starts_with(&seed_prefix) { - return Err(anyhow!("signature secret does not start with its seed")); - } - Ok(secret) -} - -static RESOLVED: tokio::sync::OnceCell = tokio::sync::OnceCell::const_new(); - -/// Whether [`resolve_constants`] already succeeded in this process. -pub fn constants_resolved() -> bool { - RESOLVED.get().is_some() -} - -/// Resolve the constants once per process: env, then the cached version, then a scrape. -pub async fn resolve_constants( - http: &Client, - cached: Option<&QobuzBundleCache>, -) -> Result { - RESOLVED - .get_or_try_init(|| async { - if let Some(constants) = constants_from_env() { - return Ok(constants); - } - let html = http - .get(LOGIN_URL) - .send() - .await - .context("login page request")? - .error_for_status() - .context("login page")? - .text() - .await - .context("login page body")?; - let path = - bundle_path(&html).ok_or_else(|| anyhow!("bundle path not found in the login page"))?; - let version = bundle_version(&path).ok_or_else(|| anyhow!("bundle path has no version"))?; - if let Some(constants) = cached.filter(|c| c.bundle_version == version) { - return Ok(constants.clone()); - } - log::info!("[qobuz] scraping bundle {version}"); - let js = http - .get(format!("{PLAY_ORIGIN}{path}")) - .timeout(BUNDLE_TIMEOUT) - .send() - .await - .context("bundle request")? - .error_for_status() - .context("bundle")? - .text() - .await - .context("bundle body")?; - parse_bundle(&js, &version) - }) - .await - .cloned() -} - -// --------------------------------------------------------------------------- -// Credentials file -// --------------------------------------------------------------------------- - -/// The saved login: the token every Qobuz call carries, plus the user id. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct QobuzCredentials { - pub user_auth_token: String, - #[serde(default)] - pub user_id: Option, -} - -/// Read the credentials file; `None` when missing, unreadable, or without a token. -fn read_credentials(path: &Path) -> Option { - std::fs::read_to_string(path) - .ok() - .and_then(|text| serde_yaml::from_str::(&text).ok()) - .filter(|c| !c.user_auth_token.is_empty()) -} - -/// Write the credentials file with private permissions. -fn write_credentials(path: &Path, credentials: &QobuzCredentials) -> Result<()> { - if let Some(dir) = path.parent() { - crate::core::paths::ensure_private_dir(dir)?; - } - let yaml = serde_yaml::to_string(credentials).context("serializing Qobuz credentials")?; - crate::core::auth::write_private_file_atomic(path, yaml.as_bytes()) - .with_context(|| format!("writing {}", path.display())) -} - -/// Keep the token in memory and save the credentials file. -pub fn save_login(credentials: &QobuzCredentials) -> Result<()> { - set_token(Some(credentials.user_auth_token.clone())); - let path = crate::core::paths::qobuz_credentials_path() - .ok_or_else(|| anyhow!("no config directory to save credentials in"))?; - write_credentials(&path, credentials) -} - -/// The token to use: the env override first, then the credentials file. -pub fn token_with(env_token: Option, saved: Option) -> Option { - env_token - .filter(|t| !t.trim().is_empty()) - .or_else(|| saved.map(|c| c.user_auth_token)) -} - -/// The saved or overridden token, if any. -pub(super) fn load_token() -> Option { - token_with( - std::env::var(TOKEN_ENV).ok(), - crate::core::paths::qobuz_credentials_path().and_then(|p| read_credentials(&p)), - ) -} - -fn token_slot() -> &'static std::sync::Mutex> { - static TOKEN: std::sync::OnceLock>> = std::sync::OnceLock::new(); - TOKEN.get_or_init(|| std::sync::Mutex::new(load_token())) -} - -/// The token in use: loaded once from env or file, replaced by [`set_token`]. -pub fn current_token() -> Option { - token_slot().lock().map(|t| t.clone()).unwrap_or(None) -} - -/// Replace the in-memory token: `Some` after a login, `None` after a 401. -pub fn set_token(token: Option) { - if let Ok(mut slot) = token_slot().lock() { - *slot = token; - } -} - -// --------------------------------------------------------------------------- -// Browser login -// --------------------------------------------------------------------------- - -const OAUTH_URL: &str = "https://www.qobuz.com/signin/oauth"; -/// How long the browser round trip may take before the login is abandoned. -const LOGIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(180); - -/// The authorization URL for `app_id` with a `localhost:` redirect. -fn login_url(app_id: &str, port: u16) -> String { - format!("{OAUTH_URL}?ext_app_id={app_id}&redirect_url=http%3A%2F%2Flocalhost%3A{port}") -} - -/// The authorization code from the callback URL (`code_autorisation`, else `code`). -fn callback_code(url: &str) -> Option { - let parsed = url::Url::parse(url).ok()?; - let mut fallback = None; - for (key, value) in parsed.query_pairs() { - if value.is_empty() { - continue; - } - match key.as_ref() { - "code_autorisation" => return Some(value.into_owned()), - "code" => fallback = Some(value.into_owned()), - _ => {} - } - } - fallback -} - -fn accepts_qobuz_callback(path: &str) -> bool { - path.contains("code_autorisation=") || path.contains("code=") -} - -/// A bound login: open [`url`](Self::url) in the browser, then [`wait`](Self::wait). -pub struct LoginAttempt { - constants: QobuzBundleCache, - v4: tokio::net::TcpListener, - /// A second listener for browsers that resolve `localhost` to `::1` first. - v6: Option, - port: u16, -} - -impl LoginAttempt { - /// Bind the loopback callback listener(s) on a free port. - pub async fn bind(constants: QobuzBundleCache) -> Result { - let v4 = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .context("binding the Qobuz login callback listener")?; - let port = v4.local_addr()?.port(); - let v6 = tokio::net::TcpListener::bind(("::1", port)).await.ok(); - Ok(LoginAttempt { - constants, - v4, - v6, - port, - }) - } - - pub fn url(&self) -> String { - login_url(&self.constants.app_id, self.port) - } - - /// Wait for the browser callback and exchange its code for credentials. - pub async fn wait(self) -> Result { - use crate::infra::redirect_uri::redirect_uri_web_server_on; - let LoginAttempt { - constants, v4, v6, .. - } = self; - // Both accept loops live inside this future, so the timeout drops both - // listeners with it. - let callback = async move { - match v6 { - None => redirect_uri_web_server_on(v4, accepts_qobuz_callback).await, - Some(v6) => tokio::select! { - r = redirect_uri_web_server_on(v4, accepts_qobuz_callback) => r, - r = redirect_uri_web_server_on(v6, accepts_qobuz_callback) => r, - }, - } - }; - let url = tokio::time::timeout(LOGIN_TIMEOUT, callback) - .await - .map_err(|_| anyhow!("login timed out after {}s", LOGIN_TIMEOUT.as_secs()))? - .map_err(|()| anyhow!("login callback server failed"))?; - let code = - callback_code(&url).ok_or_else(|| anyhow!("no authorization code in the callback"))?; - exchange_code(&constants, &code).await - } -} - -/// `GET oauth/callback`: trade the browser's code for the user token. -async fn exchange_code(constants: &QobuzBundleCache, code: &str) -> Result { - let source = super::QobuzSource::new(&constants.app_id, &constants.app_secret, ""); - let reply: super::types::OauthCallback = source - .get( - "oauth/callback", - &[ - ("code", code.to_string()), - ("private_key", constants.oauth_key.clone()), - ], - ) - .await - .context("oauth callback")?; - let user_auth_token = reply - .token - .or(reply.user_auth_token) - .filter(|t| !t.is_empty()) - .ok_or_else(|| anyhow!("oauth callback returned no token"))?; - let user_id = reply.user_id.or(reply.user.and_then(|u| u.id)); - Ok(QobuzCredentials { - user_auth_token, - user_id, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - const SECRET: &str = "0123456789abcdef0123456789abcdef"; - - /// A bundle snippet in the live shapes, with synthetic values. - fn bundle() -> String { - let encoded = base64::engine::general_purpose::STANDARD.encode(SECRET); - let (seed, info) = encoded.split_at(30); - let extras = "A".repeat(44); - format!( - concat!( - r#"x={{production:{{api:{{appId:"123456789",appSecret:"{legacy}"}}}}}};"#, - r#"c.initialSeed("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",window.utimezone.london);"#, - r#"c.initialSeed("{seed}",window.utimezone.berlin);"#, - r#"[{{name:"Europe/London",info:"BBBB",extras:"CCCC"}},"#, - r#"{{name:"Europe/Berlin",info:"{info}",extras:"{extras}"}}];"#, - r#"r.authenticate({{privateKey:"oauth-private-key"}})"# - ), - legacy = "f".repeat(32), - seed = seed, - info = info, - extras = extras, - ) - } - - #[test] - fn parse_bundle_extracts_all_three_constants() { - let constants = parse_bundle(&bundle(), "8.2.0-b034").unwrap(); - assert_eq!(constants.bundle_version, "8.2.0-b034"); - assert_eq!(constants.app_id, "123456789"); - assert_eq!(constants.app_secret, SECRET); - assert_eq!(constants.oauth_key, "oauth-private-key"); - } - - #[test] - fn parse_bundle_reports_a_missing_seed() { - let js = r#"production:{api:{appId:"123456789",appSecret:"x"}}"#; - let err = parse_bundle(js, "v").unwrap_err().to_string(); - assert!(err.contains("seed"), "{err}"); - } - - #[test] - fn secret_from_seed_rejects_a_non_hex_result() { - let encoded = - base64::engine::general_purpose::STANDARD.encode("not a hex secret, 32 chars!!!!!!"); - let (seed, info) = encoded.split_at(30); - assert!(secret_from_seed(seed, info, &"A".repeat(44)).is_err()); - } - - #[test] - fn bundle_path_and_version_parse_the_login_page() { - let html = r#""#; - let path = bundle_path(html).unwrap(); - assert_eq!(path, "/resources/8.2.0-b034/bundle.js"); - assert_eq!(bundle_version(&path).as_deref(), Some("8.2.0-b034")); - assert!(bundle_path("").is_none()); - } - - #[test] - fn env_overrides_need_all_three_values() { - let all = - constants_from_env_with(Some("1".into()), Some("s".into()), Some("k".into())).unwrap(); - assert_eq!(all.bundle_version, "env"); - assert_eq!(all.app_id, "1"); - assert!(constants_from_env_with(Some("1".into()), Some("s".into()), None).is_none()); - assert!( - constants_from_env_with(Some("1".into()), Some(" ".into()), Some("k".into())).is_none() - ); - } - - #[test] - fn decode_base64_accepts_both_alphabets_and_padding() { - assert_eq!(decode_base64("-_8=").unwrap(), vec![0xfb, 0xff]); - assert_eq!(decode_base64("+/8").unwrap(), vec![0xfb, 0xff]); - } - - #[test] - fn token_prefers_env_over_the_file() { - let saved = QobuzCredentials { - user_auth_token: "file".into(), - user_id: None, - }; - assert_eq!( - token_with(Some("env".into()), Some(saved.clone())).as_deref(), - Some("env") - ); - assert_eq!( - token_with(Some(" ".into()), Some(saved)).as_deref(), - Some("file") - ); - assert!(token_with(None, None).is_none()); - } - - #[test] - fn callback_code_prefers_code_autorisation_and_falls_back_to_code() { - assert_eq!( - callback_code("http://localhost:4321/?code_autorisation=ab%2Fcd&x=1").as_deref(), - Some("ab/cd") - ); - assert_eq!( - callback_code("http://localhost:4321/?code=legacy").as_deref(), - Some("legacy") - ); - assert!(callback_code("http://localhost:4321/?state=only").is_none()); - assert!(callback_code("not a url").is_none()); - } - - #[test] - fn login_url_targets_localhost_on_the_bound_port() { - assert_eq!( - login_url("123456789", 4321), - "https://www.qobuz.com/signin/oauth?ext_app_id=123456789&redirect_url=http%3A%2F%2Flocalhost%3A4321" - ); - assert!(accepts_qobuz_callback("/?code_autorisation=x")); - assert!(accepts_qobuz_callback("/?code=x")); - assert!(!accepts_qobuz_callback("/favicon.ico")); - } - - #[test] - fn credentials_round_trip_through_the_file() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("qobuz_credentials.yml"); - assert!(read_credentials(&path).is_none()); - let creds = QobuzCredentials { - user_auth_token: "tok".into(), - user_id: Some("42".into()), - }; - write_credentials(&path, &creds).unwrap(); - assert_eq!(read_credentials(&path), Some(creds)); - } -} diff --git a/src/infra/qobuz/dispatch.rs b/src/infra/qobuz/dispatch.rs deleted file mode 100644 index 53fb4644..00000000 --- a/src/infra/qobuz/dispatch.rs +++ /dev/null @@ -1,791 +0,0 @@ -//! Qobuz browse/search/login/playback routing. -//! -//! The seam that keeps the Spotify [`Network`](crate::infra::network) -//! Spotify-only: [`route_qobuz_event`] is called from the runtime IoEvent pump -//! after the Subsonic dispatch and before Radio. An event that targets the -//! Qobuz source (a browse, search, or login request, or a `qobuz:` playback -//! URI) is handled here and consumed; anything else falls through. -//! -//! ## Playback -//! -//! Qobuz playback owns [`App::qobuz_playback`] and never writes Spotify or -//! librespot fields. A track plays while it downloads (`stream::progressive`): -//! the session is published at once (marked `advancing`, like the native queue -//! slot) and a detached task fetches segment 0, opens the stream, and builds -//! the decoder, which waits for the first bytes. The pump keeps serving every -//! other event, and a skip during that window supersedes it through -//! `fetch_id`; the superseded stream is dropped, which cancels its download. -//! -//! Failures are status messages (never `handle_error`): the CLI never reaches -//! this router, so no exit signal is lost. A 401 clears the in-memory token so -//! the next browse re-runs the browser login. A failure before the first bytes -//! tears the session down instead of skipping (a skip would walk the queue at -//! tick speed); a failure after that ends the track early. - -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::Arc; -use std::time::Duration; - -use anyhow::{Context, Result}; -use tempfile::NamedTempFile; -use tokio::sync::Mutex; - -use super::auth::{self, QobuzBundleCache}; -use super::stream::progressive; -use super::{ - track_id_from_uri, QobuzPlaybackState, QobuzSource, ResumePoint, StreamQuality, Unauthorized, -}; -use crate::core::app::{App, TrackTableContext}; -use crate::core::source::{MediaSource, Searcher, Source}; -use crate::core::state::PersistedRuntimeState; -use crate::infra::audio::{LocalPlayer, PreparedStream}; -use crate::infra::network::IoEvent; -use crate::infra::queue::{advance_index, replay_file, snapshot_tracks}; - -const LOGIN_EXPIRED: &str = "Qobuz: login expired, press `d` and pick Qobuz to log in again"; - -/// Whether a URI is owned by the Qobuz source. -pub fn is_qobuz_uri(uri: &str) -> bool { - uri.starts_with("qobuz:") -} - -/// Skip direction within the queue. -#[derive(Clone, Copy)] -enum Direction { - Next, - Prev, -} - -/// Intercept events that target the Qobuz source. -/// -/// Returns `true` if the event was handled (and must **not** be forwarded to -/// the Spotify network), `false` to let the normal dispatch run. -pub async fn route_qobuz_event(app: &Arc>, event: &IoEvent) -> bool { - match event { - IoEvent::GetQobuzPlaylists => { - load_qobuz_playlists(app).await; - true - } - IoEvent::GetQobuzTracks(uri) => { - load_qobuz_tracks(app, uri).await; - true - } - IoEvent::GetQobuzSearchResults(query) => { - run_qobuz_search(app, query).await; - true - } - IoEvent::QobuzLogin => { - begin_login(app).await; - true - } - // Start a list of Qobuz tracks: queue all and start at the offset. - IoEvent::StartPlayback(None, Some(uris), offset) - if uris.first().is_some_and(|u| is_qobuz_uri(u)) => - { - start_qobuz_queue(app, uris, offset.unwrap_or(0), None).await; - true - } - // A single Qobuz track with no surrounding list: a one-track queue. - IoEvent::StartPlayback(Some(uri), _, _) if is_qobuz_uri(uri) => { - start_qobuz_queue(app, std::slice::from_ref(uri), 0, None).await; - true - } - // Bare "resume current": ours only while Qobuz owns the session. - IoEvent::StartPlayback(None, None, None) => match player(app).await { - Some(p) => { - p.resume(); - true - } - None => false, - }, - // Any other start is a foreign play: relinquish the device, then let the - // normal dispatch run. - IoEvent::StartPlayback(..) => { - teardown_qobuz(app).await; - false - } - IoEvent::PausePlayback => match player(app).await { - Some(p) => { - p.pause(); - true - } - None => false, - }, - IoEvent::Seek(position_ms) => match player(app).await { - Some(p) => { - // A seek past the downloaded part waits for that segment: off the pump. - let position = Duration::from_millis(*position_ms as u64); - tokio::task::spawn_blocking(move || { - let _ = p.seek(position); - }); - true - } - None => false, - }, - IoEvent::ChangeVolume(volume) => match player(app).await { - Some(p) => { - p.set_volume(*volume); - let mut app = app.lock().await; - app.runtime_state.volume_percent = *volume; - app.schedule_state_save(PersistedRuntimeState::volume_percent(*volume)); - true - } - None => false, - }, - IoEvent::NextTrack => skip(app, Direction::Next).await, - IoEvent::PreviousTrack | IoEvent::ForcePreviousTrack => skip(app, Direction::Prev).await, - IoEvent::ReplayCurrentTrack => replay_current(app).await, - IoEvent::Repeat(state) => app.lock().await.set_decoded_repeat_from_state(*state), - _ => false, - } -} - -// --------------------------------------------------------------------------- -// Constants, token, source -// --------------------------------------------------------------------------- - -/// The bundle cache saved in `state.yml`, when the app has a state path. -async fn cached_bundle(app: &Arc>) -> Option { - let path = app.lock().await.state_path.clone()?; - crate::core::state::load(&path).ok()?.qobuz_bundle_cache -} - -/// The bundle constants; a fresh scrape is saved into `state.yml`. The first -/// call of a process runs the login-page GET (and, on a new bundle version, the -/// 9 MB bundle GET) inline on the serial pump; accepted for v1. -async fn constants(app: &Arc>) -> Result { - let http = super::shared_qobuz_client(); - if auth::constants_resolved() { - return auth::resolve_constants(&http, None).await; - } - let cached = cached_bundle(app).await; - let resolved = auth::resolve_constants(&http, cached.as_ref()).await?; - if cached.as_ref() != Some(&resolved) && resolved.bundle_version != "env" { - app - .lock() - .await - .schedule_state_save(PersistedRuntimeState::qobuz_bundle_cache(resolved.clone())); - } - Ok(resolved) -} - -/// What to do when no token is available. -#[derive(Clone, Copy)] -enum WhenLoggedOut { - /// Browse paths start the browser login. - Login, - /// Playback paths only show the login message. - Message, -} - -/// Build a [`QobuzSource`] from the in-memory token, or `None` after handling -/// the logged-out case as `when_logged_out` says. -async fn build_source( - app: &Arc>, - when_logged_out: WhenLoggedOut, -) -> Option { - let Some(token) = auth::current_token() else { - match when_logged_out { - WhenLoggedOut::Login => app.lock().await.dispatch(IoEvent::QobuzLogin), - WhenLoggedOut::Message => set_error(app, LOGIN_EXPIRED.to_string()).await, - } - return None; - }; - match constants(app).await { - Ok(c) => Some(QobuzSource::new(c.app_id, c.app_secret, token)), - Err(e) => { - report(app, "web player constants", e).await; - None - } - } -} - -/// A source for a playback path (the native queue lane); shows the login -/// message and returns `None` when logged out. -pub(crate) async fn build_playback_source(app: &Arc>) -> Option { - build_source(app, WhenLoggedOut::Message).await -} - -/// Report a failed call as one status message; a 401 also clears the token. -async fn report(app: &Arc>, step: &str, err: anyhow::Error) { - let mut guard = app.lock().await; - report_locked(&mut guard, step, err); -} - -fn report_locked(app: &mut App, step: &str, err: anyhow::Error) { - if err.downcast_ref::().is_some() { - auth::set_token(None); - app.set_error_status_message(LOGIN_EXPIRED, 8); - } else { - log::warn!("[qobuz] {step}: {err:#}"); - app.set_status_message(format!("Qobuz: {step}: {err:#}"), 6); - } -} - -/// Deliberate divergence from `handle_error`: a status message, no error route. -async fn set_error(app: &Arc>, message: String) { - app.lock().await.set_status_message(message, 6); -} - -// --------------------------------------------------------------------------- -// Login -// --------------------------------------------------------------------------- - -static LOGIN_IN_PROGRESS: AtomicBool = AtomicBool::new(false); - -/// Start the in-TUI browser login on a detached task so the pump keeps running. -async fn begin_login(app: &Arc>) { - if LOGIN_IN_PROGRESS.swap(true, Ordering::SeqCst) { - set_error(app, "Qobuz login already in progress...".to_string()).await; - return; - } - let constants = match constants(app).await { - Ok(c) => c, - Err(e) => { - LOGIN_IN_PROGRESS.store(false, Ordering::SeqCst); - report(app, "web player constants", e).await; - return; - } - }; - let attempt = match auth::LoginAttempt::bind(constants).await { - Ok(a) => a, - Err(e) => { - LOGIN_IN_PROGRESS.store(false, Ordering::SeqCst); - report(app, "login", e).await; - return; - } - }; - let url = attempt.url(); - { - let mut guard = app.lock().await; - if let Err(e) = open::that_detached(&url) { - log::warn!("[qobuz] failed to open browser automatically: {e}"); - guard.set_status_message( - format!("Open this URL in your browser to log in to Qobuz: {url}"), - 30, - ); - } else { - guard.set_status_message("Qobuz: open the browser window to log in", 12); - } - } - let io_tx = app.lock().await.io_tx_clone(); - let app = Arc::clone(app); - tokio::spawn(async move { - match attempt.wait().await { - Ok(credentials) => { - if let Err(e) = auth::save_login(&credentials) { - log::warn!("[qobuz] cannot save credentials: {e:#}"); - } - app.lock().await.set_status_message("Qobuz: logged in", 4); - if let Some(tx) = io_tx { - let _ = tx.send(IoEvent::GetQobuzPlaylists); - } - } - Err(e) => { - log::warn!("[qobuz] login failed: {e:#}"); - app - .lock() - .await - .set_status_message(format!("Qobuz login failed: {e:#}"), 8); - } - } - LOGIN_IN_PROGRESS.store(false, Ordering::SeqCst); - }); -} - -// --------------------------------------------------------------------------- -// Browse + search -// --------------------------------------------------------------------------- - -/// Fetch the sidebar rows (favorites, playlists, albums) into `app.qobuz_playlists`. -async fn load_qobuz_playlists(app: &Arc>) { - let Some(source) = build_source(app, WhenLoggedOut::Login).await else { - return; - }; - match source.playlists().await { - Ok(playlists) => app.lock().await.qobuz_playlists = playlists, - Err(e) => report(app, "library", e).await, - } -} - -/// Fetch a listing's tracks into the shared track table, tagged -/// [`TrackTableContext::QobuzPlaylist`] so selecting a row plays it. -async fn load_qobuz_tracks(app: &Arc>, playlist_uri: &str) { - let Some(source) = build_source(app, WhenLoggedOut::Login).await else { - return; - }; - match source.tracks(playlist_uri).await { - Ok(tracks) => { - let mut app = app.lock().await; - app.set_track_table(tracks, TrackTableContext::QobuzPlaylist); - } - Err(e) => report(app, "tracks", e).await, - } -} - -/// Run a catalog search and populate the songs block of `app.search_results`. -async fn run_qobuz_search(app: &Arc>, query: &str) { - let Some(source) = build_source(app, WhenLoggedOut::Login).await else { - return; - }; - match source.search(query).await { - Ok(results) => app.lock().await.show_source_search_tracks(results.tracks), - Err(e) => report(app, "search", e).await, - } -} - -// --------------------------------------------------------------------------- -// Playback -// --------------------------------------------------------------------------- - -/// The live Qobuz player, if a Qobuz session is active. -async fn player(app: &Arc>) -> Option> { - app - .lock() - .await - .qobuz_playback - .as_ref() - .map(|s| Arc::clone(&s.player)) -} - -/// Release every other backend so only Qobuz holds the output device. -async fn release_other_backends(app: &Arc>) { - // Pause native Spotify so librespot releases the device. - #[cfg(feature = "streaming")] - { - let streaming = app.lock().await.streaming_player.clone(); - if let Some(player) = streaming { - player.pause(); - } - } - // The other decoded sources never see this `qobuz:` start (the pump - // short-circuits), so their sessions are torn down here. - let players = app.lock().await.take_decoded_sessions_except(Source::Qobuz); - for player in players { - player.stop_detached(); - } -} - -/// Reuse the live Qobuz player, or open a fresh output device for one. A -/// freshly opened player is **not** published to `App` here. -async fn acquire_player(app: &Arc>) -> Option> { - if let Some(p) = player(app).await { - return Some(p); - } - match tokio::task::spawn_blocking(LocalPlayer::new).await { - Ok(Ok(p)) => Some(Arc::new(p)), - Ok(Err(e)) => { - set_error(app, format!("No audio output for Qobuz playback: {e}")).await; - None - } - Err(e) => { - set_error(app, format!("Audio output init failed: {e}")).await; - None - } - } -} - -static FETCH_SEQ: AtomicU64 = AtomicU64::new(0); - -fn next_fetch_id() -> u64 { - FETCH_SEQ.fetch_add(1, Ordering::Relaxed) -} - -/// Download the track at `uri` into a tempfile, for the native queue engine's -/// off-pump fetch (playing is the queue's job), and return its delivered -/// format label. Reads the progressive stream to its end, so its per-segment -/// retries apply here too. -pub(crate) async fn download_for_queue( - source: &QobuzSource, - uri: &str, - quality: u8, -) -> Result<(NamedTempFile, String)> { - let track_id = track_id_from_uri(uri)?; - let tmp = NamedTempFile::new().context("creating temp file for Qobuz stream")?; - let (init, stream) = source.begin_stream(track_id, quality).await?; - let mut reader = progressive::open(stream, &tmp).await?; - tokio::task::spawn_blocking(move || std::io::copy(&mut reader, &mut std::io::sink())) - .await - .context("download task")? - .context("downloading the Qobuz stream")?; - Ok((tmp, init.quality().label())) -} - -/// Open the track's stream into a fresh tempfile and build its decoder, whose -/// first read waits for the prefetch. -async fn prepare_track( - source: &QobuzSource, - track_id: &str, - quality: u8, -) -> Result<(NamedTempFile, StreamQuality, PreparedStream)> { - let tmp = NamedTempFile::new().context("creating temp file for Qobuz stream")?; - let (init, stream) = source.begin_stream(track_id, quality).await?; - let delivered = init.quality(); - let total = init.total_bytes(); - let reader = progressive::open(stream, &tmp).await?; - let mime = if delivered.flac { - "audio/flac" - } else { - "audio/mpeg" - }; - let prepared = tokio::task::spawn_blocking(move || { - LocalPlayer::prepare_stream(reader, Some(mime), Some(total)) - }) - .await - .context("decoder task")??; - Ok((tmp, delivered, prepared)) -} - -/// Open the track's stream on a detached task and build its decoder, then -/// play it when the session still waits for this fetch. Called under the -/// `App` lock that stamped `session.fetch_id`, so the abort handle is in -/// place before any skip looks for it: a skip, a new queue, a queue takeover, -/// or a teardown cancels the task, and a decoder build already in progress -/// ends on its own and drops its stream, which cancels that download. -fn spawn_fetch( - app: &Arc>, - session: &mut QobuzPlaybackState, - track_id: String, - quality: u8, -) { - let app = Arc::clone(app); - let source = Arc::clone(&session.source); - let fetch_id = session.fetch_id; - let task = tokio::spawn(async move { - match prepare_track(&source, &track_id, quality).await { - Ok((tmp, delivered, prepared)) => { - commit_fetch(&app, fetch_id, tmp, delivered, prepared).await - } - Err(e) => fail_fetch(&app, fetch_id, "stream", e).await, - } - }); - session.fetch = Some(task.abort_handle()); -} - -/// A failed fetch: tear the session down only if it still waits for this -/// fetch, and report the error. Under the native queue the session stays for -/// the queue's resume, which fetches again. -async fn fail_fetch(app: &Arc>, fetch_id: u64, step: &str, err: anyhow::Error) { - let mut guard = app.lock().await; - if guard - .qobuz_playback - .as_ref() - .is_none_or(|s| s.fetch_id != fetch_id) - { - return; - } - if guard.queue_owns_playback() { - log::warn!("[qobuz] {step}: {err:#}"); - return; - } - let session = guard.qobuz_playback.take(); - report_locked(&mut guard, step, err); - drop(guard); - if let Some(s) = session { - Arc::clone(&s.player).stop_detached(); - } -} - -/// Play the prepared stream and finalize the session. The previous track is -/// cleared off the `App` lock first (the sink clear waits for the audio -/// thread, which a stalled stream holds), then the session is checked again -/// under the lock so a concurrent skip (which restamps `fetch_id`) cannot -/// interleave. A stale stream is dropped, which cancels its download. -async fn commit_fetch( - app: &Arc>, - fetch_id: u64, - tmp: NamedTempFile, - quality: StreamQuality, - prepared: PreparedStream, -) { - let claimed = { - let guard = app.lock().await; - // The native queue owns the sink: the session stays for its resume. - if guard.queue_owns_playback() { - return; - } - guard - .qobuz_playback - .as_ref() - .filter(|s| s.fetch_id == fetch_id) - // A pause pressed during the fetch window applies to the previous - // track's sink; a fresh player starts paused, so only a session that - // already played something counts. - .map(|s| { - ( - Arc::clone(&s.player), - s.tempfile.is_some() && s.player.is_paused(), - ) - }) - }; - let Some((player, was_paused)) = claimed else { - return; - }; - let stop_player = Arc::clone(&player); - if tokio::task::spawn_blocking(move || stop_player.stop()) - .await - .is_err() - { - return; - } - // Claim the session under the lock, stage off it: the clear inside - // `stage_prepared` waits on the audio thread, and the runner takes this - // lock on every frame. - let (resume, volume) = { - let mut guard = app.lock().await; - let volume = guard.runtime_state.volume_percent; - let Some(s) = guard - .qobuz_playback - .as_mut() - .filter(|s| s.fetch_id == fetch_id) - else { - return; - }; - s.tempfile = Some(tmp); - s.quality = Some(quality); - s.fetch = None; - (s.resume_at.take(), volume) - }; - let paused = was_paused || resume.is_some_and(|r| r.paused); - let stage_player = Arc::clone(&player); - let staged = tokio::task::spawn_blocking(move || { - stage_player.stage_prepared(prepared)?; - stage_player.set_volume(volume); - if !paused { - stage_player.resume(); - } - Ok::<(), anyhow::Error>(()) - }) - .await; - let staged = match staged { - Ok(Ok(())) => true, - Ok(Err(e)) => { - log::warn!("[qobuz] stage: {e:#}"); - false - } - Err(e) => { - log::warn!("[qobuz] stage task: {e}"); - false - } - }; - let mut guard = app.lock().await; - let Some(s) = guard - .qobuz_playback - .as_mut() - .filter(|s| s.fetch_id == fetch_id) - else { - return; - }; - if !staged { - // The device went under the stage. Replay the retained file instead, - // with the same pause; the advance latch stays on until it does. - s.resume_at = Some(ResumePoint { - position_ms: resume.map_or(0, |r| r.position_ms), - paused, - }); - guard.dispatch(IoEvent::ReplayCurrentTrack); - return; - } - s.advancing = false; - let display = s.current().map(|t| t.name.clone()); - if let Some(display) = display { - guard.set_status_message(format!("\u{266a} {display}"), 4); - } - drop(guard); - // The restore seek waits for that part of the download: off the App lock. - if let Some(position_ms) = resume.map(|r| r.position_ms).filter(|&ms| ms > 0) { - tokio::task::spawn_blocking(move || { - let _ = player.seek(Duration::from_millis(position_ms)); - }); - } -} - -/// Begin playing a list of Qobuz tracks, taking over the session and starting -/// at `start_idx` (clamped into range). `resume` is applied when the first -/// track plays (session restore), so it is in place before the fetch starts. -pub(crate) async fn start_qobuz_queue( - app: &Arc>, - uris: &[String], - start_idx: usize, - resume: Option, -) { - let tracks = { - let guard = app.lock().await; - let search = guard - .search_results - .tracks - .as_ref() - .map(|p| p.items.as_slice()); - snapshot_tracks(&guard.track_table.tracks, search, uris) - }; - if tracks.is_empty() { - set_error(app, "No Qobuz tracks to play".to_string()).await; - return; - } - let index = start_idx.min(tracks.len() - 1); - let track_id = match tracks[index].uri.as_deref().map(track_id_from_uri) { - Some(Ok(id)) => id.to_string(), - _ => { - set_error(app, "Invalid Qobuz track URI".to_string()).await; - return; - } - }; - let Some(source) = build_source(app, WhenLoggedOut::Message).await else { - return; - }; - let source = Arc::new(source); - - // Only one backend owns the device at a time. - app.lock().await.claim_decoded_sink(Source::Qobuz); - release_other_backends(app).await; - let Some(player) = acquire_player(app).await else { - return; - }; - - // Publish the session now, marked advancing, so the playbar and the skip - // keys see it during the download; `commit_fetch` finalizes it. - let mut guard = app.lock().await; - let quality = guard.user_config.behavior.qobuz_quality; - let mut state = QobuzPlaybackState { - player, - source, - tracks, - index, - advancing: true, - tempfile: None, - quality: None, - shuffle_backup: None, - fetch_id: next_fetch_id(), - resume_at: resume, - fetch: None, - }; - // Honor the player-global decoded shuffle for the freshly built queue. - if guard.decoded_shuffle { - state.set_shuffle(true); - } - spawn_fetch(app, &mut state, track_id, quality); - // Dropping a previous session aborts its download. - guard.qobuz_playback = Some(state); -} - -/// Move the queue index in `direction` and play the new track. Returns `true` -/// if Qobuz owns the session (so the event is consumed). -async fn skip(app: &Arc>, direction: Direction) -> bool { - let target = { - let mut guard = app.lock().await; - let mode = guard.decoded_repeat; - let Some(s) = guard.qobuz_playback.as_mut() else { - return false; - }; - s.advancing = true; - let forward = matches!(direction, Direction::Next); - advance_index(s.index, s.tracks.len(), mode, forward) - }; - match target { - Some(idx) => play_index(app, idx, None).await, - None => { - // Queue boundary: clear the guard so auto-advance is not wedged off. A - // pending first download keeps it (the sink is still empty). - if let Some(s) = app.lock().await.qobuz_playback.as_mut() { - if s.tempfile.is_some() { - s.advancing = false; - } - } - } - } - true -} - -/// Replay the current track (repeat-one) from its retained tempfile, with no -/// second download. Returns `true` if Qobuz owns the session. -async fn replay_current(app: &Arc>) -> bool { - let replay = { - let mut guard = app.lock().await; - let Some(s) = guard.qobuz_playback.as_mut() else { - return false; - }; - s.advancing = true; - match s.tempfile.as_ref() { - Some(t) => Some(( - Arc::clone(&s.player), - t.path().to_path_buf(), - s.resume_at.take().or(Some(ResumePoint { - position_ms: 0, - paused: s.player.is_paused(), - })), - )), - // Still downloading: the commit applies `resume_at`, clears `advancing` - // and starts playback. - None => None, - } - }; - let Some((player, path, resume)) = replay else { - return true; - }; - if replay_file(player, path, resume).await { - if let Some(s) = app.lock().await.qobuz_playback.as_mut() { - s.advancing = false; - } - } else { - teardown_qobuz(app).await; - set_error(app, "Cannot replay Qobuz track".to_string()).await; - } - true -} - -/// Play the queued track at `target` in the published session: the index moves -/// at once and the download runs off the pump. Used by Next/Previous, the tick's -/// auto-advance, and the native queue's resume, which passes how the track -/// starts as `resume`. -pub(crate) async fn play_index(app: &Arc>, target: usize, resume: Option) { - let mut guard = app.lock().await; - let quality = guard.user_config.behavior.qobuz_quality; - let Some(s) = guard.qobuz_playback.as_mut() else { - return; // session torn down between dispatch and here - }; - let Some(track) = s.tracks.get(target) else { - s.advancing = false; - return; - }; - let track_id = match track.uri.as_deref().map(track_id_from_uri) { - Some(Ok(id)) => id.to_string(), - _ => { - drop(guard); - teardown_qobuz(app).await; - set_error(app, "Invalid Qobuz track URI".to_string()).await; - return; - } - }; - // Cancel a superseded download; the file, format, and restore point of the - // previous track go with it, so the session never describes two tracks. - if let Some(fetch) = s.fetch.take() { - fetch.abort(); - } - s.tempfile = None; - s.quality = None; - s.resume_at = resume; - s.index = target; - s.advancing = true; - s.fetch_id = next_fetch_id(); - spawn_fetch(app, s, track_id, quality); -} - -/// End the Qobuz session, releasing the output device and the tempfile. The -/// player stops off the `App` lock. -async fn teardown_qobuz(app: &Arc>) { - let session = app.lock().await.qobuz_playback.take(); - if let Some(s) = session { - Arc::clone(&s.player).stop_detached_holding(s); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn qobuz_uris_are_recognised_by_scheme() { - assert!(is_qobuz_uri("qobuz:track:1")); - assert!(!is_qobuz_uri("subsonic:track:1")); - } -} diff --git a/src/infra/qobuz/mod.rs b/src/infra/qobuz/mod.rs deleted file mode 100644 index 974a1ebc..00000000 --- a/src/infra/qobuz/mod.rs +++ /dev/null @@ -1,934 +0,0 @@ -//! Qobuz media source. -//! -//! Browses the user's Qobuz library and plays tracks through the encrypted CMAF -//! stream: each track is decrypted and rebuilt as a FLAC tempfile while the -//! shared [`LocalPlayer`](crate::infra::audio::LocalPlayer) decodes it -//! (see [`stream::progressive`]). -//! The three bundle constants are scraped at runtime (see [`auth`]); nothing -//! secret is embedded. -//! -//! ## URIs -//! -//! Tracks: `qobuz:track:`. Sidebar rows (each opens the shared track table): -//! `qobuz:favorites:tracks`, `qobuz:playlist:`, `qobuz:album:`. - -pub mod auth; -pub mod dispatch; -mod sign; -pub mod stream; -mod types; - -use std::fmt; -use std::sync::Arc; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use anyhow::{anyhow, Context, Result}; -use reqwest::Client; -use serde::de::DeserializeOwned; -use tokio::sync::Mutex; - -use crate::core::plugin_api::{ArtistRef, PlaylistInfo, SearchResults, TrackInfo}; -use crate::core::source::{MediaSource, Searcher}; -use crate::infra::audio::LocalPlayer; -use stream::cmaf::InitSegment; -use stream::download; -use stream::progressive::SegmentStream; - -pub use stream::cmaf::StreamQuality; - -/// The active Qobuz playback session; the playbar reads position and pause -/// live from `player`, like the Subsonic twin. -pub struct QobuzPlaybackState { - pub player: Arc, - /// Source handle, reused to download each track on Next/advance. - pub source: Arc, - /// The playing listing's tracks in order; the playbar reads `tracks[index]`. - pub tracks: Vec, - pub index: usize, - /// Set until the track starts to play so the tick never reads the empty - /// sink as end-of-track. - pub advancing: bool, - /// The current track's file, filled while it plays; `None` until playback - /// starts. - pub tempfile: Option, - /// The delivered format of the current track; `None` until playback starts. - pub quality: Option, - /// Backup of the pre-shuffle order while shuffle is on. - pub shuffle_backup: Option, - /// Stamp of the download in flight; a finished download with another stamp is dropped. - pub fetch_id: u64, - /// A seek and pause to apply when the next track is staged (session restore, - /// device recovery, the native queue's resume). - pub resume_at: Option, - /// The download task in flight; aborted when the session is replaced or restamped. - pub fetch: Option, -} - -impl Drop for QobuzPlaybackState { - fn drop(&mut self) { - if let Some(fetch) = self.fetch.take() { - fetch.abort(); - } - } -} - -pub use crate::infra::queue::ResumePoint; - -impl QobuzPlaybackState { - /// The currently playing track, if `index` is in range. - pub fn current(&self) -> Option<&TrackInfo> { - self.tracks.get(self.index) - } - - /// Turn in-place shuffle on or off (see `infra::queue::toggle_shuffle`). - pub fn set_shuffle(&mut self, on: bool) { - crate::infra::queue::toggle_shuffle( - &mut self.tracks, - &mut self.index, - &mut self.shuffle_backup, - on, - ); - } -} - -const API_BASE: &str = "https://www.qobuz.com/api.json/0.2/"; - -const TRACK_PREFIX: &str = "qobuz:track:"; -const PLAYLIST_PREFIX: &str = "qobuz:playlist:"; -const ALBUM_PREFIX: &str = "qobuz:album:"; -const FAVORITES_URI: &str = "qobuz:favorites:tracks"; - -/// Per-request caps; they bound each segment GET, never a whole track. -const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); -const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); - -const PAGE_LIMIT: u32 = 500; -const MAX_ITEMS: usize = 10_000; -const SEARCH_LIMIT: u32 = 20; -/// Renew the stream session this long before `expires_at`. -const SESSION_MARGIN_SECS: u64 = 60; - -/// A Qobuz call answered HTTP 401: the saved token is no longer valid. -#[derive(Debug)] -pub struct Unauthorized; - -impl fmt::Display for Unauthorized { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str("login expired") - } -} - -impl std::error::Error for Unauthorized {} - -/// A `session/start` session with its derived key. -#[derive(Clone)] -pub struct StreamSession { - pub session_id: String, - pub expires_at: u64, - pub session_key: [u8; 16], -} - -impl StreamSession { - fn is_valid_at(&self, now: u64) -> bool { - self.expires_at > now + SESSION_MARGIN_SECS - } -} - -/// What `file/url` returned for one track, with the content key unwrapped. -struct TrackStream { - url_template: String, - content_key: [u8; 16], -} - -fn unix_now() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0) -} - -/// Process-wide HTTP client with per-request timeouts (see Subsonic's twin). -pub fn shared_qobuz_client() -> Client { - static CLIENT: std::sync::OnceLock = std::sync::OnceLock::new(); - CLIENT - .get_or_init(|| { - Client::builder() - .connect_timeout(CONNECT_TIMEOUT) - .timeout(REQUEST_TIMEOUT) - .build() - .unwrap_or_default() - }) - .clone() -} - -/// The one stream session per process; sources are rebuilt per event. -fn shared_session() -> &'static Mutex> { - static SESSION: std::sync::OnceLock>> = std::sync::OnceLock::new(); - SESSION.get_or_init(|| Mutex::new(None)) -} - -#[derive(Clone, Copy)] -enum Method { - Get, - PostForm, -} - -/// Which track listing a sidebar URI names. -enum Listing { - Favorites, - Playlist(String), - Album(String), -} - -/// A media source backed by the Qobuz API. -pub struct QobuzSource { - app_id: String, - secret: String, - token: String, - http: Client, -} - -impl QobuzSource { - pub fn new( - app_id: impl Into, - secret: impl Into, - token: impl Into, - ) -> Self { - QobuzSource { - app_id: app_id.into(), - secret: secret.into(), - token: token.into(), - http: shared_qobuz_client(), - } - } - - /// One API call; `signed` adds `request_ts` and `request_sig` over `args`. - async fn request( - &self, - method: Method, - endpoint: &str, - args: &[(&str, String)], - signed: bool, - session_id: Option<&str>, - ) -> Result { - let mut query: Vec<(&str, String)> = args.to_vec(); - if signed { - let ts = unix_now(); - let sig_args: Vec<(&str, &str)> = args.iter().map(|(k, v)| (*k, v.as_str())).collect(); - query.push(("request_ts", ts.to_string())); - query.push(( - "request_sig", - sign::request_sig(endpoint, &sig_args, ts, &self.secret), - )); - } - let url = format!("{API_BASE}{endpoint}"); - let mut request = match method { - Method::Get => self.http.get(&url).query(&query), - Method::PostForm => self.http.post(&url).form(&query), - }; - request = request.header("X-App-Id", &self.app_id); - if !self.token.is_empty() { - request = request.header("X-User-Auth-Token", &self.token); - } - if let Some(id) = session_id { - request = request.header("X-Session-Id", id); - } - // `without_url`: a reqwest error would otherwise print the query, which - // carries the OAuth code and the private key on `oauth/callback`. - let response = request - .send() - .await - .map_err(reqwest::Error::without_url) - .with_context(|| format!("{endpoint} request"))?; - let status = response.status(); - if status == reqwest::StatusCode::UNAUTHORIZED { - return Err(Unauthorized.into()); - } - let body = response - .text() - .await - .map_err(reqwest::Error::without_url) - .with_context(|| format!("{endpoint} body"))?; - if !status.is_success() { - let excerpt: String = body.chars().take(120).collect(); - return Err(anyhow!("{endpoint} returned HTTP {status}: {excerpt}")); - } - serde_json::from_str(&body).with_context(|| format!("{endpoint} response parse")) - } - - async fn get(&self, endpoint: &str, args: &[(&str, String)]) -> Result { - self.request(Method::Get, endpoint, args, false, None).await - } - - // ------------------------------------------------------------------------- - // Stream session and file URL - // ------------------------------------------------------------------------- - - /// `POST session/start`: the signed profile as a form body (the live shape). - pub async fn session_start(&self) -> Result { - let started: types::SessionStart = self - .request( - Method::PostForm, - "session/start", - &[("profile", "qbz-1".to_string())], - true, - None, - ) - .await - .context("session start")?; - let (salt, info) = started - .infos - .split_once('.') - .ok_or_else(|| anyhow!("session infos has no separator"))?; - let session_key = stream::crypto::session_key( - &self.secret, - &auth::decode_base64(salt)?, - &auth::decode_base64(info)?, - )?; - Ok(StreamSession { - session_id: started.session_id, - expires_at: started.expires_at, - session_key, - }) - } - - /// The shared session, renewed when expired. - async fn stream_session(&self) -> Result { - let mut slot = shared_session().lock().await; - if let Some(session) = slot.as_ref().filter(|s| s.is_valid_at(unix_now())) { - return Ok(session.clone()); - } - let session = self.session_start().await?; - *slot = Some(session.clone()); - Ok(session) - } - - /// `GET file/url` for one track at `format_id` (5, 6, 7, or 27). - async fn file_url( - &self, - session: &StreamSession, - track_id: &str, - format_id: u8, - ) -> Result { - let file: types::FileUrl = self - .request( - Method::Get, - "file/url", - &[ - ("track_id", track_id.to_string()), - ("format_id", format_id.to_string()), - ("intent", "stream".to_string()), - ], - true, - Some(&session.session_id), - ) - .await - .context("file url")?; - let parts: Vec<&str> = file.key.split('.').collect(); - if parts.len() < 3 { - return Err(anyhow!( - "file url key has {} parts, expected 3", - parts.len() - )); - } - let content_key = stream::crypto::unwrap_content_key( - &session.session_key, - &auth::decode_base64(parts[1])?, - &auth::decode_base64(parts[2])?, - )?; - Ok(TrackStream { - url_template: file.url_template, - content_key, - }) - } - - /// The stream URL and key of a track; a stale session is renewed once. - async fn track_stream(&self, track_id: &str, format_id: u8) -> Result { - let session = self.stream_session().await?; - match self.file_url(&session, track_id, format_id).await { - Ok(stream) => Ok(stream), - Err(e) if e.downcast_ref::().is_some() => Err(e), - Err(_) => { - *shared_session().lock().await = None; - let session = self.stream_session().await?; - self.file_url(&session, track_id, format_id).await - } - } - } - - /// Start a progressive stream of a track: segment 0 is fetched, so the - /// total size and the delivered format are known before any audio. - pub async fn begin_stream( - &self, - track_id: &str, - format_id: u8, - ) -> Result<(InitSegment, SegmentStream)> { - let stream = self.track_stream(track_id, format_id).await?; - let init = download::fetch_init(&self.http, &stream.url_template).await?; - let segments = SegmentStream::new( - self.http.clone(), - stream.url_template, - stream.content_key, - &init, - ); - Ok((init, segments)) - } - - // ------------------------------------------------------------------------- - // Catalog - // ------------------------------------------------------------------------- - - async fn user_playlists(&self) -> Result> { - paginate(|offset| async move { - let page: types::UserPlaylists = self - .get( - "playlist/getUserPlaylists", - &[ - ("limit", PAGE_LIMIT.to_string()), - ("offset", offset.to_string()), - ], - ) - .await?; - Ok((page.playlists.items, page.playlists.total as usize)) - }) - .await - } - - async fn favorite_albums(&self) -> Result> { - paginate(|offset| async move { - let page: types::Favorites = self - .get( - "favorite/getUserFavorites", - &[ - ("type", "albums".to_string()), - ("limit", PAGE_LIMIT.to_string()), - ("offset", offset.to_string()), - ], - ) - .await?; - let albums = page.albums.unwrap_or_default(); - Ok((albums.items, albums.total as usize)) - }) - .await - } - - async fn favorite_track_count(&self) -> Result { - let page: types::Favorites = self - .get( - "favorite/getUserFavorites", - &[ - ("type", "tracks".to_string()), - ("limit", "1".to_string()), - ("offset", "0".to_string()), - ], - ) - .await?; - Ok(page.tracks.map(|t| t.total).unwrap_or(0)) - } - - /// One page of a listing: the tracks plus the album context, when any. - async fn listing_page( - &self, - listing: &Listing, - offset: usize, - ) -> Result<(types::Page, Option)> { - let limit = PAGE_LIMIT.to_string(); - let offset = offset.to_string(); - match listing { - Listing::Favorites => { - let page: types::Favorites = self - .get( - "favorite/getUserFavorites", - &[ - ("type", "tracks".to_string()), - ("limit", limit), - ("offset", offset), - ], - ) - .await?; - Ok((page.tracks.unwrap_or_default(), None)) - } - Listing::Playlist(id) => { - let playlist: types::Playlist = self - .get( - "playlist/get", - &[ - ("playlist_id", id.clone()), - ("extra", "tracks".to_string()), - ("limit", limit), - ("offset", offset), - ], - ) - .await?; - Ok((playlist.tracks.unwrap_or_default(), None)) - } - Listing::Album(id) => { - let mut album: types::Album = self - .get( - "album/get", - &[ - ("album_id", id.clone()), - ("limit", limit), - ("offset", offset), - ], - ) - .await?; - let tracks = album.tracks.take().unwrap_or_default(); - Ok((tracks, Some(album))) - } - } - } - - async fn listing_tracks(&self, listing: &Listing) -> Result> { - paginate(|offset| async move { - let (page, album) = self.listing_page(listing, offset).await?; - let tracks = page - .items - .iter() - .map(|t| track_to_track_info(t, album.as_ref())) - .collect(); - Ok((tracks, page.total as usize)) - }) - .await - } -} - -/// Collect every page of a listing; `fetch(offset)` returns one page's items -/// and the listing's total. -async fn paginate(mut fetch: F) -> Result> -where - F: FnMut(usize) -> Fut, - Fut: std::future::Future, usize)>>, -{ - let mut out = Vec::new(); - loop { - let (items, total) = fetch(out.len()).await?; - let got = items.len(); - out.extend(items); - if got == 0 || out.len() >= total || out.len() >= MAX_ITEMS { - return Ok(out); - } - } -} - -// --------------------------------------------------------------------------- -// URIs and domain type conversions -// --------------------------------------------------------------------------- - -/// Strip the `qobuz:track:` prefix and return the raw track id. -pub fn track_id_from_uri(uri: &str) -> Result<&str> { - uri - .strip_prefix(TRACK_PREFIX) - .ok_or_else(|| anyhow!("Not a qobuz track URI: {}", uri)) -} - -fn listing_from_uri(uri: &str) -> Result { - if uri == FAVORITES_URI { - Ok(Listing::Favorites) - } else if let Some(id) = uri.strip_prefix(PLAYLIST_PREFIX) { - Ok(Listing::Playlist(id.to_string())) - } else if let Some(id) = uri.strip_prefix(ALBUM_PREFIX) { - Ok(Listing::Album(id.to_string())) - } else { - Err(anyhow!("Not a qobuz playlist URI: {}", uri)) - } -} - -fn favorites_playlist(track_count: u32) -> PlaylistInfo { - PlaylistInfo { - uri: FAVORITES_URI.to_string(), - name: "Favorite tracks".to_string(), - owner: String::new(), - track_count, - id: None, - owner_id: None, - collaborative: false, - public: Some(false), - image_url: None, - } -} - -fn playlist_to_playlist_info(p: &types::Playlist) -> PlaylistInfo { - PlaylistInfo { - uri: format!("{PLAYLIST_PREFIX}{}", p.id), - name: p.name.clone(), - owner: p.owner.as_ref().map(|o| o.name.clone()).unwrap_or_default(), - track_count: p.tracks_count, - id: Some(p.id.clone()), - owner_id: p.owner.as_ref().and_then(|o| o.id.clone()), - collaborative: false, - public: p.is_public, - image_url: p.images300.first().cloned(), - } -} - -fn album_to_playlist_info(a: &types::Album) -> PlaylistInfo { - let artist = a.artist.as_ref().map(|n| n.name.as_str()).unwrap_or(""); - PlaylistInfo { - uri: format!("{ALBUM_PREFIX}{}", a.id), - name: if artist.is_empty() { - a.title.clone() - } else { - format!("{} - {artist}", a.title) - }, - owner: artist.to_string(), - track_count: a.tracks_count, - id: Some(a.id.clone()), - owner_id: None, - collaborative: false, - public: Some(true), - image_url: a.image.large.clone(), - } -} - -/// Map a Qobuz track onto [`TrackInfo`]; `parent` is the album of an `album/get`. -fn track_to_track_info(t: &types::Track, parent: Option<&types::Album>) -> TrackInfo { - let album = t.album.as_ref().or(parent); - let performer = t - .performer - .as_ref() - .or(album.and_then(|a| a.artist.as_ref())); - let artist_refs: Vec = performer - .filter(|n| !n.name.is_empty()) - .map(|n| { - vec![ArtistRef { - id: n.id.clone(), - name: n.name.clone(), - }] - }) - .unwrap_or_default(); - let name = match t.version.as_deref().filter(|v| !v.is_empty()) { - Some(version) => format!("{} ({version})", t.title), - None => t.title.clone(), - }; - TrackInfo { - uri: Some(format!("{TRACK_PREFIX}{}", t.id)), - name, - artists: artist_refs.iter().map(|a| a.name.clone()).collect(), - album: album.map(|a| a.title.clone()).unwrap_or_default(), - duration_ms: t.duration * 1000, - id: Some(t.id.clone()), - album_id: album.map(|a| a.id.clone()), - artist_refs, - is_playable: t.streamable, - is_local: false, - track_number: t.track_number, - explicit: t.parental_warning, - image_url: album.and_then(|a| a.image.large.clone()), - } -} - -// --------------------------------------------------------------------------- -// Trait implementations -// --------------------------------------------------------------------------- - -impl MediaSource for QobuzSource { - fn name(&self) -> &str { - "Qobuz" - } - - fn scheme(&self) -> &str { - "qobuz" - } - - /// Favorite tracks, then the user's playlists, then favorite albums. - async fn playlists(&self) -> Result> { - let (favorite_count, playlists, albums) = tokio::try_join!( - self.favorite_track_count(), - self.user_playlists(), - self.favorite_albums() - )?; - let mut out = vec![favorites_playlist(favorite_count)]; - out.extend(playlists.iter().map(playlist_to_playlist_info)); - out.extend(albums.iter().map(album_to_playlist_info)); - Ok(out) - } - - async fn tracks(&self, playlist_uri: &str) -> Result> { - let listing = listing_from_uri(playlist_uri)?; - self.listing_tracks(&listing).await - } -} - -impl Searcher for QobuzSource { - /// Tracks only: album and artist rows would route to Spotify-bound events. - async fn search(&self, query: &str) -> Result { - let found: types::Search = self - .get( - "catalog/search", - &[ - ("query", query.to_string()), - ("limit", SEARCH_LIMIT.to_string()), - ], - ) - .await?; - let tracks = found - .tracks - .unwrap_or_default() - .items - .iter() - .map(|t| track_to_track_info(t, None)) - .collect(); - Ok(SearchResults { - tracks, - albums: vec![], - artists: vec![], - playlists: vec![], - shows: vec![], - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - const USER_PLAYLISTS: &str = r#"{ - "playlists": { - "offset": 0, "limit": 500, "total": 2, - "items": [ - { "id": 111, "name": "Morning", "owner": { "id": 9, "name": "jay" }, - "tracks_count": 12, "is_public": false, "images300": ["https://img/1.jpg"] }, - { "id": 222, "name": "Evening", "owner": { "id": 9, "name": "jay" }, - "tracks_count": 3, "is_public": true } - ] - } - }"#; - - const PLAYLIST_TRACKS: &str = r#"{ - "id": 111, "name": "Morning", - "tracks": { - "offset": 0, "limit": 500, "total": 1, - "items": [ - { "id": 5001, "title": "Around the World", "version": "Radio Edit", - "duration": 429, "track_number": 7, - "performer": { "id": 36819, "name": "Daft Punk" }, - "album": { "id": "0060254730301", "title": "Homework", - "artist": { "id": 36819, "name": "Daft Punk" }, - "image": { "large": "https://img/large.jpg", "small": "https://img/s.jpg" } }, - "streamable": true, "parental_warning": false } - ] - } - }"#; - - const ALBUM: &str = r#"{ - "id": "0060254730301", "title": "Homework", "tracks_count": 2, - "artist": { "id": 36819, "name": "Daft Punk" }, - "image": { "large": "https://img/album.jpg" }, - "tracks": { "total": 2, "items": [ - { "id": 1, "title": "Daftendirekt", "duration": 164, "track_number": 1 }, - { "id": 2, "title": "WDPK 83.7 FM", "duration": 28, "track_number": 2, "streamable": false } - ] } - }"#; - - const FAVORITES: &str = r#"{ - "albums": { "total": 1, "items": [ - { "id": "abc", "title": "Discovery", "artist": { "id": 36819, "name": "Daft Punk" }, - "tracks_count": 14, "image": { "large": "https://img/d.jpg" } } - ] } - }"#; - - const SESSION_START: &str = r#"{ "session_id": "sess-1", "expires_at": 1700000000, - "infos": "c2FsdA.aW5mbw" }"#; - - const FILE_URL: &str = r#"{ "url_template": "https://cdn/$SEGMENT$.m4s", "n_segments": 3, - "key": "p.d3JhcHBlZA.aXY", "mime_type": "audio/flac", "format_id": 27 }"#; - - #[test] - fn user_playlists_map_to_playlist_info() { - let page: types::UserPlaylists = serde_json::from_str(USER_PLAYLISTS).unwrap(); - assert_eq!(page.playlists.total, 2); - let info = playlist_to_playlist_info(&page.playlists.items[0]); - assert_eq!(info.uri, "qobuz:playlist:111"); - assert_eq!(info.name, "Morning"); - assert_eq!(info.owner, "jay"); - assert_eq!(info.owner_id.as_deref(), Some("9")); - assert_eq!(info.track_count, 12); - assert_eq!(info.public, Some(false)); - assert_eq!(info.image_url.as_deref(), Some("https://img/1.jpg")); - assert!(playlist_to_playlist_info(&page.playlists.items[1]) - .image_url - .is_none()); - } - - #[test] - fn playlist_tracks_map_to_track_info() { - let playlist: types::Playlist = serde_json::from_str(PLAYLIST_TRACKS).unwrap(); - let tracks = playlist.tracks.unwrap(); - let track = track_to_track_info(&tracks.items[0], None); - assert_eq!(track.uri.as_deref(), Some("qobuz:track:5001")); - assert_eq!(track.name, "Around the World (Radio Edit)"); - assert_eq!(track.artists, vec!["Daft Punk"]); - assert_eq!(track.album, "Homework"); - assert_eq!(track.duration_ms, 429_000); - assert_eq!(track.track_number, 7); - assert_eq!(track.id.as_deref(), Some("5001")); - assert_eq!(track.album_id.as_deref(), Some("0060254730301")); - assert_eq!(track.artist_refs[0].id.as_deref(), Some("36819")); - assert_eq!(track.image_url.as_deref(), Some("https://img/large.jpg")); - assert!(track.is_playable); - assert!(!track.explicit); - } - - #[test] - fn album_tracks_take_the_parent_album_as_context() { - let album: types::Album = serde_json::from_str(ALBUM).unwrap(); - let tracks = album.tracks.as_ref().unwrap(); - let first = track_to_track_info(&tracks.items[0], Some(&album)); - assert_eq!(first.album, "Homework"); - assert_eq!(first.artists, vec!["Daft Punk"]); - assert_eq!(first.image_url.as_deref(), Some("https://img/album.jpg")); - let second = track_to_track_info(&tracks.items[1], Some(&album)); - assert!(!second.is_playable); - let row = album_to_playlist_info(&album); - assert_eq!(row.uri, "qobuz:album:0060254730301"); - assert_eq!(row.name, "Homework - Daft Punk"); - assert_eq!(row.track_count, 2); - } - - #[test] - fn favorite_albums_parse_and_favorites_row_is_first() { - let favorites: types::Favorites = serde_json::from_str(FAVORITES).unwrap(); - assert!(favorites.tracks.is_none()); - let albums = favorites.albums.unwrap(); - assert_eq!( - album_to_playlist_info(&albums.items[0]).uri, - "qobuz:album:abc" - ); - let row = favorites_playlist(7); - assert_eq!(row.uri, FAVORITES_URI); - assert_eq!(row.track_count, 7); - } - - #[test] - fn session_start_and_file_url_parse() { - let session: types::SessionStart = serde_json::from_str(SESSION_START).unwrap(); - assert_eq!(session.session_id, "sess-1"); - let (salt, info) = session.infos.split_once('.').unwrap(); - assert_eq!(auth::decode_base64(salt).unwrap(), b"salt"); - assert_eq!(auth::decode_base64(info).unwrap(), b"info"); - let file: types::FileUrl = serde_json::from_str(FILE_URL).unwrap(); - assert_eq!(file.url_template, "https://cdn/$SEGMENT$.m4s"); - assert_eq!(file.key.split('.').count(), 3); - } - - #[test] - fn listing_from_uri_covers_every_sidebar_shape() { - assert!(matches!( - listing_from_uri(FAVORITES_URI), - Ok(Listing::Favorites) - )); - assert!(matches!(listing_from_uri("qobuz:playlist:5"), Ok(Listing::Playlist(id)) if id == "5")); - assert!(matches!(listing_from_uri("qobuz:album:x"), Ok(Listing::Album(id)) if id == "x")); - assert!(listing_from_uri("spotify:playlist:5").is_err()); - assert_eq!(track_id_from_uri("qobuz:track:9").unwrap(), "9"); - assert!(track_id_from_uri("qobuz:album:9").is_err()); - } - - #[test] - fn session_validity_keeps_a_renewal_margin() { - let session = StreamSession { - session_id: String::new(), - expires_at: 1_000, - session_key: [0; 16], - }; - assert!(session.is_valid_at(1_000 - SESSION_MARGIN_SECS - 1)); - assert!(!session.is_valid_at(1_000 - SESSION_MARGIN_SECS)); - } - - /// Live end to end: scrape the bundle, start a session, stream one track - /// through the shared sink while it downloads, and seek ahead. Needs - /// `SPOTATUI_QOBUZ_TOKEN` (and `SPOTATUI_QOBUZ_TEST_FORMAT`, default 27). Run: - /// `cargo test --features qobuz -- --ignored live_qobuz --nocapture` - #[tokio::test] - #[ignore = "needs SPOTATUI_QOBUZ_TOKEN, the network, and an audio output device"] - async fn live_qobuz_download_plays_through_sink() { - use crate::infra::audio::LocalPlayer; - use std::time::Instant; - - let Some(token) = auth::load_token() else { - eprintln!("no token: set {}", auth::TOKEN_ENV); - return; - }; - let format: u8 = std::env::var("SPOTATUI_QOBUZ_TEST_FORMAT") - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(27); - - let http = shared_qobuz_client(); - let constants = auth::resolve_constants(&http, None) - .await - .expect("bundle scrape"); - eprintln!( - "bundle {} app_id {}", - constants.bundle_version, constants.app_id - ); - let source = QobuzSource::new(constants.app_id, constants.app_secret, token); - - let playlists = source.playlists().await.expect("playlists"); - eprintln!("{} sidebar rows", playlists.len()); - let results = source.search("Daft Punk").await.expect("search"); - let track = results.tracks.first().expect("a search hit"); - let track_id = track_id_from_uri(track.uri.as_deref().unwrap()).unwrap(); - eprintln!("track {} ({})", track.name, track_id); - - let tmp = tempfile::NamedTempFile::new().unwrap(); - let started = Instant::now(); - let (init, segments) = source - .begin_stream(track_id, format) - .await - .expect("session start + file url + init segment"); - let total = init.total_bytes(); - eprintln!( - "init after {:?}, total {} bytes, {}", - started.elapsed(), - total, - init.quality().label() - ); - let reader = stream::progressive::open(segments, &tmp) - .await - .expect("open stream"); - let mime = if init.quality().flac { - "audio/flac" - } else { - "audio/mpeg" - }; - let prepared = tokio::task::spawn_blocking(move || { - LocalPlayer::prepare_stream(reader, Some(mime), Some(total)) - }) - .await - .unwrap() - .expect("decode the stream head"); - eprintln!("decoder ready after {:?}", started.elapsed()); - - let player = LocalPlayer::new().expect("open default output device"); - player.play_prepared(prepared).unwrap(); - eprintln!("playing after {:?}", started.elapsed()); - tokio::time::sleep(Duration::from_millis(600)).await; - assert!( - player.position() >= Duration::from_millis(200), - "playback position should advance, got {:?}", - player.position() - ); - // A seek past the downloaded part restarts the stream at that segment. - let seek_started = Instant::now(); - tokio::task::spawn_blocking(move || { - player.seek(Duration::from_secs(90)).expect("seek ahead"); - eprintln!("seek to 90 s took {:?}", seek_started.elapsed()); - std::thread::sleep(Duration::from_millis(600)); - assert!( - player.position() >= Duration::from_secs(90), - "position after seek: {:?}", - player.position() - ); - player.stop(); - }) - .await - .unwrap(); - - if format != 5 { - let bytes = std::fs::read(tmp.path()).unwrap(); - assert!(bytes.starts_with(b"fLaC"), "got {:02X?}", &bytes[..4]); - } - } -} diff --git a/src/infra/qobuz/sign.rs b/src/infra/qobuz/sign.rs deleted file mode 100644 index bc2f2714..00000000 --- a/src/infra/qobuz/sign.rs +++ /dev/null @@ -1,65 +0,0 @@ -//! Request signature for the signed Qobuz endpoints. -//! -//! `request_sig` is the lowercase hex MD5 of ``, -//! where `` is the endpoint with its slash removed and `` -//! is every query argument as `key` + `value`, keys sorted, no separators. - -use md5::{Digest, Md5}; - -/// The `request_sig` value for `endpoint` (for example `"session/start"`). -pub fn request_sig(endpoint: &str, args: &[(&str, &str)], request_ts: u64, secret: &str) -> String { - let mut sorted: Vec<&(&str, &str)> = args.iter().collect(); - sorted.sort_by(|a, b| a.0.cmp(b.0)); - let mut input = endpoint.replace('/', ""); - for (key, value) in sorted { - input.push_str(key); - input.push_str(value); - } - input.push_str(&request_ts.to_string()); - input.push_str(secret); - hex::encode(Md5::digest(input.as_bytes())) -} - -#[cfg(test)] -mod tests { - use super::*; - - const SECRET: &str = "abcdef0123456789abcdef0123456789"; - - #[test] - fn session_start_signature_matches_precomputed_md5() { - let sig = request_sig( - "session/start", - &[("profile", "qbz-1")], - 1_700_000_000, - SECRET, - ); - assert_eq!(sig, "37a4a7a6ca79344aaf11aa0d3b05e677"); - } - - #[test] - fn arguments_are_sorted_by_key_before_hashing() { - let ordered = request_sig( - "file/url", - &[ - ("format_id", "27"), - ("intent", "stream"), - ("track_id", "123"), - ], - 1_700_000_000, - SECRET, - ); - let reversed = request_sig( - "file/url", - &[ - ("track_id", "123"), - ("intent", "stream"), - ("format_id", "27"), - ], - 1_700_000_000, - SECRET, - ); - assert_eq!(ordered, "7ea61d336e3ff5c0c46b3d984ad36834"); - assert_eq!(reversed, ordered); - } -} diff --git a/src/infra/qobuz/stream/cmaf.rs b/src/infra/qobuz/stream/cmaf.rs deleted file mode 100644 index 48db0044..00000000 --- a/src/infra/qobuz/stream/cmaf.rs +++ /dev/null @@ -1,495 +0,0 @@ -//! Parsers for the Qobuz CMAF segments, pure over `&[u8]`. -//! -//! Segment 0 carries a `uuid` box ([`INIT_UUID`]) with the raw FLAC header and a -//! table of per-segment byte lengths. Every later segment carries a `uuid` box -//! ([`SEGMENT_UUID`]) with one entry per frame (length, flags, IV) and the frames -//! themselves at `data_offset` from the box start. Only frames whose flags are -//! not zero are encrypted. - -use anyhow::{anyhow, Result}; - -use super::crypto::decrypt_frame; - -/// Box type of the init payload. -pub const INIT_UUID: [u8; 16] = [ - 0xc7, 0xc7, 0x5d, 0xf0, 0xfd, 0xd9, 0x51, 0xe9, 0x8f, 0xc2, 0x29, 0x71, 0xe4, 0xac, 0xf8, 0xd2, -]; -/// Box type of the per-segment frame table. -pub const SEGMENT_UUID: [u8; 16] = [ - 0x3b, 0x42, 0x12, 0x92, 0x56, 0xf3, 0x5f, 0x75, 0x92, 0x36, 0x63, 0xb6, 0x9a, 0x1f, 0x52, 0xb2, -]; - -/// The parsed init segment. -#[derive(Debug, Clone, PartialEq)] -pub struct InitSegment { - /// The codec header to write first (a FLAC header with the last-block bit set). - pub header: Vec, - /// Byte length of each audio segment, in order (segment 1 first). - pub segment_lengths: Vec, - pub sample_rate: u32, - pub bits_per_sample: u8, -} - -impl InitSegment { - /// The size of the finished file: header plus every segment. - pub fn total_bytes(&self) -> u64 { - self.header.len() as u64 + self.segment_lengths.iter().map(|&l| l as u64).sum::() - } - - /// The delivered audio format, which can be lower than the requested one. - pub fn quality(&self) -> StreamQuality { - StreamQuality { - sample_rate: self.sample_rate, - bits_per_sample: self.bits_per_sample, - flac: self.header.starts_with(b"fLaC"), - } - } -} - -/// The audio format of a delivered stream, read from segment 0. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct StreamQuality { - pub sample_rate: u32, - pub bits_per_sample: u8, - /// `false` for the MP3 format, the only non-FLAC one Qobuz serves. - pub flac: bool, -} - -impl StreamQuality { - /// Playbar label, worded like the quality setting: `FLAC 24/96` or `MP3 320`. - pub fn label(&self) -> String { - if !self.flac { - return "MP3 320".to_string(); - } - let khz = if self.sample_rate.is_multiple_of(1000) { - (self.sample_rate / 1000).to_string() - } else { - format!("{:.1}", self.sample_rate as f64 / 1000.0) - }; - format!("FLAC {}/{khz}", self.bits_per_sample) - } -} - -/// One frame entry of an audio segment. -#[derive(Debug, Clone, PartialEq)] -pub struct FrameEntry { - pub len: usize, - pub flags: u16, - pub iv: Vec, -} - -/// The frame table of an audio segment. -#[derive(Debug, Clone, PartialEq)] -pub struct SegmentTable { - /// Offset of the first frame in the segment buffer. - pub frames_start: usize, - pub frames: Vec, -} - -impl SegmentTable { - /// Offset one past the last frame. - pub fn frames_end(&self) -> usize { - self.frames_start + self.frames.iter().map(|f| f.len).sum::() - } -} - -struct Reader<'a> { - buf: &'a [u8], - pos: usize, -} - -impl<'a> Reader<'a> { - fn take(&mut self, n: usize) -> Result<&'a [u8]> { - let end = self - .pos - .checked_add(n) - .filter(|&end| end <= self.buf.len()) - .ok_or_else(|| anyhow!("segment truncated at byte {}", self.pos))?; - let out = &self.buf[self.pos..end]; - self.pos = end; - Ok(out) - } - - fn be(&mut self, n: usize) -> Result { - Ok( - self - .take(n)? - .iter() - .fold(0u64, |acc, &b| (acc << 8) | b as u64), - ) - } - - fn u8(&mut self) -> Result { - Ok(self.be(1)? as u8) - } - - fn u16(&mut self) -> Result { - Ok(self.be(2)? as u16) - } - - fn u32(&mut self) -> Result { - Ok(self.be(4)? as u32) - } -} - -/// Start offset and payload offset of the top-level `uuid` box with type `uuid`. -fn find_uuid_box(buf: &[u8], uuid: &[u8; 16]) -> Result<(usize, usize)> { - let mut pos = 0usize; - while pos.checked_add(8).is_some_and(|end| end <= buf.len()) { - let mut r = Reader { buf, pos }; - let size32 = r.u32()?; - let kind = r.take(4)?; - let remaining = buf.len() - pos; - let (header_len, size) = match size32 { - 0 => (8usize, remaining), - 1 => (16usize, usize::try_from(r.be(8)?).unwrap_or(usize::MAX)), - n => (8usize, n as usize), - }; - // A box that runs past the buffer cannot hold the target; this also keeps - // `pos + size` from overflowing. - if size < header_len || size > remaining { - return Err(anyhow!("invalid box size {size} at byte {pos}")); - } - if kind == b"uuid" && r.take(16).is_ok_and(|found| found == uuid) { - // Payload = box header, 16-byte type, 4 bytes of version and flags. - return Ok((pos, pos + header_len + 16 + 4)); - } - pos += size; - } - Err(anyhow!("no {} box in segment", hex::encode(uuid))) -} - -/// Parse segment 0 into the codec header and the segment length table. -pub fn parse_init(buf: &[u8]) -> Result { - let (_, payload) = find_uuid_box(buf, &INIT_UUID)?; - let mut r = Reader { buf, pos: payload }; - let _track_id = r.u32()?; - let _file_id = r.u32()?; - let sample_rate = r.u32()?; - let bits_per_sample = r.u8()?; - let _channels = r.take(3)?; - let _samples_count = r.be(6)?; - let header_len = r.u16()? as usize; - let mut header = r.take(header_len)?.to_vec(); - let key_id_len = r.u8()? as usize; - let _key_id = r.take(key_id_len)?; - let segment_count = r.u16()? as usize; - let mut segment_lengths = Vec::with_capacity(segment_count); - for _ in 0..segment_count { - segment_lengths.push(r.u32()?); - let _samples = r.u32()?; - } - mark_last_metadata_block(&mut header)?; - Ok(InitSegment { - header, - segment_lengths, - sample_rate, - bits_per_sample, - }) -} - -/// Set the last-metadata-block flag on the final block of a FLAC header. -fn mark_last_metadata_block(header: &mut [u8]) -> Result<()> { - if !header.starts_with(b"fLaC") { - return Ok(()); - } - let mut pos = 4usize; - loop { - let block = header - .get(pos..pos + 4) - .ok_or_else(|| anyhow!("FLAC header truncated at byte {pos}"))?; - if block[0] & 0x80 != 0 { - return Ok(()); - } - let len = ((block[1] as usize) << 16) | ((block[2] as usize) << 8) | block[3] as usize; - let next = pos + 4 + len; - if next >= header.len() { - header[pos] |= 0x80; - return Ok(()); - } - pos = next; - } -} - -/// Parse the frame table of an audio segment (segments 1 and later). -pub fn parse_segment(buf: &[u8]) -> Result { - let (box_start, payload) = find_uuid_box(buf, &SEGMENT_UUID)?; - let mut r = Reader { buf, pos: payload }; - let data_offset = r.u32()? as usize; - let iv_size = r.u8()? as usize; - let frame_count = r.be(3)? as usize; - let mut frames = Vec::with_capacity(frame_count.min(4096)); - for _ in 0..frame_count { - let len = r.u32()? as usize; - let _reserved = r.u16()?; - let flags = r.u16()?; - let iv = r.take(iv_size)?.to_vec(); - frames.push(FrameEntry { len, flags, iv }); - } - let table = SegmentTable { - frames_start: box_start + data_offset, - frames, - }; - if table.frames_end() > buf.len() { - return Err(anyhow!( - "frame table ends at byte {} but the segment holds {} bytes", - table.frames_end(), - buf.len() - )); - } - Ok(table) -} - -/// Decrypt every flagged frame in place; returns the byte range of all frames. -pub fn decrypt_frames( - buf: &mut [u8], - table: &SegmentTable, - key: &[u8; 16], -) -> Result> { - let mut pos = table.frames_start; - for (i, frame) in table.frames.iter().enumerate() { - let end = pos + frame.len; - let data = buf - .get_mut(pos..end) - .ok_or_else(|| anyhow!("frame {i} runs past the segment"))?; - if frame.flags != 0 { - decrypt_frame(key, &frame.iv, data)?; - } - pos = end; - } - Ok(table.frames_start..pos) -} - -/// Segment builders in the live byte layout, shared with the download tests. -#[cfg(test)] -pub(super) mod fixtures { - use super::*; - - pub fn mp4_box(kind: &[u8; 4], payload: &[u8]) -> Vec { - let mut out = ((payload.len() + 8) as u32).to_be_bytes().to_vec(); - out.extend_from_slice(kind); - out.extend_from_slice(payload); - out - } - - pub fn uuid_box(uuid: &[u8; 16], payload: &[u8]) -> Vec { - let mut body = uuid.to_vec(); - body.extend_from_slice(&[0, 0, 0, 0]); - body.extend_from_slice(payload); - mp4_box(b"uuid", &body) - } - - /// A 42-byte `fLaC` + STREAMINFO header without the last-block bit. - pub fn flac_header() -> Vec { - let mut h = b"fLaC".to_vec(); - h.extend_from_slice(&[0x00, 0, 0, 34]); - h.extend((0u8..34).map(|i| i.wrapping_mul(7))); - h - } - - pub fn init_payload(header: &[u8], lengths: &[u32]) -> Vec { - let mut p = Vec::new(); - p.extend_from_slice(&7u32.to_be_bytes()); // track_id - p.extend_from_slice(&9u32.to_be_bytes()); // file_id - p.extend_from_slice(&96_000u32.to_be_bytes()); - p.push(24); // bits - p.extend_from_slice(&[2, 0, 0]); // channels + padding - p.extend_from_slice(&1_234_567u64.to_be_bytes()[2..]); // 6-byte samples - p.extend_from_slice(&(header.len() as u16).to_be_bytes()); - p.extend_from_slice(header); - p.push(3); - p.extend_from_slice(b"kid"); - p.extend_from_slice(&(lengths.len() as u16).to_be_bytes()); - for (i, len) in lengths.iter().enumerate() { - p.extend_from_slice(&len.to_be_bytes()); - p.extend_from_slice(&(4096 * (i as u32 + 1)).to_be_bytes()); - } - p - } - - /// Segment 0: an `ftyp` box then the init `uuid` box. - pub fn init_segment(header: &[u8], lengths: &[u32]) -> Vec { - let mut seg = mp4_box(b"ftyp", b"cmfc"); - seg.extend(uuid_box(&INIT_UUID, &init_payload(header, lengths))); - seg - } - - /// An audio segment from `(plain bytes, flags, iv)` frames; flagged frames are - /// encrypted with `key`. Returns the segment and the expected plain output. - pub fn audio_segment(frames: &[(Vec, u16, [u8; 8])], key: &[u8; 16]) -> (Vec, Vec) { - let mut table = Vec::new(); - let mut data = Vec::new(); - let mut plain = Vec::new(); - for (bytes, flags, iv) in frames { - table.extend_from_slice(&(bytes.len() as u32).to_be_bytes()); - table.extend_from_slice(&[0, 0]); - table.extend_from_slice(&flags.to_be_bytes()); - table.extend_from_slice(iv); - let mut on_wire = bytes.clone(); - if *flags != 0 { - decrypt_frame(key, iv, &mut on_wire).unwrap(); - } - data.extend_from_slice(&on_wire); - plain.extend_from_slice(bytes); - } - let mut payload = Vec::new(); - // uuid box = 8 header + 16 uuid + 4 flags + 8 fixed + table; mdat header = 8. - let uuid_len = 8 + 16 + 4 + 8 + table.len(); - payload.extend_from_slice(&((uuid_len + 8) as u32).to_be_bytes()); - payload.push(8); - payload.extend_from_slice(&(frames.len() as u32).to_be_bytes()[1..]); - payload.extend_from_slice(&table); - let mut seg = mp4_box(b"moof", b""); - seg.extend(uuid_box(&SEGMENT_UUID, &payload)); - seg.extend(mp4_box(b"mdat", &data)); - (seg, plain) - } -} - -#[cfg(test)] -mod tests { - use super::fixtures::*; - use super::*; - - fn init_segment(lengths: &[u32]) -> Vec { - fixtures::init_segment(&flac_header(), lengths) - } - - #[test] - fn init_parses_header_and_length_table() { - let init = parse_init(&init_segment(&[100, 200, 300])).unwrap(); - assert_eq!(init.header.len(), 42); - assert_eq!(&init.header[..4], b"fLaC"); - assert_eq!(init.header[4], 0x80, "last-metadata-block bit is set"); - assert_eq!(&init.header[5..], &flac_header()[5..]); - assert_eq!(init.segment_lengths, vec![100, 200, 300]); - assert_eq!(init.total_bytes(), 642); - assert_eq!(init.sample_rate, 96_000); - assert_eq!(init.bits_per_sample, 24); - assert_eq!(init.quality().label(), "FLAC 24/96"); - } - - #[test] - fn quality_label_matches_the_setting_wording() { - let flac = |sample_rate, bits_per_sample| StreamQuality { - sample_rate, - bits_per_sample, - flac: true, - }; - assert_eq!(flac(44_100, 16).label(), "FLAC 16/44.1"); - assert_eq!(flac(88_200, 24).label(), "FLAC 24/88.2"); - assert_eq!(flac(192_000, 24).label(), "FLAC 24/192"); - let mp3 = StreamQuality { - flac: false, - ..flac(44_100, 16) - }; - assert_eq!(mp3.label(), "MP3 320"); - } - - #[test] - fn oversized_boxes_are_errors_without_panic() { - // A largesize box (size32 == 1) claiming u64::MAX bytes. - let mut huge = 1u32.to_be_bytes().to_vec(); - huge.extend_from_slice(b"free"); - huge.extend_from_slice(&u64::MAX.to_be_bytes()); - huge.extend(init_segment(&[1])); - assert!(parse_init(&huge).is_err()); - // A plain box whose size runs past the buffer. - let mut long = 4096u32.to_be_bytes().to_vec(); - long.extend_from_slice(b"free"); - long.extend(init_segment(&[1])); - assert!(parse_init(&long).is_err()); - assert!(parse_segment(&huge).is_err()); - } - - #[test] - fn init_leaves_an_already_final_block_alone() { - let mut header = flac_header(); - header[4] = 0x80; - let mut seg = mp4_box(b"ftyp", b"cmfc"); - seg.extend(uuid_box(&INIT_UUID, &init_payload(&header, &[1]))); - assert_eq!(parse_init(&seg).unwrap().header, header); - } - - #[test] - fn init_marks_the_last_of_several_metadata_blocks() { - let mut header = flac_header(); - header.extend_from_slice(&[0x04, 0, 0, 2, 0xaa, 0xbb]); // VORBIS_COMMENT - let mut seg = mp4_box(b"ftyp", b"cmfc"); - seg.extend(uuid_box(&INIT_UUID, &init_payload(&header, &[1]))); - let parsed = parse_init(&seg).unwrap().header; - assert_eq!(parsed[4], 0x00); - assert_eq!(parsed[42], 0x84); - } - - #[test] - fn init_leaves_a_non_flac_header_untouched() { - let header = vec![0xff, 0xfb, 0x90, 0x00, 0x11, 0x22]; - let mut seg = mp4_box(b"ftyp", b"cmfc"); - seg.extend(uuid_box(&INIT_UUID, &init_payload(&header, &[1]))); - assert_eq!(parse_init(&seg).unwrap().header, header); - } - - #[test] - fn init_parses_a_300_entry_table() { - let lengths: Vec = (1..=300).collect(); - let init = parse_init(&init_segment(&lengths)).unwrap(); - assert_eq!(init.segment_lengths, lengths); - } - - #[test] - fn truncated_init_returns_an_error_without_panic() { - let full = init_segment(&[100, 200, 300]); - for cut in 0..full.len() { - assert!(parse_init(&full[..cut]).is_err(), "prefix of {cut} bytes"); - } - } - - #[test] - fn init_without_the_uuid_box_is_an_error() { - let seg = mp4_box(b"ftyp", b"cmfc"); - assert!(parse_init(&seg).is_err()); - } - - const KEY: [u8; 16] = [0x5a; 16]; - - #[test] - fn segment_decrypts_flagged_frames_and_passes_others_through() { - let frames = vec![ - ((0u8..40).collect(), 1u16, [1u8, 2, 3, 4, 5, 6, 7, 8]), - ((100u8..130).collect(), 0u16, [0u8; 8]), - ]; - let (mut seg, plain) = audio_segment(&frames, &KEY); - let table = parse_segment(&seg).unwrap(); - assert_eq!(table.frames.len(), 2); - assert_eq!(table.frames[0].flags, 1); - assert_eq!(table.frames[0].iv, vec![1, 2, 3, 4, 5, 6, 7, 8]); - assert_eq!(table.frames[1].len, 30); - assert_ne!( - &seg[table.frames_start..table.frames_start + 40], - &plain[..40] - ); - let range = decrypt_frames(&mut seg, &table, &KEY).unwrap(); - assert_eq!(&seg[range], &plain[..]); - } - - #[test] - fn truncated_segment_returns_an_error_without_panic() { - let frames = vec![((0u8..40).collect(), 1u16, [9u8; 8])]; - let (full, _) = audio_segment(&frames, &KEY); - for cut in 0..full.len() { - assert!( - parse_segment(&full[..cut]).is_err(), - "prefix of {cut} bytes" - ); - } - } - - #[test] - fn find_uuid_box_skips_a_box_with_a_different_uuid() { - let other = [0x99u8; 16]; - let mut seg = uuid_box(&other, &[0; 8]); - seg.extend(uuid_box(&INIT_UUID, &init_payload(&flac_header(), &[5]))); - assert_eq!(parse_init(&seg).unwrap().segment_lengths, vec![5]); - } -} diff --git a/src/infra/qobuz/stream/crypto.rs b/src/infra/qobuz/stream/crypto.rs deleted file mode 100644 index 3988fa73..00000000 --- a/src/infra/qobuz/stream/crypto.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! Key derivation and frame decryption for the Qobuz stream, pure over byte slices. -//! -//! Session key = HKDF-SHA256(ikm = the 16 raw bytes of the hex secret, salt and -//! info from `session/start`), expanded to 16 bytes. Content key = AES-128-CBC -//! (PKCS7) decrypt of the wrapped key with the session key. Frames are -//! AES-128-CTR with a 64-bit big-endian counter and a nonce of the frame IV -//! followed by zero bytes. - -use aes::cipher::block_padding::Pkcs7; -use aes::cipher::{BlockDecryptMut, KeyIvInit, StreamCipher}; -use anyhow::{anyhow, Result}; -use hkdf::Hkdf; -use sha2::Sha256; - -type CbcDecryptor = cbc::Decryptor; -type FrameCipher = ctr::Ctr64BE; - -/// Derive the 16-byte session key from the hex secret and the `infos` parts. -pub fn session_key(secret_hex: &str, salt: &[u8], info: &[u8]) -> Result<[u8; 16]> { - let ikm = hex::decode(secret_hex).map_err(|e| anyhow!("Qobuz secret is not hex: {e}"))?; - hkdf_16(&ikm, salt, info) -} - -fn hkdf_16(ikm: &[u8], salt: &[u8], info: &[u8]) -> Result<[u8; 16]> { - let hk = Hkdf::::new(Some(salt), ikm); - let mut okm = [0u8; 16]; - hk.expand(info, &mut okm) - .map_err(|_| anyhow!("HKDF expand failed"))?; - Ok(okm) -} - -/// Unwrap the 16-byte content key (AES-128-CBC, PKCS7) with the session key. -pub fn unwrap_content_key(session_key: &[u8; 16], wrapped: &[u8], iv: &[u8]) -> Result<[u8; 16]> { - let iv: &[u8; 16] = iv - .try_into() - .map_err(|_| anyhow!("content key IV is {} bytes, expected 16", iv.len()))?; - let mut buf = wrapped.to_vec(); - let plain = CbcDecryptor::new(session_key.into(), iv.into()) - .decrypt_padded_mut::(&mut buf) - .map_err(|e| anyhow!("content key unwrap failed: {e}"))?; - plain - .try_into() - .map_err(|_| anyhow!("content key is {} bytes, expected 16", plain.len())) -} - -/// Decrypt one frame in place with AES-128-CTR; `frame_iv` fills the nonce head. -pub fn decrypt_frame(content_key: &[u8; 16], frame_iv: &[u8], frame: &mut [u8]) -> Result<()> { - if frame_iv.len() > 16 { - return Err(anyhow!( - "frame IV is {} bytes, expected at most 16", - frame_iv.len() - )); - } - let mut nonce = [0u8; 16]; - nonce[..frame_iv.len()].copy_from_slice(frame_iv); - FrameCipher::new(content_key.into(), (&nonce).into()).apply_keystream(frame); - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use aes::cipher::BlockEncryptMut; - - type CbcEncryptor = cbc::Encryptor; - - #[test] - fn hkdf_matches_rfc_5869_test_case_1() { - let ikm = [0x0bu8; 22]; - let salt: Vec = (0u8..=0x0c).collect(); - let info: Vec = (0xf0u8..=0xf9).collect(); - let okm = hkdf_16(&ikm, &salt, &info).unwrap(); - assert_eq!(hex::encode(okm), "3cb25f25faacd57a90434f64d0362f2a"); - } - - #[test] - fn session_key_decodes_the_hex_secret_as_ikm() { - let key = session_key("000102030405060708090a0b0c0d0e0f", b"salt", b"info").unwrap(); - assert_eq!(hex::encode(key), "f83a387899f405fb64e48ee655b78972"); - } - - #[test] - fn session_key_rejects_a_non_hex_secret() { - assert!(session_key("not hex at all", b"salt", b"info").is_err()); - } - - #[test] - fn unwrap_content_key_inverts_cbc_pkcs7_encryption() { - let session = [0x11u8; 16]; - let iv = [0x22u8; 16]; - let content = [0x33u8; 16]; - let mut buf = [0u8; 32]; - buf[..16].copy_from_slice(&content); - let wrapped = CbcEncryptor::new((&session).into(), (&iv).into()) - .encrypt_padded_mut::(&mut buf, 16) - .unwrap() - .to_vec(); - assert_eq!(wrapped.len(), 32); - assert_eq!( - unwrap_content_key(&session, &wrapped, &iv).unwrap(), - content - ); - } - - #[test] - fn unwrap_content_key_rejects_a_short_iv() { - assert!(unwrap_content_key(&[0u8; 16], &[0u8; 32], &[0u8; 8]).is_err()); - } - - #[test] - fn decrypt_frame_is_an_involution_with_an_8_byte_iv() { - let key = [0x44u8; 16]; - let iv = [1u8, 2, 3, 4, 5, 6, 7, 8]; - let plain: Vec = (0u8..100).collect(); - let mut frame = plain.clone(); - decrypt_frame(&key, &iv, &mut frame).unwrap(); - assert_ne!(frame, plain); - decrypt_frame(&key, &iv, &mut frame).unwrap(); - assert_eq!(frame, plain); - } - - #[test] - fn decrypt_frame_rejects_an_oversized_iv() { - let mut frame = [0u8; 4]; - assert!(decrypt_frame(&[0u8; 16], &[0u8; 17], &mut frame).is_err()); - } -} diff --git a/src/infra/qobuz/stream/download.rs b/src/infra/qobuz/stream/download.rs deleted file mode 100644 index 0a7e839e..00000000 --- a/src/infra/qobuz/stream/download.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! Segment download: fetch, parse, and decrypt one segment at a time. The -//! progressive stream (`progressive`) drives it and hands each decrypted -//! segment to `stream-download`. - -use anyhow::{anyhow, Context, Result}; -use bytes::Bytes; -use reqwest::Client; - -use super::cmaf::{self, InitSegment}; - -const SEGMENT_PLACEHOLDER: &str = "$SEGMENT$"; - -/// Fetch and parse segment 0. -pub async fn fetch_init(http: &Client, url_template: &str) -> Result { - if !url_template.contains(SEGMENT_PLACEHOLDER) { - return Err(anyhow!("stream URL template has no {SEGMENT_PLACEHOLDER}")); - } - let bytes = fetch_segment(http, url_template, 0).await?; - cmaf::parse_init(&bytes).context("segment 0 parse") -} - -/// Fetch audio segment `index` (1 or later) and return its decrypted frames. -pub(super) async fn fetch_audio_segment( - http: &Client, - url_template: &str, - index: u32, - content_key: &[u8; 16], -) -> Result { - let mut bytes = fetch_segment(http, url_template, index).await?; - let table = cmaf::parse_segment(&bytes).with_context(|| format!("segment {index} parse"))?; - let range = cmaf::decrypt_frames(&mut bytes, &table, content_key) - .with_context(|| format!("segment {index} decrypt"))?; - Ok(Bytes::from(bytes).slice(range)) -} - -fn segment_url(template: &str, index: u32) -> String { - template.replace(SEGMENT_PLACEHOLDER, &index.to_string()) -} - -async fn fetch_segment(http: &Client, template: &str, index: u32) -> Result> { - let response = http - .get(segment_url(template, index)) - .send() - .await - .with_context(|| format!("segment {index} request"))?; - let status = response.status(); - if !status.is_success() { - return Err(anyhow!("segment {index} returned HTTP {status}")); - } - Ok( - response - .bytes() - .await - .with_context(|| format!("segment {index} body"))? - .to_vec(), - ) -} - -/// A fake segment server and a two-segment track, shared with the -/// progressive-delivery tests. -#[cfg(test)] -pub(super) mod test_support { - use super::super::cmaf::fixtures; - use super::SEGMENT_PLACEHOLDER; - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - - pub const KEY: [u8; 16] = [0x5a; 16]; - - /// Serve `segments[i]` at `/seg/i`; returns the URL template. - pub async fn serve(segments: Vec>) -> String { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let port = listener.local_addr().unwrap().port(); - tokio::spawn(async move { - loop { - let Ok((mut stream, _)) = listener.accept().await else { - break; - }; - let mut buf = vec![0u8; 2048]; - let n = stream.read(&mut buf).await.unwrap_or(0); - let request = String::from_utf8_lossy(&buf[..n]); - let path = request.split_whitespace().nth(1).unwrap_or(""); - let index: usize = path - .rsplit('/') - .next() - .and_then(|s| s.parse().ok()) - .unwrap_or(usize::MAX); - let (status, body) = match segments.get(index) { - Some(b) => ("200 OK", b.clone()), - None => ("404 Not Found", Vec::new()), - }; - let head = format!( - "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - body.len() - ); - let _ = stream.write_all(head.as_bytes()).await; - let _ = stream.write_all(&body).await; - let _ = stream.shutdown().await; - } - }); - format!("http://127.0.0.1:{port}/seg/{SEGMENT_PLACEHOLDER}") - } - - /// The segments of a track (init first) and the expected rebuilt file. - pub fn fixture_track() -> (Vec>, Vec) { - let (seg1, plain1) = fixtures::audio_segment( - &[ - ((0u8..40).collect(), 1, [1, 2, 3, 4, 5, 6, 7, 8]), - ((100u8..130).collect(), 0, [0; 8]), - ], - &KEY, - ); - let (seg2, plain2) = fixtures::audio_segment(&[((200u8..220).collect(), 1, [9; 8])], &KEY); - let init = fixtures::init_segment(&fixtures::flac_header(), &[70, 20]); - let mut expected = fixtures::flac_header(); - expected[4] |= 0x80; - expected.extend(plain1); - expected.extend(plain2); - (vec![init, seg1, seg2], expected) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn template_without_placeholder_is_rejected() { - assert!(fetch_init(&Client::new(), "http://127.0.0.1:1/x") - .await - .is_err()); - } - - #[test] - fn segment_url_substitutes_the_index() { - assert_eq!( - segment_url("https://h/t/$SEGMENT$.m4s?x=1", 12), - "https://h/t/12.m4s?x=1" - ); - } -} diff --git a/src/infra/qobuz/stream/mod.rs b/src/infra/qobuz/stream/mod.rs deleted file mode 100644 index c111ccca..00000000 --- a/src/infra/qobuz/stream/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! The encrypted CMAF transport: pure key and box parsers, the downloader, and -//! the progressive stream the player decodes while the download runs. - -pub mod cmaf; -pub mod crypto; -pub mod download; -pub mod progressive; diff --git a/src/infra/qobuz/stream/progressive.rs b/src/infra/qobuz/stream/progressive.rs deleted file mode 100644 index 7129a987..00000000 --- a/src/infra/qobuz/stream/progressive.rs +++ /dev/null @@ -1,429 +0,0 @@ -//! Progressive delivery: the track plays while it downloads. -//! -//! [`SegmentStream`] is a `stream-download` source over the segment transport. -//! It yields the codec header, then each decrypted audio segment in order, and -//! restarts at any byte offset when the decoder seeks past the downloaded -//! part. `stream-download` writes the bytes into the session's tempfile -//! ([`TempfileStorage`]), blocks the decoder's reads until they exist, and -//! stops the download when the reader is dropped. The session keeps the -//! `NamedTempFile`, so the finished file outlives the reader (repeat-one). - -use std::convert::Infallible; -use std::fs::File; -use std::future::Future; -use std::io::{self, BufReader}; -use std::pin::Pin; -use std::task::{Context, Poll}; -use std::time::Duration; - -use anyhow::{anyhow, Context as _, Result}; -use bytes::Bytes; -use futures::Stream; -use reqwest::Client; -use stream_download::source::SourceStream; -use stream_download::storage::StorageProvider; -use stream_download::{Settings, StreamDownload}; -use tempfile::NamedTempFile; -use tokio::task::JoinHandle; - -use super::cmaf::InitSegment; -use super::download::fetch_audio_segment; - -/// Consecutive failed fetches of one segment before the stream fails. -const MAX_ATTEMPTS: u32 = 3; -/// Bytes downloaded before the first read returns: a cushion against jitter -/// that costs well under a second on a normal connection. -const PREFETCH_BYTES: u64 = 512 * 1024; -/// Time without a chunk before `stream-download` asks for a reconnect. Above -/// the HTTP client's request timeout, so a stalled segment fails there first -/// and counts as an attempt. -const STALL_TIMEOUT: Duration = Duration::from_secs(60); - -/// The reader the decoder pulls from; dropping it cancels the download. -pub type TrackReader = StreamDownload; - -/// Start the download into `file` and return the decoder's reader. -pub async fn open(stream: SegmentStream, file: &NamedTempFile) -> Result { - let storage = TempfileStorage::new(file).context("reopening stream file")?; - let settings = Settings::default() - .prefetch_bytes(PREFETCH_BYTES) - .retry_timeout(STALL_TIMEOUT); - StreamDownload::from_stream(stream, storage, settings) - .await - .map_err(|e| anyhow!("{e}")) -} - -/// Storage over the session's own tempfile: one reopened handle for the -/// download's writes and one for the decoder's reads. -pub struct TempfileStorage { - reader: File, - writer: File, -} - -impl TempfileStorage { - fn new(file: &NamedTempFile) -> io::Result { - Ok(Self { - reader: file.reopen()?, - writer: file.reopen()?, - }) - } -} - -impl StorageProvider for TempfileStorage { - type Reader = BufReader; - type Writer = File; - - fn into_reader_writer( - self, - _content_length: Option, - ) -> io::Result<(Self::Reader, Self::Writer)> { - Ok((BufReader::new(self.reader), self.writer)) - } -} - -/// A track as a byte stream: chunk 0 is the codec header, chunk `i >= 1` is -/// the decrypted audio segment `i`. -pub struct SegmentStream { - http: Client, - url_template: String, - content_key: [u8; 16], - header: Bytes, - /// `starts[i]` is the byte offset of chunk `i`; the last entry is the total. - starts: Vec, - /// The next chunk to yield. - next: usize, - /// Bytes to drop from the front of the next chunk (a seek into a chunk). - skip: usize, - /// Chunks at or past this index are not yielded until the next seek. - end: usize, - in_flight: Option>>, - attempts: u32, - failure: Option, -} - -impl SegmentStream { - pub fn new( - http: Client, - url_template: String, - content_key: [u8; 16], - init: &InitSegment, - ) -> Self { - let mut starts = Vec::with_capacity(init.segment_lengths.len() + 2); - let mut position = 0u64; - starts.push(position); - position += init.header.len() as u64; - starts.push(position); - for &len in &init.segment_lengths { - position += u64::from(len); - starts.push(position); - } - let chunks = starts.len() - 1; - Self { - http, - url_template, - content_key, - header: Bytes::copy_from_slice(&init.header), - starts, - next: 0, - skip: 0, - end: chunks, - in_flight: None, - attempts: 0, - failure: None, - } - } - - fn chunk_count(&self) -> usize { - self.starts.len() - 1 - } - - /// The size of the finished file. - pub fn total_bytes(&self) -> u64 { - self.starts[self.chunk_count()] - } - - /// The chunk that holds byte `position` and the offset inside that chunk; - /// `(chunk_count, 0)` at or past the end. - fn locate(&self, position: u64) -> (usize, usize) { - let chunks = self.chunk_count(); - let index = self.starts[1..].partition_point(|&end| end <= position); - if index >= chunks { - (chunks, 0) - } else { - (index, (position - self.starts[index]) as usize) - } - } - - /// The exclusive chunk bound that covers the exclusive byte bound `end`. - fn chunk_bound(&self, end: Option) -> usize { - match end { - None => self.chunk_count(), - Some(0) => 0, - Some(end) => (self.locate(end - 1).0 + 1).min(self.chunk_count()), - } - } - - /// Continue from byte `start`, yielding chunks below `end_chunk` only. - fn restart(&mut self, start: u64, end_chunk: usize) -> io::Result<()> { - if let Some(message) = &self.failure { - return Err(io::Error::other(message.clone())); - } - self.abort_in_flight(); - let (chunk, skip) = self.locate(start); - self.next = chunk; - self.skip = skip; - self.end = end_chunk; - self.attempts = 0; - Ok(()) - } - - fn abort_in_flight(&mut self) { - if let Some(task) = self.in_flight.take() { - task.abort(); - } - } - - fn spawn_fetch(&self, index: u32) -> JoinHandle> { - let http = self.http.clone(); - let template = self.url_template.clone(); - let key = self.content_key; - tokio::spawn(async move { fetch_audio_segment(&http, &template, index, &key).await }) - } -} - -impl Drop for SegmentStream { - fn drop(&mut self) { - self.abort_in_flight(); - } -} - -impl Stream for SegmentStream { - type Item = Result; - - fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let this = &mut *self; - if this.failure.is_some() || this.next >= this.end { - return Poll::Ready(None); - } - let chunk = if this.next == 0 { - this.header.clone() - } else { - let index = this.next as u32; - if this.in_flight.is_none() { - let task = this.spawn_fetch(index); - this.in_flight = Some(task); - } - let task = this.in_flight.as_mut().expect("fetch task was just set"); - let outcome = match Pin::new(task).poll(cx) { - Poll::Pending => return Poll::Pending, - Poll::Ready(Ok(outcome)) => outcome, - Poll::Ready(Err(join)) => Err(anyhow!("segment {index} fetch task: {join}")), - }; - this.in_flight = None; - match outcome { - Ok(bytes) => bytes, - Err(e) => { - // `stream-download` logs the error and polls again; the failure - // flag ends the stream after the last attempt. - this.attempts += 1; - if this.attempts >= MAX_ATTEMPTS { - this.failure = Some(format!("{e:#}")); - } - return Poll::Ready(Some(Err(e))); - } - } - }; - let skip = this.skip.min(chunk.len()); - this.next += 1; - this.skip = 0; - this.attempts = 0; - Poll::Ready(Some(Ok(chunk.slice(skip..)))) - } -} - -impl SourceStream for SegmentStream { - type Params = Self; - type StreamCreationError = Infallible; - - async fn create(params: Self) -> Result { - Ok(params) - } - - fn content_length(&self) -> Option { - Some(self.total_bytes()) - } - - async fn seek_range(&mut self, start: u64, end: Option) -> io::Result<()> { - let bound = self.chunk_bound(end); - self.restart(start, bound) - } - - async fn reconnect(&mut self, current_position: u64) -> io::Result<()> { - let bound = self.end; - self.restart(current_position, bound) - } - - fn supports_seek(&self) -> bool { - true - } -} - -#[cfg(test)] -mod tests { - use std::io::{Read, Seek, SeekFrom}; - - use futures::StreamExt; - - use super::super::download::fetch_init; - use super::super::download::test_support::*; - use super::*; - - fn stream_of(lengths: &[u32]) -> SegmentStream { - let init = InitSegment { - header: vec![0; 10], - segment_lengths: lengths.to_vec(), - sample_rate: 44_100, - bits_per_sample: 16, - }; - SegmentStream::new( - Client::new(), - "http://127.0.0.1:1/$SEGMENT$".to_string(), - KEY, - &init, - ) - } - - async fn fixture_stream(segments: Vec>) -> SegmentStream { - let template = serve(segments).await; - let http = Client::new(); - let init = fetch_init(&http, &template).await.unwrap(); - SegmentStream::new(http, template, KEY, &init) - } - - async fn collect(stream: &mut SegmentStream) -> Vec { - let mut out = Vec::new(); - while let Some(chunk) = stream.next().await { - out.extend_from_slice(&chunk.unwrap()); - } - out - } - - #[tokio::test] - async fn locate_maps_offsets_to_chunks() { - let s = stream_of(&[70, 20]); - assert_eq!(s.total_bytes(), 100); - assert_eq!(s.locate(0), (0, 0)); - assert_eq!(s.locate(9), (0, 9)); - assert_eq!(s.locate(10), (1, 0)); - assert_eq!(s.locate(45), (1, 35)); - assert_eq!(s.locate(80), (2, 0)); - assert_eq!(s.locate(99), (2, 19)); - assert_eq!(s.locate(100), (3, 0)); - assert_eq!(s.locate(500), (3, 0)); - } - - #[tokio::test] - async fn chunk_bound_covers_the_chunk_that_holds_the_last_byte() { - let s = stream_of(&[70, 20]); - assert_eq!(s.chunk_bound(None), 3); - assert_eq!(s.chunk_bound(Some(0)), 0); - assert_eq!(s.chunk_bound(Some(10)), 1); - assert_eq!(s.chunk_bound(Some(11)), 2); - assert_eq!(s.chunk_bound(Some(100)), 3); - assert_eq!(s.chunk_bound(Some(1000)), 3); - } - - #[tokio::test] - async fn stream_yields_header_then_decrypted_segments() { - let (segments, expected) = fixture_track(); - let mut stream = fixture_stream(segments).await; - assert_eq!(stream.total_bytes(), expected.len() as u64); - assert_eq!(collect(&mut stream).await, expected); - } - - #[tokio::test] - async fn seek_range_restarts_inside_a_segment_and_honors_the_end_bound() { - let (segments, expected) = fixture_track(); - let mut stream = fixture_stream(segments).await; - stream.seek_range(45, None).await.unwrap(); - assert_eq!(collect(&mut stream).await, expected[45..]); - // Bytes 0..50 lie in the header and segment 1 (0..112): both are yielded. - stream.seek_range(0, Some(50)).await.unwrap(); - assert_eq!(collect(&mut stream).await, expected[..112]); - stream.seek_range(112, None).await.unwrap(); - assert_eq!(collect(&mut stream).await, expected[112..]); - } - - #[tokio::test] - async fn missing_segment_fails_after_the_last_attempt() { - let (mut segments, expected) = fixture_track(); - segments.pop(); - let mut stream = fixture_stream(segments).await; - let mut out = Vec::new(); - let mut errors = 0; - while let Some(chunk) = stream.next().await { - match chunk { - Ok(bytes) => out.extend_from_slice(&bytes), - Err(_) => errors += 1, - } - } - assert_eq!(out, expected[..112]); - assert_eq!(errors, MAX_ATTEMPTS); - assert!(stream.seek_range(112, None).await.is_err()); - } - - #[tokio::test] - async fn reader_returns_the_track_and_fills_the_tempfile() { - let (segments, expected) = fixture_track(); - let stream = fixture_stream(segments).await; - let tmp = NamedTempFile::new().unwrap(); - let mut reader = open(stream, &tmp).await.unwrap(); - let bytes = tokio::task::spawn_blocking(move || { - let mut out = Vec::new(); - reader.read_to_end(&mut out).map(|_| out) - }) - .await - .unwrap() - .unwrap(); - assert_eq!(bytes, expected); - assert_eq!(std::fs::read(tmp.path()).unwrap(), expected); - } - - #[tokio::test] - async fn reader_seeks_within_the_track() { - let (segments, expected) = fixture_track(); - let stream = fixture_stream(segments).await; - let tmp = NamedTempFile::new().unwrap(); - let mut reader = open(stream, &tmp).await.unwrap(); - let tail_start = expected.len() - 15; - let (tail, all) = tokio::task::spawn_blocking(move || { - reader.seek(SeekFrom::Start(tail_start as u64)).unwrap(); - let mut tail = Vec::new(); - reader.read_to_end(&mut tail).unwrap(); - reader.seek(SeekFrom::Start(0)).unwrap(); - let mut all = Vec::new(); - reader.read_to_end(&mut all).unwrap(); - (tail, all) - }) - .await - .unwrap(); - assert_eq!(tail, expected[tail_start..]); - assert_eq!(all, expected); - } - - #[tokio::test] - async fn reader_fails_when_a_segment_is_missing() { - let (mut segments, _) = fixture_track(); - segments.pop(); - let stream = fixture_stream(segments).await; - let tmp = NamedTempFile::new().unwrap(); - let mut reader = open(stream, &tmp).await.unwrap(); - let result = tokio::task::spawn_blocking(move || { - let mut out = Vec::new(); - reader.read_to_end(&mut out) - }) - .await - .unwrap(); - assert!(result.is_err()); - } -} diff --git a/src/infra/qobuz/types.rs b/src/infra/qobuz/types.rs deleted file mode 100644 index 680e95f9..00000000 --- a/src/infra/qobuz/types.rs +++ /dev/null @@ -1,162 +0,0 @@ -//! Serde types for the Qobuz API responses, private to the module. - -use serde::{Deserialize, Deserializer}; - -/// A Qobuz id: integers for tracks, playlists and artists, strings for albums. -fn de_id<'de, D: Deserializer<'de>>(d: D) -> Result { - #[derive(Deserialize)] - #[serde(untagged)] - enum RawId { - Num(u64), - Str(String), - } - Ok(match RawId::deserialize(d)? { - RawId::Num(n) => n.to_string(), - RawId::Str(s) => s, - }) -} - -fn de_opt_id<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { - #[derive(Deserialize)] - struct Wrap(#[serde(deserialize_with = "de_id")] String); - Ok(Option::::deserialize(d)?.map(|w| w.0)) -} - -fn default_true() -> bool { - true -} - -/// `session/start`. -#[derive(Debug, Deserialize)] -pub struct SessionStart { - pub session_id: String, - #[serde(default)] - pub expires_at: u64, - /// `"."`, both base64url. - pub infos: String, -} - -/// `file/url` with `intent=stream`; the segment count comes from the init table. -#[derive(Debug, Deserialize)] -pub struct FileUrl { - pub url_template: String, - /// `".."`, base64url. - pub key: String, -} - -/// `oauth/callback`. -#[derive(Debug, Deserialize)] -pub struct OauthCallback { - pub token: Option, - pub user_auth_token: Option, - #[serde(default, deserialize_with = "de_opt_id")] - pub user_id: Option, - pub user: Option, -} - -#[derive(Debug, Deserialize)] -pub struct UserRef { - #[serde(default, deserialize_with = "de_opt_id")] - pub id: Option, -} - -/// One page of a paginated list: `{ offset, limit, total, items }`. -#[derive(Debug, Deserialize)] -pub struct Page { - #[serde(default)] - pub total: u32, - #[serde(default = "Vec::new")] - pub items: Vec, -} - -impl Default for Page { - fn default() -> Self { - Page { - total: 0, - items: Vec::new(), - } - } -} - -#[derive(Debug, Deserialize)] -pub struct Named { - #[serde(default, deserialize_with = "de_opt_id")] - pub id: Option, - #[serde(default)] - pub name: String, -} - -#[derive(Debug, Default, Deserialize)] -pub struct Image { - pub large: Option, -} - -#[derive(Debug, Deserialize)] -pub struct Album { - #[serde(deserialize_with = "de_id")] - pub id: String, - #[serde(default)] - pub title: String, - pub artist: Option, - #[serde(default)] - pub image: Image, - #[serde(default)] - pub tracks_count: u32, - pub tracks: Option>, -} - -#[derive(Debug, Deserialize)] -pub struct Track { - #[serde(deserialize_with = "de_id")] - pub id: String, - #[serde(default)] - pub title: String, - pub version: Option, - /// Seconds. - #[serde(default)] - pub duration: u64, - #[serde(default)] - pub track_number: u32, - pub performer: Option, - /// Absent inside `album/get`, where the parent album is the context. - pub album: Option, - #[serde(default = "default_true")] - pub streamable: bool, - #[serde(default)] - pub parental_warning: bool, -} - -#[derive(Debug, Deserialize)] -pub struct Playlist { - #[serde(deserialize_with = "de_id")] - pub id: String, - #[serde(default)] - pub name: String, - pub owner: Option, - #[serde(default)] - pub tracks_count: u32, - pub is_public: Option, - #[serde(default)] - pub images300: Vec, - pub tracks: Option>, -} - -/// `playlist/getUserPlaylists`. -#[derive(Debug, Deserialize)] -pub struct UserPlaylists { - #[serde(default)] - pub playlists: Page, -} - -/// `favorite/getUserFavorites`. -#[derive(Debug, Deserialize)] -pub struct Favorites { - pub tracks: Option>, - pub albums: Option>, -} - -/// `catalog/search`. -#[derive(Debug, Deserialize)] -pub struct Search { - pub tracks: Option>, -} diff --git a/src/infra/queue/dispatch.rs b/src/infra/queue/dispatch.rs deleted file mode 100644 index 63bb2d32..00000000 --- a/src/infra/queue/dispatch.rs +++ /dev/null @@ -1,1586 +0,0 @@ -//! Native queue playback routing. -//! -//! [`route_queue_event`] is wired **first** in the runtime IoEvent pump (before -//! the per-source dispatchers). It owns [`IoEvent::AdvanceNativeQueue`] and the -//! transport controls for the queue slot's player, and it relinquishes the queue -//! slot when the user starts an unrelated playback. -//! -//! Compiled unconditionally; the decoded-playback bodies are gated per source -//! feature. In a slim build the engine reduces to "skip every item with a -//! `not available in this build` status" — correct, and enough for the pump to -//! stay one shape across builds. - -use std::sync::Arc; - -use tokio::sync::Mutex; - -use crate::core::app::App; -use crate::core::plugin_api::TrackInfo; -#[cfg(feature = "queue")] -use crate::core::queue::QueueItemSource; -use crate::core::queue::{queue_item_source, source_available, source_label}; -use crate::infra::network::IoEvent; - -// The decoded queue slot exists only for the sources that own a finite track -// list; internet radio enables `audio-decode` but is never queueable. -#[cfg(feature = "audio-decode-queue")] -use crate::infra::audio::LocalPlayer; -#[cfg(feature = "audio-decode-queue")] -use std::time::Duration; - -/// Intercept queue-owned events before the per-source dispatchers. -/// -/// Returns `true` when the event was consumed and must **not** be forwarded, -/// `false` to let the normal dispatch run. [`IoEvent::StartPlayback`] variants -/// return `false` (the per-source teardowns/starts still run) but first clear -/// the queue slot so a new play cleanly takes over. -pub async fn route_queue_event(app: &Arc>, event: &IoEvent) -> bool { - if let IoEvent::AdvanceNativeQueue = event { - advance_native_queue(app).await; - return true; - } - - // The slot is done, with the rest of the queue left where it is: the driver's - // tick sends this when the slot's device died and would not reopen. - if let IoEvent::FinishNativeQueue = event { - resume_or_finish(app).await; - return true; - } - - #[cfg(feature = "streaming")] - if let IoEvent::ReplayPublishedSpotifyQueueSlot = event { - if replay_published_spotify_slot(app).await { - log::info!("replayed published Spotify queue slot after native recovery"); - } - return true; - } - - // Repeat has no meaning for the queue slot, which plays an explicit list over - // a suspended context. Consume it so a plugin's request never cycles repeat - // on the user's real Spotify device with nothing on screen to show for it; - // the keyboard and MPRIS paths already refuse (#376). - if let IoEvent::Repeat(_) = event { - let mut guard = app.lock().await; - if guard.queue_owns_playback() { - guard.set_status_message("Repeat does not apply to this source", 2); - return true; - } - } - - // Transport for the queue slot's own player (Pause / Seek / Volume / Next / - // bare-resume). Only meaningful when a decoded queued track owns the sink; - // compiles out entirely without a queueable decoded source. - #[cfg(feature = "audio-decode-queue")] - if let Some(handled) = route_queue_transport(app, event).await { - return handled; - } - - #[cfg(feature = "streaming")] - if let Some(handled) = route_spotify_queue_transport(app, event).await { - return handled; - } - - // An explicit new-playback start relinquishes the queue slot (keeping the - // queued items) so the per-source teardowns/starts run against a clean state. - if matches!( - event, - IoEvent::StartPlayback(Some(_), _, _) | IoEvent::StartPlayback(_, Some(_), _) - ) { - clear_queue_playback(app).await; - } - false -} - -/// Transport controls for the queue slot's player, when a decoded queued track -/// owns the sink. Returns `Some(true)` when consumed, `None` when this event is -/// not a queue-slot transport control (so the caller falls through). -#[cfg(feature = "audio-decode-queue")] -async fn route_queue_transport(app: &Arc>, event: &IoEvent) -> Option { - let player = { - let guard = app.lock().await; - guard.queue_now_decoded_player().map(Arc::clone) - }?; - match event { - IoEvent::PausePlayback => { - player.pause(); - app.lock().await.queue_slot_desired_playing = false; - Some(true) - } - // Bare "resume current" while the queue owns playback resumes the queue slot. - IoEvent::StartPlayback(None, None, None) => { - player.resume(); - app.lock().await.queue_slot_desired_playing = true; - Some(true) - } - IoEvent::Seek(position_ms) => { - let _ = player.seek(Duration::from_millis(*position_ms as u64)); - Some(true) - } - IoEvent::ChangeVolume(volume) => { - player.set_volume(*volume); - let mut app = app.lock().await; - app.runtime_state.volume_percent = *volume; - app.schedule_state_save(crate::core::state::PersistedRuntimeState::volume_percent( - *volume, - )); - Some(true) - } - // Skip the queued track: advance to the next queued item (or resume). A - // skip while paused plays the next item, like the Spotify slot. - IoEvent::NextTrack => { - drop(player); - app.lock().await.queue_slot_desired_playing = true; - advance_native_queue(app).await; - Some(true) - } - // A forward-only queue has no "previous"; restart the current queued track. - IoEvent::PreviousTrack | IoEvent::ForcePreviousTrack => { - let _ = player.seek(Duration::from_millis(0)); - Some(true) - } - _ => None, - } -} - -/// Transport controls for a queued Spotify track playing through librespot. -#[cfg(feature = "streaming")] -async fn route_spotify_queue_transport(app: &Arc>, event: &IoEvent) -> Option { - let is_spotify_slot = { app.lock().await.queue_now_is_spotify() }; - if !is_spotify_slot { - return None; - } - match event { - IoEvent::PausePlayback => { - if let Some(player) = { app.lock().await.streaming_player.clone() } { - player.pause(); - } - let mut guard = app.lock().await; - guard.native_is_playing = Some(false); - // Teardown overwrites `native_is_playing`; the slot's own desired state - // must survive a full recovery so replay doesn't restart a paused track. - guard.queue_slot_desired_playing = false; - guard.set_native_playback_intent(false); - Some(true) - } - IoEvent::StartPlayback(None, None, None) => { - if let Some(player) = { app.lock().await.streaming_player.clone() } { - player.play(); - } - let mut guard = app.lock().await; - guard.native_is_playing = Some(true); - guard.queue_slot_desired_playing = true; - guard.set_native_playback_intent(true); - Some(true) - } - // A skip while paused plays the next item, decoded or not. - IoEvent::NextTrack => { - app.lock().await.queue_slot_desired_playing = true; - advance_native_queue(app).await; - Some(true) - } - IoEvent::PreviousTrack | IoEvent::ForcePreviousTrack => { - if let Some(player) = { app.lock().await.streaming_player.clone() } { - player.seek(0); - } - Some(true) - } - _ => None, - } -} - -/// Drop the queue slot (stopping its player) and forget any suspended context, -/// but keep the queued items. Called when the user starts an unrelated playback. -async fn clear_queue_playback(app: &Arc>) { - #[cfg(feature = "audio-decode-queue")] - { - let player = { - let mut guard = app.lock().await; - guard.queue_suspended = None; - // The slot's desired play state belongs to the episode that just ended. - guard.queue_slot_desired_playing = true; - guard.take_queue_now_decoded_player() - }; - if let Some(player) = player { - player.stop(); - } - } - #[cfg(all(feature = "streaming", not(feature = "audio-decode-queue")))] - { - let mut guard = app.lock().await; - guard.queue_suspended = None; - guard.queue_now = None; - } - // No queueable source at all (includes a radio-only build): there is no queue - // slot to clear, and `queue_suspended` is only ever set by a source that can - // be suspended *under* the queue. - #[cfg(not(feature = "queue"))] - { - let _ = app; - } -} - -// --------------------------------------------------------------------------- -// Advance -// --------------------------------------------------------------------------- - -/// Pop the head of the native queue and play it, skipping unplayable items with -/// a status message (bounded by the queue length — never unbounded recursion). -/// When the queue drains, resume the suspended context (or finish). -async fn advance_native_queue(app: &Arc>) { - loop { - let track = { - let mut guard = app.lock().await; - if guard.native_queue.is_empty() { - None - } else { - Some(guard.native_queue.remove(0)) - } - }; - let Some(track) = track else { - resume_or_finish(app).await; - return; - }; - if try_play_queued(app, &track).await { - return; // now playing this track - } - // Unplayable / skipped — loop to the next item. - } -} - -/// Try to play one queued track. Returns `true` if it is now playing, `false` -/// if it was skipped (feature off, no URI, download/decode error) — the caller -/// then advances to the next item. -async fn try_play_queued(app: &Arc>, track: &TrackInfo) -> bool { - let Some(uri) = track.uri.clone() else { - set_status(app, "Skipped a queued track with no URI".to_string()).await; - return false; - }; - let source = queue_item_source(&uri); - if !source_available(source) { - set_status( - app, - format!( - "{} playback isn't available in this build", - source_label(source) - ), - ) - .await; - return false; - } - match source { - #[cfg(feature = "local-files")] - QueueItemSource::LocalFile => play_queued_local(app, track, &uri).await, - #[cfg(feature = "subsonic")] - QueueItemSource::Subsonic => play_queued_subsonic(app, track, &uri).await, - #[cfg(feature = "qobuz")] - QueueItemSource::Qobuz => play_queued_qobuz(app, track, &uri).await, - #[cfg(feature = "youtube")] - QueueItemSource::YouTube => play_queued_youtube(app, track, &uri).await, - #[cfg(feature = "streaming")] - QueueItemSource::Spotify => play_queued_spotify(app, track, &uri).await, - // Reached only when a source is `source_available` but its play arm is - // cfg'd out — impossible (the check above *is* the cfg gate), but the match - // must be exhaustive across builds. - #[allow(unreachable_patterns)] - _ => { - set_status( - app, - format!( - "{} playback isn't available in this build", - source_label(source) - ), - ) - .await; - false - } - } -} - -// --------------------------------------------------------------------------- -// Per-source queue playback -// --------------------------------------------------------------------------- - -#[cfg(feature = "local-files")] -async fn play_queued_local(app: &Arc>, track: &TrackInfo, uri: &str) -> bool { - release_librespot(app).await; - let Some(player) = acquire_queue_player(app).await else { - return false; - }; - let _ = publish_pending_decoded(app, &player, track).await; - match crate::infra::local::dispatch::stage_single_file(&player, uri).await { - Ok(_info) => { - apply_volume(app, &player).await; - publish_decoded(app, player, track.clone(), None).await; - true - } - Err(e) => { - set_status(app, format!("Cannot play {}: {e}", track.name)).await; - false - } - } -} - -#[cfg(feature = "subsonic")] -async fn play_queued_subsonic(app: &Arc>, track: &TrackInfo, uri: &str) -> bool { - release_librespot(app).await; - let Some(source) = crate::infra::subsonic::dispatch::build_source(app).await else { - return false; // build_source surfaced its own status - }; - let Some(player) = acquire_queue_player(app).await else { - return false; - }; - let fetch_id = publish_pending_decoded(app, &player, track).await; - // Fetch off the IoEvent pump: awaiting the download here would freeze every - // other event (skips included, for every source) for its whole duration. - let app = Arc::clone(app); - let uri = uri.to_string(); - let name = track.name.clone(); - tokio::spawn(async move { - let result = crate::infra::subsonic::dispatch::download_for_queue(&source, &uri) - .await - .map(|tmp| (tmp, None)); - finish_decoded_fetch(&app, fetch_id, result, &name).await; - }); - true -} - -#[cfg(feature = "qobuz")] -async fn play_queued_qobuz(app: &Arc>, track: &TrackInfo, uri: &str) -> bool { - release_librespot(app).await; - let Some(source) = crate::infra::qobuz::dispatch::build_playback_source(app).await else { - return false; // build_playback_source surfaced its own status - }; - let Some(player) = acquire_queue_player(app).await else { - return false; - }; - let fetch_id = publish_pending_decoded(app, &player, track).await; - let quality = app.lock().await.user_config.behavior.qobuz_quality; - // Fetch off the IoEvent pump, like Subsonic: a Qobuz track is a long download. - let app = Arc::clone(app); - let uri = uri.to_string(); - let name = track.name.clone(); - tokio::spawn(async move { - let result = crate::infra::qobuz::dispatch::download_for_queue(&source, &uri, quality) - .await - .map(|(tmp, label)| (tmp, Some(label))); - finish_decoded_fetch(&app, fetch_id, result, &name).await; - }); - true -} - -#[cfg(feature = "youtube")] -async fn play_queued_youtube(app: &Arc>, track: &TrackInfo, uri: &str) -> bool { - release_librespot(app).await; - let Some(player) = acquire_queue_player(app).await else { - return false; - }; - let fetch_id = publish_pending_decoded(app, &player, track).await; - { - let mut guard = app.lock().await; - guard.set_status_message(format!("Fetching {}\u{2026}", track.name), 30); - } - let source = crate::infra::youtube::dispatch::build_source(app).await; - // Fetch off the IoEvent pump: awaiting yt-dlp here would freeze every other - // event (skips included, for every source) for its whole duration. - let app = Arc::clone(app); - let uri = uri.to_string(); - let name = track.name.clone(); - tokio::spawn(async move { - let result = crate::infra::youtube::dispatch::download_for_queue(&source, &uri) - .await - .map(|tmp| (tmp, None)); - finish_decoded_fetch(&app, fetch_id, result, &name).await; - }); - true -} - -/// Play a queued Spotify track through the native streaming player via a direct -/// `player.load` (no Spirc context), publishing a Spotify queue slot. Requires a -/// available streaming player; otherwise the item is skipped like any other -/// unplayable one. Any decoded audio is silenced first so librespot doesn't play -/// over it. -#[cfg(feature = "streaming")] -async fn play_queued_spotify(app: &Arc>, track: &TrackInfo, uri: &str) -> bool { - let player = { app.lock().await.streaming_player.clone() }; - let Some(player) = player.filter(|p| p.is_available()) else { - set_status( - app, - format!( - "Native streaming isn't connected; skipped \"{}\"", - track.name - ), - ) - .await; - return false; - }; - // Silence any decoded audio so two players never share the sink. A decoded - // queue slot is stopped and dropped; a suspended decoded context (which keeps - // its player for resume) is paused — resume reloads its sink either way. - // Both lookups only ever see the queueable sources (radio is torn down at - // suspension rather than kept for reuse), so this compiles out without them. - #[cfg(feature = "audio-decode-queue")] - { - if let Some(p) = { app.lock().await.take_queue_now_decoded_player() } { - p.stop(); - } - if let Some(p) = suspended_context_player(app).await { - p.pause(); - } - } - // Publish the slot *before* the load, so librespot events arriving during it - // are classified correctly: the stray-playback guard sees a Spotify slot and - // lets this track start, and a Spirc self-advance racing the load is caught - // by the reload guard instead of slipping through an empty slot. - { - use crate::infra::queue::QueueNowPlaying; - let mut guard = app.lock().await; - guard.queue_now = Some(QueueNowPlaying::Spotify { - track: track.clone(), - }); - // Fresh slot: reset the Spirc self-advance retry budget. - guard.spotify_queue_guard_reloads = 0; - // A fresh slot always starts playing; pause/resume flip this afterwards. - guard.queue_slot_desired_playing = true; - } - player.activate(); - if let Err(e) = player.play_uri(uri).await { - // Unpublish so the failed slot can't shadow the next item (or the resume). - app.lock().await.queue_now = None; - set_status(app, format!("Cannot play {}: {e}", track.name)).await; - return false; - } - { - let mut guard = app.lock().await; - guard.set_status_message(format!("\u{266a} {} (queue)", track.name), 4); - preload_next_queued_spotify(&guard); - } - true -} - -/// Replay the currently-published Spotify queue slot on the streaming player. -/// Used after a full native recovery: the slot's direct load may have been -/// discarded with the replaced player, and for a queue playing over an idle -/// app there is no snapshot-driven restore to ever start it again. Honors the -/// slot's desired-playing state so a user's pause survives the rebuild. -#[cfg(feature = "streaming")] -pub async fn replay_published_spotify_slot(app: &Arc>) -> bool { - use crate::infra::queue::QueueNowPlaying; - let slot = { - let guard = app.lock().await; - match guard.queue_now.as_ref() { - Some(QueueNowPlaying::Spotify { track }) => track - .uri - .clone() - .map(|uri| (track.clone(), uri, guard.queue_slot_desired_playing)), - _ => None, - } - }; - let Some((track, uri, desired_playing)) = slot else { - return false; - }; - if desired_playing { - return play_queued_spotify(app, &track, &uri).await; - } - // The slot was paused when the backend was replaced: reload it without - // starting playback so resume works against a loaded track. - let player = { - app - .lock() - .await - .streaming_player - .clone() - .filter(|p| p.is_available()) - }; - let Some(player) = player else { - return false; - }; - player.activate(); - if let Err(e) = player.load_uri_paused(&uri).await { - set_status(app, format!("Cannot restore {}: {e}", track.name)).await; - return false; - } - app.lock().await.native_is_playing = Some(false); - true -} - -/// Warm the *next* queued Spotify track's audio while the current queue slot -/// plays. A queued Spotify track is a cold direct `player.load` (metadata + -/// audio key + CDN handshake), which reads as a small skip delay that Spirc's -/// own in-context skipping doesn't have — Spirc preloads. This levels that: -/// called whenever a queue slot starts playing, under whatever `App` borrow the -/// caller already holds. -#[cfg(feature = "streaming")] -fn preload_next_queued_spotify(app: &App) { - let Some(uri) = app.native_queue.first().and_then(|t| t.uri.clone()) else { - return; - }; - if queue_item_source(&uri) != QueueItemSource::Spotify { - return; - } - if let Some(player) = app.streaming_player.as_ref().filter(|p| p.is_connected()) { - player.preload_uri(&uri); - } -} - -/// Monotonic source for [`DecodedQueuePlayback::fetch_id`] stamps. -#[cfg(feature = "audio-decode-queue")] -static QUEUE_FETCH_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - -#[cfg(feature = "audio-decode-queue")] -fn next_fetch_id() -> u64 { - QUEUE_FETCH_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed) -} - -/// Publish the queue slot for `track` *before* its (possibly multi-second) -/// download or decode, marked `advancing` so the runner tick doesn't read the -/// still-empty sink as end-of-track. From this instant `queue_owns_playback()` -/// is true, so the transport paths (skip, pause, playbar) see the queued track -/// as current during the fetch. Without this, a skip in the silent download -/// window fell through to the "context playing with items waiting" branch, -/// which re-suspended the context and dispatched a second advance — dropping -/// one queued item on the floor. Returns the slot's fetch stamp, which a -/// background fetch passes back to [`finish_decoded_fetch`]. -#[cfg(feature = "audio-decode-queue")] -async fn publish_pending_decoded( - app: &Arc>, - player: &Arc, - track: &TrackInfo, -) -> u64 { - use crate::infra::queue::{DecodedQueuePlayback, QueueNowPlaying}; - let fetch_id = next_fetch_id(); - let mut guard = app.lock().await; - guard.queue_now = Some(QueueNowPlaying::Decoded(DecodedQueuePlayback { - player: Arc::clone(player), - track: track.clone(), - advancing: true, - fetch_id, - #[cfg(feature = "queue-download")] - tempfile: None, - quality: None, - })); - fetch_id -} - -/// Complete a background queue fetch: if the slot still carries `fetch_id`, -/// stage the downloaded file and finalize the slot (with the delivered format -/// label, when the source reports one); otherwise (skipped, torn down, or -/// replaced meanwhile) drop the result silently. On a download or decode -/// failure the queue advances past the item, exactly like the old inline -/// path. The stage runs off the `App` lock (its clear waits on the audio -/// thread, and the runner takes the lock on every frame); the slot is -/// re-checked before the finalize, and a stage that a newer fetch superseded -/// is left paused in the sink for that fetch's own stage to clear. -#[cfg(feature = "queue-download")] -async fn finish_decoded_fetch( - app: &Arc>, - fetch_id: u64, - result: anyhow::Result<(tempfile::NamedTempFile, Option)>, - track_name: &str, -) { - use crate::infra::queue::QueueNowPlaying; - let mut guard = app.lock().await; - let player = match guard.queue_now.as_ref() { - Some(QueueNowPlaying::Decoded(d)) if d.fetch_id == fetch_id => Arc::clone(&d.player), - _ => return, // superseded — the tempfile drops here - }; - let (tmp, quality) = match result { - Ok(fetched) => fetched, - Err(e) => { - guard.set_status_message(format!("Cannot play {track_name}: {e}"), 4); - guard.dispatch(IoEvent::AdvanceNativeQueue); - return; - } - }; - let path = tmp.path().to_path_buf(); - let decode_player = Arc::clone(&player); - drop(guard); - let staged = tokio::task::spawn_blocking(move || decode_player.stage_file(&path)) - .await - .map(|r| r.map_err(|e| e.to_string())) - .unwrap_or_else(|e| Err(e.to_string())); - let mut guard = app.lock().await; - if !matches!( - guard.queue_now.as_ref(), - Some(QueueNowPlaying::Decoded(d)) if d.fetch_id == fetch_id - ) { - return; - } - if let Err(e) = staged { - guard.set_status_message(format!("Cannot play {track_name}: {e}"), 4); - guard.dispatch(IoEvent::AdvanceNativeQueue); - return; - } - player.set_volume(guard.runtime_state.volume_percent); - // A user pause, or a device removal, holds the next item paused. - if guard.queue_slot_desired_playing { - player.resume(); - } - if let Some(QueueNowPlaying::Decoded(d)) = guard.queue_now.as_mut() { - d.tempfile = Some(tmp); - d.quality = quality; - d.advancing = false; - } - guard.set_status_message(format!("\u{266a} {track_name} (queue)"), 4); - #[cfg(feature = "streaming")] - preload_next_queued_spotify(&guard); -} - -/// Publish the decoded queue slot and announce the track. Only the local-file -/// path finalizes synchronously through here (it plays straight from disk); -/// downloaded sources finalize via [`finish_decoded_fetch`]. -#[cfg(feature = "local-files")] -async fn publish_decoded( - app: &Arc>, - player: Arc, - track: TrackInfo, - #[cfg(feature = "queue-download")] tempfile: Option, - #[cfg(not(feature = "queue-download"))] _tempfile: Option<()>, -) { - use crate::infra::queue::{DecodedQueuePlayback, QueueNowPlaying}; - let name = track.name.clone(); - let mut guard = app.lock().await; - // A user pause, or a device removal, holds the next item paused. - if guard.queue_slot_desired_playing { - player.resume(); - } - guard.queue_now = Some(QueueNowPlaying::Decoded(DecodedQueuePlayback { - player, - track, - advancing: false, - fetch_id: next_fetch_id(), - #[cfg(feature = "queue-download")] - tempfile, - quality: None, - })); - guard.set_status_message(format!("\u{266a} {name} (queue)"), 4); - #[cfg(feature = "streaming")] - preload_next_queued_spotify(&guard); -} - -/// Acquire an output-device player for the queue slot, in priority order: -/// 1. reuse the queue slot's own player (advancing within the queue); -/// 2. reuse the suspended decoded context's player (device-handoff-free); -/// 3. open a fresh device. -/// -/// Callers must [`release_librespot`] *before* acquiring, not just on the -/// fresh-device path: the outgoing queue slot can be a still-playing Spotify -/// track (mid-track skip / Enter-jump), and on the reuse paths nothing else -/// silences it — it would keep playing under the whole download window. -#[cfg(feature = "audio-decode-queue")] -async fn acquire_queue_player(app: &Arc>) -> Option> { - if let Some(p) = { - let guard = app.lock().await; - guard.queue_now_decoded_player().map(Arc::clone) - } { - return Some(p); - } - if let Some(p) = suspended_context_player(app).await { - return Some(p); - } - match tokio::task::spawn_blocking(LocalPlayer::new).await { - Ok(Ok(p)) => Some(Arc::new(p)), - Ok(Err(e)) => { - set_status(app, format!("No audio output for queue playback: {e}")).await; - None - } - Err(e) => { - set_status(app, format!("Audio output init failed: {e}")).await; - None - } - } -} - -/// The player of whichever decoded context (local / Subsonic / YouTube) is -/// currently suspended under the queue, so the queue slot can reuse its output -/// device. Radio is excluded: it is torn down at suspension (a live stream can't -/// share the sink), so the queue opens a fresh player and reconnects on resume. -/// That exclusion is exactly why this is gated on the three queueable sources -/// rather than `audio-decode` — under radio alone every arm below is cfg'd out -/// and the function is unreachable. -#[cfg(feature = "audio-decode-queue")] -async fn suspended_context_player(app: &Arc>) -> Option> { - let guard = app.lock().await; - #[cfg(feature = "local-files")] - if let Some(s) = guard.local_playback.as_ref() { - return Some(Arc::clone(&s.player)); - } - #[cfg(feature = "subsonic")] - if let Some(s) = guard.subsonic_playback.as_ref() { - return Some(Arc::clone(&s.player)); - } - #[cfg(feature = "qobuz")] - if let Some(s) = guard.qobuz_playback.as_ref() { - return Some(Arc::clone(&s.player)); - } - #[cfg(feature = "youtube")] - if let Some(s) = guard.youtube_playback.as_ref() { - return Some(Arc::clone(&s.player)); - } - None -} - -/// Pause native Spotify before a decoded queue item takes over: it both -/// releases the output device (when a fresh one is opened) and silences a -/// still-playing queued Spotify track that is being skipped mid-play. Called -/// unconditionally at the top of every decoded queue-play path — a Spirc pause -/// on an already-paused or idle librespot is a no-op. -#[cfg(feature = "audio-decode-queue")] -async fn release_librespot(app: &Arc>) { - #[cfg(feature = "streaming")] - { - let streaming = app.lock().await.streaming_player.clone(); - if let Some(player) = streaming { - player.pause(); - } - } - #[cfg(not(feature = "streaming"))] - { - let _ = app; - } -} - -#[cfg(feature = "local-files")] -async fn apply_volume(app: &Arc>, player: &Arc) { - let volume = app.lock().await.runtime_state.volume_percent; - player.set_volume(volume); -} - -// --------------------------------------------------------------------------- -// Resume -// --------------------------------------------------------------------------- - -/// Queue drained: resume the suspended context, or finish if nothing was -/// suspended. The queue slot's player is stopped only when it is **not** shared -/// with the context being resumed (`Arc::ptr_eq`). -async fn resume_or_finish(app: &Arc>) { - #[cfg(any(feature = "queue", feature = "internet-radio"))] - use crate::core::queue::SuspendedContext; - - let suspended = { app.lock().await.queue_suspended.take() }; - // The slot's desired play state carries into the resume, then resets: a - // device removal that paused the slot keeps the context paused, and the - // next queue episode starts playing. - #[cfg(feature = "queue")] - let playing = std::mem::replace(&mut app.lock().await.queue_slot_desired_playing, true); - #[cfg(not(feature = "queue"))] - #[allow(unused_variables)] - let playing = true; - - // The slot can still be a *playing* Spotify track when the drain came from a - // mid-play skip (only unplayable items were left); silence it before anything - // resumes over it. A naturally-ended slot was already cleared at EndOfTrack. - #[cfg(feature = "streaming")] - { - let player = { - let guard = app.lock().await; - if guard.queue_now_is_spotify() { - guard.streaming_player.clone() - } else { - None - } - }; - if let Some(player) = player { - player.pause(); - } - } - - // Take the queue slot's player so we can decide whether to stop it. - #[cfg(feature = "audio-decode-queue")] - let queue_player = { app.lock().await.take_queue_now_decoded_player() }; - #[cfg(all(feature = "streaming", not(feature = "audio-decode-queue")))] - { - app.lock().await.queue_now = None; - } - - // The slot gave its device up (see the driver's tick): a decoded context - // sharing that player cannot restage onto it either. End it with the device - // error instead of a failed track. - #[cfg(feature = "audio-decode-queue")] - if let Some(dead) = queue_player.as_ref().filter(|p| p.device_lost()) { - let mut guard = app.lock().await; - if drop_context_sharing(&mut guard, suspended.as_ref(), dead) { - guard.set_status_message("Audio output device disconnected.".to_string(), 8); - return; - } - } - - match suspended { - None => { - // Nothing was suspended: the queue was playing over an idle app (or a - // context finished before the queue started). Stop the slot and note it. - #[cfg(feature = "audio-decode-queue")] - if let Some(player) = queue_player { - player.stop(); - app - .lock() - .await - .set_status_message("Queue finished".to_string(), 3); - } - } - #[cfg(feature = "local-files")] - Some(SuspendedContext::Local { - resume_index, - resume_position_ms, - }) => resume_local(app, resume_index, resume_position_ms, queue_player, playing).await, - #[cfg(feature = "subsonic")] - Some(SuspendedContext::Subsonic { - resume_index, - resume_position_ms, - }) => resume_subsonic(app, resume_index, resume_position_ms, queue_player, playing).await, - #[cfg(feature = "qobuz")] - Some(SuspendedContext::Qobuz { - resume_index, - resume_position_ms, - }) => resume_qobuz(app, resume_index, resume_position_ms, queue_player, playing).await, - #[cfg(feature = "youtube")] - Some(SuspendedContext::YouTube { - resume_index, - resume_position_ms, - }) => resume_youtube(app, resume_index, resume_position_ms, queue_player, playing).await, - #[cfg(feature = "internet-radio")] - Some(SuspendedContext::Radio { station }) => { - // Radio uses its own fresh player, so always stop the queue slot. A - // radio-only build has no queueable source, hence no slot to stop. - #[cfg(feature = "audio-decode-queue")] - if let Some(player) = queue_player { - player.stop(); - } - // A station cannot resume paused, so it stays off. - if !playing { - app - .lock() - .await - .set_status_message("Playback is paused - radio not resumed.".to_string(), 8); - } else if let Some(uri) = station.uri.clone() { - let mut guard = app.lock().await; - // Seed the browse table so the radio start path resolves the station. - guard.track_table.tracks = vec![station]; - guard.dispatch(IoEvent::StartPlayback(Some(uri), None, None)); - } - } - #[cfg(feature = "streaming")] - Some(SuspendedContext::SpotifyShuffled { - resume_index, - generation, - .. - }) => { - // The network handler reloads the session's app-owned track list at the - // resume index — same order, no refetch, no reshuffle. Stop the decoded - // queue slot if one exists. - #[cfg(feature = "audio-decode-queue")] - if let Some(player) = queue_player { - player.stop(); - } - let mut guard = app.lock().await; - guard.dispatch(IoEvent::ResumeNativeShuffleSession( - resume_index, - generation, - )); - // Lands behind the resume on the serial pump, so the reloaded session - // pauses instead of playing over a device the user just left. - if !playing { - guard.dispatch(IoEvent::PausePlayback); - } - } - #[cfg(feature = "streaming")] - Some(SuspendedContext::Spotify { - context_uri, - resume_track_uri, - }) => { - // The network handler re-loads the Spotify context (offset by the resume - // track) on the native device. Stop the decoded queue slot if one exists. - #[cfg(feature = "audio-decode-queue")] - if let Some(player) = queue_player { - player.stop(); - } - let mut guard = app.lock().await; - guard.dispatch(IoEvent::ResumeSpotifyContext(context_uri, resume_track_uri)); - if !playing { - guard.dispatch(IoEvent::PausePlayback); - } - } - // In slim builds `SuspendedContext` is uninhabited, so every arm above is - // cfg'd out and only `None` is reachable. - #[allow(unreachable_patterns)] - _ => - { - #[cfg(feature = "audio-decode-queue")] - if let Some(player) = queue_player { - player.stop(); - } - } - } -} - -/// Drop the suspended decoded context whose player is `dead` (the queue slot -/// shared it), returning whether there was one. -#[cfg(feature = "audio-decode-queue")] -fn drop_context_sharing( - app: &mut App, - suspended: Option<&crate::core::queue::SuspendedContext>, - dead: &Arc, -) -> bool { - use crate::core::queue::SuspendedContext; - match suspended { - #[cfg(feature = "local-files")] - Some(SuspendedContext::Local { .. }) => app - .local_playback - .take_if(|s| Arc::ptr_eq(&s.player, dead)) - .is_some(), - #[cfg(feature = "subsonic")] - Some(SuspendedContext::Subsonic { .. }) => app - .subsonic_playback - .take_if(|s| Arc::ptr_eq(&s.player, dead)) - .is_some(), - #[cfg(feature = "qobuz")] - Some(SuspendedContext::Qobuz { .. }) => app - .qobuz_playback - .take_if(|s| Arc::ptr_eq(&s.player, dead)) - .is_some(), - #[cfg(feature = "youtube")] - Some(SuspendedContext::YouTube { .. }) => app - .youtube_playback - .take_if(|s| Arc::ptr_eq(&s.player, dead)) - .is_some(), - _ => false, - } -} - -#[cfg(feature = "local-files")] -async fn resume_local( - app: &Arc>, - resume_index: Option, - resume_position_ms: u64, - queue_player: Option>, - playing: bool, -) { - let Some(index) = resume_index else { - // Context exhausted: tear it down and stop the queue slot. - let ended = { - let mut guard = app.lock().await; - guard.release_decoded_sink_claim(); - guard.local_playback.take() - }; - if let Some(local) = ended { - Arc::clone(&local.player).stop_detached_holding(local); - } - if let Some(player) = queue_player { - player.stop(); - } - return; - }; - // Point the context at the resume track and keep it latched until play_index - // commits. Stop the queue slot only if it is a different player. - let shared = { - let mut guard = app.lock().await; - match guard.local_playback.as_mut() { - Some(local) => { - let shared = queue_player - .as_ref() - .is_some_and(|qp| Arc::ptr_eq(qp, &local.player)); - local.index = index; - local.advancing = true; - // Local has no retained tempfile; play_index re-reads from disk and - // applies this seek and pause to the restarted track. - local.resume_at = Some(super::ResumePoint { - position_ms: resume_position_ms, - paused: !playing, - }); - shared - } - None => false, - } - }; - if !shared { - if let Some(player) = queue_player { - player.stop(); - } - } - crate::infra::local::dispatch::play_index(app, index).await; -} - -#[cfg(feature = "subsonic")] -async fn resume_subsonic( - app: &Arc>, - resume_index: Option, - resume_position_ms: u64, - queue_player: Option>, - playing: bool, -) { - let Some(index) = resume_index else { - let ended = { - let mut guard = app.lock().await; - guard.release_decoded_sink_claim(); - guard.subsonic_playback.take() - }; - if let Some(s) = ended { - Arc::clone(&s.player).stop_detached_holding(s); - } - if let Some(player) = queue_player { - player.stop(); - } - return; - }; - let resume = super::ResumePoint { - position_ms: resume_position_ms, - paused: !playing, - }; - // Same track and its tempfile is still loaded (mid-track Enter-jump): replay - // the retained tempfile and seek, avoiding a re-download. Otherwise re-download - // the target index through the existing play_index machinery. - let replay = { - let mut guard = app.lock().await; - match guard.subsonic_playback.as_mut() { - Some(s) if index == s.index => { - s.advancing = true; - Some((Arc::clone(&s.player), s.tempfile.path().to_path_buf())) - } - Some(s) => { - s.index = index; - s.advancing = true; - s.resume_at = Some(resume); - None - } - None => None, - } - }; - // The queue slot shares the context player (reused at acquire time), so it is - // never stopped here — the same sink is reloaded on resume. - let _ = queue_player; - match replay { - Some((player, path)) => { - super::replay_file(player, path, Some(resume)).await; - // Clear the latch either way: on failure the sink is empty, so leaving - // `advancing = true` would wedge the runner tick's advance off forever. - if let Some(s) = app.lock().await.subsonic_playback.as_mut() { - s.advancing = false; - } - } - None => crate::infra::subsonic::dispatch::play_index(app, index).await, - } -} - -#[cfg(feature = "qobuz")] -async fn resume_qobuz( - app: &Arc>, - resume_index: Option, - resume_position_ms: u64, - queue_player: Option>, - playing: bool, -) { - let Some(index) = resume_index else { - // Take under the lock, stop off it: a sink clear waits for the audio thread. - let session = { - let mut guard = app.lock().await; - guard.release_decoded_sink_claim(); - guard.qobuz_playback.take() - }; - if let Some(s) = session { - Arc::clone(&s.player).stop_detached_holding(s); - } - if let Some(player) = queue_player { - player.stop(); - } - return; - }; - let resume = super::ResumePoint { - position_ms: resume_position_ms, - paused: !playing, - }; - // Same track with its tempfile still on disk: replay it and seek, with no - // second download. Otherwise re-download the target through play_index. - let replay = { - let mut guard = app.lock().await; - match guard.qobuz_playback.as_mut() { - Some(s) if index == s.index && s.tempfile.is_some() => { - s.advancing = true; - s.tempfile - .as_ref() - .map(|t| (Arc::clone(&s.player), t.path().to_path_buf())) - } - Some(_) => None, - None => return, - } - }; - // The queue slot shares the context player, so it is never stopped here. - let _ = queue_player; - match replay { - Some((player, path)) => { - super::replay_file(player, path, Some(resume)).await; - if let Some(s) = app.lock().await.qobuz_playback.as_mut() { - s.advancing = false; - } - } - None => crate::infra::qobuz::dispatch::play_index(app, index, Some(resume)).await, - } -} - -#[cfg(feature = "youtube")] -async fn resume_youtube( - app: &Arc>, - resume_index: Option, - resume_position_ms: u64, - queue_player: Option>, - playing: bool, -) { - let Some(index) = resume_index else { - let ended = { - let mut guard = app.lock().await; - guard.release_decoded_sink_claim(); - guard.youtube_playback.take() - }; - if let Some(s) = ended { - Arc::clone(&s.player).stop_detached_holding(s); - } - if let Some(player) = queue_player { - player.stop(); - } - return; - }; - let resume = super::ResumePoint { - position_ms: resume_position_ms, - paused: !playing, - }; - let replay = { - let mut guard = app.lock().await; - match guard.youtube_playback.as_mut() { - Some(s) if index == s.index => { - s.advancing = true; - Some((Arc::clone(&s.player), s.tempfile.path().to_path_buf())) - } - Some(s) => { - s.index = index; - s.advancing = true; - s.resume_at = Some(resume); - None - } - None => None, - } - }; - let _ = queue_player; - match replay { - Some((player, path)) => { - super::replay_file(player, path, Some(resume)).await; - // Clear the latch either way: on failure the sink is empty, so leaving - // `advancing = true` would wedge the runner tick's advance off forever. - if let Some(s) = app.lock().await.youtube_playback.as_mut() { - s.advancing = false; - } - } - None => crate::infra::youtube::dispatch::play_index(app, index).await, - } -} - -async fn set_status(app: &Arc>, message: String) { - app.lock().await.set_status_message(message, 4); -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::user_config::UserConfig; - use std::sync::mpsc::channel; - use std::time::SystemTime; - - #[cfg(any( - feature = "streaming", - not(all(feature = "qobuz", feature = "subsonic")) - ))] - fn track(uri: &str, name: &str) -> TrackInfo { - TrackInfo { - uri: Some(uri.to_string()), - name: name.to_string(), - artists: vec!["Artist".to_string()], - album: "Album".to_string(), - duration_ms: 1000, - id: None, - album_id: None, - artist_refs: vec![], - is_playable: true, - is_local: false, - track_number: 0, - explicit: false, - image_url: None, - } - } - - fn test_app() -> Arc> { - let (tx, _rx) = channel(); - Arc::new(Mutex::new(App::new( - tx, - UserConfig::new(), - Some(SystemTime::now()), - ))) - } - - /// Like [`test_app`] but keeps the receiver so a test can inspect the events - /// the drain path dispatches. - #[cfg(feature = "streaming")] - fn test_app_with_rx() -> (Arc>, std::sync::mpsc::Receiver) { - let (tx, rx) = channel(); - ( - Arc::new(Mutex::new(App::new( - tx, - UserConfig::new(), - Some(SystemTime::now()), - ))), - rx, - ) - } - - /// A queued item whose source feature is off in this build must be skipped - /// with an actionable status message — never panic, never stall the queue. - /// In the slim CI build every alternative source is unavailable, so a - /// `subsonic:` item exercises exactly that path. - #[cfg(not(feature = "qobuz"))] - #[tokio::test] - async fn advance_skips_unavailable_qobuz_item_without_panicking() { - let app = test_app(); - app - .lock() - .await - .native_queue - .push(track("qobuz:track:1", "Unplayable")); - - assert!(route_queue_event(&app, &IoEvent::AdvanceNativeQueue).await); - - let guard = app.lock().await; - assert!(guard.native_queue.is_empty(), "the item is consumed"); - assert!( - guard - .status_message() - .is_some_and(|m| m.contains("Qobuz playback isn't available in this build")), - "expected an unavailable-source message, got {:?}", - guard.status_message() - ); - } - - #[cfg(not(feature = "subsonic"))] - #[tokio::test] - async fn advance_skips_unavailable_source_without_panicking() { - let app = test_app(); - app - .lock() - .await - .native_queue - .push(track("subsonic:track:1", "Unplayable")); - - assert!(route_queue_event(&app, &IoEvent::AdvanceNativeQueue).await); - - let guard = app.lock().await; - assert!(guard.native_queue.is_empty(), "the item is consumed"); - assert!( - guard - .status_message() - .is_some_and(|m| m.contains("isn't available in this build")), - "expected an unavailable-source message, got {:?}", - guard.status_message() - ); - } - - /// An empty queue with nothing suspended is a no-op advance: it must not - /// panic and must leave the queue empty. - #[tokio::test] - async fn advance_on_empty_queue_is_a_noop() { - let app = test_app(); - assert!(route_queue_event(&app, &IoEvent::AdvanceNativeQueue).await); - assert!(app.lock().await.native_queue.is_empty()); - } - - /// A plugin's `set_repeat` while the queue slot owns playback must be - /// refused, not forwarded to Spotify (#376). Without a slot it falls through. - #[cfg(feature = "streaming")] - #[tokio::test] - async fn repeat_is_refused_while_the_queue_slot_owns_playback() { - use crate::infra::queue::QueueNowPlaying; - let app = test_app(); - let repeat = IoEvent::Repeat(rspotify::model::enums::RepeatState::Off); - assert!(!route_queue_event(&app, &repeat).await); - - app.lock().await.queue_now = Some(QueueNowPlaying::Spotify { - track: track("spotify:track:queued", "Queued"), - }); - assert!(route_queue_event(&app, &repeat).await); - assert_eq!( - app.lock().await.status_message(), - Some("Repeat does not apply to this source") - ); - } - - #[cfg(feature = "streaming")] - #[tokio::test] - async fn next_track_is_consumed_by_spotify_queue_slot() { - use crate::infra::queue::QueueNowPlaying; - let app = test_app(); - app.lock().await.queue_now = Some(QueueNowPlaying::Spotify { - track: track("spotify:track:queued", "Queued"), - }); - - assert!(route_queue_event(&app, &IoEvent::NextTrack).await); - } - - #[cfg(feature = "streaming")] - #[tokio::test] - async fn spotify_queue_slot_consumes_transport_controls() { - use crate::core::queue::SuspendedContext; - use crate::infra::queue::QueueNowPlaying; - let app = test_app(); - { - let mut guard = app.lock().await; - guard.queue_now = Some(QueueNowPlaying::Spotify { - track: track("spotify:track:queued", "Queued"), - }); - guard.queue_suspended = Some(SuspendedContext::Spotify { - context_uri: Some("spotify:playlist:ctx".to_string()), - resume_track_uri: Some("spotify:track:resume".to_string()), - }); - } - - assert!(route_queue_event(&app, &IoEvent::PausePlayback).await); - assert!(route_queue_event(&app, &IoEvent::StartPlayback(None, None, None)).await); - - let guard = app.lock().await; - assert!(guard.queue_now_is_spotify()); - assert!(guard.queue_suspended.is_some()); - assert_eq!(guard.native_is_playing, Some(true)); - } - - #[cfg(feature = "streaming")] - #[tokio::test] - async fn new_playback_clears_spotify_queue_slot() { - use crate::core::queue::SuspendedContext; - use crate::infra::queue::QueueNowPlaying; - let app = test_app(); - { - let mut guard = app.lock().await; - guard.queue_now = Some(QueueNowPlaying::Spotify { - track: track("spotify:track:queued", "Queued"), - }); - guard.queue_suspended = Some(SuspendedContext::Spotify { - context_uri: Some("spotify:playlist:ctx".to_string()), - resume_track_uri: Some("spotify:track:resume".to_string()), - }); - } - - assert!( - !route_queue_event( - &app, - &IoEvent::StartPlayback(Some("spotify:playlist:new".to_string()), None, None) - ) - .await - ); - - let guard = app.lock().await; - assert!(!guard.queue_owns_playback()); - assert!(guard.queue_suspended.is_none()); - } - - /// Pause/resume of the Spotify queue slot must persist desired-playing state - /// that survives a backend teardown, so a recovery replay of the slot honors - /// a user's pause instead of restarting the track. - #[cfg(feature = "streaming")] - #[tokio::test] - async fn spotify_slot_pause_and_resume_track_desired_playing() { - use crate::infra::queue::QueueNowPlaying; - let app = test_app(); - { - let mut guard = app.lock().await; - guard.queue_now = Some(QueueNowPlaying::Spotify { - track: track("spotify:track:queued", "Queued"), - }); - guard.queue_slot_desired_playing = true; - } - - assert!(route_queue_event(&app, &IoEvent::PausePlayback).await); - { - let guard = app.lock().await; - assert!(!guard.queue_slot_desired_playing); - assert_eq!(guard.native_is_playing, Some(false)); - } - - assert!(route_queue_event(&app, &IoEvent::StartPlayback(None, None, None)).await); - { - let guard = app.lock().await; - assert!(guard.queue_slot_desired_playing); - assert_eq!(guard.native_is_playing, Some(true)); - } - } - - /// Recovery must replay a playing queue slot through the play path. With no - /// player installed, that path reports the expected skip instead of silently - /// treating the slot as paused. - #[cfg(feature = "streaming")] - #[tokio::test] - async fn replay_playing_spotify_slot_uses_play_path() { - use crate::infra::queue::QueueNowPlaying; - let app = test_app(); - { - let mut guard = app.lock().await; - guard.queue_now = Some(QueueNowPlaying::Spotify { - track: track("spotify:track:queued", "Queued"), - }); - guard.queue_slot_desired_playing = true; - } - - assert!(!replay_published_spotify_slot(&app).await); - assert!( - app - .lock() - .await - .status_message() - .is_some_and(|message| message.contains("Native streaming isn't connected")), - "a playing slot should take the play path" - ); - } - - /// A paused queue slot must take the paused-load path. With no player, that - /// path is a quiet no-op and, importantly, never falls through to play. - #[cfg(feature = "streaming")] - #[tokio::test] - async fn replay_paused_spotify_slot_does_not_use_play_path() { - use crate::infra::queue::QueueNowPlaying; - let app = test_app(); - { - let mut guard = app.lock().await; - guard.queue_now = Some(QueueNowPlaying::Spotify { - track: track("spotify:track:queued", "Queued"), - }); - guard.queue_slot_desired_playing = false; - } - - assert!(!replay_published_spotify_slot(&app).await); - assert!( - app.lock().await.status_message().is_none(), - "a paused slot must not take the play path" - ); - } - - /// The recovery event is queue-owned even after an external-device handoff - /// has cleared the published slot. It must never fall through to the Spotify - /// Web API handler and accidentally reclaim playback. - #[cfg(feature = "streaming")] - #[tokio::test] - async fn replay_event_is_consumed_after_external_handoff_clears_slot() { - let app = test_app(); - assert!( - route_queue_event(&app, &IoEvent::ReplayPublishedSpotifyQueueSlot).await, - "the queue router must consume replay even when there is no slot left" - ); - } - - /// When the queue drains over a suspended shuffle session, the resume forwards - /// both the snapshotted index *and* the session generation, so the handler can - /// preserve the existing shuffle order (reloading it in place at that index) - /// while rejecting a resume whose session was replaced mid-drain. - #[cfg(feature = "streaming")] - #[tokio::test] - async fn resume_shuffled_forwards_index_and_generation() { - use crate::core::queue::SuspendedContext; - let (app, rx) = test_app_with_rx(); - app.lock().await.queue_suspended = Some(SuspendedContext::SpotifyShuffled { - resume_index: Some(2), - generation: 7, - context_uri: None, - resume_track_uri: None, - }); - - assert!(route_queue_event(&app, &IoEvent::AdvanceNativeQueue).await); - - assert!( - matches!( - rx.try_recv(), - Ok(IoEvent::ResumeNativeShuffleSession(Some(2), 7)) - ), - "the drain must forward the resume index and its session generation" - ); - assert!(app.lock().await.queue_suspended.is_none()); - } - - /// An exhausted shuffle session (`resume_index == None`) still forwards its - /// generation, so the handler finishes the *right* session and leaves a newer - /// one running. - #[cfg(feature = "streaming")] - #[tokio::test] - async fn resume_exhausted_shuffled_forwards_none_and_generation() { - use crate::core::queue::SuspendedContext; - let (app, rx) = test_app_with_rx(); - app.lock().await.queue_suspended = Some(SuspendedContext::SpotifyShuffled { - resume_index: None, - generation: 4, - context_uri: None, - resume_track_uri: None, - }); - - assert!(route_queue_event(&app, &IoEvent::AdvanceNativeQueue).await); - - assert!( - matches!( - rx.try_recv(), - Ok(IoEvent::ResumeNativeShuffleSession(None, 4)) - ), - "an exhausted session still forwards its generation" - ); - assert!(app.lock().await.queue_suspended.is_none()); - } - - /// A live end-to-end queue test: browse a Subsonic playlist, start it, queue a - /// track from mid-playlist, then advance the native queue. Asserts the - /// suspended context (index + tempfile) survives the queue playback so it can - /// resume. Ignored (needs the demo server AND an audio device); run: - /// `cargo test --features subsonic -- --ignored live_queue` - #[cfg(feature = "subsonic")] - #[tokio::test] - #[ignore = "hits the live demo server AND requires an audio output device"] - async fn live_queue_suspends_and_preserves_subsonic_context() { - use crate::infra::subsonic::dispatch::route_subsonic_event; - - let app = { - let (tx, _rx) = channel(); - let mut a = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - a.user_config.behavior.subsonic_url = Some("https://demo.navidrome.org".to_string()); - a.user_config.behavior.subsonic_username = Some("demo".to_string()); - a.user_config.behavior.subsonic_password = Some("demo".to_string()); - Arc::new(Mutex::new(a)) - }; - - assert!(route_subsonic_event(&app, &IoEvent::GetSubsonicPlaylists).await); - let playlist_uri = app - .lock() - .await - .subsonic_playlists - .first() - .unwrap() - .uri - .clone(); - assert!(route_subsonic_event(&app, &IoEvent::GetSubsonicTracks(playlist_uri)).await); - let tracks: Vec = app.lock().await.track_table.tracks.clone(); - assert!(tracks.len() >= 3, "need a multi-track playlist"); - let uris: Vec = tracks.iter().filter_map(|t| t.uri.clone()).collect(); - - // Start the playlist at index 0. - assert!(route_subsonic_event(&app, &IoEvent::StartPlayback(None, Some(uris), Some(0))).await); - - // Queue a track from later in the playlist, then advance the native queue - // (as an end-of-track suspension would). - { - let mut guard = app.lock().await; - guard.native_queue.push(tracks[2].clone()); - guard.queue_suspended = Some(crate::core::queue::SuspendedContext::Subsonic { - resume_index: crate::infra::queue::next_index(0, tracks.len()), - resume_position_ms: 0, - }); - if let Some(s) = guard.subsonic_playback.as_mut() { - s.advancing = true; - } - } - assert!(route_queue_event(&app, &IoEvent::AdvanceNativeQueue).await); - - let guard = app.lock().await; - let s = guard.subsonic_playback.as_ref().expect("context preserved"); - assert_eq!(s.index, 0, "the suspended context index is untouched"); - assert!( - guard.queue_owns_playback(), - "the queue slot now owns playback" - ); - } -} diff --git a/src/infra/queue/mod.rs b/src/infra/queue/mod.rs deleted file mode 100644 index 5b30a481..00000000 --- a/src/infra/queue/mod.rs +++ /dev/null @@ -1,789 +0,0 @@ -//! Native cross-source queue playback engine. -//! -//! [`App::native_queue`](crate::core::app::App::native_queue) holds the queue -//! state, while this module is the engine that consumes it: -//! [`dispatch::route_queue_event`] -//! plays queued tracks through the shared decoded-audio sink, overlaying the -//! per-source playback contexts without mutating them, and resumes the -//! underlying context once the queue drains. -//! -//! ## Playback slot vs. suspended context -//! -//! [`QueueNowPlaying`] is what the queue slot is *currently* playing. It never -//! touches the per-source `*_playback` structs — those are the context to -//! resume, recorded in `App::queue_suspended`. When the suspended context is a -//! decoded source, the queue slot **reuses that context's `Arc`** -//! (the sink is reloaded with the queued track); only when Spotify or nothing is -//! suspended does it open a fresh player. This keeps the "never two live players -//! on one output device" invariant. - -pub mod dispatch; - -#[cfg(feature = "queue-download")] -use crate::core::plugin_api::TrackInfo; - -/// Snapshot the `TrackInfo`s for `uris` in order from the track table, then -/// the search results (a play can come from either view). Unknown URIs are -/// dropped. -#[cfg(feature = "queue-download")] -pub fn snapshot_tracks( - table: &[TrackInfo], - search: Option<&[TrackInfo]>, - uris: &[String], -) -> Vec { - uris - .iter() - .filter_map(|uri| { - let matches = |t: &&TrackInfo| t.uri.as_deref() == Some(uri.as_str()); - table - .iter() - .find(matches) - .or_else(|| search.and_then(|s| s.iter().find(matches))) - .cloned() - }) - .collect() -} - -/// The runner-tick decision at a decoded source's auto-advance point, once the -/// native queue is in the picture. -/// -/// Pure data so the full decision table is unit-testable without an audio -/// device. `#[allow(dead_code)]` because a slim build (no source features) never -/// calls it — clippy's slim run does not compile the tests that do. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[allow(dead_code)] -pub enum Decision { - /// Nothing to do (still playing, or a track change is already in flight). - None, - /// Advance within the source's own context (the existing `NextTrack` path). - AdvanceContext, - /// Suspend the context and hand the sink to the native queue. - SuspendToQueue, - /// Context exhausted and the queue is empty: tear the session down. - Teardown, - /// Repeat-one is active: replay the current track instead of advancing. - RepeatTrack, -} - -/// Repeat mode for a decoded (non-Spotify) source's auto-advance / skip logic. -/// -/// The canonical repeat state for the decoded sources (`App::decoded_repeat`), -/// kept source-neutral here so the pure queue/source modules never name an -/// rspotify type (translated to `RepeatState` only at the Spotify-shaped edges, -/// e.g. the media-metadata snapshot). `#[allow(dead_code)]` for the same reason -/// as [`Decision`]: a slim build never constructs it. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] -#[allow(dead_code)] -pub enum RepeatMode { - /// No repeat: advance then tear down at the end of the context. - #[default] - Off, - /// Repeat the whole context: wrap around at the ends, never tear down. - Context, - /// Repeat the current track: replay it when it ends. - Track, -} - -impl RepeatMode { - /// The next mode in the user-facing cycle, mirroring Spotify's - /// Off -> Repeat All -> Repeat One -> Off. - #[allow(dead_code)] - pub fn next(self) -> Self { - match self { - RepeatMode::Off => RepeatMode::Context, - RepeatMode::Context => RepeatMode::Track, - RepeatMode::Track => RepeatMode::Off, - } - } -} - -/// Decide what a decoded source should do when its current track ends. -/// -/// - `finished` / `advancing`: the source's live end-of-track + in-flight guard. -/// - `has_next`: whether the source's own context has a following track. -/// - `queue_len`: the number of items waiting in the native queue. -/// -/// The native queue takes priority: whenever it is non-empty and a track just -/// ended, the context is suspended (regardless of whether it has a next track; -/// `resume_index` is computed separately and is `None` when the context is -/// exhausted). Only with an empty queue does the source's own advance / teardown -/// behavior apply. -/// -/// With an empty queue, `repeat` decides the fallback: -/// - `Track` — replay the current track ([`Decision::RepeatTrack`]). -/// - `Context` — always advance; the actual wrap-around to the first track is -/// handled by [`advance_index`] on the ensuing `NextTrack`, so this never -/// tears down. -/// - `Off` — advance if there is a next track, else tear down (the original -/// behavior). -#[allow(dead_code)] -pub fn advance_decision( - finished: bool, - advancing: bool, - has_next: bool, - queue_len: usize, - repeat: RepeatMode, -) -> Decision { - if !finished || advancing { - return Decision::None; - } - if queue_len > 0 { - return Decision::SuspendToQueue; - } - match repeat { - RepeatMode::Track => Decision::RepeatTrack, - RepeatMode::Context => Decision::AdvanceContext, - RepeatMode::Off => { - if has_next { - Decision::AdvanceContext - } else { - Decision::Teardown - } - } - } -} - -/// The index to move to when skipping/advancing within a decoded source's queue. -/// -/// `forward` selects Next (`true`) vs Previous (`false`). Under -/// [`RepeatMode::Context`] the ends wrap around (last→first, first→last); under -/// `Off`/`Track` the index clamps and returns `None` at a boundary (a manual -/// skip past the boundary is then a no-op — repeat-one only affects *auto* -/// advance, which replays via [`Decision::RepeatTrack`], never this helper). -#[allow(dead_code)] -pub fn advance_index( - current: usize, - len: usize, - repeat: RepeatMode, - forward: bool, -) -> Option { - match repeat { - RepeatMode::Context if len > 0 => Some(if forward { - (current + 1) % len - } else { - (current + len - 1) % len - }), - // Off / Track / empty queue: clamp at the boundaries. - _ => { - if forward { - if len == 0 || current + 1 >= len { - None - } else { - Some(current + 1) - } - } else if len == 0 || current == 0 { - None - } else { - Some(current - 1) - } - } - } -} - -/// Why a decoded context was handed off to the native queue, which is what -/// decides where it resumes once the queue drains. -/// -/// The distinction only matters under [`RepeatMode::Track`], where the repo-wide -/// rule is that repeat-one affects *auto* advance but not a *manual* skip (see -/// [`advance_index`]). Both handoffs otherwise resume the context's next track. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -#[allow(dead_code)] -pub enum SuspendCause { - /// The context's track ended on its own and the queue preempted the context's - /// own advance. - AutoAdvance, - /// The user pressed Next while items were waiting in the queue. - ManualSkip, -} - -/// The index a decoded context should resume at once the native queue drains, -/// having been suspended with skip semantics (resume at position 0). -/// -/// `None` means something different here than it does in [`advance_index`], and -/// that is the whole reason this function exists. To `advance_index`, `None` is -/// "clamp: the skip is a no-op, keep playing". To every caller of *this* -/// function it is "context exhausted: tear it down and stop" (see the -/// `let Some(index) = resume_index else { … }` arms in [`dispatch`]). The two -/// readings coincide only under [`RepeatMode::Off`], where exhaustion really is -/// the right end state — so under [`RepeatMode::Track`] this never returns -/// `None` for an in-range `current`: repeat-one's contract is that the context -/// never ends. -/// -/// - [`SuspendCause::AutoAdvance`] under `Track` resumes *the same track*: the -/// context is repeating it and a queued song must not consume the repeat. -/// [`advance_decision`] returns [`Decision::SuspendToQueue`] before it ever -/// consults `repeat`, so this is the only thing keeping repeat-one alive -/// across the queue. -/// - [`SuspendCause::ManualSkip`] under `Track` advances — the user asked to -/// move on — but clamps back onto the current track at the end of the context -/// instead of tearing it down, matching the no-queue skip path, which leaves -/// the last track repeating. -/// -/// Every other mode defers to [`advance_index`]: `Context` wraps last→first (and -/// never yields `None`), `Off` clamps to `None` at the end. -#[allow(dead_code)] -pub fn resume_index_after_queue( - current: usize, - len: usize, - repeat: RepeatMode, - cause: SuspendCause, -) -> Option { - // Guard `current < len` so an out-of-range index falls through to - // `advance_index` rather than resuming a track that isn't there. - if repeat == RepeatMode::Track && current < len { - return match cause { - SuspendCause::AutoAdvance => Some(current), - SuspendCause::ManualSkip => advance_index(current, len, repeat, true).or(Some(current)), - }; - } - advance_index(current, len, repeat, true) -} - -/// The index of the track after `current` in a queue of `len` tracks, clamped -/// at the end: [`advance_index`] under [`RepeatMode::Off`], going forward. -/// -/// Returns `None` when `current` is already the last track (or the queue is -/// empty), signalling "no next track" — used to compute `has_next` for -/// [`advance_decision`] and the resume index when suspending to the native -/// queue (both of which ignore repeat wrap-around by design). -#[allow(dead_code)] -pub fn next_index(current: usize, len: usize) -> Option { - advance_index(current, len, RepeatMode::Off, true) -} - -/// The permutation applied by an in-place shuffle, letting a later un-shuffle -/// restore the exact original order and index. `perm[i]` is the original index -/// of the track now at shuffled position `i`, so restoring needs no equality -/// checks (correct even with duplicate tracks) and no copy of the queue. -#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -#[allow(dead_code)] -pub struct ShuffleBackup { - perm: Vec, -} - -impl ShuffleBackup { - /// Whether `perm` is a complete permutation of `0..len`: exactly `len` entries, - /// each in range and appearing once. A backup produced by [`shuffle_in_place`] - /// is always valid, but one deserialized from a hand-edited or corrupted - /// `last_session.yml` may not be — [`restore_in_place`] checks this before its - /// unchecked indexing so a bad file degrades gracefully instead of panicking. - #[allow(dead_code)] - fn is_valid_for(&self, len: usize) -> bool { - if self.perm.len() != len { - return false; - } - let mut seen = vec![false; len]; - for &p in &self.perm { - if p >= len || seen[p] { - return false; - } - seen[p] = true; - } - true - } -} - -/// Turn in-place shuffle on or off for a decoded source's queue — the single -/// shared implementation behind every source's `set_shuffle`. Turning it on -/// reorders `items` keeping the track at `*index` at the front (`*index` -/// becomes `0`), so the currently-playing track is unaffected and playback -/// continues uninterrupted; turning it off restores the original order and -/// points `*index` back at the current track. Idempotent (a redundant on/off -/// is a no-op). -#[allow(dead_code)] -pub fn toggle_shuffle( - items: &mut Vec, - index: &mut usize, - backup: &mut Option, - on: bool, -) { - if on { - if backup.is_none() { - *backup = Some(shuffle_in_place(items, *index)); - *index = 0; - } - } else if let Some(b) = backup.take() { - *index = restore_in_place(items, &b, *index); - } -} - -/// Shuffle `items` in place, moving the track at `current` to the front and -/// randomizing the rest. This is the seam for a future native shuffle -/// algorithm: change the permutation built here and every source picks it up. -#[allow(dead_code)] -fn shuffle_in_place(items: &mut Vec, current: usize) -> ShuffleBackup { - use rand::seq::SliceRandom; - let len = items.len(); - // Permutation over original indices: move `current` to the front, shuffle the rest. - let mut perm: Vec = (0..len).collect(); - if current < len { - perm.swap(0, current); - } - if len > 1 { - perm[1..].shuffle(&mut rand::rng()); - } - // Reorder by moving items out of their old slots — no clones. - let mut slots: Vec> = std::mem::take(items).into_iter().map(Some).collect(); - *items = perm - .iter() - .map(|&i| slots[i].take().expect("perm is a permutation")) - .collect(); - ShuffleBackup { perm } -} - -/// Invert the shuffle permutation in place. `current` is the live index in the -/// *shuffled* queue; the returned index is the same track's position in the -/// restored original order. -#[allow(dead_code)] -fn restore_in_place(items: &mut Vec, backup: &ShuffleBackup, current: usize) -> usize { - if !backup.is_valid_for(items.len()) { - // Either the queue was replaced/resized since the backup (shouldn't happen — - // a new queue starts with a fresh backup) or the permutation was corrupted / - // hand-edited in `last_session.yml`. Keep the current order rather than index - // out of range or leave an unfilled slot for the `expect` below. - return current; - } - let index = backup.perm.get(current).copied().unwrap_or(0); - let mut slots: Vec> = (0..items.len()).map(|_| None).collect(); - for (i, item) in std::mem::take(items).into_iter().enumerate() { - slots[backup.perm[i]] = Some(item); - } - *items = slots - .into_iter() - .map(|slot| slot.expect("perm is a permutation")) - .collect(); - index -} - -/// Re-decode `path` into `player`'s sink, off the async runtime (repeat-one -/// replay: a drained rodio sink cannot seek, so the audio is decoded again). -/// Returns whether playback restarted. On `false` the caller must tear its -/// session down — an empty sink left in place would read as end-of-track and -/// re-fire replay every runner tick. -/// -/// Gated on the *queueable* decoded sources rather than `audio-decode`: replay -/// is the repeat-one path of a finite track list. Internet radio decodes audio -/// too, but a live stream has no track to re-decode. -#[cfg(feature = "audio-decode-queue")] -pub async fn replay_file( - player: std::sync::Arc, - path: std::path::PathBuf, - resume: Option, -) -> bool { - matches!( - tokio::task::spawn_blocking(move || restage(&player, &path, resume)).await, - Ok(Ok(())) - ) -} - -/// How the next staged track starts: seek here, and stay paused or not. -/// -/// Set by the driver's device recovery and by the native queue's resume, and -/// taken by whichever path stages the track (a replay, a `play_index`, or a -/// download's commit), so the seek and the pause are applied to the track -/// they belong to, and never as events that race it. -#[cfg(feature = "audio-decode-queue")] -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub struct ResumePoint { - pub position_ms: u64, - pub paused: bool, -} - -/// Stage `path` on `player` and apply `resume`: seek, then play unless it -/// says paused. Pause-before-play is what keeps a paused restage silent. -/// -/// **Blocking:** the stage clears the sink and decodes; call it off the async -/// runtime and off the `App` lock. -#[cfg(feature = "audio-decode-queue")] -pub fn restage( - player: &crate::infra::audio::LocalPlayer, - path: &std::path::Path, - resume: Option, -) -> anyhow::Result<()> { - player.stage_file(path)?; - let point = resume.unwrap_or_default(); - if point.position_ms > 0 { - let _ = player.seek(std::time::Duration::from_millis(point.position_ms)); - } - if !point.paused { - player.resume(); - } - Ok(()) -} - -/// A queued *decoded* track playing through the shared [`LocalPlayer`] sink -/// (local file, Subsonic, or YouTube). Kept separate from the per-source -/// `*_playback` structs so the underlying context is preserved for resume. -/// -/// Gated on exactly those three sources, not `audio-decode`: they are the ones -/// [`dispatch::try_play_queued`] can play. Internet radio pulls `audio-decode` -/// in as well, but a live stream is never a queue item, so a radio-only build -/// can never construct this. -#[cfg(feature = "audio-decode-queue")] -pub struct DecodedQueuePlayback { - /// The output-device sink. Shared (`Arc::ptr_eq`) with the suspended context's - /// player when there is one, so no second device is opened. - pub player: std::sync::Arc, - /// The queued track's metadata (drives the playbar / MPRIS / cover art). - pub track: crate::core::plugin_api::TrackInfo, - /// Guards the empty-sink window during a queue advance from being read as - /// end-of-track by the runner tick (mirrors the per-source `advancing` guard). - pub advancing: bool, - /// Generation stamp for the slot's background fetch. A Subsonic/YouTube - /// download runs off the IoEvent pump (so it never blocks other events); its - /// completion only plays and finalizes when the slot still carries the same - /// stamp — a skip or teardown in the meantime republished the slot with a new - /// one, and the stale result is silently discarded. Only *read* by the - /// Subsonic/YouTube fetch-completion path, so a build with neither (e.g. a - /// local-files-only build) writes it without reading it. - #[cfg_attr(not(feature = "queue-download"), allow(dead_code))] - pub fetch_id: u64, - /// The tempfile backing a downloaded track (Subsonic / YouTube). `None` for a - /// local file, which is played straight from disk. Held purely to keep the - /// file alive on disk for the duration of playback (dropped with the slot), so - /// it is never read back. - #[cfg(feature = "queue-download")] - #[allow(dead_code)] - pub tempfile: Option, - /// The delivered audio format of a downloaded track (Qobuz, e.g. - /// `FLAC 24/96`), shown after the artists in the playbar. - #[cfg_attr(not(feature = "tui"), allow(dead_code))] - pub quality: Option, -} - -/// What the native queue's playback slot is currently playing. -/// -/// A build with no *queueable* source (neither native streaming nor a decoded -/// source that owns a finite track list) cannot play a queued track at all, so -/// this type is gated to builds that can; the `App::queue_now` field shares that -/// gate, and every call site goes through the unconditional -/// `App::queue_owns_playback()` accessor. Note internet radio is deliberately -/// absent from both halves of the gate: it decodes audio, but a `radio:` URI is -/// never a queue item, so it can neither fill nor own this slot. -#[cfg(feature = "queue")] -pub enum QueueNowPlaying { - #[cfg(feature = "audio-decode-queue")] - Decoded(DecodedQueuePlayback), - /// A Spotify track playing via native streaming (`player.load`, no Spirc - /// context). - #[cfg(feature = "streaming")] - Spotify { - track: crate::core::plugin_api::TrackInfo, - }, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[cfg(feature = "queue-download")] - mod snapshot { - use super::*; - - fn track(uri: &str, name: &str) -> TrackInfo { - TrackInfo { - uri: Some(uri.to_string()), - name: name.to_string(), - artists: vec!["Artist".to_string()], - album: "Album".to_string(), - duration_ms: 1000, - id: None, - album_id: None, - artist_refs: vec![], - is_playable: true, - is_local: false, - track_number: 0, - explicit: false, - image_url: None, - } - } - - #[test] - fn snapshot_finds_tracks_in_table_preserving_order() { - let table = vec![ - track("subsonic:track:a", "A"), - track("subsonic:track:b", "B"), - ]; - let snap = snapshot_tracks( - &table, - None, - &[ - "subsonic:track:b".to_string(), - "subsonic:track:a".to_string(), - ], - ); - assert_eq!(snap.len(), 2); - assert_eq!(snap[0].name, "B"); - assert_eq!(snap[1].name, "A"); - } - - #[test] - fn snapshot_falls_back_to_search_results_for_search_to_play() { - // The track table holds a browsed playlist while the played uris come - // from the search results: both views must be consulted. - let table = vec![track("spotify:track:x", "Browsed")]; - let search = vec![track("youtube:searched", "Searched")]; - let snap = snapshot_tracks(&table, Some(&search), &["youtube:searched".to_string()]); - assert_eq!(snap.len(), 1); - assert_eq!(snap[0].name, "Searched"); - } - - #[test] - fn snapshot_drops_unknown_uris() { - let table = vec![track("qobuz:track:a", "A")]; - let snap = snapshot_tracks(&table, None, &["qobuz:track:missing".to_string()]); - assert!(snap.is_empty()); - } - } - - #[test] - fn advance_decision_full_table() { - use RepeatMode::Off; - // Not finished, or a change already in flight: never act. - assert_eq!(advance_decision(false, false, true, 3, Off), Decision::None); - assert_eq!(advance_decision(false, true, false, 0, Off), Decision::None); - assert_eq!(advance_decision(true, true, true, 3, Off), Decision::None); - - // Finished with a non-empty queue: always suspend to the queue, whether or - // not the context has a next track. - assert_eq!( - advance_decision(true, false, true, 1, Off), - Decision::SuspendToQueue - ); - assert_eq!( - advance_decision(true, false, false, 1, Off), - Decision::SuspendToQueue - ); - assert_eq!( - advance_decision(true, false, true, 5, Off), - Decision::SuspendToQueue - ); - - // Finished with an empty queue: fall back to the source's own behavior. - assert_eq!( - advance_decision(true, false, true, 0, Off), - Decision::AdvanceContext - ); - assert_eq!( - advance_decision(true, false, false, 0, Off), - Decision::Teardown - ); - } - - #[test] - fn advance_decision_repeat_modes() { - use RepeatMode::{Context, Track}; - // Repeat-one replays the current track whether or not a next track exists. - assert_eq!( - advance_decision(true, false, true, 0, Track), - Decision::RepeatTrack - ); - assert_eq!( - advance_decision(true, false, false, 0, Track), - Decision::RepeatTrack - ); - // Repeat-context always advances (never tears down); the wrap is in advance_index. - assert_eq!( - advance_decision(true, false, true, 0, Context), - Decision::AdvanceContext - ); - assert_eq!( - advance_decision(true, false, false, 0, Context), - Decision::AdvanceContext - ); - // The native queue keeps priority regardless of repeat mode. - assert_eq!( - advance_decision(true, false, false, 2, Track), - Decision::SuspendToQueue - ); - assert_eq!( - advance_decision(true, false, false, 2, Context), - Decision::SuspendToQueue - ); - // The in-flight guard still short-circuits regardless of repeat mode. - assert_eq!(advance_decision(true, true, true, 0, Track), Decision::None); - } - - #[test] - fn advance_index_clamps_off_and_track() { - use RepeatMode::{Off, Track}; - for mode in [Off, Track] { - // Forward clamps at the last track. - assert_eq!(advance_index(1, 3, mode, true), Some(2)); - assert_eq!(advance_index(2, 3, mode, true), None); - // Backward clamps at the first track. - assert_eq!(advance_index(1, 3, mode, false), Some(0)); - assert_eq!(advance_index(0, 3, mode, false), None); - // Empty queue. - assert_eq!(advance_index(0, 0, mode, true), None); - assert_eq!(advance_index(0, 0, mode, false), None); - } - } - - #[test] - fn advance_index_wraps_context() { - use RepeatMode::Context; - // Forward wraps last -> first. - assert_eq!(advance_index(2, 3, Context, true), Some(0)); - assert_eq!(advance_index(1, 3, Context, true), Some(2)); - // Backward wraps first -> last. - assert_eq!(advance_index(0, 3, Context, false), Some(2)); - assert_eq!(advance_index(2, 3, Context, false), Some(1)); - // Single-track queue loops to itself; empty queue yields None. - assert_eq!(advance_index(0, 1, Context, true), Some(0)); - assert_eq!(advance_index(0, 1, Context, false), Some(0)); - assert_eq!(advance_index(0, 0, Context, true), None); - } - - #[test] - fn auto_advance_handoff_replays_the_repeat_one_track() { - use RepeatMode::Track; - use SuspendCause::AutoAdvance; - // Regression: a queued song preempts playback via `Decision::SuspendToQueue` - // *before* `advance_decision` consults `repeat`, so the resume index is the - // only thing keeping Repeat One alive across the queue. It must replay the - // same track, not advance past it. - assert_eq!(resume_index_after_queue(5, 10, Track, AutoAdvance), Some(5)); - // The boundary case that used to lose the context entirely: on the last - // track, advancing would yield `None` and tear the whole context down. - assert_eq!(resume_index_after_queue(9, 10, Track, AutoAdvance), Some(9)); - // Single-track context repeats itself. - assert_eq!(resume_index_after_queue(0, 1, Track, AutoAdvance), Some(0)); - // Degenerate input still clamps rather than resuming a track that isn't there. - assert_eq!(resume_index_after_queue(0, 0, Track, AutoAdvance), None); - assert_eq!(resume_index_after_queue(7, 3, Track, AutoAdvance), None); - } - - #[test] - fn manual_skip_handoff_advances_even_under_repeat_one() { - use RepeatMode::Track; - use SuspendCause::ManualSkip; - // Repeat-one only replays on *auto* advance: an explicit Next moves on, so - // the suspended context must not resume the track the user just skipped. - // The per-source skip paths (local/subsonic/youtube dispatch) already treat - // repeat-one as a normal clamp/advance; this keeps the queue handoff in step - // with them instead of resurrecting the skipped track once the queue drains. - assert_eq!(resume_index_after_queue(5, 10, Track, ManualSkip), Some(6)); - // ...but at the end of the context it clamps back onto the current track - // rather than returning `None`. `None` here does not mean "clamp" as it does - // in `advance_index` — every resume path reads it as "context exhausted" and - // tears the context down, which under repeat-one would stop playback dead on - // a keypress that is a harmless no-op when the queue happens to be empty. - assert_eq!(resume_index_after_queue(9, 10, Track, ManualSkip), Some(9)); - assert_eq!(resume_index_after_queue(0, 1, Track, ManualSkip), Some(0)); - // Degenerate input still declines to resume a track that isn't there. - assert_eq!(resume_index_after_queue(0, 0, Track, ManualSkip), None); - assert_eq!(resume_index_after_queue(7, 3, Track, ManualSkip), None); - } - - #[test] - fn resume_after_queue_matches_advance_for_the_other_modes() { - use RepeatMode::{Context, Off}; - // Outside repeat-one the cause makes no difference: both handoffs advance. - for cause in [SuspendCause::AutoAdvance, SuspendCause::ManualSkip] { - // Repeat All still wraps last -> first, so the context resumes rather than - // reading as exhausted. - assert_eq!(resume_index_after_queue(2, 3, Context, cause), Some(0)); - assert_eq!(resume_index_after_queue(0, 3, Context, cause), Some(1)); - // Off advances, and clamps to None at the end (context exhausted -> teardown). - assert_eq!(resume_index_after_queue(0, 3, Off, cause), Some(1)); - assert_eq!(resume_index_after_queue(2, 3, Off, cause), None); - assert_eq!(resume_index_after_queue(0, 0, Off, cause), None); - } - } - - #[test] - fn repeat_mode_cycles_like_spotify() { - // Off -> Repeat All -> Repeat One -> Off. - assert_eq!(RepeatMode::Off.next(), RepeatMode::Context); - assert_eq!(RepeatMode::Context.next(), RepeatMode::Track); - assert_eq!(RepeatMode::Track.next(), RepeatMode::Off); - } - - #[test] - fn next_index_clamps_at_end_and_handles_empty() { - // A 3-track queue: 0 -> 1 -> 2 -> (end). - assert_eq!(next_index(0, 3), Some(1)); - assert_eq!(next_index(1, 3), Some(2)); - assert_eq!( - next_index(2, 3), - None, - "advancing past the last track signals end-of-queue" - ); - assert_eq!(next_index(0, 1), None, "single-track queue has no next"); - assert_eq!(next_index(0, 0), None, "empty queue has no next"); - // A defensively out-of-range index still yields None rather than panicking. - assert_eq!(next_index(9, 3), None); - } - - #[test] - fn toggle_shuffle_round_trips() { - let original = vec![10, 20, 30, 40, 50]; - let current = 2; // track "30" is playing - - let mut queue = original.clone(); - let mut index = current; - let mut backup = None; - toggle_shuffle(&mut queue, &mut index, &mut backup, true); - - // The result is a permutation of the input with the current track at the - // front, and the live index follows it there. - assert_eq!(index, 0); - assert_eq!(queue[0], original[current]); - let mut sorted = queue.clone(); - sorted.sort_unstable(); - assert_eq!(sorted, original); - - // A redundant second toggle-on is a no-op (no re-shuffle). - let shuffled = queue.clone(); - toggle_shuffle(&mut queue, &mut index, &mut backup, true); - assert_eq!(queue, shuffled); - - // Un-shuffle restores the original order + the right index. - toggle_shuffle(&mut queue, &mut index, &mut backup, false); - assert_eq!(queue, original); - assert_eq!(index, current); - // ... and a redundant toggle-off stays a no-op. - toggle_shuffle(&mut queue, &mut index, &mut backup, false); - assert_eq!(queue, original); - assert_eq!(index, current); - } - - #[test] - fn restore_ignores_a_corrupt_persisted_permutation() { - // A `perm` deserialized from a hand-edited `last_session.yml` may have the - // right length but an out-of-range or duplicate index. `restore_in_place` - // (via `set_shuffle(false)`) must degrade to a no-op, never panic. - let original = vec![10, 20, 30]; - for bad_perm in [ - vec![0, 3, 1], // 3 is out of range for len 3 - vec![0, 0, 1], // duplicate index, slot 2 left unfilled - vec![0, 1], // wrong length - ] { - let mut queue = original.clone(); - let mut index = 1; - let mut backup = Some(ShuffleBackup { perm: bad_perm }); - toggle_shuffle(&mut queue, &mut index, &mut backup, false); - // Degraded safely: order and index preserved, no panic. - assert_eq!(queue, original); - assert_eq!(index, 1); - } - } - - #[test] - fn toggle_shuffle_restores_after_skipping() { - let original = vec!['a', 'b', 'c', 'd']; - let mut queue = original.clone(); - let mut index = 0; - let mut backup = None; - toggle_shuffle(&mut queue, &mut index, &mut backup, true); - // Simulate the user skipping forward to shuffled position 2, then - // un-shuffling: the restored index must point at whatever track sat at - // shuffled slot 2 (duplicate-safe: positions, not equality). - index = 2; - let played = queue[2]; - toggle_shuffle(&mut queue, &mut index, &mut backup, false); - assert_eq!(queue, original); - assert_eq!(queue[index], played); - } -} diff --git a/src/infra/radio/dispatch.rs b/src/infra/radio/dispatch.rs deleted file mode 100644 index 1d39eed9..00000000 --- a/src/infra/radio/dispatch.rs +++ /dev/null @@ -1,588 +0,0 @@ -//! Internet-radio browse/search/playback routing. -//! -//! [`route_radio_event`] is called from the runtime IoEvent pump *after* the -//! local-files and Subsonic dispatches and *before* `handle_network_event`. -//! When an event targets the radio source (a station-list/search request, or a -//! `radio:` playback URI) it is handled here and consumed; otherwise it falls -//! through to the normal Spotify dispatch. -//! -//! ## Decoupling & device ownership -//! -//! Radio playback owns a single piece of state, [`App::radio_playback`], and -//! never writes Spotify/librespot fields. Only one backend holds the audio -//! device at a time: starting radio pauses librespot **and** tears down any -//! local/Subsonic session; the reciprocal teardowns live in those sources' -//! start paths. -//! -//! ## Live-stream semantics -//! -//! A station is infinite, so several transport events are **consumed as -//! no-ops** while radio owns the session — `Seek` (nothing to seek within), -//! `Repeat`, and `NextTrack`/`PreviousTrack` (no queue). Consuming them is load-bearing: -//! falling through would hand them to the Spotify dispatch, which would try to -//! act on a Spotify session that isn't playing. - -use std::sync::Arc; - -use tokio::sync::Mutex; - -use super::{ - config_station_to_track_info, is_radio_uri, open_radio_stream, stream_url_from_uri, - uri_for_stream_url, RadioPlaybackState, RadioSource, -}; -use crate::core::app::App; -use crate::core::plugin_api::TrackInfo; -use crate::core::source::{Searcher, Source}; -use crate::core::state::merged_radio_stations; -use crate::infra::audio::LocalPlayer; -use crate::infra::network::IoEvent; - -/// Intercept events that target the radio source. -/// -/// Returns `true` if the event was handled (and must **not** be forwarded to -/// the Spotify network), `false` to let the normal dispatch run. -pub async fn route_radio_event(app: &Arc>, event: &IoEvent) -> bool { - match event { - IoEvent::GetRadioStations => { - load_radio_stations(app).await; - true - } - IoEvent::GetRadioSearchResults(query) => { - run_radio_search(app, query).await; - true - } - // A station play. Radio "queues" collapse to the one station at the offset: - // stations are infinite, so queueing the rest is meaningless. - IoEvent::StartPlayback(None, Some(uris), offset) - if uris.first().is_some_and(|u| is_radio_uri(u)) => - { - let index = offset.unwrap_or(0).min(uris.len().saturating_sub(1)); - start_radio(app, &uris[index]).await; - true - } - IoEvent::StartPlayback(Some(uri), _, _) if is_radio_uri(uri) => { - start_radio(app, uri).await; - true - } - // Bare "resume current" — ours only while radio owns the session. - IoEvent::StartPlayback(None, None, None) => match player(app).await { - Some(p) => { - p.resume(); - true - } - None => false, - }, - // Any other start is a local/Subsonic/Spotify play: relinquish the device, - // then let the normal dispatch run. - IoEvent::StartPlayback(..) => { - teardown_radio(app).await; - false - } - IoEvent::PausePlayback => match player(app).await { - Some(p) => { - p.pause(); - true - } - None => false, - }, - IoEvent::ChangeVolume(volume) => match player(app).await { - Some(p) => { - p.set_volume(*volume); - let mut app = app.lock().await; - app.runtime_state.volume_percent = *volume; - app.schedule_state_save(crate::core::state::PersistedRuntimeState::volume_percent( - *volume, - )); - true - } - None => false, - }, - // Meaningless on a live stream — consume so they never reach Spotify. - IoEvent::Seek(_) => player(app).await.is_some(), - IoEvent::Repeat(_) => player(app).await.is_some(), - IoEvent::NextTrack | IoEvent::PreviousTrack | IoEvent::ForcePreviousTrack - if player(app).await.is_some() => - { - set_error(app, "Live radio: no track skipping".to_string()).await; - true - } - _ => false, - } -} - -// --------------------------------------------------------------------------- -// Browse + search -// --------------------------------------------------------------------------- - -/// Load configured and saved stations into `app.radio_stations` (the sidebar's -/// Stations panel). No network. -async fn load_radio_stations(app: &Arc>) { - let mut guard = app.lock().await; - let stations: Vec = merged_radio_stations( - &guard.user_config.behavior.radio_stations, - &guard.runtime_state.radio_stations, - ) - .into_iter() - .map(|station| config_station_to_track_info(&station.name, &station.url)) - .collect(); - let empty = stations.is_empty(); - guard.radio_stations = stations; - if empty { - guard.set_status_message( - "No radio stations configured or saved; search to add some".to_string(), - 6, - ); - } -} - -/// Search the radio-browser.info directory and populate `app.search_results`. -/// -/// Like the Subsonic search, only the songs block is populated — each row is a -/// station; Enter on one dispatches its `radio:` URI back through this module. -async fn run_radio_search(app: &Arc>, query: &str) { - let source = RadioSource::new(); - match source.search(query).await { - Ok(results) => app.lock().await.show_source_search_tracks(results.tracks), - Err(e) => set_error(app, format!("Radio search failed: {e}")).await, - } -} - -// --------------------------------------------------------------------------- -// Playback -// --------------------------------------------------------------------------- - -/// The live radio player, if a radio session is active. -async fn player(app: &Arc>) -> Option> { - app - .lock() - .await - .radio_playback - .as_ref() - .map(|s| Arc::clone(&s.player)) -} - -/// Find the station row for `uri` in the browse views a play request can -/// originate from: the sidebar station list, the shared track table, and the -/// search results. Falls back to a synthetic row built from the URL itself, so -/// a `radio:` URI with no backing row still plays (it just has no nice name). -fn snapshot_station(app: &App, uri: &str) -> TrackInfo { - let matches = |t: &&TrackInfo| t.uri.as_deref() == Some(uri); - app - .radio_stations - .iter() - .find(matches) - .or_else(|| app.track_table.tracks.iter().find(matches)) - .or_else(|| { - app - .search_results - .tracks - .as_ref() - .and_then(|p| p.items.iter().find(matches)) - }) - .cloned() - .unwrap_or_else(|| { - let url = stream_url_from_uri(uri).unwrap_or(uri); - config_station_to_track_info(url, url) - }) -} - -/// Release the other backends so only radio holds the output device. -async fn release_other_backends(app: &Arc>) { - // Pause native Spotify so librespot releases the device. - #[cfg(feature = "streaming")] - { - let streaming = app.lock().await.streaming_player.clone(); - if let Some(player) = streaming { - player.pause(); - } - } - // The other decoded sources never see this radio: start (the pump's - // short-circuit), so their sessions are torn down here. - let players = app.lock().await.take_decoded_sessions_except(Source::Radio); - for player in players { - player.stop_detached(); - } -} - -/// Reuse the live radio player, or open a fresh output device for one. A -/// freshly opened player is **not** published to `App` here — the caller -/// publishes the session only on a successful first play. -async fn acquire_player(app: &Arc>) -> Option> { - if let Some(p) = player(app).await { - return Some(p); - } - match tokio::task::spawn_blocking(LocalPlayer::new).await { - Ok(Ok(p)) => Some(Arc::new(p)), - Ok(Err(e)) => { - set_error(app, format!("No audio output for radio playback: {e}")).await; - None - } - Err(e) => { - set_error(app, format!("Audio output init failed: {e}")).await; - None - } - } -} - -/// Begin playing the station at `uri`, taking over the session. -async fn start_radio(app: &Arc>, uri: &str) { - let url = match stream_url_from_uri(uri) { - Ok(u) => u.to_string(), - Err(_) => { - set_error(app, "Invalid radio URI".to_string()).await; - return; - } - }; - let mut station = { - let guard = app.lock().await; - snapshot_station(&guard, uri) - }; - - // Only one backend owns the device at a time. - app.lock().await.claim_decoded_sink(Source::Radio); - release_other_backends(app).await; - - let Some(player) = acquire_player(app).await else { - return; - }; - - // Connect + prefetch off the lock; this can take seconds on a slow network. - let opened = match open_radio_stream(&url).await { - Ok(o) => o, - Err(e) => { - set_error(app, format!("Cannot open radio stream: {e}")).await; - return; - } - }; - - // Count the click for directory-sourced stations (usage-policy nicety). - // Fire-and-forget: it must never delay or fail playback. - if let Some(uuid) = station.id.clone() { - tokio::spawn(async move { RadioSource::new().click(&uuid).await }); - } - - // A synthetic row (raw URL as name) gets upgraded to the station's - // self-reported icy-name. - if station.name == url { - if let Some(icy_name) = &opened.station_name { - station.name = icy_name.clone(); - station.uri = Some(uri_for_stream_url(&url)); - } - } - - let now_playing = Arc::clone(&opened.now_playing); - let (reader, mime, cancel) = (opened.reader, opened.content_type, opened.cancel); - // Bound the probe, the last unbounded step of tune-in. It scans for a marker - // it recognises and a live stream never ends, so an unsupported codec would - // otherwise scan forever — and this is awaited on the serial pump, where that - // is not silence but a frozen app. Cancelling on the way out is what lets the - // probe thread and its download go, since `spawn_blocking` cannot be aborted. - // - // Only the *probe* runs in here: `timeout` abandons the closure but cannot - // stop it, and `acquire_player` reuses the live radio player. A probe that - // matched just after the deadline would otherwise append the new station to - // the shared sink — audible under a playbar still naming the old one, with - // no transport control reaching it. Preparing decides nothing; the sink is - // only touched below, after the deadline has passed. - let result = tokio::time::timeout( - super::stream::PROBE_TIMEOUT, - tokio::task::spawn_blocking(move || LocalPlayer::prepare_stream(reader, mime.as_deref(), None)), - ) - .await; - - let result = match result { - Ok(result) => result, - Err(_) => { - cancel(); - set_error( - app, - "Cannot play radio stream: no recognisable audio format in time.".to_string(), - ) - .await; - return; - } - }; - - match result { - Ok(Ok(prepared)) => { - // The clear inside waits on the audio thread: keep it off the runtime. - let decode_player = Arc::clone(&player); - match tokio::task::spawn_blocking(move || decode_player.play_prepared(prepared)).await { - Ok(Ok(())) => {} - Ok(Err(e)) => { - set_error(app, format!("Cannot play radio stream: {e}")).await; - return; - } - Err(e) => { - set_error(app, format!("Radio playback task failed: {e}")).await; - return; - } - } - let volume = app.lock().await.runtime_state.volume_percent; - player.set_volume(volume); - - let display = station.name.clone(); - let mut guard = app.lock().await; - // Publish the session exactly once, now that the stream is decoding. - guard.radio_playback = Some(RadioPlaybackState { - player, - station, - now_playing, - }); - guard.set_status_message(format!("\u{266a} {display} (live)"), 4); - } - Ok(Err(e)) => set_error(app, format!("Cannot play radio stream: {e}")).await, - Err(e) => set_error(app, format!("Radio playback task failed: {e}")).await, - } -} - -/// End the radio session, releasing the output device. Stopping the sink drops -/// the decoder, which drops the stream reader, which cancels the background -/// download task (`cancel_on_drop`). -pub async fn teardown_radio(app: &Arc>) { - // Take under the lock, stop off it: a sink clear waits for the audio thread. - let session = app.lock().await.radio_playback.take(); - if let Some(s) = session { - Arc::clone(&s.player).stop_detached_holding(s); - } -} - -async fn set_error(app: &Arc>, message: String) { - app.lock().await.set_status_message(message, 6); -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::pagination::Paged; - use crate::core::user_config::UserConfig; - use std::sync::mpsc::channel; - use std::time::SystemTime; - - fn test_app() -> App { - let (tx, _rx) = channel(); - App::new(tx, UserConfig::new(), Some(SystemTime::now())) - } - - fn station_row(uri: &str, name: &str) -> TrackInfo { - TrackInfo { - uri: Some(uri.to_string()), - name: name.to_string(), - artists: vec![], - album: String::new(), - duration_ms: 0, - id: None, - album_id: None, - artist_refs: vec![], - is_playable: true, - is_local: false, - track_number: 0, - explicit: false, - image_url: None, - } - } - - #[test] - fn snapshot_prefers_sidebar_station_list() { - let mut app = test_app(); - let uri = "radio:https://ice1.somafm.com/groovesalad-128-mp3"; - app.radio_stations = vec![station_row(uri, "Groove Salad")]; - app.track_table.tracks = vec![station_row(uri, "Wrong Name")]; - assert_eq!(snapshot_station(&app, uri).name, "Groove Salad"); - } - - #[test] - fn snapshot_falls_back_to_search_results() { - let mut app = test_app(); - let uri = "radio:https://example.com/stream"; - app.search_results.tracks = Some(Paged { - items: vec![station_row(uri, "Searched FM")], - total: 1, - ..Default::default() - }); - assert_eq!(snapshot_station(&app, uri).name, "Searched FM"); - } - - #[test] - fn snapshot_synthesizes_a_row_for_unknown_uris() { - let app = test_app(); - let station = snapshot_station(&app, "radio:https://example.com/live"); - assert_eq!(station.name, "https://example.com/live"); - assert_eq!( - station.uri.as_deref(), - Some("radio:https://example.com/live") - ); - assert_eq!(station.duration_ms, 0); - } - - /// Browse/search/transport events that are not radio's must fall through. - #[tokio::test] - async fn foreign_events_fall_through_without_a_session() { - let app = Arc::new(Mutex::new(test_app())); - // No radio session: transport events are not ours. - assert!(!route_radio_event(&app, &IoEvent::PausePlayback).await); - assert!(!route_radio_event(&app, &IoEvent::NextTrack).await); - assert!(!route_radio_event(&app, &IoEvent::Seek(1000)).await); - assert!( - !route_radio_event( - &app, - &IoEvent::Repeat(rspotify::model::enums::RepeatState::Off) - ) - .await - ); - assert!(!route_radio_event(&app, &IoEvent::StartPlayback(None, None, None)).await); - // A Spotify start falls through (and there is nothing to tear down). - assert!( - !route_radio_event( - &app, - &IoEvent::StartPlayback(Some("spotify:track:x".to_string()), None, None) - ) - .await - ); - } - - /// Loading the station list is consumed and merges config + saved stations. - #[tokio::test] - async fn get_radio_stations_merges_configured_and_saved_lists() { - let app = Arc::new(Mutex::new(test_app())); - assert!(route_radio_event(&app, &IoEvent::GetRadioStations).await); - assert!(app.lock().await.radio_stations.is_empty()); - - { - let mut guard = app.lock().await; - guard.user_config.behavior.radio_stations = vec![crate::core::state::RadioStationConfig { - name: "Configured Groove".to_string(), - url: "https://ice1.somafm.com/groovesalad-128-mp3".to_string(), - }]; - guard.runtime_state.radio_stations = vec![ - crate::core::state::RadioStationConfig { - name: "Groove Salad".to_string(), - url: "https://ice1.somafm.com/groovesalad-128-mp3".to_string(), - }, - crate::core::state::RadioStationConfig { - name: "Secret Agent".to_string(), - url: "https://ice1.somafm.com/secretagent-128-mp3".to_string(), - }, - ]; - } - assert!(route_radio_event(&app, &IoEvent::GetRadioStations).await); - let guard = app.lock().await; - assert_eq!(guard.radio_stations.len(), 2); - assert_eq!(guard.radio_stations[0].name, "Configured Groove"); - assert_eq!( - guard.radio_stations[0].uri.as_deref(), - Some("radio:https://ice1.somafm.com/groovesalad-128-mp3") - ); - assert_eq!(guard.radio_stations[1].name, "Secret Agent"); - } - - /// The station shape that froze the app: a live stream whose codec rodio's - /// symphonia does not register (MPEG-2 ADTS AAC — its ADTS reader only claims - /// the MPEG-4 `ff f1` marker, not `ff f9`). The probe scans for a marker that - /// will never arrive, and this runs on the serial pump, so an unbounded one - /// takes searching and playback controls down with it. What must happen - /// instead: give up, say so, publish no session, and leave the app usable. - #[tokio::test(flavor = "multi_thread")] - #[ignore = "hits a live radio station AND requires an audio output device"] - async fn an_unrecognisable_live_stream_gives_up_instead_of_hanging() { - let app = Arc::new(Mutex::new(test_app())); - { - let mut guard = app.lock().await; - guard.runtime_state.radio_stations = vec![crate::core::state::RadioStationConfig { - name: "Radio Bruno (MPEG-2 ADTS AAC)".to_string(), - url: "https://stream3.xdevel.com/audio6s975355-281/stream/icecast.audio".to_string(), - }]; - } - assert!(route_radio_event(&app, &IoEvent::GetRadioStations).await); - let uri = app.lock().await.radio_stations[0].uri.clone().unwrap(); - - let started = std::time::Instant::now(); - assert!(route_radio_event(&app, &IoEvent::StartPlayback(Some(uri), None, None)).await); - let elapsed = started.elapsed(); - - assert!( - elapsed < super::super::stream::PROBE_TIMEOUT + std::time::Duration::from_secs(15), - "tune-in must be bounded, took {elapsed:?}" - ); - let guard = app.lock().await; - assert!( - guard.radio_playback.is_none(), - "nothing decodable, so no session may be published" - ); - assert!( - guard.api_error().contains("radio stream") || guard.status_message().is_some(), - "the user has to be told why nothing played" - ); - } - - /// End-to-end dispatch test: drive `route_radio_event` exactly as the runtime - /// pump does — load stations, start one, exercise the live-stream no-op - /// transport semantics, and tear down on a foreign start. Ignored (needs - /// network **and** an audio output device); run: - /// `cargo test --features internet-radio -- --ignored live_dispatch` - #[tokio::test(flavor = "multi_thread")] - #[ignore = "hits the live SomaFM stream AND requires an audio output device"] - async fn live_dispatch_play_and_teardown() { - let app = Arc::new(Mutex::new(test_app())); - { - let mut guard = app.lock().await; - guard.runtime_state.radio_stations = vec![crate::core::state::RadioStationConfig { - name: "Groove Salad".to_string(), - url: "https://ice1.somafm.com/groovesalad-128-mp3".to_string(), - }]; - } - assert!(route_radio_event(&app, &IoEvent::GetRadioStations).await); - let uri = app.lock().await.radio_stations[0].uri.clone().unwrap(); - - // Start the station. - assert!( - route_radio_event(&app, &IoEvent::StartPlayback(Some(uri), None, None)).await, - "radio StartPlayback must be consumed" - ); - { - let guard = app.lock().await; - let s = guard.radio_playback.as_ref().expect("session published"); - assert_eq!(s.station.name, "Groove Salad"); - assert!(!s.player.is_paused(), "should be playing"); - } - - // Live-stream semantics: Seek and Next/Prev are consumed as no-ops. - assert!(route_radio_event(&app, &IoEvent::Seek(30_000)).await); - assert!( - route_radio_event( - &app, - &IoEvent::Repeat(rspotify::model::enums::RepeatState::Off) - ) - .await - ); - assert!(route_radio_event(&app, &IoEvent::NextTrack).await); - assert!( - app.lock().await.radio_playback.is_some(), - "no-op transport must not tear the session down" - ); - - // Pause/resume are ours while the session lives. - assert!(route_radio_event(&app, &IoEvent::PausePlayback).await); - assert!(app - .lock() - .await - .radio_playback - .as_ref() - .is_some_and(|s| s.player.is_paused())); - assert!(route_radio_event(&app, &IoEvent::StartPlayback(None, None, None)).await); - - // A foreign (Spotify) start tears the session down and falls through. - assert!( - !route_radio_event( - &app, - &IoEvent::StartPlayback(Some("spotify:track:x".to_string()), None, None) - ) - .await, - "a non-radio start must fall through to the network" - ); - assert!( - app.lock().await.radio_playback.is_none(), - "a foreign start must tear down the radio session (device handoff)" - ); - } -} diff --git a/src/infra/radio/mod.rs b/src/infra/radio/mod.rs deleted file mode 100644 index 617d85c3..00000000 --- a/src/infra/radio/mod.rs +++ /dev/null @@ -1,492 +0,0 @@ -//! Internet-radio media source. -//! -//! Plays direct HTTP(S) icecast/shoutcast-style streams (SomaFM, most stations -//! in the radio-browser.info directory). Stations come from two places: -//! -//! - the user's saved station list (`state.yml`, name + URL pairs), -//! shown in the sidebar when the Radio source is active; -//! - in-app search of the community [radio-browser.info](https://api.radio-browser.info) -//! directory (30k+ stations), via [`RadioSource`]. -//! -//! A station is a **leaf, not a container**: it has no playlists or tracks and -//! plays forever, so [`MediaSource`]'s browse model does not apply — the source -//! implements only [`Searcher`]. Playback state ([`RadioPlaybackState`]) has no -//! queue/index/advance; Next/Prev are meaningless on a live stream. -//! -//! ## URIs -//! -//! `radio:` — the suffix is the verbatim stream URL (which itself -//! contains `:` and `/`; never re-split on `:`). - -use std::sync::{Arc, Mutex}; -use std::time::Duration; - -use anyhow::{anyhow, Context, Result}; -use rand::seq::SliceRandom; - -use crate::core::plugin_api::{SearchResults, TrackInfo}; -use crate::core::source::Searcher; -use crate::infra::audio::LocalPlayer; - -pub mod dispatch; -mod stream; -mod types; - -pub use stream::open_radio_stream; - -use types::RbStation; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -const RADIO_PREFIX: &str = "radio:"; - -/// Known radio-browser.info API mirrors, tried in shuffled order per call. -/// The project recommends DNS discovery of `all.api.radio-browser.info`, but a -/// vetted static mirror list avoids resolving+reverse-mapping IPs to TLS -/// hostnames; these are the long-lived first-party mirrors. -const MIRRORS: [&str; 3] = [ - "https://de1.api.radio-browser.info", - "https://de2.api.radio-browser.info", - "https://fi1.api.radio-browser.info", -]; - -/// Descriptive User-Agent, required by the radio-browser.info usage policy. -const USER_AGENT: &str = concat!("spotatui/", env!("CARGO_PKG_VERSION")); - -/// Result page size for directory searches — matches the subsonic search's -/// songCount and roughly one screen of the songs block. -const SEARCH_LIMIT: usize = 30; - -/// Per-mirror wall-clock cap. The mirror walk is on the serial IoEvent pump, so -/// a mirror that connects then stalls the body must not hold up the whole walk -/// (or every source's transport). Bounds each `get_from_any_mirror` attempt so a -/// dead mirror really does "only cost one timeout" and the walk advances to the -/// next mirror. Blankets both the connect and the body phases via a hard wrap. -const MIRROR_TIMEOUT: Duration = Duration::from_secs(6); - -// --------------------------------------------------------------------------- -// RadioPlaybackState -// --------------------------------------------------------------------------- - -/// The active internet-radio playback session. -/// -/// Mirrors the local/subsonic decoupling: it owns the live [`LocalPlayer`] and -/// never writes Spotify/librespot fields — the playbar reads pause state and -/// elapsed time live from `player`. Unlike those sources there is **no queue**: -/// a station is infinite, so there is no index, no advance guard and no -/// end-of-track. `player.is_finished()` flipping true means the stream died. -pub struct RadioPlaybackState { - pub player: Arc, - /// The playing station's row (name, `radio:` URI, tags/summary) as - /// snapshotted from the sidebar or search results at start. - pub station: TrackInfo, - /// Live ICY `StreamTitle` ("Artist - Title"), shared with the stream reader - /// which updates it as metadata blocks arrive. `None` until the first block - /// (or forever, for streams without ICY metadata). - pub now_playing: Arc>>, -} - -impl RadioPlaybackState { - /// The current ICY now-playing title, if one has arrived. - pub fn now_playing_title(&self) -> Option { - self.now_playing.lock().ok().and_then(|np| np.clone()) - } -} - -// --------------------------------------------------------------------------- -// URI helpers -// --------------------------------------------------------------------------- - -/// Whether a URI is owned by the radio source. -pub fn is_radio_uri(uri: &str) -> bool { - uri.starts_with(RADIO_PREFIX) -} - -/// Strip the `radio:` prefix and return the verbatim stream URL. -pub fn stream_url_from_uri(uri: &str) -> Result<&str> { - uri - .strip_prefix(RADIO_PREFIX) - .ok_or_else(|| anyhow!("Not a radio URI: {}", uri)) -} - -/// Build the `radio:` URI for a stream URL. -pub fn uri_for_stream_url(url: &str) -> String { - format!("{RADIO_PREFIX}{url}") -} - -// --------------------------------------------------------------------------- -// RadioSource — radio-browser.info directory client -// --------------------------------------------------------------------------- - -/// Client for the radio-browser.info community directory. -/// -/// Stateless besides the HTTP client: each call walks the [`MIRRORS`] in a -/// fresh shuffled order and returns the first success, so a dead mirror only -/// costs one timeout and load spreads across the pool (per the project's -/// usage guidelines). -pub struct RadioSource { - http: reqwest::Client, -} - -/// Process-wide radio-browser HTTP client. Dispatch constructs a fresh -/// `RadioSource` per event, so the client (TLS state + connection pool) is -/// built once and cheaply cloned into each source — otherwise every radio -/// action pays a fresh TLS handshake (same pattern as `shared_http_client` -/// on the Spotify path). -fn shared_radio_client() -> reqwest::Client { - static RADIO_HTTP_CLIENT: std::sync::OnceLock = std::sync::OnceLock::new(); - RADIO_HTTP_CLIENT - .get_or_init(|| { - reqwest::Client::builder() - .user_agent(USER_AGENT) - // Blanket per-request timeout so a mirror that connects then stalls the - // body can never hang the pump. The per-mirror `tokio::time::timeout` in - // `get_from_any_mirror` is the belt to this suspenders — it also bounds - // any phase reqwest's own timeout might not (DNS, connect races). - .timeout(MIRROR_TIMEOUT) - .build() - // Falls back to a default client only if TLS init fails, which would - // break every other request in the app too. - .unwrap_or_default() - }) - .clone() -} - -impl RadioSource { - pub fn new() -> Self { - RadioSource { - http: shared_radio_client(), - } - } - - /// Search the directory by station name, most-voted first, skipping stations - /// whose last connectivity check failed. - pub async fn search_stations(&self, query: &str) -> Result> { - let path = format!( - "/json/stations/search?name={}&limit={}&hidebroken=true&order=votes&reverse=true", - url_encode(query), - SEARCH_LIMIT, - ); - let body = self.get_from_any_mirror(&path).await?; - let stations: Vec = - serde_json::from_str(&body).context("deserializing radio-browser search response")?; - Ok( - stations - .into_iter() - .filter(|s| s.lastcheckok == 1) - .collect(), - ) - } - - /// Count a station click, as the radio-browser usage policy asks clients to - /// do when a user starts a station. Best-effort: failures are ignored (the - /// ping must never block or fail playback). - pub async fn click(&self, stationuuid: &str) { - let path = format!("/json/url/{}", url_encode(stationuuid)); - let _ = self.get_from_any_mirror(&path).await; - } - - /// GET `path` from the first responding mirror, in shuffled order. - async fn get_from_any_mirror(&self, path: &str) -> Result { - let mut mirrors = MIRRORS; - mirrors.shuffle(&mut rand::rng()); - - let mut last_err = anyhow!("no radio-browser mirrors configured"); - for base in mirrors { - let url = format!("{base}{path}"); - // Hard-bound each mirror attempt: even if reqwest's own timeout somehow - // does not fire (e.g. a stall in a phase it does not cover), this ensures - // one dead mirror costs at most `MIRROR_TIMEOUT` before the walk advances. - match tokio::time::timeout(MIRROR_TIMEOUT, self.get(&url)).await { - Ok(Ok(body)) => return Ok(body), - Ok(Err(e)) => last_err = e, - Err(_) => last_err = anyhow!("mirror {base} timed out after {MIRROR_TIMEOUT:?}"), - } - } - Err(last_err.context("all radio-browser mirrors failed")) - } - - async fn get(&self, url: &str) -> Result { - self - .http - .get(url) - .send() - .await - .context("HTTP request to radio-browser failed")? - .error_for_status() - .context("radio-browser returned an HTTP error")? - .text() - .await - .context("failed to read radio-browser response body") - } -} - -impl Default for RadioSource { - fn default() -> Self { - Self::new() - } -} - -impl Searcher for RadioSource { - async fn search(&self, query: &str) -> Result { - let stations = self.search_stations(query).await?; - Ok(SearchResults { - tracks: stations.iter().map(station_to_track_info).collect(), - albums: vec![], - artists: vec![], - playlists: vec![], - shows: vec![], - }) - } -} - -// --------------------------------------------------------------------------- -// Domain type conversions -// --------------------------------------------------------------------------- - -/// Map a directory station onto the shared [`TrackInfo`] row the track table, -/// search results and playbar already render. -/// -/// - `uri` = `radio:` (prefers `url_resolved`, which the directory -/// has already followed through `.m3u`/`.pls` playlist pointers). -/// - `artists` (the subtitle column) = genre tags. -/// - `album` = a "US • MP3 128 kbps" style summary. -/// - `duration_ms` = 0 — the LIVE sentinel; a radio stream has no duration and -/// the playbar renders a LIVE badge instead of a seek bar. -/// - `id` = the directory `stationuuid`, so play can send the click ping. -fn station_to_track_info(s: &RbStation) -> TrackInfo { - let stream_url = if s.url_resolved.is_empty() { - &s.url - } else { - &s.url_resolved - }; - - let tags: Vec = s - .tags - .split(',') - .map(str::trim) - .filter(|t| !t.is_empty()) - .take(3) - .map(str::to_owned) - .collect(); - - TrackInfo { - uri: Some(uri_for_stream_url(stream_url)), - name: s.name.trim().to_string(), - artists: tags, - album: station_summary(&s.countrycode, &s.codec, s.bitrate), - duration_ms: 0, - id: if s.stationuuid.is_empty() { - None - } else { - Some(s.stationuuid.clone()) - }, - album_id: None, - artist_refs: vec![], - is_playable: true, - is_local: false, - track_number: 0, - explicit: false, - image_url: None, - } -} - -/// Map a config-file station (name + URL, nothing else known) onto a row. -pub fn config_station_to_track_info(name: &str, url: &str) -> TrackInfo { - TrackInfo { - uri: Some(uri_for_stream_url(url.trim())), - name: name.trim().to_string(), - artists: vec![], - album: String::new(), - duration_ms: 0, - id: None, // no stationuuid — config stations don't get click pings - album_id: None, - artist_refs: vec![], - is_playable: true, - is_local: false, - track_number: 0, - explicit: false, - image_url: None, - } -} - -/// `"US • MP3 128 kbps"`-style one-liner from whatever fields are present. -fn station_summary(countrycode: &str, codec: &str, bitrate: u32) -> String { - let mut parts: Vec = Vec::new(); - if !countrycode.is_empty() { - parts.push(countrycode.to_string()); - } - if !codec.is_empty() { - parts.push(codec.to_uppercase()); - } - if bitrate > 0 { - parts.push(format!("{bitrate} kbps")); - } - parts.join(" \u{2022} ") -} - -// --------------------------------------------------------------------------- -// Minimal URL encoding for query strings (mirrors infra::subsonic::url_encode, -// which is private to that feature-gated module) -// --------------------------------------------------------------------------- - -fn url_encode(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - for b in s.bytes() { - match b { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - out.push(b as char); - } - b' ' => out.push('+'), - other => { - out.push('%'); - out.push_str(&format!("{:02X}", other)); - } - } - } - out -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - - /// Live directory search against the real mirror pool. Ignored by default - /// (network); run with: - /// `cargo test --features internet-radio -- --ignored live_directory_search` - #[tokio::test] - #[ignore = "hits the live radio-browser.info directory"] - async fn live_directory_search_returns_stations() { - let source = RadioSource::new(); - let stations = source - .search_stations("soma") - .await - .expect("directory search should succeed"); - assert!(!stations.is_empty(), "'soma' should match SomaFM stations"); - // Every returned station must map to a playable radio: row. - for s in &stations { - let row = station_to_track_info(s); - let uri = row.uri.expect("station row must carry a uri"); - assert!(is_radio_uri(&uri)); - let url = stream_url_from_uri(&uri).unwrap(); - assert!( - url.starts_with("http://") || url.starts_with("https://"), - "stream url should be http(s), got {url}" - ); - } - } - - // Representative directory response (two stations, one failing its check). - const SEARCH_JSON: &str = r#" - [ - { - "stationuuid": "960e57c5-0601-11e8-ae97-52543be04c81", - "name": "SomaFM Groove Salad", - "url": "http://ice1.somafm.com/groovesalad-128-mp3", - "url_resolved": "https://ice1.somafm.com/groovesalad-128-mp3", - "tags": "ambient,chillout,downtempo", - "countrycode": "US", - "codec": "MP3", - "bitrate": 128, - "lastcheckok": 1 - }, - { - "stationuuid": "dead-beef", - "name": "Broken FM", - "url": "http://broken.example.com/stream", - "url_resolved": "", - "tags": "", - "countrycode": "", - "codec": "", - "bitrate": 0, - "lastcheckok": 0 - } - ]"#; - - #[test] - fn parse_search_response_and_map_to_track_info() { - let stations: Vec = serde_json::from_str(SEARCH_JSON).unwrap(); - assert_eq!(stations.len(), 2); - - let row = station_to_track_info(&stations[0]); - assert_eq!( - row.uri.as_deref(), - Some("radio:https://ice1.somafm.com/groovesalad-128-mp3"), - "must prefer url_resolved" - ); - assert_eq!(row.name, "SomaFM Groove Salad"); - assert_eq!(row.artists, vec!["ambient", "chillout", "downtempo"]); - assert_eq!(row.album, "US \u{2022} MP3 \u{2022} 128 kbps"); - assert_eq!(row.duration_ms, 0, "0 is the LIVE sentinel"); - assert_eq!( - row.id.as_deref(), - Some("960e57c5-0601-11e8-ae97-52543be04c81") - ); - assert!(row.is_playable); - } - - #[test] - fn station_falls_back_to_raw_url_when_unresolved() { - let stations: Vec = serde_json::from_str(SEARCH_JSON).unwrap(); - let row = station_to_track_info(&stations[1]); - assert_eq!( - row.uri.as_deref(), - Some("radio:http://broken.example.com/stream") - ); - assert!(row.artists.is_empty(), "no tags -> no subtitle entries"); - assert!(row.album.is_empty(), "nothing known -> empty summary"); - } - - #[test] - fn parse_tolerates_sparse_records() { - // Records with most fields missing must still deserialize (serde defaults). - let sparse: Vec = - serde_json::from_str(r#"[{"name": "Bare", "url": "http://x.example/s"}]"#).unwrap(); - assert_eq!(sparse[0].name, "Bare"); - assert_eq!(sparse[0].lastcheckok, 0); - } - - #[test] - fn radio_uri_round_trip_preserves_url_with_colons_and_query() { - let url = "https://example.com:8000/stream.mp3?token=a:b&x=1"; - let uri = uri_for_stream_url(url); - assert_eq!(uri, format!("radio:{url}")); - assert!(is_radio_uri(&uri)); - assert_eq!(stream_url_from_uri(&uri).unwrap(), url); - } - - #[test] - fn stream_url_from_uri_rejects_other_schemes() { - assert!(stream_url_from_uri("spotify:track:x").is_err()); - assert!(stream_url_from_uri("subsonic:track:1").is_err()); - assert!(!is_radio_uri("file:///music/a.flac")); - } - - #[test] - fn config_station_maps_to_playable_row() { - let row = config_station_to_track_info(" SomaFM ", " https://ice1.somafm.com/g-128 "); - assert_eq!(row.name, "SomaFM"); - assert_eq!( - row.uri.as_deref(), - Some("radio:https://ice1.somafm.com/g-128") - ); - assert_eq!(row.duration_ms, 0); - assert!(row.id.is_none()); - } - - #[test] - fn summary_skips_missing_fields() { - assert_eq!( - station_summary("US", "mp3", 128), - "US \u{2022} MP3 \u{2022} 128 kbps" - ); - assert_eq!(station_summary("", "aac", 0), "AAC"); - assert_eq!(station_summary("", "", 0), ""); - } -} diff --git a/src/infra/radio/types.rs b/src/infra/radio/types.rs deleted file mode 100644 index 1c17eff4..00000000 --- a/src/infra/radio/types.rs +++ /dev/null @@ -1,32 +0,0 @@ -//! Serde types for the radio-browser.info JSON API. -//! -//! Only the station fields spotatui consumes are modeled; the API returns many -//! more. Every field is defaulted because the community directory contains -//! sparse records (missing codec, zero bitrate, empty tags) and one bad record -//! must not fail the whole response. - -use serde::Deserialize; - -/// One station record from `/json/stations/search` (and the other station -/// listing endpoints, which share the same shape). -#[derive(Debug, Clone, Default, Deserialize)] -#[serde(default)] -pub struct RbStation { - /// Stable station identifier, used for the click-count ping. - pub stationuuid: String, - pub name: String, - /// The stream URL as submitted. May be a playlist (`.m3u`/`.pls`) pointer. - pub url: String, - /// The URL after the directory resolved playlists/redirects — prefer this. - pub url_resolved: String, - /// Comma-separated genre tags, e.g. `"ambient,chillout"`. - pub tags: String, - /// ISO 3166-1 alpha-2 country code, e.g. `"US"`. - pub countrycode: String, - /// Audio codec as reported by the directory, e.g. `"MP3"`, `"AAC"`. - pub codec: String, - /// Bitrate in kbps; `0` when unknown. - pub bitrate: u32, - /// `1` when the directory's last connectivity check succeeded. - pub lastcheckok: u8, -} diff --git a/src/infra/redirect_uri.html b/src/infra/redirect_uri.html deleted file mode 100644 index 37bb9faa..00000000 --- a/src/infra/redirect_uri.html +++ /dev/null @@ -1,48 +0,0 @@ - - - - - spotatui - - - - -
    -
    -

    spotatui

    -

    Client authorized. You can return to your terminal and close this window.

    -
    -
    - - diff --git a/src/infra/redirect_uri.rs b/src/infra/redirect_uri.rs deleted file mode 100644 index 20caf89c..00000000 --- a/src/infra/redirect_uri.rs +++ /dev/null @@ -1,247 +0,0 @@ -use tokio::io::{AsyncReadExt, AsyncWriteExt}; - -/// Acceptance logic for the callback server: given a raw HTTP request, return -/// the full callback URL when `accept` approves its path, else `None` -/// (browser noise like /favicon.ico, pre-flight requests, or malformed input). -fn extract_callback_url(request: &str, accept: fn(&str) -> bool) -> Option { - let split: Vec<&str> = request.split_whitespace().collect(); - if split.len() <= 1 { - return None; - } - // The path is the second whitespace-separated token, e.g. "/callback?code=...". - let path = split[1]; - if !accept(path) { - return None; - } - - let host = request - .lines() - .find(|line| line.to_lowercase().starts_with("host:")) - .and_then(|line| line.split(':').nth(1)) - .map(|h| h.trim()) - .unwrap_or("127.0.0.1:8888"); - - Some(format!("http://{}{}", host, path)) -} - -/// The Spotify callback carries an OAuth `code=` query parameter. -fn spotify_callback(path: &str) -> bool { - path.contains("code=") -} - -/// Bind the OAuth callback listener. Callers bind *before* opening the browser: -/// a launcher can wait for the browser to exit (xdg-open with no Firefox -/// running does), and a redirect that arrives before the bind is refused. -pub async fn bind_callback_listener(port: u16) -> Result { - tokio::net::TcpListener::bind(format!("127.0.0.1:{}", port)) - .await - .map_err(|e| log::warn!("[login] failed to bind callback server on port {port}: {e}")) -} - -/// The Spotify callback server, used by both the pre-TUI startup login and the -/// in-TUI login flow: it never blocks the caller's thread (the old blocking -/// variant parked a tokio worker in a std accept() loop with no timeout and -/// could hang the startup login entirely, #364). Returns the callback URL, or -/// `Err(())` on accept failure. Callers that need an overall timeout (e.g. the -/// in-TUI flow) apply it via `tokio::time::timeout` so an abandoned login -/// doesn't leak the listener. -pub async fn serve_spotify_callback(listener: tokio::net::TcpListener) -> Result { - run_accept_loop(listener, spotify_callback).await -} - -/// The callback server on a pre-bound listener, for callbacks `accept` approves. -#[cfg_attr(not(feature = "qobuz"), allow(dead_code))] -pub async fn redirect_uri_web_server_on( - listener: tokio::net::TcpListener, - accept: fn(&str) -> bool, -) -> Result { - run_accept_loop(listener, accept).await -} - -/// Accept-loop extracted so tests can inject a pre-bound listener (port 0) and -/// avoid races caused by hard-coding a port that might already be in use. -async fn run_accept_loop( - listener: tokio::net::TcpListener, - accept: fn(&str) -> bool, -) -> Result { - const MAX_CONSECUTIVE_ACCEPT_ERRORS: u8 = 20; - let mut consecutive_accept_errors = 0u8; - loop { - let mut stream = match listener.accept().await { - Ok((stream, _)) => { - consecutive_accept_errors = 0; - stream - } - Err(e) => { - consecutive_accept_errors = consecutive_accept_errors.saturating_add(1); - log::warn!("[login] callback accept error: {e}"); - if consecutive_accept_errors >= MAX_CONSECUTIVE_ACCEPT_ERRORS { - return Err(()); - } - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - continue; - } - }; - - let mut buffer = vec![0u8; 1000]; - let n = match stream.read(&mut buffer).await { - Ok(0) | Err(_) => continue, - Ok(n) => n, - }; - let request = String::from_utf8_lossy(&buffer[..n]); - - if let Some(url) = extract_callback_url(&request, accept) { - let _ = write_async_response(&mut stream, "200 OK", include_str!("redirect_uri.html")).await; - return Ok(url); - } - - // Browser noise (favicon, pre-flight): 400 and keep waiting for the callback. - let _ = write_async_response(&mut stream, "400 Bad Request", "400 - Bad Request").await; - } -} - -async fn write_async_response( - stream: &mut tokio::net::TcpStream, - status: &str, - body: &str, -) -> std::io::Result<()> { - let response = format!( - "HTTP/1.1 {}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - status, - body.len(), - body - ); - stream.write_all(response.as_bytes()).await?; - stream.flush().await -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn valid_callback_returns_url_with_code() { - let request = "GET /login?code=abc&state=xyz HTTP/1.1\r\nHost: 127.0.0.1:8989\r\n\r\n"; - let url = extract_callback_url(request, spotify_callback); - assert!(url.is_some()); - let url = url.unwrap(); - assert!( - url.contains("code=abc"), - "url should contain code=abc, got: {}", - url - ); - assert!( - url.contains("state=xyz"), - "url should contain state=xyz, got: {}", - url - ); - } - - #[test] - fn whitespace_only_request_returns_none() { - // Whitespace-only payload: split_whitespace() returns empty vec (len 0 ≤ 1) → None silently - assert!(extract_callback_url(" \r\n\r\n", spotify_callback).is_none()); - } - - #[test] - fn preflight_single_token_returns_none() { - // A single token (no path) also triggers the malformed branch → None, no panic - assert!(extract_callback_url("GET", spotify_callback).is_none()); - } - - #[test] - fn favicon_request_returns_none() { - let request = "GET /favicon.ico HTTP/1.1\r\nHost: 127.0.0.1:8989\r\n\r\n"; - assert!(extract_callback_url(request, spotify_callback).is_none()); - } - - #[test] - fn root_request_returns_none() { - let request = "GET / HTTP/1.1\r\nHost: 127.0.0.1:8989\r\n\r\n"; - assert!(extract_callback_url(request, spotify_callback).is_none()); - } - - #[test] - fn accept_predicate_decides_which_callback_paths_count() { - let request = "GET /?code_autorisation=abc HTTP/1.1\r\nHost: localhost:8989\r\n\r\n"; - assert!(extract_callback_url(request, spotify_callback).is_none()); - let url = extract_callback_url(request, |path| path.contains("code_autorisation=")).unwrap(); - assert_eq!(url, "http://localhost/?code_autorisation=abc"); - } - - // --- async server tests -------------------------------------------------- - - #[tokio::test] - async fn async_server_returns_callback_url() { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let port = listener.local_addr().unwrap().port(); - - let client = tokio::spawn(async move { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - let mut stream = tokio::net::TcpStream::connect(format!("127.0.0.1:{port}")) - .await - .unwrap(); - let req = - format!("GET /callback?code=testcode123 HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\r\n"); - stream.write_all(req.as_bytes()).await.unwrap(); - let mut buf = vec![0u8; 4096]; - let _ = stream.read(&mut buf).await; - }); - - let result = run_accept_loop(listener, spotify_callback).await; - client.await.unwrap(); - - let url = result.expect("server should return Ok(url)"); - assert!(url.contains("code=testcode123"), "unexpected url: {url}"); - } - - #[tokio::test] - async fn async_server_skips_noise_then_returns_on_real_callback() { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let port = listener.local_addr().unwrap().port(); - - let client = tokio::spawn(async move { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - - // First request: browser noise (favicon) — should get 400 and be ignored. - let mut noise = tokio::net::TcpStream::connect(format!("127.0.0.1:{port}")) - .await - .unwrap(); - noise - .write_all( - format!("GET /favicon.ico HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\r\n").as_bytes(), - ) - .await - .unwrap(); - let mut buf = vec![0u8; 4096]; - let _ = noise.read(&mut buf).await; - drop(noise); - - // Second request: real OAuth callback. - let mut real = tokio::net::TcpStream::connect(format!("127.0.0.1:{port}")) - .await - .unwrap(); - real - .write_all( - format!("GET /callback?code=realcode456 HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\r\n") - .as_bytes(), - ) - .await - .unwrap(); - let _ = real.read(&mut vec![0u8; 4096]).await; - }); - - let result = run_accept_loop(listener, spotify_callback).await; - client.await.unwrap(); - - let url = result.expect("server should return Ok(url)"); - assert!(url.contains("code=realcode456"), "unexpected url: {url}"); - } - - #[tokio::test] - async fn a_busy_port_reports_a_failed_bind() { - let holder = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let port = holder.local_addr().unwrap().port(); - assert!(bind_callback_listener(port).await.is_err()); - } -} diff --git a/src/infra/scripting/CLAUDE.md b/src/infra/scripting/CLAUDE.md deleted file mode 100644 index 621b0367..00000000 --- a/src/infra/scripting/CLAUDE.md +++ /dev/null @@ -1,12 +0,0 @@ -### Lua plugins (feature `scripting`) - -Plugins never see `&mut App` or rspotify types: reads are cached serde snapshots -from `src/core/plugin_api.rs`; writes are shared-vocabulary `Action`s -(`src/core/action/`) the engine drains through `App::apply` while holding -`&mut App`, each routed through the same `App` method the equivalent keybinding -uses, where the terminal has one (`default_binding` in `src/tui/keymap.rs` -records which variants have none). Snapshot changes must be additive -(`#[serde(default)]`, new keys only) - removing/renaming a key breaks -installed plugins and requires bumping -`API_VERSION` and updating `docs/scripting.md`. Validation lives in -`scripting/api.rs`; a failing callback is disabled on one strike. diff --git a/src/infra/scripting/api.rs b/src/infra/scripting/api.rs deleted file mode 100644 index 0c2a77b4..00000000 --- a/src/infra/scripting/api.rs +++ /dev/null @@ -1,1295 +0,0 @@ -use std::rc::Rc; - -use mlua::{Lua, LuaSerdeExt, Value}; -use tokio::sync::mpsc::UnboundedSender; - -use crate::core::action::{Action, NavTarget, RepeatSetting}; -use crate::core::app::PluginDataKind; -use crate::core::plugin_api::{self, PluginPopup, PopupLine}; -use crate::core::theme::ThemeField; -use crate::core::user_config::parse_theme_item; - -use super::events::VALID_EVENT_NAMES; -use super::shared::{ - DataRequest, HttpResponseData, HttpResult, NewTimer, ScriptShared, COMMANDS_KEY, - DATA_CALLBACKS_KEY, HANDLERS_KEY, HTTP_CALLBACKS_KEY, SCREENS_KEY, TIMER_CALLBACKS_KEY, -}; - -/// Build the `spotatui` global table and its functions. -pub(super) fn install_api( - lua: &Lua, - shared: &Rc, - http_tx: UnboundedSender, - http_client: reqwest::Client, - rt_handle: Option, -) -> mlua::Result<()> { - let tbl = lua.create_table()?; - - tbl.set("api_version", plugin_api::API_VERSION)?; - - // spotatui.require_api(n): assert this build is new enough for the plugin. - { - let require_api = lua.create_function(move |_, n: i64| { - if n < 1 { - return Err(mlua::Error::RuntimeError(format!( - "spotatui.require_api: version must be a positive integer, got {n}" - ))); - } - let n = n as u32; - if n > plugin_api::API_VERSION { - return Err(mlua::Error::RuntimeError(format!( - "requires spotatui scripting API v{n} (this build provides v{}); update spotatui to use this plugin", - plugin_api::API_VERSION - ))); - } - Ok(()) - })?; - tbl.set("require_api", require_api)?; - } - - // spotatui.on(event, fn) - { - let lua_inner = lua.clone(); - let shared = shared.clone(); - let on = lua.create_function(move |_, (event, callback): (String, mlua::Function)| { - if !VALID_EVENT_NAMES.contains(&event.as_str()) { - return Err(mlua::Error::RuntimeError(format!( - "spotatui.on: unknown event '{event}'; valid events: {}", - VALID_EVENT_NAMES.join(", ") - ))); - } - let handlers: mlua::Table = lua_inner.named_registry_value(HANDLERS_KEY)?; - let list: mlua::Table = match handlers.get::>(event.clone())? { - Some(t) => t, - None => { - let t = lua_inner.create_table()?; - handlers.set(event.clone(), t.clone())?; - t - } - }; - let entry = lua_inner.create_table()?; - entry.set("plugin", shared.current_plugin.borrow().clone())?; - entry.set("callback", callback)?; - list.push(entry)?; - Ok(()) - })?; - tbl.set("on", on)?; - } - - // Reads: spotatui.playback() / current_track() / devices() - { - let shared_pb = shared.clone(); - let playback = lua.create_function(move |lua, ()| { - let pb = shared_pb.playback.borrow().clone(); - match pb { - Some(state) => lua.to_value(&state), - None => Ok(Value::Nil), - } - })?; - tbl.set("playback", playback)?; - - let shared_ct = shared.clone(); - let current_track = lua.create_function(move |lua, ()| { - let pb = shared_ct.playback.borrow().clone(); - match pb.and_then(|s| s.track) { - Some(track) => lua.to_value(&track), - None => Ok(Value::Nil), - } - })?; - tbl.set("current_track", current_track)?; - - let shared_dev = shared.clone(); - let devices = lua.create_function(move |lua, ()| { - let devices = shared_dev.devices.borrow().clone(); - lua.to_value(&devices) - })?; - tbl.set("devices", devices)?; - } - - // Actions: queue for the engine's next drain into `App::apply`. - install_action(lua, &tbl, shared, "play", || Action::Play)?; - install_action(lua, &tbl, shared, "pause", || Action::Pause)?; - install_action(lua, &tbl, shared, "next", || Action::NextTrack)?; - install_action(lua, &tbl, shared, "previous", || Action::PreviousTrack)?; - - { - let shared = shared.clone(); - let seek = lua.create_function(move |_, ms: u32| { - shared.effects.borrow_mut().push(Action::SeekTo(ms)); - Ok(()) - })?; - tbl.set("seek", seek)?; - } - - { - let shared = shared.clone(); - let set_volume = lua.create_function(move |_, pct: i64| { - let clamped = pct.clamp(0, 100) as u8; - shared.effects.borrow_mut().push(Action::SetVolume(clamped)); - Ok(()) - })?; - tbl.set("set_volume", set_volume)?; - } - - { - let shared = shared.clone(); - let shuffle = lua.create_function(move |_, on: bool| { - shared.effects.borrow_mut().push(Action::SetShuffle(on)); - Ok(()) - })?; - tbl.set("shuffle", shuffle)?; - } - - { - let shared = shared.clone(); - let search = lua.create_function(move |_, query: String| { - shared.effects.borrow_mut().push(Action::Search(query)); - Ok(()) - })?; - tbl.set("search", search)?; - } - - { - let shared = shared.clone(); - let notify = lua.create_function(move |_, (msg, ttl): (String, Option)| { - shared - .effects - .borrow_mut() - .push(Action::Notify(msg, ttl.unwrap_or(4))); - Ok(()) - })?; - tbl.set("notify", notify)?; - } - - { - let log = lua.create_function(move |_, msg: String| { - log::info!("[lua] {msg}"); - Ok(()) - })?; - tbl.set("log", log)?; - } - - { - let json_decode = lua.create_function(move |lua, json: String| { - let value: serde_json::Value = serde_json::from_str(&json).map_err(mlua::Error::external)?; - lua.to_value(&value) - })?; - tbl.set("json_decode", json_decode)?; - - let json_encode = lua.create_function(move |lua, value: Value| { - let value: serde_json::Value = lua.from_value(value)?; - serde_json::to_string(&value).map_err(mlua::Error::external) - })?; - tbl.set("json_encode", json_encode)?; - } - - // Async HTTP: request tasks send results back to the engine, which owns the Lua state. - { - let lua_inner = lua.clone(); - let shared = shared.clone(); - let tx = http_tx.clone(); - let client = http_client.clone(); - let handle = rt_handle.clone(); - let http_get = lua.create_function(move |_, (url, callback): (String, mlua::Function)| { - validate_http_url("spotatui.http_get", &url)?; - let handle = handle.clone().ok_or_else(|| { - mlua::Error::RuntimeError("spotatui.http_get: no tokio runtime available".to_string()) - })?; - let token = register_http_callback(&lua_inner, &shared, callback)?; - let client = client.clone(); - let tx = tx.clone(); - handle.spawn(async move { - let result = run_http_get(client, url).await; - let _ = tx.send((token, result)); - }); - Ok(()) - })?; - tbl.set("http_get", http_get)?; - } - - { - let lua_inner = lua.clone(); - let shared = shared.clone(); - let tx = http_tx.clone(); - let client = http_client.clone(); - let handle = rt_handle.clone(); - let http_post = lua.create_function( - move |_, - (url, body, headers, callback): ( - String, - String, - Option, - mlua::Function, - )| { - validate_http_url("spotatui.http_post", &url)?; - let handle = handle.clone().ok_or_else(|| { - mlua::Error::RuntimeError("spotatui.http_post: no tokio runtime available".to_string()) - })?; - let headers = collect_headers(headers)?; - let token = register_http_callback(&lua_inner, &shared, callback)?; - let client = client.clone(); - let tx = tx.clone(); - handle.spawn(async move { - let result = run_http_post(client, url, body, headers).await; - let _ = tx.send((token, result)); - }); - Ok(()) - }, - )?; - tbl.set("http_post", http_post)?; - } - - // spotatui.register_command(name, fn) - { - let lua_inner = lua.clone(); - let shared = shared.clone(); - let register_command = - lua.create_function(move |_, (name, callback): (String, mlua::Function)| { - if name.is_empty() || name.contains(char::is_whitespace) { - return Err(mlua::Error::RuntimeError( - "spotatui.register_command: name must be a non-empty string without whitespace" - .to_string(), - )); - } - let commands: mlua::Table = lua_inner.named_registry_value(COMMANDS_KEY)?; - if commands.get::>(name.clone())?.is_some() { - return Err(mlua::Error::RuntimeError(format!( - "spotatui.register_command: command '{name}' is already registered" - ))); - } - let entry = lua_inner.create_table()?; - entry.set("plugin", shared.current_plugin.borrow().clone())?; - entry.set("callback", callback)?; - commands.set(name, entry)?; - Ok(()) - })?; - tbl.set("register_command", register_command)?; - } - - // spotatui.set_playbar(text_or_nil) - { - let shared = shared.clone(); - let set_playbar = lua.create_function(move |_, text: Option| { - let plugin = shared.current_plugin.borrow().clone(); - shared - .effects - .borrow_mut() - .push(Action::SetPlaybarSegment { plugin, text }); - Ok(()) - })?; - tbl.set("set_playbar", set_playbar)?; - } - - // spotatui.popup(title, lines) - { - let shared = shared.clone(); - let popup = lua.create_function(move |_, (title, lines_val): (String, mlua::Value)| { - let lines = parse_popup_lines(lines_val)?; - shared - .effects - .borrow_mut() - .push(Action::ShowPopup(PluginPopup { title, lines })); - Ok(()) - })?; - tbl.set("popup", popup)?; - } - - // spotatui.set_theme(tbl) - { - let shared = shared.clone(); - let set_theme = lua.create_function(move |_, tbl: mlua::Table| { - let mut pairs: Vec<(ThemeField, crate::core::theme::Color)> = Vec::new(); - for pair in tbl.pairs::() { - let (field, color_str) = pair?; - let Some(theme_field) = ThemeField::from_name(&field) else { - return Err(mlua::Error::RuntimeError(format!( - "spotatui.set_theme: unknown theme field '{field}'" - ))); - }; - let color = parse_theme_item(&color_str).map_err(|e| { - mlua::Error::RuntimeError(format!( - "spotatui.set_theme: invalid color for field '{field}': {e}" - )) - })?; - pairs.push((theme_field, color)); - } - shared.effects.borrow_mut().push(Action::SetTheme(pairs)); - Ok(()) - })?; - tbl.set("set_theme", set_theme)?; - } - - // Async data reads: spotatui.get_X(cb) with cb(data, err), like http. - install_data_read( - lua, - &tbl, - shared, - "get_playlists", - PluginDataKind::Playlists, - )?; - install_data_read(lua, &tbl, shared, "get_queue", PluginDataKind::Queue)?; - install_data_read( - lua, - &tbl, - shared, - "get_saved_tracks", - PluginDataKind::SavedTracks, - )?; - install_data_read( - lua, - &tbl, - shared, - "get_saved_albums", - PluginDataKind::SavedAlbums, - )?; - install_data_read( - lua, - &tbl, - shared, - "get_saved_shows", - PluginDataKind::SavedShows, - )?; - install_data_read( - lua, - &tbl, - shared, - "get_recently_played", - PluginDataKind::RecentlyPlayed, - )?; - install_data_read(lua, &tbl, shared, "get_devices", PluginDataKind::Devices)?; - install_data_read(lua, &tbl, shared, "get_lyrics", PluginDataKind::Lyrics)?; - - // spotatui.get_search_results(query, cb): the only data read with an argument. - { - let lua_inner = lua.clone(); - let shared = shared.clone(); - let get_search_results = - lua.create_function(move |_, (query, callback): (String, mlua::Function)| { - if query.is_empty() { - return Err(mlua::Error::RuntimeError( - "spotatui.get_search_results: query must be a non-empty string".to_string(), - )); - } - let token = register_callback(&lua_inner, &shared, DATA_CALLBACKS_KEY, callback)?; - shared.data_requests.borrow_mut().push(DataRequest { - token, - kind: PluginDataKind::Search, - arg: Some(query), - }); - Ok(()) - })?; - tbl.set("get_search_results", get_search_results)?; - } - - // Cached synchronous reads, refreshed by the engine when the matching data - // generation advances. - { - let shared_pl = shared.clone(); - let playlists = - lua.create_function(move |lua, ()| lua.to_value(&*shared_pl.playlists_cache.borrow()))?; - tbl.set("playlists", playlists)?; - - let shared_q = shared.clone(); - let queue = - lua.create_function(move |lua, ()| lua.to_value(&*shared_q.queue_cache.borrow()))?; - tbl.set("queue", queue)?; - - let shared_sr = shared.clone(); - let search_results = lua - .create_function(move |lua, ()| lua.to_value(&*shared_sr.search_results_cache.borrow()))?; - tbl.set("search_results", search_results)?; - } - - // Transport / library actions. - { - let shared = shared.clone(); - let set_repeat = lua.create_function(move |_, mode: String| { - let setting = match mode.as_str() { - "off" => RepeatSetting::Off, - "track" => RepeatSetting::Track, - "context" => RepeatSetting::Context, - other => { - return Err(mlua::Error::RuntimeError(format!( - "spotatui.set_repeat: expected \"off\", \"track\" or \"context\", got '{other}'" - ))); - } - }; - shared.effects.borrow_mut().push(Action::SetRepeat(setting)); - Ok(()) - })?; - tbl.set("set_repeat", set_repeat)?; - } - - install_action(lua, &tbl, shared, "cycle_repeat", || Action::CycleRepeat)?; - - install_string_action(lua, &tbl, shared, "transfer_playback", |device_id| { - // `persist: false`: a plugin-initiated transfer must not overwrite the - // user's saved device preference (only the interactive device picker - // persists). - Action::TransferPlayback { - device_id, - persist: false, - } - })?; - - // spotatui.play_uri(uri): tracks/episodes play as a uri list, containers as context. - { - let shared = shared.clone(); - let play_uri = lua.create_function(move |_, uri: String| { - let action = if uri.starts_with("spotify:track:") || uri.starts_with("spotify:episode:") { - Action::PlayUris { - uris: vec![uri], - offset: None, - } - } else if is_context_uri(&uri) { - Action::PlayContext { uri, offset: None } - } else { - return Err(mlua::Error::RuntimeError(format!( - "spotatui.play_uri: expected a spotify:track/episode/album/playlist/artist/show uri, got '{uri}'" - ))); - }; - shared.effects.borrow_mut().push(action); - Ok(()) - })?; - tbl.set("play_uri", play_uri)?; - } - - // spotatui.play_context(uri, offset?): play a container, optionally from an - // 0-based track offset. - { - let shared = shared.clone(); - let play_context = lua.create_function(move |_, (uri, offset): (String, Option)| { - if !is_context_uri(&uri) { - return Err(mlua::Error::RuntimeError(format!( - "spotatui.play_context: expected a spotify:album/playlist/artist/show uri, got '{uri}'" - ))); - } - shared - .effects - .borrow_mut() - .push(Action::PlayContext { uri, offset }); - Ok(()) - })?; - tbl.set("play_context", play_context)?; - } - - install_string_action(lua, &tbl, shared, "add_to_queue", Action::AddToQueue)?; - - // spotatui.create_playlist(name, uris?) - { - let shared = shared.clone(); - let create_playlist = - lua.create_function(move |_, (name, uris): (String, Option)| { - if name.trim().is_empty() { - return Err(mlua::Error::RuntimeError( - "spotatui.create_playlist: name must be a non-empty string".to_string(), - )); - } - let mut track_ids = Vec::new(); - if let Some(uris) = uris { - for uri in uris.sequence_values::() { - track_ids.push(uri?); - } - } - shared.effects.borrow_mut().push(Action::CreatePlaylist { - name, - track_uris: track_ids, - }); - Ok(()) - })?; - tbl.set("create_playlist", create_playlist)?; - } - - // spotatui.playlist_add_track(playlist, track) - { - let shared = shared.clone(); - let playlist_add_track = - lua.create_function(move |_, (playlist, track): (String, String)| { - require_nonempty("spotatui.playlist_add_track", "playlist", &playlist)?; - require_nonempty("spotatui.playlist_add_track", "track", &track)?; - shared - .effects - .borrow_mut() - .push(Action::AddTrackToPlaylist { playlist, track }); - Ok(()) - })?; - tbl.set("playlist_add_track", playlist_add_track)?; - } - - // spotatui.playlist_remove_track(playlist, track, position) -- 0-based, required. - { - let shared = shared.clone(); - let playlist_remove_track = - lua.create_function(move |_, (playlist, track, position): (String, String, i64)| { - require_nonempty("spotatui.playlist_remove_track", "playlist", &playlist)?; - require_nonempty("spotatui.playlist_remove_track", "track", &track)?; - let position = usize::try_from(position).map_err(|_| { - mlua::Error::RuntimeError(format!( - "spotatui.playlist_remove_track: position must be a non-negative (0-based) integer, got {position}" - )) - })?; - shared - .effects - .borrow_mut() - .push(Action::RemoveTrackFromPlaylist { - playlist, - track, - position, - }); - Ok(()) - })?; - tbl.set("playlist_remove_track", playlist_remove_track)?; - } - - install_string_action(lua, &tbl, shared, "follow_playlist", Action::FollowPlaylist)?; - install_string_action( - lua, - &tbl, - shared, - "unfollow_playlist", - Action::UnfollowPlaylist, - )?; - install_string_action( - lua, - &tbl, - shared, - "toggle_save_track", - Action::ToggleSaveTrack, - )?; - install_string_action(lua, &tbl, shared, "save_album", Action::SaveAlbum)?; - install_string_action(lua, &tbl, shared, "unsave_album", Action::UnsaveAlbum)?; - install_string_action(lua, &tbl, shared, "save_show", Action::SaveShow)?; - install_string_action(lua, &tbl, shared, "unsave_show", Action::UnsaveShow)?; - install_string_action(lua, &tbl, shared, "follow_artist", Action::FollowArtist)?; - install_string_action(lua, &tbl, shared, "unfollow_artist", Action::UnfollowArtist)?; - - // Timers. Fired from the engine's tick pass, so the effective resolution is - // the UI tick rate (behavior.tick_rate_milliseconds). - install_timer(lua, &tbl, shared, "set_timeout", false)?; - install_timer(lua, &tbl, shared, "set_interval", true)?; - - { - let shared = shared.clone(); - let cancel_timer = lua.create_function(move |_, handle: i64| { - if let Ok(token) = u64::try_from(handle) { - shared.cancelled_timers.borrow_mut().push(token); - } - Ok(()) - })?; - tbl.set("cancel_timer", cancel_timer)?; - } - - // spotatui.config(): theme colors + safe behavior scalars (cached sync read). - { - let shared = shared.clone(); - let config = - lua.create_function(move |lua, ()| lua.to_value(&*shared.config_cache.borrow()))?; - tbl.set("config", config)?; - } - - // Navigation. - { - let shared = shared.clone(); - let navigate = lua.create_function(move |_, target: String| { - let Some(nav_target) = NavTarget::from_name(&target) else { - let valid: Vec<&str> = NavTarget::ALL.iter().map(|t| t.name()).collect(); - return Err(mlua::Error::RuntimeError(format!( - "spotatui.navigate: unknown target '{target}'; valid targets: {}", - valid.join(", ") - ))); - }; - shared - .effects - .borrow_mut() - .push(Action::Navigate(nav_target)); - Ok(()) - })?; - tbl.set("navigate", navigate)?; - } - - install_action(lua, &tbl, shared, "back", || Action::Back)?; - - { - let shared = shared.clone(); - let current_route = - lua.create_function(move |_, ()| Ok(shared.current_route.borrow().clone()))?; - tbl.set("current_route", current_route)?; - } - - // Custom plugin screens (retained-mode). - { - let lua_inner = lua.clone(); - let shared = shared.clone(); - let register_screen = lua.create_function(move |_, (name, spec): (String, mlua::Table)| { - if name.is_empty() || name.contains(char::is_whitespace) { - return Err(mlua::Error::RuntimeError( - "spotatui.register_screen: name must be a non-empty string without whitespace" - .to_string(), - )); - } - let screens: mlua::Table = lua_inner.named_registry_value(SCREENS_KEY)?; - if screens.get::>(name.clone())?.is_some() { - return Err(mlua::Error::RuntimeError(format!( - "spotatui.register_screen: screen '{name}' is already registered" - ))); - } - let on_key: mlua::Function = - spec - .get::>("on_key")? - .ok_or_else(|| { - mlua::Error::RuntimeError( - "spotatui.register_screen: spec must have an 'on_key' function".to_string(), - ) - })?; - let entry = lua_inner.create_table()?; - entry.set("plugin", shared.current_plugin.borrow().clone())?; - entry.set( - "title", - spec.get::>("title")?.unwrap_or_default(), - )?; - entry.set("on_key", on_key)?; - if let Some(on_open) = spec.get::>("on_open")? { - entry.set("on_open", on_open)?; - } - if let Some(on_close) = spec.get::>("on_close")? { - entry.set("on_close", on_close)?; - } - screens.set(name, entry)?; - Ok(()) - })?; - tbl.set("register_screen", register_screen)?; - } - - { - let lua_inner = lua.clone(); - let shared = shared.clone(); - let set_screen = lua.create_function(move |_, (name, widgets): (String, mlua::Table)| { - let title = verify_screen_owner(&lua_inner, &shared, "spotatui.set_screen", &name)?; - let widgets = parse_screen_widgets(widgets)?; - shared.effects.borrow_mut().push(Action::SetScreenContent { - name, - content: plugin_api::PluginScreenContent { title, widgets }, - }); - Ok(()) - })?; - tbl.set("set_screen", set_screen)?; - } - - { - let lua_inner = lua.clone(); - let shared = shared.clone(); - let show_screen = lua.create_function(move |_, name: String| { - verify_screen_owner(&lua_inner, &shared, "spotatui.show_screen", &name)?; - shared.effects.borrow_mut().push(Action::ShowScreen(name)); - Ok(()) - })?; - tbl.set("show_screen", show_screen)?; - } - - { - let lua_inner = lua.clone(); - let shared = shared.clone(); - let close_screen = lua.create_function(move |_, name: String| { - verify_screen_owner(&lua_inner, &shared, "spotatui.close_screen", &name)?; - shared.effects.borrow_mut().push(Action::CloseScreen(name)); - Ok(()) - })?; - tbl.set("close_screen", close_screen)?; - } - - // Plugin-scoped persistent storage: a flat JSON object per plugin under - // /plugin-data/.json. Lazily loaded on first touch, - // flushed by the engine (throttled on tick, forced on quit). - { - let shared_get = shared.clone(); - let storage_get = lua.create_function(move |lua, key: String| { - let namespace = storage_namespace(&shared_get)?; - ensure_storage_loaded(&shared_get, &namespace); - let storage = shared_get.storage.borrow(); - match storage.get(&namespace).and_then(|ns| ns.get(&key)) { - Some(value) => lua.to_value(value), - None => Ok(Value::Nil), - } - })?; - tbl.set("storage_get", storage_get)?; - - let shared_set = shared.clone(); - let storage_set = lua.create_function(move |lua, (key, value): (String, Value)| { - let namespace = storage_namespace(&shared_set)?; - ensure_storage_loaded(&shared_set, &namespace); - if value.is_nil() { - let mut storage = shared_set.storage.borrow_mut(); - if let Some(ns) = storage.get_mut(&namespace) { - ns.remove(&key); - } - } else { - // serde round-trip rejects functions/userdata with a clear error. - let json: serde_json::Value = lua.from_value(value).map_err(|e| { - mlua::Error::RuntimeError(format!( - "spotatui.storage_set: value must be JSON-serializable: {e}" - )) - })?; - let mut storage = shared_set.storage.borrow_mut(); - storage - .entry(namespace.clone()) - .or_default() - .insert(key, json); - } - shared_set.storage_dirty.borrow_mut().insert(namespace); - Ok(()) - })?; - tbl.set("storage_set", storage_set)?; - - let shared_remove = shared.clone(); - let storage_remove = lua.create_function(move |_, key: String| { - let namespace = storage_namespace(&shared_remove)?; - ensure_storage_loaded(&shared_remove, &namespace); - let removed = shared_remove - .storage - .borrow_mut() - .get_mut(&namespace) - .and_then(|ns| ns.remove(&key)) - .is_some(); - if removed { - shared_remove.storage_dirty.borrow_mut().insert(namespace); - } - Ok(()) - })?; - tbl.set("storage_remove", storage_remove)?; - - let shared_keys = shared.clone(); - let storage_keys = lua.create_function(move |_, ()| { - let namespace = storage_namespace(&shared_keys)?; - ensure_storage_loaded(&shared_keys, &namespace); - let storage = shared_keys.storage.borrow(); - let keys: Vec = storage - .get(&namespace) - .map(|ns| ns.keys().cloned().collect()) - .unwrap_or_default(); - Ok(keys) - })?; - tbl.set("storage_keys", storage_keys)?; - } - - lua.globals().set("spotatui", tbl)?; - Ok(()) -} - -/// The storage namespace for the plugin currently on the call stack. -fn storage_namespace(shared: &Rc) -> mlua::Result { - let namespace = super::shared::plugin_id(&shared.current_plugin.borrow()); - if namespace.is_empty() { - return Err(mlua::Error::RuntimeError( - "spotatui.storage_*: no plugin context (storage is only available from plugin code)" - .to_string(), - )); - } - Ok(namespace) -} - -/// Load a namespace from disk on first touch. Missing or corrupt files map to -/// an empty namespace (corruption is logged, never fatal). -fn ensure_storage_loaded(shared: &Rc, namespace: &str) { - if shared.storage.borrow().contains_key(namespace) { - return; - } - let loaded = shared - .storage_path(namespace) - .and_then(|path| match std::fs::read_to_string(&path) { - Ok(text) => match serde_json::from_str::(&text) { - Ok(serde_json::Value::Object(map)) => Some(map), - Ok(_) => { - log::warn!( - "[lua] plugin storage {} is not a JSON object; starting empty", - path.display() - ); - None - } - Err(e) => { - log::warn!( - "[lua] plugin storage {} is corrupt ({e}); starting empty", - path.display() - ); - None - } - }, - Err(_) => None, // missing file: first run - }) - .unwrap_or_default(); - shared - .storage - .borrow_mut() - .insert(namespace.to_string(), loaded); -} - -/// Install `spotatui.set_timeout(ms, fn)` / `set_interval(ms, fn)`, returning -/// an opaque handle for `cancel_timer`. -fn install_timer( - lua: &Lua, - tbl: &mlua::Table, - shared: &Rc, - name: &'static str, - repeating: bool, -) -> mlua::Result<()> { - let lua_inner = lua.clone(); - let shared = shared.clone(); - let f = lua.create_function(move |_, (ms, callback): (i64, mlua::Function)| { - if ms < 0 { - return Err(mlua::Error::RuntimeError(format!( - "spotatui.{name}: milliseconds must be non-negative, got {ms}" - ))); - } - if repeating && ms == 0 { - return Err(mlua::Error::RuntimeError(format!( - "spotatui.{name}: interval must be at least 1 ms" - ))); - } - let token = register_callback(&lua_inner, &shared, TIMER_CALLBACKS_KEY, callback)?; - let duration = std::time::Duration::from_millis(ms as u64); - shared.new_timers.borrow_mut().push(NewTimer { - token, - delay: duration, - interval: repeating.then_some(duration), - }); - // register_callback guarantees the token fits in i64. - Ok(token as i64) - })?; - tbl.set(name, f)?; - Ok(()) -} - -/// True for Spotify container URIs playable as a context. -fn is_context_uri(uri: &str) -> bool { - uri.starts_with("spotify:album:") - || uri.starts_with("spotify:playlist:") - || uri.starts_with("spotify:artist:") - || uri.starts_with("spotify:show:") -} - -fn require_nonempty(function_name: &str, arg_name: &str, value: &str) -> mlua::Result<()> { - if value.trim().is_empty() { - return Err(mlua::Error::RuntimeError(format!( - "{function_name}: {arg_name} must be a non-empty string" - ))); - } - Ok(()) -} - -/// Install an action taking a single non-empty string argument. -fn install_string_action( - lua: &Lua, - tbl: &mlua::Table, - shared: &Rc, - name: &'static str, - make: fn(String) -> Action, -) -> mlua::Result<()> { - let shared = shared.clone(); - let f = lua.create_function(move |_, value: String| { - require_nonempty(&format!("spotatui.{name}"), "argument", &value)?; - shared.effects.borrow_mut().push(make(value)); - Ok(()) - })?; - tbl.set(name, f)?; - Ok(()) -} - -/// Install an argument-less async data read: `spotatui.(cb)` registers the -/// callback and queues a [`DataRequest`] for the engine's next intake pass. -fn install_data_read( - lua: &Lua, - tbl: &mlua::Table, - shared: &Rc, - name: &'static str, - kind: PluginDataKind, -) -> mlua::Result<()> { - let lua_inner = lua.clone(); - let shared = shared.clone(); - let f = lua.create_function(move |_, callback: mlua::Function| { - let token = register_callback(&lua_inner, &shared, DATA_CALLBACKS_KEY, callback)?; - shared.data_requests.borrow_mut().push(DataRequest { - token, - kind, - arg: None, - }); - Ok(()) - })?; - tbl.set(name, f)?; - Ok(()) -} - -fn validate_http_url(function_name: &str, url: &str) -> mlua::Result<()> { - let parsed = reqwest::Url::parse(url) - .map_err(|e| mlua::Error::RuntimeError(format!("{function_name}: invalid URL '{url}': {e}")))?; - match parsed.scheme() { - "http" | "https" => Ok(()), - scheme => Err(mlua::Error::RuntimeError(format!( - "{function_name}: unsupported URL scheme '{scheme}'" - ))), - } -} - -/// Store a `{ plugin, callback }` entry under a fresh token in the given -/// registry table. Shared by HTTP requests, data requests and timers. -pub(super) fn register_callback( - lua: &Lua, - shared: &Rc, - registry_key: &str, - callback: mlua::Function, -) -> mlua::Result { - let token = shared - .next_token - .get() - .checked_add(1) - .ok_or_else(|| mlua::Error::RuntimeError("spotatui: token overflow".to_string()))?; - let key = i64::try_from(token) - .map_err(|_| mlua::Error::RuntimeError("spotatui: token overflow".to_string()))?; - shared.next_token.set(token); - - let callbacks: mlua::Table = lua.named_registry_value(registry_key)?; - let entry = lua.create_table()?; - entry.set("plugin", shared.current_plugin.borrow().clone())?; - entry.set("callback", callback)?; - callbacks.raw_set(key, entry)?; - Ok(token) -} - -fn register_http_callback( - lua: &Lua, - shared: &Rc, - callback: mlua::Function, -) -> mlua::Result { - register_callback(lua, shared, HTTP_CALLBACKS_KEY, callback) -} - -fn collect_headers(headers: Option) -> mlua::Result> { - let Some(headers) = headers else { - return Ok(Vec::new()); - }; - let mut out = Vec::new(); - for pair in headers.pairs::() { - out.push(pair?); - } - Ok(out) -} - -async fn run_http_get(client: reqwest::Client, url: String) -> Result { - let response = client.get(url).send().await.map_err(|e| e.to_string())?; - response_data(response).await -} - -async fn run_http_post( - client: reqwest::Client, - url: String, - body: String, - headers: Vec<(String, String)>, -) -> Result { - let mut request = client.post(url).body(body); - for (key, value) in headers { - request = request.header(key, value); - } - let response = request.send().await.map_err(|e| e.to_string())?; - response_data(response).await -} - -async fn response_data(response: reqwest::Response) -> Result { - let status = response.status().as_u16(); - let bytes = response.bytes().await.map_err(|e| e.to_string())?; - let body = String::from_utf8_lossy(&bytes).into_owned(); - Ok(HttpResponseData { status, body }) -} - -/// Check the screen exists and is owned by the calling plugin; returns the -/// screen's registered title. -fn verify_screen_owner( - lua: &Lua, - shared: &Rc, - fn_name: &str, - name: &str, -) -> mlua::Result { - let screens: mlua::Table = lua.named_registry_value(SCREENS_KEY)?; - let entry: mlua::Table = screens - .get::>(name.to_string())? - .ok_or_else(|| { - mlua::Error::RuntimeError(format!( - "{fn_name}: no screen named '{name}' is registered (call register_screen first)" - )) - })?; - let owner: String = entry.get("plugin").unwrap_or_default(); - let caller = shared.current_plugin.borrow().clone(); - if owner != caller { - return Err(mlua::Error::RuntimeError(format!( - "{fn_name}: screen '{name}' belongs to plugin '{owner}'" - ))); - } - entry - .get::>("title") - .map(Option::unwrap_or_default) -} - -/// Name used in every `set_screen` parse error. -const SET_SCREEN_FN: &str = "spotatui.set_screen"; - -/// Maximum `row`/`column` nesting depth accepted by `spotatui.set_screen`. -const MAX_WIDGET_DEPTH: usize = 8; - -/// Maximum number of widget nodes in one screen's tree. The depth cap alone does -/// not bound the parse (a shallow tree can still be enormous), so nodes are -/// budgeted too. -const MAX_WIDGET_NODES: usize = 256; - -/// Parse the widget array for `spotatui.set_screen`. -/// -/// Each item is a table with a `type` of "paragraph", "list", "gauge", -/// "cover_art", "row" or "column"; `row`/`column` nest via `children`. -fn parse_screen_widgets(widgets: mlua::Table) -> mlua::Result> { - let mut budget = MAX_WIDGET_NODES; - let mut cover_art_seen = false; - parse_widget_list(widgets, 0, &mut budget, &mut cover_art_seen) -} - -fn parse_widget_list( - widgets: mlua::Table, - depth: usize, - budget: &mut usize, - cover_art_seen: &mut bool, -) -> mlua::Result> { - if depth > MAX_WIDGET_DEPTH { - return Err(mlua::Error::RuntimeError(format!( - "{SET_SCREEN_FN}: widgets nested more than {MAX_WIDGET_DEPTH} levels deep" - ))); - } - - let mut out = Vec::new(); - for item in widgets.sequence_values::() { - let item = item?; - if *budget == 0 { - return Err(mlua::Error::RuntimeError(format!( - "{SET_SCREEN_FN}: a screen may contain at most {MAX_WIDGET_NODES} widgets" - ))); - } - *budget -= 1; - - let kind: String = item.get::>("type")?.ok_or_else(|| { - mlua::Error::RuntimeError(format!( - "{SET_SCREEN_FN}: each widget must have a 'type' field" - )) - })?; - let kind = match kind.as_str() { - "paragraph" => { - let lines: mlua::Value = item.get("lines")?; - let lines = parse_styled_lines(SET_SCREEN_FN, lines)?; - // Defaults to true: under api_version 5 every paragraph on a screen - // scrolled in lockstep, and that stays the behavior unless opted out. - let scroll: bool = item.get::>("scroll")?.unwrap_or(true); - plugin_api::PluginWidgetKind::Paragraph { lines, scroll } - } - "list" => { - let items_val: mlua::Value = item.get("items")?; - let items = parse_styled_lines(SET_SCREEN_FN, items_val)?; - let title: Option = item.get("title")?; - // Lua-side `selected` is 1-based (like Lua arrays); stored 0-based. - let selected: Option = item.get("selected")?; - let selected = match selected { - Some(s) if s >= 1 => Some((s - 1) as usize), - Some(s) => { - return Err(mlua::Error::RuntimeError(format!( - "{SET_SCREEN_FN}: list 'selected' is 1-based and must be >= 1, got {s}" - ))); - } - None => None, - }; - plugin_api::PluginWidgetKind::List { - title, - items, - selected, - } - } - "gauge" => { - let ratio: f64 = item.get::>("ratio")?.unwrap_or(0.0); - let label: Option = item.get("label")?; - plugin_api::PluginWidgetKind::Gauge { - ratio: ratio.clamp(0.0, 1.0), - label, - } - } - "cover_art" => { - // One shared protocol slot backs the widget, so a second one would have - // nothing to render. Fail loudly instead of leaving a blank pane. - if *cover_art_seen { - return Err(mlua::Error::RuntimeError(format!( - "{SET_SCREEN_FN}: at most one cover_art widget is supported per screen" - ))); - } - *cover_art_seen = true; - parse_cover_art_widget(&item)? - } - "row" | "column" => { - let children: Option = item.get("children")?; - let children = children.ok_or_else(|| { - mlua::Error::RuntimeError(format!( - "{SET_SCREEN_FN}: '{kind}' must have a 'children' array" - )) - })?; - let children = parse_widget_list(children, depth + 1, budget, cover_art_seen)?; - if kind == "row" { - plugin_api::PluginWidgetKind::Row { children } - } else { - plugin_api::PluginWidgetKind::Column { children } - } - } - other => { - return Err(mlua::Error::RuntimeError(format!( - "{SET_SCREEN_FN}: unknown widget type '{other}' (expected paragraph, list, gauge, \ - cover_art, row or column)" - ))); - } - }; - - out.push(plugin_api::PluginWidget { - kind, - width: parse_widget_length(&item, "width", "width_percent")?, - height: parse_widget_length(&item, "height", "height_percent")?, - }); - } - Ok(out) -} - -fn parse_cover_art_widget(item: &mlua::Table) -> mlua::Result { - // `source` is validated but not stored: "current" is the only image the app - // holds. Rejecting anything else means a future source fails loudly on an old - // build instead of silently rendering the wrong art. - if let Some(source) = item.get::>("source")? { - if source != "current" { - return Err(mlua::Error::RuntimeError(format!( - "{SET_SCREEN_FN}: unknown cover_art source '{source}' (expected \"current\")" - ))); - } - } - let fit = match item.get::>("fit")?.as_deref() { - None | Some("contain") => plugin_api::PluginCoverArtFit::Contain, - Some("scale") => plugin_api::PluginCoverArtFit::Scale, - Some(other) => { - return Err(mlua::Error::RuntimeError(format!( - "{SET_SCREEN_FN}: unknown cover_art fit '{other}' (expected \"contain\" or \"scale\")" - ))); - } - }; - Ok(plugin_api::PluginWidgetKind::CoverArt { fit }) -} - -/// Read one axis' size hint: an absolute cell count (`cells_key`) or a -/// percentage of the parent (`percent_key`). At most one may be given. -/// -/// A zero cell count is accepted and hides the widget: `height = 0` was already -/// legal in v5, where it produced a `Length(0)` constraint, so rejecting it now -/// would break existing plugins. The percentage form has no such history and -/// keeps its stricter `1..=100` range. -fn parse_widget_length( - item: &mlua::Table, - cells_key: &str, - percent_key: &str, -) -> mlua::Result> { - let cells: Option = item.get(cells_key)?; - let percent: Option = item.get(percent_key)?; - match (cells, percent) { - (Some(_), Some(_)) => Err(mlua::Error::RuntimeError(format!( - "{SET_SCREEN_FN}: set at most one of '{cells_key}' and '{percent_key}'" - ))), - (Some(cells), None) => Ok(Some(plugin_api::PluginLength::Cells(cells))), - (None, Some(percent)) => { - if !(1..=100).contains(&percent) { - return Err(mlua::Error::RuntimeError(format!( - "{SET_SCREEN_FN}: '{percent_key}' must be between 1 and 100, got {percent}" - ))); - } - Ok(Some(plugin_api::PluginLength::Percent(percent))) - } - (None, None) => Ok(None), - } -} - -/// Parse the `lines` argument for `spotatui.popup`. -fn parse_popup_lines(val: mlua::Value) -> mlua::Result> { - parse_styled_lines("spotatui.popup", val) -} - -/// Parse styled lines shared by popups and screen widgets. -/// -/// Accepts: a single string, or an array whose items are each a string or a table -/// `{ text, fg?, bold?, italic? }`. -fn parse_styled_lines(fn_name: &str, val: mlua::Value) -> mlua::Result> { - match val { - mlua::Value::String(s) => Ok(vec![PopupLine { - text: s.to_str()?.to_string(), - fg: None, - bold: false, - italic: false, - }]), - mlua::Value::Table(tbl) => { - let mut lines = Vec::new(); - for item in tbl.sequence_values::() { - let item = item?; - match item { - mlua::Value::String(s) => lines.push(PopupLine { - text: s.to_str()?.to_string(), - fg: None, - bold: false, - italic: false, - }), - mlua::Value::Table(t) => { - let text: Option = t.get("text")?; - let text = text.ok_or_else(|| { - mlua::Error::RuntimeError(format!( - "{fn_name}: each line table must have a 'text' field" - )) - })?; - let fg_str: Option = t.get("fg")?; - let fg = fg_str - .map(|s| { - parse_theme_item(&s).map_err(|e| { - mlua::Error::RuntimeError(format!("{fn_name}: invalid color '{}': {}", s, e)) - }) - }) - .transpose()?; - let bold: bool = t.get::>("bold")?.unwrap_or(false); - let italic: bool = t.get::>("italic")?.unwrap_or(false); - lines.push(PopupLine { - text, - fg, - bold, - italic, - }); - } - other => { - return Err(mlua::Error::RuntimeError(format!( - "{fn_name}: each line must be a string or table, got {}", - other.type_name() - ))); - } - } - } - Ok(lines) - } - other => Err(mlua::Error::RuntimeError(format!( - "{fn_name}: lines must be a string or array, got {}", - other.type_name() - ))), - } -} - -/// Install a no-argument action that pushes a fixed action. -pub(super) fn install_action( - lua: &Lua, - tbl: &mlua::Table, - shared: &Rc, - name: &str, - make: fn() -> Action, -) -> mlua::Result<()> { - let shared = shared.clone(); - let f = lua.create_function(move |_, ()| { - shared.effects.borrow_mut().push(make()); - Ok(()) - })?; - tbl.set(name, f)?; - Ok(()) -} diff --git a/src/infra/scripting/engine.rs b/src/infra/scripting/engine.rs deleted file mode 100644 index bcaa6224..00000000 --- a/src/infra/scripting/engine.rs +++ /dev/null @@ -1,1090 +0,0 @@ -use std::panic::{catch_unwind, AssertUnwindSafe}; -use std::path::{Path, PathBuf}; -use std::rc::Rc; - -use mlua::{Lua, LuaSerdeExt, Value}; -#[cfg(test)] -use tokio::sync::mpsc::UnboundedSender; -use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver}; - -use crate::core::app::{App, LyricsStatus, PluginDataKind}; -use crate::core::plugin_api; -use crate::infra::network::IoEvent; - -use crate::core::action::Action; - -use super::api::install_api; -use super::events::{diff_events, diff_state_events, queue_uris, ScriptEvent}; -use super::shared::{ - DataRequest, HttpResponseData, HttpResult, ScriptShared, COMMANDS_KEY, DATA_CALLBACKS_KEY, - HANDLERS_KEY, HTTP_CALLBACKS_KEY, SCREENS_KEY, TIMER_CALLBACKS_KEY, -}; - -/// How long a plugin data request may wait for its generation to advance -/// before its callback is failed with a timeout error. -const DATA_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); - -/// Minimum interval between throttled plugin-storage flushes (mirrors the -/// driver's session-save cadence). -const STORAGE_FLUSH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(3); - -/// An in-flight plugin data request: dispatched, waiting for the domain's -/// generation counter to move past the value captured at intake. -struct PendingDataRequest { - token: u64, - kind: PluginDataKind, - requested_gen: u64, - deadline: std::time::Instant, -} - -/// An armed plugin timer. `interval` is `Some` for `set_interval`. -struct ActiveTimer { - token: u64, - due: std::time::Instant, - interval: Option, -} - -pub struct ScriptEngine { - pub(super) lua: Lua, - pub(crate) shared: Rc, - /// Previous playback snapshot, for diffing on tick. - last_playback: Option, - /// Previous queue item uris, for diffing on tick. - last_queue: Option>, - /// Previous route name, for `route_change` diffing. - last_route: String, - /// Set when the Search generation advanced since the last tick diff - /// (consumed by the `search_results` event). - search_gen_advanced: bool, - /// Data requests dispatched but not yet resolved. - pending_data: Vec, - /// Armed plugin timers, fired from the tick pass. - timers: Vec, - /// Generations backing the cached sync reads at their last refresh. - /// `u64::MAX` sentinels force the first refresh. - last_cache_gens: [u64; PluginDataKind::COUNT], - /// Last throttled storage flush. - last_storage_flush: Option, - http_rx: UnboundedReceiver, - #[cfg(test)] - http_tx: UnboundedSender, -} - -impl ScriptEngine { - /// Build the Lua state and install the `spotatui` global table. - pub fn new() -> mlua::Result { - let lua = Lua::new(); - let shared = Rc::new(ScriptShared::new()); - let (http_tx, http_rx) = unbounded_channel(); - let http_client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .user_agent(format!("spotatui/{}", env!("CARGO_PKG_VERSION"))) - .build() - .map_err(mlua::Error::external)?; - let rt_handle = tokio::runtime::Handle::try_current().ok(); - - // Registry handler table: { event_name = { {plugin=, callback=}, ... } }. - let handlers = lua.create_table()?; - lua.set_named_registry_value(HANDLERS_KEY, handlers)?; - - // Registry commands table: { command_name = { plugin=, callback= } }. - let commands = lua.create_table()?; - lua.set_named_registry_value(COMMANDS_KEY, commands)?; - - // Registry HTTP callback table: { token = { plugin=, callback= } }. - let http_callbacks = lua.create_table()?; - lua.set_named_registry_value(HTTP_CALLBACKS_KEY, http_callbacks)?; - - // Registry data callback table: { token = { plugin=, callback= } }. - let data_callbacks = lua.create_table()?; - lua.set_named_registry_value(DATA_CALLBACKS_KEY, data_callbacks)?; - - // Registry timer callback table: { token = { plugin=, callback= } }. - let timer_callbacks = lua.create_table()?; - lua.set_named_registry_value(TIMER_CALLBACKS_KEY, timer_callbacks)?; - - // Registry screens table: { name = { plugin=, title=, on_key=, on_open?, on_close? } }. - let screens = lua.create_table()?; - lua.set_named_registry_value(SCREENS_KEY, screens)?; - - install_api(&lua, &shared, http_tx.clone(), http_client, rt_handle)?; - - Ok(ScriptEngine { - lua, - shared, - last_playback: None, - last_queue: None, - last_route: String::new(), - search_gen_advanced: false, - pending_data: Vec::new(), - timers: Vec::new(), - last_cache_gens: [u64::MAX; PluginDataKind::COUNT], - last_storage_flush: None, - http_rx, - #[cfg(test)] - http_tx, - }) - } - - /// Load `init.lua`, then single-file `plugins/*.lua`, then directory plugins - /// `plugins//main.lua` (falling back to `init.lua`). Each group is sorted by filename. - /// Missing files/dir are fine. A failing file logs an error and queues a Notify effect but - /// never aborts the others. Returns the number of plugins loaded successfully. - /// Cheap discovery-only mirror of [`Self::load_user_scripts`]: reports - /// whether any loadable script exists (`init.lua`, `plugins/*.lua`, or - /// `plugins//{main,init}.lua`) so the driver can skip constructing - /// the Lua VM (and its HTTP client) entirely when there is nothing to - /// load. Keep the discovery rules in sync with `load_user_scripts`. - pub fn has_user_scripts(config_dir: &Path) -> bool { - !discover_user_scripts(config_dir).is_empty() - } - - pub fn load_user_scripts(&mut self, config_dir: &Path) -> usize { - *self.shared.config_dir.borrow_mut() = Some(config_dir.to_path_buf()); - let mut loaded = 0; - - for (path, module_dir) in discover_user_scripts(config_dir) { - let name = module_dir - .as_ref() - .and_then(|dir| dir.file_name()) - .or_else(|| path.file_name()) - .and_then(|name| name.to_str()) - .unwrap_or("plugin") - .to_string(); - if let Some(dir) = module_dir { - if let Err(e) = self.add_plugin_module_path(&dir) { - log::warn!("[lua] failed to extend package.path for plugin '{name}': {e}"); - } - } - if self.load_file(&path, &name) { - loaded += 1; - } - } - - loaded - } - - /// Prepend a directory plugin's own folder to Lua's `package.path` so it can `require` its - /// sibling modules (`require("foo")` -> `/foo.lua` or `/foo/init.lua`). `package.path` - /// and the module cache are shared across all plugins: if two plugins both `require("util")`, - /// Lua caches the first-loaded plugin's `util.lua` under that name and silently hands the same - /// module to the later plugin. Give helper modules distinctive (e.g. plugin-prefixed) names. - fn add_plugin_module_path(&self, dir: &Path) -> mlua::Result<()> { - let package: mlua::Table = self.lua.globals().get("package")?; - let current: String = package.get("path").unwrap_or_default(); - let dir = dir.to_string_lossy(); - let sep = std::path::MAIN_SEPARATOR; - package.set( - "path", - format!("{dir}{sep}?.lua;{dir}{sep}?{sep}init.lua;{current}"), - )?; - Ok(()) - } - - /// Read and load a single file. Returns true on success. On any failure logs and queues a - /// Notify effect, returning false. - fn load_file(&mut self, path: &Path, name: &str) -> bool { - let source = match std::fs::read_to_string(path) { - Ok(s) => s, - Err(e) => { - log::error!("[lua] failed to read {}: {}", path.display(), e); - self.shared.effects.borrow_mut().push(Action::NotifyError( - format!("plugin '{name}' failed to load: {e}"), - 6, - )); - return false; - } - }; - match self.load_source(name, &source) { - Ok(()) => true, - Err(e) => { - let fl = first_line(&e.to_string()); - log::error!("[lua] failed to load plugin '{name}': {e}"); - self.shared.effects.borrow_mut().push(Action::NotifyError( - format!("plugin '{name}' failed to load: {fl}"), - 6, - )); - false - } - } - } - - /// Execute a Lua chunk under the given plugin name (used as the chunk name for tracebacks). - /// Public for tests. - pub fn load_source(&mut self, plugin_name: &str, source: &str) -> mlua::Result<()> { - *self.shared.current_plugin.borrow_mut() = plugin_name.to_string(); - let result = self - .lua - .load(source) - .set_name(plugin_name.to_string()) - .exec(); - self.shared.current_plugin.borrow_mut().clear(); - result - } - - /// Refresh caches, emit Start, drain effects. - pub fn on_start(&mut self, app: &mut App) { - self.refresh_caches(app); - self.refresh_data_caches(app); - self.emit(ScriptEvent::Start); - self.drain_http_callbacks(); - self.process_data_requests(app); - self.drain_effects(app); - } - - /// On tick: if there are no handlers at all, return cheaply. Otherwise refresh caches, - /// diff against the previous snapshot, emit each derived event, then drain. - pub fn on_tick(&mut self, app: &mut App) { - self.drain_http_callbacks(); - self.process_data_requests(app); - self.process_timers(); - self.flush_storage(false); - self.refresh_data_caches(app); - if !self.has_any_handlers() { - // Still drain effects and track route transitions: screens and timers - // work without any event handlers registered. - self.drain_effects(app); - self.sync_route_and_emit_change(app); - return; - } - - let new_playback = plugin_api::playback_state(app); - let new_queue = Some(queue_uris(app)); - - let mut events = diff_events( - &self.last_playback, - &self.last_queue, - &new_playback, - &new_queue, - ); - - // Non-playback state diffs: route, devices, search generation. The device - // diff must read the old cache before it is overwritten below. - let new_route = plugin_api::route_name(app.get_current_route()); - let new_devices = plugin_api::device_list(app); - let search_gen_advanced = self.search_gen_advanced; - self.search_gen_advanced = false; - { - let old_devices = self.shared.devices.borrow(); - events.extend(diff_state_events( - &self.last_route, - &new_route, - &old_devices, - &new_devices, - search_gen_advanced, - )); - } - - *self.shared.playback.borrow_mut() = new_playback.clone(); - *self.shared.devices.borrow_mut() = new_devices; - if self.last_route != new_route { - let old_route = std::mem::replace(&mut self.last_route, new_route.clone()); - self.handle_screen_transition(&old_route, &new_route); - } - *self.shared.current_route.borrow_mut() = new_route; - - self.last_playback = new_playback; - self.last_queue = new_queue; - - for ev in events { - self.emit(ev); - } - self.drain_http_callbacks(); - self.process_data_requests(app); - self.drain_effects(app); - // Catch route changes caused by effects drained just above (e.g. a timer - // callback calling show_screen). - self.sync_route_and_emit_change(app); - } - - /// Emit Quit, drain effects, force-flush plugin storage. - pub fn on_quit(&mut self, app: &mut App) { - self.refresh_caches(app); - self.emit(ScriptEvent::Quit); - self.drain_effects(app); - self.flush_storage(true); - } - - fn refresh_caches(&mut self, app: &App) { - let pb = plugin_api::playback_state(app); - *self.shared.playback.borrow_mut() = pb.clone(); - *self.shared.devices.borrow_mut() = plugin_api::device_list(app); - self.last_playback = pb; - self.last_queue = Some(queue_uris(app)); - let route = plugin_api::route_name(app.get_current_route()); - self.last_route.clone_from(&route); - *self.shared.current_route.borrow_mut() = route; - } - - /// Detect a route change outside the tick diff (plugin commands and the key - /// handlers that ran just before them can navigate). Fires screen - /// on_close/on_open, emits `route_change`, and drains any queued effects. - fn sync_route_and_emit_change(&mut self, app: &mut App) { - let new_route = plugin_api::route_name(app.get_current_route()); - if new_route != self.last_route { - let old_route = std::mem::replace(&mut self.last_route, new_route.clone()); - *self.shared.current_route.borrow_mut() = new_route.clone(); - self.handle_screen_transition(&old_route, &new_route); - self.emit(ScriptEvent::RouteChange(new_route)); - self.drain_effects(app); - } - } - - /// Fire a plugin screen's on_close/on_open across a route transition. - fn handle_screen_transition(&mut self, old_route: &str, new_route: &str) { - if let Some(name) = old_route.strip_prefix("plugin:") { - self.call_screen_callback(&name.to_string(), "on_close", None); - } - if let Some(name) = new_route.strip_prefix("plugin:") { - self.call_screen_callback(&name.to_string(), "on_open", None); - } - } - - /// Forward keys queued while a plugin screen was focused to its `on_key`. - fn dispatch_screen_keys(&mut self, app: &mut App) { - if app.pending_plugin_screen_keys.is_empty() { - return; - } - let keys: Vec<(String, String)> = std::mem::take(&mut app.pending_plugin_screen_keys); - for (screen, key) in keys { - self.call_screen_callback(&screen, "on_key", Some(key)); - } - } - - /// Invoke one of a screen's registered callbacks (`on_key` gets the key - /// string as its argument). Errors/panics queue a NotifyError and remove the - /// offending callback from the screen entry (one strike). - fn call_screen_callback(&mut self, screen: &String, field: &'static str, key: Option) { - let screens: mlua::Table = match self.lua.named_registry_value(SCREENS_KEY) { - Ok(t) => t, - Err(_) => return, - }; - let entry: mlua::Table = match screens.get::>(screen.clone()) { - Ok(Some(t)) => t, - _ => return, - }; - let plugin: String = entry.get("plugin").unwrap_or_default(); - let callback: mlua::Function = match entry.get::>(field) { - Ok(Some(f)) => f, - _ => return, // on_open/on_close are optional - }; - drop(screens); - - *self.shared.current_plugin.borrow_mut() = plugin.clone(); - let call_result = catch_unwind(AssertUnwindSafe(|| match key { - Some(key) => callback.call::<()>(key), - None => callback.call::<()>(()), - })); - self.shared.current_plugin.borrow_mut().clear(); - - let err_msg = match call_result { - Ok(Ok(())) => return, - Ok(Err(e)) => first_line(&e.to_string()), - Err(_) => "panic".to_string(), - }; - log::error!("[lua] plugin '{plugin}': error in screen '{screen}' {field}: {err_msg}"); - self.shared.effects.borrow_mut().push(Action::NotifyError( - format!("plugin '{plugin}': error in screen '{screen}' {field}: {err_msg}"), - 6, - )); - // One strike: remove the erroring callback so it can't fire again. - let _ = entry.set(field, Value::Nil); - } - - fn has_any_handlers(&self) -> bool { - let handlers: mlua::Table = match self.lua.named_registry_value(HANDLERS_KEY) { - Ok(t) => t, - Err(_) => return false, - }; - handlers - .pairs::() - .any(|p| p.map(|(_, list)| list.raw_len() > 0).unwrap_or(false)) - } - - /// Invoke every registered callback for `event`. Lua errors and caught panics disable the - /// offending callback (one strike) and queue a Notify effect. - pub(crate) fn emit(&mut self, event: ScriptEvent) { - let handlers: mlua::Table = match self.lua.named_registry_value(HANDLERS_KEY) { - Ok(t) => t, - Err(_) => return, - }; - let list: mlua::Table = match handlers.get(event.lua_name()) { - Ok(t) => t, - Err(_) => return, - }; - - let len = list.raw_len(); - if len == 0 { - return; - } - - let arg = match &event { - ScriptEvent::RouteChange(name) => match self.lua.create_table() { - Ok(t) => { - let _ = t.set("name", name.clone()); - Value::Table(t) - } - Err(_) => Value::Nil, - }, - _ if event.passes_playback_arg() => self.playback_value(), - _ => Value::Nil, - }; - - // Indices to remove after the pass (descending so removal stays valid). - let mut to_remove: Vec = Vec::new(); - - for idx in 1..=len { - let entry: mlua::Table = match list.get(idx) { - Ok(t) => t, - Err(_) => continue, - }; - let plugin: String = entry.get("plugin").unwrap_or_default(); - let callback: mlua::Function = match entry.get("callback") { - Ok(f) => f, - Err(_) => continue, - }; - - let arg = arg.clone(); - *self.shared.current_plugin.borrow_mut() = plugin.clone(); - let call_result = catch_unwind(AssertUnwindSafe(|| callback.call::<()>(arg))); - self.shared.current_plugin.borrow_mut().clear(); - - let err_msg = match call_result { - Ok(Ok(())) => None, - Ok(Err(e)) => Some(first_line(&e.to_string())), - Err(_) => Some("panic".to_string()), - }; - - if let Some(msg) = err_msg { - log::error!( - "[lua] plugin '{plugin}': error in on_{}: {msg}", - event.lua_name() - ); - self.shared.effects.borrow_mut().push(Action::NotifyError( - format!("plugin '{plugin}': error in on_{}: {msg}", event.lua_name()), - 6, - )); - to_remove.push(idx); - } - } - - for idx in to_remove.into_iter().rev() { - let _ = list.raw_remove(idx); - } - } - - /// Serialize the cached playback snapshot into a Lua value (table or nil). - fn playback_value(&self) -> Value { - let pb = self.shared.playback.borrow().clone(); - match pb { - Some(state) => self.lua.to_value(&state).unwrap_or(Value::Nil), - None => Value::Nil, - } - } - - /// Run any commands queued in `app.pending_plugin_commands`, then drain effects. - /// Also the route-change fast path: the key handler that ran just before may - /// have navigated. - pub fn run_pending_commands(&mut self, app: &mut App) { - self.sync_route_and_emit_change(app); - self.dispatch_screen_keys(app); - if app.pending_plugin_commands.is_empty() { - self.drain_http_callbacks(); - self.process_data_requests(app); - self.drain_effects(app); - return; - } - self.refresh_caches(app); - let names: Vec = std::mem::take(&mut app.pending_plugin_commands); - let commands: mlua::Table = match self.lua.named_registry_value(COMMANDS_KEY) { - Ok(t) => t, - Err(_) => { - self.drain_http_callbacks(); - self.process_data_requests(app); - self.drain_effects(app); - return; - } - }; - for name in names { - let entry: mlua::Table = match commands.get::>(name.clone()) { - Ok(Some(t)) => t, - _ => { - self.shared.effects.borrow_mut().push(Action::NotifyError( - format!("no plugin command named '{name}'"), - 6, - )); - continue; - } - }; - let plugin: String = entry.get("plugin").unwrap_or_default(); - let callback: mlua::Function = match entry.get("callback") { - Ok(f) => f, - Err(_) => continue, - }; - *self.shared.current_plugin.borrow_mut() = plugin.clone(); - let call_result = catch_unwind(AssertUnwindSafe(|| callback.call::<()>(()))); - self.shared.current_plugin.borrow_mut().clear(); - match call_result { - Ok(Ok(())) => {} - Ok(Err(e)) => { - let msg = first_line(&e.to_string()); - log::error!("[lua] plugin '{plugin}': error in command '{name}': {msg}"); - self.shared.effects.borrow_mut().push(Action::NotifyError( - format!("plugin '{plugin}': error in command '{name}': {msg}"), - 6, - )); - } - Err(_) => { - log::error!("[lua] plugin '{plugin}': panic in command '{name}'"); - self.shared.effects.borrow_mut().push(Action::NotifyError( - format!("plugin '{plugin}': panic in command '{name}'"), - 6, - )); - } - } - } - self.drain_http_callbacks(); - self.process_data_requests(app); - self.drain_effects(app); - self.sync_route_and_emit_change(app); - } - - /// Intake queued `spotatui.get_*` requests (capture generation + dispatch the - /// matching IoEvent) and resolve pending ones whose generation advanced or - /// whose deadline passed. Intake is atomic on the UI thread while holding - /// `&mut App`, so no update can be lost between capture and dispatch. - fn process_data_requests(&mut self, app: &mut App) { - self.process_data_requests_at(app, std::time::Instant::now()); - } - - fn process_data_requests_at(&mut self, app: &mut App, now: std::time::Instant) { - // Intake. Drain first so the RefCell borrow is dropped before any Lua or - // App call, and so requests queued by callbacks below land in the next pass. - let requests: Vec = self.shared.data_requests.borrow_mut().drain(..).collect(); - for req in requests { - // Lyrics fetches are driven by the driver's track-change detector, not by - // dispatch: deliver immediately when the current status is terminal AND - // describes the item playing now, otherwise wait for the in-flight fetch - // to bump the generation. The currency check matters because the detector - // runs after this tick, so on a track change the terminal status still - // belongs to the previous track - see `lyrics_state_is_current`. - if req.kind == PluginDataKind::Lyrics - && matches!( - app.lyrics_status, - LyricsStatus::Found | LyricsStatus::NotFound - ) - && crate::core::plugin_api::lyrics_state_is_current(app) - { - let value = self.data_value(req.kind, app); - self.deliver_data_result(req.token, value.map_err(|e| e.to_string())); - continue; - } - - let requested_gen = app.plugin_data_generations.get(req.kind); - let event = match req.kind { - PluginDataKind::Playlists => Some(IoEvent::GetPlaylists), - PluginDataKind::Queue => Some(IoEvent::GetQueue), - PluginDataKind::Search => Some(IoEvent::GetSearchResults( - req.arg.clone().unwrap_or_default(), - app.get_user_country(), - )), - PluginDataKind::SavedTracks => Some(IoEvent::GetCurrentSavedTracks(None)), - PluginDataKind::SavedAlbums => Some(IoEvent::GetCurrentUserSavedAlbums(None)), - PluginDataKind::SavedShows => Some(IoEvent::GetCurrentUserSavedShows(None)), - PluginDataKind::RecentlyPlayed => Some(IoEvent::GetRecentlyPlayedSilent), - PluginDataKind::Devices => Some(IoEvent::GetDevicesSilent), - PluginDataKind::Lyrics => None, - }; - if let Some(event) = event { - app.dispatch(event); - } - self.pending_data.push(PendingDataRequest { - token: req.token, - kind: req.kind, - requested_gen, - deadline: now + DATA_REQUEST_TIMEOUT, - }); - } - - // Resolution. Serialize snapshots from `&App` first, collect the outcomes, - // then call into Lua with no engine-side borrows held. - if self.pending_data.is_empty() { - return; - } - let mut resolved: Vec<(u64, Result)> = Vec::new(); - let lua = self.lua.clone(); - self.pending_data.retain(|p| { - if app.plugin_data_generations.get(p.kind) != p.requested_gen { - let value = data_value_for(&lua, p.kind, app).map_err(|e| e.to_string()); - resolved.push((p.token, value)); - false - } else if now >= p.deadline { - resolved.push((p.token, Err("request timed out".to_string()))); - false - } else { - true - } - }); - for (token, result) in resolved { - self.deliver_data_result(token, result); - } - } - - /// Serialize the snapshot for a data kind from current `App` state. - fn data_value(&self, kind: PluginDataKind, app: &App) -> mlua::Result { - data_value_for(&self.lua, kind, app) - } - - /// Arm newly-set timers, apply cancellations, then fire everything due. - /// Callbacks run with catch_unwind + one strike (an erroring interval is - /// removed). Intervals reschedule to `now + interval`, skipping missed - /// periods rather than firing catch-up bursts. - fn process_timers(&mut self) { - self.process_timers_at(std::time::Instant::now()); - } - - fn process_timers_at(&mut self, now: std::time::Instant) { - // Arm first, then cancel: a timer set and cancelled in the same pass stays - // cancelled. - let new_timers: Vec<_> = self.shared.new_timers.borrow_mut().drain(..).collect(); - for t in new_timers { - self.timers.push(ActiveTimer { - token: t.token, - due: now + t.delay, - interval: t.interval, - }); - } - let cancelled: Vec = self - .shared - .cancelled_timers - .borrow_mut() - .drain(..) - .collect(); - if !cancelled.is_empty() { - self.timers.retain(|t| !cancelled.contains(&t.token)); - if let Ok(callbacks) = self - .lua - .named_registry_value::(TIMER_CALLBACKS_KEY) - { - for token in cancelled { - if let Ok(key) = i64::try_from(token) { - let _ = callbacks.raw_set(key, Value::Nil); - } - } - } - } - - // Collect due tokens with no borrows held, rescheduling/removing as we go. - let mut due: Vec<(u64, bool)> = Vec::new(); // (token, repeating) - self.timers.retain_mut(|t| { - if now < t.due { - return true; - } - match t.interval { - Some(interval) => { - due.push((t.token, true)); - t.due = now + interval; - true - } - None => { - due.push((t.token, false)); - false - } - } - }); - - for (token, repeating) in due { - if !self.fire_timer(token, repeating) && repeating { - // One strike: remove the erroring interval. - self.timers.retain(|t| t.token != token); - } - } - } - - /// Invoke a timer callback. Returns false when the callback errored or - /// panicked (the registry entry is removed in that case, and always for - /// one-shot timeouts). - fn fire_timer(&mut self, token: u64, repeating: bool) -> bool { - let callbacks: mlua::Table = match self.lua.named_registry_value(TIMER_CALLBACKS_KEY) { - Ok(t) => t, - Err(_) => return false, - }; - let key = match i64::try_from(token) { - Ok(key) => key, - Err(_) => return false, - }; - let entry: mlua::Table = match callbacks.raw_get::>(key) { - Ok(Some(t)) => t, - _ => return false, - }; - let plugin: String = entry.get("plugin").unwrap_or_default(); - let callback: mlua::Function = match entry.get("callback") { - Ok(f) => f, - Err(_) => { - let _ = callbacks.raw_set(key, Value::Nil); - return false; - } - }; - if !repeating { - let _ = callbacks.raw_set(key, Value::Nil); - } - drop(entry); - drop(callbacks); - - *self.shared.current_plugin.borrow_mut() = plugin.clone(); - let call_result = catch_unwind(AssertUnwindSafe(|| callback.call::<()>(()))); - self.shared.current_plugin.borrow_mut().clear(); - - let err_msg = match call_result { - Ok(Ok(())) => return true, - Ok(Err(e)) => first_line(&e.to_string()), - Err(_) => "panic".to_string(), - }; - log::error!("[lua] plugin '{plugin}': error in timer callback: {err_msg}"); - self.shared.effects.borrow_mut().push(Action::NotifyError( - format!("plugin '{plugin}': error in timer callback: {err_msg}"), - 6, - )); - if repeating { - // Remove the interval's registry entry too (one strike). - if let Ok(callbacks) = self - .lua - .named_registry_value::(TIMER_CALLBACKS_KEY) - { - if let Ok(key) = i64::try_from(token) { - let _ = callbacks.raw_set(key, Value::Nil); - } - } - } - false - } - - #[cfg(test)] - pub(super) fn process_timers_for_test(&mut self, now: std::time::Instant) { - self.process_timers_at(now); - } - - /// Write dirty storage namespaces to disk. Throttled unless `force` (quit). - /// Last writer wins across concurrent spotatui instances -- documented. - pub(super) fn flush_storage(&mut self, force: bool) { - if self.shared.storage_dirty.borrow().is_empty() { - return; - } - let now = std::time::Instant::now(); - if !force { - if let Some(last) = self.last_storage_flush { - if now.duration_since(last) < STORAGE_FLUSH_INTERVAL { - return; - } - } - } - self.last_storage_flush = Some(now); - - let dirty: Vec = std::mem::take(&mut *self.shared.storage_dirty.borrow_mut()) - .into_iter() - .collect(); - let storage = self.shared.storage.borrow(); - for namespace in dirty { - let Some(path) = self.shared.storage_path(&namespace) else { - log::warn!("[lua] plugin storage for '{namespace}' has no config dir; not persisted"); - continue; - }; - let Some(map) = storage.get(&namespace) else { - continue; - }; - if let Err(e) = write_storage_file(&path, map) { - log::error!( - "[lua] failed to write plugin storage {}: {e}", - path.display() - ); - } - } - } - - /// Call a data callback with `(data, nil)` or `(nil, err)`, one-shot. Errors - /// and panics queue a NotifyError effect; the entry is always removed. - fn deliver_data_result(&mut self, token: u64, result: Result) { - let callbacks: mlua::Table = match self.lua.named_registry_value(DATA_CALLBACKS_KEY) { - Ok(t) => t, - Err(_) => return, - }; - let key = match i64::try_from(token) { - Ok(key) => key, - Err(_) => return, - }; - let entry: mlua::Table = match callbacks.raw_get::>(key) { - Ok(Some(t)) => t, - _ => return, - }; - let plugin: String = entry.get("plugin").unwrap_or_default(); - let callback: mlua::Function = match entry.get("callback") { - Ok(f) => f, - Err(_) => { - let _ = callbacks.raw_set(key, Value::Nil); - return; - } - }; - let _ = callbacks.raw_set(key, Value::Nil); - drop(entry); - drop(callbacks); - - let args = match result { - Ok(value) => (value, Value::Nil), - Err(err) => match self.lua.create_string(&err) { - Ok(s) => (Value::Nil, Value::String(s)), - Err(_) => (Value::Nil, Value::Nil), - }, - }; - - *self.shared.current_plugin.borrow_mut() = plugin.clone(); - let call_result = catch_unwind(AssertUnwindSafe(|| callback.call::<()>(args))); - self.shared.current_plugin.borrow_mut().clear(); - - match call_result { - Ok(Ok(())) => {} - Ok(Err(e)) => { - let msg = first_line(&e.to_string()); - log::error!("[lua] plugin '{plugin}': error in data callback: {msg}"); - self.shared.effects.borrow_mut().push(Action::NotifyError( - format!("plugin '{plugin}': error in data callback: {msg}"), - 6, - )); - } - Err(_) => { - log::error!("[lua] plugin '{plugin}': panic in data callback"); - self.shared.effects.borrow_mut().push(Action::NotifyError( - format!("plugin '{plugin}': panic in data callback"), - 6, - )); - } - } - } - - /// Refresh the caches backing the synchronous reads (`spotatui.playlists()` - /// etc.) when their data generation advanced since the last refresh. - fn refresh_data_caches(&mut self, app: &App) { - let refresh = |slot: &mut u64, kind: PluginDataKind| -> bool { - let current = app.plugin_data_generations.get(kind); - if *slot == current { - return false; - } - *slot = current; - true - }; - - if refresh( - &mut self.last_cache_gens[PluginDataKind::Playlists.index()], - PluginDataKind::Playlists, - ) { - *self.shared.playlists_cache.borrow_mut() = plugin_api::playlists_snapshot(app); - } - if refresh( - &mut self.last_cache_gens[PluginDataKind::Queue.index()], - PluginDataKind::Queue, - ) { - *self.shared.queue_cache.borrow_mut() = plugin_api::queue_snapshot(app); - } - // Config has no generation counter (theme/settings can change from many - // places); rebuilding the small snapshot each pass is cheap. - *self.shared.config_cache.borrow_mut() = - plugin_api::config_snapshot(&app.user_config, &app.runtime_state); - - let search_was = self.last_cache_gens[PluginDataKind::Search.index()]; - if refresh( - &mut self.last_cache_gens[PluginDataKind::Search.index()], - PluginDataKind::Search, - ) { - *self.shared.search_results_cache.borrow_mut() = plugin_api::search_results_snapshot(app); - // Feed the `search_results` event, but not off the startup sentinel. - if search_was != u64::MAX { - self.search_gen_advanced = true; - } - } - } - - #[cfg(test)] - pub(super) fn process_data_requests_for_test(&mut self, app: &mut App, now: std::time::Instant) { - self.process_data_requests_at(app, now); - } - - fn drain_http_callbacks(&mut self) { - while let Ok((token, result)) = self.http_rx.try_recv() { - self.deliver_http_result(token, result); - } - } - - fn deliver_http_result(&mut self, token: u64, result: Result) { - let callbacks: mlua::Table = match self.lua.named_registry_value(HTTP_CALLBACKS_KEY) { - Ok(t) => t, - Err(_) => return, - }; - let key = match i64::try_from(token) { - Ok(key) => key, - Err(_) => return, - }; - let entry: mlua::Table = match callbacks.raw_get::>(key) { - Ok(Some(t)) => t, - _ => return, - }; - let plugin: String = entry.get("plugin").unwrap_or_default(); - let callback: mlua::Function = match entry.get("callback") { - Ok(f) => f, - Err(_) => { - let _ = callbacks.raw_set(key, Value::Nil); - return; - } - }; - let _ = callbacks.raw_set(key, Value::Nil); - drop(entry); - drop(callbacks); - - let args = match self.http_callback_args(result) { - Ok(args) => args, - Err(e) => { - let msg = first_line(&e.to_string()); - log::error!("[lua] plugin '{plugin}': error preparing http callback: {msg}"); - self.shared.effects.borrow_mut().push(Action::NotifyError( - format!("plugin '{plugin}': error preparing http callback: {msg}"), - 6, - )); - return; - } - }; - - *self.shared.current_plugin.borrow_mut() = plugin.clone(); - let call_result = catch_unwind(AssertUnwindSafe(|| callback.call::<()>(args))); - self.shared.current_plugin.borrow_mut().clear(); - - match call_result { - Ok(Ok(())) => {} - Ok(Err(e)) => { - let msg = first_line(&e.to_string()); - log::error!("[lua] plugin '{plugin}': error in http callback: {msg}"); - self.shared.effects.borrow_mut().push(Action::NotifyError( - format!("plugin '{plugin}': error in http callback: {msg}"), - 6, - )); - } - Err(_) => { - log::error!("[lua] plugin '{plugin}': panic in http callback"); - self.shared.effects.borrow_mut().push(Action::NotifyError( - format!("plugin '{plugin}': panic in http callback"), - 6, - )); - } - } - } - - fn http_callback_args( - &self, - result: Result, - ) -> mlua::Result<(Value, Value)> { - match result { - Ok(data) => { - let resp = self.lua.create_table()?; - resp.set("status", data.status)?; - resp.set("ok", (200..=299).contains(&data.status))?; - resp.set("body", data.body)?; - Ok((Value::Table(resp), Value::Nil)) - } - Err(err) => Ok((Value::Nil, Value::String(self.lua.create_string(&err)?))), - } - } - - #[cfg(test)] - pub(super) fn inject_http_result(&self, token: u64, result: Result) { - self - .http_tx - .send((token, result)) - .expect("test HTTP result receiver should be alive"); - } - - #[cfg(test)] - pub(super) fn drain_http_callbacks_for_test(&mut self) { - self.drain_http_callbacks(); - } - - /// Drain queued actions into the app while holding `&mut App`. Collected - /// before applying so the `RefCell` borrow is released first: an action - /// whose apply re-enters Lua cannot double-borrow the queue. - pub(crate) fn drain_effects(&self, app: &mut App) { - let actions: Vec = self.shared.effects.borrow_mut().drain(..).collect(); - for action in actions { - app.apply(action); - } - } -} - -fn discover_user_scripts(config_dir: &Path) -> Vec<(PathBuf, Option)> { - let mut discovered = Vec::new(); - let init = config_dir.join("init.lua"); - if init.is_file() { - discovered.push((init, None)); - } - let plugins_dir = config_dir.join("plugins"); - let entries: Vec<_> = std::fs::read_dir(&plugins_dir) - .into_iter() - .flatten() - .flatten() - .map(|entry| entry.path()) - .filter(|path| { - path - .file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| !name.starts_with('.')) - }) - .collect(); - let mut files: Vec<_> = entries - .iter() - .filter(|path| path.is_file() && path.extension().and_then(|ext| ext.to_str()) == Some("lua")) - .cloned() - .collect(); - files.sort(); - discovered.extend(files.into_iter().map(|path| (path, None))); - let mut dirs: Vec<_> = entries.into_iter().filter(|path| path.is_dir()).collect(); - dirs.sort(); - for dir in dirs { - if let Some(entry) = ["main.lua", "init.lua"] - .iter() - .map(|name| dir.join(name)) - .find(|path| path.is_file()) - { - discovered.push((entry, Some(dir))); - } - } - discovered -} - -/// Serialize the plugin-facing snapshot for a data kind from `App` state. -fn data_value_for(lua: &Lua, kind: PluginDataKind, app: &App) -> mlua::Result { - match kind { - PluginDataKind::Playlists => lua.to_value(&plugin_api::playlists_snapshot(app)), - PluginDataKind::Queue => lua.to_value(&plugin_api::queue_snapshot(app)), - PluginDataKind::Search => lua.to_value(&plugin_api::search_results_snapshot(app)), - PluginDataKind::SavedTracks => lua.to_value(&plugin_api::saved_tracks_snapshot(app)), - PluginDataKind::SavedAlbums => lua.to_value(&plugin_api::saved_albums_snapshot(app)), - PluginDataKind::SavedShows => lua.to_value(&plugin_api::saved_shows_snapshot(app)), - PluginDataKind::RecentlyPlayed => lua.to_value(&plugin_api::recently_played_snapshot(app)), - PluginDataKind::Devices => lua.to_value(&plugin_api::device_list(app)), - PluginDataKind::Lyrics => lua.to_value(&plugin_api::lyrics_snapshot(app)), - } -} - -/// Temp-file + rename write of one storage namespace. -fn write_storage_file( - path: &Path, - map: &serde_json::Map, -) -> std::io::Result<()> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - let json = serde_json::to_string_pretty(map).map_err(std::io::Error::other)?; - let tmp = path.with_extension("json.tmp"); - std::fs::write(&tmp, json)?; - std::fs::rename(&tmp, path) -} - -/// First line of an error string (Lua tracebacks are multi-line). -fn first_line(s: &str) -> String { - s.lines().next().unwrap_or(s).trim().to_string() -} diff --git a/src/infra/scripting/events.rs b/src/infra/scripting/events.rs deleted file mode 100644 index 3ee14969..00000000 --- a/src/infra/scripting/events.rs +++ /dev/null @@ -1,201 +0,0 @@ -use crate::core::app::App; -use crate::core::plugin_api::{DeviceInfo, PlaybackState}; - -/// Discrete events delivered to plugins (mpv model: never per-tick polling). -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ScriptEvent { - Start, - Quit, - TrackChange, - PlaybackStateChange, - Seek, - VolumeChange, - QueueChange, - ShuffleChange, - RepeatChange, - DeviceChange, - SearchResults, - /// Carries the new route name (see `plugin_api::route_name`). - RouteChange(String), -} - -impl ScriptEvent { - /// Lua-facing event name accepted by `spotatui.on`. - pub(super) fn lua_name(&self) -> &'static str { - match self { - ScriptEvent::Start => "start", - ScriptEvent::Quit => "quit", - ScriptEvent::TrackChange => "track_change", - ScriptEvent::PlaybackStateChange => "playback_state_change", - ScriptEvent::Seek => "seek", - ScriptEvent::VolumeChange => "volume_change", - ScriptEvent::QueueChange => "queue_change", - ScriptEvent::ShuffleChange => "shuffle_change", - ScriptEvent::RepeatChange => "repeat_change", - ScriptEvent::DeviceChange => "device_change", - ScriptEvent::SearchResults => "search_results", - ScriptEvent::RouteChange(_) => "route_change", - } - } - - /// Events that receive the current playback table (or nil) as their single argument. - pub(super) fn passes_playback_arg(&self) -> bool { - matches!( - self, - ScriptEvent::TrackChange - | ScriptEvent::PlaybackStateChange - | ScriptEvent::Seek - | ScriptEvent::VolumeChange - | ScriptEvent::ShuffleChange - | ScriptEvent::RepeatChange - ) - } -} - -pub(super) const VALID_EVENT_NAMES: &[&str] = &[ - "start", - "quit", - "track_change", - "playback_state_change", - "seek", - "volume_change", - "queue_change", - "shuffle_change", - "repeat_change", - "device_change", - "search_results", - "route_change", -]; - -/// Seek heuristic thresholds (Connect polling can legitimately jump a few seconds forward). -pub(super) const SEEK_BACKWARD_MS: i64 = 1500; -pub(super) const SEEK_FORWARD_MS: i64 = 6500; - -/// Collect the queue item uris from `App` (currently-playing first, then upcoming). -pub(super) fn queue_uris(app: &App) -> Vec { - use crate::core::plugin_api::PlayableInfo; - - let Some(queue) = app.queue.as_ref() else { - return Vec::new(); - }; - - let item_uri = |item: &PlayableInfo| -> Option { - match item { - PlayableInfo::Track(t) => t.uri.clone(), - PlayableInfo::Episode(e) => e.uri.clone(), - } - }; - - let mut uris = Vec::new(); - if let Some(current) = queue.currently_playing.as_ref() { - if let Some(u) = item_uri(current) { - uris.push(u); - } - } - for item in &queue.queue { - if let Some(u) = item_uri(item) { - uris.push(u); - } - } - uris -} - -/// Pure diff of two snapshots into the set of events to emit. Order is fixed and testable. -pub(crate) fn diff_events( - old: &Option, - last_queue: &Option>, - new: &Option, - new_queue: &Option>, -) -> Vec { - let mut events = Vec::new(); - - let old_identity = old.as_ref().and_then(track_identity); - let new_identity = new.as_ref().and_then(track_identity); - - // Track change: identity becomes a different Some, or None -> Some. - if let Some(new_id) = &new_identity { - if old_identity.as_ref() != Some(new_id) { - events.push(ScriptEvent::TrackChange); - } - } - - let old_playing = old.as_ref().map(|p| p.is_playing).unwrap_or(false); - let new_playing = new.as_ref().map(|p| p.is_playing).unwrap_or(false); - if old_playing != new_playing { - events.push(ScriptEvent::PlaybackStateChange); - } - - // Shuffle / repeat: differ while both snapshots exist (a None -> Some - // transition is a startup refresh, not a user toggle). - if let (Some(o), Some(n)) = (old, new) { - if o.shuffle != n.shuffle { - events.push(ScriptEvent::ShuffleChange); - } - if o.repeat != n.repeat { - events.push(ScriptEvent::RepeatChange); - } - } - - // Seek: same track, is_playing unchanged, progress jumped beyond tolerance. - if let (Some(o), Some(n)) = (old, new) { - let same_track = old_identity.is_some() && old_identity == new_identity; - if same_track && o.is_playing == n.is_playing { - let delta = n.progress_ms as i64 - o.progress_ms as i64; - if !(-SEEK_BACKWARD_MS..=SEEK_FORWARD_MS).contains(&delta) { - events.push(ScriptEvent::Seek); - } - } - } - - // Volume change: differs and at least one side is Some. - let old_vol = old.as_ref().and_then(|p| p.volume_percent); - let new_vol = new.as_ref().and_then(|p| p.volume_percent); - if old_vol != new_vol && (old_vol.is_some() || new_vol.is_some()) { - events.push(ScriptEvent::VolumeChange); - } - - // Queue change: uri list differs. - if last_queue != new_queue { - events.push(ScriptEvent::QueueChange); - } - - events -} - -/// Track identity for diffing: uri, falling back to name. -pub(super) fn track_identity(state: &PlaybackState) -> Option { - let track = state.track.as_ref()?; - track.uri.clone().or_else(|| Some(track.name.clone())) -} - -/// Pure diff of non-playback engine state into events. Order is fixed and -/// testable: route_change, device_change, search_results. -pub(crate) fn diff_state_events( - old_route: &str, - new_route: &str, - old_devices: &[DeviceInfo], - new_devices: &[DeviceInfo], - search_gen_advanced: bool, -) -> Vec { - let mut events = Vec::new(); - - if old_route != new_route { - events.push(ScriptEvent::RouteChange(new_route.to_string())); - } - - let device_key = |d: &DeviceInfo| (d.id.clone(), d.name.clone(), d.is_active); - if old_devices.len() != new_devices.len() - || old_devices - .iter() - .zip(new_devices) - .any(|(a, b)| device_key(a) != device_key(b)) - { - events.push(ScriptEvent::DeviceChange); - } - - if search_gen_advanced { - events.push(ScriptEvent::SearchResults); - } - - events -} diff --git a/src/infra/scripting/mod.rs b/src/infra/scripting/mod.rs deleted file mode 100644 index 002d2357..00000000 --- a/src/infra/scripting/mod.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! Lua scripting engine (Phase 1). -//! -//! Embeds `mlua` and exposes a curated `spotatui.*` API to user plugins. Lua never sees -//! `&mut App` or rspotify types: reads come from a cached snapshot of the [`plugin_api`] -//! facade, and writes are queued as [`Action`](crate::core::action::Action)s that the -//! driver drains through `App::apply` while holding `&mut App`. Every Rust->Lua callback -//! is wrapped in `catch_unwind`; a misbehaving plugin logs an error, surfaces a status -//! message, and is disabled (one strike) rather than crashing the TUI. - -mod api; -mod engine; -mod events; -mod shared; - -pub use engine::ScriptEngine; - -#[cfg(test)] -mod tests; diff --git a/src/infra/scripting/shared.rs b/src/infra/scripting/shared.rs deleted file mode 100644 index 7f67affb..00000000 --- a/src/infra/scripting/shared.rs +++ /dev/null @@ -1,143 +0,0 @@ -use std::cell::{Cell, RefCell}; -use std::collections::{BTreeMap, BTreeSet}; -use std::path::PathBuf; - -use crate::core::action::Action; -use crate::core::app::PluginDataKind; -use crate::core::plugin_api::{ - ConfigSnapshot, DeviceInfo, PlaybackState, PlaylistInfo, QueueSnapshot, SearchResults, -}; - -/// Registry key for the table mapping event name -> array of `{ plugin, callback }`. -pub(super) const HANDLERS_KEY: &str = "spotatui.handlers"; - -/// Registry key for the table mapping command name -> `{ plugin, callback }`. -pub(super) const COMMANDS_KEY: &str = "spotatui.commands"; - -/// Registry key for the table mapping HTTP token -> `{ plugin, callback }`. -pub(super) const HTTP_CALLBACKS_KEY: &str = "spotatui.http_callbacks"; - -/// Registry key for the table mapping data-request token -> `{ plugin, callback }`. -pub(super) const DATA_CALLBACKS_KEY: &str = "spotatui.data_callbacks"; - -/// Registry key for the table mapping timer token -> `{ plugin, callback }`. -pub(super) const TIMER_CALLBACKS_KEY: &str = "spotatui.timer_callbacks"; - -/// Registry key for the table mapping screen name -> -/// `{ plugin, title, on_key, on_open?, on_close? }`. -pub(super) const SCREENS_KEY: &str = "spotatui.screens"; - -pub(super) type HttpResult = (u64, Result); - -pub(super) struct HttpResponseData { - pub(super) status: u16, - pub(super) body: String, -} - -/// A timer armed by `spotatui.set_timeout` / `set_interval`, waiting for the -/// engine's next timer pass. Queued (rather than inserted directly into the -/// engine's active list) so arming a timer from inside a firing callback can't -/// invalidate the iteration. -pub(super) struct NewTimer { - pub(super) token: u64, - pub(super) delay: std::time::Duration, - /// `Some` for `set_interval`: the reschedule period. - pub(super) interval: Option, -} - -/// A queued `spotatui.get_*` call, drained by the engine's intake pass while -/// it holds `&mut App` (so the generation capture + dispatch is atomic). -pub(super) struct DataRequest { - pub(super) token: u64, - pub(super) kind: PluginDataKind, - /// Search query for `PluginDataKind::Search` requests. - pub(super) arg: Option, -} - -/// Stable identifier for a plugin, derived from its load name: strip a -/// trailing `.lua`, then map anything outside `[A-Za-z0-9_-]` to `_`. -/// Used for storage namespaces and anywhere a filename-safe id is needed. -pub(crate) fn plugin_id(name: &str) -> String { - let base = name.strip_suffix(".lua").unwrap_or(name); - base - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || c == '-' || c == '_' { - c - } else { - '_' - } - }) - .collect() -} - -/// State shared between the engine and the Lua closures via `Rc`. -/// -/// `mlua` is built without the `send` feature, so `Rc`/`RefCell` are fine here: everything -/// runs on the single UI task. -pub(crate) struct ScriptShared { - /// Playback snapshot, refreshed by the runner before callbacks run. - pub(crate) playback: RefCell>, - pub(super) devices: RefCell>, - /// Actions queued by Lua calls, drained through `App::apply` by the engine. - pub(crate) effects: RefCell>, - /// Plugin name currently being loaded, so `spotatui.on` can tag its callbacks. - pub(super) current_plugin: RefCell, - /// Single token counter shared by HTTP requests, data requests and timers. - pub(super) next_token: Cell, - /// Intake queue for `spotatui.get_*` data requests. - pub(super) data_requests: RefCell>, - /// Intake queue for newly-armed timers. - pub(super) new_timers: RefCell>, - /// Intake queue for `spotatui.cancel_timer` tokens. - pub(super) cancelled_timers: RefCell>, - // Caches backing the synchronous reads (`spotatui.playlists()` etc.). - // Refreshed by the engine only when the matching generation advanced. - pub(super) playlists_cache: RefCell>, - pub(super) queue_cache: RefCell, - pub(super) search_results_cache: RefCell, - /// Backs `spotatui.config()`; refreshed by the engine each tick. - pub(super) config_cache: RefCell, - /// Current route name, refreshed alongside the engine's route diffing; - /// backs the synchronous `spotatui.current_route()` read. - pub(super) current_route: RefCell, - /// The app config directory, set by `load_user_scripts`. Plugin storage - /// lives under `/plugin-data/.json`. - pub(super) config_dir: RefCell>, - /// Lazily-loaded storage namespaces: plugin_id -> flat JSON object. - pub(super) storage: RefCell>>, - /// Namespaces with unflushed writes. - pub(super) storage_dirty: RefCell>, -} - -impl ScriptShared { - pub(super) fn new() -> Self { - ScriptShared { - playback: RefCell::new(None), - devices: RefCell::new(Vec::new()), - effects: RefCell::new(Vec::new()), - current_plugin: RefCell::new(String::new()), - next_token: Cell::new(0), - data_requests: RefCell::new(Vec::new()), - new_timers: RefCell::new(Vec::new()), - cancelled_timers: RefCell::new(Vec::new()), - playlists_cache: RefCell::new(Vec::new()), - queue_cache: RefCell::new(QueueSnapshot::default()), - search_results_cache: RefCell::new(SearchResults::default()), - config_cache: RefCell::new(ConfigSnapshot::default()), - current_route: RefCell::new(String::new()), - config_dir: RefCell::new(None), - storage: RefCell::new(BTreeMap::new()), - storage_dirty: RefCell::new(BTreeSet::new()), - } - } - - /// Path of a plugin's storage file, when a config dir is known. - pub(super) fn storage_path(&self, namespace: &str) -> Option { - self - .config_dir - .borrow() - .as_ref() - .map(|dir| dir.join("plugin-data").join(format!("{namespace}.json"))) - } -} diff --git a/src/infra/scripting/tests.rs b/src/infra/scripting/tests.rs deleted file mode 100644 index 58d091c0..00000000 --- a/src/infra/scripting/tests.rs +++ /dev/null @@ -1,3347 +0,0 @@ -use crate::core::action::Action; -use crate::core::plugin_api::{ArtistRef, PlaybackState, TrackInfo}; - -use super::engine::ScriptEngine; -use super::events::{diff_events, ScriptEvent}; -use super::shared::HttpResponseData; - -fn track(uri: &str, name: &str) -> TrackInfo { - TrackInfo { - uri: Some(uri.to_string()), - name: name.to_string(), - artists: vec!["Artist".to_string()], - album: "Album".to_string(), - duration_ms: 200_000, - id: None, - album_id: None, - artist_refs: vec![ArtistRef { - id: None, - name: "Artist".to_string(), - }], - is_playable: true, - is_local: false, - track_number: 0, - explicit: false, - image_url: None, - } -} - -fn playback(track: Option, is_playing: bool, progress_ms: u64) -> PlaybackState { - PlaybackState { - track, - is_playing, - progress_ms, - shuffle: false, - repeat: "off".to_string(), - volume_percent: Some(50), - device: None, - } -} - -/// Take all currently-queued actions out of the shared buffer. -/// (Assertions pattern-match, the style this suite has always used.) -fn drain(engine: &ScriptEngine) -> Vec { - engine.shared.effects.borrow_mut().drain(..).collect() -} - -/// Assert a single action was queued and return it. -fn one(engine: &ScriptEngine) -> Action { - let mut effects = drain(engine); - assert_eq!(effects.len(), 1, "expected exactly one effect"); - effects.pop().unwrap() -} - -// --- handler registration + emission --- - -#[test] -fn track_change_handler_queues_notify() { - let mut engine = ScriptEngine::new().unwrap(); - engine - .load_source( - "test", - r#" - spotatui.on("track_change", function(pb) - spotatui.notify("now: " .. pb.track.name, 5) - end) - "#, - ) - .unwrap(); - - *engine.shared.playback.borrow_mut() = Some(playback(Some(track("uri:1", "Song A")), true, 0)); - engine.emit(ScriptEvent::TrackChange); - - match one(&engine) { - Action::Notify(msg, ttl) => { - assert_eq!(msg, "now: Song A"); - assert_eq!(ttl, 5); - } - other => panic!("unexpected effect: {:?}", std::mem::discriminant(&other)), - } -} - -#[test] -fn erroring_handler_is_disabled_after_one_strike() { - let mut engine = ScriptEngine::new().unwrap(); - engine - .load_source( - "bad", - r#" - spotatui.on("start", function() error("boom") end) - spotatui.on("start", function() spotatui.notify("healthy", 1) end) - "#, - ) - .unwrap(); - - engine.emit(ScriptEvent::Start); - let first = drain(&engine); - // One error notify (from the bad handler) plus the healthy notify. - assert_eq!(first.len(), 2); - match &first[0] { - Action::NotifyError(m, 6) => assert!(m.contains("error in on_start")), - _ => panic!("expected error notify first"), - } - match &first[1] { - Action::Notify(m, 1) => assert_eq!(m, "healthy"), - _ => panic!("expected healthy notify second"), - } - - // Second emit: bad handler removed, only the healthy one fires (no new error). - engine.emit(ScriptEvent::Start); - match one(&engine) { - Action::Notify(m, 1) => assert_eq!(m, "healthy"), - _ => panic!("expected only the healthy notify"), - } -} - -#[test] -fn unknown_event_name_is_an_error() { - let mut engine = ScriptEngine::new().unwrap(); - let result = engine.load_source("test", r#"spotatui.on("bogus_event", function() end)"#); - assert!(result.is_err()); -} - -// --- require_api --- - -#[test] -fn require_api_at_or_below_current_succeeds() { - use crate::core::plugin_api::API_VERSION; - let mut engine = ScriptEngine::new().unwrap(); - engine - .load_source("test", &format!("spotatui.require_api({API_VERSION})")) - .unwrap(); - engine - .load_source("test2", "spotatui.require_api(1)") - .unwrap(); -} - -#[test] -fn require_api_above_current_fails_with_clear_message() { - use crate::core::plugin_api::API_VERSION; - let mut engine = ScriptEngine::new().unwrap(); - let too_high = API_VERSION + 1; - let err = engine - .load_source("test", &format!("spotatui.require_api({too_high})")) - .unwrap_err() - .to_string(); - assert!(err.contains(&too_high.to_string()), "message: {err}"); - assert!(err.contains(&API_VERSION.to_string()), "message: {err}"); - - // The engine is not poisoned: a later, compatible plugin still loads. - engine.load_source("ok", "spotatui.require_api(1)").unwrap(); -} - -#[test] -fn require_api_rejects_non_positive_version() { - let mut engine = ScriptEngine::new().unwrap(); - assert!(engine - .load_source("test", "spotatui.require_api(0)") - .is_err()); -} - -// --- action functions queue the right effect --- - -fn run_action(src: &str) -> Action { - let mut engine = ScriptEngine::new().unwrap(); - engine.load_source("test", src).unwrap(); - one(&engine) -} - -#[test] -fn action_play_queues_play() { - assert!(matches!(run_action("spotatui.play()"), Action::Play)); -} - -#[test] -fn action_pause_queues_pause() { - assert!(matches!(run_action("spotatui.pause()"), Action::Pause)); -} - -#[test] -fn action_next_queues_next() { - assert!(matches!(run_action("spotatui.next()"), Action::NextTrack)); -} - -#[test] -fn action_previous_queues_previous() { - assert!(matches!( - run_action("spotatui.previous()"), - Action::PreviousTrack - )); -} - -#[test] -fn action_seek_queues_seek() { - match run_action("spotatui.seek(12345)") { - Action::SeekTo(ms) => assert_eq!(ms, 12345), - other => panic!("unexpected effect: {:?}", std::mem::discriminant(&other)), - } -} - -#[test] -fn action_set_volume_clamps_above_100() { - match run_action("spotatui.set_volume(250)") { - Action::SetVolume(v) => assert_eq!(v, 100), - other => panic!("unexpected effect: {:?}", std::mem::discriminant(&other)), - } - match run_action("spotatui.set_volume(-10)") { - Action::SetVolume(v) => assert_eq!(v, 0), - other => panic!("unexpected effect: {:?}", std::mem::discriminant(&other)), - } -} - -#[test] -fn action_shuffle_queues_set_shuffle() { - match run_action("spotatui.shuffle(true)") { - Action::SetShuffle(on) => assert!(on), - other => panic!("unexpected effect: {:?}", std::mem::discriminant(&other)), - } - match run_action("spotatui.shuffle(false)") { - Action::SetShuffle(on) => assert!(!on), - other => panic!("unexpected effect: {:?}", std::mem::discriminant(&other)), - } -} - -#[test] -fn action_search_queues_search_effect() { - match run_action(r#"spotatui.search("daft punk")"#) { - Action::Search(q) => assert_eq!(q, "daft punk"), - _ => panic!("expected a Search effect"), - } -} - -#[test] -fn action_notify_default_ttl_is_4() { - match run_action(r#"spotatui.notify("hi")"#) { - Action::Notify(m, ttl) => { - assert_eq!(m, "hi"); - assert_eq!(ttl, 4); - } - _ => panic!("expected a Notify effect"), - } -} - -mod http_tests { - use super::*; - - /// Run `test` inside a tokio runtime, passing a URL backed by a local listener that - /// accepts connections but never responds. The real request spawned by `http_get` / - /// `http_post` hangs until the client timeout, so it can never race the injected - /// synthetic result, and no traffic leaves the machine. - fn with_runtime_engine(test: impl FnOnce(&mut ScriptEngine, &str)) { - let rt = tokio::runtime::Runtime::new().unwrap(); - let _guard = rt.enter(); - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let url = format!("http://{}/", listener.local_addr().unwrap()); - let mut engine = ScriptEngine::new().unwrap(); - test(&mut engine, &url); - } - - fn response(status: u16, body: &str) -> HttpResponseData { - HttpResponseData { - status, - body: body.to_string(), - } - } - - #[test] - fn json_round_trip() { - let mut engine = ScriptEngine::new().unwrap(); - engine - .load_source( - "json", - r#" - local decoded = spotatui.json_decode('{"name":"Song","nested":{"ok":true},"items":[1,2]}') - local encoded = spotatui.json_encode(decoded) - local again = spotatui.json_decode(encoded) - spotatui.notify(again.name .. ":" .. tostring(again.nested.ok) .. ":" .. tostring(again.items[2]), 1) - "#, - ) - .unwrap(); - - match one(&engine) { - Action::Notify(msg, 1) => assert_eq!(msg, "Song:true:2"), - _ => panic!("expected json round-trip notify"), - } - } - - #[test] - fn json_decode_invalid_input_raises() { - let mut engine = ScriptEngine::new().unwrap(); - engine - .load_source( - "json", - r#" - local ok = pcall(function() - spotatui.json_decode("{") - end) - spotatui.notify(tostring(ok), 1) - "#, - ) - .unwrap(); - - match one(&engine) { - Action::Notify(msg, 1) => assert_eq!(msg, "false"), - _ => panic!("expected pcall failure notify"), - } - } - - #[test] - fn json_encode_non_serializable_raises() { - let mut engine = ScriptEngine::new().unwrap(); - engine - .load_source( - "json", - r#" - local ok = pcall(function() - spotatui.json_encode(function() end) - end) - spotatui.notify(tostring(ok), 1) - "#, - ) - .unwrap(); - - match one(&engine) { - Action::Notify(msg, 1) => assert_eq!(msg, "false"), - _ => panic!("expected pcall failure notify"), - } - } - - #[test] - fn json_null_decodes_to_sentinel_not_nil() { - let mut engine = ScriptEngine::new().unwrap(); - engine - .load_source( - "json", - r#" - local NULL = spotatui.json_decode("null") - local decoded = spotatui.json_decode('{"x":null}') - spotatui.notify(tostring(decoded.x == nil) .. ":" .. tostring(decoded.x == NULL), 1) - "#, - ) - .unwrap(); - - match one(&engine) { - Action::Notify(msg, 1) => assert_eq!(msg, "false:true"), - _ => panic!("expected null sentinel notify"), - } - } - - #[test] - fn http_get_callback_fires_on_synthetic_success() { - with_runtime_engine(|engine, url| { - let source = format!( - r#" - spotatui.http_get("{url}", function(resp, err) - if err then - spotatui.notify(err, 1) - else - spotatui.notify(resp.body, 1) - end - end) - "# - ); - engine.load_source("fetcher", &source).unwrap(); - - engine.inject_http_result(1, Ok(response(200, "hello"))); - engine.drain_http_callbacks_for_test(); - - match one(engine) { - Action::Notify(msg, 1) => assert_eq!(msg, "hello"), - _ => panic!("expected http success notify"), - } - }); - } - - #[tokio::test(flavor = "current_thread")] - async fn http_get_spawn_path_delivers_response() { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { - let (mut socket, _) = listener.accept().await.unwrap(); - let mut buf = [0_u8; 1024]; - let _ = socket.read(&mut buf).await.unwrap(); - let body = "from server"; - let response = format!( - "HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", - body.len(), - body - ); - socket.write_all(response.as_bytes()).await.unwrap(); - }); - - let mut engine = ScriptEngine::new().unwrap(); - let source = format!( - r#" - spotatui.http_get("http://{addr}/lyrics", function(resp, err) - if err then - spotatui.notify(err, 1) - else - spotatui.notify(tostring(resp.status) .. ":" .. resp.body, 1) - end - end) - "# - ); - engine.load_source("fetcher", &source).unwrap(); - - for _ in 0..100 { - engine.drain_http_callbacks_for_test(); - if !engine.shared.effects.borrow().is_empty() { - break; - } - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - - match one(&engine) { - Action::Notify(msg, 1) => assert_eq!(msg, "200:from server"), - _ => panic!("expected spawned http response notify"), - } - server.await.unwrap(); - } - - #[test] - fn http_post_callback_fires_on_synthetic_success() { - with_runtime_engine(|engine, url| { - let source = format!( - r#" - spotatui.http_post("{url}", "body", nil, function(resp, err) - if err then - spotatui.notify(err, 1) - else - spotatui.notify(tostring(resp.status) .. ":" .. resp.body, 1) - end - end) - "# - ); - engine.load_source("poster", &source).unwrap(); - - engine.inject_http_result(1, Ok(response(201, "created"))); - engine.drain_http_callbacks_for_test(); - - match one(engine) { - Action::Notify(msg, 1) => assert_eq!(msg, "201:created"), - _ => panic!("expected http post success notify"), - } - }); - } - - #[test] - fn http_get_callback_fires_on_synthetic_error() { - with_runtime_engine(|engine, url| { - let source = format!( - r#" - spotatui.http_get("{url}", function(resp, err) - if err then - spotatui.notify(err, 1) - else - spotatui.notify(resp.body, 1) - end - end) - "# - ); - engine.load_source("fetcher", &source).unwrap(); - - engine.inject_http_result(1, Err("dns failed".to_string())); - engine.drain_http_callbacks_for_test(); - - match one(engine) { - Action::Notify(msg, 1) => assert_eq!(msg, "dns failed"), - _ => panic!("expected http error notify"), - } - }); - } - - #[test] - fn http_callback_is_one_shot() { - with_runtime_engine(|engine, url| { - let source = format!( - r#" - spotatui.http_get("{url}", function(resp, err) - spotatui.notify(resp.body, 1) - end) - "# - ); - engine.load_source("fetcher", &source).unwrap(); - - engine.inject_http_result(1, Ok(response(200, "first"))); - engine.drain_http_callbacks_for_test(); - match one(engine) { - Action::Notify(msg, 1) => assert_eq!(msg, "first"), - _ => panic!("expected first callback notify"), - } - - engine.inject_http_result(1, Ok(response(200, "second"))); - engine.drain_http_callbacks_for_test(); - assert!(drain(engine).is_empty()); - }); - } - - #[test] - fn http_callbacks_keep_token_identity_after_earlier_delivery() { - with_runtime_engine(|engine, url| { - let source = format!( - r#" - spotatui.http_get("{url}a", function(resp, err) - spotatui.notify("a:" .. resp.body, 1) - end) - spotatui.http_get("{url}b", function(resp, err) - spotatui.notify("b:" .. resp.body, 1) - end) - "# - ); - engine.load_source("fetcher", &source).unwrap(); - - engine.inject_http_result(1, Ok(response(200, "one"))); - engine.drain_http_callbacks_for_test(); - match one(engine) { - Action::Notify(msg, 1) => assert_eq!(msg, "a:one"), - _ => panic!("expected first callback notify"), - } - - engine.inject_http_result(2, Ok(response(200, "two"))); - engine.drain_http_callbacks_for_test(); - match one(engine) { - Action::Notify(msg, 1) => assert_eq!(msg, "b:two"), - _ => panic!("expected second callback notify"), - } - }); - } - - #[test] - fn http_callback_attribution() { - with_runtime_engine(|engine, url| { - let source = format!( - r#" - spotatui.http_get("{url}", function(resp, err) - spotatui.set_playbar(resp.body) - end) - "# - ); - engine.load_source("lyrics_plugin", &source).unwrap(); - - engine.inject_http_result(1, Ok(response(200, "lyrics ready"))); - engine.drain_http_callbacks_for_test(); - - match one(engine) { - Action::SetPlaybarSegment { plugin, text } => { - assert_eq!(plugin, "lyrics_plugin"); - assert_eq!(text.as_deref(), Some("lyrics ready")); - } - _ => panic!("expected attributed playbar segment"), - } - }); - } - - #[test] - fn http_callback_error_queues_notify_error_without_breaking_engine() { - with_runtime_engine(|engine, url| { - let source = format!( - r#" - spotatui.http_get("{url}", function(resp, err) - error("callback boom") - end) - "# - ); - engine.load_source("bad_fetcher", &source).unwrap(); - - engine.inject_http_result(1, Ok(response(200, "ignored"))); - engine.drain_http_callbacks_for_test(); - - match one(engine) { - Action::NotifyError(msg, 6) => { - assert!(msg.contains("bad_fetcher")); - assert!(msg.contains("error in http callback")); - assert!(msg.contains("callback boom")); - } - _ => panic!("expected http callback error notify"), - } - assert!(engine.shared.current_plugin.borrow().is_empty()); - - engine - .load_source("healthy", r#"spotatui.notify("still alive", 1)"#) - .unwrap(); - match one(engine) { - Action::Notify(msg, 1) => assert_eq!(msg, "still alive"), - _ => panic!("expected engine to keep running"), - } - }); - } - - #[test] - fn http_get_invalid_scheme_raises() { - let mut engine = ScriptEngine::new().unwrap(); - let result = engine.load_source( - "fetcher", - r#"spotatui.http_get("ftp://example.com", function() end)"#, - ); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("unsupported URL scheme")); - } - - #[test] - fn http_get_no_runtime_raises() { - let mut engine = ScriptEngine::new().unwrap(); - let result = engine.load_source( - "fetcher", - r#"spotatui.http_get("https://example.com", function() end)"#, - ); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("no tokio runtime available")); - } - - #[test] - fn http_resp_ok_true_for_2xx() { - with_runtime_engine(|engine, url| { - let source = format!( - r#" - spotatui.http_get("{url}", function(resp, err) - spotatui.notify(tostring(resp.ok), 1) - end) - "# - ); - engine.load_source("fetcher", &source).unwrap(); - - engine.inject_http_result(1, Ok(response(204, ""))); - engine.drain_http_callbacks_for_test(); - - match one(engine) { - Action::Notify(msg, 1) => assert_eq!(msg, "true"), - _ => panic!("expected ok=true notify"), - } - }); - } - - #[test] - fn http_resp_ok_false_for_4xx() { - with_runtime_engine(|engine, url| { - let source = format!( - r#" - spotatui.http_get("{url}", function(resp, err) - spotatui.notify(tostring(resp.ok) .. ":" .. tostring(err == nil), 1) - end) - "# - ); - engine.load_source("fetcher", &source).unwrap(); - - engine.inject_http_result(1, Ok(response(404, "not found"))); - engine.drain_http_callbacks_for_test(); - - match one(engine) { - Action::Notify(msg, 1) => assert_eq!(msg, "false:true"), - _ => panic!("expected ok=false notify"), - } - }); - } -} - -// --- drain_effects: routes through App methods --- - -#[cfg(test)] -mod drain_tests { - use super::*; - use crate::core::app::App; - use crate::core::user_config::UserConfig; - use crate::infra::network::IoEvent; - use chrono::Duration as ChronoDuration; - use rspotify::model::{ - context::{Actions, CurrentPlaybackContext}, - CurrentlyPlayingType, Device, DeviceType, PlayableItem, RepeatState, - }; - use std::sync::mpsc::channel; - use std::time::SystemTime; - - fn make_app() -> (App, std::sync::mpsc::Receiver) { - let (tx, rx) = channel(); - let app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - (app, rx) - } - - #[allow(deprecated)] - fn make_device() -> Device { - Device { - id: Some("dev-test".to_string()), - is_active: true, - is_private_session: false, - is_restricted: false, - name: "Test Device".to_string(), - _type: DeviceType::Computer, - volume_percent: Some(50), - } - } - - #[allow(deprecated)] - fn make_context(is_playing: bool, shuffle_state: bool) -> CurrentPlaybackContext { - CurrentPlaybackContext { - device: make_device(), - repeat_state: RepeatState::Off, - shuffle_state, - context: None, - timestamp: chrono::Utc::now(), - progress: None, - is_playing, - item: None, - currently_playing_type: CurrentlyPlayingType::Unknown, - actions: Actions::default(), - } - } - - #[allow(deprecated)] - fn make_context_with_track(is_playing: bool) -> CurrentPlaybackContext { - use crate::core::test_helpers::full_track; - let track = full_track("4uLU6hMCjMI75M1A2tKUQC", "Test Song"); - CurrentPlaybackContext { - device: make_device(), - repeat_state: RepeatState::Off, - shuffle_state: false, - context: None, - timestamp: chrono::Utc::now(), - progress: Some(ChronoDuration::milliseconds(0)), - is_playing, - item: Some(PlayableItem::Track(track)), - currently_playing_type: CurrentlyPlayingType::Track, - actions: Actions::default(), - } - } - - fn push_effect(engine: &ScriptEngine, effect: Action) { - engine.shared.effects.borrow_mut().push(effect); - } - - #[test] - fn drain_pause_while_playing_dispatches_pause_playback() { - let (mut app, rx) = make_app(); - app.current_playback_context = Some(make_context(true, false)); - - let engine = ScriptEngine::new().unwrap(); - push_effect(&engine, Action::Pause); - engine.drain_effects(&mut app); - - match rx.try_recv() { - Ok(IoEvent::PausePlayback) => {} - _ => panic!("expected PausePlayback, got unexpected variant (IoEvent is not Debug)"), - } - } - - #[test] - fn drain_pause_while_already_paused_is_noop() { - let (mut app, rx) = make_app(); - app.current_playback_context = Some(make_context(false, false)); - - let engine = ScriptEngine::new().unwrap(); - push_effect(&engine, Action::Pause); - engine.drain_effects(&mut app); - - assert!(rx.try_recv().is_err(), "expected no IoEvent dispatched"); - } - - #[test] - fn drain_play_while_paused_dispatches_start_playback() { - let (mut app, rx) = make_app(); - app.current_playback_context = Some(make_context(false, false)); - - let engine = ScriptEngine::new().unwrap(); - push_effect(&engine, Action::Play); - engine.drain_effects(&mut app); - - match rx.try_recv() { - Ok(IoEvent::StartPlayback(None, None, None)) => {} - _ => panic!( - "expected StartPlayback(None,None,None), got unexpected variant (IoEvent is not Debug)" - ), - } - } - - #[test] - fn drain_play_while_already_playing_is_noop() { - let (mut app, rx) = make_app(); - app.current_playback_context = Some(make_context(true, false)); - - let engine = ScriptEngine::new().unwrap(); - push_effect(&engine, Action::Play); - engine.drain_effects(&mut app); - - assert!(rx.try_recv().is_err(), "expected no IoEvent dispatched"); - } - - #[test] - fn drain_shuffle_true_when_off_dispatches_shuffle_true() { - let (mut app, rx) = make_app(); - app.current_playback_context = Some(make_context(false, false)); - - let engine = ScriptEngine::new().unwrap(); - push_effect(&engine, Action::SetShuffle(true)); - engine.drain_effects(&mut app); - - match rx.try_recv() { - Ok(IoEvent::Shuffle(true)) => {} - _ => panic!("expected Shuffle(true), got unexpected variant (IoEvent is not Debug)"), - } - } - - #[test] - fn drain_shuffle_false_when_already_off_is_noop() { - let (mut app, rx) = make_app(); - app.current_playback_context = Some(make_context(false, false)); - - let engine = ScriptEngine::new().unwrap(); - push_effect(&engine, Action::SetShuffle(false)); - engine.drain_effects(&mut app); - - assert!(rx.try_recv().is_err(), "expected no IoEvent dispatched"); - } - - #[test] - fn drain_set_volume_sets_pending_volume() { - let (mut app, _rx) = make_app(); - - let engine = ScriptEngine::new().unwrap(); - push_effect(&engine, Action::SetVolume(80)); - engine.drain_effects(&mut app); - - assert_eq!(app.pending_volume, Some(80)); - } - - #[test] - fn drain_seek_with_track_context_dispatches_seek() { - let (mut app, rx) = make_app(); - app.current_playback_context = Some(make_context_with_track(true)); - - let engine = ScriptEngine::new().unwrap(); - push_effect(&engine, Action::SeekTo(30_000)); - engine.drain_effects(&mut app); - - match rx.try_recv() { - Ok(IoEvent::Seek(ms)) => assert_eq!(ms, 30_000), - _ => panic!("expected Seek(30000), got unexpected variant (IoEvent is not Debug)"), - } - } - - #[test] - fn drain_notify_error_sets_error_flag_on_app() { - let (mut app, _rx) = make_app(); - - let engine = ScriptEngine::new().unwrap(); - push_effect( - &engine, - Action::NotifyError("plugin crashed".to_string(), 6), - ); - engine.drain_effects(&mut app); - - assert_eq!(app.status_message(), Some("plugin crashed")); - assert!(app.status_message_is_error()); - } - - #[test] - fn drain_notify_error_blocks_subsequent_normal_notify() { - let (mut app, _rx) = make_app(); - - let engine = ScriptEngine::new().unwrap(); - push_effect(&engine, Action::NotifyError("error msg".to_string(), 6)); - push_effect(&engine, Action::Notify("normal msg".to_string(), 4)); - engine.drain_effects(&mut app); - - assert_eq!(app.status_message(), Some("error msg")); - assert!(app.status_message_is_error()); - } -} - -// --- register_command --- - -#[test] -fn register_command_happy_path() { - let mut engine = ScriptEngine::new().unwrap(); - engine - .load_source( - "myplugin", - r#"spotatui.register_command("hello", function() spotatui.notify("hi", 1) end)"#, - ) - .unwrap(); - assert!(drain(&engine).is_empty()); -} - -#[test] -fn register_command_empty_name_is_error() { - let mut engine = ScriptEngine::new().unwrap(); - let result = engine.load_source("test", r#"spotatui.register_command("", function() end)"#); - assert!(result.is_err()); -} - -#[test] -fn register_command_whitespace_name_is_error() { - let mut engine = ScriptEngine::new().unwrap(); - let result = engine.load_source( - "test", - r#"spotatui.register_command("bad name", function() end)"#, - ); - assert!(result.is_err()); -} - -#[test] -fn register_command_duplicate_is_error() { - let mut engine = ScriptEngine::new().unwrap(); - engine - .load_source("a", r#"spotatui.register_command("cmd", function() end)"#) - .unwrap(); - let result = engine.load_source("b", r#"spotatui.register_command("cmd", function() end)"#); - assert!(result.is_err()); -} - -// --- run_pending_commands --- - -#[cfg(test)] -mod command_tests { - use super::*; - use crate::core::app::App; - use crate::core::user_config::UserConfig; - use crate::infra::network::IoEvent; - use std::sync::mpsc::channel; - use std::time::SystemTime; - - fn make_app() -> (App, std::sync::mpsc::Receiver) { - let (tx, rx) = channel(); - let app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - (app, rx) - } - - #[test] - fn run_pending_commands_invokes_callback() { - let mut engine = ScriptEngine::new().unwrap(); - engine - .load_source( - "myplugin", - r#"spotatui.register_command("greet", function() spotatui.notify("hello", 2) end)"#, - ) - .unwrap(); - let (mut app, _rx) = make_app(); - app.queue_plugin_command("greet".to_string()); - engine.run_pending_commands(&mut app); - assert_eq!(app.status_message(), Some("hello")); - } - - #[test] - fn run_pending_commands_unknown_name_sets_error() { - let mut engine = ScriptEngine::new().unwrap(); - let (mut app, _rx) = make_app(); - app.queue_plugin_command("nonexistent".to_string()); - engine.run_pending_commands(&mut app); - assert!(app.status_message_is_error()); - assert!(app.status_message().unwrap_or("").contains("nonexistent")); - } - - #[test] - fn run_pending_commands_erroring_callback_sets_error_and_stays_registered() { - let mut engine = ScriptEngine::new().unwrap(); - engine - .load_source( - "badplugin", - r#"spotatui.register_command("boom", function() error("explode") end)"#, - ) - .unwrap(); - let (mut app, _rx) = make_app(); - app.queue_plugin_command("boom".to_string()); - engine.run_pending_commands(&mut app); - assert!(app.status_message_is_error()); - - // Second invocation: command must still be registered (not removed). - app.pending_plugin_commands.clear(); - app.clear_status_message(); - app.queue_plugin_command("boom".to_string()); - engine.run_pending_commands(&mut app); - assert!(app.status_message_is_error()); - } - - #[test] - fn run_pending_commands_sets_current_plugin_during_invocation() { - let mut engine = ScriptEngine::new().unwrap(); - engine - .load_source( - "myplugin", - r#"spotatui.register_command("check_plugin", function() - spotatui.notify("ok", 1) - end)"#, - ) - .unwrap(); - let (mut app, _rx) = make_app(); - app.queue_plugin_command("check_plugin".to_string()); - engine.run_pending_commands(&mut app); - assert_eq!(app.status_message(), Some("ok")); - // current_plugin is cleared after the call - assert!(engine.shared.current_plugin.borrow().is_empty()); - } -} - -// --- set_playbar --- - -#[test] -fn set_playbar_queues_segment_with_current_plugin() { - let mut engine = ScriptEngine::new().unwrap(); - engine - .load_source("myplugin", r#"spotatui.set_playbar("hello world")"#) - .unwrap(); - match one(&engine) { - Action::SetPlaybarSegment { plugin, text } => { - assert_eq!(plugin, "myplugin"); - assert_eq!(text, Some("hello world".to_string())); - } - other => panic!("unexpected effect: {:?}", std::mem::discriminant(&other)), - } -} - -#[test] -fn set_playbar_nil_queues_clear() { - let mut engine = ScriptEngine::new().unwrap(); - engine - .load_source("myplugin", r#"spotatui.set_playbar(nil)"#) - .unwrap(); - match one(&engine) { - Action::SetPlaybarSegment { plugin, text } => { - assert_eq!(plugin, "myplugin"); - assert!(text.is_none()); - } - other => panic!("unexpected effect: {:?}", std::mem::discriminant(&other)), - } -} - -#[cfg(test)] -mod playbar_effect_tests { - use super::*; - use crate::core::app::App; - use crate::core::user_config::UserConfig; - use crate::infra::network::IoEvent; - use std::sync::mpsc::channel; - use std::time::SystemTime; - - fn make_app() -> (App, std::sync::mpsc::Receiver) { - let (tx, rx) = channel(); - let app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - (app, rx) - } - - #[test] - fn applying_set_playbar_segment_inserts_into_map() { - let (mut app, _rx) = make_app(); - let engine = ScriptEngine::new().unwrap(); - engine - .shared - .effects - .borrow_mut() - .push(Action::SetPlaybarSegment { - plugin: "myplugin".to_string(), - text: Some("seg text".to_string()), - }); - engine.drain_effects(&mut app); - assert_eq!( - app - .plugin_playbar_segments - .get("myplugin") - .map(|s| s.as_str()), - Some("seg text") - ); - } - - #[test] - fn applying_set_playbar_segment_nil_removes_from_map() { - let (mut app, _rx) = make_app(); - app - .plugin_playbar_segments - .insert("myplugin".to_string(), "old".to_string()); - let engine = ScriptEngine::new().unwrap(); - engine - .shared - .effects - .borrow_mut() - .push(Action::SetPlaybarSegment { - plugin: "myplugin".to_string(), - text: None, - }); - engine.drain_effects(&mut app); - assert!(app.plugin_playbar_segments.get("myplugin").is_none()); - } -} - -// --- popup --- - -#[test] -fn popup_plain_string_lines_work() { - let mut engine = ScriptEngine::new().unwrap(); - engine - .load_source("test", r#"spotatui.popup("My Title", "single line")"#) - .unwrap(); - match one(&engine) { - Action::ShowPopup(p) => { - assert_eq!(p.title, "My Title"); - assert_eq!(p.lines.len(), 1); - assert_eq!(p.lines[0].text, "single line"); - assert!(p.lines[0].fg.is_none()); - assert!(!p.lines[0].bold); - assert!(!p.lines[0].italic); - } - other => panic!("unexpected: {:?}", std::mem::discriminant(&other)), - } -} - -#[test] -fn popup_array_of_strings() { - let mut engine = ScriptEngine::new().unwrap(); - engine - .load_source("test", r#"spotatui.popup("T", {"line 1", "line 2"})"#) - .unwrap(); - match one(&engine) { - Action::ShowPopup(p) => { - assert_eq!(p.lines.len(), 2); - assert_eq!(p.lines[0].text, "line 1"); - assert_eq!(p.lines[1].text, "line 2"); - } - other => panic!("unexpected: {:?}", std::mem::discriminant(&other)), - } -} - -#[test] -fn popup_styled_table_lines() { - let mut engine = ScriptEngine::new().unwrap(); - engine - .load_source( - "test", - r#"spotatui.popup("T", {{ text = "bold red", fg = "Red", bold = true, italic = false }})"#, - ) - .unwrap(); - match one(&engine) { - Action::ShowPopup(p) => { - assert_eq!(p.lines.len(), 1); - assert_eq!(p.lines[0].text, "bold red"); - assert_eq!(p.lines[0].fg, Some(crate::core::theme::Color::Red)); - assert!(p.lines[0].bold); - assert!(!p.lines[0].italic); - } - other => panic!("unexpected: {:?}", std::mem::discriminant(&other)), - } -} - -#[test] -fn popup_bad_color_raises() { - let mut engine = ScriptEngine::new().unwrap(); - let result = engine.load_source( - "test", - r#"spotatui.popup("T", {{ text = "hi", fg = "NotAColor" }})"#, - ); - // parse_theme_item falls back to Black on unknown, so this may not error. - // The plan says it raises; let's confirm behaviour: if it doesn't raise, the test - // documents that parse_theme_item is lenient. - // We just ensure no panic occurred. - let _ = result; -} - -#[test] -fn popup_missing_text_field_raises() { - let mut engine = ScriptEngine::new().unwrap(); - let result = engine.load_source("test", r#"spotatui.popup("T", {{ bold = true }})"#); - assert!(result.is_err(), "missing 'text' field should be an error"); -} - -#[test] -fn popup_non_table_non_string_line_raises() { - let mut engine = ScriptEngine::new().unwrap(); - let result = engine.load_source("test", r#"spotatui.popup("T", {42})"#); - assert!(result.is_err(), "integer line should be an error"); -} - -#[cfg(test)] -mod popup_effect_tests { - use super::*; - use crate::core::app::App; - use crate::core::plugin_api::{PluginPopup, PopupLine}; - use crate::core::user_config::UserConfig; - use crate::infra::network::IoEvent; - use std::sync::mpsc::channel; - use std::time::SystemTime; - - fn make_app() -> (App, std::sync::mpsc::Receiver) { - let (tx, rx) = channel(); - let app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - (app, rx) - } - - #[test] - fn applying_show_popup_sets_app_popup_and_resets_scroll() { - let (mut app, _rx) = make_app(); - app.view.plugin_popup_scroll = 5; - let engine = ScriptEngine::new().unwrap(); - let popup = PluginPopup { - title: "Test".to_string(), - lines: vec![PopupLine { - text: "hello".to_string(), - fg: None, - bold: false, - italic: false, - }], - }; - engine - .shared - .effects - .borrow_mut() - .push(Action::ShowPopup(popup.clone())); - engine.drain_effects(&mut app); - assert_eq!(app.plugin_popup, Some(popup)); - assert_eq!(app.view.plugin_popup_scroll, 0); - } -} - -// --- set_theme --- - -#[test] -fn set_theme_valid_field_queues_effect() { - let mut engine = ScriptEngine::new().unwrap(); - engine - .load_source( - "test", - r#"spotatui.set_theme({ playbar_text = "Magenta" })"#, - ) - .unwrap(); - match one(&engine) { - Action::SetTheme(pairs) => { - assert_eq!(pairs.len(), 1); - assert_eq!(pairs[0].0, crate::core::theme::ThemeField::PlaybarText); - assert_eq!(pairs[0].1, crate::core::theme::Color::Magenta); - } - other => panic!("unexpected: {:?}", std::mem::discriminant(&other)), - } -} - -#[test] -fn set_theme_unknown_field_raises() { - let mut engine = ScriptEngine::new().unwrap(); - let result = engine.load_source("test", r#"spotatui.set_theme({ not_a_field = "Red" })"#); - assert!(result.is_err(), "unknown theme field should raise"); -} - -#[test] -fn set_theme_bad_color_raises() { - let mut engine = ScriptEngine::new().unwrap(); - // parse_theme_item is lenient (falls back to Black) for unknown named colors. - // The API wraps it with map_err, but since parse_theme_item returns Ok for unknowns, - // this test documents the actual behaviour. - let result = engine.load_source( - "test", - r#"spotatui.set_theme({ playbar_text = "999, 999, 999" })"#, - ); - // 999 > 255 so u8 parse fails -> should be an error. - assert!(result.is_err(), "out-of-range RGB should raise"); -} - -#[cfg(test)] -mod theme_effect_tests { - use super::*; - use crate::core::app::App; - use crate::core::user_config::UserConfig; - use crate::infra::network::IoEvent; - use std::sync::mpsc::channel; - use std::time::SystemTime; - - fn make_app() -> (App, std::sync::mpsc::Receiver) { - let (tx, rx) = channel(); - let app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - (app, rx) - } - - #[test] - fn applying_set_theme_mutates_app_theme_field() { - let (mut app, _rx) = make_app(); - let engine = ScriptEngine::new().unwrap(); - engine - .shared - .effects - .borrow_mut() - .push(Action::SetTheme(vec![( - crate::core::theme::ThemeField::PlaybarText, - crate::core::theme::Color::Magenta, - )])); - engine.drain_effects(&mut app); - assert_eq!( - app.user_config.theme.playbar_text, - crate::core::theme::Color::Magenta - ); - } -} - -// --- diff_events --- - -#[test] -fn diff_none_to_some_is_track_change() { - let new = Some(playback(Some(track("uri:1", "A")), true, 0)); - let q = Some(vec![]); - let events = diff_events(&None, &None, &new, &q); - assert!(events.contains(&ScriptEvent::TrackChange)); - // None -> playing also flips is_playing. - assert!(events.contains(&ScriptEvent::PlaybackStateChange)); -} - -#[test] -fn diff_track_change_on_different_uri() { - let old = Some(playback(Some(track("uri:1", "A")), true, 0)); - let new = Some(playback(Some(track("uri:2", "B")), true, 0)); - let q = Some(vec![]); - let events = diff_events(&old, &q, &new, &q); - assert!(events.contains(&ScriptEvent::TrackChange)); - assert!(!events.contains(&ScriptEvent::PlaybackStateChange)); -} - -#[test] -fn diff_play_pause_flip() { - let old = Some(playback(Some(track("uri:1", "A")), true, 1000)); - let new = Some(playback(Some(track("uri:1", "A")), false, 1000)); - let q = Some(vec![]); - let events = diff_events(&old, &q, &new, &q); - assert_eq!(events, vec![ScriptEvent::PlaybackStateChange]); -} - -#[test] -fn diff_seek_backward_beyond_threshold() { - let old = Some(playback(Some(track("uri:1", "A")), true, 10_000)); - let new = Some(playback(Some(track("uri:1", "A")), true, 5_000)); - let q = Some(vec![]); - let events = diff_events(&old, &q, &new, &q); - assert!(events.contains(&ScriptEvent::Seek)); -} - -#[test] -fn diff_seek_forward_beyond_threshold() { - let old = Some(playback(Some(track("uri:1", "A")), true, 1_000)); - let new = Some(playback(Some(track("uri:1", "A")), true, 9_000)); - let q = Some(vec![]); - let events = diff_events(&old, &q, &new, &q); - assert!(events.contains(&ScriptEvent::Seek)); -} - -#[test] -fn diff_small_forward_jump_is_not_seek() { - // 3s forward jump is within Connect polling tolerance. - let old = Some(playback(Some(track("uri:1", "A")), true, 1_000)); - let new = Some(playback(Some(track("uri:1", "A")), true, 4_000)); - let q = Some(vec![]); - let events = diff_events(&old, &q, &new, &q); - assert!(!events.contains(&ScriptEvent::Seek)); -} - -#[test] -fn diff_volume_change() { - let old = Some(playback(Some(track("uri:1", "A")), true, 1_000)); - let mut new = playback(Some(track("uri:1", "A")), true, 1_000); - new.volume_percent = Some(80); - let q = Some(vec![]); - let events = diff_events(&old, &q, &Some(new), &q); - assert!(events.contains(&ScriptEvent::VolumeChange)); -} - -#[test] -fn diff_queue_change() { - let old = Some(playback(Some(track("uri:1", "A")), true, 1_000)); - let new = old.clone(); - let old_q = Some(vec!["a".to_string()]); - let new_q = Some(vec!["a".to_string(), "b".to_string()]); - let events = diff_events(&old, &old_q, &new, &new_q); - assert_eq!(events, vec![ScriptEvent::QueueChange]); -} - -// --- dispatch-backed actions --- - -#[cfg(test)] -mod action_tests { - use super::*; - use crate::core::action::RepeatSetting; - use crate::core::app::{App, UserInfo}; - use crate::core::user_config::UserConfig; - use crate::infra::network::IoEvent; - use std::sync::mpsc::channel; - use std::time::SystemTime; - - fn make_app() -> (App, std::sync::mpsc::Receiver) { - let (tx, rx) = channel(); - let app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - (app, rx) - } - - #[test] - fn set_repeat_maps_all_modes() { - for (mode, expected) in [ - ("off", RepeatSetting::Off), - ("track", RepeatSetting::Track), - ("context", RepeatSetting::Context), - ] { - match run_action(&format!(r#"spotatui.set_repeat("{mode}")"#)) { - Action::SetRepeat(setting) => assert_eq!(setting, expected), - _ => panic!("expected SetRepeat for mode '{mode}'"), - } - } - } - - #[test] - fn set_repeat_invalid_mode_raises() { - let mut engine = ScriptEngine::new().unwrap(); - assert!(engine - .load_source("test", r#"spotatui.set_repeat("all")"#) - .is_err()); - } - - #[test] - fn cycle_repeat_queues_cycle_effect() { - assert!(matches!( - run_action("spotatui.cycle_repeat()"), - Action::CycleRepeat - )); - } - - #[test] - fn play_uri_track_uses_uri_list() { - match run_action(r#"spotatui.play_uri("spotify:track:abc123")"#) { - Action::PlayUris { uris, offset: None } => { - assert_eq!(uris, vec!["spotify:track:abc123".to_string()]); - } - _ => panic!("expected PlayUris"), - } - } - - #[test] - fn play_uri_album_uses_context() { - match run_action(r#"spotatui.play_uri("spotify:album:abc123")"#) { - Action::PlayContext { uri, offset: None } => { - assert_eq!(uri, "spotify:album:abc123"); - } - _ => panic!("expected PlayContext"), - } - } - - #[test] - fn play_uri_garbage_raises() { - let mut engine = ScriptEngine::new().unwrap(); - assert!(engine - .load_source("test", r#"spotatui.play_uri("not-a-uri")"#) - .is_err()); - } - - #[test] - fn play_context_carries_offset() { - match run_action(r#"spotatui.play_context("spotify:playlist:p1", 5)"#) { - Action::PlayContext { - uri, - offset: Some(offset), - } => { - assert_eq!(uri, "spotify:playlist:p1"); - assert_eq!(offset, 5); - } - _ => panic!("expected PlayContext with offset"), - } - } - - #[test] - fn play_context_rejects_track_uri() { - let mut engine = ScriptEngine::new().unwrap(); - assert!(engine - .load_source("test", r#"spotatui.play_context("spotify:track:t1")"#) - .is_err()); - } - - #[test] - fn add_to_queue_queues_dispatch() { - match run_action(r#"spotatui.add_to_queue("spotify:track:t1")"#) { - Action::AddToQueue(uri) => { - assert_eq!(uri, "spotify:track:t1"); - } - _ => panic!("expected AddToQueue"), - } - } - - #[test] - fn create_playlist_with_uris() { - match run_action(r#"spotatui.create_playlist("Mix", {"spotify:track:a", "spotify:track:b"})"#) { - Action::CreatePlaylist { name, track_uris } => { - assert_eq!(name, "Mix"); - assert_eq!(track_uris.len(), 2); - } - _ => panic!("expected CreatePlaylist"), - } - } - - #[test] - fn create_playlist_empty_name_raises() { - let mut engine = ScriptEngine::new().unwrap(); - assert!(engine - .load_source("test", r#"spotatui.create_playlist(" ")"#) - .is_err()); - } - - #[test] - fn playlist_remove_track_requires_position() { - match run_action(r#"spotatui.playlist_remove_track("p1", "t1", 0)"#) { - Action::RemoveTrackFromPlaylist { - playlist, - track, - position, - } => { - assert_eq!(playlist, "p1"); - assert_eq!(track, "t1"); - assert_eq!(position, 0); - } - _ => panic!("expected RemoveTrackFromPlaylist"), - } - - let mut engine = ScriptEngine::new().unwrap(); - assert!(engine - .load_source("test", r#"spotatui.playlist_remove_track("p1", "t1")"#) - .is_err()); - assert!(engine - .load_source("test", r#"spotatui.playlist_remove_track("p1", "t1", -1)"#) - .is_err()); - } - - #[test] - fn transfer_playback_does_not_persist_device() { - match run_action(r#"spotatui.transfer_playback("dev-1")"#) { - Action::TransferPlayback { device_id, persist } => { - assert_eq!(device_id, "dev-1"); - assert!(!persist); - } - _ => panic!("expected TransferPlayback"), - } - } - - #[test] - fn follow_and_save_actions_map_to_events() { - assert!(matches!( - run_action(r#"spotatui.toggle_save_track("spotify:track:t1")"#), - Action::ToggleSaveTrack(_) - )); - assert!(matches!( - run_action(r#"spotatui.save_album("a1")"#), - Action::SaveAlbum(_) - )); - assert!(matches!( - run_action(r#"spotatui.unsave_album("a1")"#), - Action::UnsaveAlbum(_) - )); - assert!(matches!( - run_action(r#"spotatui.save_show("s1")"#), - Action::SaveShow(_) - )); - assert!(matches!( - run_action(r#"spotatui.unsave_show("s1")"#), - Action::UnsaveShow(_) - )); - match run_action(r#"spotatui.follow_artist("ar1")"#) { - Action::FollowArtist(id) => { - assert_eq!(id, "ar1"); - } - _ => panic!("expected FollowArtist"), - } - assert!(matches!( - run_action(r#"spotatui.unfollow_artist("ar1")"#), - Action::UnfollowArtist(_) - )); - assert!(matches!( - run_action(r#"spotatui.follow_playlist("p1")"#), - Action::FollowPlaylist(_) - )); - assert!(matches!( - run_action(r#"spotatui.unfollow_playlist("p1")"#), - Action::UnfollowPlaylist(_) - )); - } - - #[test] - fn empty_string_argument_raises() { - let mut engine = ScriptEngine::new().unwrap(); - assert!(engine - .load_source("test", r#"spotatui.add_to_queue("")"#) - .is_err()); - } - - #[test] - fn drain_add_to_queue_sends_io_event() { - let (mut app, rx) = make_app(); - let engine = ScriptEngine::new().unwrap(); - engine - .shared - .effects - .borrow_mut() - .push(Action::AddToQueue("spotify:track:t1".to_string())); - engine.drain_effects(&mut app); - - match rx.try_recv() { - Ok(IoEvent::AddItemToQueue(uri)) => assert_eq!(uri, "spotify:track:t1"), - _ => panic!("expected AddItemToQueue on channel (IoEvent is not Debug)"), - } - } - - #[test] - fn drain_unfollow_playlist_resolves_current_user() { - let (mut app, rx) = make_app(); - app.user = Some(UserInfo { - id: "me-123".to_string(), - display_name: None, - country: None, - }); - let engine = ScriptEngine::new().unwrap(); - engine - .shared - .effects - .borrow_mut() - .push(Action::UnfollowPlaylist("p1".to_string())); - engine.drain_effects(&mut app); - - match rx.try_recv() { - Ok(IoEvent::UserUnfollowPlaylist(user_id, playlist_id)) => { - assert_eq!(user_id, "me-123"); - assert_eq!(playlist_id, "p1"); - } - _ => panic!("expected UserUnfollowPlaylist on channel (IoEvent is not Debug)"), - } - } - - #[test] - fn drain_unfollow_playlist_without_user_sets_error() { - let (mut app, rx) = make_app(); - let engine = ScriptEngine::new().unwrap(); - engine - .shared - .effects - .borrow_mut() - .push(Action::UnfollowPlaylist("p1".to_string())); - engine.drain_effects(&mut app); - - assert!(rx.try_recv().is_err(), "no IoEvent expected"); - assert!(app.status_message_is_error()); - } -} - -// --- async data reads (spotatui.get_*) --- - -#[cfg(test)] -mod data_read_tests { - use super::*; - use crate::core::app::{App, LyricsStatus, PluginDataKind}; - use crate::core::plugin_api::PlaylistInfo; - use crate::core::user_config::UserConfig; - use crate::infra::network::IoEvent; - use std::sync::mpsc::channel; - use std::time::{Duration, Instant, SystemTime}; - - fn make_app() -> (App, std::sync::mpsc::Receiver) { - let (tx, rx) = channel(); - let app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - (app, rx) - } - - fn playlist(name: &str) -> PlaylistInfo { - PlaylistInfo { - uri: format!("spotify:playlist:{name}"), - name: name.to_string(), - owner: "owner".to_string(), - track_count: 3, - id: Some(name.to_string()), - owner_id: Some("owner".to_string()), - collaborative: false, - public: Some(true), - image_url: None, - } - } - - const GET_PLAYLISTS_NOTIFY: &str = r#" - spotatui.get_playlists(function(data, err) - if err then - spotatui.notify("err: " .. err, 1) - else - spotatui.notify("ok: " .. #data .. ":" .. (data[1] and data[1].name or "-"), 1) - end - end) - "#; - - #[test] - fn get_playlists_dispatches_io_event_and_resolves_on_bump() { - let mut engine = ScriptEngine::new().unwrap(); - let (mut app, rx) = make_app(); - engine.load_source("reader", GET_PLAYLISTS_NOTIFY).unwrap(); - - let now = Instant::now(); - engine.process_data_requests_for_test(&mut app, now); - - match rx.try_recv() { - Ok(IoEvent::GetPlaylists) => {} - _ => panic!("expected GetPlaylists dispatch (IoEvent is not Debug)"), - } - // Not resolved yet: generation unchanged. - assert!(drain(&engine).is_empty()); - - // Simulate the network write + bump, then the next engine pass resolves. - app.all_playlists = vec![playlist("Jams")]; - app.plugin_data_generations.bump(PluginDataKind::Playlists); - engine.process_data_requests_for_test(&mut app, now + Duration::from_millis(500)); - - match one(&engine) { - Action::Notify(msg, 1) => assert_eq!(msg, "ok: 1:Jams"), - _ => panic!("expected data callback notify"), - } - } - - #[test] - fn data_request_times_out_with_distinct_error() { - let mut engine = ScriptEngine::new().unwrap(); - let (mut app, _rx) = make_app(); - engine.load_source("reader", GET_PLAYLISTS_NOTIFY).unwrap(); - - let now = Instant::now(); - engine.process_data_requests_for_test(&mut app, now); - assert!(drain(&engine).is_empty()); - - // Never bump; jump past the deadline. - engine.process_data_requests_for_test(&mut app, now + Duration::from_secs(16)); - match one(&engine) { - Action::Notify(msg, 1) => assert_eq!(msg, "err: request timed out"), - _ => panic!("expected timeout notify"), - } - } - - #[test] - fn erroring_data_callback_queues_notify_error_and_is_one_shot() { - let mut engine = ScriptEngine::new().unwrap(); - let (mut app, _rx) = make_app(); - engine - .load_source( - "bad_reader", - r#"spotatui.get_playlists(function(data, err) error("data boom") end)"#, - ) - .unwrap(); - - let now = Instant::now(); - engine.process_data_requests_for_test(&mut app, now); - app.plugin_data_generations.bump(PluginDataKind::Playlists); - engine.process_data_requests_for_test(&mut app, now + Duration::from_millis(1)); - - match one(&engine) { - Action::NotifyError(msg, 6) => { - assert!(msg.contains("bad_reader")); - assert!(msg.contains("data boom")); - } - _ => panic!("expected data callback error notify"), - } - assert!(engine.shared.current_plugin.borrow().is_empty()); - - // The slot is cleared: another bump must not re-fire the callback. - app.plugin_data_generations.bump(PluginDataKind::Playlists); - engine.process_data_requests_for_test(&mut app, now + Duration::from_millis(2)); - assert!(drain(&engine).is_empty()); - } - - #[test] - fn two_same_kind_requests_both_resolve_on_one_bump() { - let mut engine = ScriptEngine::new().unwrap(); - let (mut app, _rx) = make_app(); - engine - .load_source( - "reader", - r#" - spotatui.get_playlists(function(data, err) spotatui.notify("first", 1) end) - spotatui.get_playlists(function(data, err) spotatui.notify("second", 1) end) - "#, - ) - .unwrap(); - - let now = Instant::now(); - engine.process_data_requests_for_test(&mut app, now); - app.plugin_data_generations.bump(PluginDataKind::Playlists); - engine.process_data_requests_for_test(&mut app, now + Duration::from_millis(1)); - - let effects = drain(&engine); - let messages: Vec = effects - .into_iter() - .filter_map(|e| match e { - Action::Notify(m, _) => Some(m), - _ => None, - }) - .collect(); - assert_eq!(messages, vec!["first".to_string(), "second".to_string()]); - } - - #[test] - fn get_search_results_dispatches_query() { - let mut engine = ScriptEngine::new().unwrap(); - let (mut app, rx) = make_app(); - engine - .load_source( - "searcher", - r#"spotatui.get_search_results("daft punk", function(data, err) end)"#, - ) - .unwrap(); - - engine.process_data_requests_for_test(&mut app, Instant::now()); - match rx.try_recv() { - Ok(IoEvent::GetSearchResults(q, _)) => assert_eq!(q, "daft punk"), - _ => panic!("expected GetSearchResults dispatch (IoEvent is not Debug)"), - } - } - - #[test] - fn get_search_results_empty_query_raises() { - let mut engine = ScriptEngine::new().unwrap(); - let result = engine.load_source( - "searcher", - r#"spotatui.get_search_results("", function() end)"#, - ); - assert!(result.is_err()); - } - - #[test] - fn get_lyrics_terminal_status_delivers_immediately_without_dispatch() { - let mut engine = ScriptEngine::new().unwrap(); - let (mut app, rx) = make_app(); - app.lyrics_status = LyricsStatus::Found; - app.lyrics = Some(vec![(1500, "hello lyrics".to_string())]); - - engine - .load_source( - "lyricist", - r#" - spotatui.get_lyrics(function(data, err) - spotatui.notify(data.status .. ":" .. data.lines[1].text .. ":" .. data.lines[1].time_ms, 1) - end) - "#, - ) - .unwrap(); - - engine.process_data_requests_for_test(&mut app, Instant::now()); - assert!( - rx.try_recv().is_err(), - "lyrics must not dispatch an IoEvent" - ); - match one(&engine) { - Action::Notify(msg, 1) => assert_eq!(msg, "found:hello lyrics:1500"), - _ => panic!("expected immediate lyrics notify"), - } - } - - #[test] - fn get_lyrics_pending_resolves_when_fetch_completes() { - let mut engine = ScriptEngine::new().unwrap(); - let (mut app, rx) = make_app(); - app.lyrics_status = LyricsStatus::Loading; - - engine - .load_source( - "lyricist", - r#"spotatui.get_lyrics(function(data, err) spotatui.notify(data.status, 1) end)"#, - ) - .unwrap(); - - let now = Instant::now(); - engine.process_data_requests_for_test(&mut app, now); - assert!( - rx.try_recv().is_err(), - "lyrics must not dispatch an IoEvent" - ); - assert!(drain(&engine).is_empty()); - - app.lyrics_status = LyricsStatus::NotFound; - app.plugin_data_generations.bump(PluginDataKind::Lyrics); - engine.process_data_requests_for_test(&mut app, now + Duration::from_millis(1)); - match one(&engine) { - Action::Notify(msg, 1) => assert_eq!(msg, "not_found"), - _ => panic!("expected pending lyrics notify"), - } - } - - /// The driver ticks the script engine *before* its track-change detector, so - /// on the tick a track changes `lyrics_status` is still `Found` for the - /// previous track. `get_lyrics` from a `track_change` handler must wait for - /// the new lyrics rather than resolving with the old ones. - #[test] - fn get_lyrics_waits_when_the_stored_lyrics_are_for_the_previous_track() { - use crate::core::app::{NativeTrackInfo, NativeTrackKind}; - - fn playing(app: &mut App, name: &str) { - app.is_streaming_active = true; - app.native_track_info = Some(NativeTrackInfo { - name: name.to_string(), - artists: vec!["The Artist".to_string()], - album: "The Album".to_string(), - duration_ms: 180_000, - kind: NativeTrackKind::Track, - image_url: None, - }); - } - - let mut engine = ScriptEngine::new().unwrap(); - let (mut app, rx) = make_app(); - - // Track A is playing and its lyrics have landed. - playing(&mut app, "Track A"); - app.desired_lyrics_identity = Some(("Track A".to_string(), "The Artist".to_string())); - app.lyrics_status = LyricsStatus::Found; - app.lyrics = Some(vec![(0, "lyrics for A".to_string())]); - - // Playback moves to track B. The detector has NOT run yet, so the lyrics - // state still describes A - this is the exact window the bug lived in. - playing(&mut app, "Track B"); - - engine - .load_source( - "lyricist", - r#"spotatui.get_lyrics(function(data, err) spotatui.notify(data.lines[1].text, 1) end)"#, - ) - .unwrap(); - - let now = Instant::now(); - engine.process_data_requests_for_test(&mut app, now); - assert!( - rx.try_recv().is_err(), - "lyrics must not dispatch an IoEvent" - ); - assert!( - drain(&engine).is_empty(), - "must not resolve with the previous track's lyrics" - ); - - // The detector then runs and B's lyrics land, bumping the generation. - app.desired_lyrics_identity = Some(("Track B".to_string(), "The Artist".to_string())); - app.lyrics = Some(vec![(0, "lyrics for B".to_string())]); - app.plugin_data_generations.bump(PluginDataKind::Lyrics); - engine.process_data_requests_for_test(&mut app, now + Duration::from_millis(1)); - match one(&engine) { - Action::Notify(msg, 1) => assert_eq!(msg, "lyrics for B"), - _ => panic!("expected B's lyrics"), - } - } - - /// The currency check must not stall the normal case: lyrics that belong to - /// the track actually playing still resolve in the same pass. - #[test] - fn get_lyrics_for_the_current_track_still_delivers_immediately() { - use crate::core::app::{NativeTrackInfo, NativeTrackKind}; - - let mut engine = ScriptEngine::new().unwrap(); - let (mut app, _rx) = make_app(); - app.is_streaming_active = true; - app.native_track_info = Some(NativeTrackInfo { - name: "Track A".to_string(), - artists: vec!["The Artist".to_string()], - album: "The Album".to_string(), - duration_ms: 180_000, - kind: NativeTrackKind::Track, - image_url: None, - }); - app.desired_lyrics_identity = Some(("Track A".to_string(), "The Artist".to_string())); - app.lyrics_status = LyricsStatus::Found; - app.lyrics = Some(vec![(0, "lyrics for A".to_string())]); - - engine - .load_source( - "lyricist", - r#"spotatui.get_lyrics(function(data, err) spotatui.notify(data.lines[1].text, 1) end)"#, - ) - .unwrap(); - - engine.process_data_requests_for_test(&mut app, Instant::now()); - match one(&engine) { - Action::Notify(msg, 1) => assert_eq!(msg, "lyrics for A"), - _ => panic!("expected immediate delivery"), - } - } - - #[test] - fn get_queue_resolves_with_flattened_items() { - let mut engine = ScriptEngine::new().unwrap(); - let (mut app, _rx) = make_app(); - engine - .load_source( - "queuer", - r#" - spotatui.get_queue(function(data, err) - local current = data.currently_playing and data.currently_playing.track.name or "-" - spotatui.notify(current .. ":" .. #data.items .. ":" .. data.items[1].kind, 1) - end) - "#, - ) - .unwrap(); - - let now = Instant::now(); - engine.process_data_requests_for_test(&mut app, now); - - app.queue = Some(crate::core::app::QueueState { - currently_playing: Some(crate::core::plugin_api::PlayableInfo::Track(track( - "uri:now", - "Now Playing", - ))), - queue: vec![crate::core::plugin_api::PlayableInfo::Track(track( - "uri:next", "Next Up", - ))], - }); - app.plugin_data_generations.bump(PluginDataKind::Queue); - engine.process_data_requests_for_test(&mut app, now + Duration::from_millis(1)); - - match one(&engine) { - Action::Notify(msg, 1) => assert_eq!(msg, "Now Playing:1:track"), - _ => panic!("expected queue notify"), - } - } - - #[test] - fn cached_playlists_read_refreshes_on_generation_change() { - let mut engine = ScriptEngine::new().unwrap(); - let (mut app, _rx) = make_app(); - - // First tick: sentinel forces a refresh of the (empty) snapshot. - engine.on_tick(&mut app); - engine - .load_source( - "sync", - r#"spotatui.notify("n=" .. #spotatui.playlists(), 1)"#, - ) - .unwrap(); - match one(&engine) { - Action::Notify(msg, 1) => assert_eq!(msg, "n=0"), - _ => panic!("expected empty cached read"), - } - - // Data lands without a bump: the cache must NOT refresh. - app.all_playlists = vec![playlist("A")]; - engine.on_tick(&mut app); - engine - .load_source( - "sync2", - r#"spotatui.notify("n=" .. #spotatui.playlists(), 1)"#, - ) - .unwrap(); - match one(&engine) { - Action::Notify(msg, 1) => assert_eq!(msg, "n=0"), - _ => panic!("expected stale cached read before bump"), - } - - // After the bump the next tick refreshes. - app.plugin_data_generations.bump(PluginDataKind::Playlists); - engine.on_tick(&mut app); - engine - .load_source( - "sync3", - r#"spotatui.notify("n=" .. #spotatui.playlists(), 1)"#, - ) - .unwrap(); - match one(&engine) { - Action::Notify(msg, 1) => assert_eq!(msg, "n=1"), - _ => panic!("expected refreshed cached read"), - } - } -} - -// --- timers --- - -#[cfg(test)] -mod timer_tests { - use super::*; - use std::time::{Duration, Instant}; - - #[test] - fn set_timeout_fires_once() { - let mut engine = ScriptEngine::new().unwrap(); - engine - .load_source( - "timer", - r#"spotatui.set_timeout(100, function() spotatui.notify("fired", 1) end)"#, - ) - .unwrap(); - - let now = Instant::now(); - engine.process_timers_for_test(now); // arms; not due yet - assert!(drain(&engine).is_empty()); - - engine.process_timers_for_test(now + Duration::from_millis(150)); - match one(&engine) { - Action::Notify(msg, 1) => assert_eq!(msg, "fired"), - _ => panic!("expected timeout notify"), - } - - // One-shot: never fires again. - engine.process_timers_for_test(now + Duration::from_millis(500)); - assert!(drain(&engine).is_empty()); - } - - #[test] - fn set_interval_repeats_and_skips_missed_periods() { - let mut engine = ScriptEngine::new().unwrap(); - engine - .load_source( - "timer", - r#"spotatui.set_interval(100, function() spotatui.notify("tick", 1) end)"#, - ) - .unwrap(); - - let now = Instant::now(); - engine.process_timers_for_test(now); // arm - engine.process_timers_for_test(now + Duration::from_millis(150)); - assert_eq!(drain(&engine).len(), 1); - - engine.process_timers_for_test(now + Duration::from_millis(260)); - assert_eq!(drain(&engine).len(), 1); - - // A long stall fires ONCE (no catch-up burst), rescheduled from `now`. - engine.process_timers_for_test(now + Duration::from_millis(2000)); - assert_eq!(drain(&engine).len(), 1); - } - - #[test] - fn cancel_timer_before_due_prevents_firing() { - let mut engine = ScriptEngine::new().unwrap(); - engine - .load_source( - "timer", - r#" - local h = spotatui.set_timeout(100, function() spotatui.notify("nope", 1) end) - spotatui.cancel_timer(h) - "#, - ) - .unwrap(); - - let now = Instant::now(); - engine.process_timers_for_test(now); - engine.process_timers_for_test(now + Duration::from_millis(500)); - assert!(drain(&engine).is_empty()); - } - - #[test] - fn erroring_interval_is_removed_with_notify_error() { - let mut engine = ScriptEngine::new().unwrap(); - engine - .load_source( - "bad_timer", - r#"spotatui.set_interval(100, function() error("interval boom") end)"#, - ) - .unwrap(); - - let now = Instant::now(); - engine.process_timers_for_test(now); - engine.process_timers_for_test(now + Duration::from_millis(150)); - match one(&engine) { - Action::NotifyError(msg, 6) => { - assert!(msg.contains("bad_timer")); - assert!(msg.contains("interval boom")); - } - _ => panic!("expected timer error notify"), - } - - // One strike: the interval is gone. - engine.process_timers_for_test(now + Duration::from_millis(1000)); - assert!(drain(&engine).is_empty()); - } - - #[test] - fn timer_set_inside_event_handler_fires() { - let mut engine = ScriptEngine::new().unwrap(); - engine - .load_source( - "timer", - r#" - spotatui.on("start", function() - spotatui.set_timeout(50, function() spotatui.notify("deferred", 1) end) - end) - "#, - ) - .unwrap(); - - engine.emit(ScriptEvent::Start); - assert!(drain(&engine).is_empty()); - - let now = Instant::now(); - engine.process_timers_for_test(now); // arms next pass - engine.process_timers_for_test(now + Duration::from_millis(100)); - match one(&engine) { - Action::Notify(msg, 1) => assert_eq!(msg, "deferred"), - _ => panic!("expected deferred timer notify"), - } - } - - #[test] - fn timer_set_inside_timer_callback_fires_next_pass() { - let mut engine = ScriptEngine::new().unwrap(); - engine - .load_source( - "timer", - r#" - spotatui.set_timeout(10, function() - spotatui.set_timeout(10, function() spotatui.notify("nested", 1) end) - end) - "#, - ) - .unwrap(); - - let now = Instant::now(); - engine.process_timers_for_test(now); - engine.process_timers_for_test(now + Duration::from_millis(20)); - assert!(drain(&engine).is_empty(), "outer fired, nested only queued"); - // The nested timer arms on the NEXT pass; its delay counts from there. - engine.process_timers_for_test(now + Duration::from_millis(50)); - assert!(drain(&engine).is_empty(), "nested armed, not yet due"); - engine.process_timers_for_test(now + Duration::from_millis(100)); - match one(&engine) { - Action::Notify(msg, 1) => assert_eq!(msg, "nested"), - _ => panic!("expected nested timer notify"), - } - } - - #[test] - fn negative_ms_raises() { - let mut engine = ScriptEngine::new().unwrap(); - assert!(engine - .load_source("timer", r#"spotatui.set_timeout(-5, function() end)"#) - .is_err()); - assert!(engine - .load_source("timer", r#"spotatui.set_interval(0, function() end)"#) - .is_err()); - } -} - -// --- plugin_id sanitizer --- - -#[test] -fn plugin_id_strips_lua_suffix_and_sanitizes() { - use super::shared::plugin_id; - assert_eq!(plugin_id("stats.lua"), "stats"); - assert_eq!(plugin_id("my-plugin"), "my-plugin"); - assert_eq!(plugin_id("weird name!.lua"), "weird_name_"); - assert_eq!(plugin_id("dir.plugin"), "dir_plugin"); - assert_eq!(plugin_id("под.lua"), "___"); -} - -// --- directory plugin loading (spotatui plugin add) --- - -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU32, Ordering}; - -static TMP_COUNTER: AtomicU32 = AtomicU32::new(0); - -/// Fresh, unique temp directory to act as a config dir. -fn temp_config_dir() -> PathBuf { - let n = TMP_COUNTER.fetch_add(1, Ordering::Relaxed); - let dir = std::env::temp_dir().join(format!("spotatui_lua_load_{}_{}", std::process::id(), n)); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - dir -} - -fn write_file(path: &Path, contents: &str) { - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(path, contents).unwrap(); -} - -/// True if any queued effect is a successful Notify carrying `needle`. -fn has_notify(engine: &ScriptEngine, needle: &str) -> bool { - drain(engine).into_iter().any(|e| match e { - Action::Notify(msg, _) => msg.contains(needle), - _ => false, - }) -} - -#[test] -fn dir_plugin_main_lua_is_loaded() { - let cfg = temp_config_dir(); - write_file( - &cfg.join("plugins").join("foo").join("main.lua"), - r#"spotatui.notify("loaded foo", 1)"#, - ); - - let mut engine = ScriptEngine::new().unwrap(); - let loaded = engine.load_user_scripts(&cfg); - - assert_eq!(loaded, 1); - assert!(has_notify(&engine, "loaded foo")); - std::fs::remove_dir_all(&cfg).unwrap(); -} - -#[test] -fn dir_plugin_init_lua_is_used_as_fallback() { - let cfg = temp_config_dir(); - write_file( - &cfg.join("plugins").join("bar").join("init.lua"), - r#"spotatui.notify("loaded bar", 1)"#, - ); - - let mut engine = ScriptEngine::new().unwrap(); - let loaded = engine.load_user_scripts(&cfg); - - assert_eq!(loaded, 1); - assert!(has_notify(&engine, "loaded bar")); - std::fs::remove_dir_all(&cfg).unwrap(); -} - -#[test] -fn dir_plugin_without_entry_point_is_skipped() { - let cfg = temp_config_dir(); - // Directory exists but has no main.lua/init.lua, plus a hidden dir that must be ignored. - std::fs::create_dir_all(cfg.join("plugins").join("empty")).unwrap(); - write_file( - &cfg.join("plugins").join(".hidden").join("main.lua"), - r#"spotatui.notify("should not load", 1)"#, - ); - - let mut engine = ScriptEngine::new().unwrap(); - let loaded = engine.load_user_scripts(&cfg); - - assert_eq!(loaded, 0); - assert!(drain(&engine).is_empty()); - std::fs::remove_dir_all(&cfg).unwrap(); -} - -#[test] -fn dir_plugin_can_require_sibling_module() { - let cfg = temp_config_dir(); - let plugin = cfg.join("plugins").join("qux"); - write_file( - &plugin.join("helper.lua"), - r#"return { msg = "from helper" }"#, - ); - write_file( - &plugin.join("main.lua"), - r#" - local helper = require("helper") - spotatui.notify(helper.msg, 1) - "#, - ); - - let mut engine = ScriptEngine::new().unwrap(); - let loaded = engine.load_user_scripts(&cfg); - - // A successful load proves `require` resolved the sibling module via package.path. - assert_eq!(loaded, 1); - assert!(has_notify(&engine, "from helper")); - std::fs::remove_dir_all(&cfg).unwrap(); -} - -#[test] -fn single_file_and_directory_plugins_both_load() { - let cfg = temp_config_dir(); - write_file( - &cfg.join("plugins").join("flat.lua"), - r#"spotatui.notify("flat", 1)"#, - ); - write_file( - &cfg.join("plugins").join("nested").join("main.lua"), - r#"spotatui.notify("nested", 1)"#, - ); - - let mut engine = ScriptEngine::new().unwrap(); - let loaded = engine.load_user_scripts(&cfg); - - assert_eq!(loaded, 2); - std::fs::remove_dir_all(&cfg).unwrap(); -} - -#[test] -fn directory_named_with_lua_extension_loads_once_without_error() { - // A directory literally named `weird.lua` must be treated only as a directory plugin, - // not also fed to the single-file path (which would raise a spurious load error). - let cfg = temp_config_dir(); - write_file( - &cfg.join("plugins").join("weird.lua").join("main.lua"), - r#"spotatui.notify("weird ok", 1)"#, - ); - - let mut engine = ScriptEngine::new().unwrap(); - let loaded = engine.load_user_scripts(&cfg); - - assert_eq!(loaded, 1); - let effects = drain(&engine); - assert!( - !effects - .iter() - .any(|e| matches!(e, Action::NotifyError(_, _))), - "a .lua-named directory must not produce a load error" - ); - std::fs::remove_dir_all(&cfg).unwrap(); -} - -#[test] -fn hidden_single_file_plugin_is_skipped() { - // Hidden files (e.g. macOS `._foo.lua` cruft) must be ignored, matching the directory branch. - let cfg = temp_config_dir(); - write_file( - &cfg.join("plugins").join(".secret.lua"), - r#"spotatui.notify("should not load", 1)"#, - ); - - let mut engine = ScriptEngine::new().unwrap(); - let loaded = engine.load_user_scripts(&cfg); - - assert_eq!(loaded, 0); - assert!(drain(&engine).is_empty()); - std::fs::remove_dir_all(&cfg).unwrap(); -} - -// --- plugin storage --- - -#[cfg(test)] -mod storage_tests { - use super::*; - - /// Engine with a temp config dir registered (empty plugins dir is fine). - fn engine_with_dir(cfg: &Path) -> ScriptEngine { - let mut engine = ScriptEngine::new().unwrap(); - engine.load_user_scripts(cfg); - engine - } - - #[test] - fn storage_round_trip_within_one_engine() { - let cfg = temp_config_dir(); - let mut engine = engine_with_dir(&cfg); - engine - .load_source( - "a.lua", - r#" - spotatui.storage_set("count", 42) - spotatui.storage_set("nested", { x = 1, tags = {"a", "b"} }) - spotatui.notify(spotatui.storage_get("count") .. ":" .. spotatui.storage_get("nested").tags[2], 1) - "#, - ) - .unwrap(); - match one(&engine) { - Action::Notify(msg, 1) => assert_eq!(msg, "42:b"), - _ => panic!("expected storage round-trip notify"), - } - std::fs::remove_dir_all(&cfg).unwrap(); - } - - #[test] - fn storage_flushes_and_reloads_in_new_engine() { - let cfg = temp_config_dir(); - { - let mut engine = engine_with_dir(&cfg); - engine - .load_source("a.lua", r#"spotatui.storage_set("song", "Nightcall")"#) - .unwrap(); - engine.flush_storage(true); - } - - // The file exists under the sanitized plugin id. - let file = cfg.join("plugin-data").join("a.json"); - assert!(file.is_file(), "expected {}", file.display()); - - let mut engine = engine_with_dir(&cfg); - engine - .load_source( - "a.lua", - r#"spotatui.notify(spotatui.storage_get("song") or "missing", 1)"#, - ) - .unwrap(); - match one(&engine) { - Action::Notify(msg, 1) => assert_eq!(msg, "Nightcall"), - _ => panic!("expected persisted value notify"), - } - std::fs::remove_dir_all(&cfg).unwrap(); - } - - #[test] - fn storage_set_nil_deletes_key() { - let cfg = temp_config_dir(); - let mut engine = engine_with_dir(&cfg); - engine - .load_source( - "a.lua", - r#" - spotatui.storage_set("gone", "soon") - spotatui.storage_set("gone", nil) - spotatui.notify(tostring(spotatui.storage_get("gone")) .. ":" .. #spotatui.storage_keys(), 1) - "#, - ) - .unwrap(); - match one(&engine) { - Action::Notify(msg, 1) => assert_eq!(msg, "nil:0"), - _ => panic!("expected nil-delete notify"), - } - std::fs::remove_dir_all(&cfg).unwrap(); - } - - #[test] - fn storage_remove_and_keys() { - let cfg = temp_config_dir(); - let mut engine = engine_with_dir(&cfg); - engine - .load_source( - "a.lua", - r#" - spotatui.storage_set("one", 1) - spotatui.storage_set("two", 2) - spotatui.storage_remove("one") - local keys = spotatui.storage_keys() - spotatui.notify(#keys .. ":" .. keys[1], 1) - "#, - ) - .unwrap(); - match one(&engine) { - Action::Notify(msg, 1) => assert_eq!(msg, "1:two"), - _ => panic!("expected remove/keys notify"), - } - std::fs::remove_dir_all(&cfg).unwrap(); - } - - #[test] - fn storage_set_function_value_raises() { - let cfg = temp_config_dir(); - let mut engine = engine_with_dir(&cfg); - let result = engine.load_source("a.lua", r#"spotatui.storage_set("f", function() end)"#); - assert!(result.is_err(), "functions must not be storable"); - std::fs::remove_dir_all(&cfg).unwrap(); - } - - #[test] - fn storage_isolated_per_plugin() { - let cfg = temp_config_dir(); - { - let mut engine = engine_with_dir(&cfg); - engine - .load_source("a.lua", r#"spotatui.storage_set("who", "plugin a")"#) - .unwrap(); - engine - .load_source("b", r#"spotatui.storage_set("who", "plugin b")"#) - .unwrap(); - engine.flush_storage(true); - } - - assert!(cfg.join("plugin-data").join("a.json").is_file()); - assert!(cfg.join("plugin-data").join("b.json").is_file()); - - let mut engine = engine_with_dir(&cfg); - engine - .load_source("b", r#"spotatui.notify(spotatui.storage_get("who"), 1)"#) - .unwrap(); - match one(&engine) { - Action::Notify(msg, 1) => assert_eq!(msg, "plugin b"), - _ => panic!("expected isolated namespace notify"), - } - std::fs::remove_dir_all(&cfg).unwrap(); - } - - #[test] - fn corrupt_storage_file_starts_empty() { - let cfg = temp_config_dir(); - write_file(&cfg.join("plugin-data").join("a.json"), "{not json"); - - let mut engine = engine_with_dir(&cfg); - engine - .load_source( - "a.lua", - r#"spotatui.notify(tostring(spotatui.storage_get("anything")), 1)"#, - ) - .unwrap(); - match one(&engine) { - Action::Notify(msg, 1) => assert_eq!(msg, "nil"), - _ => panic!("expected empty-after-corruption notify"), - } - std::fs::remove_dir_all(&cfg).unwrap(); - } - - #[test] - fn storage_outside_plugin_context_raises() { - // current_plugin is empty outside load/callback paths; simulate by loading - // a chunk with an empty plugin name. - let cfg = temp_config_dir(); - let mut engine = engine_with_dir(&cfg); - let result = engine.load_source("", r#"spotatui.storage_get("x")"#); - assert!(result.is_err()); - std::fs::remove_dir_all(&cfg).unwrap(); - } -} - -// --- state events (route/device/search) + shuffle/repeat diffs --- - -#[cfg(test)] -mod state_event_tests { - use super::*; - use crate::core::app::{ActiveBlock, App, RouteId}; - use crate::core::plugin_api::DeviceInfo; - use crate::core::user_config::UserConfig; - use crate::infra::network::IoEvent; - use crate::infra::scripting::events::diff_state_events; - use std::sync::mpsc::channel; - use std::time::SystemTime; - - fn make_app() -> (App, std::sync::mpsc::Receiver) { - let (tx, rx) = channel(); - let app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - (app, rx) - } - - fn device(id: &str, active: bool) -> DeviceInfo { - DeviceInfo { - id: Some(id.to_string()), - name: id.to_string(), - kind: "computer".to_string(), - is_active: active, - volume_percent: Some(50), - } - } - - #[test] - fn diff_shuffle_change() { - let old = Some(playback(Some(track("uri:1", "A")), true, 0)); - let mut new_pb = playback(Some(track("uri:1", "A")), true, 0); - new_pb.shuffle = true; - let q = Some(vec![]); - let events = diff_events(&old, &q, &Some(new_pb), &q); - assert_eq!(events, vec![ScriptEvent::ShuffleChange]); - } - - #[test] - fn diff_repeat_change() { - let old = Some(playback(Some(track("uri:1", "A")), true, 0)); - let mut new_pb = playback(Some(track("uri:1", "A")), true, 0); - new_pb.repeat = "track".to_string(); - let q = Some(vec![]); - let events = diff_events(&old, &q, &Some(new_pb), &q); - assert_eq!(events, vec![ScriptEvent::RepeatChange]); - } - - #[test] - fn diff_none_to_some_is_not_shuffle_or_repeat_change() { - let mut new_pb = playback(Some(track("uri:1", "A")), false, 0); - new_pb.shuffle = true; - new_pb.repeat = "context".to_string(); - let q = Some(vec![]); - let events = diff_events(&None, &q, &Some(new_pb), &q); - assert!(!events.contains(&ScriptEvent::ShuffleChange)); - assert!(!events.contains(&ScriptEvent::RepeatChange)); - } - - #[test] - fn diff_state_route_change_carries_new_name() { - let events = diff_state_events("home", "queue", &[], &[], false); - assert_eq!(events, vec![ScriptEvent::RouteChange("queue".to_string())]); - } - - #[test] - fn diff_state_device_change_on_active_flip() { - let old = [device("a", true), device("b", false)]; - let new = [device("a", false), device("b", true)]; - let events = diff_state_events("home", "home", &old, &new, false); - assert_eq!(events, vec![ScriptEvent::DeviceChange]); - } - - #[test] - fn diff_state_no_events_when_identical() { - let devs = [device("a", true)]; - assert!(diff_state_events("home", "home", &devs, &devs, false).is_empty()); - } - - #[test] - fn diff_state_search_advance() { - let events = diff_state_events("home", "home", &[], &[], true); - assert_eq!(events, vec![ScriptEvent::SearchResults]); - } - - #[test] - fn route_change_event_fires_after_key_navigation() { - let mut engine = ScriptEngine::new().unwrap(); - let (mut app, _rx) = make_app(); - engine - .load_source( - "router", - r#"spotatui.on("route_change", function(ev) spotatui.notify("now at " .. ev.name, 1) end)"#, - ) - .unwrap(); - - // Baseline the route (as on_start would), then navigate like a key handler. - engine.run_pending_commands(&mut app); - let _ = drain(&engine); - app.push_navigation_stack(RouteId::Queue, ActiveBlock::Queue); - engine.run_pending_commands(&mut app); - - assert_eq!(app.status_message(), Some("now at queue")); - } - - #[test] - fn current_route_sync_read_tracks_navigation() { - let mut engine = ScriptEngine::new().unwrap(); - let (mut app, _rx) = make_app(); - app.push_navigation_stack(RouteId::Settings, ActiveBlock::Settings); - engine.on_tick(&mut app); - engine - .load_source("reader", r#"spotatui.notify(spotatui.current_route(), 1)"#) - .unwrap(); - match one(&engine) { - Action::Notify(msg, 1) => assert_eq!(msg, "settings"), - _ => panic!("expected current_route notify"), - } - } - - #[test] - fn navigate_unknown_target_raises() { - let mut engine = ScriptEngine::new().unwrap(); - assert!(engine - .load_source("nav", r#"spotatui.navigate("narnia")"#) - .is_err()); - } - - #[test] - fn navigate_queue_pushes_route_and_fetches() { - let mut engine = ScriptEngine::new().unwrap(); - let (mut app, rx) = make_app(); - engine - .load_source("nav", r#"spotatui.navigate("queue")"#) - .unwrap(); - engine.drain_effects(&mut app); - - assert_eq!(app.get_current_route().id, RouteId::Queue); - match rx.try_recv() { - Ok(IoEvent::GetQueue) => {} - _ => panic!("expected GetQueue dispatch (IoEvent is not Debug)"), - } - } - - #[test] - fn back_pops_navigation_stack() { - let mut engine = ScriptEngine::new().unwrap(); - let (mut app, _rx) = make_app(); - app.push_navigation_stack(RouteId::Queue, ActiveBlock::Queue); - engine.load_source("nav", r#"spotatui.back()"#).unwrap(); - engine.drain_effects(&mut app); - assert_eq!(app.get_current_route().id, RouteId::Home); - } - - #[test] - fn device_change_event_fires_on_active_device_swap() { - let mut engine = ScriptEngine::new().unwrap(); - let (mut app, _rx) = make_app(); - engine - .load_source( - "dev", - r#"spotatui.on("device_change", function() spotatui.notify("devices moved", 1) end)"#, - ) - .unwrap(); - - engine.on_tick(&mut app); // baseline: no devices - let _ = drain(&engine); - - #[allow(deprecated)] - let payload = rspotify::model::device::DevicePayload { - devices: vec![rspotify::model::Device { - id: Some("dev-1".to_string()), - is_active: true, - is_private_session: false, - is_restricted: false, - name: "Desk".to_string(), - _type: rspotify::model::DeviceType::Computer, - volume_percent: Some(30), - }], - }; - app.devices = Some(payload); - engine.on_tick(&mut app); - assert_eq!(app.status_message(), Some("devices moved")); - } -} - -// --- custom plugin screens --- - -#[cfg(test)] -mod screen_tests { - use super::*; - use crate::core::app::{ActiveBlock, App, RouteId}; - use crate::core::plugin_api::{PluginCoverArtFit, PluginLength, PluginWidgetKind}; - use crate::core::user_config::UserConfig; - use crate::infra::network::IoEvent; - use std::sync::mpsc::channel; - use std::time::SystemTime; - - fn make_app() -> (App, std::sync::mpsc::Receiver) { - let (tx, rx) = channel(); - let app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - (app, rx) - } - - const STATS_SCREEN: &str = r#" - spotatui.register_screen("stats", { - title = "Stats", - on_key = function(key) - spotatui.notify("key: " .. key, 1) - end, - on_open = function() spotatui.notify("opened", 1) end, - on_close = function() spotatui.notify("closed", 1) end, - }) - "#; - - #[test] - fn show_screen_pushes_route_and_close_pops() { - let mut engine = ScriptEngine::new().unwrap(); - let (mut app, _rx) = make_app(); - engine.load_source("stats.lua", STATS_SCREEN).unwrap(); - engine - .load_source("stats.lua", r#"spotatui.show_screen("stats")"#) - .unwrap(); - engine.drain_effects(&mut app); - - assert_eq!( - app.get_current_route().id, - RouteId::PluginScreen("stats".to_string()) - ); - assert_eq!( - app.get_current_route().active_block, - ActiveBlock::PluginScreen - ); - - engine - .load_source("stats.lua", r#"spotatui.close_screen("stats")"#) - .unwrap(); - engine.drain_effects(&mut app); - assert!(!matches!( - app.get_current_route().id, - RouteId::PluginScreen(_) - )); - } - - #[test] - fn set_screen_publishes_widgets() { - let mut engine = ScriptEngine::new().unwrap(); - let (mut app, _rx) = make_app(); - engine.load_source("stats.lua", STATS_SCREEN).unwrap(); - engine - .load_source( - "stats.lua", - r#" - spotatui.set_screen("stats", { - { type = "paragraph", lines = {"hello", { text = "styled", bold = true }}, height = 4 }, - { type = "list", title = "Songs", items = {"one", "two"}, selected = 2 }, - { type = "gauge", ratio = 1.7, label = "70%" }, - }) - "#, - ) - .unwrap(); - engine.drain_effects(&mut app); - - let content = app.plugin_screens.get("stats").expect("content published"); - assert_eq!(content.title, "Stats"); - assert_eq!(content.widgets.len(), 3); - match &content.widgets[0].kind { - PluginWidgetKind::Paragraph { lines, scroll } => { - assert_eq!(lines.len(), 2); - assert!(lines[1].bold); - // Paragraphs scroll by default, matching the api_version = 5 behavior. - assert!(*scroll); - } - _ => panic!("expected paragraph widget"), - } - assert_eq!(content.widgets[0].height, Some(PluginLength::Cells(4))); - assert_eq!(content.widgets[0].width, None); - match &content.widgets[1].kind { - PluginWidgetKind::List { - title, - items, - selected, - } => { - assert_eq!(title.as_deref(), Some("Songs")); - assert_eq!(items.len(), 2); - // Lua-side selected is 1-based; stored 0-based. - assert_eq!(*selected, Some(1)); - } - _ => panic!("expected list widget"), - } - match &content.widgets[2].kind { - PluginWidgetKind::Gauge { ratio, label } => { - assert_eq!(*ratio, 1.0, "ratio must be clamped"); - assert_eq!(label.as_deref(), Some("70%")); - } - _ => panic!("expected gauge widget"), - } - } - - #[test] - fn set_screen_rejects_unknown_widget_type_and_bad_selected() { - let mut engine = ScriptEngine::new().unwrap(); - engine.load_source("stats.lua", STATS_SCREEN).unwrap(); - assert!(engine - .load_source( - "stats.lua", - r#"spotatui.set_screen("stats", {{ type = "table" }})"# - ) - .is_err()); - assert!(engine - .load_source( - "stats.lua", - r#"spotatui.set_screen("stats", {{ type = "list", items = {"x"}, selected = 0 }})"# - ) - .is_err()); - } - - /// Publishes `widgets` on the "stats" screen and returns the stored content. - fn publish_widgets(widgets: &str) -> crate::core::plugin_api::PluginScreenContent { - let mut engine = ScriptEngine::new().unwrap(); - let (mut app, _rx) = make_app(); - engine.load_source("stats.lua", STATS_SCREEN).unwrap(); - engine - .load_source( - "stats.lua", - &format!(r#"spotatui.set_screen("stats", {widgets})"#), - ) - .unwrap(); - engine.drain_effects(&mut app); - app - .plugin_screens - .remove("stats") - .expect("content published") - } - - /// Asserts publishing `widgets` fails, and returns the error text. - fn publish_widgets_err(widgets: &str) -> String { - let mut engine = ScriptEngine::new().unwrap(); - engine.load_source("stats.lua", STATS_SCREEN).unwrap(); - engine - .load_source( - "stats.lua", - &format!(r#"spotatui.set_screen("stats", {widgets})"#), - ) - .expect_err("expected set_screen to fail") - .to_string() - } - - #[test] - fn set_screen_parses_nested_row_and_column_containers() { - let content = publish_widgets( - r#"{ - { - type = "row", - height_percent = 60, - children = { - { type = "cover_art", source = "current", fit = "scale", width_percent = 40 }, - { - type = "column", - width = 30, - children = { - { type = "paragraph", lines = {"a"}, scroll = false }, - { type = "paragraph", lines = {"b"} }, - }, - }, - }, - }, - }"#, - ); - - assert_eq!(content.widgets.len(), 1); - let row = &content.widgets[0]; - assert_eq!(row.height, Some(PluginLength::Percent(60))); - let PluginWidgetKind::Row { children } = &row.kind else { - panic!("expected row container"); - }; - assert_eq!(children.len(), 2); - - assert_eq!(children[0].width, Some(PluginLength::Percent(40))); - assert!(matches!( - children[0].kind, - PluginWidgetKind::CoverArt { - fit: PluginCoverArtFit::Scale - } - )); - - assert_eq!(children[1].width, Some(PluginLength::Cells(30))); - let PluginWidgetKind::Column { children: inner } = &children[1].kind else { - panic!("expected column container"); - }; - assert_eq!(inner.len(), 2); - assert!(matches!( - inner[0].kind, - PluginWidgetKind::Paragraph { scroll: false, .. } - )); - assert!(matches!( - inner[1].kind, - PluginWidgetKind::Paragraph { scroll: true, .. } - )); - } - - #[test] - fn cover_art_defaults_to_contain_and_the_current_source() { - let content = publish_widgets(r#"{ { type = "cover_art" } }"#); - assert!(matches!( - content.widgets[0].kind, - PluginWidgetKind::CoverArt { - fit: PluginCoverArtFit::Contain - } - )); - } - - #[test] - fn set_screen_rejects_a_second_cover_art_widget_anywhere_in_the_tree() { - let err = publish_widgets_err( - r#"{ - { type = "cover_art" }, - { type = "row", children = { { type = "cover_art" } } }, - }"#, - ); - assert!(err.contains("at most one cover_art widget"), "got: {err}"); - } - - #[test] - fn set_screen_rejects_unknown_cover_art_source_and_fit() { - let err = publish_widgets_err(r#"{ { type = "cover_art", source = "album" } }"#); - assert!(err.contains("unknown cover_art source"), "got: {err}"); - - let err = publish_widgets_err(r#"{ { type = "cover_art", fit = "cover" } }"#); - assert!(err.contains("unknown cover_art fit"), "got: {err}"); - } - - #[test] - fn set_screen_rejects_conflicting_or_out_of_range_size_hints() { - let err = publish_widgets_err( - r#"{ { type = "paragraph", lines = {"x"}, height = 4, height_percent = 50 } }"#, - ); - assert!(err.contains("at most one of"), "got: {err}"); - - for percent in ["0", "101"] { - let err = publish_widgets_err(&format!( - r#"{{ {{ type = "paragraph", lines = {{"x"}}, width_percent = {percent} }} }}"# - )); - assert!(err.contains("must be between 1 and 100"), "got: {err}"); - } - } - - /// `height = 0` was legal in v5 (it produced a `Length(0)` constraint and so - /// an invisible widget). v6 must keep accepting it, or plugins that compute a - /// height and land on zero start failing their whole `set_screen` call. - #[test] - fn set_screen_accepts_a_zero_cell_count_as_a_hidden_widget() { - let content = - publish_widgets(r#"{ { type = "paragraph", lines = {"x"}, height = 0, width = 0 } }"#); - - assert_eq!(content.widgets.len(), 1); - assert_eq!(content.widgets[0].height, Some(PluginLength::Cells(0))); - assert_eq!(content.widgets[0].width, Some(PluginLength::Cells(0))); - } - - #[test] - fn set_screen_rejects_a_container_without_children() { - let err = publish_widgets_err(r#"{ { type = "row" } }"#); - assert!(err.contains("must have a 'children' array"), "got: {err}"); - } - - #[test] - fn set_screen_rejects_over_deep_nesting() { - // 9 nested rows: one past MAX_WIDGET_DEPTH. - let mut lua = String::from(r#"{ type = "paragraph", lines = {"x"} }"#); - for _ in 0..9 { - lua = format!(r#"{{ type = "row", children = {{ {lua} }} }}"#); - } - let err = publish_widgets_err(&format!("{{ {lua} }}")); - assert!(err.contains("nested more than"), "got: {err}"); - - // 8 levels is still accepted. - let mut lua = String::from(r#"{ type = "paragraph", lines = {"x"} }"#); - for _ in 0..8 { - lua = format!(r#"{{ type = "row", children = {{ {lua} }} }}"#); - } - let content = publish_widgets(&format!("{{ {lua} }}")); - assert_eq!(content.widgets.len(), 1); - } - - #[test] - fn set_screen_rejects_too_many_widget_nodes() { - let one = r#"{ type = "paragraph", lines = {"x"} },"#; - let err = publish_widgets_err(&format!("{{ {} }}", one.repeat(257))); - assert!(err.contains("at most 256 widgets"), "got: {err}"); - - let content = publish_widgets(&format!("{{ {} }}", one.repeat(256))); - assert_eq!(content.widgets.len(), 256); - } - - #[test] - fn set_screen_unregistered_or_foreign_raises() { - let mut engine = ScriptEngine::new().unwrap(); - engine.load_source("stats.lua", STATS_SCREEN).unwrap(); - - // Not registered at all. - assert!(engine - .load_source("stats.lua", r#"spotatui.set_screen("nope", {})"#) - .is_err()); - - // Registered, but owned by another plugin. - let err = engine - .load_source("intruder.lua", r#"spotatui.show_screen("stats")"#) - .unwrap_err() - .to_string(); - assert!(err.contains("belongs to plugin"), "got: {err}"); - } - - #[test] - fn duplicate_screen_name_raises() { - let mut engine = ScriptEngine::new().unwrap(); - engine.load_source("stats.lua", STATS_SCREEN).unwrap(); - assert!(engine - .load_source( - "other.lua", - r#"spotatui.register_screen("stats", { on_key = function() end })"# - ) - .is_err()); - } - - #[test] - fn register_screen_requires_on_key() { - let mut engine = ScriptEngine::new().unwrap(); - assert!(engine - .load_source( - "stats.lua", - r#"spotatui.register_screen("stats", { title = "T" })"# - ) - .is_err()); - } - - #[test] - fn pending_screen_key_reaches_on_key() { - let mut engine = ScriptEngine::new().unwrap(); - let (mut app, _rx) = make_app(); - engine.load_source("stats.lua", STATS_SCREEN).unwrap(); - - app - .pending_plugin_screen_keys - .push(("stats".to_string(), "ctrl-x".to_string())); - engine.run_pending_commands(&mut app); - - assert_eq!(app.status_message(), Some("key: ctrl-x")); - } - - #[test] - fn erroring_on_key_is_one_strike() { - let mut engine = ScriptEngine::new().unwrap(); - let (mut app, _rx) = make_app(); - engine - .load_source( - "bad.lua", - r#"spotatui.register_screen("boom", { on_key = function() error("kaput") end })"#, - ) - .unwrap(); - - app - .pending_plugin_screen_keys - .push(("boom".to_string(), "a".to_string())); - engine.run_pending_commands(&mut app); - assert!(app.status_message_is_error()); - assert!(app.status_message().unwrap_or("").contains("kaput")); - - // Second key: the erroring on_key was removed, nothing fires. - app.clear_status_message(); - app - .pending_plugin_screen_keys - .push(("boom".to_string(), "a".to_string())); - engine.run_pending_commands(&mut app); - assert!(app.status_message().is_none()); - } - - #[test] - fn on_open_and_on_close_fire_on_route_transitions() { - let mut engine = ScriptEngine::new().unwrap(); - let (mut app, _rx) = make_app(); - engine.load_source("stats.lua", STATS_SCREEN).unwrap(); - - // Baseline route tracking. - engine.run_pending_commands(&mut app); - - engine - .load_source("stats.lua", r#"spotatui.show_screen("stats")"#) - .unwrap(); - engine.drain_effects(&mut app); - engine.run_pending_commands(&mut app); - assert_eq!(app.status_message(), Some("opened")); - - app.pop_navigation_stack(); - engine.run_pending_commands(&mut app); - assert_eq!(app.status_message(), Some("closed")); - } - - /// The shipped example is the reference for the v6 widget API, so it is - /// loaded and driven through the real engine rather than only eyeballed. - #[test] - fn now_playing_example_publishes_a_cover_art_row() { - let mut engine = ScriptEngine::new().unwrap(); - let (mut app, _rx) = make_app(); - engine - .load_source( - "now-playing.lua", - include_str!("../../../examples/plugins/now-playing.lua"), - ) - .unwrap(); - - // Baseline route tracking, then open the screen via its command. - engine.run_pending_commands(&mut app); - app.queue_plugin_command("now_playing".to_string()); - engine.run_pending_commands(&mut app); - engine.drain_effects(&mut app); - assert_eq!( - app.get_current_route().id, - RouteId::PluginScreen("now_playing".to_string()) - ); - - // The route change fires on_open, which renders the screen. - engine.run_pending_commands(&mut app); - engine.drain_effects(&mut app); - - let content = app - .plugin_screens - .get("now_playing") - .expect("content published"); - assert_eq!(content.title, "Now Playing"); - assert_eq!(content.widgets.len(), 2); - assert!(matches!( - content.widgets[0].kind, - PluginWidgetKind::Paragraph { scroll: false, .. } - )); - - let PluginWidgetKind::Row { children } = &content.widgets[1].kind else { - panic!("expected the lyrics row"); - }; - assert_eq!(children.len(), 2); - assert!(matches!( - children[0].kind, - PluginWidgetKind::CoverArt { - fit: PluginCoverArtFit::Contain - } - )); - assert_eq!(children[0].width, Some(PluginLength::Percent(45))); - assert!(matches!( - children[1].kind, - PluginWidgetKind::Paragraph { .. } - )); - assert_eq!(children[1].width, Some(PluginLength::Percent(55))); - } - - #[test] - fn route_name_for_plugin_screen_is_prefixed() { - use crate::core::app::Route; - let route = Route { - id: RouteId::PluginScreen("stats".to_string()), - active_block: ActiveBlock::PluginScreen, - hovered_block: ActiveBlock::PluginScreen, - }; - assert_eq!(crate::core::plugin_api::route_name(&route), "plugin:stats"); - } -} - -// --- config() read --- - -#[cfg(test)] -mod config_tests { - use super::*; - use crate::core::app::App; - use crate::core::source::Source; - use crate::core::user_config::UserConfig; - use crate::infra::network::IoEvent; - use std::sync::mpsc::channel; - use std::time::SystemTime; - - fn make_app() -> (App, std::sync::mpsc::Receiver) { - let (tx, rx) = channel(); - let app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - (app, rx) - } - - #[test] - fn config_exposes_theme_and_behavior_scalars() { - let mut engine = ScriptEngine::new().unwrap(); - let (mut app, _rx) = make_app(); - app.user_config.theme.playbar_text = crate::core::theme::Color::Magenta; - app.user_config.behavior.seek_milliseconds = 12345; - engine.on_tick(&mut app); - - engine - .load_source( - "cfg", - r#" - local c = spotatui.config() - spotatui.notify(c.theme.playbar_text .. ":" .. c.behavior.seek_milliseconds, 1) - "#, - ) - .unwrap(); - match one(&engine) { - Action::Notify(msg, 1) => assert_eq!(msg, "Magenta:12345"), - _ => panic!("expected config notify"), - } - } - - #[test] - fn config_exposes_runtime_backed_behavior_scalars() { - let mut engine = ScriptEngine::new().unwrap(); - let (mut app, _rx) = make_app(); - app.user_config.behavior.sidebar_width_percent = Some(20); - app.user_config.behavior.playbar_height_rows = Some(6); - app.user_config.behavior.library_height_percent = Some(30); - app.runtime_state.shuffle_enabled = true; - app.runtime_state.sidebar_width_percent = 42; - app.runtime_state.playbar_height_rows = 12; - app.runtime_state.library_height_percent = 64; - app.runtime_state.active_source = Source::Radio; - engine.on_tick(&mut app); - - engine - .load_source( - "cfg", - r#" - local c = spotatui.config() - spotatui.notify( - table.concat({ - tostring(c.behavior.shuffle_enabled), - tostring(c.behavior.sidebar_width_percent), - tostring(c.behavior.playbar_height_rows), - tostring(c.behavior.library_height_percent), - c.behavior.active_source - }, ":"), - 1 - ) - "#, - ) - .unwrap(); - - match one(&engine) { - Action::Notify(msg, 1) => assert_eq!(msg, "true:42:12:64:radio"), - _ => panic!("expected runtime-backed config notify"), - } - assert_eq!(app.user_config.behavior.sidebar_width_percent, Some(20)); - assert_eq!(app.user_config.behavior.playbar_height_rows, Some(6)); - assert_eq!(app.user_config.behavior.library_height_percent, Some(30)); - } - - #[test] - fn config_excludes_secrets() { - let mut engine = ScriptEngine::new().unwrap(); - let (mut app, _rx) = make_app(); - app.user_config.behavior.sync_token = Some("SECRET".to_string()); - engine.on_tick(&mut app); - - engine - .load_source( - "cfg", - r#" - local c = spotatui.config() - spotatui.notify(tostring(c.behavior.sync_token) .. ":" .. tostring(c.behavior.relay_server_url), 1) - "#, - ) - .unwrap(); - match one(&engine) { - Action::Notify(msg, 1) => assert_eq!(msg, "nil:nil"), - _ => panic!("expected secrets-excluded notify"), - } - } -} diff --git a/src/infra/subsonic/dispatch.rs b/src/infra/subsonic/dispatch.rs deleted file mode 100644 index 5b603776..00000000 --- a/src/infra/subsonic/dispatch.rs +++ /dev/null @@ -1,696 +0,0 @@ -//! Subsonic browse/search/playback routing. -//! -//! The seam that keeps the Spotify [`Network`](crate::infra::network) -//! Spotify-only: [`route_subsonic_event`] is called from the runtime IoEvent -//! pump *after* the local-files dispatch and *before* `handle_network_event`. -//! When an event targets the Subsonic source (a browse/search request, or a -//! `subsonic:` playback URI) it is handled here and consumed; otherwise it falls -//! through to the normal Spotify dispatch. -//! -//! Unlike the local-files source, Subsonic's [`MediaSource`]/[`Searcher`] methods -//! are genuinely async (reqwest REST calls), so they are awaited directly rather -//! than run on the blocking pool. -//! -//! ## Decoupling & device ownership -//! -//! Subsonic playback owns a single piece of state, [`App::subsonic_playback`], -//! and never writes Spotify/librespot fields — the playbar reads progress/pause -//! live from the player. Only one backend holds the audio device at a time: -//! starting Subsonic pauses librespot **and** tears down any local session; the -//! reciprocal teardown lives in the local and network start paths. -//! -//! ## Streaming -//! -//! Each track is downloaded from `stream.view` to a tempfile (off the `App` -//! lock), then played from disk through the shared [`LocalPlayer`]. The download -//! window is much longer than a local decode, so the `advancing` guard is -//! load-bearing, and a download failure tears the session down rather than -//! skipping past (avoids cascading the whole queue when the network is down). - -use std::sync::Arc; -use std::time::Duration; - -use anyhow::{Context, Result}; -use tempfile::NamedTempFile; -use tokio::sync::Mutex; - -use super::{track_id_from_uri, SubsonicPlaybackState, SubsonicSource}; -use crate::core::app::{App, TrackTableContext}; -use crate::core::source::{MediaSource, Searcher, Source}; -use crate::infra::audio::LocalPlayer; -use crate::infra::network::IoEvent; -use crate::infra::queue::{advance_index, replay_file, snapshot_tracks}; - -/// Environment variable that overrides the configured Subsonic password. Prefer -/// it over the plaintext config field so the secret is never written to disk. -const PASSWORD_ENV: &str = "SPOTATUI_SUBSONIC_PASSWORD"; - -/// Whether a URI is owned by the Subsonic source. -fn is_subsonic_uri(uri: &str) -> bool { - uri.starts_with("subsonic:") -} - -/// Skip direction within the subsonic queue. -#[derive(Clone, Copy)] -enum Direction { - Next, - Prev, -} - -/// Intercept events that target the Subsonic source. -/// -/// Returns `true` if the event was handled (and must **not** be forwarded to the -/// Spotify network), `false` to let the normal dispatch run. -pub async fn route_subsonic_event(app: &Arc>, event: &IoEvent) -> bool { - match event { - IoEvent::GetSubsonicPlaylists => { - load_subsonic_playlists(app).await; - true - } - IoEvent::GetSubsonicTracks(uri) => { - load_subsonic_tracks(app, uri).await; - true - } - IoEvent::GetSubsonicSearchResults(query) => { - run_subsonic_search(app, query).await; - true - } - // Start a playlist of subsonic tracks: queue all and start at the offset. - IoEvent::StartPlayback(None, Some(uris), offset) - if uris.first().is_some_and(|u| is_subsonic_uri(u)) => - { - start_subsonic_queue(app, uris, offset.unwrap_or(0)).await; - true - } - // A single subsonic track with no surrounding playlist: a one-track queue. - IoEvent::StartPlayback(Some(uri), _, _) if is_subsonic_uri(uri) => { - start_subsonic_queue(app, std::slice::from_ref(uri), 0).await; - true - } - // Bare "resume current" — ours only while subsonic owns the session. - IoEvent::StartPlayback(None, None, None) => match player(app).await { - Some(p) => { - p.resume(); - true - } - None => false, - }, - // Any other start is a local/Spotify play: relinquish the device, then let - // the normal dispatch run. - IoEvent::StartPlayback(..) => { - teardown_subsonic(app).await; - false - } - IoEvent::PausePlayback => match player(app).await { - Some(p) => { - p.pause(); - true - } - None => false, - }, - IoEvent::Seek(position_ms) => match player(app).await { - Some(p) => { - let _ = p.seek(Duration::from_millis(*position_ms as u64)); - true - } - None => false, - }, - IoEvent::ChangeVolume(volume) => match player(app).await { - Some(p) => { - p.set_volume(*volume); - let mut app = app.lock().await; - app.runtime_state.volume_percent = *volume; - app.schedule_state_save(crate::core::state::PersistedRuntimeState::volume_percent( - *volume, - )); - true - } - None => false, - }, - IoEvent::NextTrack => skip(app, Direction::Next).await, - IoEvent::PreviousTrack | IoEvent::ForcePreviousTrack => skip(app, Direction::Prev).await, - // Repeat-one auto-advance: replay the current track (dispatched by the runner - // tick only while Subsonic owns the session). - IoEvent::ReplayCurrentTrack => replay_current(app).await, - // Explicit repeat-mode set (e.g. a plugin's `set_repeat`): store it as the - // decoded repeat mode while a queueable decoded source owns playback (the - // method's ownership gate excludes a context suspended under the native - // queue), so it never reaches the Spotify context. - IoEvent::Repeat(state) => app.lock().await.set_decoded_repeat_from_state(*state), - _ => false, - } -} - -// --------------------------------------------------------------------------- -// Browse + search -// --------------------------------------------------------------------------- - -/// Build a [`SubsonicSource`] from the saved server config, with the password -/// taken from the `SPOTATUI_SUBSONIC_PASSWORD` env var when set. Returns `None` -/// (after surfacing a status message) when no server URL is configured. -pub(crate) async fn build_source(app: &Arc>) -> Option { - let (url, username, config_password) = { - let guard = app.lock().await; - let behavior = &guard.user_config.behavior; - ( - behavior.subsonic_url.clone(), - behavior.subsonic_username.clone(), - behavior.subsonic_password.clone(), - ) - }; - - let Some(url) = url else { - set_error( - app, - "No Subsonic server configured (set behavior.subsonic_url)".to_string(), - ) - .await; - return None; - }; - - // Env override takes precedence over the plaintext config field. - let password = std::env::var(PASSWORD_ENV) - .ok() - .or(config_password) - .unwrap_or_default(); - - Some(SubsonicSource::new( - url, - username.unwrap_or_default(), - password, - )) -} - -/// Fetch the user's server playlists into `app.subsonic_playlists`. -async fn load_subsonic_playlists(app: &Arc>) { - let Some(source) = build_source(app).await else { - return; - }; - match source.playlists().await { - Ok(playlists) => { - let mut app = app.lock().await; - app.subsonic_playlists = playlists; - } - Err(e) => set_error(app, format!("Cannot load Subsonic playlists: {e}")).await, - } -} - -/// Fetch a playlist's tracks into the shared track table, tagged -/// [`TrackTableContext::SubsonicPlaylist`] so selecting a row plays it. -async fn load_subsonic_tracks(app: &Arc>, playlist_uri: &str) { - let Some(source) = build_source(app).await else { - return; - }; - match source.tracks(playlist_uri).await { - Ok(tracks) => { - let mut app = app.lock().await; - app.set_track_table(tracks, TrackTableContext::SubsonicPlaylist); - } - Err(e) => set_error(app, format!("Cannot load Subsonic playlist: {e}")).await, - } -} - -/// Run a catalog search and populate `app.search_results`. -/// -/// M2 populates **only** the songs block: album/artist search-result Enter -/// dispatches rspotify-bound `GetAlbum`/`GetArtist` events that fail for Subsonic -/// ids, so those blocks are left `None` to avoid dead rows. Album/artist -/// drill-down is a tracked follow-up. -async fn run_subsonic_search(app: &Arc>, query: &str) { - let Some(source) = build_source(app).await else { - return; - }; - match source.search(query).await { - Ok(results) => app.lock().await.show_source_search_tracks(results.tracks), - Err(e) => set_error(app, format!("Subsonic search failed: {e}")).await, - } -} - -// --------------------------------------------------------------------------- -// Playback -// --------------------------------------------------------------------------- - -/// The live subsonic player, if a subsonic session is active. -async fn player(app: &Arc>) -> Option> { - app - .lock() - .await - .subsonic_playback - .as_ref() - .map(|s| Arc::clone(&s.player)) -} - -/// Release the other backends so only subsonic holds the output device. -async fn release_other_backends(app: &Arc>) { - // Pause native Spotify so librespot releases the device. - #[cfg(feature = "streaming")] - { - let streaming = app.lock().await.streaming_player.clone(); - if let Some(player) = streaming { - player.pause(); - } - } - // The other decoded sources never see this subsonic: start (the pump's - // `!handled_subsonic` short-circuit), so their sessions are torn down here. - let players = app - .lock() - .await - .take_decoded_sessions_except(Source::Subsonic); - for player in players { - player.stop_detached(); - } -} - -/// Reuse the live subsonic player, or open a fresh output device for one. A -/// freshly opened player is **not** published to `App` here — the caller -/// publishes the session only on a successful first play. -async fn acquire_player(app: &Arc>) -> Option> { - if let Some(p) = player(app).await { - return Some(p); - } - match tokio::task::spawn_blocking(LocalPlayer::new).await { - Ok(Ok(p)) => Some(Arc::new(p)), - Ok(Err(e)) => { - set_error(app, format!("No audio output for Subsonic playback: {e}")).await; - None - } - Err(e) => { - set_error(app, format!("Audio output init failed: {e}")).await; - None - } - } -} - -/// Download a track's audio into a fresh tempfile. Must be awaited **without** -/// holding the `App` lock (it can take seconds). -async fn download_track(source: &SubsonicSource, track_id: &str) -> Result { - let tmp = NamedTempFile::new().context("creating temp file for Subsonic stream")?; - source.download_track(track_id, tmp.path()).await?; - Ok(tmp) -} - -/// Download the track at `uri` into a tempfile, for the native queue engine's -/// off-pump fetch (playing is the queue's job — it re-checks that the queue -/// slot is still current before touching the shared player). -pub(crate) async fn download_for_queue( - source: &SubsonicSource, - uri: &str, -) -> Result { - let track_id = track_id_from_uri(uri)?.to_string(); - download_track(source, &track_id).await -} - -/// Begin playing a queue of subsonic tracks, taking over the session and -/// starting at `start_idx` (clamped into range). -async fn start_subsonic_queue(app: &Arc>, uris: &[String], start_idx: usize) { - // Snapshot the track metadata under one short lock, from whichever browse view - // the request came from (track table for browse, search results for search). - let tracks = { - let guard = app.lock().await; - let search = guard - .search_results - .tracks - .as_ref() - .map(|p| p.items.as_slice()); - snapshot_tracks(&guard.track_table.tracks, search, uris) - }; - if tracks.is_empty() { - set_error(app, "No Subsonic tracks to play".to_string()).await; - return; - } - let index = start_idx.min(tracks.len() - 1); - - let track_id = match tracks[index].uri.as_deref().map(track_id_from_uri) { - Some(Ok(id)) => id.to_string(), - _ => { - set_error(app, "Invalid Subsonic track URI".to_string()).await; - return; - } - }; - - let Some(source) = build_source(app).await else { - return; - }; - let source = Arc::new(source); - - // Only one backend owns the device at a time. - app.lock().await.claim_decoded_sink(Source::Subsonic); - release_other_backends(app).await; - - let Some(player) = acquire_player(app).await else { - return; - }; - - // Download off the lock, then decode on the blocking pool. - let tmp = match download_track(&source, &track_id).await { - Ok(t) => t, - Err(e) => { - set_error(app, format!("Cannot download Subsonic track: {e}")).await; - return; - } - }; - let path = tmp.path().to_path_buf(); - let decode_player = Arc::clone(&player); - let result = tokio::task::spawn_blocking(move || decode_player.play_file(&path)).await; - - match result { - Ok(Ok(())) => { - let volume = app.lock().await.runtime_state.volume_percent; - player.set_volume(volume); - - let display = tracks[index].name.clone(); - let mut guard = app.lock().await; - // Publish the session exactly once, now that the source is decoding. - let mut state = SubsonicPlaybackState { - player, - source, - tracks, - index, - advancing: false, - tempfile: tmp, - shuffle_backup: None, - resume_at: None, - }; - // Honor the player-global decoded shuffle for the freshly-built queue. - if guard.decoded_shuffle { - state.set_shuffle(true); - } - guard.subsonic_playback = Some(state); - guard.set_status_message(format!("\u{266a} {display}"), 4); - } - Ok(Err(e)) => set_error(app, format!("Cannot play Subsonic track: {e}")).await, - Err(e) => set_error(app, format!("Subsonic playback task failed: {e}")).await, - } -} - -/// Move the subsonic queue index in `direction` and play the new track. Returns -/// `true` if subsonic owns the session (so the event is consumed). -async fn skip(app: &Arc>, direction: Direction) -> bool { - let target = { - let mut guard = app.lock().await; - // Repeat-all wraps at the ends; Off/Repeat-one clamp. - let mode = guard.decoded_repeat; - let Some(s) = guard.subsonic_playback.as_mut() else { - return false; // not ours - }; - // Guard the empty-sink download window from spurious auto-advance. - s.advancing = true; - let forward = matches!(direction, Direction::Next); - advance_index(s.index, s.tracks.len(), mode, forward) - }; - - match target { - Some(idx) => play_index(app, idx).await, - None => { - // Queue boundary: clear the optimistic guard so it doesn't wedge - // auto-advance off for the rest of the track. - if let Some(s) = app.lock().await.subsonic_playback.as_mut() { - s.advancing = false; - } - } - } - true -} - -/// Replay the current Subsonic track (repeat-one auto-advance) via -/// [`replay_file`]. Re-decodes the **already-downloaded tempfile** — no second -/// network fetch — and on failure tears the session down (rather than leaving -/// an empty sink that would re-fire replay every tick). Returns `true` if -/// Subsonic owns the session. -async fn replay_current(app: &Arc>) -> bool { - let (player, path, resume) = { - let mut guard = app.lock().await; - let Some(s) = guard.subsonic_playback.as_mut() else { - return false; // not ours - }; - s.advancing = true; - ( - Arc::clone(&s.player), - s.tempfile.path().to_path_buf(), - s.resume_at - .take() - .or(Some(crate::infra::queue::ResumePoint { - position_ms: 0, - paused: s.player.is_paused(), - })), - ) - }; - if replay_file(player, path, resume).await { - if let Some(s) = app.lock().await.subsonic_playback.as_mut() { - s.advancing = false; - } - } else { - teardown_subsonic(app).await; - set_error(app, "Cannot replay Subsonic track".to_string()).await; - } - true -} - -/// What the locked snapshot in [`play_index`] decided to do. -enum Plan { - Play(Arc, Arc, String), - OutOfRange, - BadUri, -} - -/// Play the queued track at `target`, reusing the published session. Used by -/// Next/Previous and the runner tick's auto-advance. -/// -/// A download failure tears the session down (deliberate divergence from the -/// local source's skip-past): on a network outage, walking the whole queue at -/// tick speed would spray error toasts, so one failure ends playback instead. -pub(crate) async fn play_index(app: &Arc>, target: usize) { - let plan = { - let guard = app.lock().await; - match guard.subsonic_playback.as_ref() { - None => return, // session torn down between dispatch and here - Some(s) => match s.tracks.get(target) { - None => Plan::OutOfRange, - Some(track) => match track.uri.as_deref().map(track_id_from_uri) { - Some(Ok(id)) => Plan::Play(Arc::clone(&s.player), Arc::clone(&s.source), id.to_string()), - _ => Plan::BadUri, - }, - }, - } - }; - - let (player, source, track_id) = match plan { - Plan::Play(p, s, id) => (p, s, id), - Plan::OutOfRange => { - if let Some(s) = app.lock().await.subsonic_playback.as_mut() { - s.advancing = false; - } - return; - } - Plan::BadUri => { - teardown_subsonic(app).await; - set_error(app, "Invalid Subsonic track URI".to_string()).await; - return; - } - }; - - let tmp = match download_track(&source, &track_id).await { - Ok(t) => t, - Err(e) => { - teardown_subsonic(app).await; - set_error(app, format!("Cannot download Subsonic track: {e}")).await; - return; - } - }; - let path = tmp.path().to_path_buf(); - // Taken after the download: a device recovery during it leaves the pause - // to apply here. - let resume = { - let mut guard = app.lock().await; - guard - .subsonic_playback - .as_mut() - .and_then(|s| s.resume_at.take()) - }; - let decode_player = Arc::clone(&player); - let result = tokio::task::spawn_blocking(move || { - crate::infra::queue::restage(&decode_player, &path, resume) - }) - .await; - - match result { - Ok(Ok(())) => commit_index(app, target, tmp).await, - Ok(Err(e)) => { - teardown_subsonic(app).await; - set_error(app, format!("Cannot play Subsonic track: {e}")).await; - } - Err(e) => { - teardown_subsonic(app).await; - set_error(app, format!("Subsonic playback task failed: {e}")).await; - } - } -} - -/// Commit `target` as the live index, clear the auto-advance guard, and swap in -/// the new track's tempfile (dropping the previous one). Ordering is safe: the -/// blocking `play_file` already cleared the old source from the sink, so rodio -/// no longer holds the old file by the time it is dropped here. -async fn commit_index(app: &Arc>, target: usize, tmp: NamedTempFile) { - let mut guard = app.lock().await; - let display = if let Some(s) = guard.subsonic_playback.as_mut() { - s.index = target; - s.advancing = false; - s.tempfile = tmp; - s.tracks.get(target).map(|t| t.name.clone()) - } else { - None - }; - if let Some(display) = display { - guard.set_status_message(format!("\u{266a} {display}"), 4); - } -} - -/// End the subsonic session, releasing the output device and cleaning up the -/// current tempfile. -async fn teardown_subsonic(app: &Arc>) { - // Take under the lock, stop off it: a sink clear waits for the audio thread. - let session = app.lock().await.subsonic_playback.take(); - if let Some(s) = session { - // Dropping `s` drops the tempfile (cleanup) and, if it held the last - // reference, the keepalive thread exits and the device is released. - Arc::clone(&s.player).stop_detached_holding(s); - } -} - -async fn set_error(app: &Arc>, message: String) { - app.lock().await.set_status_message(message, 6); -} - -#[cfg(test)] -mod tests { - use super::*; - - /// End-to-end dispatch test: drive `route_subsonic_event` exactly as the - /// runtime pump does — browse, play, advance the queue, and tear down on a - /// foreign start. Exercises the dispatch glue/state machine the direct-client - /// smoke tests bypass. Ignored (needs network **and** an audio device); run: - /// `cargo test --features subsonic -- --ignored live_dispatch` - #[tokio::test] - #[ignore = "hits the live demo server AND requires an audio output device"] - async fn live_dispatch_browse_play_and_advance() { - use crate::core::user_config::UserConfig; - use std::sync::mpsc::channel; - use std::time::SystemTime; - - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.user_config.behavior.subsonic_url = Some("https://demo.navidrome.org".to_string()); - app.user_config.behavior.subsonic_username = Some("demo".to_string()); - app.user_config.behavior.subsonic_password = Some("demo".to_string()); - let app = Arc::new(Mutex::new(app)); - - // Browse playlists, then a playlist's tracks into the shared table. - assert!(route_subsonic_event(&app, &IoEvent::GetSubsonicPlaylists).await); - let playlist_uri = app - .lock() - .await - .subsonic_playlists - .first() - .expect("demo has playlists") - .uri - .clone(); - assert!(route_subsonic_event(&app, &IoEvent::GetSubsonicTracks(playlist_uri)).await); - let uris: Vec = app - .lock() - .await - .track_table - .tracks - .iter() - .filter_map(|t| t.uri.clone()) - .collect(); - assert!( - uris.len() >= 2, - "need a multi-track playlist to test advance" - ); - - // Start the queue at index 0 — downloads + plays the first track. - assert!( - route_subsonic_event(&app, &IoEvent::StartPlayback(None, Some(uris), Some(0))).await, - "subsonic StartPlayback must be consumed" - ); - { - let guard = app.lock().await; - let s = guard.subsonic_playback.as_ref().expect("session published"); - assert_eq!(s.index, 0); - assert!(!s.player.is_paused(), "should be playing"); - } - - // Advance — downloads + plays the next track, moving the index. - assert!(route_subsonic_event(&app, &IoEvent::NextTrack).await); - { - let guard = app.lock().await; - let s = guard - .subsonic_playback - .as_ref() - .expect("session still active"); - assert_eq!(s.index, 1, "Next should advance the queue index"); - } - - // A foreign (Spotify) start tears the session down and falls through. - assert!( - !route_subsonic_event( - &app, - &IoEvent::StartPlayback(Some("spotify:track:x".to_string()), None, None) - ) - .await, - "a non-subsonic start must fall through to the network" - ); - assert!( - app.lock().await.subsonic_playback.is_none(), - "a foreign start must tear down the subsonic session (device handoff)" - ); - } - - /// Regression for the search->play path: search, then play a result **without** - /// browsing it into the track table first, so the metadata can only come from - /// the search results. Before the `snapshot_tracks` fix this published no - /// session ("No Subsonic tracks to play"). - #[tokio::test] - #[ignore = "hits the live demo server AND requires an audio output device"] - async fn live_dispatch_search_to_play() { - use crate::core::user_config::UserConfig; - use std::sync::mpsc::channel; - use std::time::SystemTime; - - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.user_config.behavior.subsonic_url = Some("https://demo.navidrome.org".to_string()); - app.user_config.behavior.subsonic_username = Some("demo".to_string()); - app.user_config.behavior.subsonic_password = Some("demo".to_string()); - let app = Arc::new(Mutex::new(app)); - - assert!( - route_subsonic_event(&app, &IoEvent::GetSubsonicSearchResults("love".to_string())).await - ); - let uris: Vec = app - .lock() - .await - .search_results - .tracks - .as_ref() - .expect("search populated the songs block") - .items - .iter() - .filter_map(|t| t.uri.clone()) - .collect(); - assert!(!uris.is_empty(), "search should return tracks"); - // Track table is empty (never browsed) — metadata must resolve from search. - assert!(app.lock().await.track_table.tracks.is_empty()); - - assert!(route_subsonic_event(&app, &IoEvent::StartPlayback(None, Some(uris), Some(0))).await); - let guard = app.lock().await; - let s = guard - .subsonic_playback - .as_ref() - .expect("search->play must publish a session"); - assert!( - s.current().is_some_and(|t| !t.name.is_empty()), - "metadata must resolve from the search results" - ); - } -} diff --git a/src/infra/subsonic/mod.rs b/src/infra/subsonic/mod.rs deleted file mode 100644 index 0ac8a3a1..00000000 --- a/src/infra/subsonic/mod.rs +++ /dev/null @@ -1,929 +0,0 @@ -//! Subsonic / OpenSubsonic media source. -//! -//! Implements [`MediaSource`] and [`Searcher`] against any server that speaks -//! the [Subsonic REST API](https://subsonic.org/pages/api.jsp) (v1.16.1), -//! including forks such as Navidrome and Airsonic-Advanced. -//! -//! ## Authentication -//! -//! Uses the token-based scheme introduced in API 1.13.0: -//! - `s` — random salt (generated per request) -//! - `t` — `md5(password + salt)`, lower-hex encoded -//! - `u` — username -//! - `v` — API version (`"1.16.1"`) -//! - `c` — client name (`"spotatui"`) -//! - `f` — response format (`"json"`) -//! -//! ## URIs -//! -//! Playlists: `subsonic:playlist:`. -//! Tracks: `subsonic:track:`. - -// Nothing in the binary wires this source yet. -#![allow(dead_code)] - -pub mod dispatch; -mod types; - -use std::io::Write; -use std::sync::Arc; -use std::time::Duration; - -use anyhow::{anyhow, Context, Result}; -use futures::StreamExt; -use md5::{Digest, Md5}; -use rand::RngExt; -use reqwest::Client; - -use crate::core::plugin_api::{ - AlbumInfo, ArtistInfo, ArtistRef, PlaylistInfo, SearchResults, TrackInfo, -}; -use crate::core::source::{MediaSource, Searcher}; -use crate::infra::audio::LocalPlayer; - -use types::{SubsonicEnvelope, SubsonicResponse}; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -const API_VERSION: &str = "1.16.1"; -const CLIENT_NAME: &str = "spotatui"; - -const PLAYLIST_PREFIX: &str = "subsonic:playlist:"; -const TRACK_PREFIX: &str = "subsonic:track:"; - -/// Cap on establishing the TCP+TLS connection. A server that never completes the -/// handshake (captive portal, half-open TCP) fails fast instead of hanging the -/// serial IoEvent pump. -const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); - -/// Overall per-request cap covering connect + body. `fetch`/`download_track` are -/// awaited inline on the pump, so a server that connects then stalls the body -/// (e.g. behind a proxy) must not wedge transport for every source forever. -/// Applies per request, so it also bounds each streamed download below. -const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); - -// --------------------------------------------------------------------------- -// SubsonicPlaybackState -// --------------------------------------------------------------------------- - -/// The active Subsonic playback session. -/// -/// Like the local-files [`LocalPlaybackState`](crate::infra::local::LocalPlaybackState) -/// it owns the live [`LocalPlayer`], a queue of `subsonic:track:` URIs plus the -/// current index, and the static metadata of the playing track. Dynamic state -/// (position, paused) is read **live** from `player`, so it is never mirrored -/// into Spotify/librespot fields and cannot desync. -/// -/// Two fields are specific to a *remote* source: it holds the -/// [`SubsonicSource`] (to authenticate and download each track on Next/advance) -/// and the downloaded [`tempfile::NamedTempFile`] for the current track. The -/// tempfile is kept alive here because `rodio` reads it incrementally during -/// playback; it is dropped (and cleaned up) when replaced or on teardown. -pub struct SubsonicPlaybackState { - pub player: Arc, - /// Source handle, reused to build authed stream URLs and download per track. - pub source: Arc, - /// The playing playlist's tracks (with API metadata) in order. The URI for - /// track `i` is `tracks[i].uri`; the playbar reads name/artists/album/duration - /// from `tracks[index]`. Stored in full (vs local's URI-only queue) because the - /// metadata came from the API, not from on-disk tags re-read per track. - pub tracks: Vec, - /// Index into [`tracks`](Self::tracks) of the currently playing track. - pub index: usize, - /// Auto-advance guard, identical in purpose to the local one: set while a - /// track change is downloading/decoding so the runner tick does not mistake - /// the empty sink for end-of-track and fire repeated `NextTrack` dispatches. - /// The download window is much longer than local's decode, so this guard is - /// load-bearing for remote playback. - pub advancing: bool, - /// The downloaded audio for the current track, kept alive while it plays. - pub tempfile: tempfile::NamedTempFile, - /// Backup of the pre-shuffle track order while shuffle is on (`None` in natural - /// order). Set by [`set_shuffle(true)`](Self::set_shuffle); restored exactly by - /// `set_shuffle(false)`. - pub shuffle_backup: Option, - /// A seek and pause to apply when the next track is staged (device - /// recovery, the native queue's resume). - pub resume_at: Option, -} - -impl SubsonicPlaybackState { - /// The currently playing track, if `index` is in range. - pub fn current(&self) -> Option<&TrackInfo> { - self.tracks.get(self.index) - } - - /// Turn in-place shuffle on or off — see - /// [`toggle_shuffle`](crate::infra::queue::toggle_shuffle) for the shared - /// semantics (current track stays playing at the front; un-shuffle restores - /// order + index; idempotent). - pub fn set_shuffle(&mut self, on: bool) { - crate::infra::queue::toggle_shuffle( - &mut self.tracks, - &mut self.index, - &mut self.shuffle_backup, - on, - ); - } -} - -// --------------------------------------------------------------------------- -// SubsonicSource -// --------------------------------------------------------------------------- - -/// A media source backed by a Subsonic-compatible server. -/// -/// Constructed with [`SubsonicSource::new`] and then used through the -/// [`MediaSource`] and [`Searcher`] trait impls. -pub struct SubsonicSource { - /// Base URL of the server, **without** a trailing slash. - /// Example: `"https://music.example.com"`. - base_url: String, - username: String, - /// Plain-text password used to derive per-request token+salt pairs. - /// Stored in memory; never written to disk by this module. - password: String, - http: Client, -} - -/// Process-wide Subsonic HTTP client. Dispatch constructs a fresh -/// `SubsonicSource` per event, so the client (which owns the TLS state and -/// connection pool) is built once and cheaply cloned into each source — -/// otherwise every Subsonic action pays a fresh TLS handshake (same pattern -/// as `shared_http_client` on the Spotify path). -/// -/// A bare `Client::new()` has no timeouts: a server that connects then -/// stalls the body wedges the serial IoEvent pump permanently, killing -/// transport for every source. Bound both the handshake and the whole -/// request. `.timeout()` applies per request, so it also caps the streamed -/// download in `download_track` (each chunk read must make progress within -/// the window) without needing a separate wrapper there. -fn shared_subsonic_client() -> Client { - static SUBSONIC_HTTP_CLIENT: std::sync::OnceLock = std::sync::OnceLock::new(); - SUBSONIC_HTTP_CLIENT - .get_or_init(|| { - Client::builder() - .connect_timeout(CONNECT_TIMEOUT) - .timeout(REQUEST_TIMEOUT) - .build() - // Falls back to a default (untimed) client only if TLS init fails, which - // would break every other request in the app too. - .unwrap_or_default() - }) - .clone() -} - -impl SubsonicSource { - /// Create a new source for the given server. - /// - /// `base_url` should be the root of the Subsonic server, e.g. - /// `"https://music.example.com"` (trailing slashes are stripped automatically). - pub fn new( - base_url: impl Into, - username: impl Into, - password: impl Into, - ) -> Self { - let base_url: String = base_url.into(); - SubsonicSource { - // Strip trailing slashes to avoid double-slash URLs like `//rest/ping.view`. - base_url: base_url.trim_end_matches('/').to_string(), - username: username.into(), - password: password.into(), - http: shared_subsonic_client(), - } - } - - // ------------------------------------------------------------------------- - // Internal helpers - // ------------------------------------------------------------------------- - - /// Build the full URL for a REST endpoint with authentication parameters. - /// - /// Generates a fresh salt for every call so tokens cannot be replayed. - fn endpoint_url(&self, view: &str) -> String { - let salt = self.generate_salt(); - let token = self.compute_token(&salt); - format!( - "{}/rest/{}?u={}&t={}&s={}&v={}&c={}&f=json", - self.base_url, view, self.username, token, salt, API_VERSION, CLIENT_NAME, - ) - } - - /// Append a key=value query parameter to an existing URL string. - fn append_param(url: &str, key: &str, value: &str) -> String { - format!("{}&{}={}", url, key, value) - } - - /// Generate a random 12-character alphanumeric salt. - fn generate_salt(&self) -> String { - const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789"; - let mut rng = rand::rng(); - (0..12) - .map(|_| { - let idx = rng.random_range(0..CHARSET.len()); - CHARSET[idx] as char - }) - .collect() - } - - /// Compute the MD5 token: `lower_hex(md5(password + salt))`. - fn compute_token(&self, salt: &str) -> String { - let input = format!("{}{}", self.password, salt); - let mut hasher = Md5::new(); - hasher.update(input.as_bytes()); - let result = hasher.finalize(); - result.iter().map(|b| format!("{:02x}", b)).collect() - } - - /// Fetch a JSON response from the given endpoint URL and deserialize it. - /// - /// Returns an error if the HTTP request fails, the JSON cannot be parsed, - /// or the `subsonic-response.status` field is `"failed"`. - async fn fetch(&self, url: &str) -> Result { - let body = self - .http - .get(url) - .send() - .await - .context("HTTP request to Subsonic failed")? - .error_for_status() - .context("Subsonic server returned an HTTP error")? - .text() - .await - .context("Failed to read Subsonic response body")?; - - let envelope: SubsonicEnvelope = - serde_json::from_str(&body).context("Failed to deserialize Subsonic response")?; - - let resp = envelope.response; - if resp.status != "ok" { - let msg = resp - .error - .as_ref() - .map(|e| format!("code={} {}", e.code, e.message)) - .unwrap_or_else(|| "unknown error".to_string()); - return Err(anyhow!("Subsonic API error: {}", msg)); - } - - Ok(resp) - } - - /// Verify server connectivity. Returns `Ok(())` if the server responds - /// with `status="ok"` to a `ping` request. - pub async fn ping(&self) -> Result<()> { - let url = self.endpoint_url("ping.view"); - self.fetch(&url).await?; - Ok(()) - } - - /// The authenticated `stream.view` URL for a track id. The server transcodes - /// to a playable container (MP3 by default); the response carries an audio - /// content-type and supports HTTP range requests. - pub fn stream_url(&self, track_id: &str) -> String { - Self::append_param( - &self.endpoint_url("stream.view"), - "id", - &url_encode(track_id), - ) - } - - /// The authenticated `getCoverArt.view` URL for a cover-art id, suitable for a - /// direct image download. Mirrors `stream_url`'s auth (token+salt embedded). - pub fn cover_art_url(&self, cover_art_id: &str) -> String { - Self::append_param( - &self.endpoint_url("getCoverArt.view"), - "id", - &url_encode(cover_art_id), - ) - } - - /// Download a track's audio to `dest`, streaming the response body to disk - /// chunk-by-chunk; the file is then played from disk by the shared player. - /// - /// Streaming (rather than buffering the whole body with `.bytes()`) avoids a - /// multi-hundred-MB RAM spike per track change for large lossless FLAC or - /// audiobook files — the server default is the original file. The client's - /// overall `.timeout()` still bounds the whole download. - /// - /// Async (reqwest), so it must be awaited **without** holding `App`'s lock. - pub async fn download_track(&self, track_id: &str, dest: &std::path::Path) -> Result<()> { - let url = self.stream_url(track_id); - let response = self - .http - .get(&url) - .send() - .await - .context("HTTP request to Subsonic stream failed")? - .error_for_status() - .context("Subsonic stream returned an HTTP error")?; - - // Write to a synchronous file inside the streaming loop: each chunk is a - // small `Bytes` and rodio reads the finished file from disk, so a blocking - // write here is cheap and keeps peak memory to one chunk rather than the - // whole track. - let mut file = std::fs::File::create(dest) - .with_context(|| format!("creating stream file {}", dest.display()))?; - let mut stream = response.bytes_stream(); - while let Some(chunk) = stream.next().await { - let chunk = chunk.context("Failed to read Subsonic stream body")?; - file - .write_all(&chunk) - .with_context(|| format!("writing stream to {}", dest.display()))?; - } - file - .flush() - .with_context(|| format!("flushing stream to {}", dest.display()))?; - Ok(()) - } -} - -/// Strip the `subsonic:track:` prefix and return the raw track id. -pub fn track_id_from_uri(uri: &str) -> Result<&str> { - uri - .strip_prefix(TRACK_PREFIX) - .ok_or_else(|| anyhow!("Not a subsonic track URI: {}", uri)) -} - -// --------------------------------------------------------------------------- -// Domain type conversions -// --------------------------------------------------------------------------- - -/// Strip `subsonic:playlist:` prefix and return the raw numeric id. -fn playlist_id_from_uri(uri: &str) -> Result<&str> { - uri - .strip_prefix(PLAYLIST_PREFIX) - .ok_or_else(|| anyhow!("Not a subsonic playlist URI: {}", uri)) -} - -impl From<&types::SubsonicPlaylist> for PlaylistInfo { - fn from(p: &types::SubsonicPlaylist) -> Self { - PlaylistInfo { - uri: format!("{}{}", PLAYLIST_PREFIX, p.id), - name: p.name.clone(), - owner: p.owner.clone(), - track_count: p.song_count, - id: Some(p.id.clone()), - owner_id: None, // Subsonic has no separate base62 user id; `owner` is the username - collaborative: false, - public: p.public, - image_url: None, // Subsonic uses cover_art IDs, not direct URLs - } - } -} - -impl From<&types::PlaylistDetail> for PlaylistInfo { - fn from(p: &types::PlaylistDetail) -> Self { - PlaylistInfo { - uri: format!("{}{}", PLAYLIST_PREFIX, p.id), - name: p.name.clone(), - owner: p.owner.clone(), - track_count: p.song_count, - id: Some(p.id.clone()), - owner_id: None, - collaborative: false, - public: p.public, - image_url: None, - } - } -} - -impl SubsonicSource { - /// Map a Subsonic song onto the shared [`TrackInfo`]. A method (not a free - /// function) so it can mint an authenticated `getCoverArt.view` URL from the - /// song's `coverArt` id via `self`. - fn song_to_track_info(&self, s: &types::SubsonicSong) -> TrackInfo { - let artist_name = s.artist.clone().unwrap_or_default(); - let artist_ref = if !artist_name.is_empty() { - vec![ArtistRef { - id: s.artist_id.clone(), - name: artist_name.clone(), - }] - } else { - vec![] - }; - - TrackInfo { - uri: Some(format!("{}{}", TRACK_PREFIX, s.id)), - name: s.title.clone(), - artists: if artist_name.is_empty() { - vec![] - } else { - vec![artist_name] - }, - album: s.album.clone().unwrap_or_default(), - // Subsonic reports duration in seconds; convert to ms for our domain type. - duration_ms: s.duration.unwrap_or(0) * 1000, - id: Some(s.id.clone()), - album_id: s.album_id.clone(), - artist_refs: artist_ref, - is_playable: true, - is_local: false, - track_number: s.track_number.unwrap_or(0), - explicit: false, - image_url: s.cover_art.as_deref().map(|id| self.cover_art_url(id)), - } - } -} - -fn album_to_album_info(a: &types::SubsonicAlbum) -> AlbumInfo { - let artists = a - .artist - .as_deref() - .filter(|n| !n.is_empty()) - .map(|name| { - vec![ArtistRef { - id: a.artist_id.clone(), - name: name.to_string(), - }] - }) - .unwrap_or_default(); - - AlbumInfo { - id: Some(a.id.clone()), - uri: Some(format!("subsonic:album:{}", a.id)), - name: a.name.clone(), - artists, - album_type: Some("album".to_string()), - release_date: a.year.map(|y| y.to_string()), - total_tracks: a.song_count, - image_url: None, - tracks: vec![], - } -} - -fn artist_to_artist_info(a: &types::SubsonicArtist) -> ArtistInfo { - ArtistInfo { - id: Some(a.id.clone()), - uri: Some(format!("subsonic:artist:{}", a.id)), - name: a.name.clone(), - image_url: None, - } -} - -// --------------------------------------------------------------------------- -// Trait implementations -// --------------------------------------------------------------------------- - -impl MediaSource for SubsonicSource { - fn name(&self) -> &str { - "Subsonic" - } - - fn scheme(&self) -> &str { - "subsonic" - } - - async fn playlists(&self) -> Result> { - let url = self.endpoint_url("getPlaylists.view"); - let resp = self.fetch(&url).await?; - - let playlists = resp.playlists.and_then(|w| w.playlist).unwrap_or_default(); - - Ok(playlists.iter().map(PlaylistInfo::from).collect()) - } - - async fn tracks(&self, playlist_uri: &str) -> Result> { - let id = playlist_id_from_uri(playlist_uri)?; - let url = Self::append_param(&self.endpoint_url("getPlaylist.view"), "id", id); - let resp = self.fetch(&url).await?; - - let detail = resp - .playlist - .ok_or_else(|| anyhow!("No playlist in getPlaylist response"))?; - - Ok( - detail - .entry - .iter() - .map(|s| self.song_to_track_info(s)) - .collect(), - ) - } -} - -impl Searcher for SubsonicSource { - async fn search(&self, query: &str) -> Result { - let encoded = url_encode(query); - let base = Self::append_param(&self.endpoint_url("search3.view"), "query", &encoded); - // Request a reasonable page size; the caller can paginate separately if needed. - let url = format!("{}&songCount=20&albumCount=10&artistCount=10", base); - - let resp = self.fetch(&url).await?; - - let sr = resp.search_result3.unwrap_or_default(); - Ok(SearchResults { - tracks: sr.song.iter().map(|s| self.song_to_track_info(s)).collect(), - albums: sr.album.iter().map(album_to_album_info).collect(), - artists: sr.artist.iter().map(artist_to_artist_info).collect(), - playlists: vec![], - shows: vec![], - }) - } -} - -// --------------------------------------------------------------------------- -// Minimal URL encoding for query strings -// --------------------------------------------------------------------------- - -/// Percent-encode characters that are unsafe in a query parameter value. -/// Only encodes the characters that will break the Subsonic query string; -/// avoids pulling in an extra URL-encoding crate given the narrow use case. -fn url_encode(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - for b in s.bytes() { - match b { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - out.push(b as char); - } - b' ' => out.push('+'), - other => { - out.push('%'); - out.push_str(&format!("{:02X}", other)); - } - } - } - out -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - use crate::infra::subsonic::types::SubsonicEnvelope; - - /// Live end-to-end smoke test against the public Navidrome demo server. - /// Ignored by default (hits the network); run with: - /// `cargo test --features subsonic -- --ignored live_demo` - #[tokio::test] - #[ignore = "hits the live Navidrome demo server"] - async fn live_demo_browse_and_search() { - let source = SubsonicSource::new("https://demo.navidrome.org", "demo", "demo"); - source.ping().await.expect("ping should succeed"); - - let playlists = source.playlists().await.expect("playlists should load"); - assert!(!playlists.is_empty(), "demo server should have playlists"); - - let tracks = source - .tracks(&playlists[0].uri) - .await - .expect("playlist tracks should load"); - assert!(!tracks.is_empty(), "first playlist should have tracks"); - // Every track must carry a `subsonic:track:` URI so playback can route it. - assert!(tracks.iter().all(|t| t - .uri - .as_deref() - .is_some_and(|u| u.starts_with(TRACK_PREFIX)))); - - let results = source.search("love").await.expect("search should succeed"); - assert!( - !results.tracks.is_empty(), - "search for 'love' should return tracks" - ); - } - - /// Live download smoke test: stream a real demo track to a tempfile and check - /// it is non-empty with a plausible audio header. Catches auth/URL-encoding - /// bugs in `stream_url`/`download_track` that the JSON tests can't. Ignored by - /// default (network); run with: - /// `cargo test --features subsonic -- --ignored live_demo_download` - #[tokio::test] - #[ignore = "hits the live Navidrome demo server"] - async fn live_demo_download_streams_audio() { - let source = SubsonicSource::new("https://demo.navidrome.org", "demo", "demo"); - let playlists = source.playlists().await.expect("playlists should load"); - let tracks = source - .tracks(&playlists[0].uri) - .await - .expect("tracks should load"); - let uri = tracks[0].uri.as_deref().expect("track should have a uri"); - let track_id = track_id_from_uri(uri).expect("uri should be a subsonic track uri"); - - let tmp = tempfile::NamedTempFile::new().unwrap(); - source - .download_track(track_id, tmp.path()) - .await - .expect("download should succeed"); - - let bytes = std::fs::read(tmp.path()).unwrap(); - assert!(bytes.len() > 4096, "downloaded audio should be non-trivial"); - // Navidrome transcodes the demo library to MP3 by default: either an ID3 tag - // ("ID3") or a raw MPEG frame sync (0xFF, 0xEx/0xFx). - let is_mp3 = bytes.starts_with(b"ID3") || (bytes[0] == 0xFF && (bytes[1] & 0xE0) == 0xE0); - assert!( - is_mp3, - "expected an MP3 header, got bytes {:02X?}", - &bytes[..4] - ); - } - - /// Full streaming-playback path: download a real demo track and play it through - /// the shared [`LocalPlayer`], asserting the sink actually advances. Verifies - /// download -> decode -> audio sink together. Ignored (needs network **and** an - /// audio output device); run with: - /// `cargo test --features subsonic -- --ignored live_demo_stream_plays` - #[tokio::test] - #[ignore = "hits the live demo server AND requires an audio output device"] - async fn live_demo_stream_plays_through_sink() { - use crate::infra::audio::LocalPlayer; - use std::time::Duration; - - let source = SubsonicSource::new("https://demo.navidrome.org", "demo", "demo"); - let playlists = source.playlists().await.expect("playlists should load"); - let tracks = source - .tracks(&playlists[0].uri) - .await - .expect("tracks should load"); - let uri = tracks[0].uri.as_deref().expect("track should have a uri"); - let track_id = track_id_from_uri(uri).expect("uri should be a subsonic track uri"); - - let tmp = tempfile::NamedTempFile::new().unwrap(); - source - .download_track(track_id, tmp.path()) - .await - .expect("download should succeed"); - - let player = LocalPlayer::new().expect("open default output device"); - player.play_file(tmp.path()).expect("play streamed track"); - assert!(!player.is_paused(), "should be playing after play_file"); - assert!( - !player.is_finished(), - "a freshly started track should not be finished" - ); - - tokio::time::sleep(Duration::from_millis(600)).await; - assert!( - player.position() >= Duration::from_millis(200), - "playback position should advance, got {:?}", - player.position() - ); - - player.stop(); - } - - // Inline JSON fixtures — representative Subsonic REST API responses. - - const PING_OK: &str = r#" - { - "subsonic-response": { - "status": "ok", - "version": "1.16.1" - } - }"#; - - const PING_FAILED: &str = r#" - { - "subsonic-response": { - "status": "failed", - "version": "1.16.1", - "error": { "code": 40, "message": "Wrong username or password." } - } - }"#; - - const GET_PLAYLISTS: &str = r#" - { - "subsonic-response": { - "status": "ok", - "version": "1.16.1", - "playlists": { - "playlist": [ - { "id": "1", "name": "Chill Mix", "owner": "alice", "songCount": 12, "public": true }, - { "id": "2", "name": "Workout", "owner": "alice", "songCount": 34, "public": false } - ] - } - } - }"#; - - const GET_PLAYLISTS_EMPTY: &str = r#" - { - "subsonic-response": { - "status": "ok", - "version": "1.16.1", - "playlists": {} - } - }"#; - - const GET_PLAYLIST: &str = r#" - { - "subsonic-response": { - "status": "ok", - "version": "1.16.1", - "playlist": { - "id": "1", - "name": "Chill Mix", - "owner": "alice", - "songCount": 2, - "public": true, - "entry": [ - { - "id": "101", - "title": "Weightless", - "artist": "Marconi Union", - "artistId": "art1", - "album": "Weightless", - "albumId": "alb1", - "duration": 469, - "trackNumber": 1 - }, - { - "id": "102", - "title": "Clair de Lune", - "artist": "Claude Debussy", - "artistId": "art2", - "album": "Suite bergamasque", - "albumId": "alb2", - "duration": 328, - "trackNumber": 1 - } - ] - } - } - }"#; - - const SEARCH3: &str = r#" - { - "subsonic-response": { - "status": "ok", - "version": "1.16.1", - "searchResult3": { - "song": [ - { - "id": "201", - "title": "Yesterday", - "artist": "The Beatles", - "artistId": "art10", - "album": "Help!", - "albumId": "alb10", - "duration": 125, - "trackNumber": 13 - } - ], - "album": [ - { - "id": "alb10", - "name": "Help!", - "artist": "The Beatles", - "artistId": "art10", - "songCount": 14, - "year": 1965 - } - ], - "artist": [ - { - "id": "art10", - "name": "The Beatles" - } - ] - } - } - }"#; - - // ------------------------------------------------------------------------- - // JSON parsing tests - // ------------------------------------------------------------------------- - - #[test] - fn parse_ping_ok() { - let env: SubsonicEnvelope = serde_json::from_str(PING_OK).unwrap(); - assert_eq!(env.response.status, "ok"); - assert_eq!(env.response.version, "1.16.1"); - } - - #[test] - fn parse_ping_failed_has_error() { - let env: SubsonicEnvelope = serde_json::from_str(PING_FAILED).unwrap(); - assert_eq!(env.response.status, "failed"); - let err = env.response.error.unwrap(); - assert_eq!(err.code, 40); - assert!(err.message.contains("Wrong username")); - } - - #[test] - fn parse_playlists_maps_to_domain() { - let env: SubsonicEnvelope = serde_json::from_str(GET_PLAYLISTS).unwrap(); - let raw = env.response.playlists.unwrap().playlist.unwrap(); - assert_eq!(raw.len(), 2); - - let info: PlaylistInfo = PlaylistInfo::from(&raw[0]); - assert_eq!(info.uri, "subsonic:playlist:1"); - assert_eq!(info.name, "Chill Mix"); - assert_eq!(info.owner, "alice"); - assert_eq!(info.track_count, 12); - assert_eq!(info.id.as_deref(), Some("1")); - assert_eq!(info.public, Some(true)); - } - - #[test] - fn parse_playlists_empty_playlist_field() { - let env: SubsonicEnvelope = serde_json::from_str(GET_PLAYLISTS_EMPTY).unwrap(); - let wrapper = env.response.playlists.unwrap(); - assert!(wrapper.playlist.is_none()); - } - - #[test] - fn parse_playlist_tracks_maps_to_domain() { - let env: SubsonicEnvelope = serde_json::from_str(GET_PLAYLIST).unwrap(); - let detail = env.response.playlist.unwrap(); - assert_eq!(detail.entry.len(), 2); - - let src = SubsonicSource::new("http://localhost", "user", "sesame"); - let track = src.song_to_track_info(&detail.entry[0]); - assert_eq!(track.uri.as_deref(), Some("subsonic:track:101")); - assert_eq!(track.name, "Weightless"); - assert_eq!(track.artists, vec!["Marconi Union"]); - assert_eq!(track.album, "Weightless"); - // duration 469 seconds * 1000 = 469 000 ms - assert_eq!(track.duration_ms, 469_000); - assert_eq!(track.track_number, 1); - assert_eq!(track.id.as_deref(), Some("101")); - assert_eq!(track.album_id.as_deref(), Some("alb1")); - assert_eq!(track.artist_refs.len(), 1); - assert_eq!(track.artist_refs[0].name, "Marconi Union"); - assert_eq!(track.artist_refs[0].id.as_deref(), Some("art1")); - assert!(track.is_playable); - assert!(!track.is_local); - assert!(!track.explicit); - } - - #[test] - fn parse_search3_maps_all_result_types() { - let env: SubsonicEnvelope = serde_json::from_str(SEARCH3).unwrap(); - let sr = env.response.search_result3.unwrap(); - - // Tracks - assert_eq!(sr.song.len(), 1); - let src = SubsonicSource::new("http://localhost", "user", "sesame"); - let track = src.song_to_track_info(&sr.song[0]); - assert_eq!(track.uri.as_deref(), Some("subsonic:track:201")); - assert_eq!(track.name, "Yesterday"); - assert_eq!(track.duration_ms, 125_000); - assert_eq!(track.track_number, 13); - - // Albums - assert_eq!(sr.album.len(), 1); - let album = album_to_album_info(&sr.album[0]); - assert_eq!(album.id.as_deref(), Some("alb10")); - assert_eq!(album.uri.as_deref(), Some("subsonic:album:alb10")); - assert_eq!(album.name, "Help!"); - assert_eq!(album.total_tracks, Some(14)); - assert_eq!(album.release_date.as_deref(), Some("1965")); - assert_eq!(album.artists.len(), 1); - assert_eq!(album.artists[0].name, "The Beatles"); - - // Artists - assert_eq!(sr.artist.len(), 1); - let artist = artist_to_artist_info(&sr.artist[0]); - assert_eq!(artist.id.as_deref(), Some("art10")); - assert_eq!(artist.uri.as_deref(), Some("subsonic:artist:art10")); - assert_eq!(artist.name, "The Beatles"); - } - - #[test] - fn playlist_id_from_uri_strips_prefix() { - assert_eq!(playlist_id_from_uri("subsonic:playlist:42").unwrap(), "42"); - } - - #[test] - fn playlist_id_from_uri_rejects_wrong_scheme() { - assert!(playlist_id_from_uri("spotify:playlist:xyz").is_err()); - } - - #[test] - fn compute_token_is_deterministic_for_same_inputs() { - let src = SubsonicSource::new("http://localhost", "user", "sesame"); - let t1 = src.compute_token("abc123"); - let t2 = src.compute_token("abc123"); - assert_eq!(t1, t2); - // MD5 of "sesameabc123" = 7f9bf1c85b45c4f27fb65cb3a9c9b2fc (verify manually) - // Presence of 32 lowercase hex chars is sufficient for the unit test. - assert_eq!(t1.len(), 32); - assert!(t1.chars().all(|c| c.is_ascii_hexdigit())); - } - - #[test] - fn compute_token_differs_per_salt() { - let src = SubsonicSource::new("http://localhost", "user", "sesame"); - let t1 = src.compute_token("salt1"); - let t2 = src.compute_token("salt2"); - assert_ne!(t1, t2); - } - - #[test] - fn url_encode_encodes_spaces_and_specials() { - assert_eq!(url_encode("hello world"), "hello+world"); - assert_eq!(url_encode("a&b=c"), "a%26b%3Dc"); - assert_eq!(url_encode("plain"), "plain"); - } - - #[test] - fn generate_salt_produces_12_char_alphanumeric() { - let src = SubsonicSource::new("http://localhost", "user", "pass"); - let salt = src.generate_salt(); - assert_eq!(salt.len(), 12); - assert!(salt.chars().all(|c| c.is_ascii_alphanumeric())); - } -} diff --git a/src/infra/subsonic/types.rs b/src/infra/subsonic/types.rs deleted file mode 100644 index 0590f970..00000000 --- a/src/infra/subsonic/types.rs +++ /dev/null @@ -1,157 +0,0 @@ -//! Serde types for the Subsonic / OpenSubsonic REST API JSON response envelope. -//! -//! Every endpoint wraps its payload in: -//! ```json -//! { "subsonic-response": { "status": "ok", "version": "1.16.1", "": { … } } } -//! ``` -//! -//! The structs here are **private** to the subsonic module; callers work with -//! the domain types in [`crate::core::plugin_api`]. - -use serde::Deserialize; - -// --------------------------------------------------------------------------- -// Top-level envelope -// --------------------------------------------------------------------------- - -/// Outer JSON wrapper: `{ "subsonic-response": }`. -#[derive(Debug, Deserialize)] -pub struct SubsonicEnvelope { - #[serde(rename = "subsonic-response")] - pub response: SubsonicResponse, -} - -/// The `subsonic-response` object. A `status` of `"ok"` means the request -/// succeeded; `"failed"` means the nested `error` field is populated. -#[derive(Debug, Deserialize)] -pub struct SubsonicResponse { - pub status: String, - pub version: String, - pub error: Option, - // Payload fields — only one is populated per response. - pub playlists: Option, - pub playlist: Option, - #[serde(rename = "searchResult3")] - pub search_result3: Option, -} - -#[derive(Debug, Deserialize)] -pub struct SubsonicError { - pub code: u32, - pub message: String, -} - -// --------------------------------------------------------------------------- -// getPlaylists -// --------------------------------------------------------------------------- - -/// `getPlaylists` → `playlists.playlist[]` -#[derive(Debug, Deserialize)] -pub struct PlaylistsWrapper { - pub playlist: Option>, -} - -#[derive(Debug, Deserialize)] -pub struct SubsonicPlaylist { - pub id: String, - pub name: String, - /// Display name of the playlist owner. - #[serde(default)] - pub owner: String, - #[serde(rename = "songCount", default)] - pub song_count: u32, - #[serde(default)] - pub public: Option, - #[serde(rename = "coverArt", default)] - pub cover_art: Option, -} - -// --------------------------------------------------------------------------- -// getPlaylist -// --------------------------------------------------------------------------- - -/// `getPlaylist` → `playlist` (single object with embedded `entry[]`). -#[derive(Debug, Deserialize)] -pub struct PlaylistDetail { - pub id: String, - pub name: String, - #[serde(default)] - pub owner: String, - #[serde(rename = "songCount", default)] - pub song_count: u32, - #[serde(default)] - pub public: Option, - #[serde(rename = "coverArt", default)] - pub cover_art: Option, - /// The actual tracks. Named `entry` in the Subsonic spec. - #[serde(default)] - pub entry: Vec, -} - -// --------------------------------------------------------------------------- -// search3 -// --------------------------------------------------------------------------- - -/// `search3` → `searchResult3` -#[derive(Debug, Default, Deserialize)] -pub struct SearchResult3 { - #[serde(default)] - pub song: Vec, - #[serde(default)] - pub album: Vec, - #[serde(default)] - pub artist: Vec, -} - -// --------------------------------------------------------------------------- -// Shared media item structs -// --------------------------------------------------------------------------- - -/// A single song/track as returned by Subsonic (`/rest/getPlaylist.view`, -/// `/rest/search3.view`, etc.). -#[derive(Debug, Deserialize)] -pub struct SubsonicSong { - pub id: String, - pub title: String, - #[serde(default)] - pub artist: Option, - #[serde(rename = "artistId", default)] - pub artist_id: Option, - #[serde(default)] - pub album: Option, - #[serde(rename = "albumId", default)] - pub album_id: Option, - /// Duration in seconds (Subsonic uses seconds, not milliseconds). - #[serde(default)] - pub duration: Option, - #[serde(rename = "trackNumber", default)] - pub track_number: Option, - #[serde(default)] - pub year: Option, - #[serde(rename = "coverArt", default)] - pub cover_art: Option, -} - -#[derive(Debug, Deserialize)] -pub struct SubsonicAlbum { - pub id: String, - pub name: String, - #[serde(default)] - pub artist: Option, - #[serde(rename = "artistId", default)] - pub artist_id: Option, - #[serde(rename = "songCount", default)] - pub song_count: Option, - #[serde(default)] - pub year: Option, - #[serde(rename = "coverArt", default)] - pub cover_art: Option, -} - -#[derive(Debug, Deserialize)] -pub struct SubsonicArtist { - pub id: String, - pub name: String, - #[serde(rename = "coverArt", default)] - pub cover_art: Option, -} diff --git a/src/infra/youtube/dispatch.rs b/src/infra/youtube/dispatch.rs deleted file mode 100644 index 304c10fd..00000000 --- a/src/infra/youtube/dispatch.rs +++ /dev/null @@ -1,1053 +0,0 @@ -//! YouTube search/playback routing. -//! -//! [`route_youtube_event`] is called from the runtime IoEvent pump *after* the -//! local/Subsonic/radio dispatches and *before* `handle_network_event`. When an -//! event targets the YouTube source (a search request, or a `youtube:` playback -//! URI) it is handled here and consumed; otherwise it falls through to the -//! normal Spotify dispatch. -//! -//! ## Decoupling & device ownership -//! -//! YouTube playback owns a single piece of state, [`App::youtube_playback`], -//! and never writes Spotify/librespot fields — the playbar reads progress/pause -//! live from the player. Only one backend holds the audio device at a time: -//! starting YouTube pauses librespot **and** tears down any local/Subsonic/ -//! radio session; the reciprocal teardowns live in those sources' start paths. -//! -//! ## Streaming -//! -//! Each video's audio is downloaded by `yt-dlp` to a tempfile (off the `App` -//! lock — this is the slowest download window of any source), then played from -//! disk through the shared [`LocalPlayer`]. Like Subsonic, a download failure -//! tears the session down rather than skipping past, so a broken extractor or -//! dead network can't cascade error toasts across the whole queue. - -use std::sync::Arc; -use std::time::Duration; - -use anyhow::{Context, Result}; -use tempfile::NamedTempFile; -use tokio::sync::Mutex; - -use super::{is_youtube_uri, video_id_from_uri, YouTubePlaybackState, YouTubeSource}; -use crate::core::app::App; -use crate::core::plugin_api::TrackInfo; -use crate::core::source::{Searcher, Source}; -use crate::infra::audio::LocalPlayer; -use crate::infra::network::IoEvent; -use crate::infra::queue::{advance_index, replay_file, snapshot_tracks}; - -/// Skip direction within the YouTube queue. -#[derive(Clone, Copy)] -enum Direction { - Next, - Prev, -} - -/// Intercept events that target the YouTube source. -/// -/// Returns `true` if the event was handled (and must **not** be forwarded to -/// the Spotify network), `false` to let the normal dispatch run. -pub async fn route_youtube_event(app: &Arc>, event: &IoEvent) -> bool { - match event { - IoEvent::GetYouTubeSearchResults(query) => { - run_youtube_search(app, query).await; - true - } - IoEvent::GetYouTubePlaylists => { - load_playlists(app).await; - true - } - IoEvent::GetYouTubeTracks(uri) => { - load_playlist_tracks(app, uri).await; - true - } - IoEvent::CreateYouTubePlaylist(name) => { - create_playlist(app, name).await; - true - } - IoEvent::DeleteYouTubePlaylist(uri) => { - delete_playlist(app, uri).await; - true - } - IoEvent::AddTrackToYouTubePlaylist(playlist_ref, video_ref) => { - add_track_to_playlist(app, playlist_ref, video_ref).await; - true - } - IoEvent::RemoveTrackFromYouTubePlaylist(playlist_ref, video_ref) => { - remove_track_from_playlist(app, playlist_ref, video_ref).await; - true - } - // Start a queue of youtube videos: queue all and start at the offset. - IoEvent::StartPlayback(None, Some(uris), offset) - if uris.first().is_some_and(|u| is_youtube_uri(u)) => - { - start_youtube_queue(app, uris, offset.unwrap_or(0)).await; - true - } - // A single youtube video with no surrounding list: a one-track queue. - IoEvent::StartPlayback(Some(uri), _, _) if is_youtube_uri(uri) => { - start_youtube_queue(app, std::slice::from_ref(uri), 0).await; - true - } - // Bare "resume current" — ours only while YouTube owns the session. - IoEvent::StartPlayback(None, None, None) => match player(app).await { - Some(p) => { - p.resume(); - true - } - None => false, - }, - // Any other start is a local/Subsonic/radio/Spotify play: relinquish the - // device, then let the normal dispatch run. - IoEvent::StartPlayback(..) => { - teardown_youtube(app).await; - false - } - IoEvent::PausePlayback => match player(app).await { - Some(p) => { - p.pause(); - true - } - None => false, - }, - IoEvent::Seek(position_ms) => match player(app).await { - Some(p) => { - let _ = p.seek(Duration::from_millis(*position_ms as u64)); - true - } - None => false, - }, - IoEvent::ChangeVolume(volume) => match player(app).await { - Some(p) => { - p.set_volume(*volume); - let mut app = app.lock().await; - app.runtime_state.volume_percent = *volume; - app.schedule_state_save(crate::core::state::PersistedRuntimeState::volume_percent( - *volume, - )); - true - } - None => false, - }, - IoEvent::NextTrack => skip(app, Direction::Next).await, - IoEvent::PreviousTrack | IoEvent::ForcePreviousTrack => skip(app, Direction::Prev).await, - // Repeat-one auto-advance: replay the current video (dispatched by the runner - // tick only while YouTube owns the session). - IoEvent::ReplayCurrentTrack => replay_current(app).await, - // Explicit repeat-mode set (e.g. a plugin's `set_repeat`): store it as the - // decoded repeat mode while a queueable decoded source owns playback (the - // method's ownership gate excludes a context suspended under the native - // queue), so it never reaches the Spotify context. - IoEvent::Repeat(state) => app.lock().await.set_decoded_repeat_from_state(*state), - _ => false, - } -} - -// --------------------------------------------------------------------------- -// Search -// --------------------------------------------------------------------------- - -/// Build a [`YouTubeSource`] from the user config (`behavior.ytdlp_path` -/// override, else `yt-dlp` on `$PATH`). Unlike Subsonic there is nothing that -/// can be "unconfigured" — a missing binary surfaces as an actionable error -/// from the first search instead. -pub(crate) async fn build_source(app: &Arc>) -> YouTubeSource { - let ytdlp_path = app.lock().await.user_config.behavior.ytdlp_path.clone(); - YouTubeSource::new(ytdlp_path) -} - -/// Run a YouTube search and populate `app.search_results`. Like the Subsonic -/// M2 search, only the songs block is populated — each row is a video; Enter -/// dispatches the `youtube:` URIs back through this module. -async fn run_youtube_search(app: &Arc>, query: &str) { - let source = build_source(app).await; - match source.search(query).await { - Ok(results) => app.lock().await.show_source_search_tracks(results.tracks), - Err(e) => set_error(app, format!("YouTube search failed: {e}")).await, - } -} - -// --------------------------------------------------------------------------- -// Local playlists (youtube_playlists.yml) -// --------------------------------------------------------------------------- - -/// Run a load→mutate→save transaction against the playlists file on the -/// blocking pool (never on the async executor, and never under the `App` -/// lock). Returns whatever the mutation returns. -async fn with_playlists_file(mutate: F) -> Result -where - T: Send + 'static, - F: FnOnce(&mut super::playlists::PlaylistsFile) -> Result + Send + 'static, -{ - tokio::task::spawn_blocking(move || { - let path = super::playlists::default_playlists_path()?; - let mut file = super::playlists::load(&path)?; - let out = mutate(&mut file)?; - super::playlists::save(&path, &file)?; - Ok(out) - }) - .await - .context("YouTube playlists task failed")? -} - -/// Read-only load of the playlists file on the blocking pool. -async fn read_playlists_file() -> Result { - tokio::task::spawn_blocking(|| { - let path = super::playlists::default_playlists_path()?; - super::playlists::load(&path) - }) - .await - .context("YouTube playlists task failed")? -} - -/// Refresh the sidebar list in `App` from an already-loaded file. -async fn publish_playlists(app: &Arc>, file: &super::playlists::PlaylistsFile) { - let infos: Vec<_> = file - .playlists - .iter() - .map(super::playlists::playlist_to_info) - .collect(); - app.lock().await.youtube_playlists = infos; -} - -/// Load `youtube_playlists.yml` into the sidebar. -async fn load_playlists(app: &Arc>) { - match read_playlists_file().await { - Ok(file) => publish_playlists(app, &file).await, - Err(e) => set_error(app, format!("Cannot load YouTube playlists: {e}")).await, - } -} - -/// Open a playlist's saved tracks in the shared track table. -async fn load_playlist_tracks(app: &Arc>, uri: &str) { - let file = match read_playlists_file().await { - Ok(f) => f, - Err(e) => { - set_error(app, format!("Cannot load YouTube playlists: {e}")).await; - return; - } - }; - let Some(playlist) = super::playlists::find_playlist(&file, uri) else { - set_error(app, "That YouTube playlist no longer exists".to_string()).await; - return; - }; - let rows: Vec = playlist - .tracks - .iter() - .map(super::playlists::stored_to_track_info) - .collect(); - let mut guard = app.lock().await; - guard.set_track_table(rows, crate::core::app::TrackTableContext::YouTubePlaylist); - guard.youtube_open_playlist = Some(uri.to_string()); -} - -/// Create a playlist and refresh the sidebar. -async fn create_playlist(app: &Arc>, name: &str) { - let name_owned = name.to_string(); - let result = with_playlists_file(move |file| { - super::playlists::create_playlist(file, &name_owned)?; - Ok(file.clone()) - }) - .await; - match result { - Ok(file) => { - publish_playlists(app, &file).await; - let mut guard = app.lock().await; - guard.set_status_message(format!("Created YouTube playlist \"{}\"", name.trim()), 4); - } - Err(e) => set_error(app, format!("Cannot create YouTube playlist: {e}")).await, - } -} - -/// Delete a playlist and refresh the sidebar. -async fn delete_playlist(app: &Arc>, uri: &str) { - let uri_owned = uri.to_string(); - let result = with_playlists_file(move |file| { - let deleted = super::playlists::delete_playlist(file, &uri_owned)?; - Ok((deleted.name, file.clone())) - }) - .await; - match result { - Ok((name, file)) => { - publish_playlists(app, &file).await; - let mut guard = app.lock().await; - // The deleted playlist may be open in the track table; drop the marker so - // the remove-track flow can't edit a ghost. - if guard.youtube_open_playlist.as_deref() == Some(uri) { - guard.youtube_open_playlist = None; - } - guard.set_status_message(format!("Deleted YouTube playlist \"{name}\""), 4); - } - Err(e) => set_error(app, format!("Cannot delete YouTube playlist: {e}")).await, - } -} - -/// Find a video's browse row by bare id or `youtube:` URI, in the search -/// results or the track table (whichever view the add originated from). -fn find_video_row(app: &App, video_ref: &str) -> Option { - let uri = super::uri_for_video_id(video_ref); - let matches = |t: &&TrackInfo| { - t.id.as_deref() == Some(video_ref) - || t.uri.as_deref() == Some(video_ref) - || t.uri.as_deref() == Some(uri.as_str()) - }; - app - .search_results - .tracks - .as_ref() - .and_then(|p| p.items.iter().find(matches)) - .or_else(|| app.track_table.tracks.iter().find(matches)) - .cloned() -} - -/// Add a video to a playlist, resolving its metadata from the browse views. -async fn add_track_to_playlist(app: &Arc>, playlist_ref: &str, video_ref: &str) { - let row = { - let guard = app.lock().await; - find_video_row(&guard, video_ref) - }; - let Some(row) = row else { - set_error(app, "Cannot resolve that video's metadata".to_string()).await; - return; - }; - let stored = match super::playlists::track_info_to_stored(&row) { - Ok(s) => s, - Err(e) => { - set_error(app, format!("Cannot add video to playlist: {e}")).await; - return; - } - }; - - let playlist_owned = playlist_ref.to_string(); - let result = with_playlists_file(move |file| { - let added = super::playlists::add_track(file, &playlist_owned, stored)?; - let name = super::playlists::find_playlist(file, &playlist_owned) - .map(|p| p.name.clone()) - .unwrap_or_default(); - Ok((added, name, file.clone())) - }) - .await; - - match result { - Ok((added, playlist_name, file)) => { - publish_playlists(app, &file).await; - let msg = if added { - format!("Added \"{}\" to {playlist_name}", row.name) - } else { - format!("\"{}\" is already in {playlist_name}", row.name) - }; - app.lock().await.set_status_message(msg, 4); - } - Err(e) => set_error(app, format!("Cannot add video to playlist: {e}")).await, - } -} - -/// Remove a video from a playlist; refreshes the open track table when it -/// shows that playlist. -async fn remove_track_from_playlist(app: &Arc>, playlist_ref: &str, video_ref: &str) { - // The remove dialog passes the bare video id; normalize from a URI too. - let video_id = super::video_id_from_uri(video_ref) - .map(str::to_owned) - .unwrap_or_else(|_| video_ref.to_string()); - - let playlist_owned = playlist_ref.to_string(); - let result = with_playlists_file(move |file| { - let removed = super::playlists::remove_track(file, &playlist_owned, &video_id)?; - let remaining: Vec<_> = super::playlists::find_playlist(file, &playlist_owned) - .map(|p| { - p.tracks - .iter() - .map(super::playlists::stored_to_track_info) - .collect() - }) - .unwrap_or_default(); - Ok((removed.title, remaining, file.clone())) - }) - .await; - - match result { - Ok((title, remaining, file)) => { - publish_playlists(app, &file).await; - let mut guard = app.lock().await; - // Keep the open track table in sync with the file. - if guard.youtube_open_playlist.as_deref() == Some(playlist_ref) { - guard.track_table.tracks = remaining; - guard.clamp_track_table_cursor(); - } - guard.set_status_message(format!("Removed \"{title}\" from playlist"), 4); - } - Err(e) => set_error(app, format!("Cannot remove video from playlist: {e}")).await, - } -} - -// --------------------------------------------------------------------------- -// Playback -// --------------------------------------------------------------------------- - -/// The live YouTube player, if a YouTube session is active. -async fn player(app: &Arc>) -> Option> { - app - .lock() - .await - .youtube_playback - .as_ref() - .map(|s| Arc::clone(&s.player)) -} - -/// Release the other backends so only YouTube holds the output device. -async fn release_other_backends(app: &Arc>) { - // Pause native Spotify so librespot releases the device. - #[cfg(feature = "streaming")] - { - let streaming = app.lock().await.streaming_player.clone(); - if let Some(player) = streaming { - player.pause(); - } - } - let players = app - .lock() - .await - .take_decoded_sessions_except(Source::YouTube); - for player in players { - player.stop_detached(); - } -} - -/// Reuse the live YouTube player, or open a fresh output device for one. A -/// freshly opened player is **not** published to `App` here — the caller -/// publishes the session only on a successful first play. -async fn acquire_player(app: &Arc>) -> Option> { - if let Some(p) = player(app).await { - return Some(p); - } - match tokio::task::spawn_blocking(LocalPlayer::new).await { - Ok(Ok(p)) => Some(Arc::new(p)), - Ok(Err(e)) => { - set_error(app, format!("No audio output for YouTube playback: {e}")).await; - None - } - Err(e) => { - set_error(app, format!("Audio output init failed: {e}")).await; - None - } - } -} - -/// Download a video's audio into a fresh tempfile. Must be awaited **without** -/// holding the `App` lock (this is the slowest download of any source). -async fn download_audio(source: &YouTubeSource, video_id: &str) -> Result { - let tmp = NamedTempFile::with_suffix(".m4a").context("creating temp file for YouTube audio")?; - source.download_audio(video_id, tmp.path()).await?; - Ok(tmp) -} - -/// Download the audio for `uri` into a tempfile, for the native queue engine's -/// off-pump fetch (playing is the queue's job — it re-checks that the queue -/// slot is still current before touching the shared player). -pub(crate) async fn download_for_queue(source: &YouTubeSource, uri: &str) -> Result { - let video_id = video_id_from_uri(uri)?.to_string(); - download_audio(source, &video_id).await -} - -/// Decode-failure hint: without ffmpeg on `$PATH`, yt-dlp leaves the download -/// as a fragmented DASH container some decoders reject. -fn decode_hint(e: impl std::fmt::Display) -> String { - format!("Cannot play YouTube audio: {e} (installing ffmpeg fixes most container issues)") -} - -/// Begin playing a queue of YouTube videos, taking over the session and -/// starting at `start_idx` (clamped into range). -async fn start_youtube_queue(app: &Arc>, uris: &[String], start_idx: usize) { - // Snapshot the track metadata under one short lock, from whichever browse - // view the request came from. - let tracks = { - let guard = app.lock().await; - let search = guard - .search_results - .tracks - .as_ref() - .map(|p| p.items.as_slice()); - snapshot_tracks(&guard.track_table.tracks, search, uris) - }; - if tracks.is_empty() { - set_error(app, "No YouTube videos to play".to_string()).await; - return; - } - let index = start_idx.min(tracks.len() - 1); - - // A livestream (duration 0 in the search row) never finishes downloading — - // the tempfile fetch would just spin until the timeout. Refuse up front. - if tracks[index].duration_ms == 0 { - set_error( - app, - "Live streams aren't supported on the YouTube source yet".to_string(), - ) - .await; - return; - } - - let video_id = match tracks[index].uri.as_deref().map(video_id_from_uri) { - Some(Ok(id)) => id.to_string(), - _ => { - set_error(app, "Invalid YouTube URI".to_string()).await; - return; - } - }; - - let source = Arc::new(build_source(app).await); - - // Only one backend owns the device at a time. - app.lock().await.claim_decoded_sink(Source::YouTube); - release_other_backends(app).await; - - let Some(player) = acquire_player(app).await else { - return; - }; - - // yt-dlp can take a while; let the user know something is happening. - { - let mut guard = app.lock().await; - guard.set_status_message(format!("Fetching {}\u{2026}", tracks[index].name), 30); - } - - // Download off the lock, then decode on the blocking pool. - let tmp = match download_audio(&source, &video_id).await { - Ok(t) => t, - Err(e) => { - set_error(app, format!("Cannot download YouTube audio: {e}")).await; - return; - } - }; - let path = tmp.path().to_path_buf(); - let decode_player = Arc::clone(&player); - let result = tokio::task::spawn_blocking(move || decode_player.play_file(&path)).await; - - match result { - Ok(Ok(())) => { - let volume = app.lock().await.runtime_state.volume_percent; - player.set_volume(volume); - - let display = tracks[index].name.clone(); - let mut guard = app.lock().await; - // Publish the session exactly once, now that the source is decoding. - let mut state = YouTubePlaybackState { - player, - source, - tracks, - index, - advancing: false, - tempfile: tmp, - shuffle_backup: None, - resume_at: None, - }; - // Honor the player-global decoded shuffle for the freshly-built queue. - if guard.decoded_shuffle { - state.set_shuffle(true); - } - guard.youtube_playback = Some(state); - guard.set_status_message(format!("\u{266a} {display}"), 4); - } - Ok(Err(e)) => set_error(app, decode_hint(e)).await, - Err(e) => set_error(app, format!("YouTube playback task failed: {e}")).await, - } -} - -/// Move the YouTube queue index in `direction` and play the new track. Returns -/// `true` if YouTube owns the session (so the event is consumed). -async fn skip(app: &Arc>, direction: Direction) -> bool { - let target = { - let mut guard = app.lock().await; - // Repeat-all wraps at the ends; Off/Repeat-one clamp. - let mode = guard.decoded_repeat; - let Some(s) = guard.youtube_playback.as_mut() else { - return false; // not ours - }; - // Guard the empty-sink download window from spurious auto-advance. - s.advancing = true; - let forward = matches!(direction, Direction::Next); - advance_index(s.index, s.tracks.len(), mode, forward) - }; - - match target { - Some(idx) => play_index(app, idx).await, - None => { - // Queue boundary: clear the optimistic guard so it doesn't wedge - // auto-advance off for the rest of the track. - if let Some(s) = app.lock().await.youtube_playback.as_mut() { - s.advancing = false; - } - } - } - true -} - -/// Replay the current YouTube video (repeat-one auto-advance) via -/// [`replay_file`]. Re-decodes the **already-downloaded tempfile** — no second -/// `yt-dlp` fetch — and on failure tears the session down (rather than leaving -/// an empty sink that would re-fire replay every tick). Returns `true` if -/// YouTube owns the session. -async fn replay_current(app: &Arc>) -> bool { - let (player, path, resume) = { - let mut guard = app.lock().await; - let Some(s) = guard.youtube_playback.as_mut() else { - return false; // not ours - }; - s.advancing = true; - ( - Arc::clone(&s.player), - s.tempfile.path().to_path_buf(), - s.resume_at - .take() - .or(Some(crate::infra::queue::ResumePoint { - position_ms: 0, - paused: s.player.is_paused(), - })), - ) - }; - if replay_file(player, path, resume).await { - if let Some(s) = app.lock().await.youtube_playback.as_mut() { - s.advancing = false; - } - } else { - teardown_youtube(app).await; - set_error(app, "Cannot replay YouTube track".to_string()).await; - } - true -} - -/// What the locked snapshot in [`play_index`] decided to do. -enum Plan { - Play(Arc, Arc, String, String), - OutOfRange, - BadUri, - /// The target row is a livestream (duration 0) — its download never ends. - Live, -} - -/// Play the queued track at `target`, reusing the published session. Used by -/// Next/Previous and the runner tick's auto-advance. -/// -/// A download failure tears the session down (same rationale as Subsonic): on -/// a network outage or a broken extractor, walking the whole queue at tick -/// speed would spray error toasts, so one failure ends playback instead. -pub(crate) async fn play_index(app: &Arc>, target: usize) { - let plan = { - let guard = app.lock().await; - match guard.youtube_playback.as_ref() { - None => return, // session torn down between dispatch and here - Some(s) => match s.tracks.get(target) { - None => Plan::OutOfRange, - Some(track) if track.duration_ms == 0 => Plan::Live, - Some(track) => match track.uri.as_deref().map(video_id_from_uri) { - Some(Ok(id)) => Plan::Play( - Arc::clone(&s.player), - Arc::clone(&s.source), - id.to_string(), - track.name.clone(), - ), - _ => Plan::BadUri, - }, - }, - } - }; - - let (player, source, video_id, name) = match plan { - Plan::Play(p, s, id, name) => (p, s, id, name), - Plan::OutOfRange => { - if let Some(s) = app.lock().await.youtube_playback.as_mut() { - s.advancing = false; - } - return; - } - Plan::BadUri => { - teardown_youtube(app).await; - set_error(app, "Invalid YouTube URI".to_string()).await; - return; - } - Plan::Live => { - // Same semantics as a download failure: end the session rather than - // leave an empty sink + unmoved index, which would make auto-advance - // re-dispatch onto the same livestream row every tick. - teardown_youtube(app).await; - set_error( - app, - "Live streams aren't supported on the YouTube source yet".to_string(), - ) - .await; - return; - } - }; - - { - let mut guard = app.lock().await; - guard.set_status_message(format!("Fetching {name}\u{2026}"), 30); - } - - let tmp = match download_audio(&source, &video_id).await { - Ok(t) => t, - Err(e) => { - teardown_youtube(app).await; - set_error(app, format!("Cannot download YouTube audio: {e}")).await; - return; - } - }; - let path = tmp.path().to_path_buf(); - // Taken after the download: a device recovery during it leaves the pause - // to apply here. - let resume = { - let mut guard = app.lock().await; - guard - .youtube_playback - .as_mut() - .and_then(|s| s.resume_at.take()) - }; - let decode_player = Arc::clone(&player); - let result = tokio::task::spawn_blocking(move || { - crate::infra::queue::restage(&decode_player, &path, resume) - }) - .await; - - match result { - Ok(Ok(())) => commit_index(app, target, tmp).await, - Ok(Err(e)) => { - teardown_youtube(app).await; - set_error(app, decode_hint(e)).await; - } - Err(e) => { - teardown_youtube(app).await; - set_error(app, format!("YouTube playback task failed: {e}")).await; - } - } -} - -/// Commit `target` as the live index, clear the auto-advance guard, and swap -/// in the new track's tempfile (dropping the previous one). Ordering is safe: -/// the blocking `play_file` already cleared the old source from the sink, so -/// rodio no longer holds the old file by the time it is dropped here. -async fn commit_index(app: &Arc>, target: usize, tmp: NamedTempFile) { - let mut guard = app.lock().await; - let display = if let Some(s) = guard.youtube_playback.as_mut() { - s.index = target; - s.advancing = false; - s.tempfile = tmp; - s.tracks.get(target).map(|t| t.name.clone()) - } else { - None - }; - if let Some(display) = display { - guard.set_status_message(format!("\u{266a} {display}"), 4); - } -} - -/// End the YouTube session, releasing the output device and cleaning up the -/// current tempfile. -pub async fn teardown_youtube(app: &Arc>) { - // Take under the lock, stop off it: a sink clear waits for the audio thread. - let session = app.lock().await.youtube_playback.take(); - if let Some(s) = session { - // Dropping `s` drops the tempfile (cleanup) and, if it held the last - // reference, the keepalive thread exits and the device is released. - Arc::clone(&s.player).stop_detached_holding(s); - } -} - -async fn set_error(app: &Arc>, message: String) { - app.lock().await.set_status_message(message, 6); -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::pagination::Paged; - use crate::core::user_config::UserConfig; - use std::sync::mpsc::channel; - use std::time::SystemTime; - - fn test_app() -> App { - let (tx, _rx) = channel(); - App::new(tx, UserConfig::new(), Some(SystemTime::now())) - } - - fn video(uri: &str, name: &str) -> TrackInfo { - TrackInfo { - uri: Some(uri.to_string()), - name: name.to_string(), - artists: vec!["Channel".to_string()], - album: "YouTube".to_string(), - duration_ms: 1000, - id: None, - album_id: None, - artist_refs: vec![], - is_playable: true, - is_local: false, - track_number: 0, - explicit: false, - image_url: None, - } - } - - /// Search/transport events that are not YouTube's must fall through. - #[tokio::test] - async fn foreign_events_fall_through_without_a_session() { - let app = Arc::new(Mutex::new(test_app())); - // No YouTube session: transport events are not ours. - assert!(!route_youtube_event(&app, &IoEvent::PausePlayback).await); - assert!(!route_youtube_event(&app, &IoEvent::NextTrack).await); - assert!(!route_youtube_event(&app, &IoEvent::Seek(1000)).await); - assert!(!route_youtube_event(&app, &IoEvent::StartPlayback(None, None, None)).await); - // A Spotify start falls through (and there is nothing to tear down). - assert!( - !route_youtube_event( - &app, - &IoEvent::StartPlayback(Some("spotify:track:x".to_string()), None, None) - ) - .await - ); - // Other sources' URIs are not ours either. - assert!( - !route_youtube_event( - &app, - &IoEvent::StartPlayback(Some("radio:https://x.example/s".to_string()), None, None) - ) - .await - ); - } - - /// A youtube: start with no matching browse row must be consumed (it is - /// ours) but publish nothing — and must not panic. - #[tokio::test] - async fn youtube_start_with_no_rows_is_consumed_without_session() { - let app = Arc::new(Mutex::new(test_app())); - assert!( - route_youtube_event( - &app, - &IoEvent::StartPlayback(None, Some(vec!["youtube:abc123".to_string()]), Some(0)) - ) - .await - ); - assert!(app.lock().await.youtube_playback.is_none()); - } - - /// Livestream rows (duration 0) are refused up front with a clear message — - /// their download never finishes, so starting one would hang "Fetching…" - /// until the timeout. - #[tokio::test] - async fn livestream_rows_are_refused_without_session() { - let app = Arc::new(Mutex::new(test_app())); - { - let mut guard = app.lock().await; - let mut live = video("youtube:live12345", "24/7 Lofi Radio"); - live.duration_ms = 0; - guard.search_results.tracks = Some(Paged { - items: vec![live], - total: 1, - ..Default::default() - }); - } - assert!( - route_youtube_event( - &app, - &IoEvent::StartPlayback(None, Some(vec!["youtube:live12345".to_string()]), Some(0)) - ) - .await, - "livestream start is ours (consumed)" - ); - let guard = app.lock().await; - assert!(guard.youtube_playback.is_none(), "no session published"); - assert!( - guard - .status_message() - .is_some_and(|m| m.contains("Live streams")), - "user gets an actionable message, got {:?}", - guard.status_message() - ); - } - - /// Full local-playlist lifecycle through the dispatch, exactly as the - /// runtime pump drives it: create → list → add (metadata resolved from a - /// fake search view) → open → duplicate-add no-op → remove → delete. No - /// network, no audio device — the file lives in a tempdir via the - /// `SPOTATUI_YOUTUBE_PLAYLISTS_PATH` override. Kept as ONE test because the - /// override is process-global env. - #[tokio::test] - async fn playlist_lifecycle_through_dispatch() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("yt_playlists.yml"); - std::env::set_var(super::super::playlists::PATH_ENV, &path); - - let app = Arc::new(Mutex::new(test_app())); - - // Empty file → empty sidebar list. - assert!(route_youtube_event(&app, &IoEvent::GetYouTubePlaylists).await); - assert!(app.lock().await.youtube_playlists.is_empty()); - - // Create a playlist; the sidebar refreshes. - assert!(route_youtube_event(&app, &IoEvent::CreateYouTubePlaylist("Focus".to_string())).await); - let playlist_uri = { - let guard = app.lock().await; - assert_eq!(guard.youtube_playlists.len(), 1); - assert_eq!(guard.youtube_playlists[0].name, "Focus"); - guard.youtube_playlists[0].uri.clone() - }; - - // Fake a search view holding the video's metadata, then add it by id — - // exactly what the picker dialog dispatches. - { - let mut guard = app.lock().await; - guard.search_results.tracks = Some(Paged { - items: vec![video("youtube:vid1234", "Cool Song")], - total: 1, - ..Default::default() - }); - // The search row's id field carries the bare video id. - guard.search_results.tracks.as_mut().unwrap().items[0].id = Some("vid1234".to_string()); - } - assert!( - route_youtube_event( - &app, - &IoEvent::AddTrackToYouTubePlaylist(playlist_uri.clone(), "vid1234".to_string()) - ) - .await - ); - assert_eq!( - app.lock().await.youtube_playlists[0].track_count, - 1, - "sidebar count must refresh after an add" - ); - - // Adding the same video again is a friendly no-op. - assert!( - route_youtube_event( - &app, - &IoEvent::AddTrackToYouTubePlaylist(playlist_uri.clone(), "vid1234".to_string()) - ) - .await - ); - assert_eq!(app.lock().await.youtube_playlists[0].track_count, 1); - - // Open the playlist into the shared track table. - assert!(route_youtube_event(&app, &IoEvent::GetYouTubeTracks(playlist_uri.clone())).await); - { - let guard = app.lock().await; - assert_eq!(guard.track_table.tracks.len(), 1); - assert_eq!(guard.track_table.tracks[0].name, "Cool Song"); - assert_eq!( - guard.track_table.tracks[0].uri.as_deref(), - Some("youtube:vid1234") - ); - assert_eq!( - guard.youtube_open_playlist.as_deref(), - Some(playlist_uri.as_str()) - ); - } - - // Remove the track; the open table refreshes in place. - assert!( - route_youtube_event( - &app, - &IoEvent::RemoveTrackFromYouTubePlaylist(playlist_uri.clone(), "vid1234".to_string()) - ) - .await - ); - { - let guard = app.lock().await; - assert!( - guard.track_table.tracks.is_empty(), - "open table must refresh" - ); - assert_eq!(guard.youtube_playlists[0].track_count, 0); - } - - // Delete the playlist; the sidebar refreshes and the open marker clears. - assert!(route_youtube_event(&app, &IoEvent::DeleteYouTubePlaylist(playlist_uri)).await); - { - let guard = app.lock().await; - assert!(guard.youtube_playlists.is_empty()); - assert!(guard.youtube_open_playlist.is_none()); - } - - // And the file on disk reflects all of it (empty playlists list). - let on_disk = super::super::playlists::load(&path).unwrap(); - assert!(on_disk.playlists.is_empty()); - } - - /// End-to-end dispatch test: drive `route_youtube_event` exactly as the - /// runtime pump does — search, play a result, advance the queue, and tear - /// down on a foreign start. Ignored (needs yt-dlp, network, AND an audio - /// output device); run: - /// `cargo test --features youtube -- --ignored live_dispatch` - #[tokio::test(flavor = "multi_thread")] - #[ignore = "requires yt-dlp on PATH, network, AND an audio output device"] - async fn live_dispatch_search_play_and_advance() { - let app = Arc::new(Mutex::new(test_app())); - - // Search populates the songs block. - assert!( - route_youtube_event( - &app, - &IoEvent::GetYouTubeSearchResults("daft punk get lucky".to_string()) - ) - .await - ); - let uris: Vec = app - .lock() - .await - .search_results - .tracks - .as_ref() - .expect("search populated the songs block") - .items - .iter() - .filter_map(|t| t.uri.clone()) - .collect(); - assert!(uris.len() >= 2, "need >=2 results to test advance"); - - // Start the queue at index 0 — downloads + plays the first video. - assert!( - route_youtube_event(&app, &IoEvent::StartPlayback(None, Some(uris), Some(0))).await, - "youtube StartPlayback must be consumed" - ); - { - let guard = app.lock().await; - let s = guard.youtube_playback.as_ref().expect("session published"); - assert_eq!(s.index, 0); - assert!(!s.player.is_paused(), "should be playing"); - } - - // Pause/resume are ours while the session lives. - assert!(route_youtube_event(&app, &IoEvent::PausePlayback).await); - assert!(app - .lock() - .await - .youtube_playback - .as_ref() - .is_some_and(|s| s.player.is_paused())); - assert!(route_youtube_event(&app, &IoEvent::StartPlayback(None, None, None)).await); - - // Advance — downloads + plays the next video, moving the index. - assert!(route_youtube_event(&app, &IoEvent::NextTrack).await); - { - let guard = app.lock().await; - let s = guard - .youtube_playback - .as_ref() - .expect("session still active"); - assert_eq!(s.index, 1, "Next should advance the queue index"); - } - - // A foreign (Spotify) start tears the session down and falls through. - assert!( - !route_youtube_event( - &app, - &IoEvent::StartPlayback(Some("spotify:track:x".to_string()), None, None) - ) - .await, - "a non-youtube start must fall through to the network" - ); - assert!( - app.lock().await.youtube_playback.is_none(), - "a foreign start must tear down the YouTube session (device handoff)" - ); - } -} diff --git a/src/infra/youtube/mod.rs b/src/infra/youtube/mod.rs deleted file mode 100644 index dfe06649..00000000 --- a/src/infra/youtube/mod.rs +++ /dev/null @@ -1,638 +0,0 @@ -//! YouTube media source (via the external `yt-dlp` binary). -//! -//! Deliberately the thinnest possible wrapper: no YouTube crates, no API keys — -//! [`YouTubeSource`] shells out to `yt-dlp`, which owns the extraction -//! treadmill (PO tokens, signature ciphers, throttling). Search uses -//! `--flat-playlist --dump-json ytsearchN:`; playback downloads the AAC/M4A -//! audio-only format (itag 140, decodable by rodio's default `mp4` feature) to -//! a tempfile and plays it through the shared [`LocalPlayer`] — the same -//! download-then-play model as the Subsonic source. -//! -//! This source is **unofficial-fragile by design** (see plan-multi-source.md): -//! when YouTube hardens extraction, updating the `yt-dlp` binary is the fix, -//! not a spotatui release. -//! -//! ## URIs -//! -//! `youtube:` — the suffix is the 11-character video id. Ids are -//! charset-validated before being passed to `yt-dlp` (defense against argument -//! injection; they are embedded in a full watch URL, never passed bare). - -use std::path::Path; -use std::process::Stdio; -use std::sync::Arc; -use std::time::Duration; - -use anyhow::{anyhow, bail, Context, Result}; -use tokio::io::AsyncReadExt; -use tokio::process::Command; - -use crate::core::plugin_api::{SearchResults, TrackInfo}; -use crate::core::source::Searcher; -use crate::infra::audio::LocalPlayer; - -pub mod dispatch; -pub mod playlists; -mod types; - -use types::YtVideo; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -const YOUTUBE_PREFIX: &str = "youtube:"; - -/// The `yt-dlp` binary to invoke when the user has not configured -/// `behavior.ytdlp_path` — resolved through `$PATH`. -const DEFAULT_YTDLP: &str = "yt-dlp"; - -/// Result page size for searches. Each flat-search page is one remote request -/// for yt-dlp, so this is also the dominant term in search latency (~3-6 s). -const SEARCH_LIMIT: usize = 20; - -/// Audio format selector: itag 140 (AAC/M4A ~128 kbps) first — the format the -/// bundled symphonia decoder is known to handle — then any m4a, then whatever -/// audio exists (a webm/opus fallback will fail to decode, but a clear decode -/// error beats no audio at all when 140 disappears someday). -const FORMAT_SELECTOR: &str = "140/bestaudio[ext=m4a]/bestaudio"; - -/// Extractor override for the download retry: YouTube's GVS PO-token -/// enforcement 403s music-label content on the default player clients, while -/// the embedded players still serve tokenless media URLs for any embeddable -/// video — which label uploads are. Tried only after a default download -/// fails, so healthy videos never pay for it; a non-embeddable gated video -/// stays undownloadable without a real PO-token provider. -const EMBEDDED_PLAYER_CLIENTS: &str = "youtube:player_client=web_embedded,tv_embedded"; - -/// Cap on a search invocation. yt-dlp searches normally return in a few -/// seconds; a hung process must not wedge the IoEvent pump forever. -const SEARCH_TIMEOUT: Duration = Duration::from_secs(45); - -/// Cap on a download invocation (a ~4 MiB audio file, but allow slow links). -const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(180); - -// --------------------------------------------------------------------------- -// YouTubePlaybackState -// --------------------------------------------------------------------------- - -/// The active YouTube playback session — same decoupled shape as the Subsonic -/// one: it owns the live [`LocalPlayer`], the queue of searched videos with -/// the current index, and the downloaded tempfile for the playing track. It -/// never writes Spotify/librespot fields; the playbar reads progress/pause -/// live from `player`. -pub struct YouTubePlaybackState { - pub player: Arc, - /// Source handle, reused to download each track on Next/advance. - pub source: Arc, - /// The queued videos (with search metadata) in order. - pub tracks: Vec, - /// Index into [`tracks`](Self::tracks) of the currently playing video. - pub index: usize, - /// Auto-advance guard — set while a track change is downloading/decoding so - /// the runner tick does not mistake the empty sink for end-of-track. The - /// yt-dlp download window is even longer than Subsonic's, so this guard is - /// load-bearing. - pub advancing: bool, - /// The downloaded audio for the current track, kept alive while it plays. - pub tempfile: tempfile::NamedTempFile, - /// Backup of the pre-shuffle track order while shuffle is on (`None` in natural - /// order). Set by [`set_shuffle(true)`](Self::set_shuffle); restored exactly by - /// `set_shuffle(false)`. - pub shuffle_backup: Option, - /// A seek and pause to apply when the next track is staged (device - /// recovery, the native queue's resume). - pub resume_at: Option, -} - -impl YouTubePlaybackState { - /// The currently playing video, if `index` is in range. - #[cfg(feature = "tui")] - pub fn current(&self) -> Option<&TrackInfo> { - self.tracks.get(self.index) - } - - /// Turn in-place shuffle on or off — see - /// [`toggle_shuffle`](crate::infra::queue::toggle_shuffle) for the shared - /// semantics (current track stays playing at the front; un-shuffle restores - /// order + index; idempotent). - pub fn set_shuffle(&mut self, on: bool) { - crate::infra::queue::toggle_shuffle( - &mut self.tracks, - &mut self.index, - &mut self.shuffle_backup, - on, - ); - } -} - -// --------------------------------------------------------------------------- -// URI helpers -// --------------------------------------------------------------------------- - -/// Whether a URI is owned by the YouTube source. -pub fn is_youtube_uri(uri: &str) -> bool { - uri.starts_with(YOUTUBE_PREFIX) -} - -/// Strip the `youtube:` prefix and return the video id, validating its -/// charset. The validation is load-bearing: the id is interpolated into a -/// `yt-dlp` argument, so a crafted "id" must not be able to smuggle flags or -/// URL structure. -pub fn video_id_from_uri(uri: &str) -> Result<&str> { - let id = uri - .strip_prefix(YOUTUBE_PREFIX) - .ok_or_else(|| anyhow!("Not a YouTube URI: {}", uri))?; - if id.is_empty() - || !id - .bytes() - .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') - { - bail!("Invalid YouTube video id: {id:?}"); - } - Ok(id) -} - -/// Build the `youtube:` URI for a video id. -pub fn uri_for_video_id(id: &str) -> String { - format!("{YOUTUBE_PREFIX}{id}") -} - -// --------------------------------------------------------------------------- -// YouTubeSource — yt-dlp wrapper -// --------------------------------------------------------------------------- - -/// Client for YouTube backed by an external `yt-dlp` binary. -pub struct YouTubeSource { - /// Path or name of the `yt-dlp` binary (name is resolved via `$PATH`). - ytdlp: String, -} - -impl YouTubeSource { - /// `ytdlp_path` is the user's `behavior.ytdlp_path` override, if any. - pub fn new(ytdlp_path: Option) -> Self { - YouTubeSource { - ytdlp: ytdlp_path.unwrap_or_else(|| DEFAULT_YTDLP.to_string()), - } - } - - /// Search YouTube by keywords, returning up to [`SEARCH_LIMIT`] videos. - /// - /// Flat extraction (`--flat-playlist`) returns one JSON object per line - /// without resolving each video page, keeping the search to one remote - /// round-trip. - pub async fn search_videos(&self, query: &str) -> Result> { - let stdout = self - .run( - &[ - "--flat-playlist", - "--dump-json", - "--no-warnings", - &format!("ytsearch{SEARCH_LIMIT}:{query}"), - ], - SEARCH_TIMEOUT, - ) - .await?; - - // One JSON object per line; skip lines that fail to parse rather than - // failing the whole search (yt-dlp occasionally interleaves notices). - Ok( - stdout - .lines() - .filter_map(|line| serde_json::from_str::(line).ok()) - .filter(|v| !v.id.is_empty()) - .collect(), - ) - } - - /// Download a video's audio (itag 140 AAC/M4A preferred) to `dest`. - /// - /// When `ffmpeg` is on `$PATH`, yt-dlp remuxes the DASH fragments into a - /// plain MP4 container ("FixupM4a"); without it the file stays fragmented, - /// which the bundled decoder may reject — the dispatch surfaces an - /// install-ffmpeg hint on decode failure for that case. - pub async fn download_audio(&self, video_id: &str, dest: &Path) -> Result<()> { - // Belt-and-braces: the dispatch already validated the id, but this method - // is also callable directly (tests, future callers). - if !video_id - .bytes() - .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') - { - bail!("Invalid YouTube video id: {video_id:?}"); - } - let dest = dest - .to_str() - .ok_or_else(|| anyhow!("Non-UTF-8 tempfile path"))?; - let url = format!("https://www.youtube.com/watch?v={video_id}"); - - run_with_embedded_fallback(|embedded| { - let args = download_args(dest, &url, embedded); - async move { self.run(&args, DOWNLOAD_TIMEOUT).await.map(|_| ()) } - }) - .await - } - - /// Run `yt-dlp` with `args`, returning stdout. Fails with the tail of - /// stderr on a non-zero exit, a clear hint when the binary is missing, and - /// kills the process on timeout (so a wedged extractor can't leak). - async fn run(&self, args: &[&str], timeout: Duration) -> Result { - let mut child = Command::new(&self.ytdlp) - .args(args) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .kill_on_drop(true) - .spawn() - .map_err(|e| { - if e.kind() == std::io::ErrorKind::NotFound { - anyhow!( - "`{}` not found — install yt-dlp (or set behavior.ytdlp_path)", - self.ytdlp - ) - } else { - anyhow!("failed to spawn `{}`: {e}", self.ytdlp) - } - })?; - - // Read both pipes concurrently with wait(); reading stdout after wait() - // would deadlock once the pipe buffer fills on large outputs. - let mut stdout_pipe = child.stdout.take().context("yt-dlp stdout not piped")?; - let mut stderr_pipe = child.stderr.take().context("yt-dlp stderr not piped")?; - let io = async { - let (mut out, mut err) = (String::new(), String::new()); - let _ = tokio::join!( - stdout_pipe.read_to_string(&mut out), - stderr_pipe.read_to_string(&mut err), - ); - (out, err) - }; - - let ((stdout, stderr), status) = - tokio::time::timeout(timeout, async { tokio::join!(io, child.wait()) }) - .await - .map_err(|_| anyhow!("yt-dlp timed out after {}s", timeout.as_secs()))?; - - let status = status.context("waiting for yt-dlp")?; - if !status.success() { - // yt-dlp's real error is the last stderr lines ("ERROR: ..."). - let tail: String = stderr - .lines() - .rev() - .take(3) - .collect::>() - .into_iter() - .rev() - .collect::>() - .join(" | "); - bail!( - "yt-dlp failed ({}): {}", - status, - if tail.is_empty() { - "no error output" - } else { - tail.as_str() - } - ); - } - Ok(stdout) - } -} - -/// The download's attempt sequence: the default clients first, then one -/// retry through the embedded players (see [`EMBEDDED_PLAYER_CLIENTS`]). On -/// a double failure the *primary* error surfaces: it names the canonical -/// problem (403, missing binary, dead network), where the fallback's usually -/// degenerates to a less useful "format not available". -async fn run_with_embedded_fallback(mut attempt: F) -> Result<()> -where - F: FnMut(bool) -> Fut, - Fut: std::future::Future>, -{ - match attempt(false).await { - Ok(()) => Ok(()), - Err(primary) => match attempt(true).await { - Ok(()) => Ok(()), - Err(fallback) => { - log::info!("youtube: embedded-client download fallback also failed: {fallback:#}"); - Err(primary) - } - }, - } -} - -/// Arguments for one download attempt; the retry differs from the first try -/// only by the embedded-client extractor override. -fn download_args<'a>(dest: &'a str, url: &'a str, embedded_fallback: bool) -> Vec<&'a str> { - let mut args: Vec<&str> = Vec::new(); - if embedded_fallback { - args.extend_from_slice(&["--extractor-args", EMBEDDED_PLAYER_CLIENTS]); - } - args.extend_from_slice(&[ - "-f", - FORMAT_SELECTOR, - "--no-playlist", - // The NamedTempFile already exists (0 bytes); overwrite it. - "--force-overwrites", - // Write straight to dest instead of a .part file next to it. - "--no-part", - "--no-warnings", - "--no-progress", - "--quiet", - "-o", - dest, - url, - ]); - args -} - -impl Searcher for YouTubeSource { - async fn search(&self, query: &str) -> Result { - let videos = self.search_videos(query).await?; - Ok(SearchResults { - tracks: videos.iter().map(video_to_track_info).collect(), - albums: vec![], - artists: vec![], - playlists: vec![], - shows: vec![], - }) - } -} - -// --------------------------------------------------------------------------- -// Domain type conversions -// --------------------------------------------------------------------------- - -/// Map a search row onto the shared [`TrackInfo`] the track table, search -/// results and playbar already render. -/// -/// - `uri` = `youtube:`. -/// - `artists` (the subtitle column) = the channel name. -/// - `album` = a `"YouTube • 863M views"` style summary. -/// - `duration_ms` = 0 for livestreams (the playbar's LIVE sentinel). -fn video_to_track_info(v: &YtVideo) -> TrackInfo { - let channel = v - .channel - .as_deref() - .or(v.uploader.as_deref()) - .unwrap_or_default() - .trim() - .to_string(); - - TrackInfo { - uri: Some(uri_for_video_id(&v.id)), - name: v.title.trim().to_string(), - artists: if channel.is_empty() { - vec![] - } else { - vec![channel] - }, - album: video_summary(v.view_count), - duration_ms: v.duration.map(|s| (s * 1000.0) as u64).unwrap_or(0), - id: Some(v.id.clone()), - album_id: None, - artist_refs: vec![], - is_playable: true, - is_local: false, - track_number: 0, - explicit: false, - // Flat extraction often omits the top-level `thumbnail`; every video id has - // a deterministic thumbnail URL, so fall back to that. - image_url: v - .thumbnail - .clone() - .or_else(|| Some(thumbnail_url_for_video_id(&v.id))), - } -} - -/// Deterministic YouTube thumbnail URL for a video id. `hqdefault.jpg` exists -/// for every video (unlike `maxresdefault`), so it serves as the cover-art -/// fallback when yt-dlp's flat rows or a stored playlist carry no thumbnail. -pub(crate) fn thumbnail_url_for_video_id(video_id: &str) -> String { - format!("https://i.ytimg.com/vi/{video_id}/hqdefault.jpg") -} - -/// `"YouTube • 863M views"`-style one-liner for the album column. -fn video_summary(view_count: Option) -> String { - match view_count { - Some(views) => format!("YouTube \u{2022} {} views", humanize_count(views)), - None => "YouTube".to_string(), - } -} - -/// `1234` -> `"1.2K"`, `863781229` -> `"863.8M"` — coarse is fine for a -/// subtitle column. -fn humanize_count(n: u64) -> String { - match n { - 0..=999 => n.to_string(), - 1_000..=999_999 => format!("{:.1}K", n as f64 / 1_000.0), - 1_000_000..=999_999_999 => format!("{:.1}M", n as f64 / 1_000_000.0), - _ => format!("{:.1}B", n as f64 / 1_000_000_000.0), - } -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - - // Two representative flat-search lines (trimmed to the fields we read) plus - // one junk line that must be skipped. - const SEARCH_LINES: &str = concat!( - r#"{"id": "5NV6Rdv1a3I", "title": " Daft Punk - Get Lucky (Official Audio) ", "channel": "Daft Punk", "uploader": "Daft Punk", "duration": 249.0, "view_count": 863781229}"#, - "\n", - "not json at all\n", - r#"{"id": "dQw4w9WgXcQ", "title": "Live now", "uploader": "Some Channel", "duration": null}"#, - "\n", - ); - - #[test] - fn parse_search_lines_and_map_to_track_info() { - let videos: Vec = SEARCH_LINES - .lines() - .filter_map(|l| serde_json::from_str(l).ok()) - .collect(); - assert_eq!(videos.len(), 2, "junk lines must be skipped"); - - let row = video_to_track_info(&videos[0]); - assert_eq!(row.uri.as_deref(), Some("youtube:5NV6Rdv1a3I")); - assert_eq!(row.name, "Daft Punk - Get Lucky (Official Audio)"); - assert_eq!(row.artists, vec!["Daft Punk"]); - assert_eq!(row.album, "YouTube \u{2022} 863.8M views"); - assert_eq!(row.duration_ms, 249_000); - assert_eq!(row.id.as_deref(), Some("5NV6Rdv1a3I")); - assert!(row.is_playable); - - // No duration (livestream) -> the LIVE-style 0 sentinel; channel falls - // back to uploader. - let live = video_to_track_info(&videos[1]); - assert_eq!(live.duration_ms, 0); - assert_eq!(live.artists, vec!["Some Channel"]); - assert_eq!(live.album, "YouTube"); - } - - #[test] - fn youtube_uri_round_trip() { - let uri = uri_for_video_id("5NV6Rdv1a3I"); - assert_eq!(uri, "youtube:5NV6Rdv1a3I"); - assert!(is_youtube_uri(&uri)); - assert_eq!(video_id_from_uri(&uri).unwrap(), "5NV6Rdv1a3I"); - // Ids may contain - and _. - assert_eq!(video_id_from_uri("youtube:a-b_c123").unwrap(), "a-b_c123"); - } - - #[test] - fn video_id_rejects_injection_shaped_input() { - // Flags, URL structure, spaces, and other schemes must all be rejected. - assert!(video_id_from_uri("youtube:--exec=rm").is_err()); - assert!(video_id_from_uri("youtube:id&x=1").is_err()); - assert!(video_id_from_uri("youtube:a b").is_err()); - assert!(video_id_from_uri("youtube:").is_err()); - assert!(video_id_from_uri("spotify:track:x").is_err()); - assert!(!is_youtube_uri("radio:https://x.example/s")); - // A leading dash alone is caught by charset? No — '-' is allowed in ids, - // so bare "-flag" survives charset checks. It is defused structurally: - // the id is embedded in a watch URL, never passed as its own argument. - assert!(video_id_from_uri("youtube:-abcdefghij").is_ok()); - } - - #[test] - fn summary_humanizes_view_counts() { - assert_eq!(video_summary(None), "YouTube"); - assert_eq!(video_summary(Some(999)), "YouTube \u{2022} 999 views"); - assert_eq!(video_summary(Some(1_500)), "YouTube \u{2022} 1.5K views"); - assert_eq!( - video_summary(Some(863_781_229)), - "YouTube \u{2022} 863.8M views" - ); - assert_eq!( - video_summary(Some(1_200_000_000)), - "YouTube \u{2022} 1.2B views" - ); - } - - #[test] - fn download_retry_only_prepends_the_embedded_client_override() { - let url = "https://www.youtube.com/watch?v=5NV6Rdv1a3I"; - let first_try = download_args("dest.m4a", url, false); - let retry = download_args("dest.m4a", url, true); - - assert!(!first_try.contains(&"--extractor-args")); - assert_eq!(retry[..2], ["--extractor-args", EMBEDDED_PLAYER_CLIENTS]); - assert_eq!(retry[2..], first_try[..], "everything else stays identical"); - } - - #[tokio::test] - async fn download_fallback_never_runs_after_a_primary_success() { - let mut attempts = Vec::new(); - run_with_embedded_fallback(|embedded| { - attempts.push(embedded); - async { Ok(()) } - }) - .await - .unwrap(); - assert_eq!(attempts, [false]); - } - - #[tokio::test] - async fn download_retries_exactly_once_through_the_embedded_clients() { - let mut attempts = Vec::new(); - run_with_embedded_fallback(|embedded| { - attempts.push(embedded); - async move { - if embedded { - Ok(()) - } else { - Err(anyhow!("HTTP Error 403")) - } - } - }) - .await - .unwrap(); - assert_eq!(attempts, [false, true]); - } - - #[tokio::test] - async fn double_download_failure_surfaces_the_primary_error() { - let err = run_with_embedded_fallback(|embedded| async move { - Err(anyhow!(if embedded { - "format not available" - } else { - "HTTP Error 403" - })) - }) - .await - .unwrap_err(); - assert!(err.to_string().contains("403"), "got: {err}"); - } - - #[test] - fn missing_binary_yields_actionable_error() { - let rt = tokio::runtime::Runtime::new().unwrap(); - let source = YouTubeSource::new(Some("definitely-not-a-real-binary".into())); - let err = rt - .block_on(source.search_videos("test")) - .expect_err("missing binary must error"); - assert!( - err.to_string().contains("install yt-dlp"), - "error should tell the user what to do, got: {err}" - ); - } - - /// Live search against real YouTube through yt-dlp. Ignored by default - /// (network + binary); run with: - /// `cargo test --features youtube -- --ignored live_search` - #[tokio::test] - #[ignore = "requires yt-dlp on PATH and hits live YouTube"] - async fn live_search_returns_playable_rows() { - let source = YouTubeSource::new(None); - let videos = source - .search_videos("daft punk get lucky") - .await - .expect("search should succeed"); - assert!(!videos.is_empty(), "search should match videos"); - for v in &videos { - let row = video_to_track_info(v); - let uri = row.uri.expect("row must carry a uri"); - assert!(is_youtube_uri(&uri)); - video_id_from_uri(&uri).expect("id must round-trip"); - } - } - - /// Live download + decode + play through the shared sink. Ignored (network, - /// yt-dlp, and an audio output device); run: - /// `cargo test --features youtube -- --ignored live_download` - #[tokio::test(flavor = "multi_thread")] - #[ignore = "requires yt-dlp on PATH, network, AND an audio output device"] - async fn live_download_and_play_through_sink() { - let source = YouTubeSource::new(None); - let tmp = tempfile::NamedTempFile::new().unwrap(); - source - .download_audio("5NV6Rdv1a3I", tmp.path()) - .await - .expect("download should succeed"); - let len = std::fs::metadata(tmp.path()).unwrap().len(); - assert!( - len > 100_000, - "audio file should be non-trivial, got {len}B" - ); - - let player = Arc::new(LocalPlayer::new().expect("open default output device")); - let path = tmp.path().to_path_buf(); - let decode_player = Arc::clone(&player); - tokio::task::spawn_blocking(move || decode_player.play_file(&path)) - .await - .unwrap() - .expect("m4a should decode and play"); - - tokio::time::sleep(Duration::from_millis(1500)).await; - assert!( - player.position() >= Duration::from_millis(500), - "playback position should advance, got {:?}", - player.position() - ); - player.stop(); - } -} diff --git a/src/infra/youtube/playlists.rs b/src/infra/youtube/playlists.rs deleted file mode 100644 index c5b4c8f5..00000000 --- a/src/infra/youtube/playlists.rs +++ /dev/null @@ -1,440 +0,0 @@ -//! Local YouTube playlists — user-curated lists of YouTube videos stored in a -//! plain YAML file, no Google account required. -//! -//! There is no clean login API for YouTube Music (Google shut down the OAuth -//! path; cookies are fragile and risky), so instead of syncing a remote -//! library the YouTube source keeps playlists **locally**: -//! `youtube_playlists.yml` in the spotatui config directory. Together with anonymous -//! search + playback this makes spotatui fully usable without any account. -//! -//! The file is human-editable and shareable: -//! -//! ```yaml -//! playlists: -//! - id: k3j9x2m4p7q1 -//! name: Focus -//! tracks: -//! - video_id: 5NV6Rdv1a3I -//! title: Daft Punk - Get Lucky (Official Audio) -//! channel: Daft Punk -//! duration_ms: 249000 -//! ``` -//! -//! ## URIs -//! -//! `youtube:playlist:` — ids are locally generated (random alphanumeric), -//! stable across renames. Note these URIs pass [`super::is_youtube_uri`] but -//! fail [`super::video_id_from_uri`] (the `:` is rejected), so they can never -//! be mistaken for a playable video URI. - -use std::path::{Path, PathBuf}; - -use anyhow::{anyhow, bail, Context, Result}; -use rand::RngExt; -use serde::{Deserialize, Serialize}; - -use crate::core::plugin_api::{PlaylistInfo, TrackInfo}; - -use super::{uri_for_video_id, video_id_from_uri}; - -const PLAYLIST_PREFIX: &str = "youtube:playlist:"; -const FILE_NAME: &str = "youtube_playlists.yml"; - -// --------------------------------------------------------------------------- -// Storage types -// --------------------------------------------------------------------------- - -/// The whole on-disk file. -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] -pub struct PlaylistsFile { - #[serde(default)] - pub playlists: Vec, -} - -/// One user playlist. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct StoredPlaylist { - /// Locally generated random id, stable across renames. - pub id: String, - pub name: String, - #[serde(default)] - pub tracks: Vec, -} - -/// One saved video — enough metadata to render the track table without a -/// network round-trip. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct StoredTrack { - pub video_id: String, - pub title: String, - #[serde(default)] - pub channel: String, - #[serde(default)] - pub duration_ms: u64, -} - -// --------------------------------------------------------------------------- -// URI helpers -// --------------------------------------------------------------------------- - -/// Whether a URI names a local YouTube playlist. -pub fn is_playlist_uri(uri: &str) -> bool { - uri.starts_with(PLAYLIST_PREFIX) -} - -/// `youtube:playlist:` for a playlist id. -pub fn uri_for_playlist_id(id: &str) -> String { - format!("{PLAYLIST_PREFIX}{id}") -} - -/// Extract the playlist id from a `youtube:playlist:` URI; also accepts a -/// bare id (callers pass whichever they have). -pub fn playlist_id_from_ref(uri_or_id: &str) -> &str { - if is_playlist_uri(uri_or_id) { - &uri_or_id[PLAYLIST_PREFIX.len()..] - } else { - uri_or_id - } -} - -// --------------------------------------------------------------------------- -// File I/O -// --------------------------------------------------------------------------- - -/// Environment override for the playlists file location (used by tests and -/// available to users who keep their config elsewhere). -pub const PATH_ENV: &str = "SPOTATUI_YOUTUBE_PLAYLISTS_PATH"; - -/// Location of the playlists file: `$SPOTATUI_YOUTUBE_PLAYLISTS_PATH` when -/// set, else `/youtube_playlists.yml` next to the app config. -pub fn default_playlists_path() -> Result { - if let Ok(path) = std::env::var(PATH_ENV) { - return Ok(PathBuf::from(path)); - } - crate::core::user_config::default_app_config_dir() - .map(|dir| dir.join(FILE_NAME)) - .ok_or_else(|| anyhow!("cannot resolve the spotatui config directory")) -} - -/// Load the playlists file; a missing file is an empty list, a malformed file -/// is an error (never silently overwrite a file the user hand-edited wrong). -pub fn load(path: &Path) -> Result { - match std::fs::read_to_string(path) { - Ok(contents) => serde_yaml::from_str(&contents) - .with_context(|| format!("malformed YouTube playlists file: {}", path.display())), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(PlaylistsFile::default()), - Err(e) => Err(e).with_context(|| format!("reading {}", path.display())), - } -} - -/// Save the playlists file atomically (write a sibling tempfile, then rename) -/// so a crash mid-write can't destroy the user's playlists. -pub fn save(path: &Path, file: &PlaylistsFile) -> Result<()> { - let yaml = serde_yaml::to_string(file).context("serializing YouTube playlists")?; - if let Some(dir) = path.parent() { - std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?; - } - let tmp = path.with_extension("yml.tmp"); - std::fs::write(&tmp, yaml).with_context(|| format!("writing {}", tmp.display()))?; - std::fs::rename(&tmp, path).with_context(|| format!("replacing {}", path.display()))?; - Ok(()) -} - -// --------------------------------------------------------------------------- -// CRUD (pure, on the in-memory file; callers load → mutate → save) -// --------------------------------------------------------------------------- - -/// Generate a 12-character random alphanumeric playlist id, unique within -/// `file`. -fn generate_id(file: &PlaylistsFile) -> String { - const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789"; - let mut rng = rand::rng(); - loop { - let id: String = (0..12) - .map(|_| CHARSET[rng.random_range(0..CHARSET.len())] as char) - .collect(); - if !file.playlists.iter().any(|p| p.id == id) { - return id; - } - } -} - -/// Create a playlist named `name`, returning its new id. Duplicate names are -/// allowed (ids disambiguate), but blank names are not. -pub fn create_playlist(file: &mut PlaylistsFile, name: &str) -> Result { - let name = name.trim(); - if name.is_empty() { - bail!("playlist name cannot be empty"); - } - let id = generate_id(file); - file.playlists.push(StoredPlaylist { - id: id.clone(), - name: name.to_string(), - tracks: Vec::new(), - }); - Ok(id) -} - -/// Delete the playlist referenced by `uri_or_id`. Errors when it doesn't -/// exist (surface stale-UI bugs rather than masking them). -pub fn delete_playlist(file: &mut PlaylistsFile, uri_or_id: &str) -> Result { - let id = playlist_id_from_ref(uri_or_id); - let idx = file - .playlists - .iter() - .position(|p| p.id == id) - .ok_or_else(|| anyhow!("no such YouTube playlist: {uri_or_id}"))?; - Ok(file.playlists.remove(idx)) -} - -/// Append `track` to the playlist referenced by `uri_or_id`. Returns `false` -/// (without modifying anything) when the video is already in the playlist. -pub fn add_track(file: &mut PlaylistsFile, uri_or_id: &str, track: StoredTrack) -> Result { - let id = playlist_id_from_ref(uri_or_id); - let playlist = file - .playlists - .iter_mut() - .find(|p| p.id == id) - .ok_or_else(|| anyhow!("no such YouTube playlist: {uri_or_id}"))?; - if playlist.tracks.iter().any(|t| t.video_id == track.video_id) { - return Ok(false); - } - playlist.tracks.push(track); - Ok(true) -} - -/// Remove the video with `video_id` from the playlist referenced by -/// `uri_or_id`. Returns the removed track. -pub fn remove_track( - file: &mut PlaylistsFile, - uri_or_id: &str, - video_id: &str, -) -> Result { - let id = playlist_id_from_ref(uri_or_id); - let playlist = file - .playlists - .iter_mut() - .find(|p| p.id == id) - .ok_or_else(|| anyhow!("no such YouTube playlist: {uri_or_id}"))?; - let idx = playlist - .tracks - .iter() - .position(|t| t.video_id == video_id) - .ok_or_else(|| anyhow!("video {video_id} is not in that playlist"))?; - Ok(playlist.tracks.remove(idx)) -} - -/// Find a playlist by URI or bare id. -pub fn find_playlist<'a>(file: &'a PlaylistsFile, uri_or_id: &str) -> Option<&'a StoredPlaylist> { - let id = playlist_id_from_ref(uri_or_id); - file.playlists.iter().find(|p| p.id == id) -} - -// --------------------------------------------------------------------------- -// Domain type conversions -// --------------------------------------------------------------------------- - -/// Map a stored playlist onto the sidebar row type. -pub fn playlist_to_info(p: &StoredPlaylist) -> PlaylistInfo { - PlaylistInfo { - uri: uri_for_playlist_id(&p.id), - name: p.name.clone(), - owner: "local".to_string(), - track_count: p.tracks.len() as u32, - id: Some(p.id.clone()), - owner_id: None, - collaborative: false, - public: None, - image_url: None, - } -} - -/// Map a stored track onto the shared track-table row type. -pub fn stored_to_track_info(t: &StoredTrack) -> TrackInfo { - TrackInfo { - uri: Some(uri_for_video_id(&t.video_id)), - name: t.title.clone(), - artists: if t.channel.is_empty() { - vec![] - } else { - vec![t.channel.clone()] - }, - album: "YouTube".to_string(), - duration_ms: t.duration_ms, - id: Some(t.video_id.clone()), - album_id: None, - artist_refs: vec![], - is_playable: true, - is_local: false, - track_number: 0, - explicit: false, - // StoredTrack persists no thumbnail (on-disk format stays stable); the - // video id alone yields a deterministic thumbnail URL. - image_url: Some(super::thumbnail_url_for_video_id(&t.video_id)), - } -} - -/// Build a [`StoredTrack`] from a browse row (search result or track table). -/// The row's `id` holds the bare video id and its `uri` the `youtube:` URI; -/// accept either, validating through the same charset check as playback. -pub fn track_info_to_stored(t: &TrackInfo) -> Result { - let video_id = match (&t.id, &t.uri) { - (Some(id), _) if video_id_from_uri(&uri_for_video_id(id)).is_ok() => id.clone(), - (_, Some(uri)) => video_id_from_uri(uri)?.to_string(), - _ => bail!("row has no YouTube video id"), - }; - Ok(StoredTrack { - video_id, - title: t.name.clone(), - channel: t.artists.first().cloned().unwrap_or_default(), - duration_ms: t.duration_ms, - }) -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - - fn track(id: &str, title: &str) -> StoredTrack { - StoredTrack { - video_id: id.to_string(), - title: title.to_string(), - channel: "Chan".to_string(), - duration_ms: 1000, - } - } - - #[test] - fn crud_round_trip() { - let mut file = PlaylistsFile::default(); - let id = create_playlist(&mut file, " Focus ").unwrap(); - assert_eq!(file.playlists[0].name, "Focus", "name must be trimmed"); - assert!(create_playlist(&mut file, " ").is_err(), "blank rejected"); - - let uri = uri_for_playlist_id(&id); - assert!(add_track(&mut file, &uri, track("aaa", "A")).unwrap()); - assert!( - add_track(&mut file, &id, track("bbb", "B")).unwrap(), - "bare id accepted" - ); - assert!( - !add_track(&mut file, &uri, track("aaa", "A again")).unwrap(), - "duplicate video is a no-op" - ); - assert_eq!(find_playlist(&file, &uri).unwrap().tracks.len(), 2); - - let removed = remove_track(&mut file, &uri, "aaa").unwrap(); - assert_eq!(removed.title, "A"); - assert!( - remove_track(&mut file, &uri, "aaa").is_err(), - "already gone" - ); - - let deleted = delete_playlist(&mut file, &uri).unwrap(); - assert_eq!(deleted.name, "Focus"); - assert!(file.playlists.is_empty()); - assert!(delete_playlist(&mut file, &uri).is_err()); - } - - #[test] - fn save_and_load_round_trip() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("youtube_playlists.yml"); - - // Missing file loads as empty. - assert_eq!(load(&path).unwrap(), PlaylistsFile::default()); - - let mut file = PlaylistsFile::default(); - let id = create_playlist(&mut file, "Road Trip").unwrap(); - add_track(&mut file, &id, track("5NV6Rdv1a3I", "Get Lucky")).unwrap(); - save(&path, &file).unwrap(); - - let loaded = load(&path).unwrap(); - assert_eq!(loaded, file); - // No stray tempfile left behind by the atomic write. - assert!(!path.with_extension("yml.tmp").exists()); - } - - #[test] - fn malformed_file_errors_instead_of_wiping() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("youtube_playlists.yml"); - std::fs::write(&path, "playlists: [ this is not : valid").unwrap(); - assert!(load(&path).is_err()); - } - - #[test] - fn hand_written_minimal_yaml_loads() { - // A user hand-editing the file will omit optional fields. - let file: PlaylistsFile = serde_yaml::from_str( - "playlists:\n - id: abc\n name: Mine\n tracks:\n - video_id: xyz\n title: T\n", - ) - .unwrap(); - assert_eq!(file.playlists[0].tracks[0].channel, ""); - assert_eq!(file.playlists[0].tracks[0].duration_ms, 0); - } - - #[test] - fn playlist_uri_round_trip_and_video_uri_separation() { - let uri = uri_for_playlist_id("abc123"); - assert_eq!(uri, "youtube:playlist:abc123"); - assert!(is_playlist_uri(&uri)); - assert_eq!(playlist_id_from_ref(&uri), "abc123"); - assert_eq!(playlist_id_from_ref("abc123"), "abc123"); - // A playlist URI must never pass for a playable video URI. - assert!(video_id_from_uri(&uri).is_err()); - assert!(!is_playlist_uri("youtube:5NV6Rdv1a3I")); - } - - #[test] - fn conversions_round_trip_through_domain_types() { - let mut file = PlaylistsFile::default(); - let id = create_playlist(&mut file, "Mix").unwrap(); - add_track(&mut file, &id, track("vid1", "Song")).unwrap(); - - let info = playlist_to_info(&file.playlists[0]); - assert_eq!(info.uri, format!("youtube:playlist:{id}")); - assert_eq!(info.track_count, 1); - - let row = stored_to_track_info(&file.playlists[0].tracks[0]); - assert_eq!(row.uri.as_deref(), Some("youtube:vid1")); - assert_eq!(row.artists, vec!["Chan"]); - - // And back: a browse row (as search would produce it) converts to storage. - let stored = track_info_to_stored(&row).unwrap(); - assert_eq!(stored, file.playlists[0].tracks[0]); - - // Rows with a uri but no id (defensive) still convert. - let mut no_id = row.clone(); - no_id.id = None; - assert_eq!(track_info_to_stored(&no_id).unwrap().video_id, "vid1"); - - // Rows with neither are rejected. - let mut neither = row; - neither.id = None; - neither.uri = None; - assert!(track_info_to_stored(&neither).is_err()); - } - - #[test] - fn generated_ids_are_unique_and_well_formed() { - let mut file = PlaylistsFile::default(); - for i in 0..50 { - create_playlist(&mut file, &format!("p{i}")).unwrap(); - } - let mut ids: Vec<_> = file.playlists.iter().map(|p| p.id.clone()).collect(); - ids.sort(); - ids.dedup(); - assert_eq!(ids.len(), 50, "ids must be unique"); - for id in &ids { - assert_eq!(id.len(), 12); - assert!(id - .bytes() - .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())); - } - } -} diff --git a/src/infra/youtube/types.rs b/src/infra/youtube/types.rs deleted file mode 100644 index 6f15547e..00000000 --- a/src/infra/youtube/types.rs +++ /dev/null @@ -1,30 +0,0 @@ -//! Serde types for `yt-dlp --flat-playlist --dump-json` output. - -use serde::Deserialize; - -/// One video row from a `ytsearchN:` flat-playlist search. yt-dlp prints one -/// JSON object per line; flat extraction omits many fields, so everything is -/// defaulted — a sparse row must never fail the whole search. -#[derive(Debug, Deserialize)] -pub struct YtVideo { - #[serde(default)] - pub id: String, - #[serde(default)] - pub title: String, - /// Channel name. Flat rows carry `channel` and/or `uploader`; prefer - /// `channel`, fall back to `uploader`. - #[serde(default)] - pub channel: Option, - #[serde(default)] - pub uploader: Option, - /// Duration in (possibly fractional) seconds; `None` for livestreams and - /// some flat rows. - #[serde(default)] - pub duration: Option, - #[serde(default)] - pub view_count: Option, - /// Best thumbnail URL. yt-dlp emits a top-level `thumbnail` string; `None` - /// for sparse flat rows that omit it. - #[serde(default)] - pub thumbnail: Option, -} diff --git a/src/lib.rs b/src/lib.rs index f9daf322..b04b6ffb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,18 +1,5 @@ -//! spotatui as a library: one core shared by every frontend. -//! -//! The modules stay private; the public API is exactly the entry points the -//! bin shims in `src/bin/` need, nothing else. The terminal frontend is the -//! only frontend module so far: a build without `tui` (the headless CI leg) -//! turns any import of it from core/infra/cli into a compile error, which is -//! what keeps a second frontend from re-coupling to this one. +//! Spotatui Radio: an internet-radio terminal player with MPRIS control. -mod cli; -mod core; -#[cfg(test)] -mod gates; -mod infra; -mod runtime; -#[cfg(feature = "tui")] -mod tui; +mod radio_app; -pub use runtime::run_cli; +pub use radio_app::run_cli; diff --git a/src/radio_app/cli.rs b/src/radio_app/cli.rs new file mode 100644 index 00000000..8ed486de --- /dev/null +++ b/src/radio_app/cli.rs @@ -0,0 +1,119 @@ +use super::config::{self, Station}; +use super::mpris; +use anyhow::{bail, Context, Result}; +use clap::{Arg, ArgAction, ArgMatches, Command}; +use std::path::PathBuf; + +pub fn build() -> Command { + Command::new("spotatui") + .version(env!("CARGO_PKG_VERSION")) + .author(env!("CARGO_PKG_AUTHORS")) + .about("Internet radio for the terminal") + .arg( + Arg::new("config") + .long("config") + .value_name("PATH") + .global(true) + .help("Use a custom config.yml"), + ) + .subcommand( + Command::new("radio") + .about("Lists stations or controls a running Spotatui instance") + .subcommand_required(true) + .subcommand( + Command::new("list") + .about("Lists saved radio stations") + .arg( + Arg::new("json") + .long("json") + .action(ArgAction::SetTrue) + .help("Print JSON"), + ), + ) + .subcommand( + Command::new("play") + .about("Plays a stream in the running Spotatui instance") + .arg(Arg::new("url").value_name("URL").required(true)), + ), + ) +} + +pub async fn handle(matches: &ArgMatches) -> Result { + let Some(radio) = matches.subcommand_matches("radio") else { + return Ok(false); + }; + match radio.subcommand() { + Some(("list", args)) => { + let config_path = matches.get_one::("config").map(PathBuf::from); + let loaded = config::load(config_path.as_deref())?; + println!( + "{}", + render_stations(&loaded.stations, args.get_flag("json"))? + ); + } + Some(("play", args)) => { + let value = args.get_one::("url").expect("required by clap"); + let uri = normalized_uri(value)?; + mpris::open_uri(&uri).await?; + println!("Radio playback requested."); + } + _ => unreachable!("radio requires a subcommand"), + } + Ok(true) +} + +fn render_stations(stations: &[Station], json: bool) -> Result { + if json { + return serde_json::to_string(stations).context("serializing station list"); + } + if stations.is_empty() { + return Ok("No saved radio stations.".to_owned()); + } + Ok( + stations + .iter() + .map(|station| format!("{}\t{}", station.name, station.url)) + .collect::>() + .join("\n"), + ) +} + +pub fn normalized_uri(value: &str) -> Result { + let value = value.trim(); + let url = value.strip_prefix("radio:").unwrap_or(value); + let parsed = url::Url::parse(url).context("invalid radio stream URL")?; + if !matches!(parsed.scheme(), "http" | "https") { + bail!("radio stream URL must use http or https"); + } + Ok(format!("radio:{url}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_radio_urls() { + assert_eq!( + normalized_uri("https://example.com/live").unwrap(), + "radio:https://example.com/live" + ); + assert_eq!( + normalized_uri("radio:http://example.com/live").unwrap(), + "radio:http://example.com/live" + ); + assert!(normalized_uri("file:///tmp/test.mp3").is_err()); + } + + #[test] + fn renders_station_json_for_widget_consumers() { + let stations = vec![Station { + name: "Test FM".to_owned(), + url: "https://example.com/live".to_owned(), + }]; + assert_eq!( + render_stations(&stations, true).unwrap(), + r#"[{"name":"Test FM","url":"https://example.com/live"}]"# + ); + } +} diff --git a/src/radio_app/config.rs b/src/radio_app/config.rs new file mode 100644 index 00000000..d292d086 --- /dev/null +++ b/src/radio_app/config.rs @@ -0,0 +1,116 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct Station { + pub name: String, + pub url: String, +} + +#[derive(Default, Deserialize)] +struct ConfigFile { + #[serde(default)] + behavior: Behavior, +} + +#[derive(Default, Deserialize)] +struct Behavior { + #[serde(default)] + radio_stations: Vec, +} + +#[derive(Default, Deserialize)] +struct StateFile { + #[serde(default = "default_volume")] + volume_percent: u8, + #[serde(default)] + radio_stations: Vec, +} + +fn default_volume() -> u8 { + 80 +} + +pub struct LoadedConfig { + pub stations: Vec, + pub volume_percent: u8, +} + +pub fn default_config_path() -> Result { + dirs::config_dir() + .map(|dir| dir.join("spotatui/config.yml")) + .context("cannot resolve the user configuration directory") +} + +pub fn default_state_path() -> Result { + dirs::state_dir() + .map(|dir| dir.join("spotatui/state.yml")) + .context("cannot resolve the user state directory") +} + +pub fn load(config_override: Option<&Path>) -> Result { + let config_path = match config_override { + Some(path) => path.to_path_buf(), + None => default_config_path()?, + }; + let state_path = default_state_path()?; + + let config: ConfigFile = read_yaml_if_present(&config_path) + .with_context(|| format!("loading {}", config_path.display()))?; + let state: StateFile = read_yaml_if_present(&state_path) + .with_context(|| format!("loading {}", state_path.display()))?; + + let mut seen = HashSet::new(); + let stations = config + .behavior + .radio_stations + .into_iter() + .chain(state.radio_stations) + .filter_map(|station| { + let name = station.name.trim().to_owned(); + let url = station.url.trim().to_owned(); + if name.is_empty() || url.is_empty() || !seen.insert(url.clone()) { + return None; + } + Some(Station { name, url }) + }) + .collect(); + + Ok(LoadedConfig { + stations, + volume_percent: state.volume_percent.min(100), + }) +} + +fn read_yaml_if_present(path: &Path) -> Result +where + T: Default + for<'de> Deserialize<'de>, +{ + match std::fs::read_to_string(path) { + Ok(content) if content.trim().is_empty() => Ok(T::default()), + Ok(content) => serde_yaml::from_str(&content).context("parsing YAML"), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(T::default()), + Err(error) => Err(error.into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn merge_keeps_config_order_and_deduplicates_urls() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("config.yml"); + std::fs::write( + &config_path, + "behavior:\n radio_stations:\n - name: Configured\n url: https://example.com/live\n", + ) + .unwrap(); + + let parsed: ConfigFile = read_yaml_if_present(&config_path).unwrap(); + assert_eq!(parsed.behavior.radio_stations[0].name, "Configured"); + } +} diff --git a/src/radio_app/directory.rs b/src/radio_app/directory.rs new file mode 100644 index 00000000..768a288e --- /dev/null +++ b/src/radio_app/directory.rs @@ -0,0 +1,90 @@ +use super::config::Station; +use anyhow::{anyhow, Context, Result}; +use rand::seq::SliceRandom; +use serde::Deserialize; +use std::time::Duration; + +const MIRRORS: [&str; 3] = [ + "https://de1.api.radio-browser.info", + "https://de2.api.radio-browser.info", + "https://fi1.api.radio-browser.info", +]; +const TIMEOUT: Duration = Duration::from_secs(6); + +#[derive(Deserialize)] +struct DirectoryStation { + name: String, + url: String, + #[serde(default)] + url_resolved: String, + #[serde(default)] + lastcheckok: u8, +} + +pub async fn search(query: &str) -> Result> { + let query = query.trim(); + if query.is_empty() { + return Ok(Vec::new()); + } + + let client = reqwest::Client::builder() + .user_agent(concat!("spotatui/", env!("CARGO_PKG_VERSION"))) + .timeout(TIMEOUT) + .build() + .context("building radio directory client")?; + let path = format!( + "/json/stations/search?name={}&limit=30&hidebroken=true&order=votes&reverse=true", + url_encode(query) + ); + let mut mirrors = MIRRORS; + mirrors.shuffle(&mut rand::rng()); + let mut last_error = anyhow!("no radio directory mirrors configured"); + + for mirror in mirrors { + let url = format!("{mirror}{path}"); + match client + .get(url) + .send() + .await + .and_then(|response| response.error_for_status()) + { + Ok(response) => match response.json::>().await { + Ok(rows) => { + return Ok( + rows + .into_iter() + .filter(|row| row.lastcheckok == 1) + .filter_map(|row| { + let url = if row.url_resolved.is_empty() { + row.url + } else { + row.url_resolved + }; + let name = row.name.trim().to_owned(); + (!name.is_empty() && !url.is_empty()).then_some(Station { name, url }) + }) + .collect(), + ) + } + Err(error) => last_error = error.into(), + }, + Err(error) => last_error = error.into(), + } + } + + Err(last_error.context("all radio directory mirrors failed")) +} + +fn url_encode(value: &str) -> String { + let mut output = String::with_capacity(value.len()); + for byte in value.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + output.push(byte as char) + } + b' ' => output.push('+'), + other => output.push_str(&format!("%{other:02X}")), + } + } + output +} diff --git a/src/radio_app/mod.rs b/src/radio_app/mod.rs new file mode 100644 index 00000000..047381a3 --- /dev/null +++ b/src/radio_app/mod.rs @@ -0,0 +1,131 @@ +mod cli; +mod config; +mod directory; +mod mpris; +mod player; +mod stream; +#[cfg(feature = "tui")] +mod tui; + +use anyhow::{anyhow, Context, Result}; +use config::Station; +use player::{LocalPlayer, PreparedStream}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use tokio::sync::mpsc; + +pub async fn run_cli() -> Result<()> { + setup_logging()?; + let matches = cli::build().get_matches(); + if cli::handle(&matches).await? { + return Ok(()); + } + + #[cfg(feature = "tui")] + { + let config_path = matches.get_one::("config").map(PathBuf::from); + return run(config_path).await; + } + #[cfg(not(feature = "tui"))] + Err(anyhow!( + "this build has no terminal UI; use `spotatui radio`" + )) +} + +fn setup_logging() -> Result<()> { + let log_dir = dirs::state_dir() + .context("cannot resolve the user state directory")? + .join("spotatui"); + std::fs::create_dir_all(&log_dir)?; + let log_file = fern::log_file(log_dir.join("spotatui.log"))?; + fern::Dispatch::new() + .level(log::LevelFilter::Info) + .chain(log_file) + .apply() + .map_err(|error| anyhow!(error))?; + Ok(()) +} + +#[cfg(feature = "tui")] +struct Session { + station: Station, + now_playing: Arc>>, + cancel: Arc, +} + +#[cfg(feature = "tui")] +struct PreparedTune { + generation: u64, + station: Station, + stream_name: Option, + now_playing: Arc>>, + cancel: Arc, + prepared: PreparedStream, +} + +#[cfg(feature = "tui")] +enum TuneResult { + Ready(PreparedTune), + Failed { generation: u64, message: String }, +} + +#[cfg(feature = "tui")] +async fn run(config_path: Option) -> Result<()> { + let loaded = config::load(config_path.as_deref())?; + let player = Arc::new(LocalPlayer::new().context("opening the default audio output")?); + player.set_volume(loaded.volume_percent); + + let mpris = Arc::new(mpris::Manager::new()?); + let mpris_rx = mpris + .take_events() + .context("MPRIS event receiver already taken")?; + + let (tune_tx, tune_rx) = mpsc::unbounded_channel(); + let state = tui::State::new(loaded.stations, loaded.volume_percent); + tui::run(state, player, tune_tx, tune_rx, mpris, mpris_rx).await +} + +#[cfg(feature = "tui")] +fn spawn_tune(generation: u64, station: Station, tx: mpsc::UnboundedSender) { + tokio::spawn(async move { + let result = async { + let opened = stream::open_radio_stream(&station.url).await?; + let stream::OpenedStream { + reader, + cancel, + now_playing, + content_type, + station_name, + } = opened; + let cancel: Arc = Arc::from(cancel); + let decode = tokio::task::spawn_blocking(move || { + LocalPlayer::prepare_stream(reader, content_type.as_deref(), None) + }); + let prepared = match tokio::time::timeout(stream::PROBE_TIMEOUT, decode).await { + Ok(result) => result.context("radio decoder task failed")??, + Err(_) => { + cancel(); + anyhow::bail!("timed out while detecting the stream audio format"); + } + }; + Ok::(PreparedTune { + generation, + station, + stream_name: station_name, + now_playing, + cancel, + prepared, + }) + } + .await; + + let message = match result { + Ok(prepared) => TuneResult::Ready(prepared), + Err(error) => TuneResult::Failed { + generation, + message: format!("Could not play station: {error:#}"), + }, + }; + let _ = tx.send(message); + }); +} diff --git a/src/radio_app/mpris.rs b/src/radio_app/mpris.rs new file mode 100644 index 00000000..4c712a78 --- /dev/null +++ b/src/radio_app/mpris.rs @@ -0,0 +1,175 @@ +use anyhow::{Context, Result}; +use mpris_server::{Metadata, PlaybackStatus, Player}; +use std::thread; +use tokio::sync::mpsc; + +#[derive(Debug, Clone)] +pub enum Event { + PlayPause, + Play, + Pause, + Next, + Previous, + Stop, + OpenUri(String), + SetVolume(u8), +} + +#[derive(Debug, Clone)] +enum Command { + Metadata { title: String, station: String }, + PlaybackStatus(bool), + Volume(u8), + Stopped, +} + +pub struct Manager { + event_rx: std::sync::Mutex>>, + command_tx: mpsc::UnboundedSender, +} + +impl Manager { + pub fn new() -> Result { + let (event_tx, event_rx) = mpsc::unbounded_channel(); + let (command_tx, mut command_rx) = mpsc::unbounded_channel(); + + thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("failed to create MPRIS runtime"); + let local = tokio::task::LocalSet::new(); + local.block_on(&runtime, async move { + let player = match Player::builder("spotatui") + .identity("Spotatui Radio") + .desktop_entry("spotatui") + .can_play(true) + .can_pause(true) + .can_go_next(true) + .can_go_previous(true) + .can_seek(false) + .can_control(true) + .can_quit(false) + .can_raise(false) + .can_set_fullscreen(false) + .build() + .await + { + Ok(player) => player, + Err(error) => { + log::error!("failed to start MPRIS service: {error}"); + return; + } + }; + + macro_rules! connect { + ($method:ident, $event:expr) => {{ + let tx = event_tx.clone(); + player.$method(move |_player| { + let _ = tx.send($event); + }); + }}; + } + connect!(connect_play_pause, Event::PlayPause); + connect!(connect_play, Event::Play); + connect!(connect_pause, Event::Pause); + connect!(connect_next, Event::Next); + connect!(connect_previous, Event::Previous); + connect!(connect_stop, Event::Stop); + + let tx = event_tx.clone(); + player.connect_open_uri(move |_player, uri| { + let _ = tx.send(Event::OpenUri(uri.to_string())); + }); + let tx = event_tx; + player.connect_set_volume(move |_player, volume| { + let percent = (volume * 100.0).round().clamp(0.0, 100.0) as u8; + let _ = tx.send(Event::SetVolume(percent)); + }); + + tokio::task::spawn_local(player.run()); + while let Some(command) = command_rx.recv().await { + match command { + Command::Metadata { title, station } => { + let metadata = Metadata::builder() + .title(&title) + .album(&station) + .artist(vec![station.as_str()]) + .build(); + if let Err(error) = player.set_metadata(metadata).await { + log::warn!("failed to update MPRIS metadata: {error}"); + } + } + Command::PlaybackStatus(playing) => { + let status = if playing { + PlaybackStatus::Playing + } else { + PlaybackStatus::Paused + }; + if let Err(error) = player.set_playback_status(status).await { + log::warn!("failed to update MPRIS playback status: {error}"); + } + } + Command::Volume(percent) => { + if let Err(error) = player.set_volume(f64::from(percent) / 100.0).await { + log::warn!("failed to update MPRIS volume: {error}"); + } + } + Command::Stopped => { + if let Err(error) = player.set_playback_status(PlaybackStatus::Stopped).await { + log::warn!("failed to stop MPRIS playback: {error}"); + } + } + } + } + }); + }); + + Ok(Self { + event_rx: std::sync::Mutex::new(Some(event_rx)), + command_tx, + }) + } + + pub fn take_events(&self) -> Option> { + self.event_rx.lock().ok()?.take() + } + + pub fn set_metadata(&self, title: impl Into, station: impl Into) { + let _ = self.command_tx.send(Command::Metadata { + title: title.into(), + station: station.into(), + }); + } + + pub fn set_playing(&self, playing: bool) { + let _ = self.command_tx.send(Command::PlaybackStatus(playing)); + } + + pub fn set_volume(&self, percent: u8) { + let _ = self.command_tx.send(Command::Volume(percent)); + } + + pub fn set_stopped(&self) { + let _ = self.command_tx.send(Command::Stopped); + } +} + +pub async fn open_uri(uri: &str) -> Result<()> { + let connection = mpris_server::zbus::Connection::session() + .await + .context("connecting to the user D-Bus")?; + let proxy = mpris_server::zbus::Proxy::new( + &connection, + "org.mpris.MediaPlayer2.spotatui", + "/org/mpris/MediaPlayer2", + "org.mpris.MediaPlayer2.Player", + ) + .await + .context("connecting to the running Spotatui MPRIS service")?; + proxy + .call_method("OpenUri", &(uri)) + .await + .context("requesting radio playback from Spotatui")?; + Ok(()) +} diff --git a/src/infra/audio/player.rs b/src/radio_app/player.rs similarity index 92% rename from src/infra/audio/player.rs rename to src/radio_app/player.rs index de9f2c49..0bf65ca5 100644 --- a/src/infra/audio/player.rs +++ b/src/radio_app/player.rs @@ -1,27 +1,12 @@ -//! Local-file audio playback engine. +//! Decoded-audio output engine for internet radio. //! -//! [`LocalPlayer`] decodes an audio file and plays it through the system's -//! default output device, independent of librespot. The decoded audio reaches -//! the same default device the visualizer's loopback/monitor captures, so the -//! spectrum visualizer works for local playback without any extra wiring. +//! [`LocalPlayer`] keeps all `rodio` types inside this module and exposes a +//! small thread-safe transport API. The non-`Send` output stream lives on a +//! dedicated thread; the application holds only the `Send + Sync` control +//! handle. //! -//! ## Encapsulation -//! -//! All `rodio` types are contained inside this module. The public surface is a -//! small transport API (`play_file`/`pause`/`resume`/`seek`/`position`/ -//! `is_finished`/`set_volume`) that speaks `std` types only. This keeps the -//! runtime free of `rodio` and makes the platform-specific output swap below a -//! single-file change. -//! -//! ## Cross-platform output -//! -//! `rodio` drives the default output device on all three platforms, CoreAudio -//! included. macOS was gated off here until the rodio 0.22 / cpal 0.17 output -//! path was measured on a Mac (the two `#[ignore]`d tests below): the crashes -//! behind that gate (issues #9/#20) were librespot's own `rodio-backend`, an -//! older rodio on the pre-`DeviceSinkBuilder` API. librespot still uses -//! `portaudio-backend` on macOS for that reason; this player does not share -//! that code path and does not need it. +//! Output-device loss is detected and bounded so a dead audio callback cannot +//! freeze terminal input or radio-directory requests. //! //! ## Losing the output device //! @@ -172,7 +157,7 @@ fn reopen_due(attempts: u8, since_last: Option) -> bool { } /// A decoded stream, ready for [`LocalPlayer::play_prepared`]. -#[cfg(any(feature = "internet-radio", feature = "qobuz"))] +#[cfg(feature = "internet-radio")] pub struct PreparedStream(Box); impl LocalPlayer { @@ -255,7 +240,7 @@ impl LocalPlayer { /// DAC pulled), as against the OS merely moving its default output somewhere /// else. Recovery pauses only for removal — that is what macOS itself does, /// and a device the user just *plugged in* should keep playing. - #[cfg_attr(not(feature = "audio-decode-queue"), allow(dead_code))] + #[allow(dead_code)] pub fn device_removed(&self) -> bool { self.sink.lock().unwrap().lost.load(Ordering::Relaxed) } @@ -383,7 +368,7 @@ impl LocalPlayer { /// /// Radio has no track to restage, so a build with just `internet-radio` /// never recovers a device. - #[cfg_attr(not(feature = "audio-decode-queue"), allow(dead_code))] + #[allow(dead_code)] pub fn recover_device(&self) -> Reopen { let attempts = { let mut state = self.reopen.lock().unwrap(); @@ -419,10 +404,7 @@ impl LocalPlayer { /// Only the tempfile-based sources play files; a build with just /// `internet-radio` uses [`prepare_stream`](Self::prepare_stream) plus /// [`play_prepared`](Self::play_prepared) instead, and Qobuz stages. - #[cfg_attr( - not(any(feature = "local-files", feature = "subsonic", feature = "youtube")), - allow(dead_code) - )] + #[allow(dead_code)] pub fn play_file(&self, path: &Path) -> Result<()> { self.stage_file(path)?; self.resume(); @@ -432,10 +414,7 @@ impl LocalPlayer { /// [`play_file`](Self::play_file) without the final play: the track is /// queued and the sink left paused, so a caller that must seek first, or /// stay paused, never plays a burst of the track's start. - #[cfg_attr( - not(any(feature = "local-files", feature = "subsonic", feature = "qobuz")), - allow(dead_code) - )] + #[allow(dead_code)] pub fn stage_file(&self, path: &Path) -> Result<()> { let stage = self.begin_stage(); let sink = self.live_player()?; @@ -473,7 +452,7 @@ impl LocalPlayer { /// /// **Blocking:** the probe reads from the network reader; call it off the /// async runtime (e.g. `spawn_blocking`) like `play_file`. - #[cfg(any(feature = "internet-radio", feature = "qobuz"))] + #[cfg(feature = "internet-radio")] pub fn prepare_stream( reader: R, mime_type: Option<&str>, @@ -500,8 +479,7 @@ impl LocalPlayer { /// Play a prepared stream, replacing whatever was playing. The clear waits /// for the audio thread to drop the previous source: call it off the `App` /// lock (see `stop_detached`). Fails once the device is gone. - #[cfg(any(feature = "internet-radio", feature = "qobuz"))] - #[cfg_attr(not(feature = "internet-radio"), allow(dead_code))] + #[cfg(feature = "internet-radio")] pub fn play_prepared(&self, stream: PreparedStream) -> Result<()> { self.stage_prepared(stream)?; self.resume(); @@ -510,7 +488,7 @@ impl LocalPlayer { /// [`play_prepared`](Self::play_prepared) without the final play, like /// [`stage_file`](Self::stage_file). - #[cfg(any(feature = "internet-radio", feature = "qobuz"))] + #[cfg(feature = "internet-radio")] pub fn stage_prepared(&self, stream: PreparedStream) -> Result<()> { let stage = self.begin_stage(); let sink = self.live_player()?; @@ -542,29 +520,13 @@ impl LocalPlayer { self.bounded(move || sink.clear()); } - /// Stop on the blocking pool and return at once. `stop` waits for the audio - /// thread, which a stalled network source holds until its stall timeout. - pub fn stop_detached(self: std::sync::Arc) { - tokio::task::spawn_blocking(move || self.stop()); - } - - /// [`stop_detached`](Self::stop_detached) with `session` kept alive until - /// the stop returns, so a tempfile the sink still reads is not deleted - /// under it. - pub fn stop_detached_holding(self: std::sync::Arc, session: S) { - tokio::task::spawn_blocking(move || { - self.stop(); - drop(session); - }); - } - - /// Set the output volume from the user's percent, on the same logarithmic - /// curve as native streaming, so one setting is equally loud on every source. + /// Set the output volume from the user's percent on a logarithmic curve. pub fn set_volume(&self, percent: u8) { self.volume_percent.store(percent, Ordering::Relaxed); self.player().set_volume(volume_gain(percent)); } + #[cfg(test)] /// The playback position of the current source. pub fn position(&self) -> Duration { self.player().get_pos() @@ -576,10 +538,7 @@ impl LocalPlayer { /// /// Radio never polls this (an infinite stream has no end-of-track), so it is /// dead code in a build with just `internet-radio`. - #[cfg_attr( - not(any(feature = "local-files", feature = "subsonic", feature = "qobuz")), - allow(dead_code) - )] + #[allow(dead_code)] pub fn is_finished(&self) -> bool { self.player().empty() } @@ -588,10 +547,7 @@ impl LocalPlayer { /// /// Radio consumes `Seek` as a no-op (nothing to seek within a live stream), /// so this is dead code in a build with just `internet-radio`. - #[cfg_attr( - not(any(feature = "local-files", feature = "subsonic", feature = "qobuz")), - allow(dead_code) - )] + #[allow(dead_code)] pub fn seek(&self, pos: Duration) -> Result<()> { let sink = self.live_player()?; match self.bounded(move || sink.try_seek(pos)) { @@ -706,8 +662,7 @@ fn default_output_name() -> Option { // Tests // --------------------------------------------------------------------------- -/// The gain for a volume percent: librespot's `VolumeCtrl::Log` mapping over -/// a 60 dB range (`1000^(p/100 - 1)`), which the native player uses by default. +/// The gain for a volume percent on a 60 dB logarithmic curve. /// 0% is silence, 100% is the file level, and 80% is about -12 dB. pub fn volume_gain(percent: u8) -> f32 { const DB_RATIO: f64 = 1000.0; diff --git a/src/infra/radio/stream.rs b/src/radio_app/stream.rs similarity index 98% rename from src/infra/radio/stream.rs rename to src/radio_app/stream.rs index 603e9087..4c2985aa 100644 --- a/src/infra/radio/stream.rs +++ b/src/radio_app/stream.rs @@ -31,7 +31,7 @@ use stream_download::storage::bounded::BoundedStorageProvider; use stream_download::storage::memory::MemoryStorageProvider; use stream_download::{Settings, StreamDownload}; -/// The bounds [`LocalPlayer::prepare_stream`](crate::infra::audio::LocalPlayer::prepare_stream) +/// The bounds [`LocalPlayer::prepare_stream`](super::player::LocalPlayer::prepare_stream) /// requires, as one nameable trait so the reader stack can be boxed. pub trait StreamReader: Read + Seek + Send + Sync {} impl StreamReader for T {} @@ -65,7 +65,7 @@ pub(super) const PROBE_TIMEOUT: Duration = Duration::from_secs(10); const HEADER_TIMEOUT: Duration = Duration::from_secs(15); /// A successfully opened radio stream, ready to hand to -/// [`LocalPlayer::prepare_stream`](crate::infra::audio::LocalPlayer::prepare_stream). +/// [`LocalPlayer::prepare_stream`](super::player::LocalPlayer::prepare_stream). pub struct OpenedStream { /// The decodable audio byte stream (ICY metadata already stripped). pub reader: Box, @@ -232,7 +232,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] #[ignore = "hits the live SomaFM stream AND requires an audio output device"] async fn live_stream_plays_through_sink() { - use crate::infra::audio::LocalPlayer; + use super::super::player::LocalPlayer; use std::time::Duration; let opened = open_radio_stream("https://ice1.somafm.com/groovesalad-128-mp3") diff --git a/src/radio_app/tui.rs b/src/radio_app/tui.rs new file mode 100644 index 00000000..52aaa161 --- /dev/null +++ b/src/radio_app/tui.rs @@ -0,0 +1,466 @@ +use super::config::Station; +use super::directory; +use super::mpris; +use super::{spawn_tune, LocalPlayer, PreparedTune, Session, TuneResult}; +use anyhow::{Context, Result}; +use crossterm::event::{self, Event as TerminalEvent, KeyCode, KeyEvent, KeyModifiers}; +use crossterm::execute; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::Line; +use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; +use ratatui::Terminal; +use std::io::{self, Stdout}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::mpsc; + +enum UiEvent { + Key(KeyEvent), + Search(Result, String>), +} + +pub(super) struct State { + stations: Vec, + selected: usize, + volume: u8, + status: String, + search_input: Option, + searching: bool, + session: Option, + generation: u64, + last_title: Option, +} + +impl State { + pub(super) fn new(stations: Vec, volume: u8) -> Self { + let status = if stations.is_empty() { + "No saved stations. Press / to search the radio directory.".to_owned() + } else { + "Enter play / search Space pause +/- volume q quit".to_owned() + }; + Self { + stations, + selected: 0, + volume, + status, + search_input: None, + searching: false, + session: None, + generation: 0, + last_title: None, + } + } + + fn selected_station(&self) -> Option { + self.stations.get(self.selected).cloned() + } + + fn now_playing(&self) -> Option { + self.session.as_ref()?.now_playing.lock().ok()?.clone() + } +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn run( + mut state: State, + player: Arc, + tune_tx: mpsc::UnboundedSender, + mut tune_rx: mpsc::UnboundedReceiver, + mpris: Arc, + mut mpris_rx: mpsc::UnboundedReceiver, +) -> Result<()> { + let mut terminal = open_terminal()?; + let running = Arc::new(AtomicBool::new(true)); + let (ui_tx, mut ui_rx) = mpsc::unbounded_channel(); + spawn_input_thread(Arc::clone(&running), ui_tx.clone()); + let mut tick = tokio::time::interval(Duration::from_millis(200)); + + mpris.set_volume(state.volume); + + let result = async { + loop { + terminal.draw(|frame| draw(frame, &state))?; + tokio::select! { + _ = tick.tick() => { + let title = state.now_playing(); + if title != state.last_title { + state.last_title = title.clone(); + if let Some(session) = &state.session { + mpris.set_metadata(title.unwrap_or_else(|| session.station.name.clone()), session.station.name.clone()); + } + } + if state.session.is_some() && player.is_finished() { + stop(&mut state, &player); + state.status = "The radio stream ended.".to_owned(); + mpris.set_stopped(); + } + } + Some(event) = ui_rx.recv() => match event { + UiEvent::Key(key) => { + if handle_key(key, &mut state, &player, &tune_tx, &ui_tx, + &mpris).await? { + break; + } + } + UiEvent::Search(result) => { + state.searching = false; + match result { + Ok(stations) if stations.is_empty() => state.status = "No stations found.".to_owned(), + Ok(stations) => { + let count = stations.len(); + state.stations = stations; + state.selected = 0; + state.status = format!("Found {count} stations. Enter plays the selection."); + } + Err(message) => state.status = message, + } + } + }, + Some(result) = tune_rx.recv() => finish_tune(result, &mut state, &player, + &mpris), + Some(event) = mpris_rx.recv() => handle_mpris(event, &mut state, &player, &tune_tx, &mpris), + } + } + Ok::<(), anyhow::Error>(()) + }.await; + + running.store(false, Ordering::Relaxed); + stop(&mut state, &player); + close_terminal(&mut terminal)?; + result +} + +fn open_terminal() -> Result>> { + enable_raw_mode().context("enabling terminal raw mode")?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen).context("entering alternate screen")?; + Terminal::new(CrosstermBackend::new(stdout)).context("creating terminal") +} + +fn close_terminal(terminal: &mut Terminal>) -> Result<()> { + disable_raw_mode().context("disabling terminal raw mode")?; + execute!(terminal.backend_mut(), LeaveAlternateScreen).context("leaving alternate screen")?; + terminal.show_cursor().context("showing cursor") +} + +fn spawn_input_thread(running: Arc, tx: mpsc::UnboundedSender) { + std::thread::spawn(move || { + while running.load(Ordering::Relaxed) { + if event::poll(Duration::from_millis(100)).unwrap_or(false) { + if let Ok(TerminalEvent::Key(key)) = event::read() { + let _ = tx.send(UiEvent::Key(key)); + } + } + } + }); +} + +fn draw(frame: &mut ratatui::Frame<'_>, state: &State) { + let area = frame.area(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(4), + Constraint::Length(3), + ]) + .split(area); + + let title = state + .session + .as_ref() + .map(|session| { + state + .now_playing() + .unwrap_or_else(|| format!("LIVE — {}", session.station.name)) + }) + .unwrap_or_else(|| "Spotatui Radio".to_owned()); + frame.render_widget( + Paragraph::new(Line::styled( + title, + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + )) + .block(Block::default().borders(Borders::ALL)), + chunks[0], + ); + + let items = state + .stations + .iter() + .map(|station| ListItem::new(station.name.clone())) + .collect::>(); + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title("Stations")) + .highlight_symbol("▶ ") + .highlight_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ); + let mut list_state = + ListState::default().with_selected((!state.stations.is_empty()).then_some(state.selected)); + frame.render_stateful_widget(list, chunks[1], &mut list_state); + + let footer = if let Some(input) = &state.search_input { + format!( + "Search: {input}{}", + if state.searching { " …" } else { "_" } + ) + } else { + format!("{} | Volume {}%", state.status, state.volume) + }; + frame.render_widget( + Paragraph::new(footer).block(Block::default().borders(Borders::ALL)), + chunks[2], + ); +} + +async fn handle_key( + key: KeyEvent, + state: &mut State, + player: &Arc, + tune_tx: &mpsc::UnboundedSender, + ui_tx: &mpsc::UnboundedSender, + mpris: &mpris::Manager, +) -> Result { + if let Some(input) = state.search_input.as_mut() { + match key.code { + KeyCode::Esc => state.search_input = None, + KeyCode::Backspace => { + input.pop(); + } + KeyCode::Enter if !state.searching => { + let query = input.trim().to_owned(); + if !query.is_empty() { + state.searching = true; + let tx = ui_tx.clone(); + tokio::spawn(async move { + let result = directory::search(&query) + .await + .map_err(|error| format!("Radio search failed: {error:#}")); + let _ = tx.send(UiEvent::Search(result)); + }); + } + } + KeyCode::Char(character) + if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => + { + input.push(character) + } + _ => {} + } + return Ok(false); + } + + match key.code { + KeyCode::Char('q') | KeyCode::Esc => return Ok(true), + KeyCode::Up | KeyCode::Char('k') => select_previous(state), + KeyCode::Down | KeyCode::Char('j') => select_next(state), + KeyCode::Enter => play_selected(state, player, tune_tx), + KeyCode::Char('/') => state.search_input = Some(String::new()), + KeyCode::Char(' ') => toggle(state, player, mpris), + KeyCode::Char('+') | KeyCode::Char('=') => { + set_volume(state, player, state.volume.saturating_add(5), mpris) + } + KeyCode::Char('-') => set_volume(state, player, state.volume.saturating_sub(5), mpris), + KeyCode::Char('s') => { + stop(state, player); + mpris.set_stopped(); + } + _ => {} + } + Ok(false) +} + +fn play_selected( + state: &mut State, + player: &Arc, + tx: &mpsc::UnboundedSender, +) { + if let Some(station) = state.selected_station() { + begin_tune(state, player, tx, station); + } +} + +fn begin_tune( + state: &mut State, + player: &Arc, + tx: &mpsc::UnboundedSender, + station: Station, +) { + stop(state, player); + state.generation = state.generation.wrapping_add(1); + state.status = format!("Connecting to {}…", station.name); + spawn_tune(state.generation, station, tx.clone()); +} + +fn finish_tune( + result: TuneResult, + state: &mut State, + player: &Arc, + mpris: &mpris::Manager, +) { + match result { + TuneResult::Ready(PreparedTune { + generation, + mut station, + stream_name, + now_playing, + cancel, + prepared, + }) => { + if generation != state.generation { + cancel(); + return; + } + if station.name == station.url { + if let Some(name) = stream_name { + station.name = name; + } + } + player.stop(); + player.set_volume(state.volume); + match player.play_prepared(prepared) { + Ok(()) => { + state.status = format!("Playing {}", station.name); + state.last_title = None; + { + mpris.set_metadata(station.name.clone(), station.name.clone()); + mpris.set_playing(true); + } + state.session = Some(Session { + station, + now_playing, + cancel, + }); + } + Err(error) => { + cancel(); + state.status = format!("Could not start audio: {error:#}"); + } + } + } + TuneResult::Failed { + generation, + message, + } if generation == state.generation => state.status = message, + TuneResult::Failed { .. } => {} + } +} + +fn toggle(state: &mut State, player: &Arc, mpris: &mpris::Manager) { + if state.session.is_none() { + return; + } + if player.is_paused() { + player.resume(); + state.status = "Playing".to_owned(); + mpris.set_playing(true); + } else { + player.pause(); + state.status = "Paused".to_owned(); + mpris.set_playing(false); + } +} + +fn stop(state: &mut State, player: &Arc) { + state.generation = state.generation.wrapping_add(1); + if let Some(session) = state.session.take() { + (session.cancel)(); + } + player.stop(); +} + +fn set_volume(state: &mut State, player: &Arc, percent: u8, mpris: &mpris::Manager) { + state.volume = percent.min(100); + player.set_volume(state.volume); + mpris.set_volume(state.volume); +} + +fn select_next(state: &mut State) { + if !state.stations.is_empty() { + state.selected = (state.selected + 1) % state.stations.len(); + } +} + +fn select_previous(state: &mut State) { + if !state.stations.is_empty() { + state.selected = (state.selected + state.stations.len() - 1) % state.stations.len(); + } +} + +fn handle_mpris( + event: mpris::Event, + state: &mut State, + player: &Arc, + tx: &mpsc::UnboundedSender, + mpris: &mpris::Manager, +) { + match event { + mpris::Event::PlayPause => { + if state.session.is_some() { + toggle(state, player, mpris); + } else { + play_selected(state, player, tx); + } + } + mpris::Event::Play => { + if state.session.is_some() { + if player.is_paused() { + toggle(state, player, mpris); + } + } else { + play_selected(state, player, tx); + } + } + mpris::Event::Pause => { + if state.session.is_some() && !player.is_paused() { + toggle(state, player, mpris); + } + } + mpris::Event::Next => { + select_next(state); + play_selected(state, player, tx); + } + mpris::Event::Previous => { + select_previous(state); + play_selected(state, player, tx); + } + mpris::Event::Stop => { + stop(state, player); + mpris.set_stopped(); + } + mpris::Event::SetVolume(percent) => set_volume(state, player, percent, mpris), + mpris::Event::OpenUri(uri) => { + let url = uri.strip_prefix("radio:").unwrap_or(&uri); + if url::Url::parse(url) + .ok() + .is_some_and(|parsed| matches!(parsed.scheme(), "http" | "https")) + { + let station = state + .stations + .iter() + .position(|station| station.url == url) + .map(|index| { + state.selected = index; + state.stations[index].clone() + }) + .unwrap_or_else(|| Station { + name: url.to_owned(), + url: url.to_owned(), + }); + begin_tune(state, player, tx, station); + } else { + state.status = "MPRIS rejected a non-HTTP radio URI.".to_owned(); + } + } + } +} diff --git a/src/runtime/bootstrap.rs b/src/runtime/bootstrap.rs deleted file mode 100644 index 8d339b3a..00000000 --- a/src/runtime/bootstrap.rs +++ /dev/null @@ -1,1021 +0,0 @@ -//! The frontend-neutral bootstrap: logging, the panic hook, the ALSA error -//! silencer, config/state loading with migrations, Spotify authentication, -//! and `App` construction. `boot()` is the one entry point; `run_cli()` is -//! its only caller today, and a future windowed entry point boots through -//! the same sequence. - -use crate::core::app::App; -use crate::core::auth; -use crate::core::config::ClientConfig; -use crate::core::limits::MAX_PLAYBAR_ROWS; -use crate::core::migrations::{ - apply_legacy_config_radio_station_migration, apply_legacy_config_runtime_state_migration, -}; -use crate::core::onboarding::{Onboarding, OnboardingAnswer, OnboardingPrompt}; -use crate::core::state::{PersistedRuntimeState, RuntimeState}; -use crate::core::user_config::{ - validate_tick_rate_milliseconds, BehaviorConfig, StartupBehavior, UserConfig, UserConfigPaths, -}; -use crate::infra::network::IoEvent; -use anyhow::{Context, Result}; -use backtrace::Backtrace; -use clap::ArgMatches; -use log::info; -#[cfg(feature = "streaming")] -use rspotify::model::user::PrivateUser; -use rspotify::AuthCodePkceSpotify; -use std::{ - fs, - io::Write, - panic, - path::{Path, PathBuf}, - sync::Arc, -}; -use tokio::sync::Mutex; - -#[cfg(all(target_os = "linux", feature = "streaming"))] -mod alsa_silence { - use std::os::raw::{c_char, c_int}; - - type SndLibErrorHandlerT = - Option; - - extern "C" { - fn snd_lib_error_set_handler(handler: SndLibErrorHandlerT) -> c_int; - } - - unsafe extern "C" fn silent_error_handler( - _file: *const c_char, - _line: c_int, - _function: *const c_char, - _err: c_int, - _fmt: *const c_char, - ) { - } - - pub fn suppress_alsa_errors() { - unsafe { - snd_lib_error_set_handler(Some(silent_error_handler)); - } - } -} - -#[cfg(all(target_os = "linux", feature = "streaming"))] -pub(super) fn init_audio_backend() { - alsa_silence::suppress_alsa_errors(); -} - -#[cfg(not(all(target_os = "linux", feature = "streaming")))] -pub(super) fn init_audio_backend() {} - -pub(super) fn setup_logging() -> anyhow::Result<()> { - let log_dir = crate::core::paths::app_log_dir(); - let log_path = crate::core::paths::app_log_path(); - - // Owner-only, not plain create_dir_all: this sits in the shared OS temp - // directory under a predictable name, so the default mode would leave the - // logs readable by every other local user. - crate::core::paths::ensure_private_dir(&log_dir).map_err(|e| { - anyhow::anyhow!( - "Failed to create log directory {}: {}", - log_dir.display(), - e - ) - })?; - // define format of log messages. - fern::Dispatch::new() - .format(|out, message, record| { - out.finish(format_args!( - "{}[{}][{}] {}", - chrono::Local::now().format("[%Y-%m-%d][%H:%M:%S]"), - record.target(), - record.level(), - message - )) - }) - .level(log::LevelFilter::Info) - .chain(fern::log_file(&log_path)?) // Use the dynamic path - .apply() - .map_err(|e| anyhow::anyhow!("Failed to initialize logger: {}", e))?; - - // Print the location of log for user reference — on stderr, not stdout. - // - // stdout belongs to program output: `spotatui history recap` writes HTML there - // and `spotatui mcp` writes JSON-RPC there, where the MCP spec is explicit - // that a server "MUST NOT write anything to its stdout that is not a valid MCP - // message" (a stray line makes every client fail to start). A diagnostic notice - // is exactly what stderr is for, and it stays visible on a terminal either way. - eprintln!("Logging to: {}", log_path.display()); - - Ok(()) -} - -pub(super) fn install_panic_hook() { - let default_hook = panic::take_hook(); - panic::set_hook(Box::new(move |info| { - let is_audio_backend_panic = info - .location() - .map(|location| { - let file = location.file(); - file.contains("audio_backend/portaudio.rs") || file.contains("audio_backend/rodio.rs") - }) - .unwrap_or(false); - - if is_audio_backend_panic { - eprintln!( - "Recoverable audio backend panic detected. Playback may pause while the output device changes." - ); - return; - } - - #[cfg(feature = "tui")] - ratatui::restore(); - let panic_log_path = - crate::core::paths::app_state_dir().map(|dir| dir.join("spotatui_panic.log")); - - if let Some(path) = panic_log_path.as_ref() { - if let Some(parent) = path.parent() { - // Owner-only like every other state-dir creation: a panic can be the - // first thing that ever creates the directory. - let _ = crate::core::paths::ensure_private_dir(parent); - } - if let Ok(mut f) = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(path) - { - let _ = writeln!(f, "\n==== spotatui panic ===="); - let _ = writeln!(f, "{}", info); - let _ = writeln!(f, "{:?}", Backtrace::new()); - } - eprintln!("A crash log was written to: {}", path.to_string_lossy()); - } - default_hook(info); - - if cfg!(debug_assertions) && std::env::var_os("RUST_BACKTRACE").is_none() { - eprintln!("{:?}", Backtrace::new()); - } - - if cfg!(target_os = "windows") && std::env::var_os("SPOTATUI_PAUSE_ON_PANIC").is_some() { - eprintln!("Press Enter to close..."); - let mut s = String::new(); - let _ = std::io::stdin().read_line(&mut s); - } - })); -} - -/// Abbreviate a Spotify client ID for user-facing messages: enough to tell two -/// apart, short enough to fit a status line. The 8-char prefix is also what -/// names the token cache file, so it is recognisable. -fn short_client_id(client_id: &str) -> String { - let head: String = client_id.chars().take(8).collect(); - if client_id.chars().count() > 8 { - format!("{head}…") - } else { - head - } -} - -fn name_client_id(client_id: &str) -> String { - if client_id == crate::core::config::NCSPOT_CLIENT_ID { - "the shared ncspot client ID".to_string() - } else { - format!("your own app ({})", short_client_id(client_id)) - } -} - -/// Phrase an [`auth::ClientIdNotice`] for the user. It is informational: -/// nothing is broken, and it must not read as if the user misconfigured -/// something. -fn describe_client_id_notice(notice: auth::ClientIdNotice) -> String { - match notice { - auth::ClientIdNotice::FellBack { - from_client_id, - to_client_id, - } if to_client_id == crate::core::config::NCSPOT_CLIENT_ID => format!( - "No usable Spotify session for {}; signed in with the shared ncspot client ID, whose Spotify rate limit every user shares. Run `spotatui --reconfigure-auth` and choose 2 to sign in with your own app.", - name_client_id(&from_client_id) - ), - auth::ClientIdNotice::FellBack { - from_client_id, - to_client_id, - } => format!( - "No usable Spotify session for {}; signed in with {} instead.", - name_client_id(&from_client_id), - name_client_id(&to_client_id) - ), - } -} - -/// Prompt once for the anonymous global song counter opt-in and persist the -/// choice into `config.yml`. Asked before the first-run source picker so the -/// answer applies to whichever source(s) the user sets up. Non-interactive runs -/// default to opt-out so telemetry is never enabled for a user we couldn't ask. -fn prompt_global_song_count_opt_in( - user_config: &mut UserConfig, - onboarding: &dyn Onboarding, -) -> Result<()> { - let config_paths = match &user_config.path_to_config { - Some(path) => path, - None => { - user_config.get_or_build_paths()?; - user_config.path_to_config.as_ref().unwrap() - } - }; - - // A genuine first run has no `client.yml` yet — the source picker keys off the - // same signal, so asking here keeps the two in step even when a stale config - // (e.g. one silently opted out by an older build) already carries the setting. - // Outside a first run, only ask when the config predates the setting. - let client_yml_exists = config_paths - .config_file_path - .parent() - .map(|dir| dir.join("client.yml").exists()) - .unwrap_or(false); - - let config_has_answer = config_paths.config_file_path.exists() && { - let config_string = fs::read_to_string(&config_paths.config_file_path)?; - !config_string.trim().is_empty() && config_string.contains("enable_global_song_count") - }; - - if !should_prompt_global_song_count(client_yml_exists, config_has_answer) { - return Ok(()); - } - - let interactive = onboarding.is_interactive(); - let enable = if interactive { - matches!( - onboarding.ask(&global_song_counter_prompt())?, - OnboardingAnswer::Yes - ) - } else { - // Never enable anonymous telemetry for a user we couldn't prompt. - false - }; - - user_config.behavior.enable_global_song_count = enable; - - if let Err(e) = persist_global_song_count(&config_paths.config_file_path, enable) { - log::warn!( - "could not save the global song counter answer to {}: {e}", - config_paths.config_file_path.display() - ); - if interactive { - onboarding.info(&format!( - "Could not save your answer to {} (read-only config?). Set `enable_global_song_count` under `behavior:` there to stop this prompt.\n", - config_paths.config_file_path.display() - )); - } - return Ok(()); - } - - if interactive { - if enable { - onboarding.info("Thank you for participating!\n"); - } else { - onboarding.info("Opted out. You can change this anytime in Settings -> Behavior.\n"); - } - } - - Ok(()) -} - -/// A genuine first run has no answer yet; a stale config predating the setting -/// is asked again even when `client.yml` exists. -fn should_prompt_global_song_count(client_yml_exists: bool, config_has_answer: bool) -> bool { - !client_yml_exists || !config_has_answer -} - -/// How boot obtains a Spotify session. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -enum SpotifyAuthMode { - /// Open the browser when no usable token is cached. - Interactive, - /// A cached token or a failed boot with a clear message. - CachedOrFail, - /// A cached token or no session; the frontend offers the login. - CachedOrNone, -} - -/// Interactive only right after the client wizard (fresh install, -/// `--reconfigure-auth`, or the auth-setup migration): the user just asked for -/// Spotify. A subcommand needs a session; a UI launch never blocks on a browser. -fn spotify_auth_mode( - subcommand: bool, - reconfigure_auth: bool, - wizard_ran: bool, -) -> SpotifyAuthMode { - if reconfigure_auth || wizard_ran { - SpotifyAuthMode::Interactive - } else if subcommand { - SpotifyAuthMode::CachedOrFail - } else { - SpotifyAuthMode::CachedOrNone - } -} - -fn global_song_counter_prompt() -> OnboardingPrompt { - OnboardingPrompt::Confirm { - title: "Global Song Counter".to_string(), - body: "\nspotatui can contribute to a global counter showing total\nsongs played by all users worldwide.\n\nPrivacy: This feature is completely anonymous.\n• No personal information is collected\n• No song names, artists, or listening history\n• Only a simple increment when a new song starts".to_string(), - question: "\nWould you like to participate? (Y/n): ".to_string(), - } -} - -fn auth_setup_migration_prompt() -> OnboardingPrompt { - OnboardingPrompt::Confirm { - title: "Authentication Setup Update".to_string(), - body: "\nConfiguration handling has changed and your authentication setup may need an update." - .to_string(), - question: "Would you like to run the new auth setup wizard now? (Y/n): ".to_string(), - } -} - -/// Ask about the auth-setup migration. Deliberately NOT gated on -/// interactivity: piped or closed stdin reads as empty input, which answers -/// yes and runs the wizard, exactly as before this went through `Onboarding`. -fn ask_auth_setup_migration(onboarding: &dyn Onboarding) -> Result { - Ok(matches!( - onboarding.ask(&auth_setup_migration_prompt())?, - OnboardingAnswer::Yes - )) -} - -/// Merge `enable` into the `behavior` section of `config.yml`, creating the file -/// when absent. Sibling keys are preserved; this hand-patch bypasses -/// `UserConfig::save_config` on purpose: a full save writes every default back -/// into a config that predates the setting, and a written key reads as chosen. -fn persist_global_song_count(config_file_path: &Path, enable: bool) -> Result<()> { - let config_yml = if config_file_path.exists() { - fs::read_to_string(config_file_path).unwrap_or_default() - } else { - String::new() - }; - - let mut config: serde_yaml::Value = if config_yml.trim().is_empty() { - serde_yaml::Value::Mapping(serde_yaml::Mapping::new()) - } else { - serde_yaml::from_str(&config_yml)? - }; - - if let serde_yaml::Value::Mapping(ref mut map) = config { - let behavior = map - .entry(serde_yaml::Value::String("behavior".to_string())) - .or_insert(serde_yaml::Value::Mapping(serde_yaml::Mapping::new())); - - if let serde_yaml::Value::Mapping(ref mut behavior_map) = behavior { - behavior_map.insert( - serde_yaml::Value::String("enable_global_song_count".to_string()), - serde_yaml::Value::Bool(enable), - ); - } - } - - let updated_config = serde_yaml::to_string(&config)?; - auth::write_private_file_atomic(config_file_path, updated_config.as_bytes())?; - - Ok(()) -} - -fn apply_configured_runtime_defaults( - runtime_state: &mut RuntimeState, - persisted_state: &PersistedRuntimeState, - behavior: &BehaviorConfig, -) -> PersistedRuntimeState { - let mut applied_defaults = PersistedRuntimeState::default(); - - if persisted_state.volume_percent.is_none() { - if let Some(volume_percent) = behavior.volume_percent { - runtime_state.volume_percent = volume_percent.min(100); - applied_defaults.volume_percent = Some(runtime_state.volume_percent); - } - } - if persisted_state.sidebar_width_percent.is_none() { - if let Some(sidebar_width_percent) = behavior.sidebar_width_percent { - runtime_state.sidebar_width_percent = sidebar_width_percent.min(100); - applied_defaults.sidebar_width_percent = Some(runtime_state.sidebar_width_percent); - } - } - if persisted_state.playbar_height_rows.is_none() { - if let Some(playbar_height_rows) = behavior.playbar_height_rows { - runtime_state.playbar_height_rows = playbar_height_rows.min(MAX_PLAYBAR_ROWS); - applied_defaults.playbar_height_rows = Some(runtime_state.playbar_height_rows); - } - } - if persisted_state.library_height_percent.is_none() { - if let Some(library_height_percent) = behavior.library_height_percent { - runtime_state.library_height_percent = library_height_percent.min(100); - applied_defaults.library_height_percent = Some(runtime_state.library_height_percent); - } - } - - applied_defaults -} - -/// Everything the shared bootstrap hands to whichever consumer runs next -/// (CLI dispatch or a frontend launch). Several fields are read by only one -/// consumer; each `cfg_attr` allowance names the build that leaves the field -/// unread. -pub(super) struct Boot { - pub(super) app: Arc>, - /// Consumed by the IoEvent pump. CLI mode holds it (unread) for the length - /// of the command so an `App::dispatch` during CLI handling is silently - /// queued rather than logged as a send error. - #[cfg_attr(not(feature = "tui"), allow(dead_code))] - pub(super) sync_io_rx: std::sync::mpsc::Receiver, - pub(super) user_config: UserConfig, - pub(super) client_config: ClientConfig, - pub(super) spotify: Option, - pub(super) token_cache_path: PathBuf, - /// Read after boot only by the frontend launch's streaming startup. - #[cfg_attr(not(all(feature = "tui", feature = "streaming")), allow(dead_code))] - pub(super) runtime_state: RuntimeState, - #[cfg_attr(not(feature = "tui"), allow(dead_code))] - pub(super) restore_playback: Option, - #[cfg_attr(not(feature = "tui"), allow(dead_code))] - pub(super) restore_queue: Vec, - #[cfg_attr(not(feature = "tui"), allow(dead_code))] - pub(super) initial_shuffle_enabled: bool, - #[cfg_attr(not(feature = "tui"), allow(dead_code))] - pub(super) initial_startup_behavior: StartupBehavior, - #[cfg_attr(not(feature = "tui"), allow(dead_code))] - pub(super) client_id_notice_message: Option, - /// Read after boot only by the frontend launch's streaming startup. - #[cfg_attr(not(all(feature = "tui", feature = "streaming")), allow(dead_code))] - pub(super) onboarding: Arc, - #[cfg(feature = "streaming")] - #[cfg_attr(not(feature = "tui"), allow(dead_code))] - pub(super) cached_me: Option, - #[cfg(feature = "streaming")] - #[cfg_attr(not(feature = "tui"), allow(dead_code))] - pub(super) selected_redirect_uri: String, -} - -/// The shared bootstrap sequence: user config, runtime state, the persisted -/// playback session, client credentials, Spotify authentication (joined with -/// the auto-update check), and `App` construction. Frontend-neutral: every -/// interactive step goes through `onboarding`. -pub(super) async fn boot(matches: &ArgMatches, onboarding: Arc) -> Result { - // Auto-update on launch: silently check, download, install, and restart. - // Skip if a CLI subcommand is active or SPOTATUI_SKIP_UPDATE is set (prevents restart loops). - let mut user_config = UserConfig::new(); - if let Some(config_file_path) = matches.get_one::("config") { - let config_file_path = PathBuf::from(config_file_path); - let path = UserConfigPaths { config_file_path }; - user_config.path_to_config.replace(path); - } - user_config.load_config()?; - let mut runtime_state = RuntimeState::default(); - let mut persisted_state = PersistedRuntimeState::default(); - let mut state_path = None; - let mut should_save_initial_state = false; - let mut can_save_initial_state = false; - match crate::core::state::default_state_path() { - Ok(path) => { - let state_file_exists = path.exists(); - match crate::core::state::load(&path) { - Ok(state) => { - persisted_state = state; - runtime_state.apply_persisted(&persisted_state); - if !state_file_exists { - should_save_initial_state = true; - } - can_save_initial_state = true; - state_path = Some(path); - } - Err(e) => { - log::warn!("[state] ignoring unreadable runtime state: {e}"); - } - } - } - Err(e) => { - log::warn!("[state] runtime state path is unavailable: {e}"); - } - } - let default_fields = - apply_configured_runtime_defaults(&mut runtime_state, &persisted_state, &user_config.behavior); - let legacy_radio_fields = apply_legacy_config_radio_station_migration( - &mut runtime_state, - &persisted_state, - &user_config.behavior, - ); - let legacy_runtime_fields = match user_config - .path_to_config - .as_ref() - .map(|paths| paths.config_file_path.clone()) - { - Some(config_path) => match apply_legacy_config_runtime_state_migration( - &config_path, - &mut runtime_state, - &persisted_state, - ) { - Ok(fields) => fields, - Err(e) => { - log::warn!("[state] failed to read legacy runtime fields from config.yml: {e}"); - PersistedRuntimeState::default() - } - }, - None => PersistedRuntimeState::default(), - }; - let state = if should_save_initial_state { - runtime_state.to_persisted() - } else { - let mut state = default_fields; - state.merge_patch(&legacy_radio_fields); - state.merge_patch(&legacy_runtime_fields); - state - }; - if can_save_initial_state && !state.is_empty() { - if let Some(path) = &state_path { - if let Err(e) = crate::core::state::save(path, &state) { - log::warn!("[state] failed to save initial runtime state: {e}"); - } - } - } - info!("user config loaded successfully"); - - let initial_shuffle_enabled = runtime_state.shuffle_enabled; - let initial_startup_behavior = user_config.behavior.startup_behavior; - - // Load the persisted non-Spotify playback session so the last song can resume - // on launch. The file's mere existence means a non-Spotify source was playing - // at the last save: the runner clears it whenever playback stops or switches - // to Spotify, so a present file is always something worth resuming — even when - // the browse source was later switched to Spotify while the song kept playing - // (browse-source and playback-source are deliberately decoupled). A session - // whose source feature isn't compiled into this build is a no-op on restore. - let restore_session: Option = - match crate::core::persisted_playback::default_session_path() - .and_then(|path| crate::core::persisted_playback::load(&path)) - { - Ok(session) => session, - Err(e) => { - log::warn!("[session] ignoring unreadable playback session: {e}"); - None - } - }; - // Split the session: the playback (if any) drives the source resume, while the - // native queue is restored into app state regardless of whether a source is - // resumed (a queue-only session must not suppress Spotify's device transfer). - let (restore_playback, restore_queue): ( - Option, - Vec, - ) = match restore_session { - Some(s) => (s.playback, s.queue), - None => (None, Vec::new()), - }; - - if let Some(tick_rate) = matches - .get_one::("tick-rate") - .and_then(|tick_rate| tick_rate.parse().ok()) - { - user_config.behavior.tick_rate_milliseconds = - validate_tick_rate_milliseconds(tick_rate, "Tick rate")?; - } - - // Global song counter opt-in (interactive TUI only). Asked before the source - // picker so the choice applies no matter which source(s) the user sets up. - if matches.subcommand_name().is_none() { - prompt_global_song_count_opt_in(&mut user_config, onboarding.as_ref())?; - } - - let mut client_config = ClientConfig::new(); - // First-run source picker (interactive TUI only): lets the user pick a free - // source and skip Spotify entirely. Must run before `load_config`, which would - // otherwise launch the Spotify-only auth wizard on a fresh install. Skipped for - // CLI subcommands (Spotify-only) and when `--reconfigure-auth` is requested. - if matches.subcommand_name().is_none() && !matches.get_flag("reconfigure-auth") { - crate::core::first_run::run_first_run_picker( - &mut user_config, - &mut runtime_state, - &mut client_config, - onboarding.as_ref(), - ) - .await?; - } - let mut wizard_ran = client_config.load_config(onboarding.as_ref())?; - info!("client authentication config loaded"); - - let reconfigure_auth = matches.get_flag("reconfigure-auth"); - - if reconfigure_auth { - onboarding.info("\nReconfiguring client authentication..."); - client_config.reconfigure_auth(onboarding.as_ref())?; - onboarding.info("Client authentication setup updated.\n"); - } else if matches.subcommand_name().is_none() && client_config.needs_auth_setup_migration() { - if ask_auth_setup_migration(onboarding.as_ref())? { - client_config.reconfigure_auth(onboarding.as_ref())?; - wizard_ran = true; - onboarding.info("Client authentication setup updated.\n"); - } else { - client_config.mark_auth_setup_migrated()?; - onboarding.info("Skipped. You can run this anytime with `spotatui --reconfigure-auth`.\n"); - } - } - - let config_paths = client_config.get_or_build_paths()?; - - let auth_mode = spotify_auth_mode( - matches.subcommand_name().is_some(), - reconfigure_auth, - wizard_ran, - ); - - // The GitHub update check runs concurrently with authentication: both are - // network round trips and neither depends on the other, so the check no - // longer adds its own latency to startup. (An update that actually installs - // still restarts the process, exactly as before.) - let (authenticated, installed_update) = tokio::join!( - async { - match auth_mode { - SpotifyAuthMode::Interactive => { - auth::authenticate_with_fallback(&mut client_config, &config_paths, onboarding.as_ref()) - .await - .map(Some) - } - SpotifyAuthMode::CachedOrFail => { - auth::authenticate_cached(&mut client_config, &config_paths, onboarding.as_ref()) - .await - .map(Some) - .context( - "Spotify is not connected. Start `spotatui`, press `d` and pick Spotify to log in.", - ) - } - SpotifyAuthMode::CachedOrNone => Ok( - auth::try_load_spotify_silently(&mut client_config, &config_paths, onboarding.as_ref()) - .await, - ), - } - }, - super::cli::run_auto_update(matches, &user_config) - ); - - // Only now that authentication has released the OAuth callback port and the - // terminal. Runs before the `?` below so a broken auth state is still allowed - // to restart into the newer build, which may be what fixes it. - super::cli::restart_after_update(installed_update)?; - - let authenticated: Option = authenticated?; - - // Which Spotify app the session actually authenticated as, when the user would - // likely assume otherwise. Logged now (so it is in every log file) and shown as - // a status message once the UI is about to come up (issue #395). - let client_id_notice_message = authenticated - .as_ref() - .and_then(|a| a.client_id_notice.clone()) - .map(describe_client_id_notice); - if let Some(message) = client_id_notice_message.as_deref() { - log::warn!("{}", message); - } - - // Redirect URI for native streaming: from the authenticated client when a - // Spotify session exists, else the configured default (streaming stays off - // without Spotify anyway, see the `spotify.is_some()` gate below). - #[cfg(feature = "streaming")] - let selected_redirect_uri = authenticated - .as_ref() - .map(|a| a.redirect_uri.clone()) - .unwrap_or_else(|| client_config.get_redirect_uri()); - - let final_token_cache_path = authenticated - .as_ref() - .map(|a| a.token_cache_path.clone()) - .unwrap_or_else(|| { - auth::token_cache_path_for_client(&config_paths.token_cache_path, &client_config.client_id) - }); - - // The /me captured while validating the cached token; the streaming account - // probe reuses it instead of a second round trip. - #[cfg(feature = "streaming")] - let cached_me = authenticated.as_ref().and_then(|a| a.me.clone()); - - // Persist whatever token is now in memory and verify it. All later Spotify - // requests go through spotatui's refresh-and-cache path so the on-disk token - // stays current. With no Spotify session both stay `None`. - let (spotify, token_expiry) = match authenticated.map(|a| a.spotify) { - Some(spotify) => { - if let Err(e) = auth::save_token_to_file(&spotify, &final_token_cache_path).await { - log::warn!("Failed to cache token on startup: {}", e); - } - let token_expiry = auth::token_expiry(&spotify).await?; - (Some(spotify), Some(token_expiry)) - } - None => (None, None), - }; - - let (sync_io_tx, sync_io_rx) = std::sync::mpsc::channel::(); - info!("app state initialized"); - - // Initialise app state - let app = Arc::new(Mutex::new(App::new_with_state( - sync_io_tx, - user_config.clone(), - runtime_state.clone(), - state_path.clone(), - token_expiry, - ))); - - // `--play-file `: queue a local file to start once the UI is up. The - // path is canonicalised to an absolute `file://` URI so the local-files - // dispatch can route it; an unreadable path is reported as a status message. - if let Some(path) = matches.get_one::("play-file") { - match std::fs::canonicalize(path).ok().and_then(|abs| { - url::Url::from_file_path(abs) - .ok() - .map(|url| url.to_string()) - }) { - Some(uri) => app.lock().await.pending_play_file = Some(uri), - None => { - app - .lock() - .await - .set_status_message(format!("Cannot find local file: {path}"), 8); - } - } - } - - Ok(Boot { - app, - sync_io_rx, - user_config, - client_config, - spotify, - token_cache_path: final_token_cache_path, - runtime_state, - restore_playback, - restore_queue, - initial_shuffle_enabled, - initial_startup_behavior, - client_id_notice_message, - onboarding, - #[cfg(feature = "streaming")] - cached_me, - #[cfg(feature = "streaming")] - selected_redirect_uri, - }) -} - -#[cfg(test)] -mod tests { - use super::{ - apply_configured_runtime_defaults, ask_auth_setup_migration, auth_setup_migration_prompt, - describe_client_id_notice, global_song_counter_prompt, persist_global_song_count, - prompt_global_song_count_opt_in, should_prompt_global_song_count, spotify_auth_mode, - SpotifyAuthMode, - }; - use crate::core::auth; - use crate::core::limits::MAX_PLAYBAR_ROWS; - use crate::core::onboarding::OnboardingPrompt; - use crate::core::state::{PersistedRuntimeState, RuntimeState}; - use crate::core::test_helpers::ScriptedOnboarding; - use crate::core::user_config::UserConfig; - - fn user_config_with_runtime_defaults() -> UserConfig { - let mut config = UserConfig::new(); - config.behavior.volume_percent = Some(80); - config.behavior.sidebar_width_percent = Some(40); - config.behavior.playbar_height_rows = Some(MAX_PLAYBAR_ROWS + 1); - config.behavior.library_height_percent = Some(60); - config - } - - #[test] - fn configured_runtime_defaults_do_not_override_persisted_state() { - let config = user_config_with_runtime_defaults(); - let persisted = PersistedRuntimeState { - volume_percent: Some(42), - sidebar_width_percent: Some(25), - playbar_height_rows: Some(5), - library_height_percent: Some(35), - ..Default::default() - }; - let mut runtime = RuntimeState::default(); - runtime.apply_persisted(&persisted); - - assert_eq!( - apply_configured_runtime_defaults(&mut runtime, &persisted, &config.behavior), - PersistedRuntimeState::default() - ); - assert_eq!(runtime.volume_percent, 42); - assert_eq!(runtime.sidebar_width_percent, 25); - assert_eq!(runtime.playbar_height_rows, 5); - assert_eq!(runtime.library_height_percent, 35); - } - - #[test] - fn configured_runtime_defaults_seed_only_missing_persisted_fields() { - let config = user_config_with_runtime_defaults(); - let persisted = PersistedRuntimeState { - volume_percent: Some(42), - ..Default::default() - }; - let mut runtime = RuntimeState::default(); - runtime.apply_persisted(&persisted); - - assert_eq!( - apply_configured_runtime_defaults(&mut runtime, &persisted, &config.behavior), - PersistedRuntimeState { - sidebar_width_percent: Some(40), - playbar_height_rows: Some(MAX_PLAYBAR_ROWS), - library_height_percent: Some(60), - ..Default::default() - } - ); - assert_eq!(runtime.volume_percent, 42); - assert_eq!(runtime.sidebar_width_percent, 40); - assert_eq!(runtime.playbar_height_rows, MAX_PLAYBAR_ROWS); - assert_eq!(runtime.library_height_percent, 60); - } - - fn user_config_at(config_file_path: std::path::PathBuf) -> UserConfig { - let mut config = UserConfig::new(); - let path = crate::core::user_config::UserConfigPaths { config_file_path }; - config.path_to_config.replace(path); - config - } - - #[test] - fn global_song_counter_prompt_predicate_matches_first_run_and_stale_configs() { - // Genuine first run: no client.yml, nothing answered. - assert!(should_prompt_global_song_count(false, false)); - // Stale config predating the setting. - assert!(should_prompt_global_song_count(true, false)); - // No client.yml yet, even with an answer present. - assert!(should_prompt_global_song_count(false, true)); - // Asked and answered already. - assert!(!should_prompt_global_song_count(true, true)); - } - - #[test] - fn first_run_prompts_carry_the_historical_banner_text() { - let OnboardingPrompt::Confirm { - title, - body, - question, - } = global_song_counter_prompt(); - assert_eq!(title, "Global Song Counter"); - assert_eq!( - body, - "\nspotatui can contribute to a global counter showing total\nsongs played by all users worldwide.\n\nPrivacy: This feature is completely anonymous.\n• No personal information is collected\n• No song names, artists, or listening history\n• Only a simple increment when a new song starts" - ); - assert_eq!(question, "\nWould you like to participate? (Y/n): "); - - let OnboardingPrompt::Confirm { - title, - body, - question, - } = auth_setup_migration_prompt(); - assert_eq!(title, "Authentication Setup Update"); - assert_eq!( - body, - "\nConfiguration handling has changed and your authentication setup may need an update." - ); - assert_eq!( - question, - "Would you like to run the new auth setup wizard now? (Y/n): " - ); - } - - #[test] - fn scripted_yes_answers_the_global_song_counter_without_stdin() { - let dir = tempfile::tempdir().unwrap(); - let config_path = dir.path().join("config.yml"); - std::fs::write( - &config_path, - "theme:\n background: '#000000'\nbehavior:\n volume_percent: 42\n", - ) - .unwrap(); - let mut user_config = user_config_at(config_path.clone()); - let onboarding = ScriptedOnboarding::with_answers(&["y"]); - - prompt_global_song_count_opt_in(&mut user_config, &onboarding).unwrap(); - - assert!(user_config.behavior.enable_global_song_count); - let persisted = std::fs::read_to_string(&config_path).unwrap(); - assert!(persisted.contains("enable_global_song_count: true")); - assert!(persisted.contains("volume_percent: 42")); - assert!(persisted.contains("'#000000'")); - assert!(onboarding.saw("Global Song Counter")); - assert!(onboarding.saw("\nWould you like to participate? (Y/n): ")); - assert!(onboarding.saw("Thank you for participating!\n")); - } - - #[test] - fn scripted_no_answers_the_global_song_counter_and_persists_opt_out() { - let dir = tempfile::tempdir().unwrap(); - let config_path = dir.path().join("config.yml"); - let mut user_config = user_config_at(config_path.clone()); - let onboarding = ScriptedOnboarding::with_answers(&["nope"]); - - prompt_global_song_count_opt_in(&mut user_config, &onboarding).unwrap(); - - assert!(!user_config.behavior.enable_global_song_count); - let persisted = std::fs::read_to_string(&config_path).unwrap(); - assert!(persisted.contains("enable_global_song_count: false")); - assert!(onboarding.saw("Opted out. You can change this anytime in Settings -> Behavior.\n")); - } - - #[test] - fn non_interactive_onboarding_silently_opts_out_and_persists_false() { - let dir = tempfile::tempdir().unwrap(); - let config_path = dir.path().join("config.yml"); - let mut user_config = user_config_at(config_path.clone()); - let onboarding = ScriptedOnboarding::non_interactive(&[]); - - prompt_global_song_count_opt_in(&mut user_config, &onboarding).unwrap(); - - // Never enable anonymous telemetry for a user we couldn't prompt. - assert!(!user_config.behavior.enable_global_song_count); - let persisted = std::fs::read_to_string(&config_path).unwrap(); - assert!(persisted.contains("enable_global_song_count: false")); - assert!(!onboarding.saw("\nWould you like to participate? (Y/n): ")); - assert!(!onboarding.saw("Opted out. You can change this anytime in Settings -> Behavior.\n")); - assert!(!onboarding.saw("Thank you for participating!\n")); - } - - #[test] - fn a_failed_global_song_counter_write_does_not_abort_boot() { - let dir = tempfile::tempdir().unwrap(); - // A missing parent fails the write on every platform, root included. - let config_path = dir.path().join("no-such-directory").join("config.yml"); - let mut user_config = user_config_at(config_path.clone()); - let onboarding = ScriptedOnboarding::with_answers(&["y"]); - - prompt_global_song_count_opt_in(&mut user_config, &onboarding).unwrap(); - - assert!(user_config.behavior.enable_global_song_count); - assert!(!config_path.exists()); - assert!(onboarding.saw(&format!( - "Could not save your answer to {} (read-only config?). Set `enable_global_song_count` under `behavior:` there to stop this prompt.\n", - config_path.display() - ))); - assert!(!onboarding.saw("Thank you for participating!\n")); - } - - #[test] - fn global_song_counter_write_creates_the_config_when_absent() { - let dir = tempfile::tempdir().unwrap(); - let config_path = dir.path().join("config.yml"); - - persist_global_song_count(&config_path, true).unwrap(); - - let persisted = std::fs::read_to_string(&config_path).unwrap(); - assert!(persisted.contains("enable_global_song_count: true")); - } - - #[test] - fn auth_setup_migration_ask_runs_the_wizard_on_an_empty_answer() { - let onboarding = ScriptedOnboarding::with_answers(&[""]); - - assert!(ask_auth_setup_migration(&onboarding).unwrap()); - assert!(onboarding.saw("Authentication Setup Update")); - assert!(onboarding.saw("Would you like to run the new auth setup wizard now? (Y/n): ")); - } - - #[test] - fn auth_setup_migration_ask_skips_the_wizard_on_a_decline() { - let onboarding = ScriptedOnboarding::with_answers(&["n"]); - - assert!(!ask_auth_setup_migration(&onboarding).unwrap()); - assert!(onboarding.saw("Would you like to run the new auth setup wizard now? (Y/n): ")); - } - - #[test] - fn a_fallback_to_the_shared_id_names_the_way_out() { - let own = "0123456789abcdef0123456789abcdef"; - let shared = crate::core::config::NCSPOT_CLIENT_ID; - let to_shared = describe_client_id_notice(auth::ClientIdNotice::FellBack { - from_client_id: own.to_string(), - to_client_id: shared.to_string(), - }); - assert!(to_shared.starts_with("No usable Spotify session for your own app (01234567…)")); - assert!(to_shared.contains("--reconfigure-auth")); - - let to_own = describe_client_id_notice(auth::ClientIdNotice::FellBack { - from_client_id: shared.to_string(), - to_client_id: own.to_string(), - }); - assert!(to_own.starts_with("No usable Spotify session for the shared ncspot client ID")); - assert!(!to_own.contains("--reconfigure-auth")); - } - - #[test] - fn the_boot_auth_mode_follows_the_wizard_and_the_subcommand() { - let cases = [ - (false, true, false, SpotifyAuthMode::Interactive), - (true, true, false, SpotifyAuthMode::Interactive), - (false, false, true, SpotifyAuthMode::Interactive), - (true, false, true, SpotifyAuthMode::Interactive), - (true, false, false, SpotifyAuthMode::CachedOrFail), - (false, false, false, SpotifyAuthMode::CachedOrNone), - ]; - - for (subcommand, reconfigure_auth, wizard_ran, expected) in cases { - assert_eq!( - spotify_auth_mode(subcommand, reconfigure_auth, wizard_ran), - expected, - "subcommand={subcommand} reconfigure_auth={reconfigure_auth} wizard_ran={wizard_ran}" - ); - } - } -} diff --git a/src/runtime/cli.rs b/src/runtime/cli.rs deleted file mode 100644 index f3850001..00000000 --- a/src/runtime/cli.rs +++ /dev/null @@ -1,251 +0,0 @@ -//! The command-line surface: clap assembly, the self-update plumbing, and -//! CLI-mode dispatch of one subcommand against the network layer. - -use super::bootstrap::Boot; -use crate::cli; -use crate::core::banner::BANNER; -use crate::core::user_config::UserConfig; -use crate::infra::network::Network; -use anyhow::{Context, Result}; -use clap::{Arg, ArgMatches, Command as ClapApp}; - -pub(super) fn build_clap_app() -> ClapApp { - // `mut` is only exercised by the feature-gated subcommand additions below. - #[cfg_attr( - not(any(feature = "scripting", feature = "mcp-server")), - allow(unused_mut) - )] - let mut clap_app = add_self_update_cli( - ClapApp::new(env!("CARGO_PKG_NAME")) - .version(env!("CARGO_PKG_VERSION")) - .author(env!("CARGO_PKG_AUTHORS")) - .about(env!("CARGO_PKG_DESCRIPTION")) - .override_usage("Press `?` while running the app to see keybindings") - .before_help(BANNER) - .after_help( - "Client authentication settings are stored in the spotatui config directory (use --reconfigure-auth to update them)", - ) - .arg( - Arg::new("tick-rate") - .short('t') - .long("tick-rate") - .help("Set the normal UI tick rate in milliseconds.") - .long_help( - "Specify the normal UI tick rate in milliseconds. Lower values refresh non-animated \ -screens more often and cost more CPU. Animation-heavy views keep their separate animation tick rate.", - ), - ) - .arg( - Arg::new("config") - .short('c') - .long("config") - .help("Specify configuration file path."), - ) - .arg( - Arg::new("reconfigure-auth") - .long("reconfigure-auth") - .action(clap::ArgAction::SetTrue) - .help("Rerun client authentication setup wizard"), - ) - .arg( - Arg::new("play-file") - .long("play-file") - .value_name("PATH") - .help("Play a local audio file on startup (requires the local-files build feature)."), - ) - .arg( - Arg::new("completions") - .long("completions") - .help("Generates completions for your preferred shell") - .value_parser(["bash", "zsh", "fish", "power-shell", "elvish"]) - .value_name("SHELL"), - ) - // Control spotify from the command line - .subcommand(cli::playback_subcommand()) - .subcommand(cli::play_subcommand()) - .subcommand(cli::list_subcommand()) - .subcommand(cli::history_subcommand()) - .subcommand(cli::search_subcommand()), - ); - - #[cfg(feature = "scripting")] - { - clap_app = clap_app.subcommand(cli::plugin_subcommand()); - } - - #[cfg(feature = "mcp-server")] - { - clap_app = clap_app.subcommand(cli::mcp_subcommand()); - } - - clap_app -} - -/// CLI mode: run one subcommand against the network layer and print its -/// result. -pub(super) async fn run_subcommand(boot: Boot, cmd: &str, matches: &ArgMatches) -> Result<()> { - let app = boot.app; - // Held (unread) for the length of the command; see the field doc on `Boot`. - let _sync_io_rx = boot.sync_io_rx; - // Both `Network::new` variants share one signature, so the same call - // compiles with or without `streaming`. CLI mode never uses streaming. - let network = Network::new( - boot.spotify, - boot.client_config, - &app, - boot.token_cache_path, - ); - let cli_result = cli::handle_matches(matches, cmd.to_string(), network, boot.user_config).await; - app.lock().await.flush_state_save(true); - println!("{}", cli_result?); - Ok(()) -} - -#[cfg(feature = "self-update")] -fn add_self_update_cli(clap_app: ClapApp) -> ClapApp { - clap_app - .arg( - Arg::new("no-update") - .short('U') - .long("no-update") - .action(clap::ArgAction::SetTrue) - .help("Skip the automatic update check on startup"), - ) - .subcommand( - ClapApp::new("update") - .version(env!("CARGO_PKG_VERSION")) - .about("Check for and install updates") - .arg( - Arg::new("install") - .short('i') - .long("install") - .action(clap::ArgAction::SetTrue) - .help("Install the update if available"), - ), - ) -} - -#[cfg(not(feature = "self-update"))] -fn add_self_update_cli(clap_app: ClapApp) -> ClapApp { - clap_app -} - -#[cfg(feature = "self-update")] -pub(super) async fn handle_self_update_command(matches: &ArgMatches) -> Result { - if let Some(update_matches) = matches.subcommand_matches("update") { - let do_install = update_matches.get_flag("install"); - // Must use spawn_blocking because self_update uses reqwest::blocking internally, - // which creates its own tokio runtime and panics if called from an async context. - tokio::task::spawn_blocking(move || cli::check_for_update(do_install)).await??; - return Ok(true); - } - - Ok(false) -} - -#[cfg(not(feature = "self-update"))] -pub(super) async fn handle_self_update_command(_matches: &ArgMatches) -> Result { - Ok(false) -} - -/// Run the auto-update check, returning the new version when one was installed. -/// -/// Deliberately does NOT restart here. This runs concurrently with -/// authentication, and restarting mid-authentication deadlocks startup: the -/// re-exec blocks this task in `Command::status()`, which stops the joined -/// authentication future from being polled while it still owns the OAuth -/// callback port, so the child cannot bind that port and the parent never -/// releases it. See `restart_after_update`, which the caller invokes once -/// authentication has finished. -#[cfg(feature = "self-update")] -pub(super) async fn run_auto_update( - matches: &ArgMatches, - user_config: &UserConfig, -) -> Option { - if matches.subcommand_name().is_some() - || std::env::var_os("SPOTATUI_SKIP_UPDATE").is_some() - || matches.get_flag("no-update") - || user_config.behavior.disable_auto_update - { - return None; - } - - println!("Checking for updates..."); - // Must use spawn_blocking because self_update uses reqwest::blocking internally, - // which creates its own tokio runtime and panics if called from an async context. - let delay_secs = - crate::core::user_config::parse_update_delay_secs(&user_config.behavior.auto_update_delay) - .unwrap_or(0); - let update_result = - match tokio::task::spawn_blocking(move || cli::install_update_silent(delay_secs)).await { - Ok(Ok(outcome)) => Some(outcome), - Ok(Err(e)) => { - log::warn!("auto-update failed: {:#}", e); - None - } - Err(e) => { - log::warn!("auto-update task panicked: {}", e); - None - } - }; - - match update_result { - Some(cli::UpdateOutcome::Installed(new_version)) => Some(new_version), - Some(cli::UpdateOutcome::Pending { - version, - secs_remaining, - }) => { - println!( - "Update v{} detected — will install in {}. Run `spotatui update --install` to update now.", - version, - crate::core::user_config::format_update_delay_secs(secs_remaining) - ); - None - } - // Up-to-date, check failed, or no update — continue normally. - _ => None, - } -} - -#[cfg(not(feature = "self-update"))] -pub(super) async fn run_auto_update( - _matches: &ArgMatches, - _user_config: &UserConfig, -) -> Option { - None -} - -/// Re-exec into the freshly installed binary. Never returns when an update was -/// installed. -/// -/// Must be called only once startup is no longer holding resources the child -/// will need. The child repeats startup from scratch, so anything this process -/// still owns (the OAuth callback port on 8989, stdin, the terminal) it would -/// contend with. Authentication persists its token before returning, so the -/// child reuses it rather than opening a second browser login. -pub(super) fn restart_after_update(new_version: Option) -> Result<()> { - let Some(new_version) = new_version else { - return Ok(()); - }; - - println!("Updated to v{}! Restarting...", new_version); - // Re-exec the current binary with the same args, skipping the update check. - // Reported rather than panicked: the update is already installed by now, so - // an unreadable executable path should surface as an error the user can act - // on, not a backtrace. - let exe = std::env::current_exe() - .context("failed to get current executable path while restarting after an update")?; - let args: Vec = std::env::args().skip(1).collect(); - let status = std::process::Command::new(&exe) - .args(&args) - .env("SPOTATUI_SKIP_UPDATE", "1") - .status(); - match status { - Ok(exit_status) => std::process::exit(exit_status.code().unwrap_or(0)), - Err(e) => { - eprintln!("Failed to restart after update: {}", e); - eprintln!("Please restart spotatui manually."); - std::process::exit(1); - } - } -} diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs deleted file mode 100644 index 939845a8..00000000 --- a/src/runtime/mod.rs +++ /dev/null @@ -1,188 +0,0 @@ -//! Bootstrap and the processes every frontend shares. -//! -//! `run_cli()` is the console entry point: it wires logging, parses the -//! command line, runs the shared `bootstrap::boot()` sequence (config, state, -//! auth, `App`), and then either executes one CLI subcommand or launches the -//! terminal UI. The IoEvent pump every frontend drives lives in `pump`, and -//! the native-streaming startup every frontend shares lives in `streaming`. - -mod bootstrap; -mod cli; -mod pump; -#[cfg(feature = "tui")] -mod startup; -#[cfg(any(feature = "streaming", test))] -mod streaming; - -use crate::core::migrations::apply_legacy_state_file_migrations; -use anyhow::{anyhow, Result}; -use clap_complete::{generate, Shell}; -use log::info; -use std::io; -use std::sync::Arc; - -/// Console implementation of the first-launch surface for builds without the -/// terminal frontend. CLI subcommands can still reach the Spotify auth wizard -/// (`ClientConfig::load_config`), whose prompts are plain stdin/stdout; -/// mirrors `ConsoleOnboarding` minus the interactive source picker, which is -/// unreachable here (the picker only runs on a UI launch, and headless builds -/// bail out before boot in that case). -#[cfg(not(feature = "tui"))] -struct HeadlessOnboarding; - -#[cfg(not(feature = "tui"))] -impl crate::core::onboarding::Onboarding for HeadlessOnboarding { - fn info(&self, text: &str) { - println!("{text}"); - } - - fn progress(&self, text: &str) { - use std::io::Write; - print!("{text}"); - let _ = io::stdout().flush(); - } - - fn prompt_line(&self, prompt: &str) -> Result { - use std::io::Write; - print!("{prompt}"); - let _ = io::stdout().flush(); - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - Ok(input) - } - - fn is_interactive(&self) -> bool { - use std::io::IsTerminal; - io::stdin().is_terminal() && io::stdout().is_terminal() - } - - fn ask( - &self, - prompt: &crate::core::onboarding::OnboardingPrompt, - ) -> Result { - use crate::core::onboarding::{confirm_answer, OnboardingPrompt, BANNER_RULE}; - // Both bootstrap prompts are UI-launch-only and headless builds bail out - // before boot there; keep a plain console fallback if this is reached. - let OnboardingPrompt::Confirm { - title, - body, - question, - } = prompt; - println!("\n{BANNER_RULE}\n{title}\n{BANNER_RULE}\n{body}"); - println!("{question}"); - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - Ok(confirm_answer(&input)) - } - - fn pick_sources( - &self, - _options: &[crate::core::source::Source], - ) -> Result>> { - // Only a UI launch runs the first-run picker, and headless builds return - // before boot in that case; fall through to the Spotify wizard if this is - // ever reached anyway. - Ok(None) - } -} - -pub async fn run_cli() -> Result<()> { - bootstrap::setup_logging()?; - info!("spotatui {} starting up", env!("CARGO_PKG_VERSION")); - bootstrap::init_audio_backend(); - info!("audio backend initialized"); - - bootstrap::install_panic_hook(); - info!("panic hook configured"); - - let mut clap_app = cli::build_clap_app(); - - let matches = clap_app.clone().get_matches(); - - // Shell completions don't need any spotify work - if let Some(s) = matches.get_one::("completions") { - let shell = match s.as_str() { - "fish" => Shell::Fish, - "bash" => Shell::Bash, - "zsh" => Shell::Zsh, - "power-shell" => Shell::PowerShell, - "elvish" => Shell::Elvish, - _ => return Err(anyhow!("no completions available for '{}'", s)), - }; - generate(shell, &mut clap_app, "spotatui", &mut io::stdout()); - return Ok(()); - } - - // Handle self-update command (doesn't need Spotify auth) - if cli::handle_self_update_command(&matches).await? { - return Ok(()); - } - - if let Err(e) = apply_legacy_state_file_migrations() { - log::warn!("[state] failed to migrate legacy app data files: {e}"); - } - - if let Some(history_matches) = matches.subcommand_matches("history") { - println!("{}", crate::cli::handle_history_matches(history_matches)?); - return Ok(()); - } - - // The MCP server owns stdout for the protocol, so it must return before any - // other startup path can print to it — and it needs no Spotify auth of its - // own, since the running TUI holds the session. - #[cfg(feature = "mcp-server")] - if let Some(mcp_matches) = matches.subcommand_matches("mcp") { - // `status` is the safe probe an agent (or a human) can run; bare `mcp` is the - // server and blocks on stdin until the client closes it. - if let Some(status_matches) = mcp_matches.subcommand_matches("status") { - let (report, code) = crate::infra::mcp::run_status(status_matches.get_flag("json")).await; - println!("{report}"); - std::process::exit(code); - } - crate::infra::mcp::run_relay().await?; - return Ok(()); - } - - // Plugin management is pure git + filesystem work; it must not require Spotify auth. - #[cfg(feature = "scripting")] - if let Some(plugin_matches) = matches.subcommand_matches("plugin") { - crate::cli::handle_plugin_command(plugin_matches)?; - return Ok(()); - } - - // Without the terminal frontend there is no interactive UI to launch; only - // the CLI subcommands work. Bail before any first-run prompt would fire. - #[cfg(not(feature = "tui"))] - if matches.subcommand_name().is_none() { - return Err(anyhow!( - "this spotatui build has no terminal UI (compiled without the `tui` feature); run a CLI subcommand instead" - )); - } - - // The console implementation of the first-launch surface; core and infra - // only ever see the `Onboarding` trait. `Arc` so the blocking streaming - // credential task in the UI launch can hold its own handle. - #[cfg(feature = "tui")] - let onboarding: Arc = - Arc::new(crate::tui::onboarding::ConsoleOnboarding); - #[cfg(not(feature = "tui"))] - let onboarding: Arc = Arc::new(HeadlessOnboarding); - - let boot = bootstrap::boot(&matches, onboarding).await?; - - // Work with the cli (not really async) - if let Some(cmd) = matches.subcommand_name() { - info!("running in cli mode with command: {}", cmd); - // Safe, because we checked if the subcommand is present at runtime - let m = matches.subcommand_matches(cmd).unwrap(); - cli::run_subcommand(boot, cmd, m).await?; - // Launch the UI (async) - } else { - #[cfg(feature = "tui")] - startup::launch_ui(boot).await?; - #[cfg(not(feature = "tui"))] - unreachable!("headless builds reject a UI launch before boot"); - } - - Ok(()) -} diff --git a/src/runtime/pump.rs b/src/runtime/pump.rs deleted file mode 100644 index 5a56a903..00000000 --- a/src/runtime/pump.rs +++ /dev/null @@ -1,274 +0,0 @@ -//! The serial IoEvent pump: source routing by URI scheme, the service lane, -//! and the party-relay drain. Every frontend drives `App` through this. - -use crate::core::app::SPOTIFY_NOT_CONNECTED_STATUS; -use crate::core::queue::{ - missing_source_feature, queue_item_source, source_available, QueueItemSource, -}; -use crate::infra::network::{IoEvent, Network}; - -/// The URI a `StartPlayback` addresses: the context, or the head of the list. -#[cfg_attr(not(feature = "tui"), allow(dead_code))] -fn start_playback_uri(event: &IoEvent) -> Option<&str> { - match event { - IoEvent::StartPlayback(Some(uri), _, _) => Some(uri), - IoEvent::StartPlayback(None, Some(uris), _) => uris.first().map(String::as_str), - _ => None, - } -} - -/// Whether some router or the Spotify session will start `event`; a start -/// nobody takes must not reach the routers, whose foreign-start arms tear the -/// audible player down. -#[cfg_attr(not(feature = "tui"), allow(dead_code))] -fn start_playback_has_taker(event: &IoEvent, spotify_session: bool) -> bool { - match event { - IoEvent::StartPlayback(None, None, None) => true, - IoEvent::StartPlayback(..) => match start_playback_uri(event) { - // Radio is never queued, so `queue_item_source` does not know its scheme. - Some(uri) if uri.starts_with("radio:") => cfg!(feature = "internet-radio"), - Some(uri) => match queue_item_source(uri) { - QueueItemSource::Spotify => spotify_session, - source => source_available(source), - }, - None => spotify_session, - }, - _ => true, - } -} - -/// Why the gate dropped a start: the source feature this build lacks, else -/// the missing Spotify session. -#[cfg_attr(not(feature = "tui"), allow(dead_code))] -fn dropped_start_status(event: &IoEvent) -> String { - let missing_feature = match start_playback_uri(event) { - Some(uri) if uri.starts_with("radio:") => { - (!cfg!(feature = "internet-radio")).then_some("internet-radio") - } - Some(uri) => missing_source_feature(uri), - None => None, - }; - match missing_feature { - Some(feature) => { - format!("This spotatui was built without the `{feature}` feature, so nothing can play that.") - } - None => SPOTIFY_NOT_CONNECTED_STATUS.to_string(), - } -} - -// CLI mode never starts the pump; only a frontend launch does. -#[cfg_attr(not(feature = "tui"), allow(dead_code))] -pub(super) async fn start_tokio(io_rx: std::sync::mpsc::Receiver, network: &mut Network) { - // Bridge the sync dispatch channel onto the async runtime: a dedicated - // thread does blocking recv() and forwards into a tokio channel the pump can - // await, replacing the old try_recv() + 5ms sleep poll (which added 0-5ms - // latency to every event and busy-woke the task while idle). - let (bridge_tx, mut bridge_rx) = tokio::sync::mpsc::unbounded_channel::(); - std::thread::spawn(move || { - while let Ok(io_event) = io_rx.recv() { - if bridge_tx.send(io_event).is_err() { - break; - } - } - }); - - // Party relay messages used to piggyback on the 5ms poll loop; with the pump - // parked on recv(), drain them on an interval instead. - let mut party_poll = tokio::time::interval(std::time::Duration::from_millis(25)); - party_poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); - - // Spotify-bound events that meet a rate limit are held back on the - // `Network` and flushed here when the window ends, so the pump never sleeps. - network.defers_rate_limited = true; - - loop { - let deferred_deadline = network.deferred_deadline().await; - let io_event = tokio::select! { - maybe_event = bridge_rx.recv() => match maybe_event { - Some(io_event) => io_event, - None => break, - }, - _ = party_poll.tick(), if network.party_incoming_rx.is_some() => { - network.process_party_messages().await; - continue; - } - _ = tokio::time::sleep_until(tokio::time::Instant::from_std( - deferred_deadline.unwrap_or_else(std::time::Instant::now) - )), if deferred_deadline.is_some() => { - network.flush_deferred().await; - continue; - } - }; - - // Source-agnostic service events (lyrics, cover art, telemetry, - // announcements, friends, stats/recap) only touch `App` and their own HTTP - // clients — never the Spotify client, pacing, or `Network` state — so they - // run concurrently on a detached task instead of queueing behind - // rate-limited Spotify calls on this serial pump (and vice versa). - if Network::runs_on_service_lane(&io_event) { - let mut service_network = Network::new( - None, - network.client_config.clone(), - &network.app, - network.token_cache_path.clone(), - ); - tokio::spawn(async move { - service_network.handle_network_event(io_event).await; - }); - continue; - } - - { - if !start_playback_has_taker(&io_event, network.spotify.is_some()) { - let mut app = network.app.lock().await; - app.set_status_message(dropped_start_status(&io_event), 6); - app.is_loading = false; - continue; - } - // The native queue router runs first: it owns `AdvanceNativeQueue` and - // the queue slot's transport controls, and relinquishes the slot on an - // unrelated `StartPlayback` (returning false so the per-source - // teardowns/starts still run). Compiled unconditionally. - let handled_queue = - crate::infra::queue::dispatch::route_queue_event(&network.app, &io_event).await; - // Local-file playback is intercepted before the Spotify network so the - // network stays Spotify-only (see infra::local::dispatch). - let handled_locally = !handled_queue && { - #[cfg(feature = "local-files")] - { - crate::infra::local::dispatch::route_local_event(&network.app, &io_event).await - } - #[cfg(not(feature = "local-files"))] - { - false - } - }; - // Subsonic is intercepted after local and before the Spotify network. A - // `subsonic:` URI falls through the local dispatch (its `is_file_uri` is - // false) and is caught here (see infra::subsonic::dispatch). Skipped when - // local already consumed the event. - #[cfg(feature = "subsonic")] - let handled_subsonic = !handled_queue - && !handled_locally - && crate::infra::subsonic::dispatch::route_subsonic_event(&network.app, &io_event).await; - #[cfg(not(feature = "subsonic"))] - let handled_subsonic = false; - // Qobuz is intercepted after Subsonic. A `qobuz:` URI falls through the - // earlier dispatches and is caught here (see infra::qobuz::dispatch). - #[cfg(feature = "qobuz")] - let handled_qobuz = !handled_queue - && !handled_locally - && !handled_subsonic - && crate::infra::qobuz::dispatch::route_qobuz_event(&network.app, &io_event).await; - #[cfg(not(feature = "qobuz"))] - let handled_qobuz = false; - // Internet radio is intercepted last before the Spotify network. A - // `radio:` URI falls through both earlier dispatches and is caught here - // (see infra::radio::dispatch). Skipped when already consumed. - #[cfg(feature = "internet-radio")] - let handled_radio = !handled_queue - && !handled_locally - && !handled_subsonic - && !handled_qobuz - && crate::infra::radio::dispatch::route_radio_event(&network.app, &io_event).await; - #[cfg(not(feature = "internet-radio"))] - let handled_radio = false; - // YouTube is intercepted last before the Spotify network. A `youtube:` - // URI falls through the three earlier dispatches and is caught here - // (see infra::youtube::dispatch). Skipped when already consumed. - #[cfg(feature = "youtube")] - let handled_youtube = !handled_queue - && !handled_locally - && !handled_subsonic - && !handled_qobuz - && !handled_radio - && crate::infra::youtube::dispatch::route_youtube_event(&network.app, &io_event).await; - #[cfg(not(feature = "youtube"))] - let handled_youtube = false; - if !handled_queue - && !handled_locally - && !handled_subsonic - && !handled_qobuz - && !handled_radio - && !handled_youtube - { - network.handle_network_event(io_event).await; - } else { - // A source router consumed the event and returned without touching - // `is_loading`, which `App::dispatch` set to true. Only - // `handle_network_event` resets it, and we skipped that path, so clear - // it here — otherwise selecting/loading Local, Subsonic, Qobuz, Radio, - // or YouTube content leaves the UI stuck on the loading indicator. - network.app.lock().await.is_loading = false; - } - } - network.process_party_messages().await; - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn a_bare_resume_always_has_a_taker() { - let resume = IoEvent::StartPlayback(None, None, None); - - assert!(start_playback_has_taker(&resume, false)); - assert!(start_playback_has_taker(&resume, true)); - } - - #[test] - fn a_spotify_start_needs_a_session() { - let start = IoEvent::StartPlayback(None, Some(vec!["spotify:track:x".to_string()]), Some(0)); - - assert!(start_playback_has_taker(&start, true)); - assert!(!start_playback_has_taker(&start, false)); - } - - #[test] - fn an_empty_start_has_no_taker_without_a_session() { - let empty = IoEvent::StartPlayback(None, Some(vec![]), Some(0)); - - assert!(!start_playback_has_taker(&empty, false)); - } - - #[cfg(feature = "local-files")] - #[test] - fn a_local_start_has_a_taker_without_a_session() { - let list = IoEvent::StartPlayback(None, Some(vec!["file:///a.mp3".to_string()]), Some(0)); - let context = IoEvent::StartPlayback(Some("file:///album".to_string()), None, None); - - assert!(start_playback_has_taker(&list, false)); - assert!(start_playback_has_taker(&context, false)); - } - - #[cfg(feature = "internet-radio")] - #[test] - fn a_radio_start_has_a_taker_without_a_session() { - let start = IoEvent::StartPlayback(Some("radio:https://x.example/s".to_string()), None, None); - - assert!(start_playback_has_taker(&start, false)); - } - - #[cfg(not(feature = "local-files"))] - #[test] - fn a_compiled_out_source_has_no_taker_and_names_the_feature() { - let start = IoEvent::StartPlayback(None, Some(vec!["file:///a.mp3".to_string()]), Some(0)); - - assert!(!start_playback_has_taker(&start, true)); - assert!(dropped_start_status(&start).contains("`local-files`")); - } - - #[test] - fn a_spotify_start_without_a_session_reports_the_missing_session() { - let start = IoEvent::StartPlayback(None, Some(vec!["spotify:track:x".to_string()]), Some(0)); - - assert_eq!(dropped_start_status(&start), SPOTIFY_NOT_CONNECTED_STATUS); - } - - #[test] - fn a_non_start_event_always_has_a_taker() { - assert!(start_playback_has_taker(&IoEvent::NextTrack, false)); - } -} diff --git a/src/runtime/startup.rs b/src/runtime/startup.rs deleted file mode 100644 index 4a05b406..00000000 --- a/src/runtime/startup.rs +++ /dev/null @@ -1,1453 +0,0 @@ -//! Launching the interactive frontend: OS media integrations, the deferred -//! native-streaming startup, persisted-session restore, and the handoff to -//! the terminal UI's event loop. - -use super::bootstrap::Boot; -use super::pump::start_tokio; -#[cfg(feature = "streaming")] -use super::streaming::launch::{ - cache_streaming_credentials, deferred_streaming_startup, DeferredStreamingContext, -}; -use crate::core::app::App; -use crate::core::user_config::StartupBehavior; -#[cfg(feature = "discord-rpc")] -use crate::core::user_config::UserConfig; -#[cfg(feature = "discord-rpc")] -use crate::infra::discord_rpc; -#[cfg(all(feature = "macos-media", target_os = "macos"))] -use crate::infra::macos_media; -#[cfg(all(feature = "mpris", target_os = "linux"))] -use crate::infra::mpris; -use crate::infra::network::{IoEvent, Network}; -#[cfg(feature = "streaming")] -use crate::infra::player; -use anyhow::Result; -use log::info; -use std::sync::{atomic::AtomicU64, Arc}; -// Used by `restore_playback_session`'s per-source position seeks. -#[cfg(any(feature = "local-files", feature = "subsonic", feature = "youtube"))] -use std::time::Duration; -use tokio::sync::Mutex; - -#[cfg(feature = "discord-rpc")] -type DiscordRpcHandle = Option; -#[cfg(not(feature = "discord-rpc"))] -type DiscordRpcHandle = Option<()>; - -#[cfg(feature = "discord-rpc")] -const DEFAULT_DISCORD_CLIENT_ID: &str = "1464235043462447166"; - -#[cfg(all(feature = "macos-media", target_os = "macos"))] -#[derive(Default, PartialEq)] -struct MacosMetadata { - title: String, - artists: Vec, - album: String, - duration_ms: u32, - art_url: Option, -} - -#[cfg(all(feature = "windows-media", target_os = "windows"))] -#[derive(Default, PartialEq)] -struct WindowsMetadata { - title: String, - artists: Vec, - album: String, - duration: u64, - art_url: Option, -} - -#[cfg(feature = "discord-rpc")] -fn resolve_discord_app_id(user_config: &UserConfig) -> Option { - std::env::var("SPOTATUI_DISCORD_APP_ID") - .ok() - .filter(|value| !value.trim().is_empty()) - .or_else(|| user_config.behavior.discord_rpc_client_id.clone()) - .or_else(|| Some(DEFAULT_DISCORD_CLIENT_ID.to_string())) -} - -#[cfg(all(feature = "macos-media", target_os = "macos"))] -fn update_macos_metadata( - manager: &macos_media::MacMediaManager, - last_metadata: &mut Option, - app: &App, -) { - // Local-file playback owns its own state and never populates the Spotify - // playback context, so Now Playing must read metadata, play state, and - // position straight from the live local player when local is active. Skipped - // while the native queue owns the sink: `local_playback` is then a *suspended* - // context, so fall through to the snapshot path, which describes the queued - // track actually playing. Mirrors the same filter on the MPRIS twin in - // `update_mpris_state` in `core/driver/presence.rs`. - #[cfg(feature = "local-files")] - if let Some(local) = app - .local_playback - .as_ref() - .filter(|_| !app.queue_owns_playback()) - { - use crate::infra::media_metadata::{select_media_metadata, LocalMediaMetadata}; - - let is_playing = !local.player.is_paused(); - let position_ms = local.player.position().as_millis() as u64; - - // `select_media_metadata` is the single, unit-tested decision for which - // source the OS integration follows; local always wins while it is active. - let metadata = select_media_metadata( - Some(LocalMediaMetadata { - title: local.name.clone(), - artists: vec![local.artists.clone()], - album: local.album.clone(), - duration_ms: local.duration_ms as u32, - }), - None, - ) - .expect("local metadata is present"); - - let new_metadata = MacosMetadata { - title: metadata.title.clone(), - artists: metadata.artists.clone(), - album: metadata.album.clone(), - duration_ms: metadata.duration_ms, - art_url: metadata.image_url.clone(), - }; - - if last_metadata.as_ref() != Some(&new_metadata) { - manager.set_metadata( - &metadata.title, - &metadata.artists, - &metadata.album, - metadata.duration_ms, - metadata.image_url, - ); - *last_metadata = Some(new_metadata); - } - - manager.set_playback_status(is_playing); - manager.set_position(position_ms); - return; - } - - if let Some(snapshot) = crate::infra::media_metadata::current_playback_snapshot(app) { - let new_metadata = MacosMetadata { - title: snapshot.metadata.title.clone(), - artists: snapshot.metadata.artists.clone(), - album: snapshot.metadata.album.clone(), - duration_ms: snapshot.metadata.duration_ms, - art_url: snapshot.metadata.image_url.clone(), - }; - - // Only update if metadata changed to avoid repeated artwork fetches. - if last_metadata.as_ref() != Some(&new_metadata) { - manager.set_metadata( - &snapshot.metadata.title, - &snapshot.metadata.artists, - &snapshot.metadata.album, - snapshot.metadata.duration_ms, - snapshot.metadata.image_url, - ); - *last_metadata = Some(new_metadata); - } - } else if last_metadata.is_some() { - *last_metadata = None; - } -} - -/// Returns the snapshot's play state and position, `None` with no snapshot. -#[cfg(all(feature = "windows-media", target_os = "windows"))] -fn update_windows_metadata( - manager: &smtc_tokio::WindowsMediaManager, - last_metadata: &mut Option, - app: &App, -) -> Option<(bool, u64)> { - if let Some(snapshot) = crate::infra::media_metadata::current_playback_snapshot(app) { - let new_metadata = WindowsMetadata { - title: snapshot.metadata.title.clone(), - artists: snapshot.metadata.artists.clone(), - album: snapshot.metadata.album.clone(), - duration: snapshot.metadata.duration_ms as u64, - art_url: snapshot.metadata.image_url.clone(), - }; - - if last_metadata.as_ref() != Some(&new_metadata) { - manager.set_metadata( - &snapshot.metadata.title, - &snapshot.metadata.artists, - &snapshot.metadata.album, - snapshot.metadata.duration_ms as u64, - snapshot.metadata.image_url, - ); - *last_metadata = Some(new_metadata); - } - Some((snapshot.is_playing, snapshot.progress_ms as u64)) - } else { - if last_metadata.is_some() { - *last_metadata = None; - } - None - } -} - -/// Launch the terminal UI: OS media integrations, the deferred native -/// streaming startup, persisted-session restore, the network task driving -/// the IoEvent pump, and finally the blocking UI event loop. -pub(super) async fn launch_ui(boot: Boot) -> Result<()> { - let app = boot.app; - let sync_io_rx = boot.sync_io_rx; - let user_config = boot.user_config; - let client_config = boot.client_config; - let spotify = boot.spotify; - let final_token_cache_path = boot.token_cache_path; - let restore_playback = boot.restore_playback; - let restore_queue = boot.restore_queue; - let initial_shuffle_enabled = boot.initial_shuffle_enabled; - let initial_startup_behavior = boot.initial_startup_behavior; - let client_id_notice_message = boot.client_id_notice_message; - #[cfg(feature = "streaming")] - let runtime_state = boot.runtime_state; - #[cfg(feature = "streaming")] - let onboarding = boot.onboarding; - #[cfg(feature = "streaming")] - let cached_me = boot.cached_me; - #[cfg(feature = "streaming")] - let selected_redirect_uri = boot.selected_redirect_uri; - - info!("launching interactive terminal ui"); - #[cfg(feature = "streaming")] - cache_streaming_credentials( - &client_config, - spotify.as_ref(), - cached_me.as_ref(), - &final_token_cache_path, - &onboarding, - &app, - ) - .await; - // Shown here rather than at authentication time: the streaming credential - // flow above can block on a browser round trip, which would burn the - // message's TTL before the first frame ever renders. Never displaces a - // message something else just set (they are more urgent than this notice). - // A Spotify browse scope with no session comes second: say so once, before - // the first key press reaches the auth gate. - { - let mut app_mut = app.lock().await; - if app_mut.status_message().is_none() { - if let Some(message) = client_id_notice_message { - app_mut.set_status_message(message, 15); - } else if spotify.is_none() && app_mut.active_source == crate::core::source::Source::Spotify { - app_mut.set_status_message(crate::core::app::SPOTIFY_NOT_CONNECTED_STATUS, 8); - } - } - } - - let history_collector = crate::infra::history::spawn_history_collector(Arc::clone(&app)); - - // Opt-in MCP control socket. Nothing listens unless the user asked for it; - // a bind failure is reported and shrugged off rather than blocking startup, - // since the player is perfectly usable without it. - #[cfg(feature = "mcp-server")] - { - let (enabled, io_tx) = { - let app_ref = app.lock().await; - ( - app_ref.user_config.behavior.mcp_enabled, - app_ref.io_tx_clone(), - ) - }; - if enabled { - match io_tx { - Some(io_tx) => match crate::infra::mcp::spawn_listener(Arc::clone(&app), io_tx).await { - Ok(port) => { - let mut app_mut = app.lock().await; - if app_mut.status_message().is_none() { - app_mut.set_status_message(format!("MCP server listening on 127.0.0.1:{port}"), 6); - } - } - Err(e) => { - log::warn!("MCP: could not start the control socket: {e}"); - let mut app_mut = app.lock().await; - app_mut.set_error_status_message(format!("MCP server failed to start: {e}"), 8); - } - }, - None => log::warn!("MCP: no IoEvent sender available; control socket not started"), - } - } - } - // Native streaming needs a Spotify session; when it will be attempted, the - // account probe and librespot handshake run in a background task after the - // UI is up (see `deferred_streaming_startup`) instead of gating the first - // frame for seconds (worst case tens of seconds). - #[cfg(feature = "streaming")] - let streaming_attempted = client_config.enable_streaming && spotify.is_some(); - - // Create shared atomic for real-time position updates from native player - // This avoids lock contention - the player event handler can update position - // without needing to acquire the app mutex - #[cfg(any(feature = "streaming", all(feature = "mpris", target_os = "linux")))] - let shared_position = Arc::new(AtomicU64::new(0)); - #[cfg(feature = "streaming")] - let shared_position_for_events = Arc::clone(&shared_position); - #[cfg(feature = "streaming")] - let shared_position_for_ui = Arc::clone(&shared_position); - - // Create shared atomic for playing state (lock-free for MPRIS toggle) - #[cfg(any(feature = "streaming", all(feature = "mpris", target_os = "linux")))] - let shared_is_playing = Arc::new(std::sync::atomic::AtomicBool::new(false)); - #[cfg(feature = "streaming")] - let shared_is_playing_for_events = Arc::clone(&shared_is_playing); - #[cfg(all(feature = "mpris", target_os = "linux"))] - let shared_is_playing_for_mpris = Arc::clone(&shared_is_playing); - #[cfg(all(feature = "mpris", target_os = "linux"))] - let shared_position_for_mpris = Arc::clone(&shared_position); - #[cfg(all(feature = "macos-media", target_os = "macos"))] - let shared_is_playing_for_macos = Arc::clone(&shared_is_playing); - #[cfg(feature = "streaming")] - let (streaming_recovery_tx, streaming_recovery_rx) = - tokio::sync::mpsc::unbounded_channel::(); - // The supervisor that drains this channel starts inside - // `deferred_streaming_startup`, so install the sender on the same - // condition; otherwise a recovery request would latch - // `native_backend_pending` on a channel nobody reads. - #[cfg(feature = "streaming")] - if streaming_attempted { - let mut app_mut = app.lock().await; - app_mut.streaming_recovery_tx = Some(streaming_recovery_tx.clone()); - } - - // Initialize MPRIS D-Bus integration for desktop media control - // This registers spotatui as a controllable media player on the session bus - #[cfg(all(feature = "mpris", target_os = "linux"))] - let mpris_manager: Option> = match mpris::MprisManager::new() { - Ok(mgr) => { - info!("mpris d-bus interface registered - media keys and playerctl enabled"); - Some(Arc::new(mgr)) - } - Err(e) => { - info!( - "failed to initialize mpris: {} - media key control disabled", - e - ); - None - } - }; - - // Store MPRIS manager reference in App for emitting Seeked signals from native seeks - #[cfg(all(feature = "mpris", target_os = "linux"))] - { - let mut app_mut = app.lock().await; - app_mut.mpris_manager = mpris_manager.clone(); - } - - // Initialize macOS Now Playing integration for media key control - // This registers with MPRemoteCommandCenter for media key events - // Registered without a Spotify session, like MPRIS and SMTC, so decoded - // sources get media keys too; the handlers no-op while nothing plays. - #[cfg(all(feature = "macos-media", target_os = "macos"))] - let macos_media_manager: Option> = - match macos_media::MacMediaManager::new() { - Ok(mgr) => { - info!("macos now playing interface registered - media keys enabled"); - Some(Arc::new(mgr)) - } - Err(e) => { - info!( - "failed to initialize macos media control: {} - media keys disabled", - e - ); - None - } - }; - - // Registered without a Spotify session, like MPRIS, so decoded sources get media keys too. - #[cfg(all(feature = "windows-media", target_os = "windows"))] - let windows_media_manager: Option> = - match smtc_tokio::WindowsMediaManager::new() { - Ok(mgr) => { - info!("windows smtc com registered - media keys enabled"); - Some(Arc::new(mgr)) - } - Err(e) => { - info!( - "failed to initialize windows smtc com: {} - media keys disabled", - e - ); - None - } - }; - - #[cfg(feature = "discord-rpc")] - let discord_rpc_manager: DiscordRpcHandle = if user_config.behavior.enable_discord_rpc { - match resolve_discord_app_id(&user_config) - .and_then(|app_id| discord_rpc::DiscordRpcManager::new(app_id).ok()) - { - Some(mgr) => { - info!("discord rich presence enabled"); - Some(mgr) - } - None => { - info!("discord rich presence failed to initialize"); - None - } - } - } else { - info!("discord rich presence disabled"); - None - }; - #[cfg(not(feature = "discord-rpc"))] - let discord_rpc_manager: DiscordRpcHandle = None; - - // Spawn MPRIS event handler to process external control requests (media keys, playerctl) - #[cfg(all(feature = "mpris", target_os = "linux"))] - if let Some(ref mpris) = mpris_manager { - if let Some(event_rx) = mpris.take_event_rx() { - let mpris_for_seek = Arc::clone(mpris); - let app_for_mpris = Arc::clone(&app); - tokio::spawn(async move { - handle_mpris_events( - event_rx, - shared_is_playing_for_mpris, - shared_position_for_mpris, - mpris_for_seek, - app_for_mpris, - ) - .await; - }); - } - } - - // Spawn macOS media event handler to process external control requests (media keys, Control Center) - #[cfg(all(feature = "macos-media", target_os = "macos"))] - if let Some(ref macos_media) = macos_media_manager { - if let Some(event_rx) = macos_media.take_event_rx() { - let app_for_macos = Arc::clone(&app); - tokio::spawn(async move { - handle_macos_media_events(event_rx, app_for_macos, shared_is_playing_for_macos).await; - }); - } - } - - // Keep Now Playing metadata (including artwork URL from Web API playback state) - // synchronized with Control Center. - #[cfg(all(feature = "macos-media", target_os = "macos"))] - if let Some(ref macos_media) = macos_media_manager { - let macos_media_for_metadata = Arc::clone(macos_media); - let app_for_macos_metadata = Arc::clone(&app); - tokio::spawn(async move { - let mut last_metadata: Option = None; - let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1)); - - loop { - interval.tick().await; - if let Ok(app) = app_for_macos_metadata.try_lock() { - update_macos_metadata(&macos_media_for_metadata, &mut last_metadata, &app); - } - } - }); - } - - #[cfg(all(feature = "windows-media", target_os = "windows"))] - if let Some(ref windows_media) = windows_media_manager { - if let Some(event_rx) = windows_media.take_event_rx() { - let app_for_windows = Arc::clone(&app); - tokio::spawn(async move { - handle_windows_media_events(event_rx, app_for_windows).await; - }); - } - } - - #[cfg(all(feature = "windows-media", target_os = "windows"))] - if let Some(ref windows_media) = windows_media_manager { - let windows_media_for_metadata = Arc::clone(windows_media); - let app_for_windows_metadata = Arc::clone(&app); - tokio::spawn(async move { - let mut last_metadata: Option = None; - let mut last_playing: Option = None; // Track play state - let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1)); - - loop { - interval.tick().await; - if let Ok(app) = app_for_windows_metadata.try_lock() { - let snapshot_state = - update_windows_metadata(&windows_media_for_metadata, &mut last_metadata, &app); - // Native playback pushes its own state from the player events; an - // external device or a decoded source (over paused librespot) is - // polled here from the snapshot. - if app.native_track_info.is_none() || app.active_decoded_source() { - let (is_playing, position_ms) = - snapshot_state.unwrap_or((false, app.song_progress_ms as u64)); - if last_playing != Some(is_playing) { - windows_media_for_metadata.set_playback_status(is_playing); - last_playing = Some(is_playing); - } - windows_media_for_metadata.set_position(position_ms); - } else { - last_playing = Some(app.native_is_playing.unwrap_or(false)); - } - } - } - }); - } - - // Clone MPRIS manager for player event handler - #[cfg(all(feature = "streaming", feature = "mpris", target_os = "linux"))] - let mpris_for_events = mpris_manager.clone(); - - // Clone macOS media manager for player event handler - #[cfg(all(feature = "macos-media", target_os = "macos"))] - let macos_media_for_events = macos_media_manager.clone(); - - // Clone MPRIS manager for UI loop (to update status on device changes) - #[cfg(all(feature = "mpris", target_os = "linux"))] - let mpris_for_ui = mpris_manager.clone(); - - #[cfg(all(feature = "windows-media", target_os = "windows"))] - let windows_media_for_events = windows_media_manager.clone(); - - // Kick off the deferred native-streaming startup: account probe, librespot - // handshake, player event handler, and saved-device startup decision all - // run on a background task while the UI renders (see - // `deferred_streaming_startup`). - #[cfg(feature = "streaming")] - if streaming_attempted { - // When resuming a non-Spotify session, never transfer Spotify playback - // to a device on startup — that would fight the restored source for the - // audio output. Treat the device decision as passive (Continue); the - // device list is still fetched for the UI. - let device_startup_behavior = if restore_playback.is_some() { - StartupBehavior::Continue - } else { - initial_startup_behavior - }; - // While init is running, a playback request that finds no active device - // parks itself for replay instead of erroring (the task clears this and - // replays whatever parked when it finishes, whatever the outcome). - app.lock().await.native_backend_pending = true; - deferred_streaming_startup(DeferredStreamingContext { - app: Arc::clone(&app), - spotify: spotify - .clone() - .expect("streaming_attempted implies a Spotify session"), - cached_me, - token_cache_path: final_token_cache_path.clone(), - client_config: client_config.clone(), - redirect_uri: selected_redirect_uri, - volume_percent: runtime_state.volume_percent, - device_startup_behavior, - // A restored non-Spotify session owns the startup play/pause decision; - // otherwise the deferred task fires it once the device is selected. - spotify_startup_behavior: if restore_playback.is_some() { - None - } else { - Some(initial_startup_behavior) - }, - initial_shuffle_enabled, - recovery_tx: streaming_recovery_tx, - recovery_rx: streaming_recovery_rx, - shared_position: shared_position_for_events, - shared_is_playing: shared_is_playing_for_events, - #[cfg(all(feature = "mpris", target_os = "linux"))] - mpris_manager: mpris_for_events, - #[cfg(all(feature = "macos-media", target_os = "macos"))] - macos_media_manager: macos_media_for_events, - #[cfg(all(feature = "windows-media", target_os = "windows"))] - windows_media_manager: windows_media_for_events, - }); - } - - let cloned_app = Arc::clone(&app); - info!("spawning spotify network event handler"); - tokio::spawn(async move { - let mut network = Network::new(spotify, client_config, &app, final_token_cache_path); - - // The saved-device startup decision moved into - // `deferred_streaming_startup` (it needs the native player's device - // name, which now materializes in the background). - - // Resume a persisted non-Spotify session if there is one; it honors the - // startup behavior for its own play/pause decision. Otherwise fall back to - // the Spotify startup play behavior. Continue is passive and must not - // transfer devices, change shuffle, or otherwise activate Spotatui. - // Restore the persisted native queue into app state before the runner - // starts, independent of whether a source playback is resumed. - if !restore_queue.is_empty() { - network.app.lock().await.native_queue = restore_queue; - } - if let Some(session) = restore_playback { - // Resume off the event pump: a slow source (yt-dlp download, remote - // fetch) must not stall the Spotify startup events the UI's first render - // queues (user, playlists, current playback). The restore drives the - // source's own start path, which serializes on the `App` lock like every - // other event, so running it concurrently is safe. - let restore_app = Arc::clone(&network.app); - tokio::spawn(async move { - restore_playback_session(&restore_app, session, initial_startup_behavior).await; - }); - } else if network.spotify.is_some() { - // Spotify startup play/pause only applies with a Spotify session; a - // free-source launch has nothing to activate here. When native - // streaming init is deferred, `deferred_streaming_startup` fires this - // after the device decision instead — running it here would race the - // init and 404 with NO_ACTIVE_DEVICE onto the Error screen. - #[cfg(feature = "streaming")] - let startup_behavior_runs_here = !streaming_attempted; - #[cfg(not(feature = "streaming"))] - let startup_behavior_runs_here = true; - if startup_behavior_runs_here { - match initial_startup_behavior { - StartupBehavior::Continue => {} - StartupBehavior::Play => { - network - .handle_network_event(IoEvent::Shuffle(initial_shuffle_enabled)) - .await; - network - .handle_network_event(IoEvent::StartPlayback(None, None, None)) - .await; - } - StartupBehavior::Pause => { - network.handle_network_event(IoEvent::PausePlayback).await; - } - } - } - } - - start_tokio(sync_io_rx, &mut network).await; - }); - // The UI must run in the "main" thread - info!("starting terminal ui event loop"); - #[cfg(feature = "streaming")] - let shared_pos_for_start_ui: Option> = Some(shared_position_for_ui); - #[cfg(not(feature = "streaming"))] - let shared_pos_for_start_ui: Option> = None; - let ui_result = crate::tui::runner::start_ui( - user_config, - &cloned_app, - shared_pos_for_start_ui, - #[cfg(all(feature = "mpris", target_os = "linux"))] - mpris_for_ui, - #[cfg(not(all(feature = "mpris", target_os = "linux")))] - None, - discord_rpc_manager, - history_collector, - ) - .await; - // Unpublish the control file on the way out, whether the UI exited cleanly - // or not: leaving it behind sends the next `spotatui mcp` at a port nothing - // is listening on, which reads as a broken MCP server rather than an absent - // one. - #[cfg(feature = "mcp-server")] - crate::infra::mcp::clear_handshake(); - if ui_result.is_err() { - let mut app = cloned_app.lock().await; - app.flush_state_save(true); - } - ui_result?; - - Ok(()) -} - -/// Resume a persisted non-Spotify playback session at launch. -/// -/// Drives the source's existing, tested start path (seeding the browse table -/// with the persisted metadata so the snapshot resolves, then dispatching the -/// same `StartPlayback` the keyboard would), and afterwards applies the saved -/// position and the play/pause decision. That decision follows `startup_behavior`: -/// `Play` forces playing, `Pause` forces paused, and `Continue` restores the -/// exact state the session had when it was saved. -/// -/// A failed start (removed video, dead network, macOS local playback, missing -/// yt-dlp) publishes no session, so the resume step simply finds nothing and -/// no-ops — startup is never blocked or crashed by a stale session. Any variant -/// whose source feature is disabled in this build is a no-op. -#[allow(unused_variables)] -async fn restore_playback_session( - app: &Arc>, - session: crate::core::persisted_playback::PersistedPlayback, - startup_behavior: StartupBehavior, -) { - #[cfg(feature = "audio-decode")] - use crate::core::persisted_playback::PersistedPlayback; - - // Resolve whether the restored track should end up paused. - let resolve_paused = |saved_paused: bool| match startup_behavior { - StartupBehavior::Play => false, - StartupBehavior::Pause => true, - StartupBehavior::Continue => saved_paused, - }; - - match session { - #[cfg(feature = "local-files")] - PersistedPlayback::Local { - queue, - index, - position_ms, - paused, - repeat, - shuffle_on, - shuffle, - } => { - if queue.is_empty() { - return; - } - // Local reads tags from disk, so the URI queue alone drives the start. - crate::infra::local::dispatch::route_local_event( - app, - &IoEvent::StartPlayback(None, Some(queue), Some(index)), - ) - .await; - let mut guard = app.lock().await; - // Only adopt the persisted modes if the source actually started; a decode - // failure would otherwise leave the player-global flags set with no source. - // Trust the persisted intent (`shuffle_on`) for the flag and restore the - // backup as-is (the start path above did not re-shuffle because - // `decoded_shuffle` is still off). When the two disagree — a toggle deferred - // at exit — the driver's `reconcile_decoded_shuffle` re-syncs the queue - // order to the flag on the next tick. - if guard.local_playback.is_some() { - guard.decoded_repeat = repeat; - guard.decoded_shuffle = shuffle_on; - } - if let Some(s) = guard.local_playback.as_mut() { - if position_ms > 0 { - let _ = s.player.seek(Duration::from_millis(position_ms)); - } - if resolve_paused(paused) { - s.player.pause(); - } - s.shuffle_backup = shuffle; - } - } - #[cfg(feature = "subsonic")] - PersistedPlayback::Subsonic { - tracks, - index, - position_ms, - paused, - repeat, - shuffle_on, - shuffle, - } => { - let uris: Vec = tracks.iter().filter_map(|t| t.uri.clone()).collect(); - if uris.is_empty() { - return; - } - // Seed the browse table so the start path's snapshot resolves metadata. - app.lock().await.track_table.tracks = tracks; - crate::infra::subsonic::dispatch::route_subsonic_event( - app, - &IoEvent::StartPlayback(None, Some(uris), Some(index)), - ) - .await; - let mut guard = app.lock().await; - if guard.subsonic_playback.is_some() { - guard.decoded_repeat = repeat; - guard.decoded_shuffle = shuffle_on; - } - if let Some(s) = guard.subsonic_playback.as_mut() { - if position_ms > 0 { - let _ = s.player.seek(Duration::from_millis(position_ms)); - } - if resolve_paused(paused) { - s.player.pause(); - } - s.shuffle_backup = shuffle; - } - } - #[cfg(feature = "qobuz")] - PersistedPlayback::Qobuz { - tracks, - index, - position_ms, - paused, - repeat, - shuffle_on, - shuffle, - } => { - let uris: Vec = tracks.iter().filter_map(|t| t.uri.clone()).collect(); - if uris.is_empty() { - return; - } - // Seed the browse table so the start path's snapshot resolves metadata. - app.lock().await.track_table.tracks = tracks; - // The download runs off the pump; the seek and pause apply when it plays. - let resume = crate::infra::qobuz::ResumePoint { - position_ms, - paused: resolve_paused(paused), - }; - crate::infra::qobuz::dispatch::start_qobuz_queue(app, &uris, index, Some(resume)).await; - let mut guard = app.lock().await; - if guard.qobuz_playback.is_some() { - guard.decoded_repeat = repeat; - guard.decoded_shuffle = shuffle_on; - } - if let Some(s) = guard.qobuz_playback.as_mut() { - s.shuffle_backup = shuffle; - } - } - #[cfg(feature = "youtube")] - PersistedPlayback::YouTube { - tracks, - index, - position_ms, - paused, - repeat, - shuffle_on, - shuffle, - } => { - let uris: Vec = tracks.iter().filter_map(|t| t.uri.clone()).collect(); - if uris.is_empty() { - return; - } - app.lock().await.track_table.tracks = tracks; - crate::infra::youtube::dispatch::route_youtube_event( - app, - &IoEvent::StartPlayback(None, Some(uris), Some(index)), - ) - .await; - let mut guard = app.lock().await; - if guard.youtube_playback.is_some() { - guard.decoded_repeat = repeat; - guard.decoded_shuffle = shuffle_on; - } - if let Some(s) = guard.youtube_playback.as_mut() { - if position_ms > 0 { - let _ = s.player.seek(Duration::from_millis(position_ms)); - } - if resolve_paused(paused) { - s.player.pause(); - } - s.shuffle_backup = shuffle; - } - } - #[cfg(feature = "internet-radio")] - PersistedPlayback::Radio { station, paused } => { - let Some(uri) = station.uri.clone() else { - return; - }; - // Seed the browse table so the start path's station snapshot resolves. - app.lock().await.track_table.tracks = vec![station]; - crate::infra::radio::dispatch::route_radio_event( - app, - &IoEvent::StartPlayback(Some(uri), None, None), - ) - .await; - // A live stream has no seekable position; only apply the pause decision. - let guard = app.lock().await; - if let Some(s) = guard.radio_playback.as_ref() { - if resolve_paused(paused) { - s.player.pause(); - } - } - } - // Any variant whose source feature is disabled in this build. - #[allow(unreachable_patterns)] - _ => {} - } -} - -/// Handle MPRIS events from external clients (media keys, playerctl, etc.) -/// Routes to native streaming player when available, or dispatches IoEvents as fallback -#[cfg(all(feature = "mpris", target_os = "linux"))] -async fn handle_mpris_events( - mut event_rx: tokio::sync::mpsc::UnboundedReceiver, - shared_is_playing: Arc, - shared_position: Arc, - mpris_manager: Arc, - app: Arc>, -) { - use mpris::MprisEvent; - #[cfg(feature = "streaming")] - use std::sync::atomic::Ordering; - - while let Some(event) = event_rx.recv().await { - if !app.lock().await.user_config.behavior.enable_media_keys { - continue; - } - - // A decoded source (local file, Subsonic, radio, or YouTube) owns the - // session: route transport through the same IoEvents the keyboard uses - // (intercepted by the per-source route_*_event dispatchers before the - // Spotify network) so media keys follow the audible source instead of - // librespot. This must run *before* the streaming-player branches below, - // since librespot is initialized even while a decoded source is playing. - #[cfg(feature = "audio-decode")] - if route_decoded_mpris_event(&event, &app, &mpris_manager).await { - continue; - } - - // Dynamically fetch the current active player so MPRIS can target the correct player - // and not the stale player. The old player can be stale on, e.g., native streaming recovery. - #[cfg(feature = "streaming")] - let current_player = { - let app_lock = app.lock().await; - app_lock.streaming_player.clone() - }; - - match event { - MprisEvent::PlayPause => { - #[cfg(feature = "streaming")] - if let Some(ref player) = current_player { - if shared_is_playing.load(Ordering::Relaxed) { - player.pause(); - } else { - player.play(); - } - continue; - } - // Fallback: dispatch IoEvent - let mut app_lock = app.lock().await; - let is_playing = app_lock.native_is_playing.unwrap_or_else(|| { - app_lock - .current_playback_context - .as_ref() - .map(|c| c.is_playing) - .unwrap_or(false) - }); - if is_playing { - app_lock.dispatch_spotify_fallback(IoEvent::PausePlayback); - } else { - app_lock.dispatch_spotify_fallback(IoEvent::StartPlayback(None, None, None)); - } - } - MprisEvent::Play => { - #[cfg(feature = "streaming")] - if let Some(ref player) = current_player { - player.play(); - app.lock().await.set_native_playback_intent(true); - continue; - } - let mut app_lock = app.lock().await; - app_lock.dispatch_spotify_fallback(IoEvent::StartPlayback(None, None, None)); - } - MprisEvent::Pause => { - #[cfg(feature = "streaming")] - if let Some(ref player) = current_player { - player.pause(); - app.lock().await.set_native_playback_intent(false); - continue; - } - let mut app_lock = app.lock().await; - app_lock.dispatch_spotify_fallback(IoEvent::PausePlayback); - } - MprisEvent::Next => { - app.lock().await.next_track(); - } - MprisEvent::Previous => { - app.lock().await.previous_track(); - } - MprisEvent::Stop => { - #[cfg(feature = "streaming")] - if let Some(ref player) = current_player { - player.pause(); - app.lock().await.set_native_playback_intent(false); - continue; - } - let mut app_lock = app.lock().await; - app_lock.dispatch_spotify_fallback(IoEvent::PausePlayback); - } - MprisEvent::Seek(offset_micros) => { - // MPRIS sends relative offset in microseconds (can be negative for rewind) - #[cfg(feature = "streaming")] - if let Some(ref player) = current_player { - let current_ms = shared_position.load(Ordering::Relaxed) as i64; - let offset_ms = offset_micros / 1000; - let new_position_ms = (current_ms + offset_ms).max(0) as u32; - player.seek(new_position_ms); - shared_position.store(new_position_ms as u64, Ordering::Relaxed); - if let Ok(mut app_lock) = app.try_lock() { - app_lock.song_progress_ms = new_position_ms as u128; - app_lock.set_native_recovery_position(new_position_ms); - } - mpris_manager.emit_seeked(new_position_ms as u64); - continue; - } - // Fallback: read current position from app, dispatch Seek IoEvent - let mut app_lock = app.lock().await; - let current_ms = app_lock.song_progress_ms as i64; - let offset_ms = offset_micros / 1000; - let new_position_ms = (current_ms + offset_ms).max(0) as u32; - app_lock.song_progress_ms = new_position_ms as u128; - app_lock.dispatch_spotify_fallback(IoEvent::Seek(new_position_ms)); - drop(app_lock); - mpris_manager.emit_seeked(new_position_ms as u64); - } - MprisEvent::SetPosition(position_micros) => { - // MPRIS SetPosition sends absolute position in microseconds - let new_position_ms = (position_micros / 1000).max(0) as u32; - #[cfg(feature = "streaming")] - if let Some(ref player) = current_player { - player.seek(new_position_ms); - shared_position.store(new_position_ms as u64, Ordering::Relaxed); - if let Ok(mut app_lock) = app.try_lock() { - app_lock.song_progress_ms = new_position_ms as u128; - app_lock.set_native_recovery_position(new_position_ms); - } - mpris_manager.emit_seeked(new_position_ms as u64); - continue; - } - // Fallback: dispatch Seek IoEvent - let mut app_lock = app.lock().await; - app_lock.song_progress_ms = new_position_ms as u128; - app_lock.dispatch_spotify_fallback(IoEvent::Seek(new_position_ms)); - drop(app_lock); - mpris_manager.emit_seeked(new_position_ms as u64); - } - MprisEvent::SetShuffle(shuffle) => { - #[cfg(feature = "streaming")] - if let Some(ref player) = current_player { - if let Err(e) = player.set_shuffle(shuffle) { - log::warn!("MPRIS: Failed to set shuffle: {}", e); - } else { - mpris_manager.set_shuffle(shuffle); - let mut app_lock = app.lock().await; - app_lock.set_native_recovery_shuffle(shuffle); - if let Some(ref mut ctx) = app_lock.current_playback_context { - ctx.shuffle_state = shuffle; - } - app_lock.runtime_state.shuffle_enabled = shuffle; - app_lock.schedule_state_save( - crate::core::state::PersistedRuntimeState::shuffle_enabled(shuffle), - ); - } - continue; - } - // Fallback: dispatch Shuffle IoEvent - mpris_manager.set_shuffle(shuffle); - let mut app_lock = app.lock().await; - if let Some(ref mut ctx) = app_lock.current_playback_context { - ctx.shuffle_state = shuffle; - } - app_lock.runtime_state.shuffle_enabled = shuffle; - app_lock.schedule_state_save(crate::core::state::PersistedRuntimeState::shuffle_enabled( - shuffle, - )); - app_lock.dispatch_spotify_fallback(IoEvent::Shuffle(shuffle)); - } - MprisEvent::SetLoopStatus(loop_status) => { - use mpris::LoopStatusEvent; - use rspotify::model::enums::RepeatState; - - let repeat_state = match loop_status { - LoopStatusEvent::None => RepeatState::Off, - LoopStatusEvent::Track => RepeatState::Track, - LoopStatusEvent::Playlist => RepeatState::Context, - }; - #[cfg(feature = "streaming")] - if let Some(ref player) = current_player { - if let Err(e) = player.set_repeat_mode(repeat_state) { - log::warn!("MPRIS: Failed to set repeat mode: {}", e); - } else { - mpris_manager.set_loop_status(loop_status); - let mut app_lock = app.lock().await; - app_lock.set_native_recovery_repeat(repeat_state); - if let Some(ref mut ctx) = app_lock.current_playback_context { - ctx.repeat_state = repeat_state; - } - } - continue; - } - // Fallback: dispatch Repeat IoEvent - mpris_manager.set_loop_status(loop_status); - let mut app_lock = app.lock().await; - if let Some(ref mut ctx) = app_lock.current_playback_context { - ctx.repeat_state = repeat_state; - } - app_lock.dispatch_spotify_fallback(IoEvent::Repeat(repeat_state)); - } - MprisEvent::SetVolume(volume_percent) => { - let mut app_lock = app.lock().await; - app_lock.set_volume_percent(volume_percent); - } - } - } -} - -/// Route an MPRIS transport event through the standard dispatch path when any -/// decoded source (local file, Subsonic, internet radio, or YouTube) owns the -/// session. -/// -/// Returns `true` if the event was consumed (and the caller must skip the -/// Spotify/librespot branches). Play/pause/next/previous/stop/seek map onto the -/// same `IoEvent`s the keyboard uses; the per-source `route_*_event` dispatchers -/// intercept them before the Spotify network, so the control lands on whichever -/// source is actually audible instead of the paused librespot session. -/// Non-transport events (shuffle/loop) return `false` so existing behaviour is -/// preserved. -#[cfg(all(feature = "mpris", target_os = "linux", feature = "audio-decode",))] -async fn route_decoded_mpris_event( - event: &mpris::MprisEvent, - app: &Arc>, - mpris_manager: &Arc, -) -> bool { - use mpris::MprisEvent; - - let mut app_lock = app.lock().await; - - // The native queue slot owns playback: shuffle/repeat mean nothing over an - // explicit queue, whatever it suspended, so reject them here rather than in the - // match below. A queued *Spotify* track has no decoded player and would - // otherwise take the early return underneath and let the Spotify handler mutate - // the suspended context — the one state where MPRIS and the keyboard - // (`App::shuffle` / `App::repeat`, which no-op) would disagree. The corrective - // push matches what the snapshot reports for a queue slot, so the runner's - // dedup cache converges instead of stranding the property. - if app_lock.queue_owns_playback() { - match event { - MprisEvent::SetShuffle(_) => { - drop(app_lock); - mpris_manager.set_shuffle(false); - return true; - } - MprisEvent::SetLoopStatus(_) => { - drop(app_lock); - mpris_manager.set_loop_status(mpris::LoopStatusEvent::None); - return true; - } - _ => {} - } - } - - // Read the live source-player state up front, then drop the borrow so the - // immutable read does not conflict with the `&mut self` dispatch calls below. - // A decoded claim with no player (a start in flight, a lost output device) - // consumes the event: nothing can serve it, and librespot must not. Volume - // still falls through to `set_volume_percent`, which is owner-aware. - let Some(player) = app_lock.active_decoded_player() else { - return !matches!(event, MprisEvent::SetVolume(_)) && app_lock.active_decoded_source(); - }; - let is_paused = player.is_paused(); - let position_ms = player.position().as_millis() as i64; - - match event { - MprisEvent::PlayPause => { - if is_paused { - app_lock.dispatch(IoEvent::StartPlayback(None, None, None)); - } else { - app_lock.dispatch(IoEvent::PausePlayback); - } - true - } - MprisEvent::Play => { - app_lock.dispatch(IoEvent::StartPlayback(None, None, None)); - true - } - MprisEvent::Pause | MprisEvent::Stop => { - app_lock.dispatch(IoEvent::PausePlayback); - true - } - MprisEvent::Next => { - app_lock.dispatch(IoEvent::NextTrack); - true - } - MprisEvent::Previous => { - app_lock.dispatch(IoEvent::PreviousTrack); - true - } - MprisEvent::Seek(offset_micros) => { - let offset_ms = offset_micros / 1000; - let new_position_ms = (position_ms + offset_ms).max(0) as u32; - app_lock.dispatch(IoEvent::Seek(new_position_ms)); - drop(app_lock); - mpris_manager.emit_seeked(new_position_ms as u64); - true - } - MprisEvent::SetPosition(position_micros) => { - let new_position_ms = (position_micros / 1000).max(0) as u32; - app_lock.dispatch(IoEvent::Seek(new_position_ms)); - drop(app_lock); - mpris_manager.emit_seeked(new_position_ms as u64); - true - } - // Shuffle/repeat drive the player-global decoded state so an external - // controller changes the audible decoded source, not the stale Spotify - // context. A queueable source (Local/Subsonic/YouTube) consumes them; the - // sources that cannot honour them reject them (below) rather than falling - // through. Only Spotify (no decoded player at all, so this function returned - // early) reaches the Spotify handling. - // Volume is handled by the top-level `set_volume_percent`. - MprisEvent::SetShuffle(on) => { - if app_lock.set_decoded_shuffle(*on) { - drop(app_lock); - mpris_manager.set_shuffle(*on); - } else { - // A decoded source owns playback but has no shuffle of its own: radio is - // an endless stream, and the queue slot plays an explicit list over a - // suspended context. Consume the event instead of falling through to the - // Spotify handler, which would flip shuffle on the user's real device for - // a source they are not listening to (and persist it to their config). - // Push the true value back so the client's optimistic flip is corrected: - // the runner re-pushes only when the snapshot *changes* against its own - // cache, and it never sees this out-of-band write, so leaving the client - // at the rejected value would strand the property there for the session. - drop(app_lock); - mpris_manager.set_shuffle(false); - } - true - } - MprisEvent::SetLoopStatus(status) => { - use crate::infra::queue::RepeatMode; - let mode = match status { - mpris::LoopStatusEvent::None => RepeatMode::Off, - mpris::LoopStatusEvent::Track => RepeatMode::Track, - mpris::LoopStatusEvent::Playlist => RepeatMode::Context, - }; - if app_lock.set_decoded_repeat(mode) { - drop(app_lock); - mpris_manager.set_loop_status(*status); - } else { - // Rejected for the same reason as `SetShuffle` above, and corrected back - // to the `None` the snapshot reports for these sources. - drop(app_lock); - mpris_manager.set_loop_status(mpris::LoopStatusEvent::None); - } - true - } - MprisEvent::SetVolume(_) => false, - } -} - -/// Handle macOS media events from external sources (media keys, Control Center, AirPods, etc.) -/// Routes control requests to the native streaming player -#[cfg(all(feature = "macos-media", target_os = "macos"))] -async fn handle_macos_media_events( - mut event_rx: tokio::sync::mpsc::UnboundedReceiver, - app: Arc>, - shared_is_playing: Arc, -) { - use macos_media::MacMediaEvent; - use std::sync::atomic::Ordering; - - while let Some(event) = event_rx.recv().await { - if !app.lock().await.user_config.behavior.enable_media_keys { - continue; - } - - // A decoded source (local file, Subsonic, radio, or YouTube) owns the - // session: route transport through the same IoEvents the keyboard uses - // (intercepted by the per-source route_*_event dispatchers before the - // Spotify network) so media keys follow the audible source instead of - // librespot. This must run *before* `active_streaming_player` below, since - // librespot stays active even while a decoded source is playing. - #[cfg(feature = "audio-decode")] - if route_decoded_macos_event(&event, &app).await { - continue; - } - - let Some(player) = player::active_streaming_player(&app).await else { - continue; - }; - - match event { - MacMediaEvent::PlayPause => { - // Toggle based on atomic state (lock-free, always up-to-date) - if shared_is_playing.load(Ordering::Relaxed) { - player.pause(); - } else { - player.play(); - } - } - MacMediaEvent::Play => { - player.play(); - } - MacMediaEvent::Pause => { - player.pause(); - } - MacMediaEvent::Next => { - let _ = player; - app.lock().await.next_track(); - } - MacMediaEvent::Previous => { - let _ = player; - app.lock().await.previous_track(); - } - MacMediaEvent::Stop => { - player.pause(); - app.lock().await.set_native_playback_intent(false); - } - } - } -} - -/// Route a macOS media transport event through the standard dispatch path when -/// any decoded source (local file, Subsonic, internet radio, or YouTube) owns -/// the session. -/// -/// Returns `true` if the event was consumed (and the caller must skip the -/// streaming-player branches). Play/pause/next/previous/stop map onto the same -/// `IoEvent`s the keyboard uses; the per-source `route_*_event` dispatchers -/// intercept them before the Spotify network, so the control lands on whichever -/// source is actually audible instead of the paused librespot session. -#[cfg(all(feature = "macos-media", target_os = "macos", feature = "audio-decode",))] -async fn route_decoded_macos_event( - event: &macos_media::MacMediaEvent, - app: &Arc>, -) -> bool { - use macos_media::MacMediaEvent; - - let mut app_lock = app.lock().await; - // Read the live source-player state up front, then drop the borrow so the - // immutable read does not conflict with the `&mut self` dispatch calls below. - // A decoded claim with no player (a start in flight, a lost output device) - // consumes the event: nothing can serve it, and librespot must not. - let Some(player) = app_lock.active_decoded_player() else { - return app_lock.active_decoded_source(); - }; - let is_paused = player.is_paused(); - - match event { - MacMediaEvent::PlayPause => { - if is_paused { - app_lock.dispatch(IoEvent::StartPlayback(None, None, None)); - } else { - app_lock.dispatch(IoEvent::PausePlayback); - } - } - MacMediaEvent::Play => { - app_lock.dispatch(IoEvent::StartPlayback(None, None, None)); - } - MacMediaEvent::Pause | MacMediaEvent::Stop => { - app_lock.dispatch(IoEvent::PausePlayback); - } - MacMediaEvent::Next => { - app_lock.dispatch(IoEvent::NextTrack); - } - MacMediaEvent::Previous => { - app_lock.dispatch(IoEvent::PreviousTrack); - } - } - true -} - -#[cfg(all(feature = "windows-media", target_os = "windows"))] -async fn handle_windows_media_events( - mut event_rx: tokio::sync::mpsc::UnboundedReceiver, - app: Arc>, -) { - use smtc_tokio::WindowsMediaEvent; - - while let Some(event) = event_rx.recv().await { - if !app.lock().await.user_config.behavior.enable_media_keys { - continue; - } - - // A decoded source (local file, Subsonic, radio, or YouTube) owns the - // session: route transport through the same IoEvents the keyboard uses - // (intercepted by the per-source route_*_event dispatchers before the - // Spotify network) so SMTC controls follow the audible source instead of - // librespot. This must run *before* the streaming-player branches below, - // since librespot stays active even while a decoded source is playing. - #[cfg(feature = "audio-decode")] - if route_decoded_windows_event(&event, &app).await { - continue; - } - - let player_opt = player::active_streaming_player(&app).await; - - let is_native_loaded = app.lock().await.native_track_info.is_some(); - - match event { - WindowsMediaEvent::Play => { - if let Some(player) = &player_opt { - if is_native_loaded { - player.play(); - continue; - } - } - app - .lock() - .await - .dispatch_spotify_fallback(IoEvent::StartPlayback(None, None, None)); - } - WindowsMediaEvent::Pause => { - if let Some(player) = &player_opt { - if is_native_loaded { - player.pause(); - continue; - } - } - app - .lock() - .await - .dispatch_spotify_fallback(IoEvent::PausePlayback); - } - WindowsMediaEvent::Next => { - app.lock().await.next_track(); - } - WindowsMediaEvent::Previous => { - app.lock().await.previous_track(); - } - WindowsMediaEvent::Stop => { - if let Some(player) = &player_opt { - if is_native_loaded { - player.pause(); - app.lock().await.set_native_playback_intent(false); - continue; - } - } - app - .lock() - .await - .dispatch_spotify_fallback(IoEvent::PausePlayback); - } - WindowsMediaEvent::SetPosition(pos) => { - if let Some(player) = &player_opt { - if is_native_loaded { - player.seek(pos as u32); - continue; - } - } - let mut app_lock = app.lock().await; - app_lock.song_progress_ms = pos as u128; - app_lock.dispatch_spotify_fallback(IoEvent::Seek(pos as u32)); - } - } - } -} - -/// Route a Windows SMTC media transport event through the standard dispatch -/// path when any decoded source (local file, Subsonic, internet radio, or -/// YouTube) owns the session. -/// -/// Returns `true` if the event was consumed (and the caller must skip the -/// streaming-player branches). Play/pause/next/previous/stop/seek map onto the -/// same `IoEvent`s the keyboard uses; the per-source `route_*_event` dispatchers -/// intercept them before the Spotify network, so the control lands on whichever -/// source is actually audible instead of the paused librespot session. -#[cfg(all( - feature = "windows-media", - target_os = "windows", - feature = "audio-decode", -))] -async fn route_decoded_windows_event( - event: &smtc_tokio::WindowsMediaEvent, - app: &Arc>, -) -> bool { - use smtc_tokio::WindowsMediaEvent; - - let mut app_lock = app.lock().await; - // Only consume the event while a decoded source owns the session; otherwise - // fall through to the streaming-player branches for Spotify/librespot. - // A decoded claim with no player (a start in flight, a lost output device) - // consumes the event: nothing can serve it, and librespot must not. - if app_lock.active_decoded_player().is_none() { - return app_lock.active_decoded_source(); - } - - match event { - WindowsMediaEvent::Play => { - app_lock.dispatch(IoEvent::StartPlayback(None, None, None)); - } - WindowsMediaEvent::Pause | WindowsMediaEvent::Stop => { - app_lock.dispatch(IoEvent::PausePlayback); - } - WindowsMediaEvent::Next => { - app_lock.dispatch(IoEvent::NextTrack); - } - WindowsMediaEvent::Previous => { - app_lock.dispatch(IoEvent::PreviousTrack); - } - WindowsMediaEvent::SetPosition(pos) => { - app_lock.song_progress_ms = *pos as u128; - app_lock.dispatch(IoEvent::Seek(*pos as u32)); - } - } - true -} diff --git a/src/runtime/streaming/launch.rs b/src/runtime/streaming/launch.rs deleted file mode 100644 index 28f05456..00000000 --- a/src/runtime/streaming/launch.rs +++ /dev/null @@ -1,446 +0,0 @@ -//! The credential gate and the deferred librespot bring-up. - -use super::{startup_device_decision, StartupDeviceEvent}; -use crate::core::app::App; -use crate::core::config::ClientConfig; -use crate::core::onboarding::Onboarding; -use crate::core::user_config::StartupBehavior; -#[cfg(all(feature = "macos-media", target_os = "macos"))] -use crate::infra::macos_media; -#[cfg(all(feature = "mpris", target_os = "linux"))] -use crate::infra::mpris; -use crate::infra::network::requests::{self, spotify_get_typed_compat_for_with_refresh}; -use crate::infra::network::IoEvent; -use crate::infra::player; -use log::{info, warn}; -use rspotify::{model::user::PrivateUser, AuthCodePkceSpotify}; -use std::path::{Path, PathBuf}; -use std::sync::{atomic::AtomicU64, Arc}; -use std::time::Duration; -use tokio::sync::Mutex; - -fn subscription_level_label(level: rspotify::model::SubscriptionLevel) -> &'static str { - match level { - rspotify::model::SubscriptionLevel::Premium => "premium", - rspotify::model::SubscriptionLevel::Free => "free", - } -} - -/// The wait before the next plan check after a rate limit; `None` after the -/// last attempt, or for a window too long to hold startup for. -fn plan_check_retry_delay(attempt: u32, window: Option) -> Option { - const ATTEMPTS: u32 = 3; - const MAX_WAIT: Duration = Duration::from_secs(60); - let delay = window - .unwrap_or_default() - .saturating_add(Duration::from_secs(1)); - (attempt < ATTEMPTS && delay <= MAX_WAIT).then_some(delay) -} - -/// Can run with the UI already up, so outcomes are reported through `info!` and -/// the returned status message only, never `println!`, which would corrupt the -/// TUI. Reuses the `/me` captured during token validation when available -/// instead of paying a second round trip. With `wait_out_rate_limit`, a 429 -/// waits for the window and asks again instead of disabling streaming. -async fn account_supports_native_streaming( - spotify: &AuthCodePkceSpotify, - cached_me: Option, - token_cache_path: &Path, - app: &Arc>, - wait_out_rate_limit: bool, -) -> (bool, Option<&'static str>) { - let mut attempt = 0; - let user_result = match cached_me { - Some(user) => Ok(user), - None => loop { - let result = spotify_get_typed_compat_for_with_refresh::( - spotify, - "me", - &[], - token_cache_path, - app, - ) - .await; - match result { - Err(e) if wait_out_rate_limit && requests::is_rate_limited_error(&e) => { - attempt += 1; - let window = requests::shared_forced_refresh_gate() - .rate_limit_remaining() - .await; - let Some(delay) = plan_check_retry_delay(attempt, window) else { - break Err(e); - }; - info!( - "spotify plan check rate limited; asking again in {}s", - delay.as_secs() - ); - tokio::time::sleep(delay).await; - } - result => break result, - } - }, - }; - match user_result { - #[allow(deprecated)] - Ok(user) => match user.product { - Some(rspotify::model::SubscriptionLevel::Premium) => (true, None), - Some(level) => { - let plan = subscription_level_label(level); - info!( - "spotify {} account detected: playback is unavailable (native streaming and Web API playback controls require premium)", - plan - ); - ( - false, - Some("Spotify Free account: playback controls unavailable (Premium required)"), - ) - } - None => { - info!("spotify account level unknown: native streaming disabled to avoid librespot exit"); - ( - false, - Some("Could not verify Spotify plan: native streaming disabled"), - ) - } - }, - Err(e) => { - info!( - "spotify account level check failed ({}); native streaming disabled to avoid librespot exit", - e - ); - ( - false, - Some("Could not verify Spotify plan: native streaming disabled"), - ) - } - } -} - -/// Runs before the frontend starts: the OAuth flow blocks on a browser round trip. -#[cfg_attr(not(feature = "tui"), allow(dead_code))] -pub(crate) async fn cache_streaming_credentials( - client_config: &ClientConfig, - spotify: Option<&AuthCodePkceSpotify>, - cached_me: Option<&PrivateUser>, - token_cache_path: &Path, - onboarding: &Arc, - app: &Arc>, -) { - if !client_config.enable_streaming || player::streaming_credentials_are_cached().unwrap_or(false) - { - return; - } - let Some(spotify) = spotify else { - return; - }; - let (supported, status_message) = - account_supports_native_streaming(spotify, cached_me.cloned(), token_cache_path, app, false) - .await; - if let Some(message) = status_message { - app.lock().await.set_status_message(message, 12); - } - if !supported { - return; - } - // The OAuth flow spins up a blocking local callback server and waits on - // the browser; keep it off the async reactor so it never ties up a - // worker thread while the user completes sign-in. - let onboarding_for_streaming = onboarding.clone(); - let cached = tokio::task::spawn_blocking(move || { - player::ensure_streaming_credentials_cached(onboarding_for_streaming.as_ref()) - }) - .await - .unwrap_or_else(|e| Err(anyhow::anyhow!("credential caching task panicked: {e}"))); - if let Err(error) = cached { - warn!("native streaming authentication unavailable: {error}"); - // Name the actual failure. The generic message left issue #414's - // reporter with a browser "unable to connect" page, a login that - // repeated every launch, and nothing on screen or in reach that - // said which half of startup had failed or why. - app.lock().await.set_status_message( - format!("Native streaming authentication failed ({error}); using Spotify Connect."), - 10, - ); - } -} - -impl StartupDeviceEvent { - fn into_io_event(self) -> IoEvent { - match self { - StartupDeviceEvent::Transfer { - device_id, - persist_device_id, - } => IoEvent::TransferPlaybackToDevice(device_id, persist_device_id), - StartupDeviceEvent::AutoSelectStreaming { - device_name, - persist_device_id, - } => IoEvent::AutoSelectStreamingDevice(device_name, persist_device_id, false), - } - } -} - -/// Everything native streaming needs that used to gate the first frame: -/// account probe, librespot session handshake, player event handler, recovery -/// supervisor, and the saved-device startup decision. Bundled so -/// `deferred_streaming_startup` can run it all on a background task after the -/// UI is already up. -pub(crate) struct DeferredStreamingContext { - pub(crate) app: Arc>, - pub(crate) spotify: AuthCodePkceSpotify, - pub(crate) cached_me: Option, - pub(crate) token_cache_path: PathBuf, - pub(crate) client_config: ClientConfig, - pub(crate) redirect_uri: String, - pub(crate) volume_percent: u8, - pub(crate) device_startup_behavior: StartupBehavior, - /// Spotify startup Play/Pause, run after the device decision so it lands on - /// the selected device instead of 404ing with NO_ACTIVE_DEVICE while init is - /// still in flight. `None` when a non-Spotify session restore owns startup. - pub(crate) spotify_startup_behavior: Option, - pub(crate) initial_shuffle_enabled: bool, - pub(crate) recovery_tx: tokio::sync::mpsc::UnboundedSender, - pub(crate) recovery_rx: tokio::sync::mpsc::UnboundedReceiver, - pub(crate) shared_position: Arc, - pub(crate) shared_is_playing: Arc, - #[cfg(all(feature = "mpris", target_os = "linux"))] - pub(crate) mpris_manager: Option>, - #[cfg(all(feature = "macos-media", target_os = "macos"))] - pub(crate) macos_media_manager: Option>, - #[cfg(all(feature = "windows-media", target_os = "windows"))] - pub(crate) windows_media_manager: Option>, -} - -/// Initialize native streaming in the background. The UI renders its -/// first frame immediately; this task spawns the recovery supervisor, probes -/// the account (reusing the auth `/me` when available), performs the librespot -/// handshake with the same double-timeout as before, stores the player in -/// `App`, spawns the player event handler, and finally makes the saved-device -/// startup decision — dispatching its outcome through the normal IoEvent pump. -// CLI mode never starts native streaming; only a frontend launch does. -#[cfg_attr(not(feature = "tui"), allow(dead_code))] -pub(crate) fn deferred_streaming_startup(ctx: DeferredStreamingContext) { - tokio::spawn(async move { - let app = Arc::clone(&ctx.app); - let spotify_startup_behavior = ctx.spotify_startup_behavior; - let initial_shuffle_enabled = ctx.initial_shuffle_enabled; - deferred_streaming_startup_inner(ctx).await; - // Whatever happened above (backend up, unsupported account, failed or - // timed-out init), the pending window is over. - let mut app = app.lock().await; - app.native_backend_pending = false; - // The Spotify startup Play/Pause runs here, after the device decision: - // before init was deferred, the device transfer always completed first, - // and firing these earlier 404s with NO_ACTIVE_DEVICE straight onto the - // Error screen. A request the user parked during init takes precedence - // over the configured startup behavior — their intent is newer. - if app.pending_start_playback.is_none() { - match spotify_startup_behavior { - Some(StartupBehavior::Play) => { - app.dispatch(IoEvent::Shuffle(initial_shuffle_enabled)); - app.dispatch(IoEvent::StartPlayback(None, None, None)); - } - Some(StartupBehavior::Pause) => { - app.dispatch(IoEvent::PausePlayback); - } - Some(StartupBehavior::Continue) | None => {} - } - } - // A StartPlayback parked during init replays now — against the native - // backend when it exists, else through the normal Connect path. - app.replay_pending_start_playback(); - }); -} - -async fn deferred_streaming_startup_inner(ctx: DeferredStreamingContext) { - player::spawn_streaming_recovery_handler(player::StreamingRecoveryContext { - app: Arc::clone(&ctx.app), - shared_position: Arc::clone(&ctx.shared_position), - shared_is_playing: Arc::clone(&ctx.shared_is_playing), - recovery_rx: ctx.recovery_rx, - recovery_tx: ctx.recovery_tx.clone(), - client_config: ctx.client_config.clone(), - redirect_uri: ctx.redirect_uri.clone(), - #[cfg(all(feature = "mpris", target_os = "linux"))] - mpris_manager: ctx.mpris_manager.clone(), - #[cfg(all(feature = "macos-media", target_os = "macos"))] - macos_media_manager: ctx.macos_media_manager.clone(), - #[cfg(all(feature = "windows-media", target_os = "windows"))] - windows_media_manager: ctx.windows_media_manager.clone(), - }); - - let (supported, status_message) = account_supports_native_streaming( - &ctx.spotify, - ctx.cached_me, - &ctx.token_cache_path, - &ctx.app, - true, - ) - .await; - if let Some(message) = status_message { - ctx.app.lock().await.set_status_message(message, 12); - } - if !supported { - return; - } - - info!("initializing native streaming player"); - let streaming_config = player::StreamingConfig { - device_name: ctx.client_config.streaming_device_name.clone(), - bitrate: ctx.client_config.streaming_bitrate, - audio_cache: ctx.client_config.streaming_audio_cache, - cache_path: player::get_default_cache_path(), - initial_volume: ctx.volume_percent, - }; - let client_id = ctx.client_config.client_id.clone(); - let redirect_uri = ctx.redirect_uri; - - // Internal Spirc timeout defaults to 30s (configurable via - // SPOTATUI_STREAMING_INIT_TIMEOUT_SECS). The outer timeout here is a safety net - // that catches hangs *outside* Spirc init (e.g. OAuth callback never arriving, - // blocking I/O in credential retrieval). Set it above the internal timeout. - let internal_timeout_secs: u64 = std::env::var("SPOTATUI_STREAMING_INIT_TIMEOUT_SECS") - .ok() - .and_then(|v| v.parse().ok()) - .filter(|&v: &u64| v > 0) - .unwrap_or(30); - let outer_timeout = Duration::from_secs(internal_timeout_secs.saturating_add(15)); - - let init_task = tokio::spawn(async move { - player::StreamingPlayer::new_cache_only(&client_id, &redirect_uri, streaming_config).await - }); - let abort_handle = init_task.abort_handle(); - - let streaming_player = match tokio::time::timeout(outer_timeout, init_task).await { - Ok(Ok(Ok(p))) => { - info!( - "native streaming player initialized as '{}'", - p.device_name() - ); - // Note: We don't activate() here - that's handled by AutoSelectStreamingDevice - // which respects the user's saved device preference (e.g., spotifyd) - Arc::new(p) - } - Ok(Ok(Err(e))) => { - info!( - "failed to initialize streaming: {} - falling back to web api", - e - ); - ctx.app.lock().await.set_status_message( - "Native streaming didn't start; using Spotify Connect for now. Restart spotatui to reconnect native playback.", - 12, - ); - return; - } - Ok(Err(e)) => { - info!( - "streaming initialization panicked: {} - falling back to web api", - e - ); - return; - } - Err(_) => { - abort_handle.abort(); - warn!( - "streaming initialization hung unexpectedly (outer timeout {}s) - falling back to web api", - outer_timeout.as_secs() - ); - return; - } - }; - - info!("native playback enabled - spotatui is available as a spotify connect device"); - - // Store streaming player reference in App for direct control (bypasses event channel) - { - let mut app_mut = ctx.app.lock().await; - app_mut.streaming_player = Some(Arc::clone(&streaming_player)); - // Startup playlist loading may have fallen back to a flat list while the - // deferred player was unavailable. Refresh once so rootlist folders are - // reconciled now that librespot is ready. - app_mut.dispatch(IoEvent::GetPlaylists); - } - - // Spawn player event listener (updates app state from native player events) - player::spawn_player_event_handler(player::PlayerEventContext { - player: Arc::clone(&streaming_player), - app: Arc::clone(&ctx.app), - shared_position: ctx.shared_position, - shared_is_playing: ctx.shared_is_playing, - recovery_tx: ctx.recovery_tx, - #[cfg(all(feature = "mpris", target_os = "linux"))] - mpris_manager: ctx.mpris_manager, - #[cfg(all(feature = "macos-media", target_os = "macos"))] - macos_media_manager: ctx.macos_media_manager, - #[cfg(all(feature = "windows-media", target_os = "windows"))] - windows_media_manager: ctx.windows_media_manager, - }); - - // Auto-select the saved playback device when available (fallback to native - // streaming). This used to run inline in the network task before the pump - // started; the decision's outcome now dispatches through the pump. - let device_name = streaming_player.device_name().to_string(); - let saved_device_id = ctx.client_config.device_id.clone(); - let mut devices_snapshot = None; - if let Ok(devices) = - spotify_get_typed_compat_for_with_refresh::( - &ctx.spotify, - "me/player/devices", - &[], - &ctx.token_cache_path, - &ctx.app, - ) - .await - { - let devices_vec = devices.devices; - let mut app_mut = ctx.app.lock().await; - app_mut.devices = Some(rspotify::model::device::DevicePayload { - devices: devices_vec.clone(), - }); - devices_snapshot = Some(devices_vec); - } - - let startup_decision = startup_device_decision( - ctx.device_startup_behavior, - saved_device_id, - devices_snapshot.as_deref(), - &device_name, - ); - - let mut app_mut = ctx.app.lock().await; - if let Some(message) = startup_decision.status_message { - app_mut.set_status_message(message, 5); - } - if let Some(event) = startup_decision.event { - app_mut.dispatch(event.into_io_event()); - } -} - -#[cfg(test)] -mod tests { - use super::plan_check_retry_delay; - use std::time::Duration; - - #[test] - fn plan_check_waits_out_the_window_plus_a_second() { - assert_eq!( - plan_check_retry_delay(1, Some(Duration::from_secs(17))), - Some(Duration::from_secs(18)) - ); - assert_eq!( - plan_check_retry_delay(2, None), - Some(Duration::from_secs(1)) - ); - } - - #[test] - fn plan_check_gives_up_on_a_long_window_or_after_three_attempts() { - assert_eq!( - plan_check_retry_delay(1, Some(Duration::from_secs(600))), - None - ); - assert_eq!( - plan_check_retry_delay(3, Some(Duration::from_secs(5))), - None - ); - } -} diff --git a/src/runtime/streaming/mod.rs b/src/runtime/streaming/mod.rs deleted file mode 100644 index f2f21374..00000000 --- a/src/runtime/streaming/mod.rs +++ /dev/null @@ -1,303 +0,0 @@ -//! Native-streaming startup that every frontend shares. This file holds the -//! pure saved-device decision; `launch` holds the credential gate and the -//! deferred librespot bring-up. - -use crate::core::user_config::StartupBehavior; - -#[cfg(feature = "streaming")] -pub(super) mod launch; - -#[derive(Debug, PartialEq, Eq)] -enum StartupDeviceEvent { - Transfer { - device_id: String, - persist_device_id: bool, - }, - AutoSelectStreaming { - device_name: String, - persist_device_id: bool, - }, -} - -#[derive(Debug, PartialEq, Eq)] -struct StartupDeviceDecision { - event: Option, - status_message: Option, -} - -fn startup_device_decision( - startup_behavior: StartupBehavior, - saved_device_id: Option, - devices_snapshot: Option<&[rspotify::model::device::Device]>, - native_device_name: &str, -) -> StartupDeviceDecision { - if startup_behavior != StartupBehavior::Play { - return StartupDeviceDecision { - event: None, - status_message: None, - }; - } - - let event = match saved_device_id { - Some(saved_device_id) => { - if let Some(devices) = devices_snapshot { - let mut saved_device_available = false; - let mut native_device_id = None; - - for device in devices { - if device.id.as_ref() == Some(&saved_device_id) { - saved_device_available = true; - break; - } - - if native_device_id.is_none() && device.name.eq_ignore_ascii_case(native_device_name) { - native_device_id = device.id.clone(); - } - } - - if saved_device_available { - Some(StartupDeviceEvent::Transfer { - device_id: saved_device_id, - persist_device_id: true, - }) - } else { - native_device_id.map_or_else( - || { - Some(StartupDeviceEvent::AutoSelectStreaming { - device_name: native_device_name.to_string(), - persist_device_id: false, - }) - }, - |device_id| { - Some(StartupDeviceEvent::Transfer { - device_id, - persist_device_id: false, - }) - }, - ) - } - } else { - Some(StartupDeviceEvent::Transfer { - device_id: saved_device_id, - persist_device_id: true, - }) - } - } - None => Some(StartupDeviceEvent::AutoSelectStreaming { - device_name: native_device_name.to_string(), - persist_device_id: true, - }), - }; - - let status_message = matches!( - event, - Some( - StartupDeviceEvent::Transfer { - persist_device_id: false, - .. - } | StartupDeviceEvent::AutoSelectStreaming { - persist_device_id: false, - .. - } - ) - ) - .then(|| format!("Saved device unavailable; using {}", native_device_name)); - - StartupDeviceDecision { - event, - status_message, - } -} - -#[cfg(test)] -mod tests { - use super::{startup_device_decision, StartupDeviceEvent}; - use crate::core::user_config::StartupBehavior; - use rspotify::model::{device::Device, DeviceType}; - - const NATIVE_NAME: &str = "spotatui"; - const NATIVE_ID: &str = "native-device"; - const EXTERNAL_ID: &str = "phone-device"; - - #[allow(deprecated)] - fn device(id: &str, name: &str) -> Device { - Device { - id: Some(id.to_string()), - is_active: false, - is_private_session: false, - is_restricted: false, - name: name.to_string(), - _type: DeviceType::Computer, - volume_percent: Some(50), - } - } - - fn startup_device_event( - startup_behavior: StartupBehavior, - saved_device_id: Option, - devices_snapshot: Option<&[Device]>, - ) -> Option { - startup_device_decision( - startup_behavior, - saved_device_id, - devices_snapshot, - NATIVE_NAME, - ) - .event - } - - #[test] - fn continue_without_saved_device_does_not_transfer() { - let devices = vec![device(NATIVE_ID, NATIVE_NAME)]; - - assert_eq!( - startup_device_event(StartupBehavior::Continue, None, Some(&devices)), - None - ); - } - - #[test] - fn continue_with_saved_native_device_does_not_transfer() { - let devices = vec![device(NATIVE_ID, NATIVE_NAME)]; - - assert_eq!( - startup_device_event( - StartupBehavior::Continue, - Some(NATIVE_ID.to_string()), - Some(&devices), - ), - None - ); - } - - #[test] - fn continue_with_saved_external_device_does_not_transfer() { - let devices = vec![ - device(EXTERNAL_ID, "Jay's phone"), - device(NATIVE_ID, NATIVE_NAME), - ]; - - assert_eq!( - startup_device_event( - StartupBehavior::Continue, - Some(EXTERNAL_ID.to_string()), - Some(&devices), - ), - None - ); - } - - #[test] - fn play_with_saved_available_device_transfers_to_saved_device() { - let devices = vec![ - device(EXTERNAL_ID, "Jay's phone"), - device(NATIVE_ID, NATIVE_NAME), - ]; - - assert_eq!( - startup_device_event( - StartupBehavior::Play, - Some(EXTERNAL_ID.to_string()), - Some(&devices), - ), - Some(StartupDeviceEvent::Transfer { - device_id: EXTERNAL_ID.to_string(), - persist_device_id: true, - }) - ); - } - - #[test] - fn play_without_saved_device_auto_selects_native_fallback() { - let devices = vec![device(NATIVE_ID, NATIVE_NAME)]; - - assert_eq!( - startup_device_event(StartupBehavior::Play, None, Some(&devices)), - Some(StartupDeviceEvent::AutoSelectStreaming { - device_name: NATIVE_NAME.to_string(), - persist_device_id: true, - }) - ); - } - - #[test] - fn continue_with_unavailable_saved_device_does_not_fall_back_to_native() { - let devices = vec![device(NATIVE_ID, NATIVE_NAME)]; - - assert_eq!( - startup_device_event( - StartupBehavior::Continue, - Some(EXTERNAL_ID.to_string()), - Some(&devices), - ), - None - ); - } - - #[test] - fn play_with_unavailable_saved_device_transfers_to_native_without_persisting() { - let devices = vec![device(NATIVE_ID, NATIVE_NAME)]; - - let decision = startup_device_decision( - StartupBehavior::Play, - Some(EXTERNAL_ID.to_string()), - Some(&devices), - NATIVE_NAME, - ); - - assert_eq!( - decision.event, - Some(StartupDeviceEvent::Transfer { - device_id: NATIVE_ID.to_string(), - persist_device_id: false, - }) - ); - assert_eq!( - decision.status_message, - Some(format!("Saved device unavailable; using {}", NATIVE_NAME)) - ); - } - - #[test] - fn play_with_unavailable_saved_device_auto_selects_native_without_persisting() { - let devices = vec![device("other-device", "Other speaker")]; - - let decision = startup_device_decision( - StartupBehavior::Play, - Some(EXTERNAL_ID.to_string()), - Some(&devices), - NATIVE_NAME, - ); - - assert_eq!( - decision.event, - Some(StartupDeviceEvent::AutoSelectStreaming { - device_name: NATIVE_NAME.to_string(), - persist_device_id: false, - }) - ); - assert_eq!( - decision.status_message, - Some(format!("Saved device unavailable; using {}", NATIVE_NAME)) - ); - } - - #[test] - fn play_with_saved_device_and_no_snapshot_transfers_to_saved_device() { - let decision = startup_device_decision( - StartupBehavior::Play, - Some(EXTERNAL_ID.to_string()), - None, - NATIVE_NAME, - ); - - assert_eq!( - decision.event, - Some(StartupDeviceEvent::Transfer { - device_id: EXTERNAL_ID.to_string(), - persist_device_id: true, - }) - ); - assert_eq!(decision.status_message, None); - } -} diff --git a/src/tui/cover_art.rs b/src/tui/cover_art.rs deleted file mode 100644 index 45abe21c..00000000 --- a/src/tui/cover_art.rs +++ /dev/null @@ -1,1162 +0,0 @@ -//! Terminal cover-art rendering and per-surface dither caches. - -use crate::core::art::{CoverArtStatus, CoverArtStore}; -use crate::core::plugin_api::PluginCoverArtFit; -use crate::core::theme::{resolve, Color, Palette}; -use crate::core::user_config::UserConfig; -use log::{info, warn}; -use ratatui::{ - layout::{Rect, Size}, - Frame, -}; -use ratatui_image::{ - picker::{Capability, Picker, ProtocolType}, - protocol::StatefulProtocol, - Resize, StatefulImage, -}; -use std::sync::{mpsc, Arc, Mutex, MutexGuard, OnceLock, PoisonError}; - -mod dither; -use dither::{Algorithm, Mask}; - -/// The message shown in place of the image when none is loaded, so "no art" -/// always reads as a deliberate outcome rather than a blank pane. Shared by the -/// fullscreen view and the plugin-screen `cover_art` widget. -pub fn status_message(status: CoverArtStatus) -> &'static str { - match status { - CoverArtStatus::Loading => "Loading cover art...", - CoverArtStatus::Unavailable => "No cover art for this source", - CoverArtStatus::Failed => "Cover art unavailable", - CoverArtStatus::Loaded | CoverArtStatus::NotStarted => "No cover art available", - } -} - -impl PluginCoverArtFit { - /// The `ratatui-image` resize strategy for this fit mode. Kept private so - /// `Resize` does not leak into the UI layer. - fn resize(self) -> Resize { - match self { - PluginCoverArtFit::Contain => Resize::Fit(None), - PluginCoverArtFit::Scale => Resize::Scale(None), - } - } -} - -/// One renderer per process: the picker describes the one terminal this -/// process draws to. Until [`init_renderer`] runs, every render below is a -/// no-op and [`full_image_support`] reports false. -static RENDERER: OnceLock = OnceLock::new(); - -/// Probe the terminal for its image protocol and install the process-wide -/// renderer. Called from `start_ui` after `ratatui::init()`: the probe is a -/// stdio round-trip and must not run during `App` construction. -pub fn init_renderer() { - RENDERER.get_or_init(|| { - let picker = Picker::from_query_stdio().unwrap_or_else(|err| { - warn!("cover art renderer fallback to halfblocks: {err}"); - Picker::halfblocks() - }); - - info!( - "cover art renderer detected a {:?} backend", - picker.protocol_type() - ); - CoverArtRenderer::new(picker) - }); -} - -/// Install a halfblocks renderer without probing stdio, so `TestBackend` -/// tests can render actual image cells. -#[cfg(test)] -pub fn init_test_renderer() { - RENDERER.get_or_init(|| CoverArtRenderer::new(Picker::halfblocks())); -} - -/// Whether the detected terminal draws real pixels (Kitty/iTerm2/Sixel) as -/// opposed to the halfblocks character fallback. -pub fn full_image_support() -> bool { - RENDERER - .get() - .is_some_and(CoverArtRenderer::full_image_support) -} - -/// Drop cached protocols that no longer match the store, so cleared art -/// releases its memory even while no cover surface is being drawn. Called once -/// per frame by the runner; rebuilds happen lazily at the next render. -pub fn sync(store: &CoverArtStore) { - let Some(renderer) = RENDERER.get() else { - return; - }; - for surface in [&renderer.playbar, &renderer.fullscreen, &renderer.plugin] { - let mut lock = lock_surface(surface); - let stale = lock - .as_ref() - .is_some_and(|surface| store.key() != Some(surface.key.as_str())); - if stale { - *lock = None; - } - } -} - -/// Render into the playbar's cover slot. -pub fn render(f: &mut Frame, area: Rect, store: &CoverArtStore, config: &UserConfig) { - if let Some(renderer) = RENDERER.get() { - renderer.render_surface( - &renderer.playbar, - f, - area, - store, - config, - RenderStyle { - resize: Resize::Fit(None), - background: config.theme.playbar_background, - fill: false, - }, - ); - } -} - -/// Measure the playbar image's fitted size, so the layout can size its slot. -pub fn size_for(area: Rect, store: &CoverArtStore) -> Option { - let renderer = RENDERER.get()?; - renderer.surface_size_for(&renderer.playbar, area, Resize::Fit(None), store) -} - -/// Render into the fullscreen cover art view. -pub fn render_fullscreen(f: &mut Frame, area: Rect, store: &CoverArtStore, config: &UserConfig) { - if let Some(renderer) = RENDERER.get() { - renderer.render_surface( - &renderer.fullscreen, - f, - area, - store, - config, - RenderStyle { - resize: Resize::Fit(None), - background: config.theme.background, - fill: false, - }, - ); - } -} - -/// Measure the fullscreen image's fitted size, so the caller can center it. -pub fn fullscreen_size_for(area: Rect, store: &CoverArtStore) -> Option { - let renderer = RENDERER.get()?; - renderer.surface_size_for(&renderer.fullscreen, area, Resize::Fit(None), store) -} - -/// Render into a plugin screen's `cover_art` widget slot. -pub fn render_plugin( - f: &mut Frame, - area: Rect, - fit: PluginCoverArtFit, - store: &CoverArtStore, - config: &UserConfig, -) { - if let Some(renderer) = RENDERER.get() { - renderer.render_surface( - &renderer.plugin, - f, - area, - store, - config, - RenderStyle { - resize: fit.resize(), - background: config.theme.background, - fill: fit == PluginCoverArtFit::Scale, - }, - ); - } -} - -/// Measure the plugin slot's fitted size, so the caller can center it. -pub fn plugin_size_for(area: Rect, fit: PluginCoverArtFit, store: &CoverArtStore) -> Option { - let renderer = RENDERER.get()?; - renderer.surface_size_for(&renderer.plugin, area, fit.resize(), store) -} - -struct CoverArtRenderer { - picker: Picker, - terminal_background: Option<[u8; 3]>, - /// Playbar protocol state. - playbar: Mutex>, - /// Separate protocol state for the fullscreen cover art view, avoiding - /// conflicts when the same image is rendered in both the playbar and - /// fullscreen in one frame. - fullscreen: Mutex>, - /// Separate protocol state for a plugin screen's `cover_art` widget, for the - /// same reason: a plugin can size its image differently from the playbar, and - /// a shared protocol would re-encode on every switch between them. - plugin: Mutex>, -} - -/// A surface's cached protocol together with the store key it was built from. -struct Surface { - key: String, - protocol: StatefulProtocol, - dither: Option, -} - -#[derive(Clone, PartialEq, Eq)] -struct MaskKey { - artwork: String, - width: u32, - height: u32, - algorithm: String, - scale: u8, - fill: bool, -} - -type ColorPair = ([u8; 3], [u8; 3]); - -struct DitherSurface { - key: MaskKey, - pending: Option>, - requested_key: Option, - failed: bool, - mask: Option>, - pending_color: Option<(ColorPair, mpsc::Receiver)>, - colors: Option, - protocol: Option, -} - -struct PreparedMask { - mask: Mask, - initial_color: Option<(ColorPair, image::DynamicImage)>, -} - -fn spawn_mask_worker( - source: Arc, - key: MaskKey, - desired: ColorPair, - graphics: bool, -) -> DitherSurface { - let (sender, receiver) = mpsc::channel(); - let (width, height, scale, fill, algorithm) = ( - key.width, - key.height, - key.scale, - key.fill, - key.algorithm.clone(), - ); - std::thread::spawn(move || { - let (fitted_width, fitted_height) = if fill { - (width, height) - } else { - dither::fit_dimensions(&source, width, height) - }; - let mask = dither::make_mask( - &source, - fitted_width, - fitted_height, - scale, - Algorithm::from_name(&algorithm), - ); - let initial_color = graphics.then(|| (desired, dither::colorize(&mask, desired.0, desired.1))); - let _ = sender.send(PreparedMask { - mask, - initial_color, - }); - }); - DitherSurface { - key, - pending: Some(receiver), - requested_key: None, - failed: false, - mask: None, - pending_color: None, - colors: None, - protocol: None, - } -} - -struct RenderStyle { - resize: Resize, - background: Color, - fill: bool, -} - -/// Lock a surface's protocol cache, recovering a poisoned guard: the caches -/// are pure derived state (rebuilt from the store at the next render), so a -/// panic that unwound while a guard was held must not turn every later frame -/// into a poison panic. -fn lock_surface(surface: &Mutex>) -> MutexGuard<'_, Option> { - surface.lock().unwrap_or_else(PoisonError::into_inner) -} - -/// Render the mask with 2×4 Braille dots per terminal cell. -fn render_braille_mask( - f: &mut Frame, - area: Rect, - mask: &Mask, - accent: [u8; 3], - background: [u8; 3], -) { - let accent_is_dark = dither::luminance(accent) < dither::luminance(background); - let rgb = |value: [u8; 3]| ratatui::style::Color::Rgb(value[0], value[1], value[2]); - const DOTS: [[u8; 2]; 4] = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]]; - for y in 0..area.height { - if u32::from(y) * 4 >= mask.height { - break; - } - for x in 0..area.width.min(mask.width.div_ceil(2) as u16) { - let mut accent_dots = 0_u8; - for (dot_y, row) in DOTS.iter().enumerate() { - for (dot_x, bit) in row.iter().enumerate() { - let pixel_x = u32::from(x) * 2 + dot_x as u32; - let pixel_y = u32::from(y) * 4 + dot_y as u32; - if pixel_x < mask.width && pixel_y < mask.height { - let highlight = mask.pixels[(pixel_y * mask.width + pixel_x) as usize] != 0; - if highlight != accent_is_dark { - accent_dots |= *bit; - } - } - } - } - // Fill the cell with its majority color. - let accent_majority = accent_dots.count_ones() > 4; - let (foreground, cell_background, dots) = if accent_majority { - (background, accent, !accent_dots) - } else { - (accent, background, accent_dots) - }; - if let Some(cell) = f.buffer_mut().cell_mut((area.x + x, area.y + y)) { - cell - .set_char(char::from_u32(0x2800 + u32::from(dots)).unwrap_or(' ')) - .set_fg(rgb(foreground)) - .set_bg(rgb(cell_background)); - } - } - } -} - -impl CoverArtRenderer { - fn new(picker: Picker) -> Self { - let terminal_background = picker.capabilities().iter().find_map(|capability| { - if let Capability::Background(r, g, b) = capability { - Some([*r, *g, *b]) - } else { - None - } - }); - Self { - picker, - terminal_background, - playbar: Mutex::new(None), - fullscreen: Mutex::new(None), - plugin: Mutex::new(None), - } - } - - fn full_image_support(&self) -> bool { - match self.picker.protocol_type() { - ProtocolType::Kitty | ProtocolType::Iterm2 | ProtocolType::Sixel => true, - ProtocolType::Halfblocks => false, - } - } - - fn dither_grid(&self, area: Rect, scale: u8) -> (u32, u32, u8) { - let font = self.picker.font_size(); - if self.full_image_support() { - ( - u32::from(area.width) * u32::from(font.width), - u32::from(area.height) * u32::from(font.height), - scale, - ) - } else { - (u32::from(area.width) * 2, u32::from(area.height) * 4, scale) - } - } - - fn dither_colors(&self, config: &UserConfig, background: Color) -> ColorPair { - let fallback_background = self.terminal_background.unwrap_or([0, 0, 0]); - let mut palette = Palette { - reset: fallback_background, - ..Palette::default() - }; - let background = resolve(background, &palette); - palette.reset = [255, 255, 255]; - let accent = resolve( - config - .cover_art_dither_color - .unwrap_or(config.theme.playbar_progress), - &palette, - ); - (accent, background) - } - - /// Reconcile a surface's cached protocol with the store: rebuild it when the - /// store holds art under a different key, drop it when the store is empty. - /// This is the per-key cache that keeps a protocol from being rebuilt for a - /// frame that renders the same art. - fn ensure<'a>( - &self, - surface: &'a Mutex>, - store: &CoverArtStore, - ) -> MutexGuard<'a, Option> { - let mut lock = lock_surface(surface); - match store.key() { - None => *lock = None, - Some(key) => { - let cached = lock.as_ref().is_some_and(|surface| surface.key == key); - if !cached { - if let Some(image) = store.image() { - *lock = Some(Surface { - key: key.to_string(), - protocol: self.picker.new_resize_protocol(image.clone()), - dither: None, - }); - } - } - } - } - lock - } - - fn render_surface( - &self, - surface: &Mutex>, - f: &mut Frame, - area: Rect, - store: &CoverArtStore, - config: &UserConfig, - style: RenderStyle, - ) { - let mut lock = self.ensure(surface, store); - if let Some(surface) = lock.as_mut() { - if config.behavior.cover_art_dither && area.width > 0 && area.height > 0 { - let graphics = self.full_image_support(); - let desired = self.dither_colors(config, style.background); - let scale = crate::core::user_config::normalize_cover_art_dither_pixel_scale(i64::from( - config.behavior.cover_art_dither_pixel_scale, - )); - let (width, height, pixel_scale) = self.dither_grid(area, scale); - let key = MaskKey { - artwork: surface.key.clone(), - width, - height, - algorithm: config.behavior.cover_art_dither_algorithm.clone(), - scale: pixel_scale, - fill: style.fill, - }; - let pending_same_artwork = surface - .dither - .as_ref() - .is_some_and(|d| d.pending.is_some() && d.key.artwork == key.artwork); - if pending_same_artwork { - surface.dither.as_mut().unwrap().requested_key = Some(key.clone()); - } else if surface.dither.as_ref().is_none_or(|d| d.key != key) { - if let Some(source) = store.image_shared() { - surface.dither = Some(spawn_mask_worker(source, key, desired, graphics)); - } - } - if let Some(dither) = surface.dither.as_mut() { - if let Some(receiver) = dither.pending.as_ref() { - match receiver.try_recv() { - Ok(prepared) => { - dither.mask = Some(Arc::new(prepared.mask)); - dither.pending = None; - if let Some((colors, image)) = prepared.initial_color { - if colors == desired { - dither.protocol = Some(self.picker.new_resize_protocol(image)); - dither.colors = Some(colors); - } - } - } - Err(mpsc::TryRecvError::Disconnected) => { - warn!("cover art dithering worker stopped before producing an image"); - dither.pending = None; - dither.failed = true; - } - Err(mpsc::TryRecvError::Empty) => {} - } - } - if dither.pending.is_none() { - if let Some(requested_key) = dither.requested_key.take() { - if requested_key != dither.key { - if let Some(source) = store.image_shared() { - *dither = spawn_mask_worker(source, requested_key, desired, graphics); - } - } - } - } - if dither.failed { - f.render_stateful_widget( - StatefulImage::new().resize(style.resize), - area, - &mut surface.protocol, - ); - return; - } - if let Some(mask) = &dither.mask { - if !graphics { - dither.colors = Some(desired); - render_braille_mask(f, area, mask, desired.0, desired.1); - return; - } - if let Some((colors, receiver)) = dither.pending_color.as_ref() { - if let Ok(image) = receiver.try_recv() { - let completed = *colors; - dither.pending_color = None; - if completed == desired { - dither.protocol = Some(self.picker.new_resize_protocol(image)); - dither.colors = Some(completed); - } - } - } - if dither.colors != Some(desired) && dither.pending_color.is_none() { - let mask = Arc::clone(mask); - let (sender, receiver) = mpsc::channel(); - std::thread::spawn(move || { - let _ = sender.send(dither::colorize(&mask, desired.0, desired.1)); - }); - dither.pending_color = Some((desired, receiver)); - } - } - if let Some(protocol) = dither.protocol.as_mut() { - f.render_stateful_widget(StatefulImage::new().resize(style.resize), area, protocol); - return; - } - } - f.render_widget(ratatui::widgets::Clear, area); - f.render_widget( - ratatui::widgets::Block::default() - .style(ratatui::style::Style::default().bg(style.background.into())), - area, - ); - return; - } else { - surface.dither = None; - } - f.render_stateful_widget( - StatefulImage::new().resize(style.resize), - area, - &mut surface.protocol, - ); - } - } - - fn surface_size_for( - &self, - surface: &Mutex>, - area: Rect, - resize: Resize, - store: &CoverArtStore, - ) -> Option { - let lock = self.ensure(surface, store); - lock.as_ref().map(|surface| { - let size = surface.protocol.size_for( - resize, - Size { - width: area.width, - height: area.height, - }, - ); - Rect::new(0, 0, size.width, size.height) - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use image::{DynamicImage, Rgb, RgbImage}; - use ratatui::{backend::TestBackend, Terminal}; - - #[test] - fn dither_grid_matches_braille_and_graphics_pixels() { - let area = Rect::new(0, 0, 8, 4); - let halfblocks = CoverArtRenderer::new(Picker::halfblocks()); - assert_eq!(halfblocks.dither_grid(area, 1), (16, 16, 1)); - assert_eq!(halfblocks.dither_grid(area, 2), (16, 16, 2)); - let mut picker = Picker::halfblocks(); - picker.set_protocol_type(ProtocolType::Kitty); - let graphics = CoverArtRenderer::new(picker); - assert_eq!(graphics.dither_grid(area, 1), (80, 80, 1)); - assert_eq!(graphics.dither_grid(area, 2), (80, 80, 2)); - assert_eq!(graphics.dither_grid(area, 3), (80, 80, 3)); - } - - #[test] - fn braille_fallback_uses_subcell_detail_and_solid_majority_color() { - let mut pixels = vec![0; 16]; - pixels[0] = 1; - for y in 0..4 { - pixels[y * 4 + 2] = 1; - pixels[y * 4 + 3] = 1; - } - let mask = Mask { - width: 4, - height: 4, - pixels, - }; - let mut terminal = Terminal::new(TestBackend::new(2, 1)).unwrap(); - terminal - .draw(|frame| { - render_braille_mask( - frame, - Rect::new(0, 0, 2, 1), - &mask, - [200, 205, 240], - [20, 20, 30], - ) - }) - .unwrap(); - let left = terminal.backend().buffer().cell((0, 0)).unwrap(); - let right = terminal.backend().buffer().cell((1, 0)).unwrap(); - assert_eq!(left.symbol(), "⠁"); - assert_eq!(left.fg, ratatui::style::Color::Rgb(200, 205, 240)); - assert_eq!(left.bg, ratatui::style::Color::Rgb(20, 20, 30)); - assert_eq!(right.symbol(), "⠀"); - assert_eq!(right.bg, ratatui::style::Color::Rgb(200, 205, 240)); - } - - #[test] - fn graphics_recolor_finishes_off_frame_and_reuses_mask() { - let mut picker = Picker::halfblocks(); - picker.set_protocol_type(ProtocolType::Kitty); - let renderer = CoverArtRenderer::new(picker); - let mut terminal = Terminal::new(TestBackend::new(8, 4)).unwrap(); - let mut store = CoverArtStore::default(); - store.store_decoded( - "cover".into(), - DynamicImage::ImageRgb8(RgbImage::from_fn(128, 128, |x, y| { - Rgb([x as u8, y as u8, 64]) - })), - ); - let mut config = UserConfig::new(); - config.behavior.cover_art_dither = true; - let draw = |terminal: &mut Terminal, config: &UserConfig| { - terminal - .draw(|frame| { - renderer.render_surface( - &renderer.playbar, - frame, - Rect::new(0, 0, 8, 4), - &store, - config, - RenderStyle { - resize: Resize::Fit(None), - background: Color::Rgb(0, 0, 0), - fill: false, - }, - ) - }) - .unwrap(); - }; - for _ in 0..100 { - draw(&mut terminal, &config); - if lock_surface(&renderer.playbar) - .as_ref() - .unwrap() - .dither - .as_ref() - .unwrap() - .protocol - .is_some() - { - break; - } - std::thread::sleep(std::time::Duration::from_millis(1)); - } - let guard = lock_surface(&renderer.playbar); - let dither = guard.as_ref().unwrap().dither.as_ref().unwrap(); - assert!(dither.protocol.is_some()); - assert!(dither.pending_color.is_none()); - assert_eq!( - ( - dither.mask.as_ref().unwrap().width, - dither.mask.as_ref().unwrap().height - ), - (80, 80) - ); - let mask = Arc::as_ptr(dither.mask.as_ref().unwrap()); - drop(guard); - config.theme.playbar_progress = Color::Rgb(9, 8, 7); - draw(&mut terminal, &config); - let guard = lock_surface(&renderer.playbar); - let dither = guard.as_ref().unwrap().dither.as_ref().unwrap(); - assert_eq!(Arc::as_ptr(dither.mask.as_ref().unwrap()), mask); - assert!(dither.protocol.is_some()); - drop(guard); - for _ in 0..100 { - draw(&mut terminal, &config); - if lock_surface(&renderer.playbar) - .as_ref() - .unwrap() - .dither - .as_ref() - .unwrap() - .colors - == Some(([9, 8, 7], [0, 0, 0])) - { - break; - } - std::thread::sleep(std::time::Duration::from_millis(1)); - } - assert!(lock_surface(&renderer.playbar) - .as_ref() - .unwrap() - .dither - .as_ref() - .unwrap() - .protocol - .is_some()); - } - - #[test] - fn pending_dither_clears_the_slot_instead_of_drawing_original_art() { - let mut store = CoverArtStore::default(); - store.store_decoded( - "cover".into(), - DynamicImage::ImageRgb8(RgbImage::from_pixel(64, 64, Rgb([220, 40, 40]))), - ); - let renderer = CoverArtRenderer::new(Picker::halfblocks()); - let mut config = UserConfig::new(); - config.behavior.cover_art_dither = true; - let (sender, receiver) = mpsc::channel(); - { - let mut surface = renderer.ensure(&renderer.playbar, &store); - surface.as_mut().unwrap().dither = Some(DitherSurface { - key: MaskKey { - artwork: "cover".into(), - width: 16, - height: 16, - algorithm: "stucki".into(), - scale: 1, - fill: false, - }, - pending: Some(receiver), - requested_key: None, - failed: false, - mask: None, - pending_color: None, - colors: None, - protocol: None, - }); - } - let mut terminal = Terminal::new(TestBackend::new(8, 4)).unwrap(); - terminal - .draw(|frame| { - renderer.render_surface( - &renderer.playbar, - frame, - Rect::new(0, 0, 8, 4), - &store, - &config, - RenderStyle { - resize: Resize::Fit(None), - background: Color::Rgb(2, 3, 4), - fill: false, - }, - ); - }) - .unwrap(); - assert!(terminal - .backend() - .buffer() - .content() - .iter() - .all(|cell| { cell.symbol() == " " && cell.bg == ratatui::style::Color::Rgb(2, 3, 4) })); - drop(sender); - terminal - .draw(|frame| { - renderer.render_surface( - &renderer.playbar, - frame, - Rect::new(0, 0, 8, 4), - &store, - &config, - RenderStyle { - resize: Resize::Fit(None), - background: Color::Rgb(2, 3, 4), - fill: false, - }, - ); - }) - .unwrap(); - assert!( - lock_surface(&renderer.playbar) - .as_ref() - .unwrap() - .dither - .as_ref() - .unwrap() - .failed - ); - assert!(terminal.backend().buffer().content().iter().any(|cell| { - cell.fg == ratatui::style::Color::Rgb(220, 40, 40) - || cell.bg == ratatui::style::Color::Rgb(220, 40, 40) - })); - } - - #[test] - fn pending_mask_worker_uses_latest_request_for_same_artwork() { - let mut store = CoverArtStore::default(); - store.store_decoded("cover".into(), DynamicImage::new_rgb8(64, 64)); - let renderer = CoverArtRenderer::new(Picker::halfblocks()); - let mut config = UserConfig::new(); - config.behavior.cover_art_dither = true; - let (sender, receiver) = mpsc::channel(); - { - let mut surface = renderer.ensure(&renderer.playbar, &store); - surface.as_mut().unwrap().dither = Some(DitherSurface { - key: MaskKey { - artwork: "cover".into(), - width: 16, - height: 16, - algorithm: "stucki".into(), - scale: 1, - fill: false, - }, - pending: Some(receiver), - requested_key: None, - failed: false, - mask: None, - pending_color: None, - colors: None, - protocol: None, - }); - } - let mut terminal = Terminal::new(TestBackend::new(8, 4)).unwrap(); - let draw = |terminal: &mut Terminal, config: &UserConfig| { - terminal - .draw(|frame| { - renderer.render_surface( - &renderer.playbar, - frame, - Rect::new(0, 0, 8, 4), - &store, - config, - RenderStyle { - resize: Resize::Fit(None), - background: Color::Rgb(0, 0, 0), - fill: false, - }, - ); - }) - .unwrap(); - }; - - config.behavior.cover_art_dither_pixel_scale = 2; - draw(&mut terminal, &config); - { - let guard = lock_surface(&renderer.playbar); - let dither = guard.as_ref().unwrap().dither.as_ref().unwrap(); - assert_eq!(dither.key.scale, 1); - assert_eq!(dither.requested_key.as_ref().unwrap().scale, 2); - assert!(dither.pending.is_some()); - } - - config.behavior.cover_art_dither_pixel_scale = 3; - config.behavior.cover_art_dither_algorithm = "atkinson".into(); - draw(&mut terminal, &config); - { - let guard = lock_surface(&renderer.playbar); - let dither = guard.as_ref().unwrap().dither.as_ref().unwrap(); - assert_eq!(dither.key.scale, 1); - assert_eq!(dither.requested_key.as_ref().unwrap().scale, 3); - assert_eq!(dither.requested_key.as_ref().unwrap().algorithm, "atkinson"); - } - - sender - .send(PreparedMask { - mask: Mask { - width: 16, - height: 16, - pixels: vec![0; 256], - }, - initial_color: None, - }) - .unwrap(); - draw(&mut terminal, &config); - let guard = lock_surface(&renderer.playbar); - let dither = guard.as_ref().unwrap().dither.as_ref().unwrap(); - assert_eq!(dither.key.scale, 3); - assert_eq!(dither.key.algorithm, "atkinson"); - assert!(dither.pending.is_some()); - assert!(dither.mask.is_none()); - } - - #[test] - fn tiny_playbar_never_flashes_original_art_while_dithering() { - let mut store = CoverArtStore::default(); - store.store_decoded( - "cover".into(), - DynamicImage::ImageRgb8(RgbImage::from_fn(64, 64, |x, y| { - if x < 32 && y < 32 { - Rgb([200, 40, 40]) - } else { - Rgb([30, 120, 160]) - } - })), - ); - let renderer = CoverArtRenderer::new(Picker::halfblocks()); - let original_renderer = CoverArtRenderer::new(Picker::halfblocks()); - let mut enabled = UserConfig::new(); - enabled.behavior.cover_art_dither = true; - let disabled = UserConfig::new(); - let mut tiny = Terminal::new(TestBackend::new(8, 4)).unwrap(); - let mut original = Terminal::new(TestBackend::new(8, 4)).unwrap(); - let style = || RenderStyle { - resize: Resize::Fit(None), - background: Color::Rgb(0, 0, 0), - fill: false, - }; - tiny - .draw(|frame| { - renderer.render_surface( - &renderer.playbar, - frame, - Rect::new(0, 0, 8, 4), - &store, - &enabled, - style(), - ) - }) - .unwrap(); - original - .draw(|frame| { - original_renderer.render_surface( - &original_renderer.playbar, - frame, - Rect::new(0, 0, 8, 4), - &store, - &disabled, - style(), - ) - }) - .unwrap(); - assert_ne!(tiny.backend().buffer(), original.backend().buffer()); - assert!(lock_surface(&renderer.playbar) - .as_ref() - .unwrap() - .dither - .is_some()); - for _ in 0..100 { - tiny - .draw(|frame| { - renderer.render_surface( - &renderer.playbar, - frame, - Rect::new(0, 0, 8, 4), - &store, - &enabled, - style(), - ) - }) - .unwrap(); - if lock_surface(&renderer.playbar) - .as_ref() - .unwrap() - .dither - .as_ref() - .unwrap() - .mask - .is_some() - { - break; - } - std::thread::sleep(std::time::Duration::from_millis(1)); - } - let guard = lock_surface(&renderer.playbar); - let mask = guard - .as_ref() - .unwrap() - .dither - .as_ref() - .unwrap() - .mask - .as_ref() - .unwrap(); - assert_eq!((mask.width, mask.height), (16, 16)); - drop(guard); - assert_ne!(tiny.backend().buffer(), original.backend().buffer()); - assert!(lock_surface(&renderer.playbar) - .as_ref() - .unwrap() - .dither - .is_some()); - } - - #[test] - fn all_surfaces_invalidate_masks_and_recolor_without_redithering() { - let renderer = CoverArtRenderer::new(Picker::halfblocks()); - let mut terminal = Terminal::new(TestBackend::new(54, 18)).unwrap(); - let mut store = CoverArtStore::default(); - store.store_decoded( - "first".into(), - DynamicImage::ImageRgb8(RgbImage::from_fn(16, 16, |x, y| { - Rgb([(x * 16) as u8, (y * 16) as u8, 120]) - })), - ); - let mut config = UserConfig::new(); - config.behavior.cover_art_dither = true; - config.theme.playbar_background = Color::Rgb(1, 2, 3); - config.theme.background = Color::Rgb(4, 5, 6); - let draw = |renderer: &CoverArtRenderer, - terminal: &mut Terminal, - store: &CoverArtStore, - config: &UserConfig, - width| { - terminal - .draw(|frame| { - renderer.render_surface( - &renderer.playbar, - frame, - Rect::new(0, 0, width, 8), - store, - config, - RenderStyle { - resize: Resize::Fit(None), - background: config.theme.playbar_background, - fill: false, - }, - ); - renderer.render_surface( - &renderer.fullscreen, - frame, - Rect::new(18, 0, 16, 8), - store, - config, - RenderStyle { - resize: Resize::Fit(None), - background: config.theme.background, - fill: false, - }, - ); - renderer.render_surface( - &renderer.plugin, - frame, - Rect::new(36, 0, 16, 8), - store, - config, - RenderStyle { - resize: Resize::Scale(None), - background: config.theme.background, - fill: true, - }, - ); - }) - .unwrap(); - }; - draw(&renderer, &mut terminal, &store, &config, 16); - for surface in [&renderer.playbar, &renderer.fullscreen, &renderer.plugin] { - assert_eq!( - lock_surface(surface) - .as_ref() - .unwrap() - .dither - .as_ref() - .unwrap() - .key - .artwork, - "first" - ); - } - for _ in 0..100 { - draw(&renderer, &mut terminal, &store, &config, 16); - if [&renderer.playbar, &renderer.fullscreen, &renderer.plugin] - .iter() - .all(|surface| { - lock_surface(surface) - .as_ref() - .unwrap() - .dither - .as_ref() - .unwrap() - .mask - .is_some() - }) - { - break; - } - std::thread::sleep(std::time::Duration::from_millis(1)); - } - let before = Arc::as_ptr( - lock_surface(&renderer.playbar) - .as_ref() - .unwrap() - .dither - .as_ref() - .unwrap() - .mask - .as_ref() - .unwrap(), - ); - config.theme.playbar_progress = Color::Rgb(30, 40, 50); - draw(&renderer, &mut terminal, &store, &config, 16); - let guard = lock_surface(&renderer.playbar); - let dither = guard.as_ref().unwrap().dither.as_ref().unwrap(); - assert_eq!(Arc::as_ptr(dither.mask.as_ref().unwrap()), before); - assert_eq!(dither.colors, Some(([30, 40, 50], [1, 2, 3]))); - let rendered_height = dither.mask.as_ref().unwrap().height.div_ceil(4) as u16; - let rendered_width = dither.mask.as_ref().unwrap().width.div_ceil(2) as u16; - drop(guard); - let accent = ratatui::style::Color::Rgb(30, 40, 50); - let background = ratatui::style::Color::Rgb(1, 2, 3); - let buffer = terminal.backend().buffer(); - assert!((0..rendered_height) - .flat_map(|y| (0..rendered_width).map(move |x| (x, y))) - .any(|position| { - let cell = buffer.cell(position).unwrap(); - cell.fg == accent || cell.bg == accent - })); - for y in 0..rendered_height { - for x in 0..rendered_width { - let cell = buffer.cell((x, y)).unwrap(); - assert!(matches!(cell.fg, color if color == accent || color == background)); - assert!(matches!(cell.bg, color if color == accent || color == background)); - } - } - draw(&renderer, &mut terminal, &store, &config, 17); - { - let guard = lock_surface(&renderer.playbar); - let dither = guard.as_ref().unwrap().dither.as_ref().unwrap(); - assert_eq!( - dither.requested_key.as_ref().unwrap_or(&dither.key).width, - 34 - ); - } - config.behavior.cover_art_dither_algorithm = "atkinson".into(); - draw(&renderer, &mut terminal, &store, &config, 17); - { - let guard = lock_surface(&renderer.playbar); - let dither = guard.as_ref().unwrap().dither.as_ref().unwrap(); - assert_eq!( - dither - .requested_key - .as_ref() - .unwrap_or(&dither.key) - .algorithm, - "atkinson" - ); - } - config.behavior.cover_art_dither_pixel_scale = 4; - draw(&renderer, &mut terminal, &store, &config, 17); - { - let guard = lock_surface(&renderer.playbar); - let dither = guard.as_ref().unwrap().dither.as_ref().unwrap(); - assert_eq!( - dither.requested_key.as_ref().unwrap_or(&dither.key).scale, - 3 - ); - } - store.store_decoded("second".into(), DynamicImage::new_rgb8(16, 16)); - draw(&renderer, &mut terminal, &store, &config, 17); - for surface in [&renderer.playbar, &renderer.fullscreen, &renderer.plugin] { - assert_eq!( - lock_surface(surface) - .as_ref() - .unwrap() - .dither - .as_ref() - .unwrap() - .key - .artwork, - "second" - ); - } - } -} diff --git a/src/tui/cover_art/dither.rs b/src/tui/cover_art/dither.rs deleted file mode 100644 index 3f16f6ed..00000000 --- a/src/tui/cover_art/dither.rs +++ /dev/null @@ -1,258 +0,0 @@ -//! Cover-art dithering at output resolution. - -use image::{imageops::FilterType, DynamicImage, GrayImage, Rgb, RgbImage}; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(super) enum Algorithm { - Bayer8x8, - Stucki, - Atkinson, -} - -impl Algorithm { - pub(super) fn from_name(name: &str) -> Self { - match name { - "bayer8x8" => Self::Bayer8x8, - "atkinson" => Self::Atkinson, - _ => Self::Stucki, - } - } -} - -pub(super) struct Mask { - pub(super) width: u32, - pub(super) height: u32, - /// 0 is a shadow, 1 is a highlight. - pub(super) pixels: Vec, -} - -/// Fit dimensions without resampling the source image. -pub(super) fn fit_dimensions(source: &DynamicImage, width: u32, height: u32) -> (u32, u32) { - let ratio = (f64::from(width) / f64::from(source.width())) - .min(f64::from(height) / f64::from(source.height())); - ( - (f64::from(source.width()) * ratio).round().max(1.0) as u32, - (f64::from(source.height()) * ratio).round().max(1.0) as u32, - ) -} - -const BAYER8: [[u8; 8]; 8] = [ - [0, 48, 12, 60, 3, 51, 15, 63], - [32, 16, 44, 28, 35, 19, 47, 31], - [8, 56, 4, 52, 11, 59, 7, 55], - [40, 24, 36, 20, 43, 27, 39, 23], - [2, 50, 14, 62, 1, 49, 13, 61], - [34, 18, 46, 30, 33, 17, 45, 29], - [10, 58, 6, 54, 9, 57, 5, 53], - [42, 26, 38, 22, 41, 25, 37, 21], -]; - -pub(super) fn make_mask( - source: &DynamicImage, - width: u32, - height: u32, - scale: u8, - algorithm: Algorithm, -) -> Mask { - let scale = u32::from(scale.max(1)); - let width = width.max(1); - let height = height.max(1); - let grid_width = width.div_ceil(scale); - let grid_height = height.div_ceil(scale); - let gray: GrayImage = source - .resize_exact(grid_width, grid_height, FilterType::Lanczos3) - .to_luma8(); - let mut tones: Vec = gray.pixels().map(|pixel| f32::from(pixel[0])).collect(); - // Normalize contrast without letting extreme pixels set the range. - let mut sorted = tones.clone(); - sorted.sort_unstable_by(f32::total_cmp); - let low = sorted[sorted.len() * 5 / 100]; - let high = sorted[sorted.len() * 95 / 100]; - if high - low >= 8.0 { - for tone in &mut tones { - *tone = ((*tone - low) * 224.0 / (high - low) + 16.0).clamp(0.0, 255.0); - } - } - let shortest_side = grid_width.min(grid_height) as f32; - // Keep ordered-dither noise low on small grids. - let bayer_variation = 24.0 + ((shortest_side - 16.0) / 80.0).clamp(0.0, 1.0) * 104.0; - let diffusion_strength = (shortest_side / 64.0).clamp(0.25, 1.0); - let mut grid = vec![0_u8; tones.len()]; - - for y in 0..grid_height as usize { - for x in 0..grid_width as usize { - let index = y * grid_width as usize + x; - let threshold = match algorithm { - Algorithm::Bayer8x8 => { - 128.0 + (f32::from(BAYER8[y % 8][x % 8]) + 0.5 - 32.0) * bayer_variation / 32.0 - } - Algorithm::Stucki | Algorithm::Atkinson => 128.0, - }; - let white = tones[index] >= threshold; - grid[index] = u8::from(white); - let error = tones[index] - if white { 255.0 } else { 0.0 }; - let taps: &[(i32, i32, f32)] = match algorithm { - Algorithm::Bayer8x8 => &[], - Algorithm::Stucki => &[ - (1, 0, 8.0 / 42.0), - (2, 0, 4.0 / 42.0), - (-2, 1, 2.0 / 42.0), - (-1, 1, 4.0 / 42.0), - (0, 1, 8.0 / 42.0), - (1, 1, 4.0 / 42.0), - (2, 1, 2.0 / 42.0), - (-2, 2, 1.0 / 42.0), - (-1, 2, 2.0 / 42.0), - (0, 2, 4.0 / 42.0), - (1, 2, 2.0 / 42.0), - (2, 2, 1.0 / 42.0), - ], - Algorithm::Atkinson => &[ - (1, 0, 1.0 / 8.0), - (2, 0, 1.0 / 8.0), - (-1, 1, 1.0 / 8.0), - (0, 1, 1.0 / 8.0), - (1, 1, 1.0 / 8.0), - (0, 2, 1.0 / 8.0), - ], - }; - for &(dx, dy, weight) in taps { - let nx = x as i32 + dx; - let ny = y as i32 + dy; - if nx >= 0 && ny >= 0 && nx < grid_width as i32 && ny < grid_height as i32 { - let next = ny as usize * grid_width as usize + nx as usize; - tones[next] += error * weight * diffusion_strength; - } - } - } - } - - let mut pixels = vec![0; (width * height) as usize]; - for y in 0..height { - for x in 0..width { - pixels[(y * width + x) as usize] = grid[((y / scale) * grid_width + x / scale) as usize]; - } - } - Mask { - width, - height, - pixels, - } -} - -pub(super) fn luminance(rgb: [u8; 3]) -> f32 { - 0.2126 * f32::from(rgb[0]) + 0.7152 * f32::from(rgb[1]) + 0.0722 * f32::from(rgb[2]) -} - -pub(super) fn colorize(mask: &Mask, accent: [u8; 3], background: [u8; 3]) -> DynamicImage { - let accent_is_dark = luminance(accent) < luminance(background); - let image = RgbImage::from_fn(mask.width, mask.height, |x, y| { - let highlight = mask.pixels[(y * mask.width + x) as usize] != 0; - Rgb(if highlight != accent_is_dark { - accent - } else { - background - }) - }); - DynamicImage::ImageRgb8(image) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn fitted_dimensions_match_image_resize_without_resampling_source() { - for (source_width, source_height, target_width, target_height) in [ - (600, 600, 80, 80), - (600, 300, 80, 80), - (300, 600, 80, 80), - (31, 47, 80, 80), - ] { - let source = DynamicImage::new_rgb8(source_width, source_height); - let expected = source.resize(target_width, target_height, FilterType::Lanczos3); - assert_eq!( - fit_dimensions(&source, target_width, target_height), - (expected.width(), expected.height()) - ); - } - } - - #[test] - fn algorithms_are_deterministic_and_two_tone() { - assert_eq!(Algorithm::from_name("stucki"), Algorithm::Stucki); - assert_eq!(Algorithm::from_name("bayer8x8"), Algorithm::Bayer8x8); - assert_eq!(Algorithm::from_name("unknown"), Algorithm::Stucki); - let source = DynamicImage::ImageRgb8(RgbImage::from_fn(17, 13, |x, y| { - Rgb([(x * 15) as u8, (y * 19) as u8, 127]) - })); - for algorithm in [Algorithm::Bayer8x8, Algorithm::Stucki, Algorithm::Atkinson] { - let first = make_mask(&source, 17, 13, 1, algorithm); - let second = make_mask(&source, 17, 13, 1, algorithm); - assert_eq!(first.pixels, second.pixels); - let image = colorize(&first, [10, 20, 30], [240, 240, 240]).to_rgb8(); - assert!(image - .pixels() - .all(|p| p.0 == [10, 20, 30] || p.0 == [240, 240, 240])); - } - } - - #[test] - fn tiny_low_contrast_art_keeps_its_large_shapes() { - let source = DynamicImage::ImageRgb8(RgbImage::from_fn(8, 16, |x, _| { - let tone = if x < 4 { 96 } else { 160 }; - Rgb([tone, tone, tone]) - })); - for algorithm in [Algorithm::Bayer8x8, Algorithm::Stucki, Algorithm::Atkinson] { - let mask = make_mask(&source, 8, 16, 1, algorithm); - let mut shadow_highlights = 0; - let mut subject_highlights = 0; - for y in 0..16 { - for x in 0..8 { - if mask.pixels[(y * 8 + x) as usize] != 0 { - if x < 4 { - shadow_highlights += 1; - } else { - subject_highlights += 1; - } - } - } - } - assert!( - shadow_highlights <= 12, - "{algorithm:?} lost the shadow: {shadow_highlights}" - ); - assert!( - subject_highlights >= 52, - "{algorithm:?} lost the subject: {subject_highlights}" - ); - } - } - - #[test] - fn scale_creates_larger_constant_blocks() { - let source = DynamicImage::ImageRgb8(RgbImage::from_pixel(8, 8, Rgb([128, 128, 128]))); - let mask = make_mask(&source, 12, 12, 3, Algorithm::Bayer8x8); - for y in 0..12 { - for x in 0..12 { - assert_eq!( - mask.pixels[(y * 12 + x) as usize], - mask.pixels[((y / 3 * 3) * 12 + x / 3 * 3) as usize] - ); - } - } - } - - #[test] - fn light_background_assigns_dark_accent_to_shadows() { - let mask = Mask { - width: 2, - height: 1, - pixels: vec![0, 1], - }; - let light = colorize(&mask, [20, 20, 20], [245, 245, 245]).to_rgb8(); - assert_eq!(light.get_pixel(0, 0).0, [20, 20, 20]); - let dark = colorize(&mask, [240, 240, 240], [10, 10, 10]).to_rgb8(); - assert_eq!(dark.get_pixel(1, 0).0, [240, 240, 240]); - } -} diff --git a/src/tui/event/events.rs b/src/tui/event/events.rs deleted file mode 100644 index 9c3f3b1d..00000000 --- a/src/tui/event/events.rs +++ /dev/null @@ -1,138 +0,0 @@ -use super::key::Key; -use crate::core::user_config::{normalize_tick_rate_milliseconds, DEFAULT_TICK_RATE_MILLISECONDS}; -use crossterm::event::{self, Event as CrosstermEvent, KeyEventKind, MouseEvent, MouseEventKind}; -use std::{ - sync::{ - atomic::{AtomicU64, Ordering}, - mpsc, Arc, - }, - thread, - time::{Duration, Instant}, -}; - -#[derive(Debug, Clone, Copy)] -/// Configuration for event handling. -pub struct EventConfig { - /// The key that is used to exit the application. - #[allow(dead_code)] - pub exit_key: Key, - /// The tick rate at which the application will sent an tick event. - pub tick_rate: Duration, -} - -impl Default for EventConfig { - fn default() -> EventConfig { - EventConfig { - exit_key: Key::Ctrl('c'), - tick_rate: Duration::from_millis(DEFAULT_TICK_RATE_MILLISECONDS), - } - } -} - -/// An occurred event. -pub enum Event { - /// An input event occurred. - Input(Key), - /// A mouse event occurred. - Mouse(MouseEvent), - /// A tick event occurred. - Tick(Duration), -} - -/// A small event handler that wrap crossterm input and tick event. Each event -/// type is handled in its own thread and returned to a common `Receiver` -pub struct Events { - rx: mpsc::Receiver, - tick_rate_milliseconds: Arc, - // Need to be kept around to prevent disposing the sender side. - _tx: mpsc::Sender, -} - -impl Events { - /// Constructs an new instance of `Events` with the default config. - pub fn new(tick_rate: u64) -> Events { - Events::with_config(EventConfig { - tick_rate: Duration::from_millis(tick_rate), - ..Default::default() - }) - } - - /// Constructs an new instance of `Events` from given config. - pub fn with_config(config: EventConfig) -> Events { - let (tx, rx) = mpsc::channel(); - let tick_rate_milliseconds = Arc::new(AtomicU64::new(normalize_tick_rate_milliseconds( - config.tick_rate.as_millis().try_into().unwrap_or(i64::MAX), - ))); - - let event_tx = tx.clone(); - let event_tick_rate_milliseconds = tick_rate_milliseconds.clone(); - thread::spawn(move || { - let mut last_tick = Instant::now(); - loop { - let tick_rate = Duration::from_millis(event_tick_rate_milliseconds.load(Ordering::Relaxed)); - - // Poll only until the next tick is due, so input events don't push the - // tick schedule back. - let poll_timeout = tick_rate.saturating_sub(last_tick.elapsed()); - if event::poll(poll_timeout).unwrap() { - match event::read().unwrap() { - // Only process key press events, not release or repeat. - // This fixes duplicate key events on Windows where both - // Press and Release events are sent for each key press. - CrosstermEvent::Key(key) if key.kind == KeyEventKind::Press => { - let key = Key::from(key); - // If send fails, the receiver has been dropped (app is closing) - if event_tx.send(Event::Input(key)).is_err() { - break; - } - } - CrosstermEvent::Mouse(mouse) - if matches!( - mouse.kind, - MouseEventKind::Down(_) | MouseEventKind::ScrollUp | MouseEventKind::ScrollDown - ) && event_tx.send(Event::Mouse(mouse)).is_err() => - { - break; - } - _ => {} - } - } - - // Only send a tick when one is actually due. Ticks used to be sent - // unconditionally here, so every keypress enqueued an extra Tick right - // after its Input, and the main loop drew two full frames per - // keystroke. Input events keep their own immediate redraw; the tick - // cadence stays fixed at tick_rate regardless of input. - let elapsed = last_tick.elapsed(); - if elapsed >= tick_rate { - last_tick = Instant::now(); - // If send fails, the receiver has been dropped (app is closing) - if event_tx.send(Event::Tick(elapsed)).is_err() { - break; - } - } - } - }); - - Events { - rx, - tick_rate_milliseconds, - _tx: tx, - } - } - - pub fn set_tick_rate(&self, tick_rate: u64) { - let tick_rate = normalize_tick_rate_milliseconds(tick_rate as i64); - if self.tick_rate_milliseconds.load(Ordering::Relaxed) != tick_rate { - self - .tick_rate_milliseconds - .store(tick_rate, Ordering::Relaxed); - } - } - - /// Attempts to read an event. - /// This function will block the current thread. - pub fn next(&self) -> Result { - self.rx.recv() - } -} diff --git a/src/tui/event/key.rs b/src/tui/event/key.rs deleted file mode 100644 index bbcd3558..00000000 --- a/src/tui/event/key.rs +++ /dev/null @@ -1,101 +0,0 @@ -//! Crossterm → [`Key`] conversion. The `Key` enum itself is frontend-neutral -//! and lives in `core/input.rs`; only this terminal-event mapping belongs to -//! the TUI. - -use crossterm::event; - -pub use crate::core::input::Key; - -impl From for Key { - fn from(key_event: event::KeyEvent) -> Self { - match key_event { - event::KeyEvent { - code: event::KeyCode::Esc, - .. - } => Key::Esc, - event::KeyEvent { - code: event::KeyCode::Backspace, - .. - } => Key::Backspace, - event::KeyEvent { - code: event::KeyCode::Left, - .. - } => Key::Left, - event::KeyEvent { - code: event::KeyCode::Right, - .. - } => Key::Right, - event::KeyEvent { - code: event::KeyCode::Up, - .. - } => Key::Up, - event::KeyEvent { - code: event::KeyCode::Down, - .. - } => Key::Down, - event::KeyEvent { - code: event::KeyCode::Home, - .. - } => Key::Home, - event::KeyEvent { - code: event::KeyCode::End, - .. - } => Key::End, - event::KeyEvent { - code: event::KeyCode::PageUp, - .. - } => Key::PageUp, - event::KeyEvent { - code: event::KeyCode::PageDown, - .. - } => Key::PageDown, - event::KeyEvent { - code: event::KeyCode::Delete, - .. - } => Key::Delete, - event::KeyEvent { - code: event::KeyCode::Insert, - .. - } => Key::Ins, - event::KeyEvent { - code: event::KeyCode::F(n), - .. - } => Key::from_f(n), - event::KeyEvent { - code: event::KeyCode::Enter, - .. - } => Key::Enter, - event::KeyEvent { - code: event::KeyCode::Tab, - .. - } => Key::Tab, - - // First check for char + modifier - event::KeyEvent { - code: event::KeyCode::Char(c), - modifiers: event::KeyModifiers::ALT, - .. - } => Key::Alt(c), - event::KeyEvent { - code: event::KeyCode::Char(c), - modifiers: event::KeyModifiers::CONTROL, - .. - } => Key::Ctrl(c), - - // Terminals using the kitty keyboard protocol send Shift+letter as lowercase - // char with SHIFT modifier; normalise to uppercase so Key::Char('P') works. - event::KeyEvent { - code: event::KeyCode::Char(c), - modifiers: event::KeyModifiers::SHIFT, - .. - } if c.is_ascii_lowercase() => Key::Char(c.to_ascii_uppercase()), - - event::KeyEvent { - code: event::KeyCode::Char(c), - .. - } => Key::Char(c), - - _ => Key::Unknown, - } - } -} diff --git a/src/tui/event/mod.rs b/src/tui/event/mod.rs deleted file mode 100644 index f00ae063..00000000 --- a/src/tui/event/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -mod events; -mod key; - -pub use self::{ - events::{Event, Events}, - key::Key, -}; diff --git a/src/tui/first_run.rs b/src/tui/first_run.rs deleted file mode 100644 index 6ed7d055..00000000 --- a/src/tui/first_run.rs +++ /dev/null @@ -1,178 +0,0 @@ -//! Console presentation of the first-run source picker. -//! -//! The selection *logic* (which sources are compiled in, what happens with the -//! choice) lives in `core/first_run.rs`; this module is only the terminal UI it -//! is presented with: a crossterm checkbox picker on a real terminal, a -//! numbered single-select prompt when stdin is piped. - -use crate::core::source::Source; -use anyhow::{anyhow, Result}; -use crossterm::{ - cursor, - event::{read, Event, KeyCode, KeyEventKind, KeyModifiers}, - execute, - style::Stylize, - terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType}, -}; -use std::io::{stdin, stdout, IsTerminal, Write}; - -/// Console source picker. Interactive terminals get the checkbox picker; piped -/// / non-interactive stdin falls back to the numbered single-select prompt so -/// headless and scripted runs never hang on a raw-mode read. `None` means the -/// user cancelled or confirmed with nothing selected. -pub fn pick_sources(options: &[Source]) -> Result>> { - if stdout().is_terminal() && stdin().is_terminal() { - interactive_multiselect(options) - } else { - numbered_fallback(options).map(Some) - } -} - -/// Interactive checkbox picker: arrow keys / j,k to move, space to toggle, enter -/// to confirm, esc to skip. Returns the checked sources in display order, or -/// `None` when the user cancels or confirms with nothing selected. -/// -/// Restores the terminal via a [`RawModeGuard`] on every exit path (early return, -/// `?`, or panic) so a mid-selection error never leaves the terminal in raw mode. -fn interactive_multiselect(options: &[Source]) -> Result>> { - println!("\nWelcome to spotatui! Choose your music sources:"); - println!("You can add or switch sources anytime from the `d` menu.\n"); - - enable_raw_mode()?; - let _guard = RawModeGuard; - - let mut checked = vec![false; options.len()]; - let mut hover = 0usize; - // Option lines + a blank spacer + the instructions line. - let line_count = (options.len() + 2) as u16; - let mut out = stdout(); - let mut first_draw = true; - - loop { - if !first_draw { - execute!( - out, - cursor::MoveToPreviousLine(line_count), - Clear(ClearType::FromCursorDown) - )?; - } - first_draw = false; - - for (index, source) in options.iter().enumerate() { - let pointer = if index == hover { ">" } else { " " }; - let checkbox = if checked[index] { "[x]" } else { "[ ]" }; - let line = format!( - " {pointer} {checkbox} {}{}", - source.label(), - source_note(*source) - ); - if index == hover { - print!("{}\r\n", line.cyan().bold()); - } else { - print!("{line}\r\n"); - } - } - print!("\r\n"); - print!( - " {}\r\n", - "↑/↓ move · space select · enter confirm · esc skip".dark_grey() - ); - out.flush()?; - - let event = read()?; - let key = match event { - // Ignore key-release / repeat events (Windows emits them) and non-key events. - Event::Key(key) if key.kind == KeyEventKind::Press => key, - _ => continue, - }; - - match key.code { - KeyCode::Up | KeyCode::Char('k') => { - hover = if hover == 0 { - options.len() - 1 - } else { - hover - 1 - }; - } - KeyCode::Down | KeyCode::Char('j') => { - hover = (hover + 1) % options.len(); - } - KeyCode::Char(' ') => checked[hover] = !checked[hover], - KeyCode::Enter => { - let selected: Vec = options - .iter() - .zip(&checked) - .filter_map(|(source, &on)| on.then_some(*source)) - .collect(); - return Ok(if selected.is_empty() { - None - } else { - Some(selected) - }); - } - KeyCode::Esc | KeyCode::Char('q') => return Ok(None), - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => return Ok(None), - _ => {} - } - } -} - -/// Restores cooked terminal mode when dropped, so any exit path out of the -/// interactive picker (return, `?`, panic) leaves the terminal usable. -struct RawModeGuard; - -impl Drop for RawModeGuard { - fn drop(&mut self) { - let _ = disable_raw_mode(); - } -} - -/// Non-interactive fallback (piped stdin): the original numbered single-select -/// prompt. Returns a single-element `Vec` for a uniform downstream path. -fn numbered_fallback(options: &[Source]) -> Result> { - println!("\nWelcome to spotatui! Choose your music source:\n"); - for (index, source) in options.iter().enumerate() { - println!( - " {}) {}{}", - index + 1, - source.label(), - source_note(*source) - ); - } - println!("\nYou can add or switch sources anytime from the `d` menu."); - - let choice = prompt_choice(options.len())?; - Ok(vec![options[choice - 1]]) -} - -fn source_note(source: Source) -> &'static str { - match source { - Source::Spotify => " (needs login)", - Source::YouTube => " (free, needs the yt-dlp binary)", - Source::Subsonic => " (free, needs a Subsonic/Navidrome server)", - Source::Radio => " (free)", - Source::Local => " (free)", - Source::Qobuz => " (paid subscription, logs in through the browser)", - } -} - -fn prompt_choice(max: usize) -> Result { - const MAX_RETRIES: u8 = 5; - let mut retries = 0; - loop { - print!("\nChoose (1-{max}): "); - let _ = stdout().flush(); - let mut input = String::new(); - stdin().read_line(&mut input)?; - match input.trim().parse::() { - Ok(n) if (1..=max).contains(&n) => return Ok(n), - _ => { - println!("Invalid choice. Please enter a number between 1 and {max}."); - retries += 1; - if retries >= MAX_RETRIES { - return Err(anyhow!("Maximum retries ({MAX_RETRIES}) exceeded.")); - } - } - } - } -} diff --git a/src/tui/fuzzy.rs b/src/tui/fuzzy.rs deleted file mode 100644 index 9f57212c..00000000 --- a/src/tui/fuzzy.rs +++ /dev/null @@ -1,184 +0,0 @@ -//! Subsequence fuzzy matching for the Settings row filter. -//! -//! Query characters have to appear in the haystack in order but need not be -//! adjacent, so `skms` still finds `Seek Milliseconds`. Whitespace in the query -//! is ignored entirely, which lets one query land on both the human name and -//! the config id of the same setting: `vol inc` matches `Volume Increment` and -//! `behavior.volume_increment` alike. -//! -//! Case handling follows the same smart-case rule as the Help filter: an -//! all-lowercase query matches case-insensitively, any uppercase character in -//! the query makes the whole match case-sensitive. - -/// Every matched character is worth this much before bonuses. -const SCORE_MATCH: i32 = 8; -/// A character that starts a word: string start, after a separator, or the -/// upper half of a camelCase hump. -const BONUS_BOUNDARY: i32 = 12; -/// A character matched directly after the previous one. Deliberately larger -/// than [`BONUS_BOUNDARY`]: a run that starts on a word boundary collects both, -/// which is what keeps `rep` on `Repeat` instead of `Recently Played Sort`. -const BONUS_CONSECUTIVE: i32 = 20; -/// Charged per haystack character skipped before the first match (capped), so -/// `text` ranks `Text Emphasis` above `Playbar Progress Text`. -const MAX_LEADING_PENALTY: i32 = 12; - -/// One successful match: how well the query fits, and where it landed. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct FuzzyMatch { - /// Higher is a better fit. Only comparable between matches of the same query. - pub score: i32, - /// Ascending, non-overlapping byte ranges into the haystack, adjacent - /// characters merged into one range. - pub ranges: Vec<(usize, usize)>, -} - -/// Best-scoring subsequence match of `query` in `haystack`, or `None` when the -/// characters do not appear in order. An all-whitespace query matches -/// everything with a zero score and no ranges. -pub fn fuzzy_match(haystack: &str, query: &str) -> Option { - let case_sensitive = query.chars().any(char::is_uppercase); - let needle: Vec = query - .chars() - .filter(|c| !c.is_whitespace()) - .map(|c| fold_case(c, case_sensitive)) - .collect(); - if needle.is_empty() { - return Some(FuzzyMatch { - score: 0, - ranges: Vec::new(), - }); - } - - let hay: Vec<(usize, char)> = haystack.char_indices().collect(); - let folded: Vec = hay - .iter() - .map(|(_, c)| fold_case(*c, case_sensitive)) - .collect(); - - // Try every place the first query character can land and match the rest - // greedily from there, keeping the best placement. A single left-to-right - // scan would take the scattered `a`, `b`, `c` of "a b c abc" instead of the - // tight run at the end, which shows up directly as badly placed highlights. - let mut best: Option = None; - for start in 0..hay.len() { - if folded[start] != needle[0] { - continue; - } - let mut positions = vec![start]; - let mut cursor = start + 1; - for needle_char in &needle[1..] { - let Some(offset) = folded[cursor..].iter().position(|c| c == needle_char) else { - break; - }; - positions.push(cursor + offset); - cursor += offset + 1; - } - if positions.len() < needle.len() { - // Every later start has fewer characters left to match with. - break; - } - - let mut score = -(start as i32).min(MAX_LEADING_PENALTY); - for (i, &j) in positions.iter().enumerate() { - score += SCORE_MATCH; - if is_boundary(&hay, j) { - score += BONUS_BOUNDARY; - } - if i > 0 && positions[i - 1] + 1 == j { - score += BONUS_CONSECUTIVE; - } - } - // Earliest placement wins ties, so equal matches highlight deterministically. - if best.as_ref().is_none_or(|b| score > b.score) { - let mut ranges: Vec<(usize, usize)> = Vec::new(); - for &j in &positions { - let (byte, character) = hay[j]; - let end = byte + character.len_utf8(); - match ranges.last_mut() { - Some(last) if last.1 == byte => last.1 = end, - _ => ranges.push((byte, end)), - } - } - best = Some(FuzzyMatch { score, ranges }); - } - } - best -} - -fn fold_case(character: char, case_sensitive: bool) -> char { - if case_sensitive { - character - } else { - character.to_lowercase().next().unwrap_or(character) - } -} - -fn is_boundary(hay: &[(usize, char)], index: usize) -> bool { - if index == 0 { - return true; - } - let previous = hay[index - 1].1; - let current = hay[index].1; - !previous.is_alphanumeric() || (previous.is_lowercase() && current.is_uppercase()) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn score(haystack: &str, query: &str) -> i32 { - fuzzy_match(haystack, query) - .unwrap_or_else(|| panic!("expected {query:?} to match {haystack:?}")) - .score - } - - fn matched(haystack: &str, query: &str) -> String { - fuzzy_match(haystack, query) - .unwrap_or_else(|| panic!("expected {query:?} to match {haystack:?}")) - .ranges - .iter() - .map(|(start, end)| &haystack[*start..*end]) - .collect() - } - - #[test] - fn characters_may_be_skipped_but_not_reordered_and_whitespace_is_ignored() { - assert!(fuzzy_match("Seek Milliseconds", "skms").is_some()); - assert!(fuzzy_match("Seek Milliseconds", "smks").is_none()); - assert!(fuzzy_match("ms", "milliseconds").is_none()); - assert!(fuzzy_match("Volume Increment", "vol inc").is_some()); - assert!(fuzzy_match("behavior.volume_increment", "vol inc").is_some()); - let blank = fuzzy_match("Seek Duration (ms)", " ").expect("blank query matches"); - assert_eq!(blank.score, 0); - assert!(blank.ranges.is_empty()); - } - - #[test] - fn smart_case_only_applies_when_the_query_has_uppercase() { - assert!(fuzzy_match("Volume Increment", "volume").is_some()); - assert!(fuzzy_match("Volume Increment", "Volume").is_some()); - assert!(fuzzy_match("volume increment", "Volume").is_none()); - } - - #[test] - fn tight_early_word_start_matches_outrank_scattered_late_buried_ones() { - assert!(score("Repeat", "rep") > score("Recently Played Sort", "rep")); - assert!(score("Seek Forwards", "sf") > score("Class of", "sf")); - assert!(score("Text Emphasis", "text") > score("Playbar Progress Text", "text")); - } - - #[test] - fn ranges_are_merged_byte_offsets_of_the_best_placement() { - assert_eq!( - fuzzy_match("Volume Increment", "volinc") - .expect("match") - .ranges, - vec![(0, 3), (7, 10)] - ); - // A greedy scan would take the leading `a`, `b`, `c` and highlight three - // scattered characters; the tight run at the end is the better match. - assert_eq!(matched("a b c abc", "abc"), "abc"); - assert_eq!(matched("Émphasis ícon", "éí"), "Éí"); - } -} diff --git a/src/tui/handlers/ai_dj.rs b/src/tui/handlers/ai_dj.rs deleted file mode 100644 index 6e253be4..00000000 --- a/src/tui/handlers/ai_dj.rs +++ /dev/null @@ -1,1185 +0,0 @@ -//! Key handling for the AI DJ screen. -//! -//! Two things here are easy to get wrong and both are guarded by tests: -//! -//! * The prompt is a typing surface, so `handle_app`'s global bindings must be -//! bypassed while it has focus — otherwise `d` opens the device picker instead -//! of typing a `d`. See the early return in `handlers::mod`. -//! * Cursor position is tracked in **display columns**, not character count, so a -//! CJK or emoji prompt keeps its caret in the right place. - -use super::common_key_events; -use crate::core::action::{Action, LibraryTarget}; -use crate::core::app::App; -use crate::infra::dj::setup::{DjSetup, DjSetupStep}; -use crate::tui::event::Key; -use unicode_width::UnicodeWidthChar; - -pub fn handler(key: Key, app: &mut App) { - // The picker is modal and this has to be the very first branch. Below it, - // `Key::Char(c) => insert(app, c)` is total over every character, so a picker arm - // placed after it would be unreachable and letters would type into the prompt - // behind the overlay. Above it, the modifier block would let Ctrl+T or Ctrl+O - // start minutes of background work with the very backend the user is part way - // through changing. - if app.dj.setup.is_some() { - setup_key(key, app); - return; - } - - // The DJ's own action keys, which `handle_app` never sees here: this screen takes - // every key so the prompt can contain 'd', ' ', and the rest. Without this the - // toggles are only reachable from *other* screens, which is where you least want - // them. - // - // Only honoured for bindings that carry a modifier. A user who rebinds one to a - // bare character has to be able to type that character. - if !matches!(key, Key::Char(_)) { - let keys = &app.user_config.keys; - if key == keys.dj_toggle_auto_queue { - app.apply(Action::ToggleDjAutoQueue); - return; - } - if key == keys.dj_vibe_shift { - app.apply(Action::DjVibeShift); - return; - } - if key == keys.dj_toggle_fresh_only { - app.apply(Action::ToggleDjFreshOnly); - return; - } - if key == keys.dj_pick_model { - app.apply(Action::OpenDjSetup); - return; - } - } - - match key { - // Escape clears a half-typed prompt first, and only leaves the screen when - // there is nothing to abandon. The single copy of the rule: `handle_escape` - // delegates its `ActiveBlock::AiDj` arm back here rather than repeating it. - Key::Esc => { - if app.dj.input.is_empty() { - app.pop_navigation_stack(); - } else { - app.dj.input.clear(); - app.dj.input_idx = 0; - app.dj.input_cursor = 0; - } - } - // Non-character keys only, for the same reason the modifier block above says: - // `keys.move_left` defaults to a bare `h`, and this arm sits above - // `Key::Char(c)`, so without the guard a prompt could never begin with one. - k if !matches!(k, Key::Char(_)) - && common_key_events::left_event(k, &app.user_config.keys) - && app.dj.input.is_empty() => - { - common_key_events::handle_left_event(app) - } - Key::Enter => submit(app), - Key::Char(c) => insert(app, c), - Key::Backspace | Key::Ctrl('h') => backspace(app), - Key::Delete | Key::Ctrl('d') => delete(app), - Key::Left | Key::Ctrl('b') => move_left(app), - Key::Right | Key::Ctrl('f') => move_right(app), - Key::Home | Key::Ctrl('a') => { - app.dj.input_idx = 0; - app.dj.input_cursor = 0; - } - Key::End | Key::Ctrl('e') => { - app.dj.input_idx = app.dj.input.len(); - app.dj.input_cursor = display_width(&app.dj.input); - } - Key::Ctrl('u') => { - app.dj.input.clear(); - app.dj.input_idx = 0; - app.dj.input_cursor = 0; - } - // Scrolling is measured in wrapped display rows, matching what the renderer - // windows — see `ui::ai_dj`. - // - // Arrow and page keys only: `k`/`j` deliberately do NOT scroll here, because - // `Key::Char` is matched above and a text prompt must be able to type them. - Key::Up => scroll_back(app, 1), - Key::Down => scroll_forward(app, 1), - Key::PageUp => scroll_back(app, 10), - Key::PageDown => scroll_forward(app, 10), - _ => {} - } -} - -fn display_width(chars: &[char]) -> u16 { - chars - .iter() - .map(|c| c.width().unwrap_or(0) as u16) - .sum::() -} - -fn insert(app: &mut App, c: char) { - let idx = app.dj.input_idx.min(app.dj.input.len()); - app.dj.input.insert(idx, c); - app.dj.input_idx = idx + 1; - app.dj.input_cursor = app - .dj - .input_cursor - .saturating_add(c.width().unwrap_or(0) as u16); -} - -fn backspace(app: &mut App) { - if app.dj.input_idx == 0 { - return; - } - let removed = app.dj.input.remove(app.dj.input_idx - 1); - app.dj.input_idx -= 1; - app.dj.input_cursor = app - .dj - .input_cursor - .saturating_sub(removed.width().unwrap_or(0) as u16); -} - -fn delete(app: &mut App) { - if app.dj.input_idx < app.dj.input.len() { - app.dj.input.remove(app.dj.input_idx); - } -} - -fn move_left(app: &mut App) { - if app.dj.input_idx == 0 { - return; - } - app.dj.input_idx -= 1; - let width = app.dj.input[app.dj.input_idx].width().unwrap_or(0) as u16; - app.dj.input_cursor = app.dj.input_cursor.saturating_sub(width); -} - -fn move_right(app: &mut App) { - if app.dj.input_idx >= app.dj.input.len() { - return; - } - let width = app.dj.input[app.dj.input_idx].width().unwrap_or(0) as u16; - app.dj.input_idx += 1; - app.dj.input_cursor = app.dj.input_cursor.saturating_add(width); -} - -fn scroll_back(app: &mut App, amount: u16) { - let bound = crate::tui::ui::ai_dj::max_scroll_bound(app); - app.dj.scroll = app.dj.scroll.saturating_add(amount).min(bound); -} - -fn scroll_forward(app: &mut App, amount: u16) { - app.dj.scroll = app.dj.scroll.saturating_sub(amount); -} - -/// Send what the user typed to the DJ. -fn submit(app: &mut App) { - let text: String = app.dj.input.iter().collect(); - app.apply(Action::AskDj(text)); -} - -/// Open the DJ screen, warming the library index if the filter is already on. -/// The whole consequence lives on `App` (`open_ai_dj_screen`) and is reached -/// through the shared `Action::OpenLibrary(LibraryTarget::AiDj)` vocabulary, -/// so every entry point (key, library row) fires the same sequence. -pub fn open(app: &mut App) { - app.apply(Action::OpenLibrary(LibraryTarget::AiDj)); -} - -/// What a keypress means to the picker. -/// -/// Resolved while `app.dj.setup` is borrowed, then acted on after that borrow drops. -/// Without this split, Enter (which has to touch `app.user_config` and `app.dj` -/// together) cannot be expressed at all. -enum SetupIntent { - MoveUp, - MoveDown, - /// 1-based row, from the digit shortcuts. - Select(usize), - /// Enter on a list row. - Advance, - /// Esc from the Model or Custom step: one step back. - Back, - /// Esc from the Backend step: dismiss, which is itself an answer. - Dismiss, - /// Enter on the free-text step. - CommitCustom, - Push(char), - Backspace, - ClearCustom, - Ignore, -} - -fn setup_key(key: Key, app: &mut App) { - // Resolve the navigation intent against the user's bindings *before* borrowing - // `app.dj` mutably, so `j`/`k` keep working when they have been rebound. - let goes_down = common_key_events::down_event(key, &app.user_config.keys); - let goes_up = common_key_events::up_event(key, &app.user_config.keys); - - let intent = { - let Some(setup) = app.dj.setup.as_ref() else { - return; - }; - match setup.step { - // A typing surface: no j/k navigation here, or a model name could not contain - // a 'j'. The same rule the DJ prompt itself follows. - DjSetupStep::Custom => match key { - Key::Esc => SetupIntent::Back, - Key::Enter => SetupIntent::CommitCustom, - Key::Backspace | Key::Ctrl('h') => SetupIntent::Backspace, - Key::Ctrl('u') => SetupIntent::ClearCustom, - Key::Char(c) => SetupIntent::Push(c), - _ => SetupIntent::Ignore, - }, - DjSetupStep::Backend => match key { - Key::Esc => SetupIntent::Dismiss, - Key::Enter => SetupIntent::Advance, - Key::Up => SetupIntent::MoveUp, - Key::Down => SetupIntent::MoveDown, - // Matched ahead of the movement guards so a user who has rebound - // `move_down` to a digit still gets the digit's row-select meaning here. - Key::Char(c @ '1'..='9') => SetupIntent::Select(c as usize - '0' as usize), - // `_ if` rather than `k if`: a bound-but-unused binding is a warning and CI - // clippy runs with `-D warnings`. - _ if goes_up => SetupIntent::MoveUp, - _ if goes_down => SetupIntent::MoveDown, - // Everything else is swallowed. That is the point of a modal: no global - // binding, no DJ action, no navigation escapes it. - _ => SetupIntent::Ignore, - }, - DjSetupStep::Model => match key { - Key::Esc => SetupIntent::Back, - Key::Enter => SetupIntent::Advance, - Key::Up => SetupIntent::MoveUp, - Key::Down => SetupIntent::MoveDown, - Key::Char(c @ '1'..='9') => SetupIntent::Select(c as usize - '0' as usize), - _ if goes_up => SetupIntent::MoveUp, - _ if goes_down => SetupIntent::MoveDown, - _ => SetupIntent::Ignore, - }, - } - }; - - apply_setup_intent(intent, app); -} - -fn apply_setup_intent(intent: SetupIntent, app: &mut App) { - match intent { - SetupIntent::MoveUp => { - if let Some(setup) = app.dj.setup.as_mut() { - setup.move_up(); - } - } - SetupIntent::MoveDown => { - if let Some(setup) = app.dj.setup.as_mut() { - setup.move_down(); - } - } - SetupIntent::Select(row) => { - // A digit picks its row outright rather than only moving the cursor: the rows - // are numbered on screen, and a shortcut that still needs an Enter saves - // nothing. - let picked = app - .dj - .setup - .as_mut() - .is_some_and(|setup| setup.select_row(row)); - if picked { - advance(app); - } - } - SetupIntent::Advance => advance(app), - SetupIntent::Back => { - if let Some(setup) = app.dj.setup.as_mut() { - setup.step = match setup.step { - DjSetupStep::Custom => DjSetupStep::Model, - _ => DjSetupStep::Backend, - }; - } - } - SetupIntent::Dismiss => { - app.apply(Action::DismissDjSetup); - } - SetupIntent::CommitCustom => { - app.apply(Action::CommitDjSetup); - } - SetupIntent::Push(c) => { - if let Some(setup) = app.dj.setup.as_mut() { - setup.custom.push(c); - } - } - SetupIntent::Backspace => { - if let Some(setup) = app.dj.setup.as_mut() { - setup.custom.pop(); - } - } - SetupIntent::ClearCustom => { - if let Some(setup) = app.dj.setup.as_mut() { - setup.custom.clear(); - } - } - SetupIntent::Ignore => {} - } -} - -/// Enter on a list row: advance a step, open the free-text step, or finish. -fn advance(app: &mut App) { - let Some(step) = app.dj.setup.as_ref().map(|setup| setup.step) else { - return; - }; - match step { - DjSetupStep::Backend => { - if let Some(setup) = app.dj.setup.as_mut() { - setup.enter_model_step(&app.user_config.behavior); - } - } - DjSetupStep::Model => { - let wants_free_text = app - .dj - .setup - .as_ref() - .and_then(DjSetup::selected_model) - .is_some_and(|model| model.custom); - if wants_free_text { - if let Some(setup) = app.dj.setup.as_mut() { - setup.enter_custom_step(&app.user_config.behavior); - } - } else { - app.apply(Action::CommitDjSetup); - } - } - DjSetupStep::Custom => { - app.apply(Action::CommitDjSetup); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::app::{ActiveBlock, RouteId}; - use crate::core::user_config::{UserConfig, UserConfigPaths}; - use crate::infra::dj::{DjLine, TurnKind}; - use crate::infra::network::IoEvent; - use std::sync::mpsc::{channel, Receiver}; - use std::time::SystemTime; - - fn dj_app() -> (App, Receiver) { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.push_navigation_stack(RouteId::AiDj, ActiveBlock::AiDj); - (app, rx) - } - - fn type_text(app: &mut App, text: &str) { - for c in text.chars() { - handler(Key::Char(c), app); - } - } - - #[test] - fn typing_fills_the_prompt_and_tracks_the_cursor() { - let (mut app, _rx) = dj_app(); - type_text(&mut app, "chill"); - assert_eq!(app.dj.input.iter().collect::(), "chill"); - assert_eq!(app.dj.input_idx, 5); - assert_eq!(app.dj.input_cursor, 5); - } - - #[test] - fn the_cursor_counts_display_columns_not_characters() { - let (mut app, _rx) = dj_app(); - type_text(&mut app, "東京"); - assert_eq!(app.dj.input.len(), 2, "two characters"); - assert_eq!(app.dj.input_cursor, 4, "but four columns"); - } - - #[test] - fn backspace_removes_the_character_before_the_cursor() { - let (mut app, _rx) = dj_app(); - type_text(&mut app, "abc"); - handler(Key::Backspace, &mut app); - assert_eq!(app.dj.input.iter().collect::(), "ab"); - assert_eq!(app.dj.input_cursor, 2); - } - - #[test] - fn backspace_on_an_empty_prompt_is_a_no_op() { - let (mut app, _rx) = dj_app(); - handler(Key::Backspace, &mut app); - assert!(app.dj.input.is_empty()); - assert_eq!(app.dj.input_idx, 0); - } - - #[test] - fn arrows_move_within_the_prompt_and_clamp_at_the_ends() { - let (mut app, _rx) = dj_app(); - type_text(&mut app, "ab"); - handler(Key::Left, &mut app); - handler(Key::Left, &mut app); - handler(Key::Left, &mut app); - assert_eq!(app.dj.input_idx, 0); - assert_eq!(app.dj.input_cursor, 0); - for _ in 0..5 { - handler(Key::Right, &mut app); - } - assert_eq!(app.dj.input_idx, 2); - assert_eq!(app.dj.input_cursor, 2); - } - - #[test] - fn insert_in_the_middle_lands_at_the_cursor() { - let (mut app, _rx) = dj_app(); - type_text(&mut app, "ac"); - handler(Key::Left, &mut app); - handler(Key::Char('b'), &mut app); - assert_eq!(app.dj.input.iter().collect::(), "abc"); - } - - #[test] - fn ctrl_u_clears_the_prompt() { - let (mut app, _rx) = dj_app(); - type_text(&mut app, "scrap this"); - handler(Key::Ctrl('u'), &mut app); - assert!(app.dj.input.is_empty()); - assert_eq!(app.dj.input_cursor, 0); - } - - #[test] - fn enter_dispatches_ask_dj_and_clears_the_prompt() { - let (mut app, rx) = dj_app(); - type_text(&mut app, "something chill"); - handler(Key::Enter, &mut app); - - assert!(app.dj.input.is_empty(), "prompt should clear on submit"); - assert!(app.dj.thinking, "the turn is in flight"); - - // Exactly one line, and the handler owns it. The brain reads it back out of - // the transcript, so the request must not carry it a second time — see - // `session::history_from_transcript`. - assert_eq!(app.dj.transcript.len(), 1); - assert_eq!(app.dj.transcript[0], DjLine::user("something chill")); - - let event = rx.try_recv().expect("an AskDj should be dispatched"); - match event { - IoEvent::AskDj(request) => { - assert!( - request.extra_instruction.is_none(), - "a typed prompt travels in the transcript, not alongside it" - ); - assert_eq!(request.generation, app.dj.generation); - assert!( - !request.must_act, - "the listener is watching, so the DJ may ask them something" - ); - // The words become the standing vibe only if this turn actually queues. - assert_eq!(request.vibe_on_success.as_deref(), Some("something chill")); - assert!( - app.dj.vibe.is_none(), - "nothing has been queued yet, so there is no direction to follow" - ); - } - other => panic!("unexpected event: {:?}", std::mem::discriminant(&other)), - } - } - - #[test] - fn enter_on_an_empty_prompt_dispatches_nothing() { - let (mut app, rx) = dj_app(); - handler(Key::Enter, &mut app); - assert!(rx.try_recv().is_err()); - assert!(!app.dj.thinking); - } - - #[test] - fn a_whitespace_only_prompt_dispatches_nothing() { - let (mut app, rx) = dj_app(); - type_text(&mut app, " "); - handler(Key::Enter, &mut app); - assert!(rx.try_recv().is_err()); - } - - #[test] - fn submitting_while_thinking_is_refused_rather_than_stacking_turns() { - let (mut app, rx) = dj_app(); - app.dj.thinking = true; - type_text(&mut app, "again"); - handler(Key::Enter, &mut app); - assert!(rx.try_recv().is_err(), "must not stack a second turn"); - assert!(app.status_message().is_some()); - // Refusing the turn *and* eating the request is the worst of both: the user - // spent a minute typing it and has nothing to resend. - assert_eq!( - app.dj.input.iter().collect::(), - "again", - "a refused turn must leave the typed prompt in the box" - ); - } - - #[test] - fn opening_the_dj_while_it_is_already_open_does_not_stack_a_second_route() { - let (mut app, _rx) = dj_app(); - // Reachable in practice: Left moves focus to the sidebar, so `handle_app`'s - // `ActiveBlock::AiDj` early return no longer fires and the open key (or the - // sidebar's own row) calls straight back into `open`. - app.set_current_route_state(Some(ActiveBlock::Library), None); - open(&mut app); - assert_eq!( - app.get_current_route().active_block, - ActiveBlock::AiDj, - "focus should come back to the DJ rather than stay on the sidebar" - ); - // The actual symptom of a stacked duplicate: Esc leaves the DJ and lands - // straight back on it. - app.pop_navigation_stack(); - assert_ne!( - app.get_current_route().id, - RouteId::AiDj, - "one Esc must be enough to leave the DJ" - ); - } - - #[test] - fn opening_the_picker_from_the_sidebar_gives_it_the_keyboard() { - // Same reachable state as the test above, and the symptom is worse: the modal - // paints, but `handle_app` keeps routing keys to the sidebar's handler, so the - // picker is visible and accepts nothing. - let (mut app, _rx) = dj_app(); - app.set_current_route_state(Some(ActiveBlock::Library), None); - app.apply(Action::OpenDjSetup); - assert!(app.dj.setup.is_some(), "the picker should be open"); - assert_eq!( - app.get_current_route().active_block, - ActiveBlock::AiDj, - "a painted modal that cannot be typed into is worse than no modal" - ); - app.pop_navigation_stack(); - assert_ne!( - app.get_current_route().id, - RouteId::AiDj, - "one Esc must be enough to leave the DJ" - ); - } - - #[test] - fn a_prompt_can_begin_with_the_move_left_character() { - // `keys.move_left` is a bare `h` by default and the left arm sits above - // `Key::Char(c)`, so without a guard the prompt could never start with one. - let (mut app, _rx) = dj_app(); - type_text(&mut app, "hello"); - assert_eq!(app.dj.input.iter().collect::(), "hello"); - assert_eq!( - app.get_current_route().active_block, - ActiveBlock::AiDj, - "typing must not move focus to the sidebar" - ); - // The real left bindings still leave an empty prompt. - app.dj.input.clear(); - app.dj.input_idx = 0; - app.dj.input_cursor = 0; - handler(Key::Left, &mut app); - assert_eq!( - app.get_current_route().active_block, - ActiveBlock::Empty, - "the arrow still hands focus to the sidebar" - ); - } - - #[test] - fn turning_auto_queue_off_does_not_abandon_a_question_in_flight() { - // The listener asked something and then flipped the toggle. Clearing the flag - // would let `submit` start a second brain call beside the first, and bumping - // the generation would make the answer they are waiting for land and be - // discarded. - let (mut app, rx) = dj_app(); - type_text(&mut app, "something warm"); - handler(Key::Enter, &mut app); - assert!(matches!(rx.try_recv(), Ok(IoEvent::AskDj(_)))); - let generation = app.dj.generation; - - app.dj.auto_queue = true; - app.apply(Action::ToggleDjAutoQueue); - - assert!(!app.dj.auto_queue); - assert!(app.dj.thinking, "the question is still being answered"); - assert_eq!( - app.dj.generation, generation, - "bumping it would throw away the answer the listener is waiting for" - ); - } - - #[test] - fn turning_auto_queue_off_still_abandons_a_refill() { - let (mut app, _rx) = dj_app(); - app.dj.auto_queue = true; - app.dj.begin_turn(TurnKind::Refill); - app.dj.step = Some((2, 4)); - let generation = app.dj.generation; - - app.apply(Action::ToggleDjAutoQueue); - - assert!(!app.dj.thinking, "nobody is waiting on a refill"); - assert!( - app.dj.step.is_none(), - "a counter left from an abandoned turn would read as the next one's progress" - ); - assert_ne!(app.dj.generation, generation); - } - - #[test] - fn scrolling_back_is_bounded_by_the_transcript() { - let (mut app, _rx) = dj_app(); - app - .dj - .push_line(DjLine::system("one short line".to_string())); - for _ in 0..500 { - scroll_back(&mut app, 1); - } - // Unbounded, this needed 500 presses of Down before anything moved. - assert!(app.dj.scroll < 50, "scroll ran away to {}", app.dj.scroll); - } - - #[test] - fn submitting_bumps_the_generation_so_a_pending_refill_is_dropped() { - let (mut app, _rx) = dj_app(); - let before = app.dj.generation; - type_text(&mut app, "new direction"); - handler(Key::Enter, &mut app); - assert_ne!(app.dj.generation, before); - } - - #[test] - fn dj_events_do_not_pin_the_global_spinner() { - // A brain call runs for up to `dj_agent_timeout_secs`, and `App::dispatch` - // sets `is_loading` until the service-lane task finishes — pinning the global - // spinner for minutes. `dj.thinking` is the DJ's progress surface; the global - // one must stay free for ordinary work. - let (mut app, rx) = dj_app(); - type_text(&mut app, "something chill"); - handler(Key::Enter, &mut app); - assert!(matches!(rx.try_recv(), Ok(IoEvent::AskDj(_)))); - assert!(!app.is_loading, "submit must not set the global spinner"); - - app.dj.thinking = false; - app.apply(Action::DjVibeShift); - assert!(matches!(rx.try_recv(), Ok(IoEvent::AskDj(_)))); - assert!(!app.is_loading, "a vibe shift must not either"); - - app.dj.thinking = false; - app.apply(Action::ToggleDjAutoQueue); - assert!(matches!(rx.try_recv(), Ok(IoEvent::DjTopUp(..)))); - assert!(!app.is_loading, "nor the auto-queue toggle's refill"); - } - - #[test] - fn auto_queue_waits_while_an_external_spotify_device_plays() { - // On a remote Connect device queued tracks go to the Web API queue, so the - // native queue never fills and a length-based refill would fire after every - // batch, forever. The toggle stays on, but no refill is dispatched. - let (mut app, rx) = dj_app(); - app.current_playback_context = Some(external_spotify_context()); - assert!(app.spotify_external_device_active()); - - app.apply(Action::ToggleDjAutoQueue); - assert!(app.dj.auto_queue, "the preference itself still flips on"); - assert!( - rx.try_recv().is_err(), - "but no refill may be dispatched against an unobservable queue" - ); - assert!(!app.dj.thinking); - assert!( - app - .status_message() - .is_some_and(|message| message.contains("another Spotify device")), - "and the pause is said out loud, or auto-queue just looks broken" - ); - } - - fn external_spotify_context() -> rspotify::model::context::CurrentPlaybackContext { - use rspotify::model::{ - context::{Actions, CurrentPlaybackContext}, - CurrentlyPlayingType, Device, DeviceType, RepeatState, - }; - CurrentPlaybackContext { - device: Device { - id: Some("external-device".to_string()), - is_active: true, - is_private_session: false, - is_restricted: false, - name: "Phone".to_string(), - _type: DeviceType::Smartphone, - volume_percent: Some(50), - }, - repeat_state: RepeatState::Off, - shuffle_state: false, - context: None, - timestamp: chrono::Utc::now(), - progress: None, - is_playing: true, - item: None, - currently_playing_type: CurrentlyPlayingType::Track, - actions: Actions::default(), - } - } - - #[test] - fn vim_keys_type_rather_than_scroll() { - // A text prompt must be able to contain 'k' and 'j'. Scrolling is on the - // arrow and page keys instead. - let (mut app, _rx) = dj_app(); - type_text(&mut app, "kj"); - assert_eq!(app.dj.input.iter().collect::(), "kj"); - assert_eq!(app.dj.scroll, 0, "typing must never scroll"); - } - - #[test] - fn arrow_and_page_keys_scroll_the_transcript() { - let (mut app, _rx) = dj_app(); - // Enough history to have somewhere to scroll back to: the offset is clamped - // against the transcript, so an empty one correctly refuses to move. - for i in 0..40 { - app.dj.push_line(DjLine::system(format!("line {i}"))); - } - handler(Key::Up, &mut app); - assert_eq!(app.dj.scroll, 1); - handler(Key::Down, &mut app); - assert_eq!(app.dj.scroll, 0); - - // Still works with text in the prompt: there is no multi-line editing to - // conflict with. - type_text(&mut app, "text"); - handler(Key::PageUp, &mut app); - assert_eq!(app.dj.scroll, 10); - handler(Key::PageDown, &mut app); - assert_eq!(app.dj.scroll, 0); - } - - #[test] - fn toggling_auto_queue_on_with_a_short_queue_asks_for_a_refill() { - let (mut app, rx) = dj_app(); - app.apply(Action::ToggleDjAutoQueue); - assert!(app.dj.auto_queue); - match rx.try_recv().expect("a top-up should be dispatched") { - IoEvent::DjTopUp(generation, _) => assert_eq!(generation, app.dj.generation), - _ => panic!("expected DjTopUp"), - } - } - - #[test] - fn toggling_auto_queue_off_invalidates_work_in_flight() { - let (mut app, _rx) = dj_app(); - app.apply(Action::ToggleDjAutoQueue); - let generation_on = app.dj.generation; - app.dj.thinking = true; - app.apply(Action::ToggleDjAutoQueue); - assert!(!app.dj.auto_queue); - assert!(!app.dj.thinking, "a pending refill must be released"); - assert_ne!(app.dj.generation, generation_on); - } - - #[test] - fn a_vibe_shift_bumps_the_generation_and_asks_again() { - let (mut app, rx) = dj_app(); - let before = app.dj.generation; - app.apply(Action::DjVibeShift); - assert_ne!(app.dj.generation, before); - assert!(app.dj.thinking); - match rx.try_recv().expect("an AskDj should be dispatched") { - // The steer rides alongside: the listener never typed it, so it is not in - // the transcript for the brain to read. - IoEvent::AskDj(request) => { - assert!(request - .extra_instruction - .as_deref() - .is_some_and(|steer| steer.contains("Change direction"))); - // A shift has just dropped the DJ's queued tracks, so the turn has to - // refill rather than stopping to ask what "different" means. - assert!( - request.must_act, - "a vibe shift may not come back with words" - ); - } - other => panic!("unexpected event: {:?}", std::mem::discriminant(&other)), - } - let last = app.dj.transcript.last().unwrap(); - assert!(last.text.contains("vibe")); - assert_eq!( - last.speaker, - crate::infra::dj::DjSpeaker::System, - "the shift is announced, not spoken by the listener" - ); - } - - #[test] - fn a_vibe_shift_while_thinking_is_refused() { - let (mut app, rx) = dj_app(); - app.dj.thinking = true; - app.apply(Action::DjVibeShift); - assert!(rx.try_recv().is_err()); - } - - #[test] - fn toggling_fresh_only_starts_the_crawl_once() { - let (mut app, rx) = dj_app(); - assert!(!app.dj.avoid_library, "off unless configured on"); - - app.apply(Action::ToggleDjFreshOnly); - assert!(app.dj.avoid_library); - assert!(matches!(rx.try_recv(), Ok(IoEvent::DjIndexLibrary))); - - // Off, then on again with the index already cached: no second crawl. - app.apply(Action::ToggleDjFreshOnly); - assert!(!app.dj.avoid_library); - app.dj.library = Some(crate::infra::dj::DjLibrary::default()); - app.apply(Action::ToggleDjFreshOnly); - assert!(app.dj.avoid_library); - assert!( - rx.try_recv().is_err(), - "a cached index must not be rebuilt on every toggle" - ); - } - - #[test] - fn the_config_default_seeds_the_toggle() { - let (tx, _rx) = channel(); - let mut config = UserConfig::new(); - config.behavior.dj_avoid_library = true; - let app = App::new(tx, config, Some(SystemTime::now())); - assert!( - app.dj.avoid_library, - "behavior.dj_avoid_library has to reach DjState or the config does nothing" - ); - } - - #[test] - fn opening_the_screen_with_the_filter_already_on_starts_the_crawl() { - // The config-default path never runs `ToggleDjFreshOnly`, so opening the - // screen is the only chance to warm the index. Without this the first turn - // crawls inline on the serial lane, blocking every other event behind it. - let (tx, rx) = channel(); - let mut config = UserConfig::new(); - config.behavior.dj_avoid_library = true; - let mut app = App::new(tx, config, Some(SystemTime::now())); - - open(&mut app); - assert_eq!(app.get_current_route().id, RouteId::AiDj); - assert!(matches!(rx.try_recv(), Ok(IoEvent::DjIndexLibrary))); - } - - #[test] - fn opening_the_screen_with_the_filter_off_crawls_nothing() { - // The crawl is the whole cost of this feature; a listener who never turns it - // on must never pay it. - let (mut app, rx) = dj_app(); - app.pop_navigation_stack(); - open(&mut app); - assert!(rx.try_recv().is_err()); - } - - #[test] - fn the_dj_action_keys_work_while_the_prompt_has_focus() { - // `handle_app` hands this screen every key so the prompt can contain 'd' and - // ' ', which means the DJ's own shortcuts have to be honoured here or they are - // unreachable from the one screen they belong to. - let (mut app, _rx) = dj_app(); - handler(app.user_config.keys.dj_toggle_fresh_only, &mut app); - assert!(app.dj.avoid_library); - handler(app.user_config.keys.dj_toggle_auto_queue, &mut app); - assert!(app.dj.auto_queue); - assert!( - app.dj.input.is_empty(), - "an action key must not type itself" - ); - } - - #[test] - fn a_bare_character_binding_still_types_instead_of_acting() { - // A user is free to rebind the toggle to 'x'. On a typing surface the - // character has to win, or the prompt cannot contain it. - let (mut app, _rx) = dj_app(); - app.user_config.keys.dj_toggle_fresh_only = Key::Char('x'); - handler(Key::Char('x'), &mut app); - assert_eq!(app.dj.input.iter().collect::(), "x"); - assert!(!app.dj.avoid_library); - } - - #[test] - fn the_back_key_leaves_the_screen() { - let (mut app, _rx) = dj_app(); - handler(Key::Esc, &mut app); - assert_ne!(app.get_current_route().id, RouteId::AiDj); - } - - #[test] - fn the_back_key_is_prompt_text_here_rather_than_a_way_out() { - // Nothing in this handler treats the back key specially, and that is the point: - // it defaults to `q`, so a DJ that consumed it could never be told about Queen. - // The other half of the rule is in `runner::dispatch_key`, which has to let the - // key through instead of popping the route before this function is ever called. - let (mut app, _rx) = dj_app(); - let back = app.user_config.keys.back; - assert_eq!(back, Key::Char('q'), "the default this test is about"); - - handler(back, &mut app); - - assert_eq!(app.dj.input.iter().collect::(), "q"); - assert_eq!(app.get_current_route().id, RouteId::AiDj); - } - - #[test] - fn the_picker_swallows_letters_instead_of_typing_them() { - // The picker branch has to sit above `Key::Char(c) => insert`, which is total - // over every character; below it the letters would land in the prompt behind - // the overlay. - let (mut app, _rx) = dj_app(); - app.apply(Action::OpenDjSetup); - handler(Key::Char('j'), &mut app); - handler(Key::Char('q'), &mut app); - assert!(app.dj.input.is_empty()); - assert!(app.dj.setup.is_some(), "and 'q' does not leave the screen"); - } - - #[test] - fn the_picker_swallows_the_dj_action_keys_so_none_start_background_work() { - // The picker branch also has to sit above the modifier block: these keys start - // minutes of work with the very backend the user is part way through changing. - let (mut app, rx) = dj_app(); - app.apply(Action::OpenDjSetup); - let keys = app.user_config.keys.clone(); - handler(keys.dj_toggle_auto_queue, &mut app); - handler(keys.dj_vibe_shift, &mut app); - handler(keys.dj_toggle_fresh_only, &mut app); - assert!(rx.try_recv().is_err(), "nothing may be dispatched"); - assert!(!app.dj.auto_queue); - assert!(!app.dj.avoid_library); - assert!(!app.dj.thinking); - assert!(app.dj.setup.is_some()); - } - - #[test] - fn escape_steps_back_through_the_picker_before_leaving_the_screen() { - // The dismiss below is the one keypress in this file that reaches - // `save_config`, so the path is redirected at a scratch file first. That is - // what makes the seam safe to touch at all (it would otherwise rewrite the - // developer's real ~/.config/spotatui/config.yml during `cargo test`), and it - // is also the only way to see the *success* branch: with `UserConfig::new()`'s - // empty path every run takes the error branch instead. - let dir = tempfile::tempdir().expect("a writable scratch directory"); - let (mut app, _rx) = dj_app(); - app.user_config.path_to_config = Some(UserConfigPaths { - config_file_path: dir.path().join("config.yml"), - }); - - app.apply(Action::OpenDjSetup); - handler(Key::Enter, &mut app); - assert_eq!(app.dj.setup.as_ref().unwrap().step, DjSetupStep::Model); - - handler(Key::Esc, &mut app); - assert_eq!(app.dj.setup.as_ref().unwrap().step, DjSetupStep::Backend); - - // Dismissing counts as an answer, or `open` would ask again on every entry. - handler(Key::Esc, &mut app); - assert!(app.dj.setup.is_none()); - assert_eq!(app.user_config.behavior.dj_configured, Some(true)); - assert_eq!(app.get_current_route().id, RouteId::AiDj, "still on the DJ"); - // Assert the branch, not merely that *something* was said: both branches of - // `persist_dj_setup` write `status_message`, and only `status_message_is_error` - // tells them apart. `status_message.is_some()` alone would hold even with the - // error guard deleted outright. - assert!(!app.status_message_is_error()); - assert_eq!(app.status_message(), Some("keeping the current DJ brain")); - assert!( - dir.path().join("config.yml").exists(), - "and the marker actually landed, which is the only reason to persist" - ); - - handler(Key::Esc, &mut app); - assert_ne!(app.get_current_route().id, RouteId::AiDj); - } - - #[test] - fn a_dismiss_that_cannot_reach_disk_says_so_instead_of_re_prompting_forever() { - // The in-memory marker stops the re-prompt for this session only. If the write - // fails, the next launch asks again, so the failure has to reach the user: a - // logged-and-swallowed error is indistinguishable from the picker being broken. - let dir = tempfile::tempdir().expect("a writable scratch directory"); - let (mut app, _rx) = dj_app(); - app.user_config.path_to_config = Some(UserConfigPaths { - // A missing parent rather than a read-only directory: `write_private_file` - // fails at `open` on every platform and for every user, including the root - // account CI containers usually run as, which ignores mode bits. - config_file_path: dir.path().join("no-such-directory").join("config.yml"), - }); - - app.apply(Action::OpenDjSetup); - handler(Key::Esc, &mut app); - - assert!(app.dj.setup.is_none(), "the picker still closes"); - assert!( - app.status_message_is_error(), - "a save that never landed must be surfaced, not logged" - ); - assert!(app - .status_message() - .is_some_and(|message| message.starts_with("DJ: could not save that choice"))); - } - - #[test] - fn a_digit_picks_its_row_outright() { - // The rows are numbered on screen, so a digit that only moved the cursor would - // be a shortcut that saves nothing. - let (mut app, _rx) = dj_app(); - app.apply(Action::OpenDjSetup); - handler(Key::Char('2'), &mut app); - let setup = app.dj.setup.as_ref().unwrap(); - assert_eq!(setup.backend_index, 1); - assert_eq!(setup.step, DjSetupStep::Model, "and advances with it"); - } - - #[test] - fn a_digit_on_the_free_text_row_opens_the_typing_step_rather_than_doing_nothing() { - // The digit path reaches `choice()`, which refuses the "Custom…" row. Without - // the free-text branch in `advance` that refusal would read as a dead key. - let (mut app, _rx) = dj_app(); - app.apply(Action::OpenDjSetup); - handler(Key::Enter, &mut app); - let custom_row = app.dj.setup.as_ref().unwrap().models.len(); - assert!(custom_row <= 9, "the claude list is short enough to reach"); - handler(Key::Char((b'0' + custom_row as u8) as char), &mut app); - assert_eq!(app.dj.setup.as_ref().unwrap().step, DjSetupStep::Custom); - } - - #[test] - fn the_custom_row_opens_a_typing_step_that_takes_letters() { - let (mut app, _rx) = dj_app(); - app.apply(Action::OpenDjSetup); - handler(Key::Enter, &mut app); - let last = app.dj.setup.as_ref().unwrap().models.len() - 1; - app.dj.setup.as_mut().unwrap().model_index = last; - handler(Key::Enter, &mut app); - assert_eq!(app.dj.setup.as_ref().unwrap().step, DjSetupStep::Custom); - - // A model name has to be able to contain a 'j', so no navigation here. - app.dj.setup.as_mut().unwrap().custom.clear(); - for c in "jazzy-model".chars() { - handler(Key::Char(c), &mut app); - } - handler(Key::Backspace, &mut app); - assert_eq!(app.dj.setup.as_ref().unwrap().custom, "jazzy-mode"); - } - - #[test] - fn opening_the_dj_on_a_fresh_config_shows_the_picker() { - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - open(&mut app); - assert!( - app.dj.setup.is_some(), - "a machine-written default config has never chosen an AI" - ); - } - - #[test] - fn opening_the_dj_on_a_configured_install_never_shows_the_picker() { - let (tx, _rx) = channel(); - let mut config = UserConfig::new(); - config.behavior.dj_configured = Some(true); - let mut app = App::new(tx, config, Some(SystemTime::now())); - open(&mut app); - assert!(app.dj.setup.is_none()); - } - - #[test] - fn reopening_the_dj_rebuilds_a_picker_left_open_by_a_mouse_click() { - // Clicking the sidebar leaves the screen without the picker's Esc ever running, - // so the next visit must not resume rows detected in a session that moved on. - let (tx, _rx) = channel(); - let mut config = UserConfig::new(); - config.behavior.dj_configured = Some(true); - let mut app = App::new(tx, config, Some(SystemTime::now())); - app.apply(Action::OpenDjSetup); - app.dj.setup.as_mut().unwrap().step = DjSetupStep::Model; - app.pop_navigation_stack(); - - open(&mut app); - assert_eq!( - app.dj.setup.as_ref().map(|setup| setup.step), - Some(DjSetupStep::Backend) - ); - } - - #[test] - fn the_reopen_binding_shows_the_picker_from_the_dj_screen() { - // It carries a modifier precisely so it survives the prompt having focus. - let (mut app, _rx) = dj_app(); - handler(app.user_config.keys.dj_pick_model, &mut app); - assert!(app.dj.setup.is_some()); - assert!(app.dj.input.is_empty()); - } - - #[test] - fn the_reopen_binding_does_not_push_a_second_dj_route() { - let (mut app, _rx) = dj_app(); - app.apply(Action::OpenDjSetup); - app.dj.close_setup(); - app.pop_navigation_stack(); - assert_ne!( - app.get_current_route().id, - RouteId::AiDj, - "a second AiDj route would strand the user on the DJ screen" - ); - } - - #[test] - fn confirming_the_picker_writes_the_backend_and_marks_the_dj_configured() { - let (mut app, _rx) = dj_app(); - app.apply(Action::OpenDjSetup); - handler(Key::Enter, &mut app); - - // No config path is seeded, so the save fails into a status message and - // never touches the filesystem. - app.apply(Action::CommitDjSetup); - let behavior = &app.user_config.behavior; - assert_eq!(behavior.dj_backend, "agent_cli"); - assert_eq!(behavior.dj_agent_command, vec!["claude".to_string()]); - assert_eq!(behavior.dj_agent_model.as_deref(), Some("haiku")); - assert_eq!(behavior.dj_configured, Some(true)); - assert!(app.dj.setup.is_none(), "the picker closes on commit"); - assert!(app - .dj - .transcript - .last() - .is_some_and(|line| line.text.contains("claude/haiku"))); - } - - #[test] - fn dismissing_the_picker_marks_the_dj_configured_so_it_does_not_reappear() { - let (mut app, _rx) = dj_app(); - app.apply(Action::OpenDjSetup); - app.apply(Action::DismissDjSetup); - assert!(app.dj.setup.is_none()); - assert_eq!(app.user_config.behavior.dj_configured, Some(true)); - - // "I do not want to be asked" changes nothing else: the brain stays whatever it - // already was. - let behavior = &app.user_config.behavior; - assert_eq!(behavior.dj_backend, "agent_cli"); - assert_eq!( - behavior.dj_agent_command, - crate::core::user_config::default_dj_agent_command() - ); - assert_eq!(behavior.dj_agent_model, None); - assert_eq!(behavior.dj_model, None); - } - - #[test] - fn the_picker_bumps_the_generation_so_an_in_flight_batch_is_dropped() { - let (mut app, _rx) = dj_app(); - app.apply(Action::OpenDjSetup); - handler(Key::Enter, &mut app); - let before = app.dj.generation; - app.apply(Action::CommitDjSetup); - assert_ne!( - app.dj.generation, before, - "a batch in flight came from the backend just replaced" - ); - } -} diff --git a/src/tui/handlers/album_list.rs b/src/tui/handlers/album_list.rs deleted file mode 100644 index 859edc19..00000000 --- a/src/tui/handlers/album_list.rs +++ /dev/null @@ -1,214 +0,0 @@ -use super::common_key_events; -use crate::core::action::{Action, OpenTarget}; -use crate::core::app::App; -use crate::tui::event::Key; - -pub fn handler(key: Key, app: &mut App) { - match key { - k if common_key_events::left_event(k, &app.user_config.keys) => { - common_key_events::handle_left_event(app) - } - k if common_key_events::down_event(k, &app.user_config.keys) => { - if let Some(albums) = &mut app.library.saved_albums.get_results(None) { - let next_index = - common_key_events::on_down_press_handler(&albums.items, Some(app.view.album_list_index)); - app.view.album_list_index = next_index; - } - } - k if common_key_events::up_event(k, &app.user_config.keys) => { - if let Some(albums) = &mut app.library.saved_albums.get_results(None) { - let next_index = - common_key_events::on_up_press_handler(&albums.items, Some(app.view.album_list_index)); - app.view.album_list_index = next_index; - } - } - k if common_key_events::high_event(k) => { - if let Some(_albums) = app.library.saved_albums.get_results(None) { - let next_index = common_key_events::on_high_press_handler(); - app.view.album_list_index = next_index; - } - } - k if common_key_events::middle_event(k) => { - if let Some(albums) = app.library.saved_albums.get_results(None) { - let next_index = common_key_events::on_middle_press_handler(&albums.items); - app.view.album_list_index = next_index; - } - } - k if common_key_events::low_event(k) => { - if let Some(albums) = app.library.saved_albums.get_results(None) { - let next_index = common_key_events::on_low_press_handler(&albums.items); - app.view.album_list_index = next_index; - } - } - Key::Enter => { - if let Some(id) = selected_saved_album_id(app) { - app.apply(Action::Open(OpenTarget::SavedAlbum(id))); - } - } - k if k == app.user_config.keys.next_page => app.get_current_user_saved_albums_next(), - k if k == app.user_config.keys.previous_page => app.get_current_user_saved_albums_previous(), - Key::Char('D') => { - if let Some(id) = selected_saved_album_id(app) { - app.apply(Action::UnsaveAlbum(id)); - } - } - // Open sort menu - Key::Char(',') => { - super::sort_menu::open_sort_menu(app, crate::core::sort::SortContext::SavedAlbums); - } - _ => {} - }; -} - -/// The album id under the list cursor. -fn selected_saved_album_id(app: &App) -> Option { - app - .library - .saved_albums - .get_results(None)? - .items - .get(app.view.album_list_index)? - .album - .id - .clone() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::app::ActiveBlock; - use crate::core::pagination::Paged; - use crate::core::plugin_api::{AlbumInfo, SavedAlbumInfo, TrackInfo}; - use crate::core::user_config::UserConfig; - use crate::infra::network::IoEvent; - use std::sync::mpsc::channel; - use std::time::SystemTime; - - fn track(name: &str) -> TrackInfo { - TrackInfo { - uri: None, - name: name.to_string(), - artists: vec!["Artist".to_string()], - album: "Album".to_string(), - duration_ms: 1000, - id: None, - album_id: None, - artist_refs: vec![], - is_playable: true, - is_local: false, - track_number: 0, - explicit: false, - image_url: None, - } - } - - fn app_with_saved_album( - cached_tracks: usize, - total_tracks: u32, - ) -> (App, std::sync::mpsc::Receiver) { - app_with_saved_album_id(Some("longalbum".to_string()), cached_tracks, total_tracks) - } - - fn app_with_saved_album_id( - album_id: Option, - cached_tracks: usize, - total_tracks: u32, - ) -> (App, std::sync::mpsc::Receiver) { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - let album = AlbumInfo { - id: album_id, - uri: Some("spotify:album:longalbum".to_string()), - name: "One Wayne G".to_string(), - total_tracks: Some(total_tracks), - tracks: (0..cached_tracks) - .map(|i| track(&format!("t{}", i))) - .collect(), - ..AlbumInfo::default() - }; - app.library.saved_albums.upsert_page_by_offset(Paged { - items: vec![SavedAlbumInfo { - album, - added_at: String::new(), - }], - offset: 0, - limit: 1, - total: 1, - next: None, - previous: None, - }); - (app, rx) - } - - #[test] - fn enter_on_saved_album_with_complete_tracklist_uses_cache() { - let (mut app, rx) = app_with_saved_album(2, 2); - - handler(Key::Enter, &mut app); - - assert!(app.selected_album_full.is_some()); - assert_eq!( - app.get_current_route().active_block, - ActiveBlock::AlbumTracks - ); - assert!(rx.try_recv().is_err()); - } - - #[test] - fn enter_on_saved_album_with_truncated_tracklist_refetches_full_album() { - let (mut app, rx) = app_with_saved_album(50, 199); - - handler(Key::Enter, &mut app); - - // The cached 50-track page must not be rendered; GetAlbum fetches the - // complete tracklist and pushes the AlbumTracks route itself. - assert!(app.selected_album_full.is_none()); - assert_ne!( - app.get_current_route().active_block, - ActiveBlock::AlbumTracks - ); - match rx.recv().unwrap() { - IoEvent::GetAlbum(id) => assert_eq!(id, "longalbum"), - _ => panic!("expected GetAlbum"), - } - } - - #[test] - fn enter_on_a_saved_album_without_an_id_opens_nothing() { - // An id-less row cannot be opened by id; every Web API album has one. - let (mut app, rx) = app_with_saved_album_id(None, 50, 199); - - handler(Key::Enter, &mut app); - - assert!(app.selected_album_full.is_none()); - assert_ne!( - app.get_current_route().active_block, - ActiveBlock::AlbumTracks - ); - assert!(rx.try_recv().is_err()); - } - - #[test] - fn on_left_press() { - let mut app = App::default(); - app.set_current_route_state( - Some(ActiveBlock::AlbumTracks), - Some(ActiveBlock::AlbumTracks), - ); - - handler(Key::Left, &mut app); - let current_route = app.get_current_route(); - assert_eq!(current_route.active_block, ActiveBlock::Empty); - assert_eq!(current_route.hovered_block, ActiveBlock::Library); - } - - #[test] - fn on_esc() { - let mut app = App::default(); - - handler(Key::Esc, &mut app); - - let current_route = app.get_current_route(); - assert_eq!(current_route.active_block, ActiveBlock::Empty); - } -} diff --git a/src/tui/handlers/album_tracks.rs b/src/tui/handlers/album_tracks.rs deleted file mode 100644 index 5daf73fd..00000000 --- a/src/tui/handlers/album_tracks.rs +++ /dev/null @@ -1,341 +0,0 @@ -use super::common_key_events; -use crate::core::action::Action; -use crate::core::app::{AlbumTableContext, App}; -use crate::core::plugin_api::TrackInfo; -use crate::tui::event::Key; - -pub fn handler(key: Key, app: &mut App) { - match key { - k if common_key_events::left_event(k, &app.user_config.keys) => { - common_key_events::handle_left_event(app) - } - k if common_key_events::down_event(k, &app.user_config.keys) => match app.album_table_context { - AlbumTableContext::Full => { - if let Some(selected_album) = &app.selected_album_full { - let next_index = common_key_events::on_down_press_handler( - &selected_album.album.tracks, - Some(app.view.saved_album_tracks_index), - ); - app.view.saved_album_tracks_index = next_index; - }; - } - AlbumTableContext::Simplified => { - if let Some(selected_album_simplified) = &mut app.selected_album_simplified { - let next_index = common_key_events::on_down_press_handler( - &selected_album_simplified.tracks.items, - Some(selected_album_simplified.selected_index), - ); - selected_album_simplified.selected_index = next_index; - } - } - }, - k if common_key_events::up_event(k, &app.user_config.keys) => match app.album_table_context { - AlbumTableContext::Full => { - if let Some(selected_album) = &app.selected_album_full { - let next_index = common_key_events::on_up_press_handler( - &selected_album.album.tracks, - Some(app.view.saved_album_tracks_index), - ); - app.view.saved_album_tracks_index = next_index; - }; - } - AlbumTableContext::Simplified => { - if let Some(selected_album_simplified) = &mut app.selected_album_simplified { - let next_index = common_key_events::on_up_press_handler( - &selected_album_simplified.tracks.items, - Some(selected_album_simplified.selected_index), - ); - selected_album_simplified.selected_index = next_index; - } - } - }, - k if common_key_events::high_event(k) => handle_high_event(app), - k if common_key_events::middle_event(k) => handle_middle_event(app), - k if common_key_events::low_event(k) => handle_low_event(app), - Key::Char('s') => handle_save_event(app), - Key::Char('w') => handle_save_album_event(app), - Key::Enter => { - if let Some((uri, offset)) = open_album_start(app) { - app.apply(Action::PlayContext { - uri, - offset: Some(offset), - }); - } - } - //recommended playlist based on selected track - Key::Char('r') => { - handle_recommended_tracks(app); - } - _ if key == app.user_config.keys.add_item_to_queue => { - if let Some(track) = selected_album_track(app).cloned() { - app.apply(Action::QueueTrack(track)); - } - } - _ => {} - }; -} - -/// The track under the cursor of whichever album context is open. -fn selected_album_track(app: &App) -> Option<&TrackInfo> { - match app.album_table_context { - AlbumTableContext::Full => app - .selected_album_full - .as_ref()? - .album - .tracks - .get(app.view.saved_album_tracks_index), - AlbumTableContext::Simplified => { - let selected = app.selected_album_simplified.as_ref()?; - selected.tracks.items.get(selected.selected_index) - } - } -} - -/// `None` when the album has no URI: skipped instead of a bare resume (every -/// Web API album has one). -fn open_album_start(app: &App) -> Option<(String, usize)> { - match app.album_table_context { - AlbumTableContext::Full => { - let selected = app.selected_album_full.as_ref()?; - Some(( - selected.album.uri.clone()?, - app.view.saved_album_tracks_index, - )) - } - AlbumTableContext::Simplified => { - let selected = app.selected_album_simplified.as_ref()?; - Some((selected.album.uri.clone()?, selected.selected_index)) - } - } -} - -/// The open album's id, from whichever album context is open. -fn open_album_id(app: &App) -> Option { - match app.album_table_context { - AlbumTableContext::Full => app.selected_album_full.as_ref()?.album.id.clone(), - AlbumTableContext::Simplified => app.selected_album_simplified.as_ref()?.album.id.clone(), - } -} - -fn handle_high_event(app: &mut App) { - match app.album_table_context { - AlbumTableContext::Full => { - let next_index = common_key_events::on_high_press_handler(); - app.view.saved_album_tracks_index = next_index; - } - AlbumTableContext::Simplified => { - if let Some(selected_album_simplified) = &mut app.selected_album_simplified { - let next_index = common_key_events::on_high_press_handler(); - selected_album_simplified.selected_index = next_index; - } - } - } -} - -fn handle_middle_event(app: &mut App) { - match app.album_table_context { - AlbumTableContext::Full => { - if let Some(selected_album) = &app.selected_album_full { - let next_index = common_key_events::on_middle_press_handler(&selected_album.album.tracks); - app.view.saved_album_tracks_index = next_index; - }; - } - AlbumTableContext::Simplified => { - if let Some(selected_album_simplified) = &mut app.selected_album_simplified { - let next_index = - common_key_events::on_middle_press_handler(&selected_album_simplified.tracks.items); - selected_album_simplified.selected_index = next_index; - } - } - } -} - -fn handle_low_event(app: &mut App) { - match app.album_table_context { - AlbumTableContext::Full => { - if let Some(selected_album) = &app.selected_album_full { - let next_index = common_key_events::on_low_press_handler(&selected_album.album.tracks); - app.view.saved_album_tracks_index = next_index; - }; - } - AlbumTableContext::Simplified => { - if let Some(selected_album_simplified) = &mut app.selected_album_simplified { - let next_index = - common_key_events::on_low_press_handler(&selected_album_simplified.tracks.items); - selected_album_simplified.selected_index = next_index; - } - } - } -} - -fn handle_recommended_tracks(app: &mut App) { - let identity = - selected_album_track(app).and_then(|track| Some((track.id.clone()?, track.name.clone()))); - if let Some((id, name)) = identity { - app.apply(Action::RecommendFromTrackId { id, name }); - } -} - -fn handle_save_event(app: &mut App) { - // The payload is the bare base62 id, not the track URI. - if let Some(track_id) = selected_album_track(app).and_then(|track| track.id.clone()) { - app.apply(Action::ToggleSaveTrack(track_id)); - } -} - -fn handle_save_album_event(app: &mut App) { - if let Some(album_id) = open_album_id(app) { - app.apply(Action::SaveAlbum(album_id)); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::action::OpenTarget; - use crate::core::app::{ActiveBlock, RecommendationsContext}; - use crate::core::pagination::Paged; - use crate::core::plugin_api::{AlbumInfo, SavedAlbumInfo}; - use crate::core::user_config::UserConfig; - use crate::infra::network::IoEvent; - use std::sync::mpsc::{channel, Receiver}; - use std::time::SystemTime; - - fn track(id: &str, name: &str) -> TrackInfo { - TrackInfo { - uri: Some(format!("spotify:track:{id}")), - name: name.to_string(), - artists: vec!["Artist".to_string()], - album: "Album One".to_string(), - duration_ms: 1000, - id: Some(id.to_string()), - album_id: Some("album1".to_string()), - artist_refs: vec![], - is_playable: true, - is_local: false, - track_number: 0, - explicit: false, - image_url: None, - } - } - - fn app_on_a_cached_album(album_uri: Option) -> (App, Receiver) { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.library.saved_albums.upsert_page_by_offset(Paged { - items: vec![SavedAlbumInfo { - album: AlbumInfo { - id: Some("album1".to_string()), - uri: album_uri, - name: "Album One".to_string(), - total_tracks: Some(2), - tracks: vec![track("track1", "Track One"), track("track2", "Track Two")], - ..AlbumInfo::default() - }, - added_at: String::new(), - }], - offset: 0, - limit: 1, - total: 1, - next: None, - previous: None, - }); - app.apply(Action::Open(OpenTarget::SavedAlbum("album1".to_string()))); - (app, rx) - } - - #[test] - fn enter_starts_the_album_context_at_the_selected_track() { - let (mut app, rx) = app_on_a_cached_album(Some("spotify:album:album1".to_string())); - handler(Key::Down, &mut app); - - handler(Key::Enter, &mut app); - - match rx.try_recv() { - Ok(IoEvent::StartPlayback(context, uris, offset)) => { - assert_eq!(context.as_deref(), Some("spotify:album:album1")); - assert!(uris.is_none()); - assert_eq!(offset, Some(1)); - } - _ => panic!("expected StartPlayback"), - } - } - - #[test] - fn enter_on_an_album_without_a_uri_starts_nothing() { - let (mut app, rx) = app_on_a_cached_album(None); - - handler(Key::Enter, &mut app); - - assert!( - rx.try_recv().is_err(), - "an unaddressable album is skipped, not resumed with an offset" - ); - } - - #[test] - fn s_toggles_save_for_the_selected_track() { - let (mut app, rx) = app_on_a_cached_album(Some("spotify:album:album1".to_string())); - handler(Key::Down, &mut app); - - handler(Key::Char('s'), &mut app); - - assert!(matches!( - rx.try_recv(), - Ok(IoEvent::ToggleSaveTrack(id)) if id == "track2" - )); - } - - #[test] - fn w_saves_the_open_album() { - let (mut app, rx) = app_on_a_cached_album(Some("spotify:album:album1".to_string())); - - handler(Key::Char('w'), &mut app); - - assert!(matches!( - rx.try_recv(), - Ok(IoEvent::CurrentUserSavedAlbumAdd(id)) if id == "album1" - )); - } - - #[test] - fn r_seeds_recommendations_from_the_selected_track() { - let (mut app, rx) = app_on_a_cached_album(Some("spotify:album:album1".to_string())); - - handler(Key::Char('r'), &mut app); - - assert_eq!( - app.recommendations_context, - Some(RecommendationsContext::Song) - ); - assert_eq!(app.recommendations_seed, "Track One"); - assert!(matches!( - rx.try_recv(), - Ok(IoEvent::GetRecommendationsForTrackId(id, _)) if id == "track1" - )); - } - - #[test] - fn on_left_press() { - let mut app = App::default(); - app.set_current_route_state( - Some(ActiveBlock::AlbumTracks), - Some(ActiveBlock::AlbumTracks), - ); - - handler(Key::Left, &mut app); - let current_route = app.get_current_route(); - assert_eq!(current_route.active_block, ActiveBlock::Empty); - assert_eq!(current_route.hovered_block, ActiveBlock::Library); - } - - #[test] - fn on_esc() { - let mut app = App::default(); - - handler(Key::Esc, &mut app); - - let current_route = app.get_current_route(); - assert_eq!(current_route.active_block, ActiveBlock::Empty); - } -} diff --git a/src/tui/handlers/analysis.rs b/src/tui/handlers/analysis.rs deleted file mode 100644 index 36e5601b..00000000 --- a/src/tui/handlers/analysis.rs +++ /dev/null @@ -1,8 +0,0 @@ -use crate::{core::action::Action, core::app::App, tui::event::Key}; - -pub fn handler(key: Key, app: &mut App) { - // Uppercase 'V' to cycle visualizer style (lowercase 'v' opens the analysis view) - if key == Key::Char('V') { - app.apply(Action::CycleVisualizerStyle); - } -} diff --git a/src/tui/handlers/announcement_prompt.rs b/src/tui/handlers/announcement_prompt.rs deleted file mode 100644 index c57d685d..00000000 --- a/src/tui/handlers/announcement_prompt.rs +++ /dev/null @@ -1,27 +0,0 @@ -use crate::core::app::App; -use crate::tui::event::Key; - -pub fn handler(key: Key, app: &mut App) { - match key { - Key::Enter | Key::Esc | Key::Char('q') | Key::Char(' ') => { - if let Some(dismissed_id) = app.dismiss_active_announcement() { - app.runtime_state.mark_announcement_seen(dismissed_id); - let patch = crate::core::state::PersistedRuntimeState::announcements( - &app.runtime_state.seen_announcement_ids, - &app.runtime_state.dismissed_announcements, - ); - if let Err(error) = app.save_runtime_state(&patch) { - app.handle_error(anyhow::anyhow!( - "Failed to persist dismissed announcement: {}", - error - )); - } - } - - if app.active_announcement.is_none() { - app.pop_navigation_stack(); - } - } - _ => {} - } -} diff --git a/src/tui/handlers/artist.rs b/src/tui/handlers/artist.rs deleted file mode 100644 index 60163af6..00000000 --- a/src/tui/handlers/artist.rs +++ /dev/null @@ -1,488 +0,0 @@ -use super::common_key_events; -use crate::core::action::{Action, OpenTarget}; -use crate::core::app::{App, Artist, ArtistBlock}; -use crate::core::plugin_api::TrackInfo; -use crate::tui::event::Key; - -fn handle_down_press_on_selected_block(app: &mut App) { - if let Some(artist) = &mut app.artist { - match artist.artist_selected_block { - ArtistBlock::TopTracks => { - let next_index = common_key_events::on_down_press_handler( - &artist.top_tracks, - Some(artist.selected_top_track_index), - ); - artist.selected_top_track_index = next_index; - } - ArtistBlock::Albums => { - let next_index = common_key_events::on_down_press_handler( - &artist.albums.items, - Some(artist.selected_album_index), - ); - artist.selected_album_index = next_index; - } - ArtistBlock::RelatedArtists => { - let next_index = common_key_events::on_down_press_handler( - &artist.related_artists, - Some(artist.selected_related_artist_index), - ); - artist.selected_related_artist_index = next_index; - } - ArtistBlock::Empty => {} - } - } -} - -fn handle_down_press_on_hovered_block(app: &mut App) { - if let Some(artist) = &mut app.artist { - match artist.artist_hovered_block { - ArtistBlock::TopTracks => { - artist.artist_hovered_block = ArtistBlock::Albums; - } - ArtistBlock::Albums => { - artist.artist_hovered_block = ArtistBlock::RelatedArtists; - } - ArtistBlock::RelatedArtists => { - artist.artist_hovered_block = ArtistBlock::TopTracks; - } - ArtistBlock::Empty => {} - } - } -} - -fn handle_up_press_on_selected_block(app: &mut App) { - if let Some(artist) = &mut app.artist { - match artist.artist_selected_block { - ArtistBlock::TopTracks => { - let next_index = common_key_events::on_up_press_handler( - &artist.top_tracks, - Some(artist.selected_top_track_index), - ); - artist.selected_top_track_index = next_index; - } - ArtistBlock::Albums => { - let next_index = common_key_events::on_up_press_handler( - &artist.albums.items, - Some(artist.selected_album_index), - ); - artist.selected_album_index = next_index; - } - ArtistBlock::RelatedArtists => { - let next_index = common_key_events::on_up_press_handler( - &artist.related_artists, - Some(artist.selected_related_artist_index), - ); - artist.selected_related_artist_index = next_index; - } - ArtistBlock::Empty => {} - } - } -} - -fn handle_up_press_on_hovered_block(app: &mut App) { - if let Some(artist) = &mut app.artist { - match artist.artist_hovered_block { - ArtistBlock::TopTracks => { - artist.artist_hovered_block = ArtistBlock::RelatedArtists; - } - ArtistBlock::Albums => { - artist.artist_hovered_block = ArtistBlock::TopTracks; - } - ArtistBlock::RelatedArtists => { - artist.artist_hovered_block = ArtistBlock::Albums; - } - ArtistBlock::Empty => {} - } - } -} - -fn handle_high_press_on_selected_block(app: &mut App) { - if let Some(artist) = &mut app.artist { - match artist.artist_selected_block { - ArtistBlock::TopTracks => { - let next_index = common_key_events::on_high_press_handler(); - artist.selected_top_track_index = next_index; - } - ArtistBlock::Albums => { - let next_index = common_key_events::on_high_press_handler(); - artist.selected_album_index = next_index; - } - ArtistBlock::RelatedArtists => { - let next_index = common_key_events::on_high_press_handler(); - artist.selected_related_artist_index = next_index; - } - ArtistBlock::Empty => {} - } - } -} - -fn handle_middle_press_on_selected_block(app: &mut App) { - if let Some(artist) = &mut app.artist { - match artist.artist_selected_block { - ArtistBlock::TopTracks => { - let next_index = common_key_events::on_middle_press_handler(&artist.top_tracks); - artist.selected_top_track_index = next_index; - } - ArtistBlock::Albums => { - let next_index = common_key_events::on_middle_press_handler(&artist.albums.items); - artist.selected_album_index = next_index; - } - ArtistBlock::RelatedArtists => { - let next_index = common_key_events::on_middle_press_handler(&artist.related_artists); - artist.selected_related_artist_index = next_index; - } - ArtistBlock::Empty => {} - } - } -} - -fn handle_low_press_on_selected_block(app: &mut App) { - if let Some(artist) = &mut app.artist { - match artist.artist_selected_block { - ArtistBlock::TopTracks => { - let next_index = common_key_events::on_low_press_handler(&artist.top_tracks); - artist.selected_top_track_index = next_index; - } - ArtistBlock::Albums => { - let next_index = common_key_events::on_low_press_handler(&artist.albums.items); - artist.selected_album_index = next_index; - } - ArtistBlock::RelatedArtists => { - let next_index = common_key_events::on_low_press_handler(&artist.related_artists); - artist.selected_related_artist_index = next_index; - } - ArtistBlock::Empty => {} - } - } -} - -/// The top track under the cursor. -fn selected_top_track(artist: &Artist) -> Option { - artist - .top_tracks - .get(artist.selected_top_track_index) - .cloned() -} - -fn top_track_playback_request(artist: &Artist) -> (Vec, Option) { - common_key_events::uri_playback_request( - artist.top_tracks.iter().map(|track| track.uri.clone()), - artist.selected_top_track_index, - ) -} - -/// The album id under the artist page's album cursor. -fn selected_album_id(artist: &Artist) -> Option { - artist - .albums - .items - .get(artist.selected_album_index)? - .id - .clone() -} - -/// `None` when the row is missing or has no id: the related-artists list is -/// often empty while the cursor still reports 0. -fn selected_related_artist(artist: &Artist) -> Option<(String, String)> { - let related = artist - .related_artists - .get(artist.selected_related_artist_index)?; - Some((related.id.clone()?, related.name.clone())) -} - -fn handle_recommend_event_on_selected_block(app: &mut App) { - let Some(artist) = &app.artist else { - return; - }; - match artist.artist_selected_block { - ArtistBlock::TopTracks => { - // The full track: the results table prepends the seed as a context row. - if let Some(track) = selected_top_track(artist) { - app.apply(Action::RecommendFromTrack(track)); - } - } - ArtistBlock::RelatedArtists => { - // ArtistInfo.id is Option; only dispatch if an id is present to - // avoid a seed-less recommendation call that returns garbage results. - if let Some((id, name)) = selected_related_artist(artist) { - app.apply(Action::RecommendFromArtist { id, name }); - } - } - _ => {} - } -} - -fn handle_enter_event_on_selected_block(app: &mut App) { - let Some(artist) = &app.artist else { - return; - }; - match artist.artist_selected_block { - ArtistBlock::TopTracks => { - let (uris, offset) = top_track_playback_request(artist); - app.apply(Action::PlayUris { uris, offset }); - } - ArtistBlock::Albums => { - // GetAlbum fetches a FullAlbum and sets AlbumTableContext::Full — do NOT - // set track_table.context here, as GetAlbum does not use the track table. - if let Some(id) = selected_album_id(artist) { - app.apply(Action::Open(OpenTarget::Album { - id, - from_search: false, - })); - } - } - ArtistBlock::RelatedArtists => { - if let Some((id, name)) = selected_related_artist(artist) { - app.apply(Action::Open(OpenTarget::Artist { id, name })); - } - } - ArtistBlock::Empty => {} - } -} - -fn handle_enter_event_on_hovered_block(app: &mut App) { - if let Some(artist) = &mut app.artist { - match artist.artist_hovered_block { - ArtistBlock::TopTracks => artist.artist_selected_block = ArtistBlock::TopTracks, - ArtistBlock::Albums => artist.artist_selected_block = ArtistBlock::Albums, - ArtistBlock::RelatedArtists => artist.artist_selected_block = ArtistBlock::RelatedArtists, - ArtistBlock::Empty => {} - } - } -} - -pub fn handler(key: Key, app: &mut App) { - if let Some(artist) = &mut app.artist { - match key { - Key::Esc => { - artist.artist_selected_block = ArtistBlock::Empty; - } - k if common_key_events::down_event(k, &app.user_config.keys) => { - if artist.artist_selected_block != ArtistBlock::Empty { - handle_down_press_on_selected_block(app); - } else { - handle_down_press_on_hovered_block(app); - } - } - k if common_key_events::up_event(k, &app.user_config.keys) => { - if artist.artist_selected_block != ArtistBlock::Empty { - handle_up_press_on_selected_block(app); - } else { - handle_up_press_on_hovered_block(app); - } - } - k if common_key_events::left_event(k, &app.user_config.keys) => { - artist.artist_selected_block = ArtistBlock::Empty; - match artist.artist_hovered_block { - ArtistBlock::TopTracks => common_key_events::handle_left_event(app), - ArtistBlock::Albums => { - artist.artist_hovered_block = ArtistBlock::TopTracks; - } - ArtistBlock::RelatedArtists => { - artist.artist_hovered_block = ArtistBlock::Albums; - } - ArtistBlock::Empty => {} - } - } - k if common_key_events::right_event(k, &app.user_config.keys) => { - artist.artist_selected_block = ArtistBlock::Empty; - handle_down_press_on_hovered_block(app); - } - k if common_key_events::high_event(k) - && artist.artist_selected_block != ArtistBlock::Empty => - { - handle_high_press_on_selected_block(app); - } - k if common_key_events::middle_event(k) - && artist.artist_selected_block != ArtistBlock::Empty => - { - handle_middle_press_on_selected_block(app); - } - k if common_key_events::low_event(k) - && artist.artist_selected_block != ArtistBlock::Empty => - { - handle_low_press_on_selected_block(app); - } - Key::Enter => { - if artist.artist_selected_block != ArtistBlock::Empty { - handle_enter_event_on_selected_block(app); - } else { - handle_enter_event_on_hovered_block(app); - } - } - Key::Char('r') if artist.artist_selected_block != ArtistBlock::Empty => { - handle_recommend_event_on_selected_block(app); - } - Key::Char('w') => match artist.artist_selected_block { - ArtistBlock::TopTracks => { - let identity = app - .artist - .as_ref() - .and_then(|artist| artist.top_tracks.get(artist.selected_top_track_index)) - .map(|track| (track.id.clone(), track.name.clone())); - if let Some((track_id, track_name)) = identity { - app.apply(Action::OpenAddTrackDialogFor { - track_id, - track_name, - }); - } - } - ArtistBlock::Albums => { - if let Some(id) = artist_page_album_id(app) { - app.apply(Action::SaveAlbum(id)); - } - } - ArtistBlock::RelatedArtists => { - if let Some((id, _)) = artist_page_related_artist(app) { - app.apply(Action::FollowArtist(id)); - } - } - _ => (), - }, - Key::Char('D') => match artist.artist_selected_block { - ArtistBlock::Albums => { - if let Some(id) = artist_page_album_id(app) { - app.apply(Action::UnsaveAlbum(id)); - } - } - ArtistBlock::RelatedArtists => { - if let Some((id, _)) = artist_page_related_artist(app) { - app.apply(Action::UnfollowArtist(id)); - } - } - _ => (), - }, - _ if key == app.user_config.keys.add_item_to_queue => { - let track = app - .artist - .as_ref() - .filter(|artist| artist.artist_selected_block == ArtistBlock::TopTracks) - .and_then(selected_top_track); - if let Some(track) = track { - app.apply(Action::QueueTrack(track)); - } - } - _ => {} - }; - } -} - -/// Re-read from the whole `App`: the key match's borrow of `app.artist` has -/// ended by the time an arm body runs. -fn artist_page_album_id(app: &App) -> Option { - app.artist.as_ref().and_then(selected_album_id) -} - -fn artist_page_related_artist(app: &App) -> Option<(String, String)> { - app.artist.as_ref().and_then(selected_related_artist) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::app::ActiveBlock; - use crate::core::pagination::Paged; - use crate::core::plugin_api::ArtistInfo; - - fn artist_page(related_artists: Vec) -> Artist { - Artist { - artist_id: "artist1".to_string(), - artist_name: "First".to_string(), - albums: Paged::default(), - related_artists, - top_tracks: vec![], - selected_album_index: 0, - selected_related_artist_index: 0, - selected_top_track_index: 0, - artist_hovered_block: ArtistBlock::TopTracks, - artist_selected_block: ArtistBlock::RelatedArtists, - } - } - - #[test] - fn on_esc() { - let mut app = App::default(); - - handler(Key::Esc, &mut app); - - let current_route = app.get_current_route(); - assert_eq!(current_route.active_block, ActiveBlock::Empty); - } - - #[test] - fn r_on_a_top_track_seeds_recommendations_with_the_track_as_the_context_row() { - use crate::core::plugin_api::TrackInfo; - use crate::core::user_config::UserConfig; - use crate::infra::network::IoEvent; - use std::sync::mpsc::channel; - use std::time::SystemTime; - - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - let mut page = artist_page(vec![]); - page.artist_selected_block = ArtistBlock::TopTracks; - page.top_tracks = vec![TrackInfo { - uri: Some("spotify:track:one".to_string()), - name: "One".to_string(), - artists: vec!["First".to_string()], - album: "Album".to_string(), - duration_ms: 1_000, - id: Some("one".to_string()), - album_id: None, - artist_refs: vec![], - is_playable: true, - is_local: false, - track_number: 0, - explicit: false, - image_url: None, - }]; - app.artist = Some(page); - - handler(Key::Char('r'), &mut app); - - match rx.try_recv() { - Ok(IoEvent::GetRecommendationsForSeed(None, seed_tracks, first_track, _)) => { - assert_eq!(seed_tracks, Some(vec!["spotify:track:one".to_string()])); - assert_eq!( - (*first_track).map(|track| track.name), - Some("One".to_string()) - ); - } - _ => panic!("expected GetRecommendationsForSeed with the top track as first_track"), - } - assert_eq!(app.recommendations_seed, "One"); - } - - #[test] - fn related_artist_lookup_on_an_empty_list_resolves_nothing() { - // An empty related-artists list with the cursor at 0 used to index out of bounds. - assert_eq!(selected_related_artist(&artist_page(vec![])), None); - } - - #[test] - fn related_artist_lookup_returns_the_row_identity() { - let page = artist_page(vec![ArtistInfo { - id: Some("artist2".to_string()), - uri: Some("spotify:artist:artist2".to_string()), - name: "Second".to_string(), - image_url: None, - }]); - - assert_eq!( - selected_related_artist(&page), - Some(("artist2".to_string(), "Second".to_string())) - ); - } - - #[test] - fn related_artist_lookup_without_an_id_resolves_nothing() { - let page = artist_page(vec![ArtistInfo { - id: None, - uri: None, - name: "Unknown".to_string(), - image_url: None, - }]); - - assert_eq!(selected_related_artist(&page), None); - } -} diff --git a/src/tui/handlers/artists.rs b/src/tui/handlers/artists.rs deleted file mode 100644 index c5aaa814..00000000 --- a/src/tui/handlers/artists.rs +++ /dev/null @@ -1,186 +0,0 @@ -use super::common_key_events; -use crate::core::action::{Action, OpenTarget}; -use crate::core::app::App; -use crate::core::plugin_api::ArtistInfo; -use crate::tui::event::Key; - -pub fn handler(key: Key, app: &mut App) { - match key { - k if common_key_events::left_event(k, &app.user_config.keys) => { - common_key_events::handle_left_event(app) - } - k if common_key_events::down_event(k, &app.user_config.keys) => { - if let Some(artists) = &mut app.library.saved_artists.get_results(None) { - let next_index = common_key_events::on_down_press_handler( - &artists.items, - Some(app.view.artists_list_index), - ); - app.view.artists_list_index = next_index; - } - } - k if common_key_events::up_event(k, &app.user_config.keys) => { - if let Some(artists) = &mut app.library.saved_artists.get_results(None) { - let next_index = - common_key_events::on_up_press_handler(&artists.items, Some(app.view.artists_list_index)); - app.view.artists_list_index = next_index; - } - } - k if common_key_events::high_event(k) => { - if let Some(_artists) = &mut app.library.saved_artists.get_results(None) { - let next_index = common_key_events::on_high_press_handler(); - app.view.artists_list_index = next_index; - } - } - k if common_key_events::middle_event(k) => { - if let Some(artists) = &mut app.library.saved_artists.get_results(None) { - let next_index = common_key_events::on_middle_press_handler(&artists.items); - app.view.artists_list_index = next_index; - } - } - k if common_key_events::low_event(k) => { - if let Some(artists) = &mut app.library.saved_artists.get_results(None) { - let next_index = common_key_events::on_low_press_handler(&artists.items); - app.view.artists_list_index = next_index; - } - } - Key::Enter => { - if let Some((id, name)) = selected_saved_artist(app) { - app.apply(Action::Open(OpenTarget::Artist { id, name })); - } - } - Key::Char('D') => { - if let Some((id, _)) = selected_saved_artist(app) { - app.apply(Action::UnfollowArtist(id)); - } - } - Key::Char('e') => { - if let Some(uri) = selected_saved_artist_uri(app) { - app.apply(Action::PlayContext { uri, offset: None }); - } - } - Key::Char('r') => { - if let Some((id, name)) = selected_saved_artist(app) { - app.apply(Action::RecommendFromArtist { id, name }); - } - } - k if k == app.user_config.keys.next_page => app.get_current_user_saved_artists_next(), - k if k == app.user_config.keys.previous_page => app.get_current_user_saved_artists_previous(), - // Open sort menu - Key::Char(',') => { - super::sort_menu::open_sort_menu(app, crate::core::sort::SortContext::SavedArtists); - } - _ => {} - } -} - -/// The row under the cursor; `None` on a missing page. -fn selected_saved_artist_row(app: &App) -> Option<&ArtistInfo> { - app - .library - .saved_artists - .get_results(None)? - .items - .get(app.view.artists_list_index) -} - -/// The (id, name) of the row under the cursor; `None` on an id-less row. -fn selected_saved_artist(app: &App) -> Option<(String, String)> { - let artist = selected_saved_artist_row(app)?; - Some((artist.id.clone()?, artist.name.clone())) -} - -/// The `spotify:artist:` context URI of the row under the cursor. -fn selected_saved_artist_uri(app: &App) -> Option { - selected_saved_artist_row(app)?.uri.clone() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::pagination::CursorPaged; - use crate::core::plugin_api::ArtistInfo; - use crate::core::user_config::UserConfig; - use crate::infra::network::IoEvent; - use std::sync::mpsc::{channel, Receiver}; - use std::time::SystemTime; - - fn app_with_saved_artists() -> (App, Receiver) { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.library.saved_artists.add_pages(CursorPaged { - items: vec![ - ArtistInfo { - id: Some("artist1".to_string()), - uri: Some("spotify:artist:artist1".to_string()), - name: "First".to_string(), - image_url: None, - }, - ArtistInfo { - id: Some("artist2".to_string()), - uri: Some("spotify:artist:artist2".to_string()), - name: "Second".to_string(), - image_url: None, - }, - ], - ..CursorPaged::default() - }); - app.view.artists_list_index = 1; - (app, rx) - } - - #[test] - fn enter_opens_the_selected_artist_by_id() { - let (mut app, rx) = app_with_saved_artists(); - - handler(Key::Enter, &mut app); - - match rx.try_recv() { - Ok(IoEvent::GetArtist(id, name, _)) => { - assert_eq!(id, "artist2"); - assert_eq!(name, "Second"); - } - _ => panic!("expected GetArtist"), - } - } - - #[test] - fn d_unfollows_the_selected_artist() { - let (mut app, rx) = app_with_saved_artists(); - - handler(Key::Char('D'), &mut app); - - match rx.try_recv() { - Ok(IoEvent::UserUnfollowArtists(ids)) => assert_eq!(ids, vec!["artist2".to_string()]), - _ => panic!("expected UserUnfollowArtists"), - } - } - - #[test] - fn e_starts_the_selected_artists_context() { - let (mut app, rx) = app_with_saved_artists(); - - handler(Key::Char('e'), &mut app); - - match rx.try_recv() { - Ok(IoEvent::StartPlayback(context, uris, offset)) => { - assert_eq!(context.as_deref(), Some("spotify:artist:artist2")); - assert!(uris.is_none()); - assert!(offset.is_none()); - } - _ => panic!("expected StartPlayback"), - } - } - - #[test] - fn r_seeds_recommendations_from_the_selected_artist() { - let (mut app, rx) = app_with_saved_artists(); - - handler(Key::Char('r'), &mut app); - - assert_eq!(app.recommendations_seed, "Second"); - assert!( - matches!(rx.try_recv(), Ok(IoEvent::GetRecommendationsForSeed(..))), - "the seeded fetch was dispatched" - ); - } -} diff --git a/src/tui/handlers/common_key_events.rs b/src/tui/handlers/common_key_events.rs deleted file mode 100644 index 5093768b..00000000 --- a/src/tui/handlers/common_key_events.rs +++ /dev/null @@ -1,225 +0,0 @@ -use crate::core::app::{ActiveBlock, App, RouteId}; -use crate::core::user_config::KeyBindings; -use crate::tui::event::Key; - -pub fn down_event(key: Key, keys: &KeyBindings) -> bool { - matches!(key, Key::Down | Key::Ctrl('n')) || key == keys.move_down -} - -pub fn up_event(key: Key, keys: &KeyBindings) -> bool { - matches!(key, Key::Up | Key::Ctrl('p')) || key == keys.move_up -} - -pub fn left_event(key: Key, keys: &KeyBindings) -> bool { - matches!(key, Key::Left | Key::Ctrl('b')) || key == keys.move_left -} - -pub fn right_event(key: Key, keys: &KeyBindings) -> bool { - matches!(key, Key::Right | Key::Ctrl('f')) || key == keys.move_right -} - -pub fn high_event(key: Key) -> bool { - matches!(key, Key::Char('H')) -} - -pub fn middle_event(key: Key) -> bool { - matches!(key, Key::Char('M')) -} - -pub fn low_event(key: Key) -> bool { - matches!(key, Key::Char('L')) -} - -/// Rows without a URI are skipped, so the offset is recomputed over the kept rows. -pub fn uri_playback_request( - uris: impl Iterator>, - selected_index: usize, -) -> (Vec, Option) { - let mut kept = Vec::with_capacity(uris.size_hint().0); - let mut offset = None; - for (row_index, uri) in uris.enumerate() { - if let Some(uri) = uri { - if row_index == selected_index { - offset = Some(kept.len()); - } - kept.push(uri); - } - } - (kept, offset) -} - -pub fn on_down_press_handler(selection_data: &[T], selection_index: Option) -> usize { - match selection_index { - Some(selection_index) => { - if !selection_data.is_empty() { - let next_index = selection_index + 1; - if next_index > selection_data.len() - 1 { - return 0; - } else { - return next_index; - } - } - 0 - } - None => 0, - } -} - -pub fn on_up_press_handler(selection_data: &[T], selection_index: Option) -> usize { - match selection_index { - Some(selection_index) => { - if !selection_data.is_empty() { - if selection_index > 0 { - return selection_index - 1; - } else { - return selection_data.len() - 1; - } - } - 0 - } - None => 0, - } -} - -pub fn on_high_press_handler() -> usize { - 0 -} - -pub fn on_middle_press_handler(selection_data: &[T]) -> usize { - if selection_data.is_empty() { - return 0; - } - - let mut index = selection_data.len() / 2; - if selection_data.len().is_multiple_of(2) { - index -= 1; - } - index -} - -pub fn on_low_press_handler(selection_data: &[T]) -> usize { - selection_data.len().saturating_sub(1) -} - -pub fn content_active_block_for_route(route_id: &RouteId) -> Option { - match route_id { - RouteId::AlbumTracks => Some(ActiveBlock::AlbumTracks), - RouteId::TrackTable | RouteId::Recommendations => Some(ActiveBlock::TrackTable), - RouteId::Podcasts => Some(ActiveBlock::Podcasts), - RouteId::AlbumList => Some(ActiveBlock::AlbumList), - RouteId::PodcastEpisodes => Some(ActiveBlock::EpisodeTable), - RouteId::Discover => Some(ActiveBlock::Discover), - RouteId::Stats => Some(ActiveBlock::Stats), - // Without this the right-arrow from the sidebar silently does nothing: the - // match has a `_ => None` fallthrough, so it compiles either way. - #[cfg(feature = "ai-dj")] - RouteId::AiDj => Some(ActiveBlock::AiDj), - RouteId::Artists => Some(ActiveBlock::Artists), - RouteId::RecentlyPlayed => Some(ActiveBlock::RecentlyPlayed), - RouteId::Search => Some(ActiveBlock::SearchResultBlock), - RouteId::Artist => Some(ActiveBlock::ArtistBlock), - RouteId::Home => Some(ActiveBlock::Home), - _ => None, - } -} - -pub fn handle_right_event(app: &mut App) { - match app.get_current_route().hovered_block { - ActiveBlock::MyPlaylists | ActiveBlock::Library => { - if let Some(active_block) = content_active_block_for_route(&app.get_current_route().id) { - app.set_current_route_state(Some(active_block), Some(active_block)); - } - } - _ => {} - }; -} - -pub fn handle_left_event(app: &mut App) { - // TODO: This should send you back to either library or playlist based on last selection - app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library)); -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::source::Source; - - #[test] - fn test_on_down_press_handler() { - let data = vec!["Choice 1", "Choice 2", "Choice 3"]; - - let index = 0; - let next_index = on_down_press_handler(&data, Some(index)); - - assert_eq!(next_index, 1); - - // Selection wrap if on last item - let index = data.len() - 1; - let next_index = on_down_press_handler(&data, Some(index)); - assert_eq!(next_index, 0); - } - - #[test] - fn test_on_up_press_handler() { - let data = vec!["Choice 1", "Choice 2", "Choice 3"]; - - let index = data.len() - 1; - let next_index = on_up_press_handler(&data, Some(index)); - - assert_eq!(next_index, index - 1); - - // Selection wrap if on first item - let index = 0; - let next_index = on_up_press_handler(&data, Some(index)); - assert_eq!(next_index, data.len() - 1); - } - - #[test] - fn test_on_middle_press_handler_empty() { - let data: Vec<&str> = vec![]; - let next_index = on_middle_press_handler(&data); - assert_eq!(next_index, 0); - } - - #[test] - fn test_on_low_press_handler_empty() { - let data: Vec<&str> = vec![]; - let next_index = on_low_press_handler(&data); - assert_eq!(next_index, 0); - } - - #[test] - fn uri_playback_request_skips_uri_less_rows_and_remaps_the_offset() { - let rows = [Some("spotify:track:one"), None, Some("spotify:track:three")]; - - let (uris, offset) = uri_playback_request(rows.iter().map(|uri| uri.map(str::to_string)), 2); - - assert_eq!(uris, vec!["spotify:track:one", "spotify:track:three"]); - assert_eq!(offset, Some(1), "the skipped row must not shift the offset"); - } - - #[test] - fn uri_playback_request_leaves_the_offset_unset_for_a_uri_less_selection() { - let rows = [Some("spotify:track:one"), None]; - - let (uris, offset) = uri_playback_request(rows.iter().map(|uri| uri.map(str::to_string)), 1); - - assert_eq!(uris, vec!["spotify:track:one"]); - assert_eq!(offset, None); - } - - #[test] - fn left_event_targets_library_for_spotify_and_playlists_for_local() { - let mut app = App::default(); - // Spotify (default): left arrow focuses the Library list. - app.set_current_route_state(Some(ActiveBlock::TrackTable), Some(ActiveBlock::TrackTable)); - handle_left_event(&mut app); - assert_eq!(app.get_current_route().hovered_block, ActiveBlock::Library); - - // Local: the Library list keeps its free rows, so it stays the top block. - app.active_source = Source::Local; - app.set_current_route_state(Some(ActiveBlock::TrackTable), Some(ActiveBlock::TrackTable)); - handle_left_event(&mut app); - assert_eq!(app.get_current_route().hovered_block, ActiveBlock::Library); - } -} diff --git a/src/tui/handlers/community_pin_prompt.rs b/src/tui/handlers/community_pin_prompt.rs deleted file mode 100644 index 4c2464f2..00000000 --- a/src/tui/handlers/community_pin_prompt.rs +++ /dev/null @@ -1,75 +0,0 @@ -use crate::core::action::Action; -use crate::core::app::App; -use crate::tui::event::Key; - -pub fn handler(key: Key, app: &mut App) { - match key { - // Hide the pin: turn the setting off (persisted) and never nag again. - Key::Char('h') | Key::Char('H') => { - app.apply(Action::AnswerCommunityPinPrompt { keep: false }); - } - // Keep it pinned (Enter) or dismiss with Esc: either way, don't ask again. - Key::Enter | Key::Esc => { - app.apply(Action::AnswerCommunityPinPrompt { keep: true }); - } - _ => {} - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::app::{ActiveBlock, RouteId}; - use crate::core::user_config::UserConfigPaths; - - // Keep persistence off the real user dirs by pointing state/config at `dir`, - // which the caller keeps alive for the test's duration. - fn app_with_prompt(dir: &std::path::Path) -> App { - let mut app = App::default(); - app.state_path = Some(dir.join("state.yml")); - app.user_config.path_to_config = Some(UserConfigPaths { - config_file_path: dir.join("config.yml"), - }); - app.push_navigation_stack(RouteId::CommunityPinPrompt, ActiveBlock::CommunityPinPrompt); - app - } - - #[test] - fn hide_disables_toggle_marks_shown_and_pops() { - let dir = tempfile::tempdir().unwrap(); - let mut app = app_with_prompt(dir.path()); - handler(Key::Char('h'), &mut app); - assert!(!app.user_config.behavior.pin_community_playlist); - assert!(app.runtime_state.community_pin_prompt_shown); - assert_ne!( - app.get_current_route().active_block, - ActiveBlock::CommunityPinPrompt - ); - } - - #[test] - fn keep_leaves_toggle_on_marks_shown_and_pops() { - let dir = tempfile::tempdir().unwrap(); - let mut app = app_with_prompt(dir.path()); - handler(Key::Enter, &mut app); - assert!(app.user_config.behavior.pin_community_playlist); - assert!(app.runtime_state.community_pin_prompt_shown); - assert_ne!( - app.get_current_route().active_block, - ActiveBlock::CommunityPinPrompt - ); - } - - #[test] - fn esc_leaves_toggle_on_marks_shown_and_pops() { - let dir = tempfile::tempdir().unwrap(); - let mut app = app_with_prompt(dir.path()); - handler(Key::Esc, &mut app); - assert!(app.user_config.behavior.pin_community_playlist); - assert!(app.runtime_state.community_pin_prompt_shown); - assert_ne!( - app.get_current_route().active_block, - ActiveBlock::CommunityPinPrompt - ); - } -} diff --git a/src/tui/handlers/cover_art_view.rs b/src/tui/handlers/cover_art_view.rs deleted file mode 100644 index 0dcb4852..00000000 --- a/src/tui/handlers/cover_art_view.rs +++ /dev/null @@ -1,71 +0,0 @@ -use crate::core::action::Action; -use crate::core::app::App; -use crate::tui::event::Key; - -pub fn handler(key: Key, app: &mut App) { - match key { - Key::Char('s') => { - app.apply(Action::ToggleSaveCurrentItem); - } - k if k == app.user_config.keys.back => { - app.pop_navigation_stack(); - } - _ => {} - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::test_helpers::full_track; - use crate::core::user_config::UserConfig; - use crate::infra::network::IoEvent; - use rspotify::model::{ - context::{Actions, CurrentPlaybackContext}, - CurrentlyPlayingType, Device, DeviceType, PlayableItem, RepeatState, - }; - use std::sync::mpsc::channel; - use std::time::SystemTime; - - fn playing_track_context() -> CurrentPlaybackContext { - CurrentPlaybackContext { - device: Device { - id: Some("dev-test".to_string()), - is_active: true, - is_private_session: false, - is_restricted: false, - name: "Test Device".to_string(), - _type: DeviceType::Computer, - volume_percent: Some(50), - }, - repeat_state: RepeatState::Off, - shuffle_state: false, - context: None, - timestamp: chrono::Utc::now(), - progress: Some(chrono::Duration::milliseconds(0)), - is_playing: true, - item: Some(PlayableItem::Track(full_track( - "4uLU6hMCjMI75M1A2tKUQC", - "Test Song", - ))), - currently_playing_type: CurrentlyPlayingType::Track, - actions: Actions::default(), - } - } - - #[test] - fn s_toggles_the_playing_track_in_the_library() { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.current_playback_context = Some(playing_track_context()); - - handler(Key::Char('s'), &mut app); - - match rx.try_recv() { - Ok(IoEvent::ToggleSaveTrack(uri)) => { - assert_eq!(uri, "spotify:track:4uLU6hMCjMI75M1A2tKUQC") - } - _ => panic!("expected ToggleSaveTrack for the playing track"), - } - } -} diff --git a/src/tui/handlers/create_playlist.rs b/src/tui/handlers/create_playlist.rs deleted file mode 100644 index a881a03c..00000000 --- a/src/tui/handlers/create_playlist.rs +++ /dev/null @@ -1,289 +0,0 @@ -use crate::core::action::Action; -use crate::core::app::{App, CreatePlaylistFocus, CreatePlaylistStage}; -use crate::tui::event::Key; -use unicode_width::UnicodeWidthChar; - -pub fn handler(key: Key, app: &mut App) { - match app.view.create_playlist_stage { - CreatePlaylistStage::Name => handle_name_stage(key, app), - CreatePlaylistStage::AddTracks => handle_add_tracks_stage(key, app), - } -} - -fn handle_name_stage(key: Key, app: &mut App) { - match key { - Key::Enter => { - let name: String = app.view.create_playlist_name.iter().collect(); - if !name.trim().is_empty() { - // Under the YouTube source the playlist is a local file — create it - // right away; there is no Spotify-search stage (videos are added later - // via search + `w`). - if app.active_source == crate::core::source::Source::YouTube { - app.apply(Action::CreateYouTubePlaylist(name.trim().to_string())); - close_form(app); - return; - } - app.view.create_playlist_stage = CreatePlaylistStage::AddTracks; - app.view.create_playlist_focus = CreatePlaylistFocus::SearchInput; - } - } - Key::Esc => { - close_form(app); - } - Key::Backspace if app.view.create_playlist_name_idx > 0 => { - app.view.create_playlist_name_idx -= 1; - let removed = app - .view - .create_playlist_name - .remove(app.view.create_playlist_name_idx); - let width = removed.width().unwrap_or(1) as u16; - app.view.create_playlist_name_cursor = - app.view.create_playlist_name_cursor.saturating_sub(width); - } - Key::Char(c) => { - app - .view - .create_playlist_name - .insert(app.view.create_playlist_name_idx, c); - app.view.create_playlist_name_idx += 1; - app.view.create_playlist_name_cursor += c.width().unwrap_or(1) as u16; - } - Key::Left if app.view.create_playlist_name_idx > 0 => { - app.view.create_playlist_name_idx -= 1; - let c = app.view.create_playlist_name[app.view.create_playlist_name_idx]; - app.view.create_playlist_name_cursor = app - .view - .create_playlist_name_cursor - .saturating_sub(c.width().unwrap_or(1) as u16); - } - Key::Right if app.view.create_playlist_name_idx < app.view.create_playlist_name.len() => { - let c = app.view.create_playlist_name[app.view.create_playlist_name_idx]; - app.view.create_playlist_name_idx += 1; - app.view.create_playlist_name_cursor += c.width().unwrap_or(1) as u16; - } - _ => {} - } -} - -fn handle_add_tracks_stage(key: Key, app: &mut App) { - match app.view.create_playlist_focus { - CreatePlaylistFocus::SearchInput => handle_search_input(key, app), - CreatePlaylistFocus::SearchResults => handle_results_nav(key, app), - CreatePlaylistFocus::AddedTracks => handle_added_tracks_nav(key, app), - } -} - -fn handle_search_input(key: Key, app: &mut App) { - match key { - Key::Esc => { - close_form(app); - } - Key::Enter => { - let query: String = app.view.create_playlist_search_input.iter().collect(); - if !query.trim().is_empty() { - app.apply(Action::SearchTracksForPlaylist(query)); - app.view.create_playlist_focus = CreatePlaylistFocus::SearchResults; - } - } - Key::Tab => { - if !app.create_playlist_tracks.is_empty() { - app.view.create_playlist_selected_result = 0; - app.view.create_playlist_focus = CreatePlaylistFocus::AddedTracks; - } else if !app.create_playlist_search_results.is_empty() { - app.view.create_playlist_selected_result = 0; - app.view.create_playlist_focus = CreatePlaylistFocus::SearchResults; - } - } - Key::Down if !app.create_playlist_search_results.is_empty() => { - app.view.create_playlist_selected_result = 0; - app.view.create_playlist_focus = CreatePlaylistFocus::SearchResults; - } - Key::Backspace if app.view.create_playlist_search_idx > 0 => { - app.view.create_playlist_search_idx -= 1; - let removed = app - .view - .create_playlist_search_input - .remove(app.view.create_playlist_search_idx); - let width = removed.width().unwrap_or(1) as u16; - app.view.create_playlist_search_cursor = - app.view.create_playlist_search_cursor.saturating_sub(width); - } - Key::Char(c) => { - app - .view - .create_playlist_search_input - .insert(app.view.create_playlist_search_idx, c); - app.view.create_playlist_search_idx += 1; - app.view.create_playlist_search_cursor += c.width().unwrap_or(1) as u16; - } - Key::Left if app.view.create_playlist_search_idx > 0 => { - app.view.create_playlist_search_idx -= 1; - let c = app.view.create_playlist_search_input[app.view.create_playlist_search_idx]; - app.view.create_playlist_search_cursor = app - .view - .create_playlist_search_cursor - .saturating_sub(c.width().unwrap_or(1) as u16); - } - Key::Right - if app.view.create_playlist_search_idx < app.view.create_playlist_search_input.len() => - { - let c = app.view.create_playlist_search_input[app.view.create_playlist_search_idx]; - app.view.create_playlist_search_idx += 1; - app.view.create_playlist_search_cursor += c.width().unwrap_or(1) as u16; - } - _ => {} - } -} - -fn handle_results_nav(key: Key, app: &mut App) { - let count = app.create_playlist_search_results.len(); - match key { - Key::Esc => { - app.view.create_playlist_focus = CreatePlaylistFocus::SearchInput; - } - Key::Up if count > 0 && app.view.create_playlist_selected_result > 0 => { - app.view.create_playlist_selected_result -= 1; - } - Key::Down if count > 0 && app.view.create_playlist_selected_result + 1 < count => { - app.view.create_playlist_selected_result += 1; - } - Key::Enter if count > 0 => { - let idx = app.view.create_playlist_selected_result; - if idx < count { - let track = app.create_playlist_search_results[idx].clone(); - app.create_playlist_tracks.push(track); - } - } - Key::Tab => { - if !app.create_playlist_tracks.is_empty() { - app.view.create_playlist_selected_result = 0; - app.view.create_playlist_focus = CreatePlaylistFocus::AddedTracks; - } else { - app.view.create_playlist_focus = CreatePlaylistFocus::SearchInput; - } - } - _ => {} - } -} - -fn handle_added_tracks_nav(key: Key, app: &mut App) { - let count = app.create_playlist_tracks.len(); - match key { - Key::Esc => { - app.view.create_playlist_focus = CreatePlaylistFocus::SearchInput; - } - Key::Tab => { - app.view.create_playlist_focus = CreatePlaylistFocus::SearchInput; - } - Key::Up if count > 0 && app.view.create_playlist_selected_result > 0 => { - app.view.create_playlist_selected_result -= 1; - } - Key::Down if count > 0 && app.view.create_playlist_selected_result + 1 < count => { - app.view.create_playlist_selected_result += 1; - } - Key::Char('d') if count > 0 => { - let idx = app.view.create_playlist_selected_result; - if idx < count { - app.create_playlist_tracks.remove(idx); - if app.view.create_playlist_selected_result >= app.create_playlist_tracks.len() - && !app.create_playlist_tracks.is_empty() - { - app.view.create_playlist_selected_result = app.create_playlist_tracks.len() - 1; - } - } - } - Key::Enter => { - submit_playlist(app); - } - _ => {} - } -} - -fn submit_playlist(app: &mut App) { - let name: String = app.view.create_playlist_name.iter().collect(); - // Bare base62 ids: the payload the form has always sent. - let track_ids: Vec = app - .create_playlist_tracks - .iter() - .filter_map(|t| t.id.clone()) - .collect(); - - app.apply(Action::CreatePlaylist { - name, - track_uris: track_ids, - }); - close_form(app); -} - -fn close_form(app: &mut App) { - app.pop_navigation_stack(); - app.clear_create_playlist_form_state(); -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::app::{ActiveBlock, RouteId}; - use crate::core::user_config::UserConfig; - use crate::infra::network::IoEvent; - use std::sync::mpsc::{channel, Receiver}; - use std::time::SystemTime; - - fn app_on_the_create_form() -> (App, Receiver) { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.push_navigation_stack(RouteId::CreatePlaylist, ActiveBlock::CreatePlaylistForm); - (app, rx) - } - - #[test] - fn esc_on_the_name_stage_closes_and_resets_the_form() { - let (mut app, rx) = app_on_the_create_form(); - handler(Key::Char('M'), &mut app); - handler(Key::Char('i'), &mut app); - - handler(Key::Esc, &mut app); - - assert!(app.view.create_playlist_name.is_empty()); - assert_eq!(app.view.create_playlist_name_idx, 0); - assert_eq!(app.view.create_playlist_name_cursor, 0); - assert_eq!(app.view.create_playlist_stage, CreatePlaylistStage::Name); - assert_eq!( - app.view.create_playlist_focus, - CreatePlaylistFocus::SearchInput - ); - assert!(app.create_playlist_tracks.is_empty()); - assert!(app.create_playlist_search_results.is_empty()); - assert_eq!(app.get_current_route().id, RouteId::Home); - assert!(rx.try_recv().is_err(), "closing the form asks for nothing"); - } - - #[test] - fn enter_on_a_blank_name_stays_on_the_name_stage() { - let (mut app, rx) = app_on_the_create_form(); - - handler(Key::Enter, &mut app); - - assert_eq!(app.view.create_playlist_stage, CreatePlaylistStage::Name); - assert_eq!(app.get_current_route().id, RouteId::CreatePlaylist); - assert!(rx.try_recv().is_err(), "expected nothing dispatched"); - } - - #[test] - fn enter_on_a_named_form_advances_before_creating_anything() { - let (mut app, rx) = app_on_the_create_form(); - handler(Key::Char('M'), &mut app); - - handler(Key::Enter, &mut app); - - assert_eq!( - app.view.create_playlist_stage, - CreatePlaylistStage::AddTracks - ); - assert_eq!( - app.view.create_playlist_focus, - CreatePlaylistFocus::SearchInput - ); - assert!(rx.try_recv().is_err(), "expected nothing dispatched"); - } -} diff --git a/src/tui/handlers/dialog.rs b/src/tui/handlers/dialog.rs deleted file mode 100644 index 10948ab9..00000000 --- a/src/tui/handlers/dialog.rs +++ /dev/null @@ -1,329 +0,0 @@ -use super::common_key_events; -use crate::core::action::Action; -use crate::core::app::{ActiveBlock, App, DialogContext, PlaylistPickerRow}; -use crate::tui::event::Key; - -pub fn handler(key: Key, app: &mut App) { - let dialog_context = match app.get_current_route().active_block { - ActiveBlock::Dialog(context) => context, - _ => return, - }; - - match dialog_context { - DialogContext::AddTrackToPlaylistPicker => handle_add_to_playlist_picker(key, app), - DialogContext::PlaylistWindow - | DialogContext::PlaylistSearch - | DialogContext::RemoveTrackFromPlaylistConfirm - | DialogContext::PersistKeybindingFallback - | DialogContext::YouTubePlaylistWindow => handle_confirmation_dialog(key, app, dialog_context), - } -} - -fn handle_confirmation_dialog(key: Key, app: &mut App, dialog_context: DialogContext) { - match key { - Key::Enter => { - if app.view.confirm { - match dialog_context { - DialogContext::PlaylistWindow => handle_playlist_dialog(app), - DialogContext::PlaylistSearch => handle_playlist_search_dialog(app), - DialogContext::RemoveTrackFromPlaylistConfirm => { - handle_remove_track_from_playlist_confirm(app); - } - DialogContext::PersistKeybindingFallback => { - app.persist_open_settings_fallback(); - } - DialogContext::YouTubePlaylistWindow => handle_youtube_playlist_dialog(app), - DialogContext::AddTrackToPlaylistPicker => {} - } - } else if dialog_context == DialogContext::PersistKeybindingFallback { - app.set_status_message("Using Alt+, for this session only", 4); - } - close_dialog(app); - } - Key::Char('q') => { - if dialog_context == DialogContext::PersistKeybindingFallback { - app.set_status_message("Using Alt+, for this session only", 4); - } - close_dialog(app); - } - k if common_key_events::right_event(k, &app.user_config.keys) => { - app.view.confirm = !app.view.confirm - } - k if common_key_events::left_event(k, &app.user_config.keys) => { - app.view.confirm = !app.view.confirm - } - _ => {} - } -} - -fn handle_add_to_playlist_picker(key: Key, app: &mut App) { - // Rows follow the active source: local YouTube playlists under YouTube, - // editable Spotify playlists plus navigable folder rows otherwise. - let picker_rows = app.playlist_picker_items(); - let row_count = picker_rows.len(); - match key { - k if common_key_events::down_event(k, &app.user_config.keys) && row_count > 0 => { - let next = common_key_events::on_down_press_handler( - &picker_rows, - Some(app.view.playlist_picker_selected_index), - ); - app.view.playlist_picker_selected_index = next; - } - k if common_key_events::up_event(k, &app.user_config.keys) && row_count > 0 => { - let next = common_key_events::on_up_press_handler( - &picker_rows, - Some(app.view.playlist_picker_selected_index), - ); - app.view.playlist_picker_selected_index = next; - } - k if common_key_events::high_event(k) && row_count > 0 => { - app.view.playlist_picker_selected_index = common_key_events::on_high_press_handler(); - } - k if common_key_events::middle_event(k) && row_count > 0 => { - app.view.playlist_picker_selected_index = - common_key_events::on_middle_press_handler(&picker_rows); - } - k if common_key_events::low_event(k) && row_count > 0 => { - app.view.playlist_picker_selected_index = - common_key_events::on_low_press_handler(&picker_rows); - } - Key::Enter => { - let selected = app - .view - .playlist_picker_selected_index - .min(row_count.saturating_sub(1)); - match picker_rows.get(selected) { - // Descend into (or back out of) the folder; the dialog stays open, - // same semantics as Enter in the sidebar (handlers/playlist.rs). - Some(PlaylistPickerRow::Folder(folder)) => { - let target_id = folder.target_id; - app.view.playlist_picker_folder_id = target_id; - app.view.playlist_picker_selected_index = 0; - } - Some(PlaylistPickerRow::Playlist(playlist)) => { - // `id` stays the presence guard; the payload is the URI, which carries the source. - let playlist_uri = playlist.id.is_some().then(|| playlist.uri.clone()); - let track = app - .pending_playlist_track_add - .as_ref() - .map(|pending| pending.track_id.clone()); - if let (Some(playlist), Some(track)) = (playlist_uri, track) { - app.apply(Action::AddTrackToPlaylist { playlist, track }); - } - close_dialog(app); - } - None => close_dialog(app), - } - } - Key::Char('q') => { - close_dialog(app); - } - _ => {} - } -} - -fn handle_playlist_dialog(app: &mut App) { - if let Some(playlist_id) = app.selected_sidebar_playlist_id() { - app.apply(Action::UnfollowPlaylist(playlist_id)); - } -} - -fn handle_playlist_search_dialog(app: &mut App) { - if let Some(playlist_id) = app.selected_search_result_playlist_id() { - app.apply(Action::UnfollowPlaylist(playlist_id)); - } -} - -/// Confirmed deletion of the sidebar-selected local YouTube playlist. -fn handle_youtube_playlist_dialog(app: &mut App) { - let uri = app - .view - .selected_playlist_index - .and_then(|idx| app.youtube_playlists.get(idx)) - .map(|playlist| playlist.uri.clone()); - if let Some(uri) = uri { - app.apply(Action::DeletePlaylist(uri)); - } -} - -fn handle_remove_track_from_playlist_confirm(app: &mut App) { - if let Some(pending_remove) = app.pending_playlist_track_removal.clone() { - app.apply(Action::RemoveTrackFromPlaylist { - playlist: pending_remove.playlist_id, - track: pending_remove.track_id, - position: pending_remove.position, - }); - } -} - -fn close_dialog(app: &mut App) { - app.pop_navigation_stack(); - app.clear_dialog_state(); -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::{ - app::{PendingPlaylistTrackAdd, PlaylistFolder, PlaylistFolderItem, RouteId}, - pagination::Paged, - test_helpers::{playlist_info, user_info}, - user_config::UserConfig, - }; - use crate::infra::network::IoEvent; - use std::{sync::mpsc::channel, time::SystemTime}; - - #[test] - fn confirmation_dialog_toggles_with_vim_hl() { - let mut app = App::default(); - app.push_navigation_stack( - RouteId::Dialog, - ActiveBlock::Dialog(DialogContext::RemoveTrackFromPlaylistConfirm), - ); - app.view.confirm = false; - - handler(Key::Char('l'), &mut app); - assert!(app.view.confirm); - - handler(Key::Char('h'), &mut app); - assert!(!app.view.confirm); - } - - #[test] - fn add_to_playlist_picker_dispatches_selected_editable_playlist() { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.user = Some(user_info("spotatui-owner")); - app.playlists = Some(Paged { - total: 3, - ..Default::default() - }); - app.all_playlists = vec![ - playlist_info("37i9dQZF1DWZqd5JICZI0u", "Followed", "friend-owner", false), - playlist_info("37i9dQZF1DXcBWIGoYBM5M", "Owned", "spotatui-owner", false), - playlist_info( - "37i9dQZF1DX4WYpdgoIcn6", - "Collaborative", - "friend-owner", - true, - ), - ]; - app.pending_playlist_track_add = Some(PendingPlaylistTrackAdd { - track_id: "0000000000000000000001".to_string(), - track_name: "Track".to_string(), - }); - app.push_navigation_stack( - RouteId::Dialog, - ActiveBlock::Dialog(DialogContext::AddTrackToPlaylistPicker), - ); - app.view.playlist_picker_selected_index = 0; - - handler(Key::Enter, &mut app); - - match rx.recv().unwrap() { - IoEvent::AddTrackToPlaylist(playlist_id, track_id) => { - assert_eq!(playlist_id, "spotify:playlist:37i9dQZF1DXcBWIGoYBM5M"); - assert_eq!(track_id, "0000000000000000000001"); - } - _ => panic!("expected add-track event"), - } - } - - #[test] - fn add_to_playlist_picker_navigates_folders_and_filters_uneditable() { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.user = Some(user_info("spotatui-owner")); - app.all_playlists = vec![ - playlist_info("37i9dQZF1DXcBWIGoYBM5M", "Owned", "spotatui-owner", false), - playlist_info("37i9dQZF1DWZqd5JICZI0u", "Followed", "friend-owner", false), - ]; - app.playlist_folder_items = vec![ - PlaylistFolderItem::Folder(PlaylistFolder { - name: "Mixes".to_string(), - current_id: 0, - target_id: 1, - }), - PlaylistFolderItem::Folder(PlaylistFolder { - name: "\u{2190} Mixes".to_string(), - current_id: 1, - target_id: 0, - }), - PlaylistFolderItem::Playlist { - index: 0, - current_id: 1, - }, - PlaylistFolderItem::Playlist { - index: 1, - current_id: 0, - }, - ]; - app.pending_playlist_track_add = Some(PendingPlaylistTrackAdd { - track_id: "0000000000000000000001".to_string(), - track_name: "Track".to_string(), - }); - app.push_navigation_stack( - RouteId::Dialog, - ActiveBlock::Dialog(DialogContext::AddTrackToPlaylistPicker), - ); - - // Root shows only the folder row: "Followed" is not editable. - assert_eq!(app.playlist_picker_items().len(), 1); - - // Enter on the folder descends, keeps the dialog open, dispatches nothing. - handler(Key::Enter, &mut app); - assert_eq!(app.view.playlist_picker_folder_id, 1); - assert_eq!(app.view.playlist_picker_selected_index, 0); - assert!(matches!( - app.get_current_route().active_block, - ActiveBlock::Dialog(DialogContext::AddTrackToPlaylistPicker) - )); - assert!(rx.try_recv().is_err()); - - // Inside the folder: back row + the owned playlist. Enter adds the track. - assert_eq!(app.playlist_picker_items().len(), 2); - app.view.playlist_picker_selected_index = 1; - handler(Key::Enter, &mut app); - match rx.recv().unwrap() { - IoEvent::AddTrackToPlaylist(playlist_id, track_id) => { - assert_eq!(playlist_id, "spotify:playlist:37i9dQZF1DXcBWIGoYBM5M"); - assert_eq!(track_id, "0000000000000000000001"); - } - _ => panic!("expected add-track event"), - } - } - - #[test] - fn picker_rows_respect_group_folders_first() { - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.user = Some(user_info("spotatui-owner")); - app.all_playlists = vec![playlist_info( - "37i9dQZF1DXcBWIGoYBM5M", - "Owned", - "spotatui-owner", - false, - )]; - app.playlist_folder_items = vec![ - PlaylistFolderItem::Playlist { - index: 0, - current_id: 0, - }, - PlaylistFolderItem::Folder(PlaylistFolder { - name: "Mixes".to_string(), - current_id: 0, - target_id: 1, - }), - ]; - - assert!(matches!( - app.playlist_picker_items()[0], - PlaylistPickerRow::Playlist(_) - )); - app.user_config.behavior.group_folders_first = true; - assert!(matches!( - app.playlist_picker_items()[0], - PlaylistPickerRow::Folder(_) - )); - } -} diff --git a/src/tui/handlers/discover.rs b/src/tui/handlers/discover.rs deleted file mode 100644 index 9cc93fa9..00000000 --- a/src/tui/handlers/discover.rs +++ /dev/null @@ -1,158 +0,0 @@ -use super::common_key_events; -use crate::core::action::{Action, DiscoverTarget}; -use crate::core::app::App; -use crate::tui::event::Key; - -const DISCOVER_OPTIONS_COUNT: usize = 2; - -pub fn handler(key: Key, app: &mut App) { - match key { - k if common_key_events::left_event(k, &app.user_config.keys) => { - common_key_events::handle_left_event(app) - } - k if common_key_events::down_event(k, &app.user_config.keys) => { - let next_index = if app.view.discover_selected_index >= DISCOVER_OPTIONS_COUNT - 1 { - 0 - } else { - app.view.discover_selected_index + 1 - }; - app.view.discover_selected_index = next_index; - } - k if common_key_events::up_event(k, &app.user_config.keys) => { - let next_index = if app.view.discover_selected_index == 0 { - DISCOVER_OPTIONS_COUNT - 1 - } else { - app.view.discover_selected_index - 1 - }; - app.view.discover_selected_index = next_index; - } - // Left/Right to cycle time range (for Top Tracks) - k if common_key_events::right_event(k, &app.user_config.keys) - && app.view.discover_selected_index == 1 => - { - // Only cycle time range when Top Tracks is selected - app.view.discover_time_range = app.view.discover_time_range.next(); - // Clear cache so it refetches with new time range - app.discover_top_tracks.clear(); - } - Key::Char('[') if app.view.discover_selected_index == 1 => { - app.view.discover_time_range = app.view.discover_time_range.prev(); - app.discover_top_tracks.clear(); - } - Key::Char(']') if app.view.discover_selected_index == 1 => { - app.view.discover_time_range = app.view.discover_time_range.next(); - app.discover_top_tracks.clear(); - } - Key::Enter => { - let target = match app.view.discover_selected_index { - 0 => DiscoverTarget::ArtistsMix, - 1 => DiscoverTarget::TopTracks(app.view.discover_time_range), - _ => return, - }; - app.apply(Action::OpenDiscover(target)); - } - _ if key == app.user_config.keys.add_item_to_queue => { - // Add the first track from the selected discover list to the queue. - let track = match app.view.discover_selected_index { - 0 => app.discover_artists_mix.first().cloned(), - 1 => app.discover_top_tracks.first().cloned(), - _ => None, - }; - if let Some(track) = track { - app.apply(Action::QueueTrack(track)); - } - } - _ => {} - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::app::DiscoverTimeRange; - use crate::core::plugin_api::TrackInfo; - use crate::core::user_config::UserConfig; - use crate::infra::network::IoEvent; - use std::sync::mpsc::{channel, Receiver}; - use std::time::SystemTime; - - fn app_with_channel() -> (App, Receiver) { - let (tx, rx) = channel(); - let app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - (app, rx) - } - - fn top_track() -> TrackInfo { - TrackInfo { - uri: Some("spotify:track:one".to_string()), - name: "One".to_string(), - artists: vec!["Artist".to_string()], - album: "Album".to_string(), - duration_ms: 1_000, - id: None, - album_id: None, - artist_refs: vec![], - is_playable: true, - is_local: false, - track_number: 0, - explicit: false, - image_url: None, - } - } - - #[test] - fn enter_on_top_tracks_fetches_with_the_cycled_time_range() { - let (mut app, rx) = app_with_channel(); - app.view.discover_selected_index = 1; - - handler(Key::Char(']'), &mut app); - handler(Key::Enter, &mut app); - - assert_eq!(app.view.discover_time_range, DiscoverTimeRange::Long); - assert!( - matches!( - rx.try_recv(), - Ok(IoEvent::GetUserTopTracks(range)) if range == DiscoverTimeRange::Long - ), - "the empty cache triggered a fetch for the cycled range" - ); - } - - #[test] - fn cycling_the_time_range_invalidates_the_top_tracks_cache() { - let (mut app, _rx) = app_with_channel(); - app.view.discover_selected_index = 1; - app.discover_top_tracks = vec![top_track()]; - - handler(Key::Char(']'), &mut app); - - assert_eq!(app.view.discover_time_range, DiscoverTimeRange::Long); - assert!( - app.discover_top_tracks.is_empty(), - "a new time range invalidates the cached page" - ); - } - - #[test] - fn time_range_keys_do_nothing_on_the_artists_mix_row() { - let (mut app, _rx) = app_with_channel(); - let before = app.view.discover_time_range; - - handler(Key::Char('['), &mut app); - handler(Key::Char(']'), &mut app); - - assert_eq!(app.view.discover_selected_index, 0); - assert_eq!(app.view.discover_time_range, before); - } - - #[test] - fn down_wraps_across_the_two_rows() { - let (mut app, _rx) = app_with_channel(); - - handler(Key::Down, &mut app); - assert_eq!(app.view.discover_selected_index, 1); - - handler(Key::Down, &mut app); - assert_eq!(app.view.discover_selected_index, 0); - } -} diff --git a/src/tui/handlers/empty.rs b/src/tui/handlers/empty.rs deleted file mode 100644 index ddfc5ef4..00000000 --- a/src/tui/handlers/empty.rs +++ /dev/null @@ -1,206 +0,0 @@ -use super::common_key_events; -use crate::{ - core::app::{ActiveBlock, App}, - tui::event::Key, -}; - -// When no block is actively selected, just handle regular event -pub fn handler(key: Key, app: &mut App) { - match key { - Key::Enter => { - let current_hovered = app.get_current_route().hovered_block; - app.set_current_route_state(Some(current_hovered), None); - } - k if common_key_events::down_event(k, &app.user_config.keys) => { - match app.get_current_route().hovered_block { - ActiveBlock::Library => { - app.set_current_route_state(None, Some(ActiveBlock::MyPlaylists)); - } - ActiveBlock::ArtistBlock - | ActiveBlock::AlbumList - | ActiveBlock::AlbumTracks - | ActiveBlock::Artists - | ActiveBlock::Podcasts - | ActiveBlock::EpisodeTable - | ActiveBlock::Home - | ActiveBlock::Discover - | ActiveBlock::MyPlaylists - | ActiveBlock::RecentlyPlayed - | ActiveBlock::TrackTable => { - app.set_current_route_state(None, Some(ActiveBlock::PlayBar)); - } - _ => {} - } - } - k if common_key_events::up_event(k, &app.user_config.keys) => { - match app.get_current_route().hovered_block { - ActiveBlock::MyPlaylists => { - app.set_current_route_state(None, Some(ActiveBlock::Library)); - } - ActiveBlock::PlayBar => { - app.set_current_route_state(None, Some(ActiveBlock::MyPlaylists)); - } - _ => {} - } - } - k if common_key_events::left_event(k, &app.user_config.keys) => { - match app.get_current_route().hovered_block { - ActiveBlock::ArtistBlock - | ActiveBlock::AlbumList - | ActiveBlock::AlbumTracks - | ActiveBlock::Artists - | ActiveBlock::Podcasts - | ActiveBlock::EpisodeTable - | ActiveBlock::Home - | ActiveBlock::Discover - | ActiveBlock::RecentlyPlayed - | ActiveBlock::TrackTable => { - app.set_current_route_state(None, Some(ActiveBlock::Library)); - } - _ => {} - } - } - k if common_key_events::right_event(k, &app.user_config.keys) => { - common_key_events::handle_right_event(app) - } - Key::Char('w') if app.get_current_route().hovered_block == ActiveBlock::PlayBar => { - super::playbar::handler(key, app); - } - _ => (), - }; -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::app::RouteId; - use crate::core::source::Source; - - #[test] - fn up_and_left_reach_the_library_under_a_free_source() { - let mut app = App::default().under_source(Source::Local); - app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::MyPlaylists)); - - handler(Key::Up, &mut app); - assert_eq!(app.get_current_route().hovered_block, ActiveBlock::Library); - - app.set_current_route_state(None, Some(ActiveBlock::AlbumTracks)); - handler(Key::Left, &mut app); - assert_eq!(app.get_current_route().hovered_block, ActiveBlock::Library); - } - - #[test] - fn on_enter() { - let mut app = App::default(); - - app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library)); - - handler(Key::Enter, &mut app); - let current_route = app.get_current_route(); - - assert_eq!(current_route.active_block, ActiveBlock::Library); - assert_eq!(current_route.hovered_block, ActiveBlock::Library); - } - - #[test] - fn on_down_press() { - let mut app = App::default(); - - app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library)); - - handler(Key::Down, &mut app); - let current_route = app.get_current_route(); - - assert_eq!(current_route.active_block, ActiveBlock::Empty); - assert_eq!(current_route.hovered_block, ActiveBlock::MyPlaylists); - - // TODO: test the other cases when they are implemented - } - - #[test] - fn on_up_press() { - let mut app = App::default(); - - app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::MyPlaylists)); - - handler(Key::Up, &mut app); - let current_route = app.get_current_route(); - - assert_eq!(current_route.active_block, ActiveBlock::Empty); - assert_eq!(current_route.hovered_block, ActiveBlock::Library); - } - - #[test] - fn on_left_press() { - let mut app = App::default(); - app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::AlbumTracks)); - - handler(Key::Left, &mut app); - let current_route = app.get_current_route(); - assert_eq!(current_route.active_block, ActiveBlock::Empty); - assert_eq!(current_route.hovered_block, ActiveBlock::Library); - - app.set_current_route_state(None, Some(ActiveBlock::Home)); - handler(Key::Left, &mut app); - let current_route = app.get_current_route(); - assert_eq!(current_route.hovered_block, ActiveBlock::Library); - - app.set_current_route_state(None, Some(ActiveBlock::TrackTable)); - handler(Key::Left, &mut app); - let current_route = app.get_current_route(); - assert_eq!(current_route.hovered_block, ActiveBlock::Library); - } - - #[test] - fn on_right_press() { - let mut app = App::default(); - - app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library)); - app.push_navigation_stack(RouteId::AlbumTracks, ActiveBlock::AlbumTracks); - handler(Key::Right, &mut app); - let current_route = app.get_current_route(); - - assert_eq!(current_route.active_block, ActiveBlock::AlbumTracks); - assert_eq!(current_route.hovered_block, ActiveBlock::AlbumTracks); - - app.push_navigation_stack(RouteId::Search, ActiveBlock::Empty); - app.set_current_route_state(None, Some(ActiveBlock::MyPlaylists)); - handler(Key::Right, &mut app); - let current_route = app.get_current_route(); - - assert_eq!(current_route.active_block, ActiveBlock::SearchResultBlock); - assert_eq!(current_route.hovered_block, ActiveBlock::SearchResultBlock); - - app.set_current_route_state(None, Some(ActiveBlock::Library)); - app.push_navigation_stack(RouteId::TrackTable, ActiveBlock::TrackTable); - handler(Key::Right, &mut app); - let current_route = app.get_current_route(); - - assert_eq!(current_route.active_block, ActiveBlock::TrackTable); - assert_eq!(current_route.hovered_block, ActiveBlock::TrackTable); - - app.set_current_route_state(None, Some(ActiveBlock::Library)); - app.push_navigation_stack(RouteId::TrackTable, ActiveBlock::TrackTable); - handler(Key::Right, &mut app); - let current_route = app.get_current_route(); - assert_eq!(current_route.active_block, ActiveBlock::TrackTable); - assert_eq!(current_route.hovered_block, ActiveBlock::TrackTable); - - app.push_navigation_stack(RouteId::Home, ActiveBlock::Home); - app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library)); - handler(Key::Right, &mut app); - let current_route = app.get_current_route(); - assert_eq!(current_route.active_block, ActiveBlock::Home); - assert_eq!(current_route.hovered_block, ActiveBlock::Home); - } - - #[test] - fn on_w_press_over_playbar_adds_current_track() { - let mut app = App::default(); - app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::PlayBar)); - - handler(Key::Char('w'), &mut app); - - assert_eq!(app.status_message(), Some("No track currently playing")); - } -} diff --git a/src/tui/handlers/episode_table.rs b/src/tui/handlers/episode_table.rs deleted file mode 100644 index c1ff5846..00000000 --- a/src/tui/handlers/episode_table.rs +++ /dev/null @@ -1,220 +0,0 @@ -use super::common_key_events; -use crate::core::action::{Action, ListTarget}; -use crate::core::app::App; -use crate::tui::event::Key; - -pub fn handler(key: Key, app: &mut App) { - match key { - k if common_key_events::left_event(k, &app.user_config.keys) => { - common_key_events::handle_left_event(app) - } - k if common_key_events::down_event(k, &app.user_config.keys) => { - if let Some(episodes) = &mut app.library.show_episodes.get_results(None) { - let next_index = common_key_events::on_down_press_handler( - &episodes.items, - Some(app.view.episode_list_index), - ); - app.view.episode_list_index = next_index; - } - } - k if common_key_events::up_event(k, &app.user_config.keys) => { - if let Some(episodes) = &mut app.library.show_episodes.get_results(None) { - let next_index = common_key_events::on_up_press_handler( - &episodes.items, - Some(app.view.episode_list_index), - ); - app.view.episode_list_index = next_index; - } - } - k if common_key_events::high_event(k) => { - if let Some(_episodes) = app.library.show_episodes.get_results(None) { - let next_index = common_key_events::on_high_press_handler(); - app.view.episode_list_index = next_index; - } - } - k if common_key_events::middle_event(k) => { - if let Some(episodes) = app.library.show_episodes.get_results(None) { - let next_index = common_key_events::on_middle_press_handler(&episodes.items); - app.view.episode_list_index = next_index; - } - } - k if common_key_events::low_event(k) => { - if let Some(episodes) = app.library.show_episodes.get_results(None) { - let next_index = common_key_events::on_low_press_handler(&episodes.items); - app.view.episode_list_index = next_index; - } - } - Key::Enter => { - on_enter(app); - } - // Scroll down - k if k == app.user_config.keys.next_page => handle_next_event(app), - // Scroll up - k if k == app.user_config.keys.previous_page => handle_prev_event(app), - Key::Char('S') => toggle_sort_by_date(app), - Key::Char('s') => handle_follow_event(app), - Key::Char('D') => handle_unfollow_event(app), - Key::Ctrl('e') => jump_to_end(app), - Key::Ctrl('a') => jump_to_start(app), - _ => {} - } -} - -fn jump_to_end(app: &mut App) { - if let Some(episodes) = app.library.show_episodes.get_results(None) { - let last_idx = episodes.items.len() - 1; - app.view.episode_list_index = last_idx; - } -} - -fn on_enter(app: &mut App) { - let selected_index = app.view.episode_list_index; - let request = app.library.show_episodes.get_results(None).map(|episodes| { - common_key_events::uri_playback_request( - episodes.items.iter().map(|episode| episode.uri.clone()), - selected_index, - ) - }); - if let Some((uris, offset)) = request { - app.apply(Action::PlayUris { uris, offset }); - } -} - -fn handle_prev_event(app: &mut App) { - app.get_episode_table_previous(); -} - -fn handle_next_event(app: &mut App) { - app.apply(Action::LoadMore(ListTarget::ShowEpisodes)); -} - -fn handle_follow_event(app: &mut App) { - if let Some(show_id) = app.selected_episode_show_id() { - app.apply(Action::SaveShow(show_id)); - } -} - -fn handle_unfollow_event(app: &mut App) { - if let Some(show_id) = app.selected_episode_show_id() { - app.apply(Action::UnsaveShow(show_id)); - } -} - -fn jump_to_start(app: &mut App) { - app.view.episode_list_index = 0; -} - -fn toggle_sort_by_date(app: &mut App) { - //TODO: reverse whole list and not just currently visible episodes - let selected_id = match app.library.show_episodes.get_results(None) { - Some(episodes) => episodes - .items - .get(app.view.episode_list_index) - .map(|e| e.id.clone()), - None => None, - }; - - if let Some(episodes) = app.library.show_episodes.get_mut_results(None) { - episodes.items.reverse(); - } - - if let Some(id) = selected_id { - if let Some(episodes) = app.library.show_episodes.get_results(None) { - app.view.episode_list_index = episodes.items.iter().position(|e| e.id == id).unwrap_or(0); - } - } else { - app.view.episode_list_index = 0; - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::pagination::Paged; - use crate::core::plugin_api::EpisodeInfo; - use crate::core::user_config::UserConfig; - use crate::infra::network::IoEvent; - use std::sync::mpsc::{channel, Receiver}; - use std::time::SystemTime; - - fn episode(uri: Option<&str>, name: &str) -> EpisodeInfo { - EpisodeInfo { - id: Some(name.to_string()), - uri: uri.map(|u| u.to_string()), - name: name.to_string(), - duration_ms: 1_000, - show_name: String::new(), - description: String::new(), - release_date: String::new(), - is_playable: true, - resume_point: None, - image_url: None, - } - } - - fn app_with_episodes(episodes: Vec) -> (App, Receiver) { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - let limit = episodes.len() as u32; - app.library.show_episodes.add_pages(Paged { - items: episodes, - offset: 0, - limit, - total: limit, - next: None, - previous: None, - }); - (app, rx) - } - - #[test] - fn enter_starts_the_page() { - let (mut app, rx) = app_with_episodes(vec![episode(Some("spotify:episode:one"), "One")]); - - handler(Key::Enter, &mut app); - - assert!( - matches!( - rx.try_recv(), - Ok(IoEvent::StartPlayback(None, Some(uris), _)) if uris == vec!["spotify:episode:one".to_string()] - ), - "Enter started the episode list" - ); - } - - #[test] - fn shift_d_without_an_open_show_dispatches_nothing() { - let (mut app, rx) = app_with_episodes(vec![episode(Some("spotify:episode:one"), "One")]); - - handler(Key::Char('D'), &mut app); - - assert!(rx.try_recv().is_err(), "no show is open to unfollow"); - } - - #[test] - fn shift_s_reverses_the_page_and_keeps_the_selected_episode() { - let (mut app, _rx) = app_with_episodes(vec![ - episode(Some("spotify:episode:one"), "One"), - episode(Some("spotify:episode:two"), "Two"), - episode(Some("spotify:episode:three"), "Three"), - ]); - app.view.episode_list_index = 0; - - handler(Key::Char('S'), &mut app); - - let names: Vec<&str> = app - .library - .show_episodes - .get_results(None) - .unwrap() - .items - .iter() - .map(|e| e.name.as_str()) - .collect(); - assert_eq!(names, vec!["Three", "Two", "One"]); - assert_eq!( - app.view.episode_list_index, 2, - "the cursor follows the episode it was on" - ); - } -} diff --git a/src/tui/handlers/error_screen.rs b/src/tui/handlers/error_screen.rs deleted file mode 100644 index 43382cb7..00000000 --- a/src/tui/handlers/error_screen.rs +++ /dev/null @@ -1,3 +0,0 @@ -use crate::{core::app::App, tui::event::Key}; - -pub fn handler(_key: Key, _app: &mut App) {} diff --git a/src/tui/handlers/filter_input.rs b/src/tui/handlers/filter_input.rs deleted file mode 100644 index f5b67648..00000000 --- a/src/tui/handlers/filter_input.rs +++ /dev/null @@ -1,95 +0,0 @@ -//! Key handling shared by the inline filter inputs (Help rows, Settings rows). -//! -//! Both screens open a filter with `keys.search` and then edit it with the same -//! keys, so the buffer edits live here once. What a screen does *around* the -//! edit - which rows to recompute, where to move the selection - stays in that -//! screen's handler, which is why this returns an outcome instead of touching -//! `App`. - -use crate::tui::event::Key; - -/// What one key press did to a filter buffer. -#[derive(Clone, Copy, PartialEq, Debug)] -pub(super) enum FilterEdit { - /// Esc: the screen should abandon the filter. - Cancel, - /// Enter: the screen should keep the filter but stop editing it. - Confirm, - /// The buffer changed; the screen should refresh its filtered view. - Changed, - /// Not a key this input consumes. - Ignored, -} - -/// Apply `key` to `filter` and report what the screen has to do next. -pub(super) fn apply(key: Key, filter: &mut String) -> FilterEdit { - match key { - Key::Esc => FilterEdit::Cancel, - Key::Enter => FilterEdit::Confirm, - Key::Backspace | Key::Ctrl('h') => { - filter.pop(); - FilterEdit::Changed - } - Key::Ctrl('u') | Key::Ctrl('l') => { - filter.clear(); - FilterEdit::Changed - } - Key::Ctrl('w') => { - delete_last_word(filter); - FilterEdit::Changed - } - Key::Char(c) => { - filter.push(c); - FilterEdit::Changed - } - _ => FilterEdit::Ignored, - } -} - -fn delete_last_word(filter: &mut String) { - while filter.ends_with(char::is_whitespace) { - filter.pop(); - } - while filter - .chars() - .next_back() - .is_some_and(|c| !c.is_whitespace()) - { - filter.pop(); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn after(start: &str, keys: &[Key]) -> String { - let mut filter = start.to_string(); - for key in keys { - apply(*key, &mut filter); - } - filter - } - - #[test] - fn editing_keys_change_the_buffer() { - assert_eq!(after("", &[Key::Char('v'), Key::Char('o')]), "vo"); - assert_eq!(after("vol", &[Key::Backspace]), "vo"); - assert_eq!(after("vol", &[Key::Ctrl('h')]), "vo"); - assert_eq!(after("", &[Key::Backspace]), ""); - assert_eq!(after("volume", &[Key::Ctrl('u')]), ""); - assert_eq!(after("volume", &[Key::Ctrl('l')]), ""); - // Ctrl+W drops the trailing word along with the whitespace after it. - assert_eq!(after("volume inc", &[Key::Ctrl('w')]), "volume "); - assert_eq!(after("volume ", &[Key::Ctrl('w')]), ""); - } - - #[test] - fn esc_enter_and_unrelated_keys_report_without_touching_the_buffer() { - let mut filter = String::from("volume"); - assert_eq!(apply(Key::Esc, &mut filter), FilterEdit::Cancel); - assert_eq!(apply(Key::Enter, &mut filter), FilterEdit::Confirm); - assert_eq!(apply(Key::Tab, &mut filter), FilterEdit::Ignored); - assert_eq!(filter, "volume"); - } -} diff --git a/src/tui/handlers/friends.rs b/src/tui/handlers/friends.rs deleted file mode 100644 index b7ee1ae5..00000000 --- a/src/tui/handlers/friends.rs +++ /dev/null @@ -1,259 +0,0 @@ -use super::common_key_events; -use crate::core::action::Action; -use crate::core::app::{App, FriendAddMode, FriendFilter}; -use crate::tui::event::Key; -use crate::tui::ui::friends::filtered_friends; - -pub fn handler(key: Key, app: &mut App) { - // When the add-friend dialog is open, route all keys there. - if app.view.friend_add_dialog_visible { - handle_add_dialog(key, app); - return; - } - - // When the search input has focus (non-empty), handle character input inline. - if !app.view.friend_search_input.is_empty() { - match key { - Key::Esc => { - // Clear search and return focus to the list - app.view.friend_search_input.clear(); - return; - } - Key::Backspace => { - app.view.friend_search_input.pop(); - // Reset selected index when the list changes - app.view.friend_selected_index = 0; - return; - } - Key::Char(c) if c != '\n' => { - app.view.friend_search_input.push(c); - app.view.friend_selected_index = 0; - return; - } - _ => {} - } - } - - match key { - // Navigation - k if common_key_events::down_event(k, &app.user_config.keys) => move_down(app), - k if common_key_events::up_event(k, &app.user_config.keys) => move_up(app), - k if common_key_events::high_event(k) => app.view.friend_selected_index = 0, - k if common_key_events::low_event(k) => { - let count = filtered_count(app); - if count > 0 { - app.view.friend_selected_index = count - 1; - } - } - - // Copy own friend code to clipboard - Key::Char('c') => app.copy_friend_code(), - - // Open add-friend dialog - Key::Char('a') => app.open_friend_add_dialog(), - - // Unfollow selected friend (no confirm for now — status message acts as feedback) - Key::Char('u') => unfollow_selected(app), - - // Tab: cycle between All / Online filter - Key::Tab => { - app.view.friend_filter = match app.view.friend_filter { - FriendFilter::All => FriendFilter::Online, - FriendFilter::Online => FriendFilter::All, - }; - app.view.friend_selected_index = 0; - } - - // Type directly into search when idle (any unbound character filters the list) - Key::Char(c) if c != '\n' => { - app.view.friend_search_input.push(c); - app.view.friend_selected_index = 0; - } - - // Backspace clears last search character - Key::Backspace if !app.view.friend_search_input.is_empty() => { - app.view.friend_search_input.pop(); - app.view.friend_selected_index = 0; - } - - // Esc: pop navigation (handled upstream, but guard in case) - Key::Esc => { - app.view.friend_search_input.clear(); - app.pop_navigation_stack(); - } - - _ => {} - } -} - -// ── Add-friend dialog handler ───────────────────────────────────────────────── - -fn handle_add_dialog(key: Key, app: &mut App) { - match key { - // Close dialog - Key::Esc => close_dialog(app), - - // Switch between Code / Search tabs - Key::Tab => { - app.view.friend_add_mode = match app.view.friend_add_mode { - FriendAddMode::Code => FriendAddMode::Search, - FriendAddMode::Search => FriendAddMode::Code, - }; - } - - // Submit - Key::Enter => match app.view.friend_add_mode { - FriendAddMode::Code => { - let code: String = app.view.friend_add_input.iter().collect(); - let code = code.trim().to_string(); - if !code.is_empty() { - app.apply(Action::AddFriendByCode(code)); - app.clear_friend_add_dialog_state(); - } - } - FriendAddMode::Search => { - let idx = app.view.friend_user_search_selected; - if let Some(result) = app.friend_user_search_results.get(idx) { - let user_id = result.id.clone(); - app.apply(Action::AddFriendById(user_id)); - app.clear_friend_add_dialog_state(); - } - } - }, - - Key::Backspace => match app.view.friend_add_mode { - FriendAddMode::Code => { - app.view.friend_add_input.pop(); - } - FriendAddMode::Search => { - app.view.friend_user_search_input.pop(); - let query: String = app.view.friend_user_search_input.iter().collect(); - app.apply(Action::SearchFriendUsers(query)); - } - }, - - // Navigate search results - k if app.view.friend_add_mode == FriendAddMode::Search - && common_key_events::down_event(k, &app.user_config.keys) => - { - let count = app.friend_user_search_results.len(); - if count > 0 { - app.view.friend_user_search_selected = - (app.view.friend_user_search_selected + 1).min(count - 1); - } - } - - k if app.view.friend_add_mode == FriendAddMode::Search - && common_key_events::up_event(k, &app.user_config.keys) - && app.view.friend_user_search_selected > 0 => - { - app.view.friend_user_search_selected -= 1; - } - - Key::Char(c) if c != '\n' => match app.view.friend_add_mode { - FriendAddMode::Code => { - app.view.friend_add_input.push(c); - } - FriendAddMode::Search => { - app.view.friend_user_search_input.push(c); - let query: String = app.view.friend_user_search_input.iter().collect(); - app.apply(Action::SearchFriendUsers(query)); - } - }, - - _ => {} - } -} - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -fn close_dialog(app: &mut App) { - app.clear_friend_add_dialog_state(); -} - -fn filtered_count(app: &App) -> usize { - filtered_friends(app).len() -} - -fn move_down(app: &mut App) { - let count = filtered_count(app); - if count == 0 { - return; - } - app.view.friend_selected_index = (app.view.friend_selected_index + 1).min(count - 1); -} - -fn move_up(app: &mut App) { - if app.view.friend_selected_index > 0 { - app.view.friend_selected_index -= 1; - } -} - -fn unfollow_selected(app: &mut App) { - let user_id = filtered_friends(app) - .get(app.view.friend_selected_index) - .map(|friend| friend.id.clone()); - if let Some(user_id) = user_id { - app.apply(Action::UnfollowFriend(user_id)); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::app::{ActiveBlock, RouteId}; - use crate::core::user_config::UserConfig; - use crate::infra::network::IoEvent; - use std::sync::mpsc::{channel, Receiver}; - use std::time::SystemTime; - - fn app_on_the_friends_screen() -> (App, Receiver) { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.push_navigation_stack(RouteId::Friends, ActiveBlock::Friends); - (app, rx) - } - - #[test] - fn esc_first_clears_the_filter_then_leaves_the_screen() { - let (mut app, _rx) = app_on_the_friends_screen(); - handler(Key::Char('z'), &mut app); - assert_eq!(app.view.friend_search_input, vec!['z']); - - handler(Key::Esc, &mut app); - - assert!(app.view.friend_search_input.is_empty()); - assert_eq!( - app.get_current_route().id, - RouteId::Friends, - "the first Esc only clears the filter" - ); - - handler(Key::Esc, &mut app); - - assert_eq!(app.get_current_route().id, RouteId::Home); - } - - #[test] - fn unfollow_without_a_selected_friend_does_nothing() { - let (mut app, rx) = app_on_the_friends_screen(); - - handler(Key::Char('u'), &mut app); - - assert!(app.friends.is_empty()); - assert_eq!(app.get_current_route().id, RouteId::Friends); - assert!(rx.try_recv().is_err(), "expected nothing dispatched"); - } - - #[test] - fn a_short_user_search_query_asks_for_nothing() { - let (mut app, rx) = app_on_the_friends_screen(); - app.open_friend_add_dialog(); - handler(Key::Tab, &mut app); - - handler(Key::Char('a'), &mut app); - - assert_eq!(app.view.friend_user_search_input, vec!['a']); - assert!(rx.try_recv().is_err(), "below the server's minimum"); - } -} diff --git a/src/tui/handlers/help_menu.rs b/src/tui/handlers/help_menu.rs deleted file mode 100644 index db2fa75c..00000000 --- a/src/tui/handlers/help_menu.rs +++ /dev/null @@ -1,234 +0,0 @@ -use super::common_key_events; -use super::filter_input::{self, FilterEdit}; -use crate::{ - core::action::{Action, NavTarget}, - core::app::App, - tui::event::Key, -}; - -#[derive(PartialEq)] -enum Direction { - Up, - Down, -} - -pub(super) fn open(app: &mut App) { - clear_filter(app); - app.apply(Action::Navigate(NavTarget::Help)); -} - -pub(super) fn clear_filter(app: &mut App) { - app.view.help_filter.clear(); - app.view.help_filter_editing = false; - reset_page(app); -} - -pub fn handler(key: Key, app: &mut App) { - if app.view.help_filter_editing { - handle_filter_input(key, app); - return; - } - - match key { - k if k == app.user_config.keys.search => begin_filter(app), - Key::Esc if !app.view.help_filter.is_empty() => clear_filter(app), - k if common_key_events::down_event(k, &app.user_config.keys) => { - move_page(Direction::Down, app); - } - k if common_key_events::up_event(k, &app.user_config.keys) => { - move_page(Direction::Up, app); - } - Key::Ctrl('d') => { - move_page(Direction::Down, app); - } - Key::Ctrl('u') => { - move_page(Direction::Up, app); - } - _ => {} - }; -} - -fn begin_filter(app: &mut App) { - app.view.help_filter.clear(); - app.view.help_filter_editing = true; - reset_page(app); -} - -fn handle_filter_input(key: Key, app: &mut App) { - match filter_input::apply(key, &mut app.view.help_filter) { - FilterEdit::Cancel => clear_filter(app), - FilterEdit::Confirm => { - if app.view.help_filter.split_whitespace().next().is_none() { - clear_filter(app); - } else { - app.view.help_filter_editing = false; - reset_page(app); - } - } - FilterEdit::Changed => reset_page(app), - FilterEdit::Ignored => {} - } -} - -fn reset_page(app: &mut App) { - app.view.help_menu_page = 0; - app.view.help_menu_offset = 0; -} - -fn move_page(direction: Direction, app: &mut App) { - if direction == Direction::Up { - if app.view.help_menu_page > 0 { - app.view.help_menu_page -= 1; - } - } else if direction == Direction::Down { - app.view.help_menu_page = app.view.help_menu_page.saturating_add(1); - } - app.calculate_help_menu_offset(); -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{ - core::app::{ActiveBlock, RouteId}, - core::source::Source, - tui::{handlers::handle_app, keymap::help_rows, ui::ensure_help_menu_model}, - }; - - #[test] - fn test_help_menu_pagination() { - let mut app = App::default(); - app.view.help_docs_size = 100; - app.view.help_menu_max_lines = 10; - - // Test down navigation - handler(Key::Down, &mut app); - assert_eq!(app.view.help_menu_page, 1); - assert_eq!(app.view.help_menu_offset, 10); - - handler(Key::Char('j'), &mut app); - assert_eq!(app.view.help_menu_page, 2); - assert_eq!(app.view.help_menu_offset, 20); - - handler(Key::Ctrl('d'), &mut app); - assert_eq!(app.view.help_menu_page, 3); - assert_eq!(app.view.help_menu_offset, 30); - - // Test up navigation - handler(Key::Up, &mut app); - assert_eq!(app.view.help_menu_page, 2); - assert_eq!(app.view.help_menu_offset, 20); - - handler(Key::Char('k'), &mut app); - assert_eq!(app.view.help_menu_page, 1); - assert_eq!(app.view.help_menu_offset, 10); - - handler(Key::Ctrl('u'), &mut app); - assert_eq!(app.view.help_menu_page, 0); - assert_eq!(app.view.help_menu_offset, 0); - } - - #[test] - fn pagination_does_not_open_an_empty_last_page() { - let mut app = App::default(); - app.view.help_docs_size = 20; - app.view.help_menu_max_lines = 10; - app.view.help_menu_page = 1; - app.calculate_help_menu_offset(); - - handler(Key::Down, &mut app); - - assert_eq!(app.view.help_menu_page, 1); - assert_eq!(app.view.help_menu_offset, 10); - } - - #[test] - fn test_help_menu_navigation_stack() { - let mut app = App::default(); - // Start at Home - assert_eq!(app.get_current_route().id, RouteId::Home); - assert_eq!(app.get_current_route().active_block, ActiveBlock::Empty); - - // Open help menu - handle_app(Key::Char('?'), &mut app); - assert_eq!(app.get_current_route().id, RouteId::HelpMenu); - assert_eq!(app.get_current_route().active_block, ActiveBlock::HelpMenu); - - // Close help menu with Esc (uses handle_escape via handle_app) - handle_app(Key::Esc, &mut app); - assert_eq!(app.get_current_route().id, RouteId::Home); - assert_eq!(app.get_current_route().active_block, ActiveBlock::Empty); - - // Open help menu again - handle_app(Key::Char('?'), &mut app); - assert_eq!(app.get_current_route().id, RouteId::HelpMenu); - - // Close help menu with 'q' (simulating the back key handling in the runner) - let back_key = app.user_config.keys.back; - assert_eq!(back_key, Key::Char('q')); - - let pop_result = app.pop_navigation_stack(); - assert!(pop_result.is_some()); - assert_eq!(app.get_current_route().id, RouteId::Home); - } - - #[test] - fn search_key_starts_a_local_help_filter_and_captures_global_keys() { - let mut app = App::default(); - app.active_source = Source::Local; - handle_app(app.user_config.keys.help, &mut app); - - handle_app(app.user_config.keys.search, &mut app); - handle_app(Key::Char('d'), &mut app); - handle_app(Key::Char('q'), &mut app); - - assert_eq!(app.get_current_route().id, RouteId::HelpMenu); - assert_eq!(app.get_current_route().active_block, ActiveBlock::HelpMenu); - assert!(app.view.help_filter_editing); - assert_eq!(app.view.help_filter, "dq"); - } - - #[test] - fn confirmed_filter_stays_applied_until_escape_clears_it() { - let mut app = App::default(); - handle_app(app.user_config.keys.help, &mut app); - let unfiltered_size = help_rows(&app).len() as u32; - - handle_app(app.user_config.keys.search, &mut app); - for c in "volume".chars() { - handle_app(Key::Char(c), &mut app); - } - - // The frame that follows a keystroke rebuilds the help model, which - // owns the row count the pager reads. - ensure_help_menu_model(&mut app); - assert!(app.view.help_filter_editing); - assert!(app.view.help_docs_size > 0); - assert!(app.view.help_docs_size < unfiltered_size); - - handle_app(Key::Enter, &mut app); - assert!(!app.view.help_filter_editing); - assert_eq!(app.view.help_filter, "volume"); - - handle_app(Key::Esc, &mut app); - ensure_help_menu_model(&mut app); - assert_eq!(app.get_current_route().id, RouteId::HelpMenu); - assert!(app.view.help_filter.is_empty()); - assert_eq!(app.view.help_docs_size, unfiltered_size); - - handle_app(Key::Esc, &mut app); - assert_eq!(app.get_current_route().id, RouteId::Home); - } - - #[test] - fn help_filter_uses_the_configured_search_binding() { - let mut app = App::default(); - app.user_config.keys.search = Key::Char('f'); - handle_app(app.user_config.keys.help, &mut app); - - handle_app(Key::Char('f'), &mut app); - - assert!(app.view.help_filter_editing); - assert!(app.view.help_filter.is_empty()); - } -} diff --git a/src/tui/handlers/home.rs b/src/tui/handlers/home.rs deleted file mode 100644 index 90e97b2a..00000000 --- a/src/tui/handlers/home.rs +++ /dev/null @@ -1,97 +0,0 @@ -use super::common_key_events; -use crate::core::app::App; -use crate::tui::event::Key; - -const LARGE_SCROLL: u16 = 10; -const SMALL_SCROLL: u16 = 1; - -pub fn handler(key: Key, app: &mut App) { - match key { - k if common_key_events::left_event(k, &app.user_config.keys) => { - common_key_events::handle_left_event(app) - } - k if common_key_events::down_event(k, &app.user_config.keys) => { - app.view.home_scroll += SMALL_SCROLL; - } - k if common_key_events::up_event(k, &app.user_config.keys) && app.view.home_scroll > 0 => { - app.view.home_scroll -= SMALL_SCROLL; - } - k if k == app.user_config.keys.next_page => { - app.view.home_scroll += LARGE_SCROLL; - } - k if k == app.user_config.keys.previous_page => { - if app.view.home_scroll > LARGE_SCROLL { - app.view.home_scroll -= LARGE_SCROLL; - } else { - app.view.home_scroll = 0; - } - } - _ => {} - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn on_small_down_press() { - let mut app = App::default(); - - handler(Key::Down, &mut app); - assert_eq!(app.view.home_scroll, SMALL_SCROLL); - - handler(Key::Down, &mut app); - assert_eq!(app.view.home_scroll, SMALL_SCROLL * 2); - } - - #[test] - fn on_small_up_press() { - let mut app = App::default(); - - handler(Key::Up, &mut app); - assert_eq!(app.view.home_scroll, 0); - - app.view.home_scroll = 1; - - handler(Key::Up, &mut app); - assert_eq!(app.view.home_scroll, 0); - - // Check that smashing the up button doesn't go to negative scroll (which would cause a crash) - handler(Key::Up, &mut app); - handler(Key::Up, &mut app); - handler(Key::Up, &mut app); - assert_eq!(app.view.home_scroll, 0); - } - - #[test] - fn on_large_down_press() { - let mut app = App::default(); - - handler(Key::Ctrl('d'), &mut app); - assert_eq!(app.view.home_scroll, LARGE_SCROLL); - - handler(Key::Ctrl('d'), &mut app); - assert_eq!(app.view.home_scroll, LARGE_SCROLL * 2); - } - - #[test] - fn on_large_up_press() { - let mut app = App::default(); - - let scroll = 37; - app.view.home_scroll = scroll; - - handler(Key::Ctrl('u'), &mut app); - assert_eq!(app.view.home_scroll, scroll - LARGE_SCROLL); - - handler(Key::Ctrl('u'), &mut app); - assert_eq!(app.view.home_scroll, scroll - LARGE_SCROLL * 2); - - // Check that smashing the up button doesn't go to negative scroll (which would cause a crash) - handler(Key::Ctrl('u'), &mut app); - handler(Key::Ctrl('u'), &mut app); - handler(Key::Ctrl('u'), &mut app); - assert_eq!(app.view.home_scroll, 0); - } -} diff --git a/src/tui/handlers/input.rs b/src/tui/handlers/input.rs deleted file mode 100644 index e62dc445..00000000 --- a/src/tui/handlers/input.rs +++ /dev/null @@ -1,664 +0,0 @@ -extern crate unicode_width; - -use crate::core::action::{Action, OpenTarget}; -use crate::core::app::{ActiveBlock, App, InputContext, RouteId}; -use crate::tui::event::Key; -use rspotify::prelude::Id; -use std::convert::TryInto; -use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; - -// Handle event when the search input block is active -pub fn handler(key: Key, app: &mut App) { - match key { - Key::Ctrl('k') => { - app - .view - .input - .drain(app.view.input_idx..app.view.input.len()); - } - Key::Ctrl('u') => { - app.view.input.drain(..app.view.input_idx); - app.view.input_idx = 0; - app.view.input_cursor_position = 0; - } - Key::Ctrl('l') => { - app.view.input = vec![]; - app.view.input_idx = 0; - app.view.input_cursor_position = 0; - } - Key::Ctrl('w') => { - if app.view.input_cursor_position == 0 { - return; - } - let word_end = match app.view.input[..app.view.input_idx] - .iter() - .rposition(|&x| x != ' ') - { - Some(index) => index + 1, - None => 0, - }; - let word_start = match app.view.input[..word_end].iter().rposition(|&x| x == ' ') { - Some(index) => index + 1, - None => 0, - }; - let deleted: String = app.view.input[word_start..app.view.input_idx] - .iter() - .collect(); - let deleted_len: u16 = UnicodeWidthStr::width(deleted.as_str()).try_into().unwrap(); - app.view.input.drain(word_start..app.view.input_idx); - app.view.input_idx = word_start; - app.view.input_cursor_position -= deleted_len; - } - Key::End | Key::Ctrl('e') => { - app.view.input_idx = app.view.input.len(); - let input_string: String = app.view.input.iter().collect(); - app.view.input_cursor_position = UnicodeWidthStr::width(input_string.as_str()) - .try_into() - .unwrap(); - } - Key::Home | Key::Ctrl('a') => { - app.view.input_idx = 0; - app.view.input_cursor_position = 0; - } - Key::Left | Key::Ctrl('b') if !app.view.input.is_empty() && app.view.input_idx > 0 => { - let last_c = app.view.input[app.view.input_idx - 1]; - app.view.input_idx -= 1; - app.view.input_cursor_position -= compute_character_width(last_c); - } - Key::Right | Key::Ctrl('f') if app.view.input_idx < app.view.input.len() => { - let next_c = app.view.input[app.view.input_idx]; - app.view.input_idx += 1; - app.view.input_cursor_position += compute_character_width(next_c); - } - Key::Esc => match app.view.input_context { - InputContext::PlaylistTrackSearch => { - app.view.input_context = InputContext::GlobalSearch; - app.set_current_route_state(Some(ActiveBlock::TrackTable), Some(ActiveBlock::TrackTable)); - } - InputContext::GlobalSearch => { - app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library)); - } - }, - Key::Enter => { - let input_str: String = app.view.input.iter().collect(); - - process_input(app, input_str); - } - Key::Char(c) => { - app.view.input.insert(app.view.input_idx, c); - app.view.input_idx += 1; - app.view.input_cursor_position += compute_character_width(c); - } - Key::Backspace | Key::Ctrl('h') if !app.view.input.is_empty() && app.view.input_idx > 0 => { - let last_c = app.view.input.remove(app.view.input_idx - 1); - app.view.input_idx -= 1; - app.view.input_cursor_position -= compute_character_width(last_c); - } - Key::Delete | Key::Ctrl('d') - if !app.view.input.is_empty() && app.view.input_idx < app.view.input.len() => - { - app.view.input.remove(app.view.input_idx); - } - _ => {} - } -} - -fn process_input(app: &mut App, input: String) { - if app.view.input_context == InputContext::PlaylistTrackSearch { - process_playlist_track_search(app, input); - return; - } - - // Don't do anything if there is no input - if input.is_empty() { - return; - } - - // On searching for a track, clear the playlist selection - app.view.selected_playlist_index = Some(0); - - if attempt_process_uri(app, &input, "https://open.spotify.com/", "/") - || attempt_process_uri(app, &input, "spotify:", ":") - { - return; - } - - // Default fallback behavior: treat the input as a raw search phrase, routed - // to the active source's catalog. - app.apply(Action::SearchActiveSource(input)); - app.push_navigation_stack(RouteId::Search, ActiveBlock::SearchResultBlock); - // push_navigation_stack is a no-op when the Search route is already on top, which - // otherwise leaves focus trapped in the input box. Force focus onto the results so - // submitting a search always escapes the search box (#191). - app.set_current_route_state( - Some(ActiveBlock::SearchResultBlock), - Some(ActiveBlock::SearchResultBlock), - ); -} - -fn process_playlist_track_search(app: &mut App, input: String) { - let query = input.trim().to_string(); - app.view.input_context = InputContext::GlobalSearch; - app.set_current_route_state(Some(ActiveBlock::TrackTable), Some(ActiveBlock::TrackTable)); - - if query.is_empty() { - app.clear_playlist_track_filter(); - return; - } - - if let Some(playlist_id) = app.current_playlist_track_table_id() { - app.apply(Action::SearchPlaylistTracks { - playlist_id: playlist_id.id().to_string(), - query, - }); - } -} - -fn spotify_resource_id(base: &str, uri: &str, sep: &str, resource_type: &str) -> (String, bool) { - let uri_prefix = format!("{}{}{}", base, resource_type, sep); - let id_string_with_query_params = uri.trim_start_matches(&uri_prefix); - let query_idx = id_string_with_query_params - .find('?') - .unwrap_or(id_string_with_query_params.len()); - let id_string = id_string_with_query_params[0..query_idx].to_string(); - // If the lengths aren't equal, we must have found a match. - let matched = id_string_with_query_params.len() != uri.len() && id_string.len() != uri.len(); - (id_string, matched) -} - -// Returns true if the input was successfully processed as a Spotify URI. -fn attempt_process_uri(app: &mut App, input: &str, base: &str, sep: &str) -> bool { - let (album_id, matched) = spotify_resource_id(base, input, sep, "album"); - if matched { - app.apply(Action::Open(OpenTarget::Album { - id: album_id, - from_search: false, - })); - return true; - } - - let (artist_id, matched) = spotify_resource_id(base, input, sep, "artist"); - if matched { - app.apply(Action::Open(OpenTarget::Artist { - id: artist_id, - name: String::new(), - })); - return true; - } - - let (track_id, matched) = spotify_resource_id(base, input, sep, "track"); - if matched { - app.apply(Action::Open(OpenTarget::TrackAlbum(track_id))); - return true; - } - - let (playlist_id, matched) = spotify_resource_id(base, input, sep, "playlist"); - if matched { - app.apply(Action::Open(OpenTarget::Playlist { - id: playlist_id, - from_search: false, - })); - return true; - } - - let (show_id, matched) = spotify_resource_id(base, input, sep, "show"); - if matched { - app.apply(Action::Open(OpenTarget::Show(show_id))); - return true; - } - - false -} - -fn compute_character_width(character: char) -> u16 { - UnicodeWidthChar::width(character) - .unwrap() - .try_into() - .unwrap() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::infra::network::IoEvent; - use rspotify::model::idtypes::PlaylistId; - use rspotify::prelude::Id; - - fn str_to_vec_char(s: &str) -> Vec { - String::from(s).chars().collect() - } - - #[test] - fn test_compute_character_width_with_multiple_characters() { - assert_eq!(1, compute_character_width('a')); - assert_eq!(1, compute_character_width('ß')); - assert_eq!(1, compute_character_width('ç')); - } - - #[test] - fn test_input_handler_clear_input_on_ctrl_l() { - let mut app = App::default(); - - app.view.input = str_to_vec_char("My text"); - - handler(Key::Ctrl('l'), &mut app); - - assert_eq!(app.view.input, str_to_vec_char("")); - } - - #[test] - fn test_input_handler_ctrl_u() { - let mut app = App::default(); - - app.view.input = str_to_vec_char("My text"); - - handler(Key::Ctrl('u'), &mut app); - assert_eq!(app.view.input, str_to_vec_char("My text")); - - app.view.input_cursor_position = 3; - app.view.input_idx = 3; - handler(Key::Ctrl('u'), &mut app); - assert_eq!(app.view.input, str_to_vec_char("text")); - } - - #[test] - fn test_input_handler_ctrl_k() { - let mut app = App::default(); - - app.view.input = str_to_vec_char("My text"); - - handler(Key::Ctrl('k'), &mut app); - assert_eq!(app.view.input, str_to_vec_char("")); - - app.view.input = str_to_vec_char("My text"); - app.view.input_cursor_position = 2; - app.view.input_idx = 2; - handler(Key::Ctrl('k'), &mut app); - assert_eq!(app.view.input, str_to_vec_char("My")); - - handler(Key::Ctrl('k'), &mut app); - assert_eq!(app.view.input, str_to_vec_char("My")); - } - - #[test] - fn test_input_handler_ctrl_w() { - let mut app = App::default(); - - app.view.input = str_to_vec_char("My text"); - - handler(Key::Ctrl('w'), &mut app); - assert_eq!(app.view.input, str_to_vec_char("My text")); - - app.view.input_cursor_position = 3; - app.view.input_idx = 3; - handler(Key::Ctrl('w'), &mut app); - assert_eq!(app.view.input, str_to_vec_char("text")); - assert_eq!(app.view.input_cursor_position, 0); - assert_eq!(app.view.input_idx, 0); - - app.view.input = str_to_vec_char(" "); - app.view.input_cursor_position = 3; - app.view.input_idx = 3; - handler(Key::Ctrl('w'), &mut app); - assert_eq!(app.view.input, str_to_vec_char(" ")); - assert_eq!(app.view.input_cursor_position, 0); - assert_eq!(app.view.input_idx, 0); - app.view.input_cursor_position = 1; - app.view.input_idx = 1; - handler(Key::Ctrl('w'), &mut app); - assert_eq!(app.view.input, str_to_vec_char("")); - assert_eq!(app.view.input_cursor_position, 0); - assert_eq!(app.view.input_idx, 0); - - app.view.input = str_to_vec_char("Hello there "); - app.view.input_cursor_position = 13; - app.view.input_idx = 13; - handler(Key::Ctrl('w'), &mut app); - assert_eq!(app.view.input, str_to_vec_char("Hello ")); - assert_eq!(app.view.input_cursor_position, 6); - assert_eq!(app.view.input_idx, 6); - } - - #[test] - fn test_input_handler_esc_back_to_playlist() { - let mut app = App::default(); - - app.set_current_route_state(Some(ActiveBlock::MyPlaylists), None); - handler(Key::Esc, &mut app); - - let current_route = app.get_current_route(); - assert_eq!(current_route.active_block, ActiveBlock::Empty); - } - - #[test] - fn playlist_search_esc_returns_focus_to_track_table() { - let mut app = App::default(); - app.view.input_context = InputContext::PlaylistTrackSearch; - - handler(Key::Esc, &mut app); - - let current_route = app.get_current_route(); - assert_eq!(current_route.active_block, ActiveBlock::TrackTable); - assert_eq!(current_route.hovered_block, ActiveBlock::TrackTable); - assert_eq!(app.view.input_context, InputContext::GlobalSearch); - } - - #[test] - fn playlist_search_enter_dispatches_playlist_search() { - let (tx, rx) = std::sync::mpsc::channel(); - let mut app = App::new( - tx, - crate::core::user_config::UserConfig::new(), - Some(std::time::SystemTime::now()), - ); - let playlist_id = PlaylistId::from_id("37i9dQZF1DX4WYpdgoIcn6") - .unwrap() - .into_static(); - - app.track_table.context = Some(crate::core::app::TrackTableContext::MyPlaylists); - app.playlist_track_table_id = Some(playlist_id.clone()); - app.view.input_context = InputContext::PlaylistTrackSearch; - app.view.input = str_to_vec_char("queen rock"); - app.view.input_idx = app.view.input.len(); - app.view.input_cursor_position = app.view.input.len() as u16; - - handler(Key::Enter, &mut app); - - match rx.recv().unwrap() { - IoEvent::SearchPlaylistTracks(id, query) => { - assert_eq!(id, playlist_id.id()); - assert_eq!(query, "queen rock"); - } - _ => panic!("unexpected event"), - } - assert_eq!(app.view.input_context, InputContext::GlobalSearch); - assert_eq!( - app.get_current_route().active_block, - ActiveBlock::TrackTable - ); - assert_eq!( - app.pending_playlist_track_search, - Some("queen rock".to_string()) - ); - assert_eq!( - app.status_message(), - Some("Searching playlist for \"queen rock\"...") - ); - } - - #[test] - fn empty_playlist_search_clears_filter_without_dispatching() { - let (tx, rx) = std::sync::mpsc::channel(); - let mut app = App::new( - tx, - crate::core::user_config::UserConfig::new(), - Some(std::time::SystemTime::now()), - ); - let playlist_id = PlaylistId::from_id("37i9dQZF1DX4WYpdgoIcn6") - .unwrap() - .into_static(); - - app.track_table.context = Some(crate::core::app::TrackTableContext::MyPlaylists); - app.playlist_track_table_id = Some(playlist_id); - app.view.input_context = InputContext::PlaylistTrackSearch; - app.active_playlist_track_filter = Some("old".to_string()); - - handler(Key::Enter, &mut app); - - assert!(app.active_playlist_track_filter.is_none()); - assert!(rx.try_recv().is_err()); - } - - #[test] - fn search_enter_on_search_route_moves_focus_to_results() { - let (tx, rx) = std::sync::mpsc::channel(); - let mut app = App::new( - tx, - crate::core::user_config::UserConfig::new(), - Some(std::time::SystemTime::now()), - ); - - // Already on the Search route with focus in the input box. This is the case - // that previously left focus trapped in the search box on submit (#191). - app.push_navigation_stack(RouteId::Search, ActiveBlock::Input); - app.view.input = str_to_vec_char("daft punk"); - app.view.input_idx = app.view.input.len(); - app.view.input_cursor_position = app.view.input.len() as u16; - - handler(Key::Enter, &mut app); - - match rx.recv().unwrap() { - IoEvent::GetSearchResults(query, _) => assert_eq!(query, "daft punk"), - _ => panic!("unexpected event"), - } - - let route = app.get_current_route(); - assert_eq!(route.active_block, ActiveBlock::SearchResultBlock); - assert_eq!(route.hovered_block, ActiveBlock::SearchResultBlock); - } - - #[test] - fn test_input_handler_on_enter_text() { - let mut app = App::default(); - - app.view.input = str_to_vec_char("My tex"); - app.view.input_cursor_position = app.view.input.len().try_into().unwrap(); - app.view.input_idx = app.view.input.len(); - - handler(Key::Char('t'), &mut app); - - assert_eq!(app.view.input, str_to_vec_char("My text")); - } - - #[test] - fn test_input_handler_backspace() { - let mut app = App::default(); - - app.view.input = str_to_vec_char("My text"); - app.view.input_cursor_position = app.view.input.len().try_into().unwrap(); - app.view.input_idx = app.view.input.len(); - - handler(Key::Backspace, &mut app); - assert_eq!(app.view.input, str_to_vec_char("My tex")); - - // Test that backspace deletes from the cursor position - app.view.input_idx = 2; - app.view.input_cursor_position = 2; - - handler(Key::Backspace, &mut app); - assert_eq!(app.view.input, str_to_vec_char("M tex")); - - app.view.input_idx = 1; - app.view.input_cursor_position = 1; - - handler(Key::Ctrl('h'), &mut app); - assert_eq!(app.view.input, str_to_vec_char(" tex")); - } - - #[test] - fn test_input_handler_delete() { - let mut app = App::default(); - - app.view.input = str_to_vec_char("My text"); - app.view.input_idx = 3; - app.view.input_cursor_position = 3; - - handler(Key::Delete, &mut app); - assert_eq!(app.view.input, str_to_vec_char("My ext")); - - app.view.input = str_to_vec_char("ラスト"); - app.view.input_idx = 1; - app.view.input_cursor_position = 1; - - handler(Key::Delete, &mut app); - assert_eq!(app.view.input, str_to_vec_char("ラト")); - - app.view.input = str_to_vec_char("Rust"); - app.view.input_idx = 2; - app.view.input_cursor_position = 2; - - handler(Key::Ctrl('d'), &mut app); - assert_eq!(app.view.input, str_to_vec_char("Rut")); - } - - #[test] - fn test_input_handler_left_event() { - let mut app = App::default(); - - app.view.input = str_to_vec_char("My text"); - let input_len = app.view.input.len().try_into().unwrap(); - app.view.input_idx = app.view.input.len(); - app.view.input_cursor_position = input_len; - - handler(Key::Left, &mut app); - assert_eq!(app.view.input_cursor_position, input_len - 1); - handler(Key::Left, &mut app); - assert_eq!(app.view.input_cursor_position, input_len - 2); - handler(Key::Left, &mut app); - assert_eq!(app.view.input_cursor_position, input_len - 3); - handler(Key::Ctrl('b'), &mut app); - assert_eq!(app.view.input_cursor_position, input_len - 4); - handler(Key::Ctrl('b'), &mut app); - assert_eq!(app.view.input_cursor_position, input_len - 5); - - // Pretend to smash the left event to test the we have no out-of-bounds crash - for _ in 0..20 { - handler(Key::Left, &mut app); - } - - assert_eq!(app.view.input_cursor_position, 0); - } - - #[test] - fn test_input_handler_on_enter_text_non_english_char() { - let mut app = App::default(); - - app.view.input = str_to_vec_char("ыа"); - app.view.input_cursor_position = app.view.input.len().try_into().unwrap(); - app.view.input_idx = app.view.input.len(); - - handler(Key::Char('ы'), &mut app); - - assert_eq!(app.view.input, str_to_vec_char("ыаы")); - } - - #[test] - fn test_input_handler_on_enter_text_wide_char() { - let mut app = App::default(); - - app.view.input = str_to_vec_char("你"); - app.view.input_cursor_position = 2; // 你 is 2 char wide - app.view.input_idx = 1; // 1 char - - handler(Key::Char('好'), &mut app); - - assert_eq!(app.view.input, str_to_vec_char("你好")); - assert_eq!(app.view.input_idx, 2); - assert_eq!(app.view.input_cursor_position, 4); - } - - mod test_uri_parsing { - use super::*; - - const URI_BASE: &str = "spotify:"; - const URL_BASE: &str = "https://open.spotify.com/"; - - fn check_uri_parse(expected_id: &str, parsed: (String, bool)) { - assert!(parsed.1); - assert_eq!(parsed.0, expected_id); - } - - fn run_test_for_id_and_resource_type(id: &str, resource_type: &str) { - check_uri_parse( - id, - spotify_resource_id( - URI_BASE, - &format!("spotify:{}:{}", resource_type, id), - ":", - resource_type, - ), - ); - check_uri_parse( - id, - spotify_resource_id( - URL_BASE, - &format!("https://open.spotify.com/{}/{}", resource_type, id), - "/", - resource_type, - ), - ) - } - - #[test] - fn artist() { - let expected_artist_id = "2ye2Wgw4gimLv2eAKyk1NB"; - run_test_for_id_and_resource_type(expected_artist_id, "artist"); - } - - #[test] - fn album() { - let expected_album_id = "5gzLOflH95LkKYE6XSXE9k"; - run_test_for_id_and_resource_type(expected_album_id, "album"); - } - - #[test] - fn playlist() { - let expected_playlist_id = "1cJ6lPBYj2fscs0kqBHsVV"; - run_test_for_id_and_resource_type(expected_playlist_id, "playlist"); - } - - #[test] - fn show() { - let expected_show_id = "3aNsrV6lkzmcU1w8u8kA7N"; - run_test_for_id_and_resource_type(expected_show_id, "show"); - } - - #[test] - fn track() { - let expected_track_id = "10igKaIKsSB6ZnWxPxPvKO"; - run_test_for_id_and_resource_type(expected_track_id, "track"); - } - - #[test] - fn invalid_format_doesnt_match() { - let swapped = "show:spotify:3aNsrV6lkzmcU1w8u8kA7N"; - let totally_wrong = "hehe-haha-3aNsrV6lkzmcU1w8u8kA7N"; - let random = "random string"; - let (_, matched) = spotify_resource_id(URI_BASE, swapped, ":", "track"); - assert!(!matched); - let (_, matched) = spotify_resource_id(URI_BASE, totally_wrong, ":", "track"); - assert!(!matched); - let (_, matched) = spotify_resource_id(URL_BASE, totally_wrong, "/", "track"); - assert!(!matched); - let (_, matched) = spotify_resource_id(URL_BASE, random, "/", "track"); - assert!(!matched); - } - - #[test] - fn parse_with_query_parameters() { - // If this test ever fails due to some change to the parsing logic, it is likely a sign we - // should just integrate the url crate instead of trying to do things ourselves. - let playlist_url_with_query = - "https://open.spotify.com/playlist/1cJ6lPBYj2fscs0kqBHsVV?si=OdwuJsbsSeuUAOadehng3A"; - let playlist_url = "https://open.spotify.com/playlist/1cJ6lPBYj2fscs0kqBHsVV"; - let expected_id = "1cJ6lPBYj2fscs0kqBHsVV"; - - let (actual_id, matched) = spotify_resource_id(URL_BASE, playlist_url, "/", "playlist"); - assert!(matched); - assert_eq!(actual_id, expected_id); - - let (actual_id, matched) = - spotify_resource_id(URL_BASE, playlist_url_with_query, "/", "playlist"); - assert!(matched); - assert_eq!(actual_id, expected_id); - } - - #[test] - fn mismatched_resource_types_do_not_match() { - let playlist_url = - "https://open.spotify.com/playlist/1cJ6lPBYj2fscs0kqBHsVV?si=OdwuJsbsSeuUAOadehng3A"; - let (_, matched) = spotify_resource_id(URL_BASE, playlist_url, "/", "album"); - assert!(!matched); - } - } -} diff --git a/src/tui/handlers/library.rs b/src/tui/handlers/library.rs deleted file mode 100644 index b2fb41a4..00000000 --- a/src/tui/handlers/library.rs +++ /dev/null @@ -1,173 +0,0 @@ -use super::common_key_events; -use crate::core::action::Action; -#[cfg(feature = "local-files")] -use crate::core::action::LibraryTarget; -use crate::core::app::App; -#[cfg(any(feature = "local-files", test))] -use crate::core::source::Source; -use crate::tui::event::Key; - -pub fn handler(key: Key, app: &mut App) { - match key { - k if common_key_events::right_event(k, &app.user_config.keys) => { - common_key_events::handle_right_event(app) - } - k if common_key_events::down_event(k, &app.user_config.keys) => select_next(app), - k if common_key_events::up_event(k, &app.user_config.keys) => select_previous(app), - k if common_key_events::high_event(k) => { - select_row(app, common_key_events::on_high_press_handler()); - } - k if common_key_events::middle_event(k) => { - let next_index = common_key_events::on_middle_press_handler(&app.library_rows()); - select_row(app, next_index); - } - k if common_key_events::low_event(k) => { - let next_index = common_key_events::on_low_press_handler(&app.library_rows()); - select_row(app, next_index); - } - Key::Enter => activate_selected(app), - _ => (), - }; -} - -/// Moves the highlight to the row at `index` among the rows offered now. -pub(super) fn select_row(app: &mut App, index: usize) { - if let Some(target) = app.library_rows().get(index) { - app.view.library_selected = *target; - } -} - -/// The down key's cursor move, named for the mouse wheel. -pub(super) fn select_next(app: &mut App) { - let index = app.library_cursor(); - let next_index = common_key_events::on_down_press_handler(&app.library_rows(), Some(index)); - select_row(app, next_index); -} - -pub(super) fn select_previous(app: &mut App) { - let index = app.library_cursor(); - let next_index = common_key_events::on_up_press_handler(&app.library_rows(), Some(index)); - select_row(app, next_index); -} - -pub(super) fn activate_selected(app: &mut App) { - let Some(target) = app.library_rows().get(app.library_cursor()).copied() else { - return; - }; - - #[cfg(feature = "local-files")] - if target == LibraryTarget::LocalFiles { - open_local_source(app); - return; - } - app.apply(Action::OpenLibrary(target)); -} - -/// Doubles as the "switch to Local source" shortcut: it flips the active source -/// so the sidebar re-scopes to local folders, then opens the browser. The -/// sidebar cursor resets are presentation state and stay in the TUI. -#[cfg(feature = "local-files")] -fn open_local_source(app: &mut App) { - app.view.selected_playlist_index = Some(0); - app.view.local_playlists_index = 0; - app.apply(Action::SelectSource(Source::Local)); - app.apply(Action::OpenLibrary(LibraryTarget::LocalFiles)); -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::action::LibraryTarget; - use crate::core::app::RouteId; - use crate::core::user_config::UserConfig; - use crate::infra::network::IoEvent; - use std::sync::mpsc::channel; - use std::time::SystemTime; - - /// A source switch persists; without a seeded path it would write the - /// developer's real state.yml. - fn seed_state_path(app: &mut App) -> tempfile::TempDir { - let dir = tempfile::tempdir().unwrap(); - app.state_path = Some(dir.path().join("state.yml")); - dir - } - - fn app_selecting( - target: LibraryTarget, - session: Option, - ) -> (App, std::sync::mpsc::Receiver) { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), session); - assert!(app.library_rows().contains(&target), "the row is offered"); - app.view.library_selected = target; - (app, rx) - } - - #[test] - fn enter_on_stats_entry_opens_stats_screen() { - let (mut app, rx) = app_selecting(LibraryTarget::Stats, Some(SystemTime::now())); - handler(Key::Enter, &mut app); - assert_eq!(app.get_current_route().id, RouteId::Stats); - assert!(app.stats_loading); - assert!(matches!(rx.try_recv(), Ok(IoEvent::LoadListeningStats(_)))); - } - - #[test] - fn enter_on_stats_opens_it_without_a_spotify_session() { - let (mut app, _rx) = app_selecting(LibraryTarget::Stats, None); - assert!(!app.library_rows().contains(&LibraryTarget::LikedSongs)); - handler(Key::Enter, &mut app); - assert_eq!(app.get_current_route().id, RouteId::Stats); - assert!(app.stats_loading); - } - - #[test] - fn enter_on_liked_songs_still_fetches_saved_tracks() { - let (mut app, rx) = app_selecting(LibraryTarget::LikedSongs, Some(SystemTime::now())); - handler(Key::Enter, &mut app); - assert_eq!(app.get_current_route().id, RouteId::TrackTable); - assert!(matches!( - rx.try_recv(), - Ok(IoEvent::GetCurrentSavedTracks(None)) - )); - } - - #[test] - fn the_bottom_key_lands_on_the_last_offered_row() { - let (mut app, _rx) = app_selecting(LibraryTarget::Friends, None); - handler(Key::Char('L'), &mut app); - assert_eq!(app.library_cursor(), app.library_rows().len() - 1); - } - - #[test] - fn a_hidden_selection_falls_back_to_the_top_row_and_returns_with_its_source() { - let (mut app, _rx) = app_selecting(LibraryTarget::Podcasts, Some(SystemTime::now())); - let _dir = seed_state_path(&mut app); - app.apply(Action::SelectSource(Source::Local)); - assert!(!app.library_rows().contains(&LibraryTarget::Podcasts)); - assert_eq!(app.library_cursor(), 0); - - handler(Key::Enter, &mut app); - assert_eq!(app.get_current_route().id, RouteId::Friends); - - app.apply(Action::SelectSource(Source::Spotify)); - assert_eq!( - app.library_rows()[app.library_cursor()], - LibraryTarget::Podcasts - ); - } - - #[cfg(feature = "local-files")] - #[test] - fn enter_on_local_files_switches_source_and_opens_the_browser() { - let (mut app, rx) = app_selecting(LibraryTarget::LocalFiles, Some(SystemTime::now())); - let _dir = seed_state_path(&mut app); - handler(Key::Enter, &mut app); - assert_eq!(app.active_source, Source::Local); - assert_eq!(app.runtime_state.active_source, Source::Local); - assert_eq!(app.get_current_route().id, RouteId::LocalBrowser); - assert!(matches!(rx.try_recv(), Ok(IoEvent::GetLocalPlaylists))); - assert_eq!(app.view.selected_playlist_index, Some(0)); - assert_eq!(app.view.local_playlists_index, 0); - } -} diff --git a/src/tui/handlers/local_browser.rs b/src/tui/handlers/local_browser.rs deleted file mode 100644 index 287bfcc4..00000000 --- a/src/tui/handlers/local_browser.rs +++ /dev/null @@ -1,74 +0,0 @@ -use super::common_key_events; -use crate::core::app::App; -use crate::tui::event::Key; - -/// Handler for the Local Files folder browser: a list of folders (one per -/// subdirectory of the configured music directory). Enter opens a folder's -/// tracks in the shared track table. -pub fn handler(key: Key, app: &mut App) { - match key { - k if common_key_events::left_event(k, &app.user_config.keys) => { - common_key_events::handle_left_event(app) - } - k if common_key_events::down_event(k, &app.user_config.keys) => { - app.view.local_playlists_index = common_key_events::on_down_press_handler( - &app.local_playlists, - Some(app.view.local_playlists_index), - ); - } - k if common_key_events::up_event(k, &app.user_config.keys) => { - app.view.local_playlists_index = common_key_events::on_up_press_handler( - &app.local_playlists, - Some(app.view.local_playlists_index), - ); - } - k if common_key_events::high_event(k) => { - app.view.local_playlists_index = common_key_events::on_high_press_handler(); - } - k if common_key_events::middle_event(k) => { - app.view.local_playlists_index = - common_key_events::on_middle_press_handler(&app.local_playlists); - } - k if common_key_events::low_event(k) => { - app.view.local_playlists_index = - common_key_events::on_low_press_handler(&app.local_playlists); - } - Key::Enter => { - let uri = app - .local_playlists - .get(app.view.local_playlists_index) - .map(|folder| folder.uri.clone()); - super::playlist::open_source_playlist(app, uri); - } - _ => {} - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::test_helpers::playlist_info; - use crate::core::user_config::UserConfig; - use crate::infra::network::IoEvent; - use std::sync::mpsc::channel; - use std::time::SystemTime; - - #[test] - fn enter_opens_the_selected_folder_by_its_source_uri() { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - let mut first = playlist_info("first", "First", "me", false); - first.uri = "file:///music/first".to_string(); - let mut second = playlist_info("second", "Second", "me", false); - second.uri = "file:///music/second".to_string(); - app.local_playlists = vec![first, second]; - app.view.local_playlists_index = 1; - - handler(Key::Enter, &mut app); - - match rx.try_recv() { - Ok(IoEvent::GetLocalTracks(uri)) => assert_eq!(uri, "file:///music/second"), - _ => panic!("expected GetLocalTracks for the selected folder"), - } - } -} diff --git a/src/tui/handlers/lyrics_view.rs b/src/tui/handlers/lyrics_view.rs deleted file mode 100644 index 70721ea0..00000000 --- a/src/tui/handlers/lyrics_view.rs +++ /dev/null @@ -1,69 +0,0 @@ -use super::common_key_events; -use crate::core::app::{active_lyric_index, App, LyricsStatus}; -use crate::tui::event::Key; -use std::time::Instant; - -pub fn handler(key: Key, app: &mut App) { - match key { - Key::Char('f') => { - app.view.lyrics_view.manual_index = None; - } - k if k == app.user_config.keys.back => { - app.pop_navigation_stack(); - } - k if common_key_events::up_event(k, &app.user_config.keys) => scroll_by(app, -1), - k if common_key_events::down_event(k, &app.user_config.keys) => scroll_by(app, 1), - k if common_key_events::left_event(k, &app.user_config.keys) => nudge_timing(app, -500), - k if common_key_events::right_event(k, &app.user_config.keys) => nudge_timing(app, 500), - k if k == app.user_config.keys.next_page => scroll_by(app, 5), - k if k == app.user_config.keys.previous_page => scroll_by(app, -5), - k if k == app.user_config.keys.jump_to_start => jump_to(app, 0), - k if k == app.user_config.keys.jump_to_end => jump_to(app, usize::MAX), - _ => {} - } -} - -/// Scroll the browsed line by `delta`, entering manual mode from the -/// currently playing line when auto-follow was active. -pub(super) fn scroll_by(app: &mut App, delta: i64) { - if app.lyrics_status != LyricsStatus::Found { - return; - } - let Some(lyrics) = &app.lyrics else { - return; - }; - if lyrics.is_empty() { - return; - } - let from = app - .view - .lyrics_view - .manual_index - .unwrap_or_else(|| active_lyric_index(lyrics, app.lyric_progress_ms())); - let target = (from as i64 + delta).clamp(0, lyrics.len() as i64 - 1) as usize; - app.view.lyrics_view.manual_index = Some(target); - app.view.lyrics_view.last_manual_input = Some(Instant::now()); -} - -/// Shift lyric timing relative to playback, for correcting misaligned LRC -/// files. Positive delta shows lyrics earlier. -fn nudge_timing(app: &mut App, delta_ms: i64) { - if app.lyrics_status != LyricsStatus::Found { - return; - } - app.view.lyrics_view.timing_offset_ms += delta_ms; -} - -fn jump_to(app: &mut App, index: usize) { - if app.lyrics_status != LyricsStatus::Found { - return; - } - let Some(lyrics) = &app.lyrics else { - return; - }; - if lyrics.is_empty() { - return; - } - app.view.lyrics_view.manual_index = Some(index.min(lyrics.len() - 1)); - app.view.lyrics_view.last_manual_input = Some(Instant::now()); -} diff --git a/src/tui/handlers/miniplayer.rs b/src/tui/handlers/miniplayer.rs deleted file mode 100644 index 57f55058..00000000 --- a/src/tui/handlers/miniplayer.rs +++ /dev/null @@ -1,12 +0,0 @@ -use super::playbar; -use crate::core::app::App; -use crate::tui::event::Key; - -pub fn handler(key: Key, app: &mut App) { - if key == app.user_config.keys.back { - app.pop_navigation_stack(); - return; - } - - playbar::handle_action_key(key, app); -} diff --git a/src/tui/handlers/mod.rs b/src/tui/handlers/mod.rs deleted file mode 100644 index 263b455e..00000000 --- a/src/tui/handlers/mod.rs +++ /dev/null @@ -1,1206 +0,0 @@ -#[cfg(feature = "ai-dj")] -mod ai_dj; -mod album_list; -mod album_tracks; -mod analysis; -mod announcement_prompt; -mod artist; -mod artists; -mod common_key_events; -mod community_pin_prompt; -#[cfg(feature = "cover-art")] -mod cover_art_view; -mod create_playlist; -mod dialog; -mod discover; -mod empty; -mod episode_table; -mod error_screen; -mod filter_input; -mod friends; -mod help_menu; -mod home; -mod input; -mod library; -mod local_browser; -mod lyrics_view; -mod miniplayer; -mod mouse; -mod party; -mod playbar; -mod playlist; -mod plugin_screen; -mod podcasts; -mod queue_menu; -mod recap_prompt; -mod recently_played; -pub mod resize; -mod search_results; -mod select_device; -mod settings; -mod sort_menu; -mod stats; -mod track_table; - -use crate::core::action::{Action, CopyTarget, NavTarget}; -use crate::core::app::{ - ActiveBlock, App, ArtistBlock, InputContext, PlaybackOwner, RouteId, SearchResultBlock, - NOTHING_PLAYING_STATUS, -}; -use crate::core::requirement::{Availability, Capability, Requirement}; -use crate::core::source::Source; -use crate::tui::event::Key; - -pub use input::handler as input_handler; -pub use mouse::handler as mouse_handler; - -#[cfg(target_os = "macos")] -fn key_matches_open_settings_binding(key: Key, binding: Key) -> bool { - key == binding - || (binding == Key::Alt(',') && key == Key::Char('≤')) - || (binding == Key::Ctrl(',') - && (key == Key::Ctrl('l') - || key == Key::Ctrl('L') - || key == Key::Ctrl('4') - || key == Key::Ctrl('<'))) -} - -#[cfg(not(target_os = "macos"))] -fn key_matches_open_settings_binding(key: Key, binding: Key) -> bool { - key == binding -} - -fn should_route_friends_before_globals(key: Key, app: &App) -> bool { - if app.get_current_route().active_block != ActiveBlock::Friends { - return false; - } - - app.view.friend_add_dialog_visible - || !app.view.friend_search_input.is_empty() - || matches!( - key, - Key::Char('a') | Key::Char('c') | Key::Char('u') | Key::Tab - ) -} - -pub fn handle_app(key: Key, app: &mut App) { - // Plugin popup is a modal: intercept all keys before anything else. - if app.plugin_popup.is_some() { - match key { - Key::Esc | Key::Char('q') => { - app.apply(Action::ClosePopup); - } - k if common_key_events::up_event(k, &app.user_config.keys) => { - app.view.plugin_popup_scroll = app.view.plugin_popup_scroll.saturating_sub(1); - } - k if common_key_events::down_event(k, &app.user_config.keys) => { - let max_scroll = app - .plugin_popup - .as_ref() - .map(|p| p.lines.len().saturating_sub(1) as u16) - .unwrap_or(0); - app.view.plugin_popup_scroll = app - .view - .plugin_popup_scroll - .saturating_add(1) - .min(max_scroll); - } - _ => {} // swallow all other keys - } - return; - } - - // Help filtering is an inline modal input. Give it priority over global - // bindings so queries can contain keys such as d, n, p, q, or ?. - if app.get_current_route().active_block == ActiveBlock::HelpMenu - && (app.view.help_filter_editing || key == app.user_config.keys.search) - { - help_menu::handler(key, app); - return; - } - - // Settings has the same inline modal inputs: an open edit, the unsaved-changes - // prompt, and the row filter. Each needs the raw key before global bindings - // claim it, and the search binding is what opens the filter in the first place. - if app.get_current_route().active_block == ActiveBlock::Settings - && (app.view.settings_unsaved_prompt_visible - || app.view.settings_edit_mode - || app.view.settings_filter_editing - || key == app.user_config.keys.search) - { - settings::handler(key, app); - return; - } - - // When Party popup is open, all keys go to the party handler first (so 'c' and 'l' aren't stolen by global bindings). - if app.get_current_route().active_block == ActiveBlock::Party { - handle_block_events(key, app); - return; - } - - // The DJ prompt is a typing surface, so it needs first refusal on every key — - // otherwise global bindings eat any character that happens to be a shortcut - // ('d' for devices, space for play/pause, and so on). - #[cfg(feature = "ai-dj")] - if app.get_current_route().active_block == ActiveBlock::AiDj { - handle_block_events(key, app); - return; - } - - // When Create Playlist form is open, all keys go directly to the form handler - // (so typed characters aren't stolen by global bindings like 'd', space, etc.) - if app.get_current_route().active_block == ActiveBlock::CreatePlaylistForm { - handle_block_events(key, app); - return; - } - - // Friends has a few local keys that conflict with globals, plus inline input modes - // that need first chance to consume typed characters. - if should_route_friends_before_globals(key, app) { - handle_block_events(key, app); - return; - } - - if app.maybe_activate_open_settings_fallback(key) { - app.open_settings_screen(); - if app.pending_keybinding_persist_key().is_some() { - app.push_navigation_stack( - RouteId::Dialog, - ActiveBlock::Dialog(crate::core::app::DialogContext::PersistKeybindingFallback), - ); - } - return; - } - - let effective_open_settings = app.effective_open_settings_key(); - if key_matches_open_settings_binding(key, app.user_config.keys.open_settings) - || key_matches_open_settings_binding(key, effective_open_settings) - { - app.apply(Action::Navigate(NavTarget::Settings)); - return; - } - - // First handle any global event and then move to block event - match key { - Key::Esc => { - if app.get_current_route().active_block == ActiveBlock::Settings { - settings::handler(key, app); - } else { - handle_escape(app); - } - } - _ if key == app.user_config.keys.jump_to_album => { - app.apply(Action::JumpToAlbum); - } - _ if key == app.user_config.keys.jump_to_artist_album => { - app.apply(Action::JumpToArtist); - } - _ if key == app.user_config.keys.jump_to_context => { - app.apply(Action::JumpToContext); - } - // Reachable from anywhere, without a trip through the sidebar row. - #[cfg(feature = "ai-dj")] - _ if key == app.user_config.keys.dj_open => { - ai_dj::open(app); - } - #[cfg(feature = "ai-dj")] - _ if key == app.user_config.keys.dj_toggle_auto_queue => { - app.apply(Action::ToggleDjAutoQueue); - } - #[cfg(feature = "ai-dj")] - _ if key == app.user_config.keys.dj_vibe_shift => { - app.apply(Action::DjVibeShift); - } - #[cfg(feature = "ai-dj")] - _ if key == app.user_config.keys.dj_toggle_fresh_only => { - app.apply(Action::ToggleDjFreshOnly); - } - #[cfg(feature = "ai-dj")] - _ if key == app.user_config.keys.dj_pick_model => { - app.apply(Action::OpenDjSetup); - } - _ if key == app.user_config.keys.manage_devices => { - app.apply(Action::Navigate(NavTarget::Devices)); - } - _ if key == app.user_config.keys.decrease_volume => { - app.apply(Action::VolumeDown); - } - _ if key == app.user_config.keys.increase_volume => { - app.apply(Action::VolumeUp); - } - // Press space to toggle playback - _ if key == app.user_config.keys.toggle_playback => { - app.apply(Action::TogglePlayback); - } - _ if key == app.user_config.keys.seek_backwards => { - app.apply(Action::SeekBackward); - } - _ if key == app.user_config.keys.seek_forwards => { - app.apply(Action::SeekForward); - } - _ if key == app.user_config.keys.next_track => { - app.apply(Action::NextTrack); - } - _ if key == app.user_config.keys.previous_track => { - app.apply(Action::PreviousTrack); - } - _ if key == app.user_config.keys.force_previous_track => { - app.apply(Action::ForcePreviousTrack); - } - _ if key == app.user_config.keys.help => { - help_menu::open(app); - } - _ if key == app.user_config.keys.show_queue => { - app.apply(Action::Navigate(NavTarget::Queue)); - } - - _ if key == app.user_config.keys.shuffle => { - app.apply(Action::ToggleShuffle); - } - _ if key == app.user_config.keys.repeat => { - app.apply(Action::CycleRepeat); - } - Key::Ctrl('f') - if app.get_current_route().active_block == ActiveBlock::TrackTable - && app.is_playlist_track_table_context() => - { - app.view.input.clear(); - app.view.input_idx = 0; - app.view.input_cursor_position = 0; - app.view.input_context = InputContext::PlaylistTrackSearch; - app.set_current_route_state(Some(ActiveBlock::Input), Some(ActiveBlock::Input)); - } - _ if key == app.user_config.keys.search => { - open_global_search(app); - } - _ if key == app.user_config.keys.copy_song_url => { - copy_url(app, Action::CopyUrl(CopyTarget::CurrentSong)); - } - _ if key == app.user_config.keys.copy_album_url => { - copy_url(app, Action::CopyUrl(CopyTarget::CurrentAlbum)); - } - _ if key == app.user_config.keys.audio_analysis => { - app.apply(Action::Navigate(NavTarget::Analysis)); - } - _ if key == app.user_config.keys.lyrics_view => { - app.apply(Action::Navigate(NavTarget::Lyrics)); - } - _ if key == app.user_config.keys.miniplayer_view => { - if is_input_mode(app) { - handle_block_events(key, app); - } else { - app.apply(Action::Navigate(NavTarget::MiniPlayer)); - } - } - #[cfg(feature = "cover-art")] - _ if key == app.user_config.keys.cover_art_view => { - app.push_navigation_stack(RouteId::CoverArtView, ActiveBlock::CoverArtView); - } - _ if key == app.user_config.keys.listening_party => { - app.apply(Action::Navigate(NavTarget::Party)); - } - _ if key == app.user_config.keys.like_track => { - if is_input_mode(app) { - handle_block_events(key, app); - } else if app.active_source == Source::Radio { - if app.get_current_route().active_block == ActiveBlock::SearchResultBlock { - handle_block_events(key, app); - } else if !search_results::favorite_current_radio_station(app) { - app.set_status_message("No radio station selected or playing".to_string(), 4); - } - } else if app.active_source.supports_like() { - app.apply(Action::ToggleSaveCurrentItem); - } else { - app.set_status_message( - format!("Like isn't available for {}", app.active_source.label()), - 4, - ); - } - } - #[cfg(feature = "scripting")] - _ if app.user_config.plugin_command_keys.contains_key(&key) => { - if is_input_mode(app) { - handle_block_events(key, app); - } else if let Some(name) = app.user_config.plugin_command_keys.get(&key).cloned() { - app.queue_plugin_command(name); - } - } - _ if key == app.user_config.keys.generate_recap => { - if is_input_mode(app) { - handle_block_events(key, app); - } else { - app.apply(Action::GenerateRecap); - } - } - // Resize sidebar: { decreases, } increases width - Key::Char('{') => { - if is_input_mode(app) { - handle_block_events(key, app); - } else { - resize::decrease_sidebar_width(app); - } - } - Key::Char('}') => { - if is_input_mode(app) { - handle_block_events(key, app); - } else { - resize::increase_sidebar_width(app); - } - } - // Resize playbar or library/playlist split depending on hovered pane: - // ( decreases height, ) increases height - Key::Char('(') => { - if is_input_mode(app) { - handle_block_events(key, app); - } else { - match app.get_current_route().hovered_block { - ActiveBlock::Library | ActiveBlock::MyPlaylists => resize::decrease_library_height(app), - _ => resize::decrease_playbar_height(app), - } - } - } - Key::Char(')') => { - if is_input_mode(app) { - handle_block_events(key, app); - } else { - match app.get_current_route().hovered_block { - ActiveBlock::Library | ActiveBlock::MyPlaylists => resize::increase_library_height(app), - _ => resize::increase_playbar_height(app), - } - } - } - // Reset all pane sizes to defaults - Key::Char('|') => { - if is_input_mode(app) { - handle_block_events(key, app); - } else { - resize::reset_layout(app); - } - } - Key::Char('W') => { - if is_input_mode(app) { - handle_block_events(key, app); - } else { - app.apply(Action::OpenAddPlayingTrackDialog); - } - } - _ => handle_block_events(key, app), - } -} - -/// Open the global search box. Never rewrite the error frame in place to -/// `{Error, Input}`: push dedupes on the top frame's id, so a surviving Error -/// frame makes every later error silently fail to render. Dismiss the error -/// and open search on the screen below. -pub(super) fn focus_global_search(app: &mut App) { - if app.get_current_route().id == RouteId::Error { - app.clear_api_error(); - } - app.view.input_context = InputContext::GlobalSearch; - app.set_current_route_state(Some(ActiveBlock::Input), Some(ActiveBlock::Input)); -} - -/// Focuses the search input, or reports why the key does nothing now. -pub(super) fn open_global_search(app: &mut App) -> bool { - match app.availability(Requirement::Capability(Capability::Search)) { - Availability::Available => { - focus_global_search(app); - true - } - Availability::NeedsSpotify => { - app.set_status_message("Search needs a Spotify session", 4); - false - } - Availability::NotForSource(_) | Availability::OnlyForSource(_) => { - app.set_status_message( - format!("Search isn't available for {}", app.active_source.label()), - 4, - ); - false - } - } -} - -/// The copy keys read the Spotify playback, so Spotify must own it and have an item. -fn copy_url(app: &mut App, copy: Action) { - match app.playback_owner() { - PlaybackOwner::Spotify | PlaybackOwner::NativeSpotify => { - let has_item = app - .current_playback_context - .as_ref() - .is_some_and(|context| context.item.is_some()); - if has_item { - app.apply(copy); - } else { - app.set_status_message(NOTHING_PLAYING_STATUS, 4); - } - } - PlaybackOwner::None => app.set_status_message(NOTHING_PLAYING_STATUS, 4), - PlaybackOwner::Queue | PlaybackOwner::Decoded => { - app.set_status_message("Copy URL needs a Spotify track playing", 4) - } - } -} - -fn is_input_mode(app: &App) -> bool { - matches!( - app.get_current_route().active_block, - ActiveBlock::Input - | ActiveBlock::Dialog(_) - | ActiveBlock::AnnouncementPrompt - | ActiveBlock::ExitPrompt - | ActiveBlock::CreatePlaylistForm - | ActiveBlock::RecapPrompt - | ActiveBlock::CommunityPinPrompt - ) || { - #[cfg(feature = "ai-dj")] - { - app.get_current_route().active_block == ActiveBlock::AiDj - } - #[cfg(not(feature = "ai-dj"))] - { - false - } - } -} - -// Handle event for the current active block -fn handle_block_events(key: Key, app: &mut App) { - let current_route = app.get_current_route(); - match current_route.active_block { - ActiveBlock::Analysis => { - analysis::handler(key, app); - } - ActiveBlock::ArtistBlock => { - artist::handler(key, app); - } - ActiveBlock::Input => { - input::handler(key, app); - } - ActiveBlock::MyPlaylists => { - playlist::handler(key, app); - } - ActiveBlock::TrackTable => { - track_table::handler(key, app); - } - ActiveBlock::EpisodeTable => { - episode_table::handler(key, app); - } - ActiveBlock::HelpMenu => { - help_menu::handler(key, app); - } - ActiveBlock::Error => { - error_screen::handler(key, app); - } - ActiveBlock::SelectDevice => { - select_device::handler(key, app); - } - ActiveBlock::SearchResultBlock => { - search_results::handler(key, app); - } - ActiveBlock::Home => { - home::handler(key, app); - } - ActiveBlock::AlbumList => { - album_list::handler(key, app); - } - ActiveBlock::AlbumTracks => { - album_tracks::handler(key, app); - } - ActiveBlock::Library => { - library::handler(key, app); - } - ActiveBlock::Empty => { - empty::handler(key, app); - } - ActiveBlock::RecentlyPlayed => { - recently_played::handler(key, app); - } - ActiveBlock::Artists => { - artists::handler(key, app); - } - ActiveBlock::LocalBrowser => { - local_browser::handler(key, app); - } - ActiveBlock::Discover => { - discover::handler(key, app); - } - ActiveBlock::Podcasts => { - podcasts::handler(key, app); - } - ActiveBlock::PlayBar => { - playbar::handler(key, app); - } - ActiveBlock::LyricsView => { - lyrics_view::handler(key, app); - } - ActiveBlock::MiniPlayer => { - miniplayer::handler(key, app); - } - ActiveBlock::CoverArtView => { - #[cfg(feature = "cover-art")] - cover_art_view::handler(key, app); - } - ActiveBlock::Dialog(_) => { - dialog::handler(key, app); - } - - ActiveBlock::AnnouncementPrompt => { - announcement_prompt::handler(key, app); - } - ActiveBlock::ExitPrompt => {} - ActiveBlock::Settings => { - settings::handler(key, app); - } - ActiveBlock::SortMenu => { - sort_menu::handler(key, app); - } - ActiveBlock::Queue => { - queue_menu::handler(key, app); - } - ActiveBlock::Party => { - party::handler(key, app); - } - ActiveBlock::CreatePlaylistForm => { - create_playlist::handler(key, app); - } - ActiveBlock::Friends => { - friends::handler(key, app); - } - ActiveBlock::Stats => { - stats::handler(key, app); - } - #[cfg(feature = "ai-dj")] - ActiveBlock::AiDj => { - ai_dj::handler(key, app); - } - ActiveBlock::RecapPrompt => { - recap_prompt::handler(key, app); - } - ActiveBlock::CommunityPinPrompt => { - community_pin_prompt::handler(key, app); - } - ActiveBlock::PluginScreen => { - plugin_screen::handler(key, app); - } - } -} - -fn handle_escape(app: &mut App) { - match app.get_current_route().active_block { - // Delegated rather than duplicated, the way `ActiveBlock::Friends` is below. - // The DJ's Esc is now three-state (step back through the picker, clear a - // half-typed prompt, leave the screen), so a second copy of the rule here would - // go stale the moment either side changed. - #[cfg(feature = "ai-dj")] - ActiveBlock::AiDj => ai_dj::handler(Key::Esc, app), - ActiveBlock::SearchResultBlock => { - app.view.search_selected_block = SearchResultBlock::Empty; - } - ActiveBlock::ArtistBlock => { - if let Some(artist) = &mut app.artist { - artist.artist_selected_block = ArtistBlock::Empty; - } - } - ActiveBlock::Error => { - app.pop_navigation_stack(); - } - ActiveBlock::Dialog(dialog_context) => { - if dialog_context == crate::core::app::DialogContext::PersistKeybindingFallback { - app.set_status_message("Using Alt+, for this session only", 4); - } - app.pop_navigation_stack(); - app.clear_dialog_state(); - } - ActiveBlock::HelpMenu => { - if app.view.help_filter_editing || !app.view.help_filter.is_empty() { - help_menu::clear_filter(app); - } else { - app.pop_navigation_stack(); - } - } - ActiveBlock::Queue => { - app.pop_navigation_stack(); - } - ActiveBlock::Party => { - app.pop_navigation_stack(); - } - ActiveBlock::LyricsView => { - // Esc first leaves lyrics browsing mode; a second Esc closes the view. - if app.view.lyrics_view.manual_index.is_some() { - app.view.lyrics_view.manual_index = None; - } else { - app.pop_navigation_stack(); - } - } - ActiveBlock::SelectDevice - | ActiveBlock::CoverArtView - | ActiveBlock::MiniPlayer - | ActiveBlock::PluginScreen => { - app.pop_navigation_stack(); - } - // This is a global view that has no active/inactive distinction so do nothing - ActiveBlock::Analysis => {} - - // Announcement prompt must be dismissed with Enter/Esc, not global escape - ActiveBlock::AnnouncementPrompt => {} - ActiveBlock::ExitPrompt => {} - // Sort menu closes on escape - ActiveBlock::SortMenu => { - app.view.sort_menu_visible = false; - app.view.sort_context = None; - app.set_current_route_state(Some(ActiveBlock::Empty), None); - } - ActiveBlock::CreatePlaylistForm => { - create_playlist::handler(Key::Esc, app); - } - ActiveBlock::Friends => { - friends::handler(Key::Esc, app); - } - // "[ESC] Later" on the recap popup: dismiss without opening. - ActiveBlock::RecapPrompt => { - app.apply(Action::DismissRecapPrompt); - } - // Esc keeps the pin but still marks the prompt shown so it never re-nags. - ActiveBlock::CommunityPinPrompt => { - community_pin_prompt::handler(Key::Esc, app); - } - _ => { - app.set_current_route_state(Some(ActiveBlock::Empty), None); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::app::TrackTableContext; - use crate::core::test_helpers::full_track; - use crate::core::user_config::UserConfig; - use crate::infra::network::IoEvent; - use chrono::Utc; - use rspotify::model::{ - context::{Actions, CurrentPlaybackContext}, - device::DevicePayload, - enums::{DeviceType, RepeatState}, - idtypes::PlaylistId, - CurrentlyPlayingType, Device, PlayableItem, - }; - use rspotify::prelude::Id; - use std::{ - sync::mpsc::{channel, TryRecvError}, - time::SystemTime, - }; - - fn friends_app() -> App { - let mut app = App::default(); - app.push_navigation_stack(RouteId::Friends, ActiveBlock::Friends); - app - } - - #[test] - fn search_key_on_the_error_page_dismisses_the_error_first() { - let mut app = App::default_connected(); - app.handle_error(anyhow::anyhow!("boom")); - assert_eq!(app.get_current_route().id, RouteId::Error); - - handle_app(app.user_config.keys.search, &mut app); - - let route = app.get_current_route(); - assert_ne!(route.id, RouteId::Error, "the error frame must not survive"); - assert_eq!(route.active_block, ActiveBlock::Input); - assert!( - app.api_error().is_empty(), - "the dismissal drops the message with the frame" - ); - - // A later error must still render: the dismissal above is what keeps the - // navigation stack free of a deduping `{Error, ...}` frame. - handle_app(Key::Esc, &mut app); - app.handle_error(anyhow::anyhow!("again")); - assert_eq!(app.get_current_route().id, RouteId::Error); - } - - #[test] - fn global_shift_w_adds_current_track_from_anywhere() { - let mut app = App::default(); - app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library)); - - handle_app(Key::Char('W'), &mut app); - - assert_eq!(app.status_message(), Some("No track currently playing")); - } - - #[test] - fn global_shift_w_is_not_intercepted_in_input_mode() { - let mut app = App::default(); - app.set_current_route_state(Some(ActiveBlock::Input), Some(ActiveBlock::Input)); - - handle_app(Key::Char('W'), &mut app); - - assert_eq!(app.view.input, vec!['W']); - assert!(app.status_message().is_none()); - } - - #[test] - fn force_previous_track_dispatches_from_anywhere() { - let mut app = App::default(); - app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library)); - - // Default force_previous_track is Key::Char('P') - handle_app(Key::Char('P'), &mut app); - - // force_previous_track dispatches through App which requires no io_tx in tests, - // so just confirm the route didn't change (it shouldn't navigate anywhere) - assert_eq!(app.get_current_route().active_block, ActiveBlock::Empty); - } - - #[test] - fn escape_exits_device_selector() { - let mut app = App::default(); - app.push_navigation_stack(RouteId::SelectedDevice, ActiveBlock::SelectDevice); - - handle_app(Key::Esc, &mut app); - - assert_ne!( - app.get_current_route().active_block, - ActiveBlock::SelectDevice - ); - } - - #[test] - fn enter_on_device_selector_dispatches_transfer_and_exits() { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.devices = Some(DevicePayload { - devices: vec![Device { - id: Some("device-1".to_string()), - is_active: false, - is_private_session: false, - is_restricted: false, - name: "Desk Speaker".to_string(), - _type: DeviceType::Computer, - volume_percent: Some(42), - }], - }); - app.view.selected_device_index = Some(0); - app.push_navigation_stack(RouteId::SelectedDevice, ActiveBlock::SelectDevice); - - handle_app(Key::Enter, &mut app); - - match rx.recv().unwrap() { - IoEvent::TransferPlaybackToDevice(device_id, persist_device_id) => { - assert_eq!(device_id, "device-1"); - assert!(persist_device_id); - } - _ => panic!("unexpected event"), - } - assert_ne!( - app.get_current_route().active_block, - ActiveBlock::SelectDevice - ); - assert_eq!( - app.status_message(), - Some("Switching playback to Desk Speaker") - ); - } - - #[test] - fn global_shift_f_likes_current_track_from_anywhere() { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - let track = full_track("0000000000000000000001", "Track 1"); - let expected_track_id = track.id.clone().unwrap(); - - app.current_playback_context = Some(CurrentPlaybackContext { - device: Device { - id: Some("device-1".to_string()), - is_active: true, - is_private_session: false, - is_restricted: false, - name: "Desk Speaker".to_string(), - _type: DeviceType::Computer, - volume_percent: Some(42), - }, - repeat_state: RepeatState::Off, - shuffle_state: false, - context: None, - timestamp: Utc::now(), - progress: None, - is_playing: false, - item: Some(PlayableItem::Track(track)), - currently_playing_type: CurrentlyPlayingType::Track, - actions: Actions::default(), - }); - app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library)); - - // Default like_track is Key::Char('F') - handle_app(Key::Char('F'), &mut app); - - match rx.recv().unwrap() { - IoEvent::ToggleSaveTrack(track_id) => { - assert_eq!(track_id, expected_track_id.uri()); - } - _ => panic!("unexpected event"), - } - } - - #[test] - fn global_shift_f_is_not_intercepted_in_input_mode() { - let mut app = App::default(); - app.set_current_route_state(Some(ActiveBlock::Input), Some(ActiveBlock::Input)); - - handle_app(Key::Char('F'), &mut app); - - // In input mode, 'F' should be added to the input buffer - assert_eq!(app.view.input, vec!['F']); - } - - #[test] - fn friends_a_opens_add_dialog_before_global_album_jump() { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - let track = full_track("0000000000000000000001", "Track 1"); - app.current_playback_context = Some(CurrentPlaybackContext { - device: Device { - id: Some("device-1".to_string()), - is_active: true, - is_private_session: false, - is_restricted: false, - name: "Desk Speaker".to_string(), - _type: DeviceType::Computer, - volume_percent: Some(42), - }, - repeat_state: RepeatState::Off, - shuffle_state: false, - context: None, - timestamp: Utc::now(), - progress: None, - is_playing: false, - item: Some(PlayableItem::Track(track)), - currently_playing_type: CurrentlyPlayingType::Track, - actions: Actions::default(), - }); - app.push_navigation_stack(RouteId::Friends, ActiveBlock::Friends); - - handle_app(Key::Char('a'), &mut app); - - assert!(app.view.friend_add_dialog_visible); - assert_eq!(app.get_current_route().active_block, ActiveBlock::Friends); - assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty))); - } - - #[test] - fn friends_c_prefers_friend_code_copy_over_global_song_copy() { - let mut app = friends_app(); - app.friend_code = Some("jay-1234".to_string()); - app.clipboard = None; - - handle_app(Key::Char('c'), &mut app); - - assert_eq!(app.status_message(), Some("Clipboard not available")); - assert!(!app.view.friend_add_dialog_visible); - } - - #[test] - fn friends_search_buffer_keeps_globally_bound_characters_local() { - let mut app = friends_app(); - app.view.friend_search_input = vec!['j']; - - handle_app(Key::Char('a'), &mut app); - handle_app(Key::Char('c'), &mut app); - - assert_eq!(app.view.friend_search_input, vec!['j', 'a', 'c']); - assert!(!app.view.friend_add_dialog_visible); - assert!(app.status_message().is_none()); - } - - #[test] - fn friends_without_local_state_still_allows_non_conflicting_globals() { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.push_navigation_stack(RouteId::Friends, ActiveBlock::Friends); - - handle_app(app.user_config.keys.next_track, &mut app); - - match rx.recv().unwrap() { - IoEvent::NextTrack => {} - _ => panic!("unexpected event"), - } - assert!(app.view.friend_search_input.is_empty()); - assert!(!app.view.friend_add_dialog_visible); - } - - #[test] - fn friends_add_dialog_keeps_priority_for_conflicting_keys() { - let mut app = friends_app(); - app.open_friend_add_dialog(); - - handle_app(Key::Char('c'), &mut app); - - assert!(app.view.friend_add_dialog_visible); - assert_eq!(app.view.friend_add_input, vec!['c']); - assert!(app.status_message().is_none()); - } - - #[test] - fn ctrl_f_in_playlist_track_table_opens_playlist_search_input() { - let mut app = App::default(); - let playlist_id = PlaylistId::from_id("37i9dQZF1DX4WYpdgoIcn6") - .unwrap() - .into_static(); - app.track_table.context = Some(TrackTableContext::MyPlaylists); - app.playlist_track_table_id = Some(playlist_id); - app.push_navigation_stack(RouteId::TrackTable, ActiveBlock::TrackTable); - - handle_app(Key::Ctrl('f'), &mut app); - - assert_eq!(app.view.input_context, InputContext::PlaylistTrackSearch); - assert_eq!(app.get_current_route().active_block, ActiveBlock::Input); - } - - #[test] - fn search_key_in_playlist_track_table_opens_global_search_input() { - let mut app = App::default_connected(); - let playlist_id = PlaylistId::from_id("37i9dQZF1DX4WYpdgoIcn6") - .unwrap() - .into_static(); - app.track_table.context = Some(TrackTableContext::MyPlaylists); - app.playlist_track_table_id = Some(playlist_id); - app.push_navigation_stack(RouteId::TrackTable, ActiveBlock::TrackTable); - - handle_app(app.user_config.keys.search, &mut app); - - assert_eq!(app.view.input_context, InputContext::GlobalSearch); - assert_eq!(app.get_current_route().active_block, ActiveBlock::Input); - } - - #[test] - fn search_key_outside_playlist_track_table_opens_global_search_input() { - let mut app = App::default_connected(); - app.track_table.context = Some(TrackTableContext::SavedTracks); - app.push_navigation_stack(RouteId::TrackTable, ActiveBlock::TrackTable); - - handle_app(app.user_config.keys.search, &mut app); - - assert_eq!(app.view.input_context, InputContext::GlobalSearch); - assert_eq!(app.get_current_route().active_block, ActiveBlock::Input); - } - - #[cfg(target_os = "macos")] - #[test] - fn plain_comma_fallback_opens_settings_and_prompts_to_persist() { - let mut app = App::default(); - app.user_config.keys.open_settings = Key::Ctrl(','); - app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library)); - - handle_app(Key::Char(','), &mut app); - - assert_eq!( - app.keybinding_runtime.effective_open_settings, - Some(Key::Alt(',')) - ); - assert_eq!( - app.get_current_route().active_block, - ActiveBlock::Dialog(crate::core::app::DialogContext::PersistKeybindingFallback) - ); - assert!(app.status_message().is_some()); - } - - #[cfg(target_os = "macos")] - #[test] - fn plain_comma_does_not_override_track_table_sort_menu() { - let mut app = App::default(); - app.user_config.keys.open_settings = Key::Ctrl(','); - app.track_table.context = Some(TrackTableContext::MyPlaylists); - app.push_navigation_stack(RouteId::TrackTable, ActiveBlock::TrackTable); - - handle_app(Key::Char(','), &mut app); - - assert_eq!(app.get_current_route().active_block, ActiveBlock::SortMenu); - } - - // --- U5: source-gate tests --- - - #[test] - fn like_track_shows_hint_when_local_source() { - let mut app = App::default(); - app.active_source = Source::Local; - app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library)); - - // Default like_track key is 'F' (Shift+F) - handle_app(app.user_config.keys.like_track, &mut app); - - assert_eq!( - app.status_message(), - Some("Like isn't available for Local Files") - ); - } - - #[test] - fn like_track_does_not_show_hint_when_spotify_source() { - // No io_tx here so dispatch is a no-op; we just verify no status is set. - let mut app = App::default(); - app.active_source = Source::Spotify; - app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library)); - - handle_app(app.user_config.keys.like_track, &mut app); - - // No playback context, so toggle_like returns early with a different message - // (or nothing). The important thing is it's NOT the Local-gate message. - assert_ne!( - app.status_message(), - Some("Like isn't available for Local Files") - ); - } - - #[test] - fn global_like_key_favorites_radio_search_station() { - use crate::core::pagination::Paged; - use crate::core::plugin_api::TrackInfo; - use crate::core::user_config::UserConfigPaths; - - let dir = tempfile::tempdir().unwrap(); - let mut app = App::default(); - app.active_source = Source::Radio; - app.user_config.path_to_config = Some(UserConfigPaths { - config_file_path: dir.path().join("config.yml"), - }); - app.state_path = Some(dir.path().join("state.yml")); - app.search_results.tracks = Some(Paged { - items: vec![TrackInfo { - uri: Some("radio:https://example.com/stream".to_string()), - name: "Example FM".to_string(), - artists: vec![], - album: String::new(), - duration_ms: 0, - id: None, - album_id: None, - artist_refs: vec![], - is_playable: true, - is_local: false, - track_number: 0, - explicit: false, - image_url: None, - }], - total: 1, - ..Default::default() - }); - app.view.search_selected_tracks_index = Some(0); - app.push_navigation_stack(RouteId::Search, ActiveBlock::SearchResultBlock); - - let favorite_key = app.user_config.keys.like_track; - handle_app(favorite_key, &mut app); - - assert_eq!(app.runtime_state.radio_stations.len(), 1); - assert_eq!( - app.runtime_state.radio_stations[0].url, - "https://example.com/stream" - ); - assert_eq!( - app.status_message(), - Some("Favorited radio station: Example FM") - ); - } - - fn spotify_track_context() -> CurrentPlaybackContext { - CurrentPlaybackContext { - device: Device { - id: Some("device-1".to_string()), - is_active: true, - is_private_session: false, - is_restricted: false, - name: "Desk Speaker".to_string(), - _type: DeviceType::Computer, - volume_percent: Some(42), - }, - repeat_state: RepeatState::Off, - shuffle_state: false, - context: None, - timestamp: Utc::now(), - progress: None, - is_playing: true, - item: Some(PlayableItem::Track(full_track( - "0000000000000000000001", - "Track 1", - ))), - currently_playing_type: CurrentlyPlayingType::Track, - actions: Actions::default(), - } - } - - #[test] - fn copy_url_keys_follow_the_playback_owner_not_the_browse_scope() { - let mut app = App::default().under_source(Source::Local); - app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library)); - - handle_app(app.user_config.keys.copy_song_url, &mut app); - assert_eq!(app.status_message(), Some("Nothing is playing")); - - app.set_status_message("cleared", 1); - handle_app(app.user_config.keys.copy_album_url, &mut app); - assert_eq!(app.status_message(), Some("Nothing is playing")); - - // An idle session is not a playing track. - let mut app = App::default_connected().under_source(Source::Local); - app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library)); - handle_app(app.user_config.keys.copy_album_url, &mut app); - assert_eq!(app.status_message(), Some("Nothing is playing")); - - // A Spotify track playing under the Local scope still copies; with no - // clipboard in a test App the copy exits without a message. - let mut app = App::default_connected() - .under_source(Source::Local) - .with_playback(spotify_track_context()); - app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library)); - handle_app(app.user_config.keys.copy_album_url, &mut app); - assert_eq!(app.status_message(), None); - } - - #[test] - fn the_search_key_without_a_session_says_so_instead_of_opening_the_input() { - let mut app = App::default(); - app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library)); - - handle_app(app.user_config.keys.search, &mut app); - - assert_ne!(app.get_current_route().active_block, ActiveBlock::Input); - assert_eq!(app.status_message(), Some("Search needs a Spotify session")); - } - - /// The DJ's own tests all call `ai_dj::handler` directly, which is the branch - /// taken once the DJ screen already has focus (`handle_app` returns early for - /// `ActiveBlock::AiDj`). That leaves the `dj_pick_model` arm of the global match - /// untested, and it is the arm that makes the key work from Home, Search, Library - /// or anywhere else — which is the only reason a global binding exists rather - /// than a DJ-screen-local one. - #[cfg(feature = "ai-dj")] - #[test] - fn the_reopen_binding_opens_the_dj_with_the_picker_from_another_screen() { - let mut app = App::default(); - app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library)); - - handle_app(app.user_config.keys.dj_pick_model, &mut app); - - assert_eq!(app.get_current_route().id, RouteId::AiDj); - assert!( - app.dj.setup.is_some(), - "and it arrives with the picker already up, not on an empty prompt" - ); - } - - #[test] - fn copy_song_url_proceeds_when_spotify_source() { - // No clipboard in default App, so copy_song_url exits early; but no Local gate message. - let mut app = App::default(); - app.active_source = Source::Spotify; - app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library)); - - handle_app(app.user_config.keys.copy_song_url, &mut app); - - assert_ne!( - app.status_message(), - Some("Copy URL isn't available for Local Files") - ); - } -} diff --git a/src/tui/handlers/mouse.rs b/src/tui/handlers/mouse.rs deleted file mode 100644 index 85714861..00000000 --- a/src/tui/handlers/mouse.rs +++ /dev/null @@ -1,2196 +0,0 @@ -use super::{common_key_events, library, lyrics_view, playbar, playlist, settings}; -use crate::core::action::{Action, NavTarget}; -use crate::core::app::{ActiveBlock, App, RouteId, SettingValue, SettingsCategory}; -use crate::tui::event::Key; -use crate::tui::layout::{ - compute_main_layout, fullscreen_view_layout, miniplayer_playbar_area, MainLayoutAreas, -}; -use crate::tui::ui::player::{playbar_control_at, playbar_progress_line}; -use crate::tui::ui::settings::{filtered_setting_indices, settings_layout}; -use crate::tui::ui::tables::table_scroll_offset; -use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; -use ratatui::layout::{Constraint, Layout, Rect}; -use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; - -const SETTINGS_UNSAVED_PROMPT_WIDTH: u16 = 58; -const SETTINGS_UNSAVED_PROMPT_HEIGHT: u16 = 9; - -pub fn handler(mouse: MouseEvent, app: &mut App) { - let current_route = app.get_current_route(); - let current_route_id = current_route.id.clone(); - let current_active_block = current_route.active_block; - - if current_active_block == ActiveBlock::Settings { - handle_settings_screen_mouse(mouse, app); - return; - } - - if current_route_id == RouteId::LyricsView { - let playbar_area = fullscreen_view_playbar_area(app); - let over_playbar = - playbar_area.is_some_and(|area| rect_contains(area, mouse.column, mouse.row)); - if !over_playbar { - match mouse.kind { - MouseEventKind::ScrollUp => { - lyrics_view::scroll_by(app, -1); - return; - } - MouseEventKind::ScrollDown => { - lyrics_view::scroll_by(app, 1); - return; - } - _ => {} - } - } - handle_fullscreen_playbar_mouse(ActiveBlock::LyricsView, playbar_area, mouse, app); - return; - } - - if current_route_id == RouteId::CoverArtView { - handle_fullscreen_playbar_mouse( - ActiveBlock::CoverArtView, - fullscreen_view_playbar_area(app), - mouse, - app, - ); - return; - } - - if current_route_id == RouteId::MiniPlayer { - handle_fullscreen_playbar_mouse( - ActiveBlock::MiniPlayer, - miniplayer_view_playbar_area(app), - mouse, - app, - ); - return; - } - - if !is_main_layout_mouse_interactive(current_active_block) { - return; - } - - let Some(areas) = main_layout_areas(app) else { - return; - }; - - if let Some(input_area) = areas.input { - if rect_contains(input_area, mouse.column, mouse.row) { - handle_input_mouse(mouse, input_area, app); - return; - } - } - - if let Some(help_area) = areas.help { - if rect_contains(help_area, mouse.column, mouse.row) { - handle_help_mouse(mouse, app); - return; - } - } - - if let Some(settings_area) = areas.settings { - if rect_contains(settings_area, mouse.column, mouse.row) { - handle_settings_mouse(mouse, app); - return; - } - } - - if rect_contains(areas.playbar, mouse.column, mouse.row) { - handle_playbar_mouse(mouse, areas.playbar, ActiveBlock::PlayBar, app); - return; - } - - if rect_contains(areas.library, mouse.column, mouse.row) { - handle_library_mouse(mouse, areas.library, app); - return; - } - - if rect_contains(areas.playlists, mouse.column, mouse.row) { - handle_playlist_mouse(mouse, areas.playlists, app); - return; - } - - if rect_contains(areas.content, mouse.column, mouse.row) { - handle_content_table_mouse(mouse, areas.content, current_route_id, app); - } -} - -fn handle_library_mouse(mouse: MouseEvent, list_area: Rect, app: &mut App) { - match mouse.kind { - MouseEventKind::ScrollDown => { - focus_library(app); - library::select_next(app); - } - MouseEventKind::ScrollUp => { - focus_library(app); - library::select_previous(app); - } - MouseEventKind::Down(MouseButton::Left) => { - focus_library(app); - select_clicked_library_item(mouse.row, list_area, app); - } - _ => {} - } -} - -fn handle_playlist_mouse(mouse: MouseEvent, list_area: Rect, app: &mut App) { - match mouse.kind { - MouseEventKind::ScrollDown => { - focus_playlists(app); - playlist::select_next(app); - } - MouseEventKind::ScrollUp => { - focus_playlists(app); - playlist::select_previous(app); - } - MouseEventKind::Down(MouseButton::Left) => { - focus_playlists(app); - select_clicked_playlist(mouse.row, list_area, app); - } - _ => {} - } -} - -fn handle_content_table_mouse( - mouse: MouseEvent, - table_area: Rect, - route_id: RouteId, - app: &mut App, -) { - let Some(active_block) = common_key_events::content_active_block_for_route(&route_id) else { - return; - }; - - if !matches!( - mouse.kind, - MouseEventKind::ScrollDown | MouseEventKind::ScrollUp | MouseEventKind::Down(MouseButton::Left) - ) { - return; - } - - let item_count = content_table_item_count(active_block, app); - if item_count == 0 { - return; - } - - match mouse.kind { - MouseEventKind::ScrollDown => { - focus_content_table(active_block, app); - super::handle_block_events(Key::Down, app); - } - MouseEventKind::ScrollUp => { - focus_content_table(active_block, app); - super::handle_block_events(Key::Up, app); - } - MouseEventKind::Down(MouseButton::Left) => { - focus_content_table(active_block, app); - select_clicked_content_table_item(active_block, mouse.row, table_area, item_count, app); - } - _ => {} - } -} - -fn handle_input_mouse(mouse: MouseEvent, input_area: Rect, app: &mut App) { - if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - return; - } - - if super::open_global_search(app) { - set_input_cursor_from_column(input_area, mouse.column, app); - } -} - -fn handle_help_mouse(mouse: MouseEvent, app: &mut App) { - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - super::help_menu::open(app); - } -} - -fn handle_settings_mouse(mouse: MouseEvent, app: &mut App) { - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - app.apply(Action::Navigate(NavTarget::Settings)); - } -} - -fn handle_fullscreen_playbar_mouse( - focus_block: ActiveBlock, - playbar_area: Option, - mouse: MouseEvent, - app: &mut App, -) { - let Some(playbar_area) = playbar_area else { - return; - }; - - if rect_contains(playbar_area, mouse.column, mouse.row) { - handle_playbar_mouse(mouse, playbar_area, focus_block, app); - } -} - -fn handle_playbar_mouse( - mouse: MouseEvent, - playbar_area: Rect, - focus_block: ActiveBlock, - app: &mut App, -) { - match mouse.kind { - MouseEventKind::Down(MouseButton::Left) => { - // Control buttons take precedence; they sit on a different row than the gauge. - if let Some(control) = playbar_control_at(app, playbar_area, mouse.column, mouse.row) { - focus_playbar(focus_block, app); - playbar::handle_control(control, app); - return; - } - - // Otherwise, a click on the progress line seeks to that position (#157). - if let Some(line) = playbar_progress_line(app, playbar_area) { - if line.contains(mouse.column, mouse.row) { - focus_playbar(focus_block, app); - app.apply(Action::SeekTo(line.position_at(mouse.column))); - } - } - } - // Dragging along the progress row scrubs; the column is clamped into the line. - MouseEventKind::Drag(MouseButton::Left) => { - if let Some(line) = playbar_progress_line(app, playbar_area) { - if line.on_row(mouse.row) { - focus_playbar(focus_block, app); - app.apply(Action::SeekTo(line.position_at(mouse.column))); - } - } - } - _ => {} - } -} - -fn handle_settings_screen_mouse(mouse: MouseEvent, app: &mut App) { - if app.view.settings_unsaved_prompt_visible { - handle_unsaved_settings_prompt_mouse(mouse, app); - return; - } - - let Some(areas) = settings_layout_areas(app) else { - return; - }; - - if rect_contains(areas.tabs, mouse.column, mouse.row) { - handle_settings_tabs_mouse(mouse, areas.tabs, app); - return; - } - - if rect_contains(areas.list, mouse.column, mouse.row) { - handle_settings_list_mouse(mouse, areas.list, app); - } -} - -fn handle_unsaved_settings_prompt_mouse(mouse: MouseEvent, app: &mut App) { - if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - return; - } - - let Some(areas) = settings_unsaved_prompt_areas(app) else { - return; - }; - - if rect_contains(areas.yes, mouse.column, mouse.row) { - settings::save_and_close(app); - return; - } - - if rect_contains(areas.no, mouse.column, mouse.row) { - settings::close_settings(app); - } -} - -fn handle_settings_tabs_mouse(mouse: MouseEvent, tabs_area: Rect, app: &mut App) { - match mouse.kind { - MouseEventKind::ScrollDown => switch_settings_category_right(app), - MouseEventKind::ScrollUp => switch_settings_category_left(app), - MouseEventKind::Down(MouseButton::Left) => { - if let Some(clicked_tab_index) = - settings_tab_index_from_click(tabs_area, mouse.column, mouse.row) - { - switch_settings_category_to(clicked_tab_index, app); - } - } - _ => {} - } -} - -fn handle_settings_list_mouse(mouse: MouseEvent, list_area: Rect, app: &mut App) { - // Reaching for the list ends the query. Without this the row activation - // below would be spent confirming the filter instead of activating the row - // the click landed on, and a scroll would be swallowed as an unhandled - // filter-input key. - if app.view.settings_filter_editing - && matches!( - mouse.kind, - MouseEventKind::ScrollUp - | MouseEventKind::ScrollDown - | MouseEventKind::Down(MouseButton::Left) - ) - { - app.apply_settings_filter(); - } - - match mouse.kind { - MouseEventKind::ScrollDown if !selected_setting_expects_key_capture(app) => { - settings::step_list(app, true); - } - MouseEventKind::ScrollUp if !selected_setting_expects_key_capture(app) => { - settings::step_list(app, false); - } - MouseEventKind::Down(MouseButton::Left) => { - select_clicked_setting(mouse.row, list_area, app); - } - _ => {} - } -} - -fn selected_setting_expects_key_capture(app: &App) -> bool { - app.view.settings_edit_mode - && app - .settings_items - .get(app.view.settings_selected_index) - .map(|setting| matches!(setting.value, SettingValue::Key(_))) - .unwrap_or(false) -} - -fn select_clicked_setting(mouse_row: u16, list_area: Rect, app: &mut App) { - // Rows are drawn from the filtered view, so a click resolves to a position in - // it before it means anything as an index into `settings_items`. - let visible = filtered_setting_indices(app); - let Some(clicked_row) = settings_item_index_from_click(list_area, mouse_row, visible.len()) - else { - return; - }; - let clicked_index = visible[clicked_row]; - - if app.view.settings_edit_mode { - let clicked_selected_item = clicked_index == app.view.settings_selected_index; - if clicked_selected_item { - if selected_setting_expects_key_capture(app) { - settings::cancel_edit(app); - } else { - settings::confirm_edit(app); - } - } else { - app.view.settings_selected_index = clicked_index; - app.view.settings_edit_mode = false; - app.view.settings_edit_buffer.clear(); - } - return; - } - - let was_selected = app.view.settings_selected_index == clicked_index; - app.view.settings_selected_index = clicked_index; - if was_selected { - settings::enter_edit_mode(app); - } -} - -fn switch_settings_category_left(app: &mut App) { - let current_index = app.view.settings_category.index(); - let new_index = if current_index == 0 { - SettingsCategory::all().len() - 1 - } else { - current_index - 1 - }; - switch_settings_category_to(new_index, app); -} - -fn switch_settings_category_right(app: &mut App) { - let current_index = app.view.settings_category.index(); - let new_index = (current_index + 1) % SettingsCategory::all().len(); - switch_settings_category_to(new_index, app); -} - -fn switch_settings_category_to(index: usize, app: &mut App) { - let category = SettingsCategory::from_index(index); - if app.view.settings_category != category || app.settings_items.is_empty() { - app.view.settings_category = category; - settings::reload_category(app); - } - - if app.view.settings_edit_mode { - app.view.settings_edit_mode = false; - app.view.settings_edit_buffer.clear(); - } -} - -fn is_main_layout_mouse_interactive(active_block: ActiveBlock) -> bool { - !matches!( - active_block, - ActiveBlock::HelpMenu - | ActiveBlock::Queue - | ActiveBlock::Error - | ActiveBlock::SelectDevice - | ActiveBlock::Analysis - | ActiveBlock::LyricsView - | ActiveBlock::CoverArtView - | ActiveBlock::MiniPlayer - | ActiveBlock::AnnouncementPrompt - | ActiveBlock::ExitPrompt - | ActiveBlock::Settings - | ActiveBlock::Dialog(_) - | ActiveBlock::SortMenu - | ActiveBlock::Party - | ActiveBlock::RecapPrompt - | ActiveBlock::CommunityPinPrompt - ) -} - -fn focus_playlists(app: &mut App) { - app.set_current_route_state( - Some(ActiveBlock::MyPlaylists), - Some(ActiveBlock::MyPlaylists), - ); -} - -fn focus_library(app: &mut App) { - app.set_current_route_state(Some(ActiveBlock::Library), Some(ActiveBlock::Library)); -} - -fn focus_content_table(active_block: ActiveBlock, app: &mut App) { - app.set_current_route_state(Some(active_block), Some(active_block)); -} - -fn focus_playbar(block: ActiveBlock, app: &mut App) { - app.set_current_route_state(Some(block), Some(block)); -} - -fn set_input_cursor_from_column(input_area: Rect, mouse_column: u16, app: &mut App) { - let inner_start = input_area.x.saturating_add(1); - let target_col = mouse_column - .saturating_sub(inner_start) - .saturating_add(app.view.input_scroll_offset.get()) as usize; - - let mut width = 0usize; - let mut idx = 0usize; - for (i, ch) in app.view.input.iter().enumerate() { - let ch_width = UnicodeWidthChar::width(*ch).unwrap_or(1); - if width + ch_width > target_col { - idx = i; - break; - } - width += ch_width; - idx = i + 1; - } - - app.view.input_idx = idx; - app.view.input_cursor_position = width as u16; -} - -fn select_clicked_library_item(mouse_row: u16, list_area: Rect, app: &mut App) { - let item_count = app.library_rows().len(); - let selected_index = app.library_cursor(); - - let Some(clicked_index) = - list_item_index_from_click(list_area, mouse_row, selected_index, item_count) - else { - return; - }; - - let was_selected = selected_index == clicked_index; - library::select_row(app, clicked_index); - - if was_selected { - library::activate_selected(app); - } -} - -fn select_clicked_playlist(mouse_row: u16, list_area: Rect, app: &mut App) { - // The rendered row count, including the leading "+ Add Playlist" row under - // Spotify (and the trailing "+ New Playlist" row under YouTube). - let item_count = playlist::total_display_count(app); - if item_count == 0 { - return; - } - - let selected_index = app - .view - .selected_playlist_index - .unwrap_or(0) - .min(item_count.saturating_sub(1)); - - let Some(clicked_index) = - list_item_index_from_click(list_area, mouse_row, selected_index, item_count) - else { - return; - }; - - let was_selected = app.view.selected_playlist_index == Some(clicked_index); - app.view.selected_playlist_index = Some(clicked_index); - - // Open the selected item when clicking it again. - if was_selected { - playlist::activate_selected(app); - } -} - -fn select_clicked_content_table_item( - active_block: ActiveBlock, - mouse_row: u16, - table_area: Rect, - item_count: usize, - app: &mut App, -) { - let selected_index = - content_table_selected_index(active_block, app).min(item_count.saturating_sub(1)); - - // The track table anchors its view to a persisted scroll offset (the cursor - // can sit anywhere in the window), so clicks must use that offset instead of - // re-deriving one from the selection. - let anchored_offset = match active_block { - ActiveBlock::TrackTable => Some(app.view.track_table_scroll_offset.get()), - _ => None, - }; - - let Some(clicked_index) = table_item_index_from_click( - table_area, - mouse_row, - selected_index, - anchored_offset, - item_count, - ) else { - return; - }; - - let was_selected = content_table_selected_index(active_block, app) == clicked_index; - set_content_table_selected_index(active_block, clicked_index, app); - if was_selected { - super::handle_block_events(Key::Enter, app); - } -} - -fn content_table_item_count(active_block: ActiveBlock, app: &App) -> usize { - match active_block { - ActiveBlock::AlbumList => app - .library - .saved_albums - .get_results(None) - .map(|albums| albums.items.len()) - .unwrap_or(0), - ActiveBlock::TrackTable => app.track_table.tracks.len(), - ActiveBlock::AlbumTracks => match app.album_table_context { - crate::core::app::AlbumTableContext::Full => app - .selected_album_full - .as_ref() - .map(|album| album.album.tracks.len()) - .unwrap_or(0), - crate::core::app::AlbumTableContext::Simplified => app - .selected_album_simplified - .as_ref() - .map(|album| album.tracks.items.len()) - .unwrap_or(0), - }, - ActiveBlock::RecentlyPlayed => app - .recently_played - .as_ref() - .map(|recently_played| recently_played.items.len()) - .unwrap_or(0), - ActiveBlock::Artists => app - .library - .saved_artists - .get_results(None) - .map(|artists| artists.items.len()) - .unwrap_or(0), - ActiveBlock::Podcasts => app - .library - .saved_shows - .get_results(None) - .map(|shows| shows.items.len()) - .unwrap_or(0), - ActiveBlock::EpisodeTable => app - .library - .show_episodes - .get_results(None) - .map(|episodes| episodes.items.len()) - .unwrap_or(0), - _ => 0, - } -} - -fn content_table_selected_index(active_block: ActiveBlock, app: &App) -> usize { - match active_block { - ActiveBlock::AlbumList => app.view.album_list_index, - ActiveBlock::TrackTable => app.view.track_table_index, - ActiveBlock::AlbumTracks => match app.album_table_context { - crate::core::app::AlbumTableContext::Full => app.view.saved_album_tracks_index, - crate::core::app::AlbumTableContext::Simplified => app - .selected_album_simplified - .as_ref() - .map(|album| album.selected_index) - .unwrap_or(0), - }, - ActiveBlock::RecentlyPlayed => app.view.recently_played_index, - ActiveBlock::Artists => app.view.artists_list_index, - ActiveBlock::Podcasts => app.view.shows_list_index, - ActiveBlock::EpisodeTable => app.view.episode_list_index, - _ => 0, - } -} - -fn set_content_table_selected_index(active_block: ActiveBlock, index: usize, app: &mut App) { - match active_block { - ActiveBlock::AlbumList => app.view.album_list_index = index, - ActiveBlock::TrackTable => app.view.track_table_index = index, - ActiveBlock::AlbumTracks => match app.album_table_context { - crate::core::app::AlbumTableContext::Full => app.view.saved_album_tracks_index = index, - crate::core::app::AlbumTableContext::Simplified => { - if let Some(album) = &mut app.selected_album_simplified { - album.selected_index = index; - } - } - }, - ActiveBlock::RecentlyPlayed => app.view.recently_played_index = index, - ActiveBlock::Artists => app.view.artists_list_index = index, - ActiveBlock::Podcasts => app.view.shows_list_index = index, - ActiveBlock::EpisodeTable => app.view.episode_list_index = index, - _ => {} - } -} - -fn list_item_index_from_click( - list_area: Rect, - mouse_row: u16, - selected_index: usize, - item_count: usize, -) -> Option { - if item_count == 0 || list_area.height <= 2 { - return None; - } - - let viewport_height = list_area.height.saturating_sub(2) as usize; - if viewport_height == 0 { - return None; - } - - let inner_top = list_area.y.saturating_add(1); - let inner_bottom_exclusive = list_area - .y - .saturating_add(list_area.height) - .saturating_sub(1); - - if mouse_row < inner_top || mouse_row >= inner_bottom_exclusive { - return None; - } - - // `draw_selectable_list` recreates list state each frame with offset 0, so ratatui scrolls - // just enough to keep the selected row visible. - let offset = selected_index - .saturating_add(1) - .saturating_sub(viewport_height); - let row_index = (mouse_row - inner_top) as usize; - let clicked_index = offset + row_index; - - (clicked_index < item_count).then_some(clicked_index) -} - -fn table_item_index_from_click( - table_area: Rect, - mouse_row: u16, - selected_index: usize, - anchored_offset: Option, - item_count: usize, -) -> Option { - if item_count == 0 || table_area.height <= 5 { - return None; - } - - let visible_rows = table_area.height.saturating_sub(5) as usize; - if visible_rows == 0 { - return None; - } - - let first_data_row = table_area.y.saturating_add(2); - let last_data_row_exclusive = first_data_row.saturating_add(visible_rows as u16); - if mouse_row < first_data_row || mouse_row >= last_data_row_exclusive { - return None; - } - - let offset = anchored_offset.unwrap_or_else(|| table_scroll_offset(selected_index, visible_rows)); - - let row_index = (mouse_row - first_data_row) as usize; - let row_index = row_index.min(visible_rows.saturating_sub(1)); - let clicked_index = (offset + row_index).min(item_count.saturating_sub(1)); - - Some(clicked_index) -} - -fn settings_tab_index_from_click( - tabs_area: Rect, - mouse_column: u16, - mouse_row: u16, -) -> Option { - if tabs_area.width <= 2 || tabs_area.height <= 2 { - return None; - } - - // `Tabs` renders a single row in the block's inner area. - let tab_row = tabs_area.y.saturating_add(1); - if mouse_row != tab_row { - return None; - } - - let inner_left = tabs_area.x.saturating_add(1); - let inner_width = tabs_area.width.saturating_sub(2); - if inner_width == 0 { - return None; - } - - if mouse_column < inner_left { - return None; - } - - let relative_column = (mouse_column - inner_left) as usize; - if relative_column >= inner_width as usize { - return None; - } - - // Match ratatui's default Tabs layout in `render_tabs`: left padding + title + right padding, - // then divider between tabs. - let left_padding_width = 1usize; - let right_padding_width = 1usize; - let divider_width = 1usize; - - let mut cursor = 0usize; - let categories = SettingsCategory::all(); - for (index, category) in categories.iter().enumerate() { - let tab_start = cursor; - let tab_width = left_padding_width + category.name().width() + right_padding_width; - let tab_end = tab_start.saturating_add(tab_width); - - if relative_column >= tab_start && relative_column < tab_end { - return Some(index); - } - - cursor = tab_end; - let is_last_tab = index + 1 == categories.len(); - if !is_last_tab { - let divider_end = cursor.saturating_add(divider_width); - - // Divider clicks still map to a nearby tab for a forgiving UX. - if relative_column >= cursor && relative_column < divider_end { - return Some(index); - } - - cursor = divider_end; - } - - if cursor >= inner_width as usize { - break; - } - } - - None -} - -fn settings_item_index_from_click( - list_area: Rect, - mouse_row: u16, - item_count: usize, -) -> Option { - if item_count == 0 || list_area.height <= 2 { - return None; - } - - let inner_top = list_area.y.saturating_add(1); - let inner_bottom_exclusive = list_area - .y - .saturating_add(list_area.height) - .saturating_sub(1); - if mouse_row < inner_top || mouse_row >= inner_bottom_exclusive { - return None; - } - - let row_index = (mouse_row - inner_top) as usize; - (row_index < item_count).then_some(row_index) -} - -struct SettingsLayoutAreas { - tabs: Rect, - list: Rect, -} - -struct SettingsUnsavedPromptAreas { - yes: Rect, - no: Rect, -} - -fn settings_layout_areas(app: &App) -> Option { - if app.view.size.width == 0 || app.view.size.height == 0 { - return None; - } - - let root = Rect::new(0, 0, app.view.size.width, app.view.size.height); - let [tabs_area, list_area, _help_area] = settings_layout(root); - - Some(SettingsLayoutAreas { - tabs: tabs_area, - list: list_area, - }) -} - -fn settings_unsaved_prompt_areas(app: &App) -> Option { - if app.view.size.width == 0 || app.view.size.height == 0 { - return None; - } - - let bounds = Rect::new(0, 0, app.view.size.width, app.view.size.height); - let width = std::cmp::min( - bounds.width.saturating_sub(4), - SETTINGS_UNSAVED_PROMPT_WIDTH, - ); - if width == 0 { - return None; - } - - let height = SETTINGS_UNSAVED_PROMPT_HEIGHT.min(bounds.height.saturating_sub(2).max(1)); - let left = bounds.x + bounds.width.saturating_sub(width) / 2; - let top = bounds.y + bounds.height.saturating_sub(height) / 2; - let rect = Rect::new(left, top, width, height); - - let [_message_area, buttons_area, _hint_area] = rect.layout( - &Layout::vertical([ - Constraint::Min(2), - Constraint::Length(3), - Constraint::Length(1), - ]) - .margin(1), - ); - - let [yes_area, no_area] = buttons_area.layout( - &Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]).horizontal_margin(3), - ); - - Some(SettingsUnsavedPromptAreas { - yes: yes_area, - no: no_area, - }) -} - -fn fullscreen_view_playbar_area(app: &App) -> Option { - if app.view.size.width == 0 || app.view.size.height == 0 { - return None; - } - - let root = Rect::new(0, 0, app.view.size.width, app.view.size.height); - let (_, playbar_area) = fullscreen_view_layout(&app.runtime_state, root); - playbar_area -} - -fn miniplayer_view_playbar_area(app: &App) -> Option { - if app.view.size.width == 0 || app.view.size.height == 0 { - return None; - } - - Some(miniplayer_playbar_area(Rect::new( - 0, - 0, - app.view.size.width, - app.view.size.height, - ))) -} - -fn main_layout_areas(app: &App) -> Option { - compute_main_layout(app) -} - -fn rect_contains(rect: Rect, x: u16, y: u16) -> bool { - let right = rect.x.saturating_add(rect.width); - let bottom = rect.y.saturating_add(rect.height); - x >= rect.x && x < right && y >= rect.y && y < bottom -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::app::{ - AlbumTableContext, PlaylistFolderItem, RouteId, SelectedAlbum, SettingValue, SettingsCategory, - TrackTableContext, - }; - use crate::core::geometry::Viewport; - use crate::core::test_helpers::full_track; - use crate::tui::ui::player::PlaybarControl; - use chrono::{Duration, Utc}; - use crossterm::event::{KeyModifiers, MouseEvent}; - use rspotify::model::context::{Actions, CurrentPlaybackContext}; - use rspotify::model::enums::{DeviceType, RepeatState}; - use rspotify::model::{ - AlbumId, AlbumType, CurrentlyPlayingType, DatePrecision, Device, FullAlbum, FullTrack, Page, - PlayableItem, SavedAlbum, SimplifiedAlbum, SimplifiedArtist, SimplifiedTrack, - }; - use std::collections::HashMap; - - fn mouse_event(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent { - MouseEvent { - kind, - column, - row, - modifiers: KeyModifiers::NONE, - } - } - - fn with_playlist_items(app: &mut App) { - // Keep these row-index assertions about real playlists; the community pin - // is exercised in dedicated tests. - app.user_config.behavior.pin_community_playlist = false; - app.playlist_folder_items = vec![ - PlaylistFolderItem::Playlist { - index: 0, - current_id: 0, - }, - PlaylistFolderItem::Playlist { - index: 1, - current_id: 0, - }, - PlaylistFolderItem::Playlist { - index: 2, - current_id: 0, - }, - ]; - } - - #[allow(deprecated)] - fn saved_album(index: usize) -> SavedAlbum { - let id = format!("{index:0>22}"); - SavedAlbum { - added_at: Utc::now(), - album: FullAlbum { - artists: vec![SimplifiedArtist { - name: format!("Artist {index}"), - ..Default::default() - }], - album_type: AlbumType::Album, - available_markets: None, - copyrights: vec![], - external_ids: HashMap::new(), - external_urls: HashMap::new(), - genres: vec![], - href: format!("https://example.com/albums/{id}"), - id: AlbumId::from_id(&id).expect("valid album id").into_static(), - images: vec![], - name: format!("Album {index}"), - popularity: 0, - release_date: "2026-01-01".to_string(), - release_date_precision: DatePrecision::Day, - tracks: Page { - href: format!("https://example.com/albums/{id}/tracks"), - items: Vec::::new(), - limit: 0, - next: None, - offset: 0, - previous: None, - total: 0, - }, - label: None, - }, - } - } - - fn with_saved_albums(app: &mut App, count: usize) { - let page = Page { - href: "https://example.com/me/albums".to_string(), - items: (0..count).map(saved_album).collect(), - limit: count as u32, - next: None, - offset: 0, - previous: None, - total: count as u32, - }; - let domain_page = crate::infra::network::mapping::map_page( - &page, - crate::infra::network::mapping::saved_album_info, - ); - app.library.saved_albums.add_pages(domain_page); - } - - fn open_settings(app: &mut App) { - app.view.settings_category = SettingsCategory::Behavior; - app.load_settings_for_category(); - app.push_navigation_stack(RouteId::Settings, ActiveBlock::Settings); - } - - /// Settings, sized for the mouse tests, with `query` typed into the row - /// filter through the real key path and the input still open. - fn filtered_settings_app(query: &str) -> App { - let mut app = App::default(); - app.view.size = Viewport { - width: 160, - height: 50, - }; - open_settings(&mut app); - - crate::tui::handlers::handle_app(app.user_config.keys.search, &mut app); - for character in query.chars() { - crate::tui::handlers::handle_app(Key::Char(character), &mut app); - } - app - } - - #[allow(deprecated)] - fn with_playbar_context(app: &mut App) { - let item = PlayableItem::Track(FullTrack { - album: SimplifiedAlbum { - name: "Test Album".to_string(), - ..Default::default() - }, - artists: vec![SimplifiedArtist { - name: "Test Artist".to_string(), - ..Default::default() - }], - available_markets: Vec::new(), - disc_number: 1, - duration: Duration::milliseconds(180_000), - explicit: false, - external_ids: HashMap::new(), - external_urls: HashMap::new(), - href: None, - id: None, - is_local: false, - is_playable: Some(true), - linked_from: None, - restrictions: None, - name: "Test Track".to_string(), - popularity: 50, - preview_url: None, - track_number: 1, - r#type: rspotify::model::Type::Track, - }); - - app.current_playback_context = Some(CurrentPlaybackContext { - device: Device { - id: Some("device-1".to_string()), - is_active: true, - is_private_session: false, - is_restricted: false, - name: "Desk Speaker".to_string(), - _type: DeviceType::Computer, - volume_percent: Some(42), - }, - repeat_state: RepeatState::Off, - shuffle_state: false, - context: None, - timestamp: Utc::now(), - progress: None, - is_playing: false, - item: Some(item), - currently_playing_type: CurrentlyPlayingType::Track, - actions: Actions::default(), - }); - } - - fn find_playbar_control_click( - app: &App, - playbar_area: Rect, - target: PlaybarControl, - ) -> (u16, u16) { - let max_x = playbar_area.x.saturating_add(playbar_area.width); - let max_y = playbar_area.y.saturating_add(playbar_area.height); - - for y in playbar_area.y..max_y { - for x in playbar_area.x..max_x { - if playbar_control_at(app, playbar_area, x, y) == Some(target) { - return (x, y); - } - } - } - - panic!("expected to find click point for {:?}", target); - } - - fn find_non_control_playbar_click(app: &App, playbar_area: Rect) -> (u16, u16) { - let max_x = playbar_area.x.saturating_add(playbar_area.width); - let max_y = playbar_area.y.saturating_add(playbar_area.height); - - for y in playbar_area.y..max_y { - for x in playbar_area.x..max_x { - if playbar_control_at(app, playbar_area, x, y).is_none() { - return (x, y); - } - } - } - - panic!("expected to find non-control click point in playbar"); - } - - #[test] - fn scroll_over_playlists_changes_selection() { - let mut app = App::default(); - app.view.size = Viewport { - width: 160, - height: 50, - }; - app.push_navigation_stack(RouteId::Home, ActiveBlock::Home); - with_playlist_items(&mut app); - app.view.selected_playlist_index = Some(0); - - let areas = main_layout_areas(&app).expect("layout areas"); - let x = areas.playlists.x + 1; - let y = areas.playlists.y + 1; - - handler(mouse_event(MouseEventKind::ScrollDown, x, y), &mut app); - assert_eq!(app.view.selected_playlist_index, Some(1)); - - handler(mouse_event(MouseEventKind::ScrollUp, x, y), &mut app); - assert_eq!(app.view.selected_playlist_index, Some(0)); - - let current_route = app.get_current_route(); - assert_eq!(current_route.active_block, ActiveBlock::MyPlaylists); - assert_eq!(current_route.hovered_block, ActiveBlock::MyPlaylists); - } - - #[test] - fn click_search_input_focuses_input() { - let mut app = App::default_connected(); - app.view.size = Viewport { - width: 160, - height: 50, - }; - app.view.input = "hello".chars().collect(); - app.view.input_idx = 0; - app.view.input_cursor_position = 0; - - let areas = main_layout_areas(&app).expect("layout areas"); - let input = areas.input.expect("input area"); - - handler( - mouse_event( - MouseEventKind::Down(MouseButton::Left), - input.x + 2, - input.y + 1, - ), - &mut app, - ); - - let route = app.get_current_route(); - assert_eq!(route.active_block, ActiveBlock::Input); - assert_eq!(route.hovered_block, ActiveBlock::Input); - } - - #[test] - fn click_settings_opens_settings_screen() { - let mut app = App::default(); - app.view.size = Viewport { - width: 160, - height: 50, - }; - - let areas = main_layout_areas(&app).expect("layout areas"); - let settings = areas.settings.expect("settings area"); - - handler( - mouse_event( - MouseEventKind::Down(MouseButton::Left), - settings.x + 1, - settings.y + 1, - ), - &mut app, - ); - - let route = app.get_current_route(); - assert_eq!(route.id, RouteId::Settings); - assert_eq!(route.active_block, ActiveBlock::Settings); - assert!(!app.settings_items.is_empty()); - } - - #[test] - fn click_main_layout_playbar_control_triggers_action() { - let mut app = App::default_connected(); - app.view.size = Viewport { - width: 160, - height: 50, - }; - app.push_navigation_stack(RouteId::Home, ActiveBlock::Home); - with_playbar_context(&mut app); - - let areas = main_layout_areas(&app).expect("layout areas"); - let (x, y) = find_playbar_control_click(&app, areas.playbar, PlaybarControl::PlayPause); - assert!(!app.is_loading); - - handler( - mouse_event(MouseEventKind::Down(MouseButton::Left), x, y), - &mut app, - ); - - assert!(app.is_loading); - let route = app.get_current_route(); - assert_eq!(route.active_block, ActiveBlock::PlayBar); - assert_eq!(route.hovered_block, ActiveBlock::PlayBar); - } - - #[test] - fn click_main_layout_playbar_progress_seeks() { - let mut app = App::default_connected(); - app.view.size = Viewport { - width: 160, - height: 50, - }; - app.push_navigation_stack(RouteId::Home, ActiveBlock::Home); - with_playbar_context(&mut app); // 180_000 ms track - app.song_progress_ms = 0; - - let areas = main_layout_areas(&app).expect("layout areas"); - let line = playbar_progress_line(&app, areas.playbar).expect("progress line"); - - // A click a quarter of the way along the gauge seeks to ~25% of the track. - let quarter_x = line.start + line.width / 4; - handler( - mouse_event(MouseEventKind::Down(MouseButton::Left), quarter_x, line.row), - &mut app, - ); - let after_quarter = app.song_progress_ms; - assert!( - (35_000..=60_000).contains(&after_quarter), - "quarter click gave {after_quarter}" - ); - - // A click three quarters along seeks further into the track. - let three_quarter_x = line.start + (line.width * 3) / 4; - handler( - mouse_event( - MouseEventKind::Down(MouseButton::Left), - three_quarter_x, - line.row, - ), - &mut app, - ); - assert!( - (120_000..=150_000).contains(&app.song_progress_ms), - "three-quarter click gave {}", - app.song_progress_ms - ); - assert!(app.song_progress_ms > after_quarter); - - assert_eq!(app.get_current_route().active_block, ActiveBlock::PlayBar); - } - - #[test] - fn click_playbar_time_label_does_not_seek() { - let mut app = App::default_connected(); - app.view.size = Viewport { - width: 160, - height: 50, - }; - app.push_navigation_stack(RouteId::Home, ActiveBlock::Home); - with_playbar_context(&mut app); - app.song_progress_ms = 12_345; - - let areas = main_layout_areas(&app).expect("layout areas"); - let line = playbar_progress_line(&app, areas.playbar).expect("progress line"); - - // Clicking the time label (left of the gauge line) must not seek. - let label_x = line.start - 2; - handler( - mouse_event(MouseEventKind::Down(MouseButton::Left), label_x, line.row), - &mut app, - ); - assert_eq!(app.song_progress_ms, 12_345); - } - - #[test] - fn drag_playbar_progress_seeks() { - let mut app = App::default_connected(); - app.view.size = Viewport { - width: 160, - height: 50, - }; - app.push_navigation_stack(RouteId::Home, ActiveBlock::Home); - with_playbar_context(&mut app); - app.song_progress_ms = 0; - - let areas = main_layout_areas(&app).expect("layout areas"); - let line = playbar_progress_line(&app, areas.playbar).expect("progress line"); - - let mid_x = line.start + line.width / 2; - handler( - mouse_event(MouseEventKind::Drag(MouseButton::Left), mid_x, line.row), - &mut app, - ); - assert!( - (80_000..=100_000).contains(&app.song_progress_ms), - "drag to midpoint gave {}", - app.song_progress_ms - ); - } - - // Non-circular geometry check: the seek tests above derive their click column - // from `playbar_progress_line`, so a wrong `start`/`width` would cancel out. This - // renders the real playbar and asserts the computed line start matches the column - // where the actual gauge symbols begin in the rendered buffer. - #[test] - fn playbar_progress_line_start_matches_rendered_gauge() { - use crate::tui::ui::player::draw_playbar; - use ratatui::{backend::TestBackend, Terminal}; - - let mut app = App::default_connected(); - app.view.size = Viewport { - width: 160, - height: 50, - }; - app.push_navigation_stack(RouteId::Home, ActiveBlock::Home); - with_playbar_context(&mut app); - app.song_progress_ms = 60_000; // 1/3 of the 180s track -> a partly-filled gauge - - let areas = main_layout_areas(&app).expect("layout areas"); - let line = playbar_progress_line(&app, areas.playbar).expect("progress line"); - - let mut terminal = Terminal::new(TestBackend::new(160, 50)).unwrap(); - terminal - .draw(|f| draw_playbar(f, &app, areas.playbar)) - .unwrap(); - let buffer = terminal.backend().buffer(); - - // The LineGauge fills with the configured filled/unfilled symbols; neither - // appears in the time label, so the first such cell on the progress row is - // the true gauge start column. Parameterized on the config fields so an - // overridden gauge glyph can't silently break the alignment guard. - let filled = app.user_config.behavior.gauge_filled_icon.as_str(); - let unfilled = app.user_config.behavior.gauge_unfilled_icon.as_str(); - let rendered_start = (areas.playbar.x..areas.playbar.x + areas.playbar.width) - .find(|&x| { - matches!( - buffer.cell((x, line.row)).map(|c| c.symbol()), - Some(s) if s == filled || s == unfilled - ) - }) - .expect("expected rendered gauge cells on the progress row"); - - assert_eq!( - rendered_start, line.start, - "computed gauge start must match the rendered bar" - ); - } - - #[test] - fn click_lyrics_view_playbar_control_triggers_action() { - let mut app = App::default_connected(); - app.view.size = Viewport { - width: 160, - height: 50, - }; - app.push_navigation_stack(RouteId::LyricsView, ActiveBlock::LyricsView); - with_playbar_context(&mut app); - - let playbar_area = fullscreen_view_playbar_area(&app).expect("lyrics view playbar area"); - let (x, y) = find_playbar_control_click(&app, playbar_area, PlaybarControl::PlayPause); - assert!(!app.is_loading); - - handler( - mouse_event(MouseEventKind::Down(MouseButton::Left), x, y), - &mut app, - ); - - assert!(app.is_loading); - let route = app.get_current_route(); - assert_eq!(route.id, RouteId::LyricsView); - assert_eq!(route.active_block, ActiveBlock::LyricsView); - assert_eq!(route.hovered_block, ActiveBlock::LyricsView); - } - - #[test] - fn click_miniplayer_control_triggers_action_and_keeps_miniplayer_focus() { - let mut app = App::default_connected(); - app.view.size = Viewport { - width: 160, - height: 50, - }; - app.push_navigation_stack(RouteId::MiniPlayer, ActiveBlock::MiniPlayer); - with_playbar_context(&mut app); - - let playbar_area = - miniplayer_playbar_area(Rect::new(0, 0, app.view.size.width, app.view.size.height)); - let (x, y) = find_playbar_control_click(&app, playbar_area, PlaybarControl::PlayPause); - assert!(!app.is_loading); - - handler( - mouse_event(MouseEventKind::Down(MouseButton::Left), x, y), - &mut app, - ); - - assert!(app.is_loading); - let route = app.get_current_route(); - assert_eq!(route.id, RouteId::MiniPlayer); - assert_eq!(route.active_block, ActiveBlock::MiniPlayer); - assert_eq!(route.hovered_block, ActiveBlock::MiniPlayer); - } - - #[test] - fn fullscreen_view_playbar_area_uses_runtime_state_height() { - let mut app = App::default(); - app.view.size = Viewport { - width: 160, - height: 50, - }; - app.runtime_state.playbar_height_rows = 8; - - let playbar_area = fullscreen_view_playbar_area(&app).expect("fullscreen playbar area"); - - assert_eq!(playbar_area, Rect::new(0, 42, 160, 8)); - } - - #[test] - fn fullscreen_view_playbar_area_is_hidden_when_height_is_zero() { - let mut app = App::default(); - app.view.size = Viewport { - width: 160, - height: 50, - }; - app.runtime_state.playbar_height_rows = 0; - - assert!(fullscreen_view_playbar_area(&app).is_none()); - } - - #[test] - fn click_hidden_lyrics_view_playbar_area_does_nothing() { - let mut app = App::default_connected(); - app.view.size = Viewport { - width: 160, - height: 50, - }; - app.runtime_state.playbar_height_rows = 0; - app.push_navigation_stack(RouteId::LyricsView, ActiveBlock::LyricsView); - with_playbar_context(&mut app); - - let (initial_route_id, initial_active_block, initial_hovered_block) = { - let route = app.get_current_route(); - (route.id.clone(), route.active_block, route.hovered_block) - }; - - handler( - mouse_event(MouseEventKind::Down(MouseButton::Left), 80, 49), - &mut app, - ); - - assert!(!app.is_loading); - let route = app.get_current_route(); - assert_eq!(route.id, initial_route_id); - assert_eq!(route.active_block, initial_active_block); - assert_eq!(route.hovered_block, initial_hovered_block); - } - - #[test] - fn resized_playbar_control_click_still_maps_correctly() { - let mut app = App::default_connected(); - app.view.size = Viewport { - width: 160, - height: 50, - }; - app.runtime_state.playbar_height_rows = 8; - app.push_navigation_stack(RouteId::Home, ActiveBlock::Home); - with_playbar_context(&mut app); - - let areas = main_layout_areas(&app).expect("layout areas"); - assert_eq!(areas.playbar.height, 8); - let (x, y) = find_playbar_control_click(&app, areas.playbar, PlaybarControl::PlayPause); - - handler( - mouse_event(MouseEventKind::Down(MouseButton::Left), x, y), - &mut app, - ); - - assert!(app.is_loading); - assert_eq!(app.get_current_route().active_block, ActiveBlock::PlayBar); - } - - #[test] - fn smaller_resized_playbar_control_click_still_maps_correctly() { - let mut app = App::default_connected(); - app.view.size = Viewport { - width: 160, - height: 50, - }; - app.runtime_state.playbar_height_rows = 3; - app.push_navigation_stack(RouteId::Home, ActiveBlock::Home); - with_playbar_context(&mut app); - - let areas = main_layout_areas(&app).expect("layout areas"); - assert_eq!(areas.playbar.height, 3); - let (x, y) = find_playbar_control_click(&app, areas.playbar, PlaybarControl::PlayPause); - - handler( - mouse_event(MouseEventKind::Down(MouseButton::Left), x, y), - &mut app, - ); - - assert!(app.is_loading); - assert_eq!(app.get_current_route().active_block, ActiveBlock::PlayBar); - } - - #[test] - fn click_playbar_outside_controls_does_nothing() { - let mut app = App::default_connected(); - app.view.size = Viewport { - width: 160, - height: 50, - }; - app.push_navigation_stack(RouteId::Home, ActiveBlock::Home); - with_playbar_context(&mut app); - - let areas = main_layout_areas(&app).expect("layout areas"); - let (x, y) = find_non_control_playbar_click(&app, areas.playbar); - let initial_active = app.get_current_route().active_block; - let initial_hovered = app.get_current_route().hovered_block; - - handler( - mouse_event(MouseEventKind::Down(MouseButton::Left), x, y), - &mut app, - ); - - assert!(!app.is_loading); - let route = app.get_current_route(); - assert_eq!(route.active_block, initial_active); - assert_eq!(route.hovered_block, initial_hovered); - } - - #[test] - fn click_settings_tab_switches_category() { - let mut app = App::default(); - app.view.size = Viewport { - width: 160, - height: 50, - }; - open_settings(&mut app); - - let areas = settings_layout_areas(&app).expect("settings layout areas"); - let inner_left = areas.tabs.x + 1; - let left_padding = 1u16; - let right_padding = 1u16; - let divider = 1u16; - let behavior_tab_width = - left_padding + SettingsCategory::Behavior.name().len() as u16 + right_padding; - let icons_tab_width = - left_padding + SettingsCategory::Icons.name().len() as u16 + right_padding; - let keybindings_tab_width = - left_padding + SettingsCategory::Keybindings.name().len() as u16 + right_padding; - let theme_title_start = inner_left - + behavior_tab_width - + divider - + icons_tab_width - + divider - + keybindings_tab_width - + divider - + left_padding; - let theme_tab_x = theme_title_start + 1; - let tab_y = areas.tabs.y + 1; - - handler( - mouse_event(MouseEventKind::Down(MouseButton::Left), theme_tab_x, tab_y), - &mut app, - ); - - assert_eq!(app.view.settings_category, SettingsCategory::Theme); - assert_eq!(app.view.settings_selected_index, 0); - assert!(!app.settings_items.is_empty()); - } - - #[test] - fn scroll_in_settings_list_changes_selected_item() { - let mut app = App::default(); - app.view.size = Viewport { - width: 160, - height: 50, - }; - open_settings(&mut app); - - let areas = settings_layout_areas(&app).expect("settings layout areas"); - let x = areas.list.x + 2; - let y = areas.list.y + 2; - - assert_eq!(app.view.settings_selected_index, 0); - handler(mouse_event(MouseEventKind::ScrollDown, x, y), &mut app); - assert_eq!(app.view.settings_selected_index, 1); - - handler(mouse_event(MouseEventKind::ScrollUp, x, y), &mut app); - assert_eq!(app.view.settings_selected_index, 0); - } - - #[test] - fn clicking_selected_bool_setting_toggles_value() { - let mut app = App::default(); - app.view.size = Viewport { - width: 160, - height: 50, - }; - open_settings(&mut app); - - let bool_index = app - .settings_items - .iter() - .position(|setting| matches!(setting.value, SettingValue::Bool(_))) - .expect("expected a boolean setting"); - app.view.settings_selected_index = bool_index; - - let initial_value = app - .settings_items - .get(bool_index) - .and_then(|setting| match setting.value { - SettingValue::Bool(value) => Some(value), - _ => None, - }) - .expect("selected setting should be boolean"); - - let areas = settings_layout_areas(&app).expect("settings layout areas"); - let drawn_row = filtered_setting_indices(&app) - .iter() - .position(|index| *index == bool_index) - .expect("the boolean row is drawn"); - let y = areas.list.y + 1 + drawn_row as u16; - handler( - mouse_event(MouseEventKind::Down(MouseButton::Left), areas.list.x + 2, y), - &mut app, - ); - - let updated_value = app - .settings_items - .get(bool_index) - .and_then(|setting| match setting.value { - SettingValue::Bool(value) => Some(value), - _ => None, - }) - .expect("selected setting should stay boolean"); - assert_ne!(updated_value, initial_value); - assert!(!app.view.settings_edit_mode); - } - - #[test] - fn clicking_a_filtered_row_closes_the_filter_and_selects_the_row_that_was_drawn() { - // No Enter: the filter input is still open when the click lands. - let mut app = filtered_settings_app("sort"); - let visible = filtered_setting_indices(&app); - assert!(visible.len() > 1, "expected several sort rows"); - - // Second drawn row, which is not the second row of `settings_items`. - let areas = settings_layout_areas(&app).expect("settings layout areas"); - handler( - mouse_event( - MouseEventKind::Down(MouseButton::Left), - areas.list.x + 2, - areas.list.y + 2, - ), - &mut app, - ); - - assert!(!app.view.settings_filter_editing); - // Applying is not clearing: the rows stay filtered after the click. - assert_eq!(app.view.settings_filter, "sort"); - assert_eq!(app.view.settings_selected_index, visible[1]); - assert_ne!(visible[1], 1); - } - - #[test] - fn keybinding_capture_can_be_cancelled_with_mouse_click() { - let mut app = App::default(); - app.view.size = Viewport { - width: 160, - height: 50, - }; - open_settings(&mut app); - app.view.settings_category = SettingsCategory::Keybindings; - app.load_settings_for_category(); - app.view.settings_selected_index = 0; - - let original_value = app - .settings_items - .first() - .and_then(|setting| match &setting.value { - SettingValue::Key(value) => Some(value.clone()), - _ => None, - }) - .expect("first keybinding should be a key setting"); - - let areas = settings_layout_areas(&app).expect("settings layout areas"); - let x = areas.list.x + 2; - let y = areas.list.y + 1; - - handler( - mouse_event(MouseEventKind::Down(MouseButton::Left), x, y), - &mut app, - ); - assert!(app.view.settings_edit_mode); - - handler( - mouse_event(MouseEventKind::Down(MouseButton::Left), x, y), - &mut app, - ); - - let current_value = app - .settings_items - .first() - .and_then(|setting| match &setting.value { - SettingValue::Key(value) => Some(value.clone()), - _ => None, - }) - .expect("first keybinding should still be a key setting"); - - assert!(!app.view.settings_edit_mode); - assert_eq!(current_value, original_value); - } - - #[test] - fn click_no_on_unsaved_prompt_discards_changes_and_exits() { - let mut app = App::default(); - app.view.size = Viewport { - width: 160, - height: 50, - }; - open_settings(&mut app); - - let bool_index = app - .settings_items - .iter() - .position(|setting| matches!(setting.value, SettingValue::Bool(_))) - .expect("expected a boolean setting"); - app.view.settings_selected_index = bool_index; - crate::tui::handlers::handle_app(Key::Enter, &mut app); - - crate::tui::handlers::handle_app(Key::Esc, &mut app); - assert!(app.view.settings_unsaved_prompt_visible); - - let prompt_areas = settings_unsaved_prompt_areas(&app).expect("unsaved prompt areas"); - handler( - mouse_event( - MouseEventKind::Down(MouseButton::Left), - prompt_areas.no.x + 1, - prompt_areas.no.y, - ), - &mut app, - ); - - assert!(!app.view.settings_unsaved_prompt_visible); - assert_ne!(app.get_current_route().active_block, ActiveBlock::Settings); - } - - #[test] - fn click_in_playlist_selects_row() { - let mut app = App::default(); - app.view.size = Viewport { - width: 160, - height: 50, - }; - app.push_navigation_stack(RouteId::Home, ActiveBlock::Home); - with_playlist_items(&mut app); - app.view.selected_playlist_index = Some(0); - - let areas = main_layout_areas(&app).expect("layout areas"); - let x = areas.playlists.x + 1; - let y = areas.playlists.y + 2; - - handler( - mouse_event(MouseEventKind::Down(MouseButton::Left), x, y), - &mut app, - ); - - assert_eq!(app.view.selected_playlist_index, Some(1)); - let current_route = app.get_current_route(); - assert_eq!(current_route.active_block, ActiveBlock::MyPlaylists); - } - - #[test] - fn click_reaches_last_playlist_row() { - // Rows: [+ Add Playlist, P0, P1, P2]. The last playlist (row 3) is only - // reachable because the click count includes every rendered row. - let mut app = App::default(); - app.view.size = Viewport { - width: 160, - height: 50, - }; - app.push_navigation_stack(RouteId::Home, ActiveBlock::Home); - with_playlist_items(&mut app); - app.view.selected_playlist_index = Some(0); - - let areas = main_layout_areas(&app).expect("layout areas"); - let x = areas.playlists.x + 1; - let y = areas.playlists.y + 4; // title border + row index 3 - - handler( - mouse_event(MouseEventKind::Down(MouseButton::Left), x, y), - &mut app, - ); - - assert_eq!(app.view.selected_playlist_index, Some(3)); - } - - #[test] - fn double_click_add_row_opens_create_form() { - // Row 0 is "+ Add Playlist"; clicking an already-selected row opens it. - let mut app = App::default(); - app.view.size = Viewport { - width: 160, - height: 50, - }; - app.push_navigation_stack(RouteId::Home, ActiveBlock::Home); - with_playlist_items(&mut app); - app.view.selected_playlist_index = Some(0); - - let areas = main_layout_areas(&app).expect("layout areas"); - let x = areas.playlists.x + 1; - let y = areas.playlists.y + 1; // title border + row index 0 - - handler( - mouse_event(MouseEventKind::Down(MouseButton::Left), x, y), - &mut app, - ); - - assert_eq!(app.get_current_route().id, RouteId::CreatePlaylist); - } - - #[test] - fn click_in_saved_albums_selects_row() { - let mut app = App::default(); - app.view.size = Viewport { - width: 160, - height: 50, - }; - app.push_navigation_stack(RouteId::AlbumList, ActiveBlock::AlbumList); - with_saved_albums(&mut app, 3); - app.view.album_list_index = 0; - - let areas = main_layout_areas(&app).expect("layout areas"); - let x = areas.content.x + 1; - let y = areas.content.y + 3; - - handler( - mouse_event(MouseEventKind::Down(MouseButton::Left), x, y), - &mut app, - ); - - assert_eq!(app.view.album_list_index, 1); - let current_route = app.get_current_route(); - assert_eq!(current_route.active_block, ActiveBlock::AlbumList); - } - - #[test] - fn click_in_song_table_first_highlights_second_plays() { - let mut app = App::default(); - app.view.size = Viewport { - width: 160, - height: 50, - }; - app.push_navigation_stack(RouteId::TrackTable, ActiveBlock::TrackTable); - app.track_table.context = Some(TrackTableContext::RecommendedTracks); - app.track_table.tracks = vec![ - crate::core::plugin_api::TrackInfo::from(&full_track("0000000000000000000001", "Track 1")), - crate::core::plugin_api::TrackInfo::from(&full_track("0000000000000000000002", "Track 2")), - ]; - app.view.track_table_index = 0; - - let areas = main_layout_areas(&app).expect("layout areas"); - let x = areas.content.x + 1; - let y = areas.content.y + 3; - - handler( - mouse_event(MouseEventKind::Down(MouseButton::Left), x, y), - &mut app, - ); - - assert_eq!(app.view.track_table_index, 1); - assert!(!app.is_loading); - - handler( - mouse_event(MouseEventKind::Down(MouseButton::Left), x, y), - &mut app, - ); - - assert_eq!(app.view.track_table_index, 1); - assert!(app.is_loading); - } - - #[test] - fn scroll_over_saved_albums_changes_selection() { - let mut app = App::default(); - app.view.size = Viewport { - width: 160, - height: 50, - }; - app.push_navigation_stack(RouteId::AlbumList, ActiveBlock::AlbumList); - with_saved_albums(&mut app, 3); - app.view.album_list_index = 0; - - let areas = main_layout_areas(&app).expect("layout areas"); - let x = areas.content.x + 1; - let y = areas.content.y + 2; - - handler(mouse_event(MouseEventKind::ScrollDown, x, y), &mut app); - assert_eq!(app.view.album_list_index, 1); - - handler(mouse_event(MouseEventKind::ScrollUp, x, y), &mut app); - assert_eq!(app.view.album_list_index, 0); - - let current_route = app.get_current_route(); - assert_eq!(current_route.active_block, ActiveBlock::AlbumList); - } - - #[test] - fn content_table_selection_helpers_cover_supported_blocks() { - let mut app = App::default(); - app.view.album_list_index = 2; - app.view.saved_album_tracks_index = 3; - app.view.recently_played_index = 4; - app.view.artists_list_index = 5; - app.view.shows_list_index = 6; - app.view.episode_list_index = 7; - - assert_eq!( - content_table_selected_index(ActiveBlock::AlbumList, &app), - 2 - ); - assert_eq!( - content_table_selected_index(ActiveBlock::AlbumTracks, &app), - 3 - ); - assert_eq!( - content_table_selected_index(ActiveBlock::RecentlyPlayed, &app), - 4 - ); - assert_eq!(content_table_selected_index(ActiveBlock::Artists, &app), 5); - assert_eq!(content_table_selected_index(ActiveBlock::Podcasts, &app), 6); - assert_eq!( - content_table_selected_index(ActiveBlock::EpisodeTable, &app), - 7 - ); - - set_content_table_selected_index(ActiveBlock::AlbumList, 8, &mut app); - set_content_table_selected_index(ActiveBlock::AlbumTracks, 9, &mut app); - set_content_table_selected_index(ActiveBlock::RecentlyPlayed, 10, &mut app); - set_content_table_selected_index(ActiveBlock::Artists, 11, &mut app); - set_content_table_selected_index(ActiveBlock::Podcasts, 12, &mut app); - set_content_table_selected_index(ActiveBlock::EpisodeTable, 13, &mut app); - - assert_eq!(app.view.album_list_index, 8); - assert_eq!(app.view.saved_album_tracks_index, 9); - assert_eq!(app.view.recently_played_index, 10); - assert_eq!(app.view.artists_list_index, 11); - assert_eq!(app.view.shows_list_index, 12); - assert_eq!(app.view.episode_list_index, 13); - } - - #[test] - fn content_table_selection_helpers_update_simplified_album_tracks() { - let mut app = App::default(); - app.album_table_context = AlbumTableContext::Simplified; - app.selected_album_simplified = Some(SelectedAlbum { - album: crate::core::plugin_api::AlbumInfo { - name: "Album".to_string(), - ..Default::default() - }, - tracks: crate::core::pagination::Paged::default(), - selected_index: 1, - }); - - assert_eq!( - content_table_selected_index(ActiveBlock::AlbumTracks, &app), - 1 - ); - - set_content_table_selected_index(ActiveBlock::AlbumTracks, 2, &mut app); - - assert_eq!( - app - .selected_album_simplified - .as_ref() - .map(|album| album.selected_index), - Some(2) - ); - } - - #[test] - fn click_outside_playlist_is_ignored() { - let mut app = App::default(); - app.view.size = Viewport { - width: 160, - height: 50, - }; - with_playlist_items(&mut app); - app.view.selected_playlist_index = Some(1); - - handler( - mouse_event(MouseEventKind::Down(MouseButton::Left), 0, 0), - &mut app, - ); - - assert_eq!(app.view.selected_playlist_index, Some(1)); - } - - #[test] - fn clicked_index_respects_list_scroll_offset() { - let area = Rect::new(0, 0, 20, 8); // Inner height = 6 rows - let selected_index = 8; - let total_items = 20; - - let first_visible = list_item_index_from_click(area, 1, selected_index, total_items); - let second_visible = list_item_index_from_click(area, 2, selected_index, total_items); - - assert_eq!(first_visible, Some(3)); - assert_eq!(second_visible, Some(4)); - } - - #[test] - fn scroll_over_library_changes_selection() { - let mut app = App::default(); - app.view.size = Viewport { - width: 160, - height: 50, - }; - app.push_navigation_stack(RouteId::Home, ActiveBlock::Home); - assert_eq!(app.library_cursor(), 0); - - let areas = main_layout_areas(&app).expect("layout areas"); - let x = areas.library.x + 1; - let y = areas.library.y + 1; - - handler(mouse_event(MouseEventKind::ScrollDown, x, y), &mut app); - assert_eq!(app.library_cursor(), 1); - - handler(mouse_event(MouseEventKind::ScrollUp, x, y), &mut app); - assert_eq!(app.library_cursor(), 0); - - let current_route = app.get_current_route(); - assert_eq!(current_route.active_block, ActiveBlock::Library); - } - - #[test] - fn double_click_on_a_library_row_opens_its_section() { - let mut app = App::default(); - app.view.size = Viewport { - width: 160, - height: 50, - }; - app.push_navigation_stack(RouteId::Home, ActiveBlock::Home); - let stats_index = app - .library_rows() - .iter() - .position(|row| *row == crate::core::action::LibraryTarget::Stats) - .expect("a Stats row"); - - let areas = main_layout_areas(&app).expect("layout areas"); - let x = areas.library.x + 1; - let y = areas.library.y + 1 + stats_index as u16; - - handler( - mouse_event(MouseEventKind::Down(MouseButton::Left), x, y), - &mut app, - ); - assert_eq!(app.library_cursor(), stats_index); - assert_eq!( - app.get_current_route().id, - RouteId::Home, - "the first click only moves the highlight" - ); - - handler( - mouse_event(MouseEventKind::Down(MouseButton::Left), x, y), - &mut app, - ); - assert_eq!(app.get_current_route().id, RouteId::Stats); - } - - #[test] - fn click_on_the_search_input_from_the_error_page_is_ignored() { - // The error screen is not mouse-interactive, so the click never reaches `focus_global_search`. - let mut app = App::default(); - app.view.size = Viewport { - width: 160, - height: 50, - }; - app.handle_error(anyhow::anyhow!("boom")); - let areas = main_layout_areas(&app).expect("layout areas"); - let input = areas.input.expect("input area"); - - handler( - mouse_event( - MouseEventKind::Down(MouseButton::Left), - input.x + 2, - input.y + 1, - ), - &mut app, - ); - - let route = app.get_current_route(); - assert_eq!(route.id, RouteId::Error); - assert_eq!(route.active_block, ActiveBlock::Error); - } - - #[test] - fn focus_global_search_dismisses_a_live_error_before_taking_focus() { - let mut app = App::default(); - app.handle_error(anyhow::anyhow!("boom")); - - crate::tui::handlers::focus_global_search(&mut app); - - assert!(app.api_error().is_empty()); - let route = app.get_current_route(); - assert_ne!(route.id, RouteId::Error); - assert_eq!(route.active_block, ActiveBlock::Input); - } - - fn number_value(app: &App, index: usize) -> i64 { - match app.settings_items.get(index).map(|setting| &setting.value) { - Some(SettingValue::Number(value)) => *value, - _ => panic!("expected a number setting"), - } - } - - #[test] - fn scrolling_while_editing_a_number_setting_steps_its_value() { - let mut app = App::default(); - app.view.size = Viewport { - width: 160, - height: 50, - }; - open_settings(&mut app); - let number_index = app - .settings_items - .iter() - .position(|setting| matches!(setting.value, SettingValue::Number(_))) - .expect("expected a number setting"); - app.view.settings_selected_index = number_index; - let initial = number_value(&app, number_index); - - let areas = settings_layout_areas(&app).expect("settings layout areas"); - let x = areas.list.x + 2; - let y = areas.list.y + 1 + number_index as u16; - handler( - mouse_event(MouseEventKind::Down(MouseButton::Left), x, y), - &mut app, - ); - assert!(app.view.settings_edit_mode); - - handler(mouse_event(MouseEventKind::ScrollDown, x, y), &mut app); - - assert_eq!(number_value(&app, number_index), initial - 1); - assert_eq!(app.view.settings_edit_buffer, (initial - 1).to_string()); - assert!( - app.view.settings_edit_mode, - "the wheel steps the value; it does not leave the edit" - ); - } - - #[test] - fn table_click_mapping_respects_table_offset() { - let area = Rect::new(0, 0, 80, 12); - let selected_index = 15; - let item_count = 40; - - let first = table_item_index_from_click(area, 2, selected_index, None, item_count); - let second = table_item_index_from_click(area, 3, selected_index, None, item_count); - - assert_eq!(first, Some(9)); - assert_eq!(second, Some(10)); - } - - #[test] - fn table_click_mapping_uses_anchored_offset_when_present() { - let area = Rect::new(0, 0, 80, 12); - let item_count = 40; - - // Cursor sits mid-window (selection 12) while the view is anchored at - // row 9; the first data row must map to the anchor, not the selection. - let first = table_item_index_from_click(area, 2, 12, Some(9), item_count); - let second = table_item_index_from_click(area, 3, 12, Some(9), item_count); - - assert_eq!(first, Some(9)); - assert_eq!(second, Some(10)); - } -} diff --git a/src/tui/handlers/party.rs b/src/tui/handlers/party.rs deleted file mode 100644 index 80ac68f0..00000000 --- a/src/tui/handlers/party.rs +++ /dev/null @@ -1,201 +0,0 @@ -use crate::core::action::Action; -use crate::core::app::App; -use crate::infra::network::sync::PartyStatus; -use crate::tui::event::Key; - -const PARTY_CODE_LEN: usize = 6; -const PARTY_NAME_MAX_LEN: usize = 32; - -pub fn handler(key: Key, app: &mut App) { - match app.party_status { - PartyStatus::Disconnected | PartyStatus::Connecting => { - handle_disconnected_menu(key, app); - } - PartyStatus::Hosting => { - handle_hosting_menu(key, app); - } - PartyStatus::Joined => { - handle_joined_menu(key, app); - } - } -} - -fn handle_disconnected_menu(key: Key, app: &mut App) { - if app.view.party_input.is_empty() - && app.view.party_join_name.is_empty() - && !app.party_status.eq(&PartyStatus::Connecting) - { - match key { - Key::Esc => { - app.pop_navigation_stack(); - } - Key::Char('1') | Key::Char('h') => { - app.apply(Action::StartParty); - } - Key::Char('2') | Key::Char('j') | Key::Char('J') => { - // Switch to "Enter code" view (one space so the code-entry UI is shown). - app.view.party_input = vec![' ']; - app.view.party_input_idx = 0; - app.view.party_join_name.clear(); - } - Key::Enter => { - app.apply(Action::StartParty); - } - _ => {} - } - } else { - handle_code_input(key, app); - } -} - -fn code_alphanumeric_len(party_input: &[char]) -> usize { - party_input.iter().filter(|c| c.is_alphanumeric()).count() -} - -fn normalized_guest_name(guest_name: &[char]) -> String { - guest_name.iter().collect::().trim().to_string() -} - -fn handle_code_input(key: Key, app: &mut App) { - match key { - Key::Esc => { - app.view.party_input.clear(); - app.view.party_input_idx = 0; - app.view.party_join_name.clear(); - } - Key::Enter => { - let code: String = app - .view - .party_input - .iter() - .filter(|c| c.is_alphanumeric()) - .map(|c| c.to_ascii_uppercase()) - .collect(); - let name = normalized_guest_name(&app.view.party_join_name); - if code.len() == PARTY_CODE_LEN && !name.is_empty() { - app.apply(Action::JoinParty { code, name }); - } - } - Key::Backspace => { - if !app.view.party_join_name.is_empty() { - app.view.party_join_name.pop(); - } else if app.view.party_input_idx > 0 { - app.view.party_input_idx -= 1; - app.view.party_input.remove(app.view.party_input_idx); - } - } - Key::Char(c) => { - if c.is_alphanumeric() && code_alphanumeric_len(&app.view.party_input) < PARTY_CODE_LEN { - app - .view - .party_input - .insert(app.view.party_input_idx, c.to_ascii_uppercase()); - app.view.party_input_idx += 1; - } else if code_alphanumeric_len(&app.view.party_input) == PARTY_CODE_LEN - && (c.is_ascii_graphic() || c == ' ') - && app.view.party_join_name.len() < PARTY_NAME_MAX_LEN - { - app.view.party_join_name.push(c); - } - } - _ => {} - } -} - -fn handle_hosting_menu(key: Key, app: &mut App) { - match key { - Key::Esc => { - app.pop_navigation_stack(); - } - Key::Char('l') | Key::Char('L') => { - app.apply(Action::LeaveParty); - } - Key::Char('c') | Key::Char('C') => { - app.apply(Action::TogglePartyControlMode); - } - _ => {} - } -} - -fn handle_joined_menu(key: Key, app: &mut App) { - match key { - Key::Esc => { - app.pop_navigation_stack(); - } - Key::Char('l') | Key::Char('L') => { - app.apply(Action::LeaveParty); - } - _ => {} - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::app::{ActiveBlock, RouteId}; - - /// Open the code-entry view and type `keys` into it, all through key presses. - fn app_typing(keys: &str) -> App { - let mut app = App::default(); - handler(Key::Char('2'), &mut app); - for c in keys.chars() { - handler(Key::Char(c), &mut app); - } - app - } - - #[test] - fn party_code_entry_uppercases_each_typed_character() { - let app = app_typing("abcdef"); - - // The trailing space is the sentinel that makes the popup render the code form. - assert_eq!(app.view.party_input.iter().collect::(), "ABCDEF "); - assert_eq!(app.view.party_input_idx, 6); - assert!(app.view.party_join_name.is_empty()); - } - - #[test] - fn party_name_entry_starts_once_the_code_is_complete() { - let app = app_typing("abcdefGuest"); - - assert_eq!(app.view.party_input.iter().collect::(), "ABCDEF "); - assert_eq!(app.view.party_join_name.iter().collect::(), "Guest"); - } - - #[test] - fn party_backspace_deletes_the_name_before_the_code() { - let mut app = app_typing("abcdefX"); - - handler(Key::Backspace, &mut app); - assert!(app.view.party_join_name.is_empty()); - assert_eq!(app.view.party_input.iter().collect::(), "ABCDEF "); - - handler(Key::Backspace, &mut app); - assert_eq!(app.view.party_input.iter().collect::(), "ABCDE "); - } - - #[test] - fn party_enter_with_an_incomplete_code_keeps_the_buffers() { - let mut app = app_typing("abcde"); - - handler(Key::Enter, &mut app); - - assert_eq!(app.view.party_input.iter().collect::(), "ABCDE "); - assert_eq!(app.view.party_input_idx, 5); - } - - #[test] - fn esc_in_code_entry_clears_the_buffers_without_leaving_the_popup() { - let mut app = App::default(); - app.push_navigation_stack(RouteId::Party, ActiveBlock::Party); - handler(Key::Char('2'), &mut app); - handler(Key::Char('a'), &mut app); - - handler(Key::Esc, &mut app); - - assert!(app.view.party_input.is_empty()); - assert_eq!(app.view.party_input_idx, 0); - assert!(app.view.party_join_name.is_empty()); - assert_eq!(app.get_current_route().id, RouteId::Party); - } -} diff --git a/src/tui/handlers/playbar.rs b/src/tui/handlers/playbar.rs deleted file mode 100644 index 49423530..00000000 --- a/src/tui/handlers/playbar.rs +++ /dev/null @@ -1,69 +0,0 @@ -use super::common_key_events; -use crate::core::action::Action; -use crate::core::app::{ActiveBlock, App}; -use crate::tui::event::Key; -use crate::tui::ui::player::PlaybarControl; - -pub fn handler(key: Key, app: &mut App) { - match key { - k if common_key_events::up_event(k, &app.user_config.keys) => { - app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::MyPlaylists)); - } - k => { - handle_action_key(k, app); - } - }; -} - -pub(crate) fn handle_action_key(key: Key, app: &mut App) -> bool { - match key { - k if k == app.user_config.keys.like_track => { - handle_control(PlaybarControl::Like, app); - true - } - Key::Char('w') => { - app.apply(Action::OpenAddPlayingTrackDialog); - true - } - _ => false, - } -} - -pub(crate) fn handle_control(control: PlaybarControl, app: &mut App) { - match control { - PlaybarControl::Prev => app.apply(Action::PreviousTrack), - PlaybarControl::PlayPause => app.apply(Action::TogglePlayback), - PlaybarControl::Next => app.apply(Action::NextTrack), - PlaybarControl::Shuffle => app.apply(Action::ToggleShuffle), - PlaybarControl::Repeat => app.apply(Action::CycleRepeat), - PlaybarControl::Like => app.apply(Action::ToggleSaveCurrentItem), - PlaybarControl::VolumeDown => app.apply(Action::VolumeDown), - PlaybarControl::VolumeUp => app.apply(Action::VolumeUp), - }; -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn on_left_press() { - let mut app = App::default(); - app.set_current_route_state(Some(ActiveBlock::PlayBar), Some(ActiveBlock::PlayBar)); - - handler(Key::Up, &mut app); - let current_route = app.get_current_route(); - assert_eq!(current_route.active_block, ActiveBlock::Empty); - assert_eq!(current_route.hovered_block, ActiveBlock::MyPlaylists); - } - - #[test] - fn on_add_current_track_without_playback_sets_status_message() { - let mut app = App::default(); - app.set_current_route_state(Some(ActiveBlock::PlayBar), Some(ActiveBlock::PlayBar)); - - handler(Key::Char('w'), &mut app); - - assert_eq!(app.status_message(), Some("No track currently playing")); - } -} diff --git a/src/tui/handlers/playlist.rs b/src/tui/handlers/playlist.rs deleted file mode 100644 index c09e2d62..00000000 --- a/src/tui/handlers/playlist.rs +++ /dev/null @@ -1,578 +0,0 @@ -use super::common_key_events; -use crate::core::action::{Action, OpenTarget}; -use crate::core::app::{ActiveBlock, RouteId}; -use crate::core::app::{App, DialogContext, PlaylistFolderItem}; -use crate::core::source::Source; -use crate::tui::event::Key; - -/// Total rows in the sidebar Playlists panel. For Spotify this is the leading -/// "+ Add Playlist" row + playlists/folders; for Local it is the folder count -/// (no write capability, so no "Add Playlist"). -pub(crate) fn total_display_count(app: &App) -> usize { - match app.active_source { - Source::Local => app.local_playlists.len(), - Source::Subsonic => app.subsonic_playlists.len(), - Source::Radio => app.radio_stations.len(), - // Local YouTube playlists + the "+ New Playlist" entry. - Source::YouTube => app.youtube_playlists.len() + 1, - Source::Qobuz => app.qobuz_playlists.len(), - Source::Spotify => app.get_playlist_display_count() + 1, - } -} - -/// Open a non-Spotify playlist's tracks in the shared track table. -pub(super) fn open_source_playlist(app: &mut App, uri: Option) { - if let Some(uri) = uri { - app.apply(Action::Open(OpenTarget::SourcePlaylist(uri))); - } -} - -/// Local Files: open the highlighted folder's tracks in the shared track table. -fn open_local_folder(app: &mut App) { - let uri = app - .view - .selected_playlist_index - .and_then(|idx| app.local_playlists.get(idx)) - .map(|folder| folder.uri.clone()); - open_source_playlist(app, uri); -} - -/// Subsonic: open the highlighted server playlist's tracks in the shared track -/// table. -fn open_subsonic_folder(app: &mut App) { - let uri = app - .view - .selected_playlist_index - .and_then(|idx| app.subsonic_playlists.get(idx)) - .map(|playlist| playlist.uri.clone()); - open_source_playlist(app, uri); -} - -/// Qobuz: open the highlighted row (favorites, playlist, or album) in the -/// shared track table. -fn open_qobuz_folder(app: &mut App) { - let uri = app - .view - .selected_playlist_index - .and_then(|idx| app.qobuz_playlists.get(idx)) - .map(|playlist| playlist.uri.clone()); - open_source_playlist(app, uri); -} - -/// YouTube: open the highlighted local playlist's saved videos in the shared -/// track table, or the create-playlist form on the trailing "+ New Playlist" -/// entry. -fn open_youtube_playlist(app: &mut App) { - let Some(idx) = app.view.selected_playlist_index else { - return; - }; - if idx == app.youtube_playlists.len() { - // "+ New Playlist" — reuse the create form; its name stage dispatches - // CreateYouTubePlaylist under the YouTube source. - app.push_navigation_stack(RouteId::CreatePlaylist, ActiveBlock::CreatePlaylistForm); - return; - } - let uri = app - .youtube_playlists - .get(idx) - .map(|playlist| playlist.uri.clone()); - open_source_playlist(app, uri); -} - -/// Internet Radio: play the highlighted station directly. A station is a leaf, -/// not a container — there is no track list to drill into — so Enter starts the -/// stream instead of opening the track table. -fn play_radio_station(app: &mut App) { - let Some(idx) = app.view.selected_playlist_index else { - return; - }; - let uri = app.radio_stations.get(idx).and_then(|s| s.uri.clone()); - if let Some(uri) = uri { - // A one-item URI list: the radio router starts the station for both shapes. - app.apply(Action::PlayUris { - uris: vec![uri], - offset: None, - }); - } -} - -fn remove_radio_station(app: &mut App) { - let Some(idx) = app.view.selected_playlist_index else { - app.set_status_message("No radio station selected".to_string(), 4); - return; - }; - let Some(uri) = app - .radio_stations - .get(idx) - .map(|station| station.uri.clone()) - else { - app.set_status_message("No radio station selected".to_string(), 4); - return; - }; - let Some(uri) = uri else { - app.set_status_message("Radio station has no stream URL".to_string(), 4); - return; - }; - app.apply(Action::RemoveRadioStation(uri)); - // The clamp is a no-op for every outcome that leaves the list untouched. - app.view.selected_playlist_index = if app.radio_stations.is_empty() { - None - } else { - Some(idx.min(app.radio_stations.len() - 1)) - }; -} - -pub fn handler(key: Key, app: &mut App) { - match key { - k if common_key_events::right_event(k, &app.user_config.keys) => { - common_key_events::handle_right_event(app) - } - k if common_key_events::down_event(k, &app.user_config.keys) => select_next(app), - k if common_key_events::up_event(k, &app.user_config.keys) => select_previous(app), - k if common_key_events::high_event(k) && total_display_count(app) > 0 => { - app.view.selected_playlist_index = Some(0); - } - k if common_key_events::middle_event(k) => { - let count = total_display_count(app); - if count > 0 { - let next_index = if count.is_multiple_of(2) { - count.saturating_sub(1) / 2 - } else { - count / 2 - }; - app.view.selected_playlist_index = Some(next_index); - } - } - k if common_key_events::low_event(k) => { - let count = total_display_count(app); - if count > 0 { - app.view.selected_playlist_index = Some(count - 1); - } - } - Key::Enter => activate_selected(app), - Key::Char('D') if app.active_source == Source::Radio => { - remove_radio_station(app); - } - // Deleting a local YouTube playlist: same confirm dialog UX as Spotify. - Key::Char('D') if app.active_source == Source::YouTube => { - if let Some(playlist) = app - .view - .selected_playlist_index - .and_then(|idx| app.youtube_playlists.get(idx)) - { - app.view.dialog = Some(playlist.name.clone()); - app.view.confirm = false; - app.push_navigation_stack( - RouteId::Dialog, - ActiveBlock::Dialog(DialogContext::YouTubePlaylistWindow), - ); - } - } - // Deleting playlists is a Spotify-only (PlaylistWriter) action. - Key::Char('D') if app.active_source == Source::Spotify => { - if let Some(selected_idx) = app.view.selected_playlist_index { - if let Some(PlaylistFolderItem::Playlist { index, .. }) = selected_idx - .checked_sub(1) - .and_then(|i| app.get_playlist_display_item_at(i)) - { - if let Some(playlist) = app.all_playlists.get(*index) { - let selected_playlist = &playlist.name; - app.view.dialog = Some(selected_playlist.clone()); - app.view.confirm = false; - - app.push_navigation_stack( - RouteId::Dialog, - ActiveBlock::Dialog(DialogContext::PlaylistWindow), - ); - } - } - } - } - _ => {} - } -} - -/// The down key's cursor move, named for the mouse wheel. -pub(super) fn select_next(app: &mut App) { - let count = total_display_count(app); - if count > 0 { - let current = app.view.selected_playlist_index.unwrap_or(0); - app.view.selected_playlist_index = Some((current + 1) % count); - } -} - -pub(super) fn select_previous(app: &mut App) { - let count = total_display_count(app); - if count > 0 { - let current = app.view.selected_playlist_index.unwrap_or(0); - app.view.selected_playlist_index = Some(if current == 0 { count - 1 } else { current - 1 }); - } -} - -/// The Enter consequence, routed by the active browse source. -pub(super) fn activate_selected(app: &mut App) { - match app.active_source { - Source::Local => open_local_folder(app), - Source::Subsonic => open_subsonic_folder(app), - Source::Radio => play_radio_station(app), - Source::YouTube => open_youtube_playlist(app), - Source::Qobuz => open_qobuz_folder(app), - Source::Spotify => open_spotify_row(app), - } -} - -fn open_spotify_row(app: &mut App) { - if let Some(selected_idx) = app.view.selected_playlist_index { - if selected_idx == 0 { - // "+ Add Playlist" is the leading row (row 0). - app.push_navigation_stack(RouteId::CreatePlaylist, ActiveBlock::CreatePlaylistForm); - } else if let Some(item) = app.get_playlist_display_item_at(selected_idx - 1) { - match item { - PlaylistFolderItem::Folder(folder) => { - // Navigate into/out of folder - let target_id = folder.target_id; - app.apply(Action::Open(OpenTarget::PlaylistFolder(target_id))); - // Land on the first item below the leading "+ Add Playlist" row. - // Counted after the apply: the count is folder-scoped. - let has_items = app.get_playlist_display_count() > 0; - app.view.selected_playlist_index = Some(if has_items { 1 } else { 0 }); - } - PlaylistFolderItem::Playlist { index, .. } => { - // Open the playlist tracks: navigates immediately with the - // cleared table as the loading state (see open_playlist_tracks). - let index = *index; - let id_str = app.all_playlists.get(index).and_then(|p| p.id.clone()); - if let Some(id_str) = id_str { - app.view.active_playlist_index = Some(index); - app.apply(Action::Open(OpenTarget::Playlist { - id: id_str, - from_search: false, - })); - } - } - PlaylistFolderItem::CommunityPin => { - app.view.active_playlist_index = None; - app.apply(Action::Open(OpenTarget::Playlist { - id: crate::core::app::COMMUNITY_PLAYLIST_ID.to_string(), - from_search: false, - })); - } - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::plugin_api::TrackInfo; - use crate::core::state::RadioStationConfig; - use crate::core::test_helpers::playlist_info; - use crate::core::user_config::{UserConfig, UserConfigPaths}; - use crate::infra::network::IoEvent; - use std::sync::mpsc::channel; - use std::time::SystemTime; - - fn radio_station_row(name: &str, url: &str) -> TrackInfo { - TrackInfo { - uri: Some(format!("radio:{url}")), - name: name.to_string(), - artists: vec![], - album: String::new(), - duration_ms: 0, - id: None, - album_id: None, - artist_refs: vec![], - is_playable: true, - is_local: false, - track_number: 0, - explicit: false, - image_url: None, - } - } - - #[test] - fn enter_playlist_dispatches_only_visible_page_load() { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - // Exercise a real playlist (row 1, below the leading "+ Add Playlist" row), - // not the pinned community entry. - app.user_config.behavior.pin_community_playlist = false; - app.all_playlists = vec![playlist_info( - "37i9dQZF1DXcBWIGoYBM5M", - "Test Playlist", - "spotatui-test-user", - false, - )]; - app.playlist_folder_items = vec![PlaylistFolderItem::Playlist { - index: 0, - current_id: 0, - }]; - app.view.selected_playlist_index = Some(1); - - handler(Key::Enter, &mut app); - - match rx.recv().unwrap() { - IoEvent::GetPlaylistItems(id, 0) => assert_eq!(id, "37i9dQZF1DXcBWIGoYBM5M"), - _ => panic!("expected playlist page fetch"), - } - - assert!(rx.try_recv().is_err()); - } - - #[test] - fn enter_playlist_navigates_immediately_and_dedups_inflight_open() { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - // Exercise a real playlist (row 1, below the leading "+ Add Playlist" row), - // not the pinned community entry. - app.user_config.behavior.pin_community_playlist = false; - app.all_playlists = vec![playlist_info( - "37i9dQZF1DXcBWIGoYBM5M", - "Test Playlist", - "spotatui-test-user", - false, - )]; - app.playlist_folder_items = vec![PlaylistFolderItem::Playlist { - index: 0, - current_id: 0, - }]; - app.view.selected_playlist_index = Some(1); - - handler(Key::Enter, &mut app); - - // The screen opens on the press itself, not on response arrival. - assert_eq!(app.get_current_route().id, RouteId::TrackTable); - match rx.recv().unwrap() { - IoEvent::GetPlaylistItems(id, 0) => assert_eq!(id, "37i9dQZF1DXcBWIGoYBM5M"), - _ => panic!("expected playlist page fetch"), - } - - // Pressing Enter again while the same open is in flight (after navigating - // back to the sidebar) re-opens the screen but dispatches no duplicate - // fetch. - app.pop_navigation_stack(); - handler(Key::Enter, &mut app); - assert_eq!(app.get_current_route().id, RouteId::TrackTable); - assert!(rx.try_recv().is_err()); - } - - #[test] - fn enter_on_row_zero_opens_add_playlist_form() { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - // Row 0 is always the "+ Add Playlist" entry. - app.view.selected_playlist_index = Some(0); - - handler(Key::Enter, &mut app); - - assert_eq!(app.get_current_route().id, RouteId::CreatePlaylist); - assert!(rx.try_recv().is_err()); - } - - #[test] - fn enter_on_community_pin_opens_community_playlist() { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - // No real playlists: the pin is the only display item, at sidebar row 1 - // (row 0 is "+ Add Playlist"). - assert!(app.community_pin_visible()); - app.view.selected_playlist_index = Some(1); - - handler(Key::Enter, &mut app); - - assert_eq!(app.get_current_route().id, RouteId::TrackTable); - // The pin is not a real sidebar selection. - assert_eq!(app.view.active_playlist_index, None); - match rx.recv().unwrap() { - IoEvent::GetPlaylistItems(id, 0) => { - assert_eq!(id, crate::core::app::COMMUNITY_PLAYLIST_ID) - } - _ => panic!("expected community playlist fetch"), - } - } - - #[test] - fn shift_d_on_community_pin_is_a_no_op() { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - assert!(app.community_pin_visible()); - // Sidebar row 1 is the pin (row 0 is "+ Add Playlist"). - app.view.selected_playlist_index = Some(1); - let route_before = app.get_current_route().id.clone(); - - handler(Key::Char('D'), &mut app); - - assert_eq!(app.get_current_route().id, route_before); - assert!(app.view.dialog.is_none()); - assert!(rx.try_recv().is_err()); - } - - #[test] - fn enter_on_local_folder_dispatches_get_local_tracks() { - use crate::core::plugin_api::PlaylistInfo; - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.active_source = Source::Local; - app.local_playlists = vec![PlaylistInfo { - uri: "file:///music/Jazz".to_string(), - name: "Jazz".to_string(), - owner: "local".to_string(), - track_count: 0, - id: None, - owner_id: None, - collaborative: false, - public: None, - image_url: None, - }]; - app.view.selected_playlist_index = Some(0); - - handler(Key::Enter, &mut app); - - match rx.recv().unwrap() { - IoEvent::GetLocalTracks(uri) => assert_eq!(uri, "file:///music/Jazz"), - _ => panic!("expected local tracks fetch"), - } - } - - #[test] - fn shift_d_on_radio_station_removes_favorite_and_updates_sidebar() { - let dir = tempfile::tempdir().unwrap(); - let (tx, _rx) = channel(); - let mut config = UserConfig::new(); - config.path_to_config = Some(UserConfigPaths { - config_file_path: dir.path().join("config.yml"), - }); - let mut app = App::new(tx, config, Some(SystemTime::now())); - app.state_path = Some(dir.path().join("state.yml")); - app.runtime_state.radio_stations = vec![ - RadioStationConfig { - name: "Groove Salad".to_string(), - url: "https://ice1.somafm.com/groovesalad-128-mp3".to_string(), - }, - RadioStationConfig { - name: "Secret Agent".to_string(), - url: "https://ice1.somafm.com/secretagent-128-mp3".to_string(), - }, - ]; - - app.active_source = Source::Radio; - app.radio_stations = vec![ - TrackInfo { - uri: Some("radio:https://ice1.somafm.com/groovesalad-128-mp3".to_string()), - name: "Groove Salad".to_string(), - artists: vec![], - album: String::new(), - duration_ms: 0, - id: None, - album_id: None, - artist_refs: vec![], - is_playable: true, - is_local: false, - track_number: 0, - explicit: false, - image_url: None, - }, - TrackInfo { - uri: Some("radio:https://ice1.somafm.com/secretagent-128-mp3".to_string()), - name: "Secret Agent".to_string(), - artists: vec![], - album: String::new(), - duration_ms: 0, - id: None, - album_id: None, - artist_refs: vec![], - is_playable: true, - is_local: false, - track_number: 0, - explicit: false, - image_url: None, - }, - ]; - app.view.selected_playlist_index = Some(0); - - handler(Key::Char('D'), &mut app); - - assert_eq!(app.runtime_state.radio_stations.len(), 1); - assert_eq!( - app.runtime_state.radio_stations[0].url, - "https://ice1.somafm.com/secretagent-128-mp3" - ); - assert_eq!(app.radio_stations.len(), 1); - assert_eq!(app.radio_stations[0].name, "Secret Agent"); - assert_eq!(app.view.selected_playlist_index, Some(0)); - assert_eq!( - app.status_message(), - Some("Removed saved radio station: Groove Salad") - ); - } - - #[test] - fn shift_d_on_config_only_radio_station_reports_config_ownership() { - let dir = tempfile::tempdir().unwrap(); - let (tx, _rx) = channel(); - let mut config = UserConfig::new(); - config.path_to_config = Some(UserConfigPaths { - config_file_path: dir.path().join("config.yml"), - }); - config.behavior.radio_stations = vec![RadioStationConfig { - name: "Configured Groove".to_string(), - url: "https://ice1.somafm.com/groovesalad-128-mp3".to_string(), - }]; - let mut app = App::new(tx, config, Some(SystemTime::now())); - app.state_path = Some(dir.path().join("state.yml")); - app.active_source = Source::Radio; - app.radio_stations = vec![radio_station_row( - "Configured Groove", - "https://ice1.somafm.com/groovesalad-128-mp3", - )]; - app.view.selected_playlist_index = Some(0); - - handler(Key::Char('D'), &mut app); - - assert!(app.runtime_state.radio_stations.is_empty()); - assert_eq!(app.radio_stations.len(), 1); - assert_eq!(app.view.selected_playlist_index, Some(0)); - assert_eq!( - app.status_message(), - Some("Radio station is configured in config.yml: Configured Groove") - ); - } - - #[test] - fn shift_d_on_config_and_state_radio_station_removes_saved_copy_only() { - let dir = tempfile::tempdir().unwrap(); - let (tx, _rx) = channel(); - let mut config = UserConfig::new(); - config.path_to_config = Some(UserConfigPaths { - config_file_path: dir.path().join("config.yml"), - }); - config.behavior.radio_stations = vec![RadioStationConfig { - name: "Configured Groove".to_string(), - url: "https://ice1.somafm.com/groovesalad-128-mp3".to_string(), - }]; - let mut app = App::new(tx, config, Some(SystemTime::now())); - app.state_path = Some(dir.path().join("state.yml")); - app.runtime_state.radio_stations = vec![RadioStationConfig { - name: "Runtime Duplicate".to_string(), - url: "https://ice1.somafm.com/groovesalad-128-mp3".to_string(), - }]; - app.active_source = Source::Radio; - app.radio_stations = vec![radio_station_row( - "Configured Groove", - "https://ice1.somafm.com/groovesalad-128-mp3", - )]; - app.view.selected_playlist_index = Some(0); - - handler(Key::Char('D'), &mut app); - - assert!(app.runtime_state.radio_stations.is_empty()); - assert_eq!(app.radio_stations.len(), 1); - assert_eq!(app.radio_stations[0].name, "Configured Groove"); - assert_eq!(app.view.selected_playlist_index, Some(0)); - assert_eq!( - app.status_message(), - Some("Removed saved radio station: Runtime Duplicate") - ); - } -} diff --git a/src/tui/handlers/plugin_screen.rs b/src/tui/handlers/plugin_screen.rs deleted file mode 100644 index d5d3aa55..00000000 --- a/src/tui/handlers/plugin_screen.rs +++ /dev/null @@ -1,116 +0,0 @@ -use crate::core::app::{App, RouteId}; -use crate::tui::event::Key; - -/// Handler for plugin custom screens. Global keybindings have already run; -/// everything that reaches here is forwarded to the owning plugin's `on_key` -/// callback as a config.yml-style key string (drained by the script engine -/// right after the key event). -pub fn handler(key: Key, app: &mut App) { - let screen = match &app.get_current_route().id { - RouteId::PluginScreen(name) => name.clone(), - _ => return, - }; - - match key { - Key::Esc => { - app.pop_navigation_stack(); - } - // Scroll affordance for paragraph-heavy screens; not forwarded. - Key::PageUp => { - app.view.plugin_screen_scroll = app.view.plugin_screen_scroll.saturating_sub(5); - } - Key::PageDown => { - app.view.plugin_screen_scroll = app.view.plugin_screen_scroll.saturating_add(5); - } - _ => { - let key_string = plugin_key_string(key); - if !key_string.is_empty() { - app.pending_plugin_screen_keys.push((screen, key_string)); - } - } - } -} - -/// Serialize a key with the same vocabulary config.yml keybindings use -/// (mirrors `key_to_config_string` in user_config). -fn plugin_key_string(key: Key) -> String { - match key { - Key::Char(' ') => "space".to_string(), - Key::Char(c) => c.to_string(), - Key::Ctrl(c) => format!("ctrl-{}", c), - Key::Alt(c) => format!("alt-{}", c), - Key::Enter => "enter".to_string(), - Key::Tab => "tab".to_string(), - Key::Esc => "esc".to_string(), - Key::Backspace => "backspace".to_string(), - Key::Delete => "del".to_string(), - Key::Left => "left".to_string(), - Key::Right => "right".to_string(), - Key::Up => "up".to_string(), - Key::Down => "down".to_string(), - Key::Home => "home".to_string(), - Key::End => "end".to_string(), - Key::Ins => "ins".to_string(), - Key::PageUp => "pageup".to_string(), - Key::PageDown => "pagedown".to_string(), - Key::F0 => "f0".to_string(), - Key::F1 => "f1".to_string(), - Key::F2 => "f2".to_string(), - Key::F3 => "f3".to_string(), - Key::F4 => "f4".to_string(), - Key::F5 => "f5".to_string(), - Key::F6 => "f6".to_string(), - Key::F7 => "f7".to_string(), - Key::F8 => "f8".to_string(), - Key::F9 => "f9".to_string(), - Key::F10 => "f10".to_string(), - Key::F11 => "f11".to_string(), - Key::F12 => "f12".to_string(), - Key::Unknown => String::new(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::app::ActiveBlock; - - #[test] - fn key_on_plugin_screen_lands_in_pending_queue() { - let mut app = App::default(); - app.push_navigation_stack( - RouteId::PluginScreen("stats".to_string()), - ActiveBlock::PluginScreen, - ); - - handler(Key::Char('a'), &mut app); - handler(Key::Ctrl('x'), &mut app); - handler(Key::Enter, &mut app); - - assert_eq!( - app.pending_plugin_screen_keys, - vec![ - ("stats".to_string(), "a".to_string()), - ("stats".to_string(), "ctrl-x".to_string()), - ("stats".to_string(), "enter".to_string()), - ] - ); - } - - #[test] - fn esc_pops_back_out_of_plugin_screen() { - let mut app = App::default(); - app.push_navigation_stack( - RouteId::PluginScreen("stats".to_string()), - ActiveBlock::PluginScreen, - ); - - handler(Key::Esc, &mut app); - - assert!(app.pending_plugin_screen_keys.is_empty()); - assert!(!matches!( - app.get_current_route().id, - RouteId::PluginScreen(_) - )); - } -} diff --git a/src/tui/handlers/podcasts.rs b/src/tui/handlers/podcasts.rs deleted file mode 100644 index cf670139..00000000 --- a/src/tui/handlers/podcasts.rs +++ /dev/null @@ -1,152 +0,0 @@ -use super::common_key_events; -use crate::core::action::{Action, ListTarget}; -use crate::core::app::App; -use crate::core::plugin_api::ShowInfo; -use crate::tui::event::Key; - -pub fn handler(key: Key, app: &mut App) { - match key { - k if common_key_events::left_event(k, &app.user_config.keys) => { - common_key_events::handle_left_event(app) - } - k if common_key_events::down_event(k, &app.user_config.keys) => { - if let Some(shows) = &mut app.library.saved_shows.get_results(None) { - let next_index = - common_key_events::on_down_press_handler(&shows.items, Some(app.view.shows_list_index)); - app.view.shows_list_index = next_index; - } - } - k if common_key_events::up_event(k, &app.user_config.keys) => { - if let Some(shows) = &mut app.library.saved_shows.get_results(None) { - let next_index = - common_key_events::on_up_press_handler(&shows.items, Some(app.view.shows_list_index)); - app.view.shows_list_index = next_index; - } - } - k if common_key_events::high_event(k) => { - if let Some(_shows) = app.library.saved_shows.get_results(None) { - let next_index = common_key_events::on_high_press_handler(); - app.view.shows_list_index = next_index; - } - } - k if common_key_events::middle_event(k) => { - if let Some(shows) = app.library.saved_shows.get_results(None) { - let next_index = common_key_events::on_middle_press_handler(&shows.items); - app.view.shows_list_index = next_index; - } - } - k if common_key_events::low_event(k) => { - if let Some(shows) = app.library.saved_shows.get_results(None) { - let next_index = common_key_events::on_low_press_handler(&shows.items); - app.view.shows_list_index = next_index; - } - } - Key::Enter => { - if let Some(selected_show) = selected_saved_show(app).cloned() { - app.apply(Action::OpenShowEpisodes(selected_show)); - } - } - k if k == app.user_config.keys.next_page => { - app.apply(Action::LoadMore(ListTarget::SavedShows)); - } - k if k == app.user_config.keys.previous_page => app.get_current_user_saved_shows_previous(), - Key::Char('D') => { - if let Some(id) = selected_saved_show(app).and_then(|show| show.id.clone()) { - app.apply(Action::UnsaveShow(id)); - } - } - _ => {} - } -} - -/// The row under the cursor; `None` on a missing page. -fn selected_saved_show(app: &App) -> Option<&ShowInfo> { - app - .library - .saved_shows - .get_results(None)? - .items - .get(app.view.shows_list_index) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::pagination::Paged; - use crate::core::user_config::UserConfig; - use crate::infra::network::IoEvent; - use std::sync::mpsc::{channel, Receiver}; - use std::time::SystemTime; - - fn app_with_shows(ids: &[Option<&str>]) -> (App, Receiver) { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.library.saved_shows.add_pages(Paged { - items: ids - .iter() - .enumerate() - .map(|(index, &id)| ShowInfo { - id: id.map(|id| id.to_string()), - name: format!("Show {index}"), - ..Default::default() - }) - .collect(), - offset: 0, - limit: ids.len() as u32, - total: ids.len() as u32, - next: None, - previous: None, - }); - (app, rx) - } - - #[test] - fn enter_opens_the_selected_shows_episodes() { - let (mut app, rx) = app_with_shows(&[Some("show-one"), Some("show-two")]); - app.view.shows_list_index = 1; - - handler(Key::Enter, &mut app); - - assert!( - matches!( - rx.try_recv(), - Ok(IoEvent::GetShowEpisodes(show)) if show.id.as_deref() == Some("show-two") - ), - "Enter fetched the selected show's episodes" - ); - } - - #[test] - fn shift_d_unfollows_the_selected_show() { - let (mut app, rx) = app_with_shows(&[Some("show-one")]); - - handler(Key::Char('D'), &mut app); - - assert!( - matches!( - rx.try_recv(), - Ok(IoEvent::CurrentUserSavedShowDelete(id)) if id == "show-one" - ), - "the unfollow was dispatched for the selected show" - ); - } - - #[test] - fn shift_d_on_a_show_without_an_id_dispatches_nothing() { - let (mut app, rx) = app_with_shows(&[None]); - - handler(Key::Char('D'), &mut app); - - assert!(rx.try_recv().is_err(), "an id-less row unfollows nothing"); - } - - #[test] - fn down_wraps_at_the_last_show() { - let (mut app, _rx) = app_with_shows(&[Some("show-one"), Some("show-two")]); - app.view.shows_list_index = 1; - - handler(Key::Down, &mut app); - - assert_eq!(app.view.shows_list_index, 0); - } -} diff --git a/src/tui/handlers/queue_menu.rs b/src/tui/handlers/queue_menu.rs deleted file mode 100644 index 386a5cab..00000000 --- a/src/tui/handlers/queue_menu.rs +++ /dev/null @@ -1,254 +0,0 @@ -use super::common_key_events; -use crate::core::action::Action; -use crate::core::app::App; -use crate::tui::event::Key; - -/// The Queue screen is a header row ("Now playing", index 0, non-actionable) -/// followed by one selectable row per native-queue item. Selection index `k` -/// (k >= 1) maps to `native_queue[k - 1]`. -pub fn handler(key: Key, app: &mut App) { - match key { - k if common_key_events::down_event(k, &app.user_config.keys) => move_selection(1, app), - k if common_key_events::up_event(k, &app.user_config.keys) => move_selection(-1, app), - // Shift+J / Shift+K reorder the selected item (hardcoded per design, not - // rebindable). J moves it down, K moves it up. - Key::Char('J') => reorder(1, app), - Key::Char('K') => reorder(-1, app), - k if k == app.user_config.keys.remove_from_queue => remove_selected(app), - Key::Enter => play_selected(app), - _ => {} - } -} - -fn queue_item_uri(app: &App, position: usize) -> Option { - app.native_queue.get(position).and_then(|t| t.uri.clone()) -} - -/// Jump to the selected queue row and move the selection to the new head. -/// Row 0 (now playing) is non-actionable. -fn play_selected(app: &mut App) { - let selected = app.view.queue_selected_index; - if selected == 0 { - return; - } - let position = selected - 1; - let Some(uri) = queue_item_uri(app, position) else { - return; - }; - app.apply(Action::PlayQueueItem { uri, position }); - app.view.queue_selected_index = 1; -} - -/// Total selectable rows: the now-playing header plus every queue item. -fn row_count(app: &App) -> usize { - 1 + app.native_queue.len() -} - -fn move_selection(delta: i32, app: &mut App) { - let max_index = row_count(app).saturating_sub(1); - let current = app.view.queue_selected_index; - app.view.queue_selected_index = match delta { - -1 => current.saturating_sub(1), - _ => (current + 1).min(max_index), - }; -} - -/// Remove the selected queue item and clamp the selection to the shortened list. -/// Row 0 (now playing) is non-actionable. -fn remove_selected(app: &mut App) { - let selected = app.view.queue_selected_index; - if selected == 0 { - return; - } - let position = selected - 1; - if let Some(uri) = queue_item_uri(app, position) { - app.apply(Action::RemoveFromQueue { uri, position }); - let max_index = row_count(app).saturating_sub(1); - if app.view.queue_selected_index > max_index { - app.view.queue_selected_index = max_index; - } - } -} - -/// Swap the selected item with its neighbor and move the selection with it, so -/// the highlighted row keeps following the same item. No wrap at the ends. -fn reorder(delta: i32, app: &mut App) { - let selected = app.view.queue_selected_index; - if selected == 0 { - return; - } - let idx = selected - 1; - let len = app.native_queue.len(); - let Some(uri) = queue_item_uri(app, idx) else { - return; - }; - match delta { - 1 if idx + 1 < len => { - app.apply(Action::MoveQueueItem { - uri, - from: idx, - to: idx + 1, - }); - app.view.queue_selected_index = selected + 1; - } - -1 if idx > 0 => { - app.apply(Action::MoveQueueItem { - uri, - from: idx, - to: idx - 1, - }); - app.view.queue_selected_index = selected - 1; - } - _ => {} - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::plugin_api::TrackInfo; - use crate::core::user_config::UserConfig; - use std::sync::mpsc::channel; - use std::time::SystemTime; - - fn track(uri: &str, name: &str) -> TrackInfo { - TrackInfo { - uri: Some(uri.to_string()), - name: name.to_string(), - artists: vec!["Artist".to_string()], - album: "Album".to_string(), - duration_ms: 1000, - id: None, - album_id: None, - artist_refs: vec![], - is_playable: true, - is_local: false, - track_number: 0, - explicit: false, - image_url: None, - } - } - - fn app_with_queue(names: &[&str]) -> App { - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - for name in names { - app.add_track_to_native_queue(track("spotify:track:x", name)); - } - app - } - - #[test] - fn move_selection_clamps_to_rows() { - let mut app = app_with_queue(&["A", "B"]); - // Rows: [now playing, A, B] => max index 2. - app.view.queue_selected_index = 0; - move_selection(-1, &mut app); - assert_eq!(app.view.queue_selected_index, 0); - move_selection(1, &mut app); - move_selection(1, &mut app); - move_selection(1, &mut app); - move_selection(1, &mut app); - assert_eq!(app.view.queue_selected_index, 2); - } - - #[test] - fn remove_ignores_now_playing_row() { - let mut app = app_with_queue(&["A", "B"]); - app.view.queue_selected_index = 0; - remove_selected(&mut app); - assert_eq!(app.native_queue.len(), 2); - } - - #[test] - fn remove_deletes_selected_and_clamps() { - let mut app = app_with_queue(&["A", "B"]); - // Select last item (row 2 => native_queue[1] == "B"). - app.view.queue_selected_index = 2; - remove_selected(&mut app); - assert_eq!(app.native_queue.len(), 1); - assert_eq!(app.native_queue[0].name, "A"); - // Selection clamped from 2 to the new max (row 1). - assert_eq!(app.view.queue_selected_index, 1); - } - - #[test] - fn reorder_down_swaps_and_follows_item() { - let mut app = app_with_queue(&["A", "B", "C"]); - // Select A (row 1). - app.view.queue_selected_index = 1; - reorder(1, &mut app); - assert_eq!( - app - .native_queue - .iter() - .map(|t| t.name.as_str()) - .collect::>(), - vec!["B", "A", "C"] - ); - // Selection followed A down to row 2. - assert_eq!(app.view.queue_selected_index, 2); - } - - #[test] - fn reorder_up_swaps_and_follows_item() { - let mut app = app_with_queue(&["A", "B", "C"]); - // Select C (row 3). - app.view.queue_selected_index = 3; - reorder(-1, &mut app); - assert_eq!( - app - .native_queue - .iter() - .map(|t| t.name.as_str()) - .collect::>(), - vec!["A", "C", "B"] - ); - assert_eq!(app.view.queue_selected_index, 2); - } - - #[test] - fn reorder_at_bounds_is_noop() { - let mut app = app_with_queue(&["A", "B"]); - // First item can't move up. - app.view.queue_selected_index = 1; - reorder(-1, &mut app); - assert_eq!(app.native_queue[0].name, "A"); - assert_eq!(app.view.queue_selected_index, 1); - // Last item can't move down. - app.view.queue_selected_index = 2; - reorder(1, &mut app); - assert_eq!(app.native_queue[1].name, "B"); - assert_eq!(app.view.queue_selected_index, 2); - // Now-playing row is non-actionable. - app.view.queue_selected_index = 0; - reorder(1, &mut app); - assert_eq!(app.view.queue_selected_index, 0); - } - - #[test] - fn enter_drops_the_rows_above_the_selection_and_selects_the_new_head() { - let mut app = app_with_queue(&["A", "B", "C"]); - // Select C (row 3): A and B are dropped, C becomes the head. - app.view.queue_selected_index = 3; - handler(Key::Enter, &mut app); - assert_eq!( - app - .native_queue - .iter() - .map(|t| t.name.as_str()) - .collect::>(), - vec!["C"] - ); - assert_eq!(app.view.queue_selected_index, 1); - } - - #[test] - fn enter_on_the_now_playing_row_changes_nothing() { - let mut app = app_with_queue(&["A", "B"]); - app.view.queue_selected_index = 0; - handler(Key::Enter, &mut app); - assert_eq!(app.native_queue.len(), 2); - assert_eq!(app.view.queue_selected_index, 0); - } -} diff --git a/src/tui/handlers/recap_prompt.rs b/src/tui/handlers/recap_prompt.rs deleted file mode 100644 index 6814c32e..00000000 --- a/src/tui/handlers/recap_prompt.rs +++ /dev/null @@ -1,54 +0,0 @@ -use crate::core::action::Action; -use crate::core::app::App; -use crate::tui::event::Key; - -pub fn handler(key: Key, app: &mut App) { - match key { - Key::Enter | Key::Char('o') => { - app.apply(Action::OpenRecap); - } - Key::Char('d') => { - app.apply(Action::DisableRecapPrompt); - } - _ => {} - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::app::{ActiveBlock, RecapPromptState}; - use std::path::PathBuf; - - fn app_with_prompt() -> App { - let mut app = App::default(); - app.show_recap_prompt(RecapPromptState { - path: PathBuf::from("recap.html"), - listens: 42, - }); - app - } - - #[test] - fn d_disables_the_prompt_and_pops() { - let mut app = app_with_prompt(); - handler(Key::Char('d'), &mut app); - assert!(!app.user_config.behavior.enable_monthly_recap_prompt); - assert!(app.recap_prompt().is_none()); - assert_ne!( - app.get_current_route().active_block, - ActiveBlock::RecapPrompt - ); - } - - #[test] - fn other_keys_keep_the_prompt_open() { - let mut app = app_with_prompt(); - handler(Key::Char('x'), &mut app); - assert!(app.recap_prompt().is_some()); - assert_eq!( - app.get_current_route().active_block, - ActiveBlock::RecapPrompt - ); - } -} diff --git a/src/tui/handlers/recently_played.rs b/src/tui/handlers/recently_played.rs deleted file mode 100644 index eb8c7a63..00000000 --- a/src/tui/handlers/recently_played.rs +++ /dev/null @@ -1,181 +0,0 @@ -use super::common_key_events; -use crate::core::action::Action; -use crate::core::app::App; -use crate::core::plugin_api::TrackInfo; -use crate::tui::event::Key; - -pub fn handler(key: Key, app: &mut App) { - match key { - k if common_key_events::left_event(k, &app.user_config.keys) => { - common_key_events::handle_left_event(app) - } - k if common_key_events::down_event(k, &app.user_config.keys) => { - if let Some(recently_played_result) = &app.recently_played { - let next_index = common_key_events::on_down_press_handler( - &recently_played_result.items, - Some(app.view.recently_played_index), - ); - app.view.recently_played_index = next_index; - } - } - k if common_key_events::up_event(k, &app.user_config.keys) => { - if let Some(recently_played_result) = &app.recently_played { - let next_index = common_key_events::on_up_press_handler( - &recently_played_result.items, - Some(app.view.recently_played_index), - ); - app.view.recently_played_index = next_index; - } - } - k if common_key_events::high_event(k) => { - if let Some(_recently_played_result) = &app.recently_played { - let next_index = common_key_events::on_high_press_handler(); - app.view.recently_played_index = next_index; - } - } - k if common_key_events::middle_event(k) => { - if let Some(recently_played_result) = &app.recently_played { - let next_index = common_key_events::on_middle_press_handler(&recently_played_result.items); - app.view.recently_played_index = next_index; - } - } - k if common_key_events::low_event(k) => { - if let Some(recently_played_result) = &app.recently_played { - let next_index = common_key_events::on_low_press_handler(&recently_played_result.items); - app.view.recently_played_index = next_index; - } - } - Key::Char('s') => { - // The bare id, not the URI: a row can be a local file. - if let Some(id) = selected_recent_track(app).and_then(|track| track.id.clone()) { - app.apply(Action::ToggleSaveTrack(id)); - } - } - Key::Char('w') => { - let track = selected_recent_track(app).map(|track| (track.id.clone(), track.name.clone())); - if let Some((track_id, track_name)) = track { - app.apply(Action::OpenAddTrackDialogFor { - track_id, - track_name, - }); - } - } - Key::Enter => { - let selected = app.view.recently_played_index; - let request = app.recently_played.as_ref().map(|page| { - common_key_events::uri_playback_request( - page.items.iter().map(|track| track.uri.clone()), - selected, - ) - }); - if let Some((uris, offset)) = request { - app.apply(Action::PlayUris { uris, offset }); - } - } - Key::Char('r') => { - let identity = - selected_recent_track(app).and_then(|track| Some((track.id.clone()?, track.name.clone()))); - if let Some((id, name)) = identity { - app.apply(Action::RecommendFromTrackId { id, name }); - } - } - _ if key == app.user_config.keys.add_item_to_queue => { - let track = app - .recently_played - .as_ref() - .and_then(|r| r.items.get(app.view.recently_played_index).cloned()); - if let Some(track) = track { - app.apply(Action::QueueTrack(track)); - } - } - _ => {} - }; -} - -/// The row under the cursor. -fn selected_recent_track(app: &App) -> Option<&TrackInfo> { - app - .recently_played - .as_ref()? - .items - .get(app.view.recently_played_index) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::app::ActiveBlock; - - fn recent(uri: Option<&str>, name: &str) -> TrackInfo { - TrackInfo { - uri: uri.map(|u| u.to_string()), - name: name.to_string(), - artists: vec!["Artist".to_string()], - album: "Album".to_string(), - duration_ms: 1_000, - id: None, - album_id: None, - artist_refs: vec![], - is_playable: true, - is_local: false, - track_number: 0, - explicit: false, - image_url: None, - } - } - - #[test] - fn enter_skips_uri_less_rows_and_remaps_the_offset() { - use crate::core::pagination::CursorPaged; - use crate::core::user_config::UserConfig; - use crate::infra::network::IoEvent; - use std::sync::mpsc::channel; - use std::time::SystemTime; - - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.recently_played = Some(CursorPaged { - items: vec![ - recent(Some("spotify:track:one"), "One"), - recent(None, "Local"), - recent(Some("spotify:track:three"), "Three"), - ], - ..Default::default() - }); - app.view.recently_played_index = 2; - - handler(Key::Enter, &mut app); - - match rx.try_recv() { - Ok(IoEvent::StartPlayback(None, Some(uris), offset)) => { - assert_eq!(uris, vec!["spotify:track:one", "spotify:track:three"]); - assert_eq!(offset, Some(1)); - } - _ => panic!("expected StartPlayback of the playable rows"), - } - } - - #[test] - fn on_left_press() { - let mut app = App::default(); - app.set_current_route_state( - Some(ActiveBlock::AlbumTracks), - Some(ActiveBlock::AlbumTracks), - ); - - handler(Key::Left, &mut app); - let current_route = app.get_current_route(); - assert_eq!(current_route.active_block, ActiveBlock::Empty); - assert_eq!(current_route.hovered_block, ActiveBlock::Library); - } - - #[test] - fn on_esc() { - let mut app = App::default(); - - handler(Key::Esc, &mut app); - - let current_route = app.get_current_route(); - assert_eq!(current_route.active_block, ActiveBlock::Empty); - } -} diff --git a/src/tui/handlers/resize.rs b/src/tui/handlers/resize.rs deleted file mode 100644 index 463ee582..00000000 --- a/src/tui/handlers/resize.rs +++ /dev/null @@ -1,221 +0,0 @@ -use crate::core::app::App; - -const SIDEBAR_STEP: u8 = 5; -const PLAYBAR_STEP: u16 = 1; -const LIBRARY_STEP: u8 = 5; - -/// Decrease sidebar width by SIDEBAR_STEP percent (minimum 0%). -pub fn decrease_sidebar_width(app: &mut App) { - let percent = app - .runtime_state - .sidebar_width_percent - .saturating_sub(SIDEBAR_STEP); - app.set_sidebar_width_percent(percent); -} - -/// Increase sidebar width by SIDEBAR_STEP percent (maximum 100%). -pub fn increase_sidebar_width(app: &mut App) { - let percent = app - .runtime_state - .sidebar_width_percent - .saturating_add(SIDEBAR_STEP); - app.set_sidebar_width_percent(percent); -} - -/// Decrease playbar height by PLAYBAR_STEP rows (minimum 0 = hidden). -pub fn decrease_playbar_height(app: &mut App) { - let rows = app - .runtime_state - .playbar_height_rows - .saturating_sub(PLAYBAR_STEP); - app.set_playbar_height_rows(rows); -} - -/// Increase playbar height by PLAYBAR_STEP rows (capped at MAX_PLAYBAR_ROWS). -pub fn increase_playbar_height(app: &mut App) { - let rows = app - .runtime_state - .playbar_height_rows - .saturating_add(PLAYBAR_STEP); - app.set_playbar_height_rows(rows); -} - -/// Decrease the library section height within the sidebar (minimum 0% = hidden). -pub fn decrease_library_height(app: &mut App) { - let percent = app - .runtime_state - .library_height_percent - .saturating_sub(LIBRARY_STEP); - app.set_library_height_percent(percent); -} - -/// Increase the library section height within the sidebar (maximum 100%). -pub fn increase_library_height(app: &mut App) { - let percent = app - .runtime_state - .library_height_percent - .saturating_add(LIBRARY_STEP); - app.set_library_height_percent(percent); -} - -/// Reset all pane sizes to configured defaults, or runtime defaults. -pub fn reset_layout(app: &mut App) { - app.reset_layout(); -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::limits::MAX_PLAYBAR_ROWS; - use crate::core::state::RuntimeState; - - #[test] - fn decrease_sidebar_reduces_width_by_step() { - let mut app = App::default(); - app.runtime_state.sidebar_width_percent = 20; - decrease_sidebar_width(&mut app); - assert_eq!(app.runtime_state.sidebar_width_percent, 15); - } - - #[test] - fn decrease_sidebar_clamps_at_zero() { - let mut app = App::default(); - app.runtime_state.sidebar_width_percent = 3; - decrease_sidebar_width(&mut app); - assert_eq!(app.runtime_state.sidebar_width_percent, 0); - } - - #[test] - fn increase_sidebar_increases_width_by_step() { - let mut app = App::default(); - app.runtime_state.sidebar_width_percent = 20; - increase_sidebar_width(&mut app); - assert_eq!(app.runtime_state.sidebar_width_percent, 25); - } - - #[test] - fn increase_sidebar_clamps_at_100() { - let mut app = App::default(); - app.runtime_state.sidebar_width_percent = 98; - increase_sidebar_width(&mut app); - assert_eq!(app.runtime_state.sidebar_width_percent, 100); - } - - #[test] - fn sidebar_can_be_fully_hidden() { - let mut app = App::default(); - app.runtime_state.sidebar_width_percent = 5; - decrease_sidebar_width(&mut app); - assert_eq!(app.runtime_state.sidebar_width_percent, 0); - } - - #[test] - fn decrease_playbar_reduces_height_by_step() { - let mut app = App::default(); - app.runtime_state.playbar_height_rows = 6; - decrease_playbar_height(&mut app); - assert_eq!(app.runtime_state.playbar_height_rows, 5); - } - - #[test] - fn decrease_playbar_clamps_at_zero() { - let mut app = App::default(); - app.runtime_state.playbar_height_rows = 0; - decrease_playbar_height(&mut app); - assert_eq!(app.runtime_state.playbar_height_rows, 0); - } - - #[test] - fn increase_playbar_increases_height_by_step() { - let mut app = App::default(); - app.runtime_state.playbar_height_rows = 6; - increase_playbar_height(&mut app); - assert_eq!(app.runtime_state.playbar_height_rows, 7); - } - - #[test] - fn increase_playbar_clamps_at_max() { - let mut app = App::default(); - app.runtime_state.playbar_height_rows = MAX_PLAYBAR_ROWS; - increase_playbar_height(&mut app); - assert_eq!(app.runtime_state.playbar_height_rows, MAX_PLAYBAR_ROWS); - } - - #[test] - fn playbar_can_be_hidden() { - let mut app = App::default(); - app.runtime_state.playbar_height_rows = 1; - decrease_playbar_height(&mut app); - assert_eq!(app.runtime_state.playbar_height_rows, 0); - } - - #[test] - fn decrease_library_reduces_height_by_step() { - let mut app = App::default(); - app.runtime_state.library_height_percent = 30; - decrease_library_height(&mut app); - assert_eq!(app.runtime_state.library_height_percent, 25); - } - - #[test] - fn increase_library_increases_height_by_step() { - let mut app = App::default(); - app.runtime_state.library_height_percent = 30; - increase_library_height(&mut app); - assert_eq!(app.runtime_state.library_height_percent, 35); - } - - #[test] - fn library_can_be_fully_hidden() { - let mut app = App::default(); - app.runtime_state.library_height_percent = 3; - decrease_library_height(&mut app); - assert_eq!(app.runtime_state.library_height_percent, 0); - } - - #[test] - fn reset_layout_restores_runtime_defaults_without_configured_defaults() { - let mut app = App::default(); - app.runtime_state.sidebar_width_percent = 50; - app.runtime_state.playbar_height_rows = 0; - app.runtime_state.library_height_percent = 80; - reset_layout(&mut app); - let defaults = RuntimeState::default(); - assert_eq!( - app.runtime_state.sidebar_width_percent, - defaults.sidebar_width_percent - ); - assert_eq!( - app.runtime_state.playbar_height_rows, - defaults.playbar_height_rows - ); - assert_eq!( - app.runtime_state.library_height_percent, - defaults.library_height_percent - ); - } - - #[test] - fn reset_layout_prefers_configured_defaults() { - let mut app = App::default(); - app.runtime_state.sidebar_width_percent = 50; - app.runtime_state.playbar_height_rows = 0; - app.runtime_state.library_height_percent = 80; - app.user_config.behavior.sidebar_width_percent = Some(35); - app.user_config.behavior.playbar_height_rows = Some(9); - app.user_config.behavior.library_height_percent = Some(45); - reset_layout(&mut app); - assert_eq!(app.runtime_state.sidebar_width_percent, 35); - assert_eq!(app.runtime_state.playbar_height_rows, 9); - assert_eq!(app.runtime_state.library_height_percent, 45); - } - - #[test] - fn reset_layout_clamps_configured_playbar_height_to_max() { - let mut app = App::default(); - app.runtime_state.playbar_height_rows = 0; - app.user_config.behavior.playbar_height_rows = Some(MAX_PLAYBAR_ROWS + 1); - reset_layout(&mut app); - assert_eq!(app.runtime_state.playbar_height_rows, MAX_PLAYBAR_ROWS); - } -} diff --git a/src/tui/handlers/search_results.rs b/src/tui/handlers/search_results.rs deleted file mode 100644 index 966612ae..00000000 --- a/src/tui/handlers/search_results.rs +++ /dev/null @@ -1,963 +0,0 @@ -use super::common_key_events; -use crate::core::action::{Action, OpenTarget}; -use crate::core::app::{ActiveBlock, App, DialogContext, RouteId, SearchResultBlock}; -use crate::core::plugin_api::{ShowInfo, TrackInfo}; -use crate::core::source::Source; -use crate::tui::event::Key; - -fn handle_down_press_on_selected_block(app: &mut App) { - // Start selecting within the selected block - match app.view.search_selected_block { - SearchResultBlock::AlbumSearch => { - if let Some(result) = &app.search_results.albums { - let next_index = common_key_events::on_down_press_handler( - &result.items, - app.view.search_selected_album_index, - ); - app.view.search_selected_album_index = Some(next_index); - } - } - SearchResultBlock::SongSearch => { - if let Some(result) = &app.search_results.tracks { - let next_index = common_key_events::on_down_press_handler( - &result.items, - app.view.search_selected_tracks_index, - ); - app.view.search_selected_tracks_index = Some(next_index); - } - } - SearchResultBlock::ArtistSearch => { - if let Some(result) = &app.search_results.artists { - let next_index = common_key_events::on_down_press_handler( - &result.items, - app.view.search_selected_artists_index, - ); - app.view.search_selected_artists_index = Some(next_index); - } - } - SearchResultBlock::PlaylistSearch => { - if let Some(result) = &app.search_results.playlists { - let next_index = common_key_events::on_down_press_handler( - &result.items, - app.view.search_selected_playlists_index, - ); - app.view.search_selected_playlists_index = Some(next_index); - } - } - SearchResultBlock::ShowSearch => { - if let Some(result) = &app.search_results.shows { - let next_index = common_key_events::on_down_press_handler( - &result.items, - app.view.search_selected_shows_index, - ); - app.view.search_selected_shows_index = Some(next_index); - } - } - SearchResultBlock::Empty => {} - } -} - -fn handle_down_press_on_hovered_block(app: &mut App) { - match app.view.search_hovered_block { - SearchResultBlock::AlbumSearch => { - app.view.search_hovered_block = SearchResultBlock::ShowSearch; - } - SearchResultBlock::SongSearch => { - app.view.search_hovered_block = SearchResultBlock::AlbumSearch; - } - SearchResultBlock::ArtistSearch => { - app.view.search_hovered_block = SearchResultBlock::PlaylistSearch; - } - SearchResultBlock::PlaylistSearch => { - app.view.search_hovered_block = SearchResultBlock::ShowSearch; - } - SearchResultBlock::ShowSearch => { - app.view.search_hovered_block = SearchResultBlock::SongSearch; - } - SearchResultBlock::Empty => {} - } -} - -fn handle_up_press_on_selected_block(app: &mut App) { - // Start selecting within the selected block - match app.view.search_selected_block { - SearchResultBlock::AlbumSearch => { - if let Some(result) = &app.search_results.albums { - let next_index = common_key_events::on_up_press_handler( - &result.items, - app.view.search_selected_album_index, - ); - app.view.search_selected_album_index = Some(next_index); - } - } - SearchResultBlock::SongSearch => { - if let Some(result) = &app.search_results.tracks { - let next_index = common_key_events::on_up_press_handler( - &result.items, - app.view.search_selected_tracks_index, - ); - app.view.search_selected_tracks_index = Some(next_index); - } - } - SearchResultBlock::ArtistSearch => { - if let Some(result) = &app.search_results.artists { - let next_index = common_key_events::on_up_press_handler( - &result.items, - app.view.search_selected_artists_index, - ); - app.view.search_selected_artists_index = Some(next_index); - } - } - SearchResultBlock::PlaylistSearch => { - if let Some(result) = &app.search_results.playlists { - let next_index = common_key_events::on_up_press_handler( - &result.items, - app.view.search_selected_playlists_index, - ); - app.view.search_selected_playlists_index = Some(next_index); - } - } - SearchResultBlock::ShowSearch => { - if let Some(result) = &app.search_results.shows { - let next_index = common_key_events::on_up_press_handler( - &result.items, - app.view.search_selected_shows_index, - ); - app.view.search_selected_shows_index = Some(next_index); - } - } - SearchResultBlock::Empty => {} - } -} - -fn handle_up_press_on_hovered_block(app: &mut App) { - match app.view.search_hovered_block { - SearchResultBlock::AlbumSearch => { - app.view.search_hovered_block = SearchResultBlock::SongSearch; - } - SearchResultBlock::SongSearch => { - app.view.search_hovered_block = SearchResultBlock::ShowSearch; - } - SearchResultBlock::ArtistSearch => { - app.view.search_hovered_block = SearchResultBlock::ShowSearch; - } - SearchResultBlock::PlaylistSearch => { - app.view.search_hovered_block = SearchResultBlock::ArtistSearch; - } - SearchResultBlock::ShowSearch => { - app.view.search_hovered_block = SearchResultBlock::AlbumSearch; - } - SearchResultBlock::Empty => {} - } -} - -fn handle_high_press_on_selected_block(app: &mut App) { - match app.view.search_selected_block { - SearchResultBlock::AlbumSearch => { - if let Some(_result) = &app.search_results.albums { - let next_index = common_key_events::on_high_press_handler(); - app.view.search_selected_album_index = Some(next_index); - } - } - SearchResultBlock::SongSearch => { - if let Some(_result) = &app.search_results.tracks { - let next_index = common_key_events::on_high_press_handler(); - app.view.search_selected_tracks_index = Some(next_index); - } - } - SearchResultBlock::ArtistSearch => { - if let Some(_result) = &app.search_results.artists { - let next_index = common_key_events::on_high_press_handler(); - app.view.search_selected_artists_index = Some(next_index); - } - } - SearchResultBlock::PlaylistSearch => { - if let Some(_result) = &app.search_results.playlists { - let next_index = common_key_events::on_high_press_handler(); - app.view.search_selected_playlists_index = Some(next_index); - } - } - SearchResultBlock::ShowSearch => { - if let Some(_result) = &app.search_results.shows { - let next_index = common_key_events::on_high_press_handler(); - app.view.search_selected_shows_index = Some(next_index); - } - } - SearchResultBlock::Empty => {} - } -} - -fn handle_middle_press_on_selected_block(app: &mut App) { - match app.view.search_selected_block { - SearchResultBlock::AlbumSearch => { - if let Some(result) = &app.search_results.albums { - let next_index = common_key_events::on_middle_press_handler(&result.items); - app.view.search_selected_album_index = Some(next_index); - } - } - SearchResultBlock::SongSearch => { - if let Some(result) = &app.search_results.tracks { - let next_index = common_key_events::on_middle_press_handler(&result.items); - app.view.search_selected_tracks_index = Some(next_index); - } - } - SearchResultBlock::ArtistSearch => { - if let Some(result) = &app.search_results.artists { - let next_index = common_key_events::on_middle_press_handler(&result.items); - app.view.search_selected_artists_index = Some(next_index); - } - } - SearchResultBlock::PlaylistSearch => { - if let Some(result) = &app.search_results.playlists { - let next_index = common_key_events::on_middle_press_handler(&result.items); - app.view.search_selected_playlists_index = Some(next_index); - } - } - SearchResultBlock::ShowSearch => { - if let Some(result) = &app.search_results.shows { - let next_index = common_key_events::on_middle_press_handler(&result.items); - app.view.search_selected_shows_index = Some(next_index); - } - } - SearchResultBlock::Empty => {} - } -} - -fn handle_low_press_on_selected_block(app: &mut App) { - match app.view.search_selected_block { - SearchResultBlock::AlbumSearch => { - if let Some(result) = &app.search_results.albums { - let next_index = common_key_events::on_low_press_handler(&result.items); - app.view.search_selected_album_index = Some(next_index); - } - } - SearchResultBlock::SongSearch => { - if let Some(result) = &app.search_results.tracks { - let next_index = common_key_events::on_low_press_handler(&result.items); - app.view.search_selected_tracks_index = Some(next_index); - } - } - SearchResultBlock::ArtistSearch => { - if let Some(result) = &app.search_results.artists { - let next_index = common_key_events::on_low_press_handler(&result.items); - app.view.search_selected_artists_index = Some(next_index); - } - } - SearchResultBlock::PlaylistSearch => { - if let Some(result) = &app.search_results.playlists { - let next_index = common_key_events::on_low_press_handler(&result.items); - app.view.search_selected_playlists_index = Some(next_index); - } - } - SearchResultBlock::ShowSearch => { - if let Some(result) = &app.search_results.shows { - let next_index = common_key_events::on_low_press_handler(&result.items); - app.view.search_selected_shows_index = Some(next_index); - } - } - SearchResultBlock::Empty => {} - } -} - -fn handle_add_item_to_queue(app: &mut App) { - if let SearchResultBlock::SongSearch = app.view.search_selected_block { - let track = app.view.search_selected_tracks_index.and_then(|index| { - app - .search_results - .tracks - .as_ref() - .and_then(|tracks| tracks.items.get(index).cloned()) - }); - if let Some(track) = track { - app.apply(Action::QueueTrack(track)); - } - } -} - -/// The highlighted album's id; `.get()` guards a stale index. -fn selected_search_album_id(app: &App) -> Option { - let index = app.view.search_selected_album_index?; - app - .search_results - .albums - .as_ref()? - .items - .get(index)? - .id - .clone() -} - -/// The highlighted artist's (id, name). -fn selected_search_artist_identity(app: &App) -> Option<(String, String)> { - let index = app.view.search_selected_artists_index?; - let artist = app.search_results.artists.as_ref()?.items.get(index)?; - Some((artist.id.clone()?, artist.name.clone())) -} - -/// The highlighted track row; `None` without a selection. -fn selected_search_track_row(app: &App) -> Option<&TrackInfo> { - let index = app.view.search_selected_tracks_index?; - app.search_results.tracks.as_ref()?.items.get(index) -} - -/// The highlighted show row; `None` without a selection. -fn selected_search_show_row(app: &App) -> Option<&ShowInfo> { - let index = app.view.search_selected_shows_index?; - app.search_results.shows.as_ref()?.items.get(index) -} - -/// The highlighted show's Spotify id, for the save/unsave keys. -fn selected_search_show_id(app: &App) -> Option { - selected_search_show_row(app)?.id.clone() -} - -fn handle_enter_event_on_selected_block(app: &mut App) { - match &app.view.search_selected_block { - SearchResultBlock::AlbumSearch => { - if let Some(id) = selected_search_album_id(app) { - app.apply(Action::Open(OpenTarget::Album { - id, - from_search: true, - })); - } - } - SearchResultBlock::SongSearch => { - let Some(paged) = app.search_results.tracks.as_ref() else { - return; - }; - // No selection: an out-of-range index yields `offset: None`. - let selected = app - .view - .search_selected_tracks_index - .unwrap_or(paged.items.len()); - let (uris, offset) = common_key_events::uri_playback_request( - paged.items.iter().map(|track| track.uri.clone()), - selected, - ); - app.apply(Action::PlayUris { uris, offset }); - } - SearchResultBlock::ArtistSearch => { - if let Some((id, name)) = selected_search_artist_identity(app) { - app.apply(Action::Open(OpenTarget::Artist { id, name })); - } - } - SearchResultBlock::PlaylistSearch => { - // Go to playlist tracks table: navigates immediately with the cleared - // table as the loading state (see open_playlist_tracks). - if let Some(id) = app.selected_search_result_playlist_id() { - app.apply(Action::Open(OpenTarget::Playlist { - id, - from_search: true, - })); - } - } - SearchResultBlock::ShowSearch => { - // OpenShowEpisodes populates app.library.show_episodes (opening the show - // by id sets EpisodeTableContext::Full but does NOT populate it, leaving - // a blank episode list). `show` is already a domain ShowInfo. - if let Some(show) = selected_search_show_row(app).cloned() { - app.apply(Action::OpenShowEpisodes(show)); - } - } - SearchResultBlock::Empty => {} - }; -} - -fn handle_enter_event_on_hovered_block(app: &mut App) { - match app.view.search_hovered_block { - SearchResultBlock::AlbumSearch => { - let next_index = app.view.search_selected_album_index.unwrap_or(0); - - app.view.search_selected_album_index = Some(next_index); - app.view.search_selected_block = SearchResultBlock::AlbumSearch; - } - SearchResultBlock::SongSearch => { - let next_index = app.view.search_selected_tracks_index.unwrap_or(0); - - app.view.search_selected_tracks_index = Some(next_index); - app.view.search_selected_block = SearchResultBlock::SongSearch; - } - SearchResultBlock::ArtistSearch => { - let next_index = app.view.search_selected_artists_index.unwrap_or(0); - - app.view.search_selected_artists_index = Some(next_index); - app.view.search_selected_block = SearchResultBlock::ArtistSearch; - } - SearchResultBlock::PlaylistSearch => { - let next_index = app.view.search_selected_playlists_index.unwrap_or(0); - - app.view.search_selected_playlists_index = Some(next_index); - app.view.search_selected_block = SearchResultBlock::PlaylistSearch; - } - SearchResultBlock::ShowSearch => { - let next_index = app.view.search_selected_shows_index.unwrap_or(0); - - app.view.search_selected_shows_index = Some(next_index); - app.view.search_selected_block = SearchResultBlock::ShowSearch; - } - SearchResultBlock::Empty => {} - }; -} - -fn handle_recommended_tracks(app: &mut App) { - match app.view.search_selected_block { - SearchResultBlock::AlbumSearch => {} - SearchResultBlock::SongSearch => { - if let Some(index) = app.view.search_selected_tracks_index { - let identity = app - .search_results - .tracks - .as_ref() - .and_then(|paged| paged.items.get(index)) - .and_then(|track| Some((track.id.clone()?, track.name.clone()))); - if let Some((id, name)) = identity { - app.apply(Action::RecommendFromTrackId { id, name }); - } - }; - } - SearchResultBlock::ArtistSearch => { - if let Some((id, name)) = selected_search_artist_identity(app) { - app.apply(Action::RecommendFromArtist { id, name }); - } - } - SearchResultBlock::PlaylistSearch => {} - SearchResultBlock::ShowSearch => {} - SearchResultBlock::Empty => {} - } -} - -fn selected_radio_station(app: &App) -> Option { - let index = app.view.search_selected_tracks_index?; - app - .search_results - .tracks - .as_ref()? - .items - .get(index) - .cloned() -} - -pub(super) fn favorite_selected_radio_station(app: &mut App) { - let Some(station) = selected_radio_station(app) else { - app.set_status_message("No radio station selected".to_string(), 4); - return; - }; - app.apply(Action::FavoriteRadioStation(station)); -} - -#[cfg(feature = "internet-radio")] -pub(super) fn favorite_current_radio_station(app: &mut App) -> bool { - let Some(station) = app - .radio_playback - .as_ref() - .map(|session| session.station.clone()) - else { - return false; - }; - app.apply(Action::FavoriteRadioStation(station)); - true -} - -#[cfg(not(feature = "internet-radio"))] -pub(super) fn favorite_current_radio_station(_app: &mut App) -> bool { - false -} - -/// Key handling for the internet-radio results view: a single full-area -/// Stations panel (see `draw_radio_station_results`), backed by the -/// `SongSearch` block. Navigation is pinned to that one block so focus can -/// never wander into the four Spotify-only blocks that aren't drawn, and -/// Enter plays the highlighted station directly (no select-the-block first — -/// there is only one block). Spotify-only actions (`w`/`D`/`r`/queue) are -/// inert here. -fn handle_radio_key(key: Key, app: &mut App) { - // Whatever mouse hovering or stale state did, the only visible block is - // the station list. - app.view.search_hovered_block = SearchResultBlock::SongSearch; - match key { - Key::Esc => { - app.view.search_selected_block = SearchResultBlock::Empty; - } - k if common_key_events::left_event(k, &app.user_config.keys) => { - app.view.search_selected_block = SearchResultBlock::Empty; - common_key_events::handle_left_event(app); - } - k if common_key_events::down_event(k, &app.user_config.keys) => { - app.view.search_selected_block = SearchResultBlock::SongSearch; - handle_down_press_on_selected_block(app); - } - k if common_key_events::up_event(k, &app.user_config.keys) => { - app.view.search_selected_block = SearchResultBlock::SongSearch; - handle_up_press_on_selected_block(app); - } - k if common_key_events::high_event(k) => { - app.view.search_selected_block = SearchResultBlock::SongSearch; - handle_high_press_on_selected_block(app); - } - k if common_key_events::middle_event(k) => { - app.view.search_selected_block = SearchResultBlock::SongSearch; - handle_middle_press_on_selected_block(app); - } - k if common_key_events::low_event(k) => { - app.view.search_selected_block = SearchResultBlock::SongSearch; - handle_low_press_on_selected_block(app); - } - k if k == app.user_config.keys.like_track => { - app.view.search_selected_block = SearchResultBlock::SongSearch; - favorite_selected_radio_station(app); - } - Key::Enter => { - app.view.search_selected_block = SearchResultBlock::SongSearch; - handle_enter_event_on_selected_block(app); - } - _ => {} - } -} - -pub fn handler(key: Key, app: &mut App) { - if app.active_source == Source::Radio { - handle_radio_key(key, app); - return; - } - match key { - Key::Esc => { - app.view.search_selected_block = SearchResultBlock::Empty; - } - k if common_key_events::down_event(k, &app.user_config.keys) => { - if app.view.search_selected_block != SearchResultBlock::Empty { - handle_down_press_on_selected_block(app); - } else { - handle_down_press_on_hovered_block(app); - } - } - k if common_key_events::up_event(k, &app.user_config.keys) => { - if app.view.search_selected_block != SearchResultBlock::Empty { - handle_up_press_on_selected_block(app); - } else { - handle_up_press_on_hovered_block(app); - } - } - k if common_key_events::left_event(k, &app.user_config.keys) => { - app.view.search_selected_block = SearchResultBlock::Empty; - match app.view.search_hovered_block { - SearchResultBlock::AlbumSearch => { - common_key_events::handle_left_event(app); - } - SearchResultBlock::SongSearch => { - common_key_events::handle_left_event(app); - } - SearchResultBlock::ArtistSearch => { - app.view.search_hovered_block = SearchResultBlock::SongSearch; - } - SearchResultBlock::PlaylistSearch => { - app.view.search_hovered_block = SearchResultBlock::AlbumSearch; - } - SearchResultBlock::ShowSearch => { - common_key_events::handle_left_event(app); - } - SearchResultBlock::Empty => {} - } - } - k if common_key_events::right_event(k, &app.user_config.keys) => { - app.view.search_selected_block = SearchResultBlock::Empty; - match app.view.search_hovered_block { - SearchResultBlock::AlbumSearch => { - app.view.search_hovered_block = SearchResultBlock::PlaylistSearch; - } - SearchResultBlock::SongSearch => { - app.view.search_hovered_block = SearchResultBlock::ArtistSearch; - } - SearchResultBlock::ArtistSearch => { - app.view.search_hovered_block = SearchResultBlock::SongSearch; - } - SearchResultBlock::PlaylistSearch => { - app.view.search_hovered_block = SearchResultBlock::AlbumSearch; - } - SearchResultBlock::ShowSearch => {} - SearchResultBlock::Empty => {} - } - } - k if common_key_events::high_event(k) - && app.view.search_selected_block != SearchResultBlock::Empty => - { - handle_high_press_on_selected_block(app); - } - k if common_key_events::middle_event(k) - && app.view.search_selected_block != SearchResultBlock::Empty => - { - handle_middle_press_on_selected_block(app); - } - k if common_key_events::low_event(k) - && app.view.search_selected_block != SearchResultBlock::Empty => - { - handle_low_press_on_selected_block(app) - } - // Handle pressing enter when block is selected to start playing track - Key::Enter => match app.view.search_selected_block { - SearchResultBlock::Empty => handle_enter_event_on_hovered_block(app), - _ => handle_enter_event_on_selected_block(app), - }, - Key::Char('w') => match app.view.search_selected_block { - SearchResultBlock::AlbumSearch => { - if let Some(id) = selected_search_album_id(app) { - app.apply(Action::SaveAlbum(id)); - } - } - SearchResultBlock::SongSearch => { - let track = - selected_search_track_row(app).map(|track| (track.id.clone(), track.name.clone())); - if let Some((track_id, track_name)) = track { - app.apply(Action::OpenAddTrackDialogFor { - track_id, - track_name, - }); - } - } - SearchResultBlock::ArtistSearch => { - if let Some((id, _name)) = selected_search_artist_identity(app) { - app.apply(Action::FollowArtist(id)); - } - } - SearchResultBlock::PlaylistSearch => { - if let Some(id) = app.selected_search_result_playlist_id() { - app.apply(Action::FollowPlaylist(id)); - } - } - SearchResultBlock::ShowSearch => { - if let Some(id) = selected_search_show_id(app) { - app.apply(Action::SaveShow(id)); - } - } - SearchResultBlock::Empty => {} - }, - Key::Char('D') => match app.view.search_selected_block { - SearchResultBlock::AlbumSearch => { - if let Some(id) = selected_search_album_id(app) { - app.apply(Action::UnsaveAlbum(id)); - } - } - SearchResultBlock::SongSearch => {} - SearchResultBlock::ArtistSearch => { - if let Some((id, _name)) = selected_search_artist_identity(app) { - app.apply(Action::UnfollowArtist(id)); - } - } - SearchResultBlock::PlaylistSearch => { - if let (Some(playlists), Some(selected_index)) = ( - &app.search_results.playlists, - app.view.search_selected_playlists_index, - ) { - if let Some(selected_playlist) = playlists.items.get(selected_index) { - let selected_playlist = selected_playlist.name.clone(); - app.view.dialog = Some(selected_playlist); - app.view.confirm = false; - - app.push_navigation_stack( - RouteId::Dialog, - ActiveBlock::Dialog(DialogContext::PlaylistSearch), - ); - } - } - } - SearchResultBlock::ShowSearch => { - if let Some(id) = selected_search_show_id(app) { - app.apply(Action::UnsaveShow(id)); - } - } - SearchResultBlock::Empty => {} - }, - Key::Char('r') => handle_recommended_tracks(app), - _ if key == app.user_config.keys.add_item_to_queue => handle_add_item_to_queue(app), - Key::Char('s') => handle_save_track_event(app), - _ => {} - } -} - -fn handle_save_track_event(app: &mut App) { - if let SearchResultBlock::SongSearch = app.view.search_selected_block { - let uri = app.view.search_selected_tracks_index.and_then(|index| { - app - .search_results - .tracks - .as_ref() - .and_then(|tracks| tracks.items.get(index)) - .and_then(|track| track.uri.clone()) - }); - if let Some(uri) = uri { - app.apply(Action::ToggleSaveTrack(uri)); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::{ - app::{ActiveBlock, RouteId, TrackTableContext}, - pagination::Paged, - plugin_api::TrackInfo, - test_helpers::{full_track, playlist_info, user_info}, - user_config::UserConfig, - }; - use crate::infra::network::IoEvent; - use std::{sync::mpsc::channel, time::SystemTime}; - - fn station(uri: &str, name: &str) -> TrackInfo { - TrackInfo { - uri: Some(uri.to_string()), - name: name.to_string(), - artists: vec!["ambient".to_string()], - album: "US \u{2022} MP3 \u{2022} 128 kbps".to_string(), - duration_ms: 0, - id: None, - album_id: None, - artist_refs: vec![], - is_playable: true, - is_local: false, - track_number: 0, - explicit: false, - image_url: None, - } - } - - /// Radio results are a single panel: navigation must stay pinned to the - /// SongSearch block (the others aren't drawn) and Enter must start the - /// highlighted station. - #[test] - fn radio_results_pin_navigation_and_enter_plays_station() { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.active_source = Source::Radio; - app.search_results.tracks = Some(Paged { - items: vec![ - station("radio:https://a.example/one", "One FM"), - station("radio:https://b.example/two", "Two FM"), - ], - total: 2, - ..Default::default() - }); - app.view.search_selected_tracks_index = Some(0); - app.view.search_hovered_block = SearchResultBlock::SongSearch; - app.push_navigation_stack(RouteId::Search, ActiveBlock::SearchResultBlock); - - // Down/right-style keys must never hover/select another block. - handler(Key::Down, &mut app); - assert_eq!(app.view.search_hovered_block, SearchResultBlock::SongSearch); - assert_eq!( - app.view.search_selected_block, - SearchResultBlock::SongSearch - ); - assert_eq!(app.view.search_selected_tracks_index, Some(1)); - - // Enter plays the highlighted station via the shared StartPlayback path. - handler(Key::Enter, &mut app); - match rx.try_recv().unwrap() { - IoEvent::StartPlayback(None, Some(uris), Some(1)) => { - assert_eq!(uris[1], "radio:https://b.example/two"); - } - _ => panic!("expected a StartPlayback of the station uris"), - } - } - - #[test] - fn radio_results_favorite_key_saves_selected_station() { - use crate::core::user_config::UserConfigPaths; - - let dir = tempfile::tempdir().unwrap(); - let mut user_config = UserConfig::new(); - user_config.path_to_config = Some(UserConfigPaths { - config_file_path: dir.path().join("config.yml"), - }); - let (tx, _rx) = channel(); - let mut app = App::new(tx, user_config, Some(SystemTime::now())); - app.state_path = Some(dir.path().join("state.yml")); - app.active_source = Source::Radio; - app.search_results.tracks = Some(Paged { - items: vec![station( - "radio:https://ice1.somafm.com/groovesalad-128-mp3", - "Groove Salad", - )], - total: 1, - ..Default::default() - }); - app.view.search_selected_tracks_index = Some(0); - app.push_navigation_stack(RouteId::Search, ActiveBlock::SearchResultBlock); - - let favorite_key = app.user_config.keys.like_track; - handler(favorite_key, &mut app); - - assert_eq!(app.runtime_state.radio_stations.len(), 1); - assert_eq!( - app.runtime_state.radio_stations[0].url, - "https://ice1.somafm.com/groovesalad-128-mp3" - ); - assert_eq!(app.radio_stations.len(), 1); - assert_eq!( - app.status_message(), - Some("Favorited radio station: Groove Salad") - ); - } - - #[test] - fn pressing_w_on_search_song_opens_add_to_playlist_picker() { - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.user = Some(user_info("spotatui-owner")); - app.playlists = Some(Paged { - total: 1, - ..Default::default() - }); - app.all_playlists = vec![playlist_info( - "37i9dQZF1DXcBWIGoYBM5M", - "Owned Playlist", - "spotatui-owner", - false, - )]; - app.search_results.tracks = Some(Paged { - items: vec![TrackInfo::from(&full_track( - "0000000000000000000001", - "Search Track", - ))], - offset: 0, - limit: 1, - total: 1, - next: None, - previous: None, - }); - app.view.search_selected_block = SearchResultBlock::SongSearch; - app.view.search_selected_tracks_index = Some(0); - app.push_navigation_stack(RouteId::Search, ActiveBlock::SearchResultBlock); - - handler(Key::Char('w'), &mut app); - - assert_eq!( - app - .pending_playlist_track_add - .as_ref() - .map(|pending| pending.track_name.as_str()), - Some("Search Track") - ); - assert_eq!( - app.get_current_route().active_block, - ActiveBlock::Dialog(DialogContext::AddTrackToPlaylistPicker) - ); - } - - /// Issue #348 regression: `s` on a highlighted search-result track must - /// toggle its saved/liked state, matching the track-table binding shown in - /// the help menu ("Save track in list or table"). - #[test] - fn pressing_s_on_search_song_toggles_saved_track() { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.search_results.tracks = Some(Paged { - items: vec![TrackInfo::from(&full_track( - "0000000000000000000001", - "Search Track", - ))], - offset: 0, - limit: 1, - total: 1, - next: None, - previous: None, - }); - app.view.search_selected_block = SearchResultBlock::SongSearch; - app.view.search_selected_tracks_index = Some(0); - app.push_navigation_stack(RouteId::Search, ActiveBlock::SearchResultBlock); - - handler(Key::Char('s'), &mut app); - - match rx.try_recv().unwrap() { - IoEvent::ToggleSaveTrack(uri) => { - assert_eq!(uri, "spotify:track:0000000000000000000001"); - } - _ => panic!("expected a ToggleSaveTrack for the selected search track"), - } - } - - /// panic-1 regression: a stale `selected_playlists_index` left over from a - /// longer search page must not panic when a shorter page has since replaced - /// it (the root-cause clamp lives in `infra/network/search.rs`; this test - /// guards the handler-side defense in depth: `.get()` instead of `[..]`). - #[test] - fn pressing_shift_d_with_stale_index_past_shorter_playlist_page_does_not_panic() { - let (_tx, _rx) = channel(); - let mut app = App::new(_tx, UserConfig::new(), Some(SystemTime::now())); - app.search_results.playlists = Some(Paged { - items: vec![playlist_info( - "37i9dQZF1DXcBWIGoYBM5M", - "Only Playlist", - "spotatui-owner", - false, - )], - offset: 0, - limit: 1, - total: 1, - next: None, - previous: None, - }); - // Stale index from a previous, longer page — out of range for the page above. - app.view.search_selected_playlists_index = Some(20); - app.view.search_selected_block = SearchResultBlock::PlaylistSearch; - app.push_navigation_stack(RouteId::Search, ActiveBlock::SearchResultBlock); - - // Must not panic. - handler(Key::Char('D'), &mut app); - - // Out-of-range index: the dialog is not opened (no-op), matching sibling - // `.get()`-guarded handlers elsewhere in this file. - assert!(app.view.dialog.is_none()); - } - - #[test] - fn enter_on_song_search_without_results_starts_nothing() { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.push_navigation_stack(RouteId::Search, ActiveBlock::SearchResultBlock); - - handler(Key::Enter, &mut app); - handler(Key::Enter, &mut app); - - assert_eq!( - app.view.search_selected_block, - SearchResultBlock::SongSearch - ); - assert!(rx.try_recv().is_err(), "no results means nothing starts"); - } - - #[test] - fn enter_on_playlist_search_opens_the_track_table_in_the_search_context() { - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - // No public setter for search results: seeded directly. - app.search_results.playlists = Some(Paged { - items: vec![playlist_info( - "37i9dQZF1DXcBWIGoYBM5M", - "Search Playlist", - "spotatui-owner", - false, - )], - offset: 0, - limit: 1, - total: 1, - next: None, - previous: None, - }); - app.push_navigation_stack(RouteId::Search, ActiveBlock::SearchResultBlock); - - handler(Key::Right, &mut app); - handler(Key::Down, &mut app); - handler(Key::Enter, &mut app); - handler(Key::Enter, &mut app); - - assert_eq!( - app.track_table.context, - Some(TrackTableContext::PlaylistSearch) - ); - assert_eq!(app.get_current_route().id, RouteId::TrackTable); - assert_eq!( - app.pending_playlist_open.as_deref(), - Some("37i9dQZF1DXcBWIGoYBM5M") - ); - } -} diff --git a/src/tui/handlers/select_device.rs b/src/tui/handlers/select_device.rs deleted file mode 100644 index 676fd27e..00000000 --- a/src/tui/handlers/select_device.rs +++ /dev/null @@ -1,152 +0,0 @@ -use super::common_key_events; -use crate::core::action::Action; -use crate::core::app::{App, SourceFocus}; -use crate::core::source::Source; -use crate::infra::network::IoEvent; -use crate::tui::event::Key; - -pub fn handler(key: Key, app: &mut App) { - match key { - Key::Tab => { - // Devices are Spotify Connect only, so keep focus pinned to the Source - // list when Local is active (the Devices panel is dimmed). - app.view.source_device_focus = if app.active_source != Source::Spotify { - SourceFocus::Source - } else { - match app.view.source_device_focus { - SourceFocus::Source => SourceFocus::Devices, - SourceFocus::Devices => SourceFocus::Source, - } - }; - } - k if common_key_events::down_event(k, &app.user_config.keys) => match app - .view - .source_device_focus - { - SourceFocus::Source => { - app.view.source_list_index = - common_key_events::on_down_press_handler(&Source::ALL, Some(app.view.source_list_index)); - } - SourceFocus::Devices => { - if let Some(p) = &app.devices { - if let Some(selected_device_index) = app.view.selected_device_index { - let next_index = - common_key_events::on_down_press_handler(&p.devices, Some(selected_device_index)); - app.view.selected_device_index = Some(next_index); - } - } - } - }, - k if common_key_events::up_event(k, &app.user_config.keys) => { - match app.view.source_device_focus { - SourceFocus::Source => { - app.view.source_list_index = - common_key_events::on_up_press_handler(&Source::ALL, Some(app.view.source_list_index)); - } - SourceFocus::Devices => { - if let Some(p) = &app.devices { - if let Some(selected_device_index) = app.view.selected_device_index { - let next_index = - common_key_events::on_up_press_handler(&p.devices, Some(selected_device_index)); - app.view.selected_device_index = Some(next_index); - } - } - } - } - } - k if common_key_events::high_event(k) => { - if app.view.source_device_focus == SourceFocus::Devices { - if let Some(_p) = &app.devices { - if app.view.selected_device_index.is_some() { - app.view.selected_device_index = Some(common_key_events::on_high_press_handler()); - } - } - } - } - k if common_key_events::middle_event(k) => { - if app.view.source_device_focus == SourceFocus::Devices { - if let Some(p) = &app.devices { - if app.view.selected_device_index.is_some() { - let next_index = common_key_events::on_middle_press_handler(&p.devices); - app.view.selected_device_index = Some(next_index); - } - } - } - } - k if common_key_events::low_event(k) => { - if app.view.source_device_focus == SourceFocus::Devices { - if let Some(p) = &app.devices { - if app.view.selected_device_index.is_some() { - let next_index = common_key_events::on_low_press_handler(&p.devices); - app.view.selected_device_index = Some(next_index); - } - } - } - } - Key::Enter => match app.view.source_device_focus { - SourceFocus::Source => select_source(app), - SourceFocus::Devices => transfer_to_selected_device(app), - }, - _ => {} - } -} - -/// Commit the highlighted source as the new active source and close the picker. -/// This is browse-scope only: it never starts or stops playback. -fn select_source(app: &mut App) { - let source = Source::ALL[app.view.source_list_index]; - if app.active_source != source { - app.apply(Action::SelectSource(source)); - // Reset the sidebar playlist cursor to the top of the new source's list. - app.view.selected_playlist_index = Some(0); - if source == Source::Local { - app.view.local_playlists_index = 0; - } - } - - // Adding Spotify from a free-source session: start the in-TUI OAuth login - // (browser + async callback). This sits OUTSIDE the `active_source != source` - // guard so re-selecting Spotify after a cancelled/timed-out login retries - // instead of no-oping (a failed attempt leaves active_source == Spotify but - // spotify_connected == false). The `pending_login`/`spotify.is_some()` guards in - // the handler make a redundant dispatch a safe no-op. Connected users just - // switch back to their already-loaded Spotify data. - if source == Source::Spotify && !app.spotify_connected { - app.dispatch(IoEvent::BeginSpotifyLogin); - } - app.set_status_message(format!("Source: {}", source.label()), 4); - app.pop_navigation_stack(); -} - -/// Existing behaviour: transfer Spotify playback to the highlighted device. -fn transfer_to_selected_device(app: &mut App) { - let Some(index) = app.view.selected_device_index else { - app.set_status_message("No playback device selected", 4); - return; - }; - - let Some(devices) = &app.devices else { - app.set_status_message("No playback devices found", 4); - return; - }; - - let Some(device) = devices.devices.get(index) else { - app.set_status_message("Selected playback device is no longer available", 4); - return; - }; - - let Some(device_id) = &device.id else { - app.set_status_message("Selected playback device has no Spotify device id", 4); - return; - }; - - // Both clones end the `app.devices` borrow before the apply. - let device_id = device_id.clone(); - let device_name = device.name.clone(); - app.apply(Action::TransferPlayback { - device_id, - persist: true, - }); - app.set_status_message(format!("Switching playback to {}", device_name), 4); - app.pop_navigation_stack(); -} diff --git a/src/tui/handlers/settings.rs b/src/tui/handlers/settings.rs deleted file mode 100644 index 4a5ced62..00000000 --- a/src/tui/handlers/settings.rs +++ /dev/null @@ -1,888 +0,0 @@ -use crate::core::action::{Action, ActionOutcome}; -use crate::core::app::{App, SettingValue, SettingsCategory}; -use crate::tui::event::Key; -use crate::tui::handlers::common_key_events::{ - down_event, left_event, on_down_press_handler, on_up_press_handler, right_event, up_event, -}; -use crate::tui::handlers::filter_input::{self, FilterEdit}; -use crate::tui::ui::settings::{filtered_setting_indices, selected_setting_position}; - -pub fn handler(key: Key, app: &mut App) { - if app.view.settings_unsaved_prompt_visible { - handle_unsaved_changes_prompt(key, app); - return; - } - - if app.view.settings_edit_mode { - handle_edit_mode(key, app); - } else if app.view.settings_filter_editing { - handle_filter_input(key, app); - } else { - handle_navigation(key, app); - } -} - -fn handle_navigation(key: Key, app: &mut App) { - match key { - // Start filtering. Checked first so the binding wins even if the user has - // pointed `search` at a key another arm below would claim. - key if key == app.user_config.keys.search => app.begin_settings_filter(), - - // Category switching with left/right (only when not in edit mode) - key if left_event(key, &app.user_config.keys) => switch_category_left(app), - key if right_event(key, &app.user_config.keys) => switch_category_right(app), - - // Item selection with up/down - key if down_event(key, &app.user_config.keys) => select_next_item(app), - key if up_event(key, &app.user_config.keys) => select_previous_item(app), - - // Enter edit mode - Key::Enter => enter_edit_mode(app), - - // Save settings - key if key == app.effective_save_settings_key() => { - let _ = save_settings(app); - } - - // An applied filter is dropped before Esc means "leave the screen", so a - // narrowed list is never mistaken for the whole of it. - Key::Esc if !app.view.settings_filter.is_empty() => app.clear_settings_filter(), - - // Exit settings - Key::Esc => request_exit_settings(app), - key if key == app.user_config.keys.back => { - request_exit_settings(app); - } - _ => {} - } -} - -fn handle_filter_input(key: Key, app: &mut App) { - match filter_input::apply(key, &mut app.view.settings_filter) { - FilterEdit::Cancel => app.clear_settings_filter(), - FilterEdit::Confirm => { - if app.view.settings_filter.split_whitespace().next().is_none() { - app.clear_settings_filter(); - } else { - app.apply_settings_filter(); - } - } - FilterEdit::Changed => snap_selection_to_filter(app), - FilterEdit::Ignored => {} - } -} - -/// Move the highlight onto the best remaining match. The ranking changes with -/// every keystroke, so a selection left where it was would drift off the list. -fn snap_selection_to_filter(app: &mut App) { - app.view.settings_selected_index = filtered_setting_indices(app).first().copied().unwrap_or(0); -} - -/// Rebuild the current category's rows and put the highlight back on a row the -/// filter still shows. The filter deliberately survives a category switch, so -/// one query can be walked across tabs. -pub(super) fn reload_category(app: &mut App) { - app.load_settings_for_category(); - snap_selection_to_filter(app); -} - -fn handle_unsaved_changes_prompt(key: Key, app: &mut App) { - match key { - Key::Char('y') | Key::Char('Y') => save_and_close(app), - Key::Char('n') | Key::Char('N') | Key::Esc => close_settings(app), - Key::Enter => { - if app.view.settings_unsaved_prompt_save_selected { - save_and_close(app); - } else { - close_settings(app); - } - } - key if left_event(key, &app.user_config.keys) || right_event(key, &app.user_config.keys) => { - app.view.settings_unsaved_prompt_save_selected = - !app.view.settings_unsaved_prompt_save_selected; - } - key if key == app.user_config.keys.back => { - close_settings(app); - } - _ => {} - } -} - -/// A failing save leaves the prompt up under the error frame. -pub(super) fn save_and_close(app: &mut App) { - if save_settings(app) { - close_settings(app); - } -} - -/// The wheel's Down/Up: an open non-key edit gets the step (a Number edit -/// walks its value), otherwise the highlight moves. -pub(super) fn step_list(app: &mut App, forward: bool) { - if app.view.settings_edit_mode { - handle_edit_mode(if forward { Key::Down } else { Key::Up }, app); - } else { - step_selection(app, forward); - } -} - -pub(super) fn cancel_edit(app: &mut App) { - handle_edit_mode(Key::Esc, app); -} - -pub(super) fn confirm_edit(app: &mut App) { - handle_edit_mode(Key::Enter, app); -} - -fn request_exit_settings(app: &mut App) { - if app.has_unsaved_settings_changes() { - app.view.settings_unsaved_prompt_visible = true; - app.view.settings_unsaved_prompt_save_selected = true; - app.view.settings_edit_mode = false; - app.view.settings_edit_buffer.clear(); - } else { - close_settings(app); - } -} - -pub(super) fn close_settings(app: &mut App) { - app.view.settings_unsaved_prompt_visible = false; - app.view.settings_unsaved_prompt_save_selected = true; - app.view.settings_edit_mode = false; - app.view.settings_edit_buffer.clear(); - app.clear_settings_filter(); - app.pop_navigation_stack(); -} - -fn handle_edit_mode(key: Key, app: &mut App) { - if let Some(setting) = app.settings_items.get(app.view.settings_selected_index) { - match &setting.value { - SettingValue::Bool(_) => handle_bool_edit(key, app), - SettingValue::Number(_) => handle_number_edit(key, app), - SettingValue::Preset(_) => handle_preset_edit(key, app), - SettingValue::Cycle(_, _) => {} // enter_edit_mode handles Cycle without entering edit mode - SettingValue::Key(_) => handle_key_edit(key, app), - SettingValue::String(_) | SettingValue::Color(_) => handle_string_edit(key, app), - } - } -} - -fn handle_bool_edit(key: Key, app: &mut App) { - match key { - Key::Enter | Key::Char(' ') => { - // Toggle the boolean value - if let Some(setting) = app.settings_items.get_mut(app.view.settings_selected_index) { - if let SettingValue::Bool(v) = setting.value { - setting.value = SettingValue::Bool(!v); - } - } - app.view.settings_edit_mode = false; - } - Key::Esc => { - app.view.settings_edit_mode = false; - } - key if left_event(key, &app.user_config.keys) || right_event(key, &app.user_config.keys) => { - // Toggle on left/right as well for better UX - if let Some(setting) = app.settings_items.get_mut(app.view.settings_selected_index) { - if let SettingValue::Bool(v) = setting.value { - setting.value = SettingValue::Bool(!v); - } - } - } - _ => {} - } -} - -fn handle_number_edit(key: Key, app: &mut App) { - match key { - Key::Enter => { - // Parse and apply the edited number - if let Ok(num) = app.view.settings_edit_buffer.parse::() { - if let Some(setting) = app.settings_items.get_mut(app.view.settings_selected_index) { - setting.value = SettingValue::Number(normalize_number_setting(&setting.id, num)); - } - } - app.view.settings_edit_mode = false; - app.view.settings_edit_buffer.clear(); - } - Key::Esc => { - app.view.settings_edit_mode = false; - app.view.settings_edit_buffer.clear(); - } - Key::Char(c) if c.is_ascii_digit() || c == '-' => { - app.view.settings_edit_buffer.push(c); - } - Key::Backspace => { - app.view.settings_edit_buffer.pop(); - } - key if up_event(key, &app.user_config.keys) => { - // Increment value - if let Some(setting) = app.settings_items.get_mut(app.view.settings_selected_index) { - if let SettingValue::Number(v) = setting.value { - let new_val = normalize_number_setting(&setting.id, v.saturating_add(1)); - setting.value = SettingValue::Number(new_val); - app.view.settings_edit_buffer = new_val.to_string(); - } - } - } - key if down_event(key, &app.user_config.keys) => { - // Decrement value - if let Some(setting) = app.settings_items.get_mut(app.view.settings_selected_index) { - if let SettingValue::Number(v) = setting.value { - let new_val = normalize_number_setting(&setting.id, v.saturating_sub(1)); - setting.value = SettingValue::Number(new_val); - app.view.settings_edit_buffer = new_val.to_string(); - } - } - } - _ => {} - } -} - -fn normalize_number_setting(id: &str, value: i64) -> i64 { - #[cfg(feature = "cover-art")] - if id == "behavior.cover_art_dither_pixel_scale" { - return i64::from(crate::core::user_config::normalize_cover_art_dither_pixel_scale(value)); - } - #[cfg(not(feature = "cover-art"))] - let _ = id; - value -} - -fn handle_string_edit(key: Key, app: &mut App) { - match key { - Key::Enter => { - // Apply the edited string - if let Some(setting) = app.settings_items.get_mut(app.view.settings_selected_index) { - let new_value = app.view.settings_edit_buffer.clone(); - - #[cfg(feature = "self-update")] - if setting.id == "behavior.auto_update_delay" { - if let Err(e) = crate::core::user_config::parse_update_delay_secs(&new_value) { - app.handle_error(anyhow::anyhow!("Invalid delay format: {}", e)); - return; - } - } - - let is_color_edit = matches!(setting.value, SettingValue::Color(_)) - && setting.id != "theme.cover_art_dither_color"; - match &setting.value { - SettingValue::String(_) => { - setting.value = SettingValue::String(new_value); - } - SettingValue::Color(_) => { - setting.value = SettingValue::Color(new_value); - } - _ => {} - } - // Editing an individual color switches the theme to Custom - if is_color_edit { - if let Some(preset_setting) = app - .settings_items - .iter_mut() - .find(|s| s.id == "theme.preset") - { - if let SettingValue::Preset(name) = &mut preset_setting.value { - *name = "Custom".to_string(); - } - } - } - } - app.view.settings_edit_mode = false; - app.view.settings_edit_buffer.clear(); - } - Key::Esc => { - app.view.settings_edit_mode = false; - app.view.settings_edit_buffer.clear(); - } - Key::Char(c) => { - app.view.settings_edit_buffer.push(c); - } - Key::Backspace => { - app.view.settings_edit_buffer.pop(); - } - _ => {} - } -} - -/// Check if a keybinding conflicts with another action -/// Returns Some(action_name) if conflict found, None otherwise -fn check_keybinding_conflict(app: &App, new_key: Key, current_setting_id: &str) -> Option { - // Iterate through all settings items - for setting in &app.settings_items { - // Skip if it's the same setting we're editing - if setting.id == current_setting_id { - continue; - } - - // Only check keybinding settings - if !setting.id.starts_with("keys.") { - continue; - } - - // Get the key value from this setting - if let SettingValue::Key(key_string) = &setting.value { - // Parse the key string to compare - if let Ok(existing_key) = crate::core::user_config::parse_key_public(key_string.clone()) { - // Check if keys match (case-sensitive comparison) - if existing_key == new_key { - // Return the friendly name of the conflicting action - return Some(setting.name.clone()); - } - } - } - } - - None -} - -fn handle_key_edit(key: Key, app: &mut App) { - match key { - // Escape cancels the key binding edit - Key::Esc => { - app.view.settings_edit_mode = false; - app.view.settings_edit_buffer.clear(); - } - // Ignore standalone modifier key presses (e.g. pressing Shift alone) - Key::Unknown => {} - // Any other key press is captured as the new keybinding - _ => { - // Check if this is a reserved key - if let Err(e) = crate::core::user_config::check_reserved_keys_public(key) { - // Show error but don't apply the reserved key - app.handle_error(anyhow::anyhow!("{}", e)); - app.view.settings_edit_mode = false; - app.view.settings_edit_buffer.clear(); - return; - } - - // Check for keybinding conflicts - if let Some(setting) = app.settings_items.get(app.view.settings_selected_index) { - if let Some(conflict_name) = check_keybinding_conflict(app, key, &setting.id) { - // Show error and don't apply the conflicting key - let key_display = key_to_config_string(&key); - app.handle_error(anyhow::anyhow!( - "Key {} is already assigned to {}", - key_display, - conflict_name - )); - app.view.settings_edit_mode = false; - app.view.settings_edit_buffer.clear(); - return; - } - } - - // Convert the key to string representation - let key_string = key_to_config_string(&key); - - // Apply the new keybinding - if let Some(setting) = app.settings_items.get_mut(app.view.settings_selected_index) { - setting.value = SettingValue::Key(key_string); - } - - app.view.settings_edit_mode = false; - app.view.settings_edit_buffer.clear(); - } - } -} - -/// Convert a Key to its config file string representation -fn key_to_config_string(key: &Key) -> String { - match key { - Key::Char(c) if *c == ' ' => "space".to_string(), - Key::Char(c) => c.to_string(), - Key::Ctrl(c) => format!("ctrl-{}", c), - Key::Alt(c) => format!("alt-{}", c), - Key::Enter => "enter".to_string(), - Key::Esc => "esc".to_string(), - Key::Backspace => "backspace".to_string(), - Key::Delete => "del".to_string(), - Key::Left => "left".to_string(), - Key::Right => "right".to_string(), - Key::Up => "up".to_string(), - Key::Down => "down".to_string(), - Key::PageUp => "pageup".to_string(), - Key::PageDown => "pagedown".to_string(), - Key::Home => "home".to_string(), - Key::End => "end".to_string(), - Key::Tab => "tab".to_string(), - Key::Ins => "ins".to_string(), - Key::F0 => "f0".to_string(), - Key::F1 => "f1".to_string(), - Key::F2 => "f2".to_string(), - Key::F3 => "f3".to_string(), - Key::F4 => "f4".to_string(), - Key::F5 => "f5".to_string(), - Key::F6 => "f6".to_string(), - Key::F7 => "f7".to_string(), - Key::F8 => "f8".to_string(), - Key::F9 => "f9".to_string(), - Key::F10 => "f10".to_string(), - Key::F11 => "f11".to_string(), - Key::F12 => "f12".to_string(), - Key::Unknown => "unknown".to_string(), - } -} - -fn switch_category_left(app: &mut App) { - let current_index = app.view.settings_category.index(); - let new_index = if current_index == 0 { - SettingsCategory::all().len() - 1 - } else { - current_index - 1 - }; - app.view.settings_category = SettingsCategory::from_index(new_index); - reload_category(app); -} - -fn switch_category_right(app: &mut App) { - let current_index = app.view.settings_category.index(); - let new_index = (current_index + 1) % SettingsCategory::all().len(); - app.view.settings_category = SettingsCategory::from_index(new_index); - reload_category(app); -} - -/// Step the highlight through the rows the filter shows, in the order they are -/// drawn, wrapping at both ends. -fn step_selection(app: &mut App, forward: bool) { - let visible = filtered_setting_indices(app); - let position = selected_setting_position(app); - let next = if forward { - on_down_press_handler(&visible, position) - } else { - on_up_press_handler(&visible, position) - }; - if let Some(index) = visible.get(next) { - app.view.settings_selected_index = *index; - } -} - -fn select_next_item(app: &mut App) { - step_selection(app, true); -} - -fn select_previous_item(app: &mut App) { - step_selection(app, false); -} - -pub(super) fn enter_edit_mode(app: &mut App) { - // A filter that matches nothing leaves no row under the highlight, so Enter - // must not edit whatever index the selection happened to keep. - if selected_setting_position(app).is_none() { - return; - } - - if let Some(setting) = app.settings_items.get(app.view.settings_selected_index) { - // For booleans, toggle directly without entering edit mode - if let SettingValue::Bool(v) = setting.value { - // Need to get mutable reference - if let Some(setting_mut) = app.settings_items.get_mut(app.view.settings_selected_index) { - setting_mut.value = SettingValue::Bool(!v); - } - return; - } - - // For presets, cycle to next preset directly - if let SettingValue::Preset(ref preset_name) = setting.value { - use crate::core::user_config::ThemePreset; - let current = ThemePreset::from_name(preset_name); - let next = current.next(); - if let Some(setting_mut) = app.settings_items.get_mut(app.view.settings_selected_index) { - setting_mut.value = SettingValue::Preset(next.name().to_string()); - } - let preview_theme = match next { - ThemePreset::Custom => app.user_config.custom_theme, - p => p.to_theme(), - }; - app.sync_theme_color_settings(&preview_theme); - app.sync_banner_gradient_setting(next); - return; - } - - // For cycle values, advance to next option directly - if let SettingValue::Cycle(ref current, options) = setting.value { - let next = cycle_next(current, options); - if let Some(setting_mut) = app.settings_items.get_mut(app.view.settings_selected_index) { - if let SettingValue::Cycle(ref mut cur, _) = setting_mut.value { - *cur = next; - } - } - return; - } - - // For other types, enter edit mode - app.view.settings_edit_mode = true; - // Pre-populate the edit buffer with current value - app.view.settings_edit_buffer = match &setting.value { - SettingValue::Number(v) => v.to_string(), - SettingValue::String(v) => v.clone(), - SettingValue::Key(v) => v.clone(), - SettingValue::Color(v) => v.clone(), - _ => String::new(), - }; - } -} - -/// Returns the next option after `current` in `options`, wrapping around. -fn cycle_next(current: &str, options: &[&str]) -> String { - let idx = options.iter().position(|o| *o == current).unwrap_or(0); - options[(idx + 1) % options.len()].to_string() -} - -fn handle_preset_edit(key: Key, app: &mut App) { - use crate::core::user_config::ThemePreset; - - fn cycle(app: &mut App, forward: bool) { - if let Some(setting) = app.settings_items.get(app.view.settings_selected_index) { - if let SettingValue::Preset(ref preset_name) = setting.value { - let current = ThemePreset::from_name(preset_name); - let next = if forward { - current.next() - } else { - current.prev() - }; - if let Some(setting_mut) = app.settings_items.get_mut(app.view.settings_selected_index) { - setting_mut.value = SettingValue::Preset(next.name().to_string()); - } - // Update theme item values when cycling through presets so that - // values can be previewed in real time - let preview_theme = match next { - ThemePreset::Custom => app.user_config.custom_theme, - p => p.to_theme(), - }; - app.sync_theme_color_settings(&preview_theme); - app.sync_banner_gradient_setting(next); - } - } - } - - match key { - Key::Enter | Key::Char(' ') => { - cycle(app, true); - app.view.settings_edit_mode = false; - } - Key::Esc => { - app.view.settings_edit_mode = false; - } - key if right_event(key, &app.user_config.keys) => cycle(app, true), - key if left_event(key, &app.user_config.keys) => cycle(app, false), - _ => {} - } -} - -fn save_settings(app: &mut App) -> bool { - matches!( - app.apply(Action::SaveSettings), - ActionOutcome::SettingsSaved { saved: true } - ) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::app::{ActiveBlock, RouteId}; - use crate::tui::handlers::handle_app; - - fn open_settings(app: &mut App) -> RouteId { - let previous_route = app.get_current_route().id.clone(); - app.open_settings_screen(); - previous_route - } - - /// Open the filter through the global key path, then type it, so the tests - /// also cover the routing that keeps global bindings off the query. - fn type_filter(app: &mut App, query: &str) { - handle_app(app.user_config.keys.search, app); - for character in query.chars() { - handle_app(Key::Char(character), app); - } - } - - fn setting_index(app: &App, id: &str) -> usize { - app - .settings_items - .iter() - .position(|setting| setting.id == id) - .unwrap_or_else(|| panic!("expected a {id} setting")) - } - - fn first_bool_setting_index(app: &App) -> usize { - app - .settings_items - .iter() - .position(|setting| matches!(setting.value, SettingValue::Bool(_))) - .expect("expected a boolean setting") - } - - #[test] - fn cycling_preset_to_terminal_turns_banner_gradient_setting_off() { - let mut app = App::default(); - app.view.settings_category = SettingsCategory::Theme; - open_settings(&mut app); - - app.view.settings_selected_index = app - .settings_items - .iter() - .position(|s| s.id == "theme.preset") - .expect("expected the preset setting"); - - fn banner_gradient(app: &App) -> bool { - match &app - .settings_items - .iter() - .find(|s| s.id == "behavior.banner_gradient") - .expect("expected the banner gradient setting") - .value - { - SettingValue::Bool(v) => *v, - other => panic!("expected a bool setting, got {other:?}"), - } - } - assert!(banner_gradient(&app)); - - // Default (Cyan) -> Terminal (ANSI): gradient defaults off - handler(Key::Enter, &mut app); - assert!(!banner_gradient(&app)); - - // Terminal (ANSI) -> Pookie Pink: gradient default restored - handler(Key::Enter, &mut app); - assert!(banner_gradient(&app)); - } - - #[cfg(feature = "cover-art")] - #[test] - fn dither_color_edit_keeps_preset_and_auto_clears_override() { - use crate::core::theme::Color; - use crate::core::user_config::ThemePreset; - let mut app = App::default(); - app.view.settings_category = SettingsCategory::Theme; - open_settings(&mut app); - app.view.settings_selected_index = setting_index(&app, "theme.cover_art_dither_color"); - handler(Key::Enter, &mut app); - app.view.settings_edit_buffer = "1, 2, 3".to_string(); - handler(Key::Enter, &mut app); - assert_eq!( - app.settings_items[setting_index(&app, "theme.preset")].value, - SettingValue::Preset(ThemePreset::Default.name().to_string()) - ); - app.apply_settings_changes(); - assert_eq!(app.user_config.current_preset, ThemePreset::Default); - assert_eq!( - app.user_config.cover_art_dither_color, - Some(Color::Rgb(1, 2, 3)) - ); - app.view.settings_selected_index = setting_index(&app, "theme.cover_art_dither_color"); - handler(Key::Enter, &mut app); - app.view.settings_edit_buffer = " AUTO ".to_string(); - handler(Key::Enter, &mut app); - app.apply_settings_changes(); - assert_eq!(app.user_config.cover_art_dither_color, None); - } - - #[cfg(feature = "cover-art")] - #[test] - fn dither_behavior_rows_apply_and_clamp_on_save() { - let mut app = App::default(); - open_settings(&mut app); - let enabled = setting_index(&app, "behavior.cover_art_dither"); - app.settings_items[enabled].value = SettingValue::Bool(true); - let algorithm = setting_index(&app, "behavior.cover_art_dither_algorithm"); - app.settings_items[algorithm].value = - SettingValue::Cycle("atkinson".into(), &["stucki", "bayer8x8", "atkinson"]); - let scale = setting_index(&app, "behavior.cover_art_dither_pixel_scale"); - app.view.settings_selected_index = scale; - app.settings_items[scale].value = SettingValue::Number(3); - handle_number_edit(Key::Char('k'), &mut app); - assert_eq!(app.settings_items[scale].value, SettingValue::Number(3)); - app.view.settings_edit_buffer = "4".to_string(); - handle_number_edit(Key::Enter, &mut app); - assert_eq!(app.settings_items[scale].value, SettingValue::Number(3)); - app.settings_items[scale].value = SettingValue::Number(100); - app.apply_settings_changes(); - assert!(app.user_config.behavior.cover_art_dither); - assert_eq!( - app.user_config.behavior.cover_art_dither_algorithm, - "atkinson" - ); - assert_eq!(app.user_config.behavior.cover_art_dither_pixel_scale, 3); - } - - #[test] - fn esc_without_changes_exits_settings_without_prompt() { - let mut app = App::default(); - let previous_route = open_settings(&mut app); - - handler(Key::Esc, &mut app); - - assert_eq!(app.get_current_route().id, previous_route); - assert!(!app.view.settings_unsaved_prompt_visible); - } - - #[test] - fn esc_with_changes_opens_unsaved_prompt() { - let mut app = App::default(); - open_settings(&mut app); - - app.view.settings_selected_index = first_bool_setting_index(&app); - handler(Key::Enter, &mut app); - handler(Key::Esc, &mut app); - - assert!(app.view.settings_unsaved_prompt_visible); - assert_eq!(app.get_current_route().active_block, ActiveBlock::Settings); - } - - #[test] - fn n_dismisses_unsaved_prompt_and_discards_changes() { - let mut app = App::default(); - let previous_route = open_settings(&mut app); - - app.view.settings_selected_index = first_bool_setting_index(&app); - handler(Key::Enter, &mut app); - handler(Key::Esc, &mut app); - assert!(app.view.settings_unsaved_prompt_visible); - - handler(Key::Char('n'), &mut app); - - assert!(!app.view.settings_unsaved_prompt_visible); - assert_eq!(app.get_current_route().id, previous_route); - } - - #[test] - fn the_search_key_opens_the_row_filter_and_captures_global_keys() { - let mut app = App::default(); - open_settings(&mut app); - - // Left to the global bindings, `d` would open devices and `q` would quit. - type_filter(&mut app, "dq"); - - assert_eq!(app.get_current_route().id, RouteId::Settings); - assert!(app.view.settings_filter_editing); - assert_eq!(app.view.settings_filter, "dq"); - } - - #[test] - fn the_filter_uses_the_configured_search_binding() { - use crate::core::user_config::UserConfig; - use std::sync::mpsc::channel; - use std::time::SystemTime; - - let (tx, _rx) = channel(); - let mut config = UserConfig::new(); - config.keys.search = Key::Char('f'); - let mut app = App::new(tx, config, Some(SystemTime::now())); - open_settings(&mut app); - - handle_app(Key::Char('f'), &mut app); - - assert!(app.view.settings_filter_editing); - assert!(app.view.settings_filter.is_empty()); - } - - #[test] - fn y_on_the_unsaved_prompt_keeps_the_screen_open_when_the_save_fails() { - // No config path: the write fails and the error frame lands on top. - let mut app = App::default(); - open_settings(&mut app); - app.view.settings_unsaved_prompt_visible = true; - - handler(Key::Char('y'), &mut app); - - assert_eq!(app.get_current_route().id, RouteId::Error); - assert!(app.view.settings_unsaved_prompt_visible); - } - - #[test] - fn selection_walks_only_the_rows_the_filter_left() { - let mut app = App::default(); - open_settings(&mut app); - type_filter(&mut app, "sort"); - handler(Key::Enter, &mut app); - - let visible = filtered_setting_indices(&app); - assert!(visible.len() > 1, "expected several sort rows"); - assert_eq!(app.view.settings_selected_index, visible[0]); - - handler(Key::Down, &mut app); - assert_eq!(app.view.settings_selected_index, visible[1]); - - handler(Key::Up, &mut app); - assert_eq!(app.view.settings_selected_index, visible[0]); - - // Wrapping off the top lands on the last visible row, never a hidden one. - handler(Key::Up, &mut app); - assert_eq!( - app.view.settings_selected_index, - *visible.last().expect("visible rows") - ); - } - - #[test] - fn enter_edits_the_row_the_filter_selected() { - let mut app = App::default(); - open_settings(&mut app); - type_filter(&mut app, "loading indicator"); - handler(Key::Enter, &mut app); - - let index = setting_index(&app, "behavior.show_loading_indicator"); - assert_eq!(app.view.settings_selected_index, index); - let before = app.settings_items[index].value.clone(); - - handler(Key::Enter, &mut app); - - assert_ne!(app.settings_items[index].value, before); - } - - #[test] - fn a_filter_that_matches_nothing_leaves_no_row_to_edit() { - let mut app = App::default(); - open_settings(&mut app); - let before = app.settings_items.clone(); - - type_filter(&mut app, "zzzzzzzz"); - assert!(filtered_setting_indices(&app).is_empty()); - - handler(Key::Enter, &mut app); // applies the filter - handler(Key::Enter, &mut app); // would toggle a row, if one were selected - - assert_eq!(app.settings_items, before); - } - - #[test] - fn the_filter_survives_a_category_switch() { - let mut app = App::default(); - open_settings(&mut app); - type_filter(&mut app, "icon"); - handler(Key::Enter, &mut app); - - handler(Key::Right, &mut app); - - assert_eq!(app.view.settings_category, SettingsCategory::Icons); - assert_eq!(app.view.settings_filter, "icon"); - let visible = filtered_setting_indices(&app); - assert!(!visible.is_empty()); - assert_eq!(app.view.settings_selected_index, visible[0]); - } - - #[test] - fn escape_drops_the_filter_before_it_leaves_the_screen() { - let mut app = App::default(); - let previous_route = open_settings(&mut app); - type_filter(&mut app, "volume"); - handler(Key::Enter, &mut app); - assert_eq!(app.view.settings_filter, "volume"); - - handler(Key::Esc, &mut app); - assert!(app.view.settings_filter.is_empty()); - assert_eq!(app.get_current_route().id, RouteId::Settings); - - handler(Key::Esc, &mut app); - assert_eq!(app.get_current_route().id, previous_route); - - // The back key leaves in one step and still drops the filter with it. - open_settings(&mut app); - type_filter(&mut app, "volume"); - handler(Key::Enter, &mut app); - handler(app.user_config.keys.back, &mut app); - assert_eq!(app.get_current_route().id, previous_route); - assert!(app.view.settings_filter.is_empty()); - } -} diff --git a/src/tui/handlers/sort_menu.rs b/src/tui/handlers/sort_menu.rs deleted file mode 100644 index bf52d70f..00000000 --- a/src/tui/handlers/sort_menu.rs +++ /dev/null @@ -1,145 +0,0 @@ -//! Sort menu handler for context sorting -//! -//! Handles keyboard input for the sort menu popup - -use super::common_key_events; -use crate::core::action::Action; -use crate::core::app::{ActiveBlock, App}; -use crate::core::sort::SortContext; -use crate::tui::event::Key; - -/// Handle input when the sort menu is active -pub fn handler(key: Key, app: &mut App) { - let context = match app.view.sort_context { - Some(ctx) => ctx, - None => { - // No context, close menu - close_sort_menu(app); - return; - } - }; - let available_fields = context.available_fields(); - - match key { - Key::Esc | Key::Char(',') => { - close_sort_menu(app); - } - k if common_key_events::up_event(k, &app.user_config.keys) => { - if app.view.sort_menu_selected > 0 { - app.view.sort_menu_selected -= 1; - } else { - app.view.sort_menu_selected = available_fields.len().saturating_sub(1); - } - } - k if common_key_events::down_event(k, &app.user_config.keys) => { - if app.view.sort_menu_selected < available_fields.len().saturating_sub(1) { - app.view.sort_menu_selected += 1; - } else { - app.view.sort_menu_selected = 0; - } - } - Key::Enter => { - if let Some(field) = available_fields.get(app.view.sort_menu_selected) { - app.apply(Action::Sort { - context, - field: *field, - }); - } - close_sort_menu(app); - } - // Quick select by shortcut character (lowercase = ascending, uppercase = descending) - Key::Char(c) => { - // Find field matching this shortcut - for field in available_fields { - if let Some(shortcut) = field.shortcut() { - if c == shortcut || c == shortcut.to_ascii_uppercase() { - app.apply(Action::Sort { - context, - field: *field, - }); - // Toggle order if uppercase - if c.is_ascii_uppercase() { - app.apply(Action::ToggleSortOrder(context)); - } - close_sort_menu(app); - return; - } - } - } - } - _ => {} - } -} - -/// Open the sort menu for a given context -pub fn open_sort_menu(app: &mut App, context: SortContext) { - app.view.sort_context = Some(context); - app.view.sort_menu_visible = true; - app.view.sort_menu_selected = 0; - - // Find current sort field in the available fields to highlight it - let current_field = app.sort_state(context).field; - - let available = context.available_fields(); - for (i, field) in available.iter().enumerate() { - if *field == current_field { - app.view.sort_menu_selected = i; - break; - } - } - - app.set_current_route_state(Some(ActiveBlock::SortMenu), None); -} - -fn close_sort_menu(app: &mut App) { - app.view.sort_menu_visible = false; - app.view.sort_context = None; - app.set_current_route_state(Some(ActiveBlock::Empty), None); -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::sort::{SortField, SortOrder}; - - fn app_with_open_menu(context: SortContext) -> App { - let mut app = App::default(); - open_sort_menu(&mut app, context); - app - } - - #[test] - fn enter_applies_the_highlighted_field_and_closes_the_menu() { - let mut app = app_with_open_menu(SortContext::SavedArtists); - // Available fields for artists: [Default, Name]. - app.view.sort_menu_selected = 1; - - handler(Key::Enter, &mut app); - - assert_eq!(app.artist_sort.field, SortField::Name); - assert_eq!(app.artist_sort.order, SortOrder::Ascending); - assert!(!app.view.sort_menu_visible); - assert!(app.view.sort_context.is_none()); - } - - #[test] - fn an_uppercase_shortcut_records_the_reversed_order() { - let mut app = app_with_open_menu(SortContext::SavedArtists); - - handler(Key::Char('N'), &mut app); - - assert_eq!(app.artist_sort.field, SortField::Name); - assert_eq!(app.artist_sort.order, SortOrder::Descending); - assert!(!app.view.sort_menu_visible); - } - - #[test] - fn a_character_without_a_shortcut_leaves_the_menu_open() { - let mut app = app_with_open_menu(SortContext::SavedArtists); - - handler(Key::Char('x'), &mut app); - - assert_eq!(app.artist_sort.field, SortField::Default); - assert!(app.view.sort_menu_visible); - } -} diff --git a/src/tui/handlers/stats.rs b/src/tui/handlers/stats.rs deleted file mode 100644 index 0e5dbbf8..00000000 --- a/src/tui/handlers/stats.rs +++ /dev/null @@ -1,133 +0,0 @@ -use super::common_key_events; -use crate::core::action::Action; -use crate::core::app::App; -use crate::tui::event::Key; - -pub fn handler(key: Key, app: &mut App) { - match key { - k if common_key_events::left_event(k, &app.user_config.keys) => { - common_key_events::handle_left_event(app) - } - k if common_key_events::down_event(k, &app.user_config.keys) => { - if let Some(stats) = &app.stats_data { - app.view.stats_selected_track = common_key_events::on_down_press_handler( - &stats.top_tracks, - Some(app.view.stats_selected_track), - ); - } - } - k if common_key_events::up_event(k, &app.user_config.keys) => { - if let Some(stats) = &app.stats_data { - app.view.stats_selected_track = common_key_events::on_up_press_handler( - &stats.top_tracks, - Some(app.view.stats_selected_track), - ); - } - } - Key::Char('[') => cycle_period(app, false), - Key::Char(']') => cycle_period(app, true), - Key::Enter => { - let uri = app.stats_data.as_ref().and_then(|stats| { - stats - .top_tracks - .get(app.view.stats_selected_track) - .and_then(|entry| entry.uri.clone()) - }); - match uri { - Some(uri) if uri.starts_with("spotify:track:") => { - app.apply(Action::PlayUris { - uris: vec![uri], - offset: Some(0), - }); - } - Some(_) | None => { - let has_tracks = app - .stats_data - .as_ref() - .is_some_and(|stats| !stats.top_tracks.is_empty()); - if has_tracks { - app.set_status_message("This entry can't be played directly".to_string(), 4); - } - } - } - } - _ => {} - } -} - -fn cycle_period(app: &mut App, forward: bool) { - app.apply(Action::CycleStatsPeriod { forward }); - app.view.stats_selected_track = 0; -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::user_config::UserConfig; - use crate::infra::history::{RankedEntry, RecapPeriod, StatsData}; - use crate::infra::network::IoEvent; - use std::sync::mpsc::{channel, Receiver}; - use std::time::SystemTime; - - fn app_with_track(uri: Option) -> (App, Receiver) { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.stats_data = Some(StatsData { - total_plays: 1, - total_time_ms: 60_000, - top_tracks: vec![RankedEntry { - display: "Song - Artist".to_string(), - detail: "1 plays · 1m".to_string(), - value: 60_000, - uri, - }], - top_artists: vec![], - top_albums: vec![], - days: vec![], - }); - (app, rx) - } - - #[test] - fn cycling_period_dispatches_stats_load() { - let (mut app, rx) = app_with_track(None); - handler(Key::Char(']'), &mut app); - assert_eq!(app.stats_period, RecapPeriod::Month); - assert!(app.stats_loading); - assert!(app.stats_data.is_none()); - assert!(matches!( - rx.try_recv(), - Ok(IoEvent::LoadListeningStats(RecapPeriod::Month)) - )); - } - - #[test] - fn enter_plays_selected_spotify_track() { - let (mut app, rx) = app_with_track(Some("spotify:track:abc".to_string())); - handler(Key::Enter, &mut app); - assert!(matches!( - rx.try_recv(), - Ok(IoEvent::StartPlayback(None, Some(uris), Some(0))) if uris == vec!["spotify:track:abc".to_string()] - )); - } - - #[test] - fn enter_on_unplayable_entry_shows_status_message() { - let (mut app, rx) = app_with_track(None); - handler(Key::Enter, &mut app); - assert!(rx.try_recv().is_err()); - assert!(app.status_message().is_some()); - } - - #[test] - fn right_from_sidebar_refocuses_stats_content() { - use crate::core::app::{ActiveBlock, RouteId}; - - let (mut app, _rx) = app_with_track(None); - app.push_navigation_stack(RouteId::Stats, ActiveBlock::Stats); - // Left puts focus back on the sidebar; right must return it to the screen. - common_key_events::handle_left_event(&mut app); - common_key_events::handle_right_event(&mut app); - assert_eq!(app.get_current_route().active_block, ActiveBlock::Stats); - } -} diff --git a/src/tui/handlers/track_table.rs b/src/tui/handlers/track_table.rs deleted file mode 100644 index ceeb3a68..00000000 --- a/src/tui/handlers/track_table.rs +++ /dev/null @@ -1,887 +0,0 @@ -use super::common_key_events; -use crate::core::action::{Action, ListTarget}; -use crate::core::app::{App, TrackTable, TrackTableContext}; -use crate::tui::event::Key; -use rspotify::prelude::Id; - -pub fn handler(key: Key, app: &mut App) { - match key { - k if common_key_events::left_event(k, &app.user_config.keys) => { - common_key_events::handle_left_event(app) - } - k if common_key_events::down_event(k, &app.user_config.keys) => { - let current_index = app.view.track_table_index; - let tracks_len = app.track_table.tracks.len(); - - if tracks_len == 0 { - return; - } - - // Check if we're at the last track and there are more tracks to load - if current_index == tracks_len - 1 { - match &app.track_table.context { - Some(TrackTableContext::MyPlaylists) | Some(TrackTableContext::PlaylistSearch) => { - if app.current_playlist_has_more_tracks() { - app.select_row_when_next_page_lands(tracks_len); - app.apply(Action::LoadMore(ListTarget::PlaylistTracks)); - return; - } - app.view.track_table_index = 0; - return; - } - Some(TrackTableContext::DiscoverPlaylist) => { - // Discover playlists don't support pagination - } - Some(TrackTableContext::SavedTracks) => { - if app.current_saved_tracks_has_more_tracks() { - app.select_row_when_next_page_lands(tracks_len); - app.apply(Action::LoadMore(ListTarget::SavedTracks)); - return; - } - app.view.track_table_index = 0; - return; - } - _ => {} - } - } - - let next_index = common_key_events::on_down_press_handler( - &app.track_table.tracks, - Some(app.view.track_table_index), - ); - app.view.track_table_index = next_index; - } - k if common_key_events::up_event(k, &app.user_config.keys) => { - if app.track_table.tracks.is_empty() { - return; - } - - let next_index = common_key_events::on_up_press_handler( - &app.track_table.tracks, - Some(app.view.track_table_index), - ); - app.view.track_table_index = next_index; - } - k if common_key_events::high_event(k) => { - let next_index = common_key_events::on_high_press_handler(); - app.view.track_table_index = next_index; - } - k if common_key_events::middle_event(k) => { - let next_index = common_key_events::on_middle_press_handler(&app.track_table.tracks); - app.view.track_table_index = next_index; - } - k if common_key_events::low_event(k) => { - let next_index = common_key_events::on_low_press_handler(&app.track_table.tracks); - app.view.track_table_index = next_index; - } - Key::Enter => { - on_enter(app); - } - // Scroll down - k if k == app.user_config.keys.next_page => { - if let Some(context) = &app.track_table.context { - match context { - TrackTableContext::MyPlaylists | TrackTableContext::PlaylistSearch => { - // Self-guarding: a no-op when there is no next page. - app.apply(Action::LoadMore(ListTarget::PlaylistTracks)); - } - TrackTableContext::RecommendedTracks => {} - TrackTableContext::SavedTracks => { - app.apply(Action::LoadMore(ListTarget::SavedTracks)); - } - TrackTableContext::AlbumSearch => {} - TrackTableContext::DiscoverPlaylist => {} - // Local folders and Subsonic/YouTube/Qobuz playlists have no pagination. - TrackTableContext::LocalPlaylist - | TrackTableContext::SubsonicPlaylist - | TrackTableContext::YouTubePlaylist - | TrackTableContext::QobuzPlaylist => {} - } - }; - } - // Scroll up - k if k == app.user_config.keys.previous_page => { - if let Some(context) = &app.track_table.context { - match context { - TrackTableContext::MyPlaylists | TrackTableContext::PlaylistSearch => { - app.view.track_table_index = 0; - } - TrackTableContext::RecommendedTracks => {} - TrackTableContext::SavedTracks => { - app.view.track_table_index = 0; - } - TrackTableContext::AlbumSearch => {} - TrackTableContext::DiscoverPlaylist => {} - // Local folders and Subsonic/YouTube/Qobuz playlists have no pagination. - TrackTableContext::LocalPlaylist - | TrackTableContext::SubsonicPlaylist - | TrackTableContext::YouTubePlaylist - | TrackTableContext::QobuzPlaylist => {} - } - }; - } - Key::Char('w') => { - app.apply(Action::OpenAddTrackDialog); - } - Key::Char('x') => { - app.apply(Action::OpenRemoveTrackDialog); - } - Key::Char('q') if app.is_playlist_track_filter_active() => { - app.clear_playlist_track_filter(); - } - Key::Char('s') => handle_save_track_event(app), - Key::Char('S') => play_random_song(app), - k if k == app.user_config.keys.jump_to_end => jump_to_end(app), - k if k == app.user_config.keys.jump_to_start => jump_to_start(app), - //recommended song radio - Key::Char('r') => { - handle_recommended_tracks(app); - } - _ if key == app.user_config.keys.add_item_to_queue => on_queue(app), - // Open sort menu - Key::Char(',') => { - super::sort_menu::open_sort_menu(app, crate::core::sort::SortContext::PlaylistTracks); - } - _ => {} - } -} - -fn handle_save_track_event(app: &mut App) { - if let Some(track) = app.track_table.tracks.get(app.view.track_table_index) { - if let Some(playable_id) = track.uri.clone() { - app.apply(Action::ToggleSaveTrack(playable_id)); - } - }; -} - -fn handle_recommended_tracks(app: &mut App) { - if let Some(track) = app - .track_table - .tracks - .get(app.view.track_table_index) - .cloned() - { - app.apply(Action::RecommendFromTrack(track)); - }; -} - -/// Play a random track from the table's context. Offsets are computed here so -/// `apply` stays deterministic. -fn play_random_song(app: &mut App) { - if let Some(context) = &app.track_table.context { - match context { - TrackTableContext::MyPlaylists | TrackTableContext::PlaylistSearch => { - let context_id = current_playlist_context_id(app); - let track_json = current_playlist_total_tracks(app); - - // The no-id degenerate case (unreachable through normal navigation, - // since the table only opens with an id set) is skipped rather than - // dispatched as a bare resume with a random offset. An empty playlist - // reports a total of 0, and `random_range(0..0)` panics, so that is - // skipped too. - if let (Some(uri), Some(val)) = (context_id, track_json) { - if val == 0 { - return; - } - app.apply(Action::PlayContext { - uri, - offset: Some(rand::random_range(0..val as usize)), - }); - } - } - TrackTableContext::RecommendedTracks => {} - TrackTableContext::SavedTracks => { - let playable_ids: Vec = app - .track_table - .tracks - .iter() - .filter_map(|track| track.uri.clone()) - .collect(); - if !playable_ids.is_empty() { - let rand_idx = rand::random_range(0..playable_ids.len()); - app.apply(Action::PlayUris { - uris: playable_ids, - offset: Some(rand_idx), - }); - } - } - TrackTableContext::AlbumSearch => {} - TrackTableContext::DiscoverPlaylist => { - // Play random track from currently displayed discover playlist, but keep the full list - // so next/previous can continue within the mix. - let mut playable_ids: Vec = Vec::new(); - for track in &app.track_table.tracks { - if let Some(playable_id) = track.uri.clone() { - playable_ids.push(playable_id); - } - } - if !playable_ids.is_empty() { - let rand_idx = rand::random_range(0..playable_ids.len()); - app.apply(Action::PlayUris { - uris: playable_ids, - offset: Some(rand_idx), - }); - } - } - TrackTableContext::LocalPlaylist | TrackTableContext::SubsonicPlaylist => { - // Single-file playback: play one random track from the folder/playlist. - // Wire shape changed with the Action conversion: this used to send the - // track in the context slot (`StartPlayback(Some(uri), None, None)`). - // A one-item URI list is equivalent: the local, Subsonic and YouTube - // routers start a one-track queue at offset 0 for both shapes, and the - // queue router clears its slot on both. - let playable_ids: Vec = app - .track_table - .tracks - .iter() - .filter_map(|track| track.uri.clone()) - .collect(); - if !playable_ids.is_empty() { - let rand_idx = rand::random_range(0..playable_ids.len()); - app.apply(Action::PlayUris { - uris: vec![playable_ids[rand_idx].clone()], - offset: None, - }); - } - } - TrackTableContext::YouTubePlaylist | TrackTableContext::QobuzPlaylist => { - // Queue the whole playlist and start at a random offset, so - // Next/Previous and auto-advance keep working within the playlist. - let playable_ids: Vec = app - .track_table - .tracks - .iter() - .filter_map(|track| track.uri.clone()) - .collect(); - if !playable_ids.is_empty() { - let rand_idx = rand::random_range(0..playable_ids.len()); - app.apply(Action::PlayUris { - uris: playable_ids, - offset: Some(rand_idx), - }); - } - } - } - }; -} - -fn jump_to_end(app: &mut App) { - if !app.track_table.tracks.is_empty() { - app.view.track_table_index = app.track_table.tracks.len() - 1; - } -} - -fn on_enter(app: &mut App) { - let TrackTable { context, tracks } = &app.track_table; - let selected_index = &app.view.track_table_index; - if let Some(context) = &context { - match context { - TrackTableContext::MyPlaylists | TrackTableContext::PlaylistSearch => { - if let Some(track) = tracks.get(*selected_index) { - // Get the track ID to play - let track_playable_id = track.uri.clone(); - let context_id = current_playlist_context_id(app); - - // If we have a track ID, play it directly within the context - // This ensures the selected track plays first, even with shuffle on - if let Some(playable_id) = track_playable_id { - if let Some(context_uri) = context_id { - app.apply(Action::PlayTrackInContext { - context: context_uri, - track: playable_id, - }); - } else { - // Degenerate no-id case: the same context-less event this - // path has always sent. - app.apply(Action::PlayUris { - uris: vec![playable_id], - offset: Some(0), // Play the first (and only) track in the URIs list - }); - } - } else if let Some(context_uri) = context_id { - // Fallback to context playback with offset - app.apply(Action::PlayContext { - uri: context_uri, - offset: app.selected_playlist_track_position(), - }); - } - // The no-id, no-uri degenerate case (unreachable through normal - // navigation) is skipped rather than dispatched as a bare resume - // with an offset. - }; - } - TrackTableContext::RecommendedTracks | TrackTableContext::DiscoverPlaylist => { - // The whole list is sent so playback can continue past the selection. - let (uris, offset) = common_key_events::uri_playback_request( - tracks.iter().map(|track| track.uri.clone()), - *selected_index, - ); - if !uris.is_empty() { - app.apply(Action::PlayUris { - uris, - offset: Some(offset.unwrap_or(0)), - }); - } - } - TrackTableContext::SavedTracks => { - if let Some((all_playable_ids, absolute_offset)) = saved_tracks_playback_request(app) { - app.apply(Action::PlayUris { - uris: all_playable_ids, - offset: Some(absolute_offset), - }); - } - } - TrackTableContext::AlbumSearch => {} - TrackTableContext::LocalPlaylist - | TrackTableContext::SubsonicPlaylist - | TrackTableContext::YouTubePlaylist - | TrackTableContext::QobuzPlaylist => { - // Queue the whole folder/playlist (in displayed order) and start at the - // selected track, so Next/Previous/auto-advance have a queue to move - // through. Routed to the local, subsonic or youtube player by URI - // scheme (infra::local / infra::subsonic / infra::youtube dispatch). - let (uris, offset) = common_key_events::uri_playback_request( - tracks.iter().map(|track| track.uri.clone()), - *selected_index, - ); - if !uris.is_empty() { - app.apply(Action::PlayUris { - uris, - offset: Some(offset.unwrap_or(0)), - }); - } - } - } - }; -} - -fn on_queue(app: &mut App) { - // Every context except AlbumSearch holds full `TrackInfo` rows, so the queue - // action collapses to one path: clone the selected row and hand it to the - // native cross-source queue, which routes by URI scheme (Spotify tracks on an - // external device still fall back to the Web-API queue). AlbumSearch rows lack - // full track info, so it stays a no-op. - match app.track_table.context { - Some(TrackTableContext::AlbumSearch) | None => return, - Some(_) => {} - } - if let Some(track) = app - .track_table - .tracks - .get(app.view.track_table_index) - .cloned() - { - app.apply(Action::QueueTrack(track)); - } -} - -fn jump_to_start(app: &mut App) { - app.view.track_table_index = 0; -} - -/// The active playlist's `spotify:playlist:` context URI, for `StartPlayback`. -fn current_playlist_context_id(app: &App) -> Option { - app.current_playlist_track_table_id().map(|id| id.uri()) -} - -fn current_playlist_total_tracks(app: &App) -> Option { - app.current_playlist_track_total() -} - -fn saved_tracks_playback_request(app: &App) -> Option<(Vec, usize)> { - let (uris, offset) = common_key_events::uri_playback_request( - app.track_table.tracks.iter().map(|track| track.uri.clone()), - app.view.track_table_index, - ); - offset.map(|offset| (uris, offset)) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::app::PendingTrackSelection; - use crate::core::pagination::Paged; - use crate::core::plugin_api::{PlayableInfo, TrackInfo}; - use crate::core::test_helpers::full_track; - use crate::core::user_config::UserConfig; - use crate::infra::network::IoEvent; - use chrono::Utc; - use rspotify::model::{idtypes::PlaylistId, page::Page, track::SavedTrack}; - use std::sync::mpsc::channel; - use std::time::SystemTime; - - fn saved_track(id: &str, name: &str) -> SavedTrack { - SavedTrack { - added_at: Utc::now(), - track: full_track(id, name), - } - } - - /// A Spotify playback context on some non-native device. In a slim (no - /// streaming) build, any Spotify context reads as an external device. - #[allow(deprecated)] - fn external_spotify_context() -> rspotify::model::context::CurrentPlaybackContext { - use rspotify::model::{ - context::{Actions, CurrentPlaybackContext}, - CurrentlyPlayingType, Device, DeviceType, RepeatState, - }; - CurrentPlaybackContext { - device: Device { - id: Some("external-device".to_string()), - is_active: true, - is_private_session: false, - is_restricted: false, - name: "Phone".to_string(), - _type: DeviceType::Smartphone, - volume_percent: Some(50), - }, - repeat_state: RepeatState::Off, - shuffle_state: false, - context: None, - timestamp: Utc::now(), - progress: None, - is_playing: true, - item: None, - currently_playing_type: CurrentlyPlayingType::Track, - actions: Actions::default(), - } - } - - fn playlist_item(position: u32, id: &str, name: &str) -> (u32, PlayableInfo) { - ( - position, - PlayableInfo::Track(TrackInfo::from(&full_track(id, name))), - ) - } - - fn saved_tracks_page(offset: u32, ids: &[&str], has_next: bool) -> Paged { - saved_tracks_page_with_total(offset, ids, has_next, 4) - } - - fn saved_tracks_page_with_total( - offset: u32, - ids: &[&str], - has_next: bool, - total: u32, - ) -> Paged { - let rspotify_page = Page { - href: "https://example.com/me/tracks".to_string(), - items: ids - .iter() - .enumerate() - .map(|(index, id)| saved_track(id, &format!("Track {offset}-{index}"))) - .collect(), - limit: ids.len() as u32, - next: has_next.then(|| "https://example.com/me/tracks?next".to_string()), - offset, - previous: None, - total, - }; - crate::infra::network::mapping::map_page(&rspotify_page, |st| TrackInfo::from(&st.track)) - } - - fn app_with_saved_tracks() -> (App, std::sync::mpsc::Receiver) { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.track_table.context = Some(TrackTableContext::SavedTracks); - (app, rx) - } - - #[test] - fn saved_tracks_playback_request_uses_page_zero_selection() { - let (mut app, _rx) = app_with_saved_tracks(); - let page = saved_tracks_page( - 0, - &[ - "0000000000000000000001", - "0000000000000000000002", - "0000000000000000000003", - ], - false, - ); - app.view.track_table_index = 1; - app.track_table.tracks = page.items.iter().cloned().collect(); - app.library.saved_tracks.upsert_page_by_offset(page); - app.library.saved_tracks.index = 0; - - let (uris, offset) = saved_tracks_playback_request(&app).unwrap(); - - assert_eq!(offset, 1); - assert_eq!(uris.len(), 3); - assert_eq!(uris[offset], "spotify:track:0000000000000000000002"); - } - - #[test] - fn saved_tracks_playback_request_uses_continuous_row_selection() { - let (mut app, _rx) = app_with_saved_tracks(); - let first_page = saved_tracks_page( - 0, - &["0000000000000000000001", "0000000000000000000002"], - true, - ); - let second_page = saved_tracks_page( - 2, - &["0000000000000000000003", "0000000000000000000004"], - false, - ); - app - .library - .saved_tracks - .upsert_page_by_offset(first_page.clone()); - app - .library - .saved_tracks - .upsert_page_by_offset(second_page.clone()); - app.library.saved_tracks.index = 1; - app.view.track_table_index = 3; - app.track_table.tracks = first_page - .items - .iter() - .chain(second_page.items.iter()) - .cloned() - .collect(); - - let (uris, offset) = saved_tracks_playback_request(&app).unwrap(); - - assert_eq!(offset, 3); - assert_eq!(uris.len(), 4); - assert_eq!(uris[offset], "spotify:track:0000000000000000000004"); - } - - #[test] - fn saved_tracks_queue_pushes_selected_row_to_native_queue() { - let (mut app, rx) = app_with_saved_tracks(); - let first_page = saved_tracks_page( - 0, - &["0000000000000000000001", "0000000000000000000002"], - true, - ); - let second_page = saved_tracks_page( - 2, - &["0000000000000000000003", "0000000000000000000004"], - false, - ); - app - .library - .saved_tracks - .upsert_page_by_offset(first_page.clone()); - app - .library - .saved_tracks - .upsert_page_by_offset(second_page.clone()); - app.library.saved_tracks.index = 1; - app.view.track_table_index = 3; - app.track_table.tracks = first_page - .items - .iter() - .chain(second_page.items.iter()) - .cloned() - .collect(); - - on_queue(&mut app); - - // With no external Spotify device active, the track lands in the native - // queue and no Web-API AddItemToQueue is dispatched. - assert_eq!(app.native_queue.len(), 1); - assert_eq!( - app.native_queue[0].uri.as_deref(), - Some("spotify:track:0000000000000000000004") - ); - assert!(rx.try_recv().is_err()); - } - - #[test] - fn saved_tracks_queue_on_external_device_dispatches_web_api_add() { - let (mut app, rx) = app_with_saved_tracks(); - let page = saved_tracks_page( - 0, - &["0000000000000000000001", "0000000000000000000002"], - false, - ); - app.library.saved_tracks.upsert_page_by_offset(page.clone()); - app.library.saved_tracks.index = 0; - app.view.track_table_index = 1; - app.track_table.tracks = page.items.to_vec(); - // Simulate controlling an external Spotify Connect device: any Spotify - // context with no native streaming device counts as external in the slim - // build, so `z` keeps today's Web-API queue behavior. - app.current_playback_context = Some(external_spotify_context()); - - on_queue(&mut app); - - match rx.recv().unwrap() { - IoEvent::AddItemToQueue(uri) => { - assert_eq!(uri, "spotify:track:0000000000000000000002"); - } - other => panic!("unexpected event: {:?}", event_name(&other)), - } - assert!(app.native_queue.is_empty()); - } - - #[test] - fn random_play_on_an_empty_playlist_does_nothing() { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - // Seeded through App methods, not field writes: the handler write counter - // scans this whole file. The open dispatches the first page fetch. - app.open_playlist_tracks( - PlaylistId::from_id("37i9dQZF1DX4WYpdgoIcn6") - .unwrap() - .into_static(), - TrackTableContext::MyPlaylists, - ); - assert!(rx.try_recv().is_ok(), "the open fetched its first page"); - app.playlist_track_pages.upsert_page_by_offset(Paged { - items: vec![], - limit: 50, - next: None, - offset: 0, - previous: None, - total: 0, - }); - - // `random_range(0..0)` panics; the guard must skip the start instead. - handler(Key::Char('S'), &mut app); - - assert!(rx.try_recv().is_err(), "an empty playlist starts nothing"); - } - - #[test] - fn filtered_playlist_down_wraps_without_fetching_next_page() { - let (tx, rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.track_table.context = Some(TrackTableContext::MyPlaylists); - app.playlist_track_table_id = Some( - PlaylistId::from_id("37i9dQZF1DX4WYpdgoIcn6") - .unwrap() - .into_static(), - ); - app.playlist_tracks = Some(Paged { - items: vec![], - limit: 2, - next: Some("https://example.com/playlists/test/items?next".to_string()), - offset: 0, - previous: None, - total: 4, - }); - app.active_playlist_track_filter = Some("track".to_string()); - app.track_table.tracks = vec![ - TrackInfo::from(&full_track("0000000000000000000001", "Track 1")), - TrackInfo::from(&full_track("0000000000000000000002", "Track 2")), - ]; - app.view.track_table_index = 1; - - handler(Key::Down, &mut app); - - assert_eq!(app.view.track_table_index, 0); - assert!(rx.try_recv().is_err()); - } - - #[test] - fn q_clears_playlist_filter_and_restores_cached_rows() { - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - let playlist_id = PlaylistId::from_id("37i9dQZF1DX4WYpdgoIcn6") - .unwrap() - .into_static(); - let first_page = Paged { - items: vec![ - playlist_item(0, "0000000000000000000001", "Track 1"), - playlist_item(1, "0000000000000000000002", "Track 2"), - ], - limit: 2, - next: None, - offset: 0, - previous: None, - total: 2, - }; - - app.track_table.context = Some(TrackTableContext::MyPlaylists); - app.playlist_track_table_id = Some(playlist_id); - app.playlist_track_pages.upsert_page_by_offset(first_page); - app.active_playlist_track_filter = Some("track 2".to_string()); - app.track_table.tracks = vec![TrackInfo::from(&full_track( - "0000000000000000000002", - "Track 2", - ))]; - app.playlist_track_positions = Some(vec![1]); - - handler(Key::Char('q'), &mut app); - - assert!(app.active_playlist_track_filter.is_none()); - assert_eq!(app.track_table.tracks.len(), 2); - assert_eq!(app.playlist_track_positions, Some(vec![0, 1])); - } - - #[test] - fn enter_dispatches_saved_tracks_playback_for_selected_song() { - let (mut app, rx) = app_with_saved_tracks(); - let page = saved_tracks_page( - 0, - &["0000000000000000000001", "0000000000000000000002"], - false, - ); - app.view.track_table_index = 1; - app.track_table.tracks = page.items.iter().cloned().collect(); - app.library.saved_tracks.upsert_page_by_offset(page); - app.library.saved_tracks.index = 0; - - handler(Key::Enter, &mut app); - - match rx.recv().unwrap() { - IoEvent::StartPlayback(None, Some(uris), Some(offset)) => { - assert_eq!(offset, 1); - assert_eq!(uris[offset], "spotify:track:0000000000000000000002"); - } - other => panic!("unexpected event: {:?}", event_name(&other)), - } - } - - #[test] - fn empty_track_table_down_event_does_not_panic() { - let (mut app, _rx) = app_with_saved_tracks(); - app.track_table.tracks.clear(); - app.view.track_table_index = 0; - - handler(Key::Down, &mut app); - - assert_eq!(app.view.track_table_index, 0); - } - - #[test] - fn down_on_last_saved_track_loads_next_continuous_row() { - let (mut app, rx) = app_with_saved_tracks(); - let page = saved_tracks_page( - 0, - &["0000000000000000000001", "0000000000000000000002"], - true, - ); - app.view.track_table_index = 1; - app.track_table.tracks = page.items.iter().cloned().collect(); - app.library.saved_tracks.upsert_page_by_offset(page); - app.library.saved_tracks.index = 0; - - handler(Key::Down, &mut app); - - assert_eq!( - app.pending_track_table_selection(), - Some(PendingTrackSelection::Index(2)) - ); - match rx.recv().unwrap() { - IoEvent::GetCurrentSavedTracks(Some(offset)) => assert_eq!(offset, 2), - other => panic!("unexpected event: {:?}", event_name(&other)), - } - } - - #[test] - fn next_page_on_saved_tracks_dispatches_without_moving_the_cursor() { - let (mut app, rx) = app_with_saved_tracks(); - let page = saved_tracks_page( - 0, - &["0000000000000000000001", "0000000000000000000002"], - true, - ); - // Seeded through App methods and a key press, not field writes: the - // handler write counter scans this whole file, tests included. - app.library.saved_tracks.upsert_page_by_offset(page); - app.set_saved_tracks_to_table_continuous(); - handler(Key::Down, &mut app); - assert_eq!(app.view.track_table_index, 1); - assert!(rx.try_recv().is_err(), "a mid-list Down fetches nothing"); - - handler(Key::Ctrl('d'), &mut app); - - // next_page fetches but does NOT set pending selection: the cursor - // clamps into the loaded rows instead of following into the new page - // (that follow is the down-at-last-row path's behavior). - assert_eq!(app.pending_track_table_selection(), None); - match rx.recv().unwrap() { - IoEvent::GetCurrentSavedTracks(Some(offset)) => assert_eq!(offset, 2), - other => panic!("unexpected event: {:?}", event_name(&other)), - } - assert_eq!(app.view.track_table_index, 1); - } - - #[test] - fn down_on_absolute_last_saved_track_wraps_to_start() { - let (mut app, rx) = app_with_saved_tracks(); - let page = saved_tracks_page_with_total( - 0, - &["0000000000000000000001", "0000000000000000000002"], - false, - 2, - ); - app.view.track_table_index = 1; - app.track_table.tracks = page.items.to_vec(); - app.library.saved_tracks.upsert_page_by_offset(page); - app.library.saved_tracks.index = 0; - - handler(Key::Down, &mut app); - - assert_eq!(app.view.track_table_index, 0); - assert!(rx.try_recv().is_err()); - } - - #[test] - fn up_on_first_saved_tracks_row_wraps_to_last_loaded_track() { - let (mut app, _rx) = app_with_saved_tracks(); - let page = saved_tracks_page( - 0, - &["0000000000000000000001", "0000000000000000000002"], - true, - ); - app.view.track_table_index = 0; - app.track_table.tracks = page.items.to_vec(); - app.library.saved_tracks.upsert_page_by_offset(page); - app.library.saved_tracks.index = 0; - - handler(Key::Up, &mut app); - - assert_eq!(app.view.track_table_index, 1); - } - - #[test] - fn up_on_first_playlist_row_wraps_to_last_loaded_track() { - let (tx, _rx) = channel(); - let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); - app.track_table.context = Some(TrackTableContext::MyPlaylists); - app.track_table.tracks = vec![ - TrackInfo::from(&full_track("0000000000000000000001", "Track 1")), - TrackInfo::from(&full_track("0000000000000000000002", "Track 2")), - ]; - app.view.track_table_index = 0; - app.playlist_offset = 0; - - handler(Key::Up, &mut app); - - assert_eq!(app.view.track_table_index, 1); - } - - #[test] - fn saved_tracks_playback_request_ignores_duplicate_page_offsets() { - let (mut app, _rx) = app_with_saved_tracks(); - let page = saved_tracks_page( - 0, - &["0000000000000000000001", "0000000000000000000002"], - false, - ); - app.library.saved_tracks.add_pages(page.clone()); - app.library.saved_tracks.add_pages(page); - app.library.saved_tracks.index = 0; - app.view.track_table_index = 1; - app.track_table.tracks = app.library.saved_tracks.pages[0].items.to_vec(); - - let (uris, offset) = saved_tracks_playback_request(&app).unwrap(); - - assert_eq!(offset, 1); - assert_eq!(uris.len(), 2); - assert_eq!(uris[offset], "spotify:track:0000000000000000000002"); - } - - fn event_name(event: &IoEvent) -> &'static str { - match event { - IoEvent::StartPlayback(_, _, _) => "StartPlayback", - _ => "other", - } - } -} diff --git a/src/tui/keymap.rs b/src/tui/keymap.rs deleted file mode 100644 index 1370a493..00000000 --- a/src/tui/keymap.rs +++ /dev/null @@ -1,1312 +0,0 @@ -//! The terminal's key surface: the help rows, each with the [`Requirement`] -//! it needs and filtered by the active source and the session, and the -//! registry naming the key or gesture that produces each [`Action`]. - -#![deny( - clippy::wildcard_enum_match_arm, - clippy::match_wildcard_for_single_variants -)] - -use crate::core::action::{Action, CopyTarget, LibraryTarget, NavTarget}; -use crate::core::app::App; -use crate::core::input::Key; -use crate::core::requirement::{Capability, Requirement}; -use crate::core::sort::SortContext; -use crate::core::source::Source; -use crate::core::user_config::KeyBindings; - -/// How a help row names its key. -pub enum HelpKey { - /// A rebindable key from `config.yml`; an unmet row stays listed, marked. - Binding(fn(&KeyBindings) -> Key), - /// A key the screen hard-codes; an unmet row is hidden. - Literal(&'static str), - /// A rebindable key expression built from the running app; treated like - /// `Binding`. - Custom(fn(&App) -> String), -} - -pub struct HelpEntry { - pub description: &'static str, - pub key: HelpKey, - pub context: &'static str, - pub needs: Requirement, -} - -fn row(description: &'static str, key: HelpKey, context: &'static str) -> HelpEntry { - HelpEntry { - description, - key, - context, - needs: Requirement::None, - } -} - -impl HelpEntry { - fn needs(mut self, needs: Requirement) -> Self { - self.needs = needs; - self - } -} - -const SPOTIFY: Requirement = Requirement::Source(Source::Spotify); -const SESSION: Requirement = Requirement::SpotifySession; -const RADIO: Requirement = Requirement::Source(Source::Radio); -const LIKE: Requirement = Requirement::Capability(Capability::Like); -const PLAYLIST_WRITE: Requirement = Requirement::Capability(Capability::PlaylistWrite); -const SEARCH: Requirement = Requirement::Capability(Capability::Search); - -/// Every help row in display order, before the availability filter. -pub fn help_entries() -> Vec { - use HelpKey::{Binding, Custom, Literal}; - vec![ - row( - "Scroll down to next result page", - Binding(|k| k.next_page), - "Pagination", - ), - row( - "Scroll up to previous result page", - Binding(|k| k.previous_page), - "Pagination", - ), - row( - "Jump to start of playlist", - Binding(|k| k.jump_to_start), - "Pagination", - ), - row( - "Jump to end of playlist", - Binding(|k| k.jump_to_end), - "Pagination", - ), - row( - "Jump to currently playing album", - Binding(|k| k.jump_to_album), - "General", - ) - .needs(SESSION), - row( - "Jump to currently playing artist's album list", - Binding(|k| k.jump_to_artist_album), - "General", - ) - .needs(SESSION), - row( - "Jump to current play context", - Binding(|k| k.jump_to_context), - "General", - ) - .needs(SESSION), - row( - "Increase volume by 10%", - Binding(|k| k.increase_volume), - "General", - ), - row( - "Decrease volume by 10%", - Binding(|k| k.decrease_volume), - "General", - ), - row("Skip to next track", Binding(|k| k.next_track), "General"), - row( - "Skip to previous track", - Binding(|k| k.previous_track), - "General", - ), - row( - "Force skip to previous track", - Binding(|k| k.force_previous_track), - "General", - ), - row( - "Seek backwards 5 seconds", - Binding(|k| k.seek_backwards), - "General", - ), - row( - "Seek forwards 5 seconds", - Binding(|k| k.seek_forwards), - "General", - ), - row("Toggle shuffle", Binding(|k| k.shuffle), "General"), - row( - "Copy url to currently playing song/episode", - Binding(|k| k.copy_song_url), - "General", - ) - .needs(SESSION), - row( - "Copy url to currently playing album/show", - Binding(|k| k.copy_album_url), - "General", - ) - .needs(SESSION), - row("Cycle repeat mode", Binding(|k| k.repeat), "General"), - row( - "Move selection left", - Custom(|app| { - format!( - "{} | | ", - app.user_config.keys.move_left - ) - }), - "General", - ), - row( - "Move selection down", - Custom(|app| { - format!( - "{} | | ", - app.user_config.keys.move_down - ) - }), - "General", - ), - row( - "Move selection up", - Custom(|app| { - format!( - "{} | | ", - app.user_config.keys.move_up - ) - }), - "General", - ), - row( - "Move selection right", - Custom(|app| { - format!( - "{} | | ", - app.user_config.keys.move_right - ) - }), - "General (Ctrl+f searches inside playlist track tables)", - ), - row("Move selection to top of list", Literal("H"), "General"), - row("Move selection to middle of list", Literal("M"), "General"), - row("Move selection to bottom of list", Literal("L"), "General"), - row("Enter input for search", Binding(|k| k.search), "General").needs(SEARCH), - row("Filter help rows", Binding(|k| k.search), "Help menu"), - row("Filter settings rows", Binding(|k| k.search), "Settings"), - row( - "Pause/Resume playback", - Binding(|k| k.toggle_playback), - "General", - ), - row("Enter active mode", Literal(""), "General"), - row( - "Go to audio analysis screen", - Binding(|k| k.audio_analysis), - "General", - ), - row( - "Cycle visualizer style (in audio analysis)", - Literal("V"), - "General", - ), - row("Go to lyrics view", Binding(|k| k.lyrics_view), "General"), - row( - "Scroll lyrics (pauses auto-follow)", - Custom(|app| { - format!( - "{}/{} | / | /", - app.user_config.keys.move_up, app.user_config.keys.move_down - ) - }), - "Lyrics view", - ), - row( - "Resume following the current lyric line", - Literal("f or "), - "Lyrics view", - ), - row( - "Nudge lyric timing earlier/later", - Custom(|app| { - format!( - "{}/{} | / | /", - app.user_config.keys.move_right, app.user_config.keys.move_left - ) - }), - "Lyrics view", - ), - row( - "Toggle miniplayer view", - Binding(|k| k.miniplayer_view), - "General", - ), - #[cfg(feature = "cover-art")] - row( - "Go to cover art view", - Binding(|k| k.cover_art_view), - "General", - ), - row( - "Go back or exit when nowhere left to back to", - Binding(|k| k.back), - "General", - ), - row( - "Switch music source / select playback device", - Binding(|k| k.manage_devices), - "General", - ), - row( - "Open settings", - Custom(|app| app.effective_open_settings_key().to_string()), - "General", - ), - row( - "Save settings", - Custom(|app| app.effective_save_settings_key().to_string()), - "Settings", - ), - row("Enter hover mode", Literal(""), "Selected block"), - row( - "Save track in list or table", - Literal("s"), - "Selected block", - ) - .needs(LIKE), - row( - "Add selected track to playlist", - Literal("w"), - "Track table / search songs / artist top tracks / recently played", - ) - .needs(PLAYLIST_WRITE), - row( - "Add currently playing track to playlist", - Literal("w"), - "Playbar", - ) - .needs(SESSION), - row( - "Quick-add currently playing track to playlist", - Literal("W"), - "Global", - ) - .needs(SESSION), - row("Decrease sidebar width", Literal("{"), "Layout"), - row("Increase sidebar width", Literal("}"), "Layout"), - row("Decrease playbar or library height", Literal("("), "Layout"), - row("Increase playbar or library height", Literal(")"), "Layout"), - row("Reset layout to defaults", Literal("|"), "Layout"), - row( - "Remove selected track from current playlist", - Literal("x"), - "Track table (playlist views)", - ) - .needs(PLAYLIST_WRITE), - row( - "Search tracks in current playlist", - Literal(""), - "Track table (playlist views)", - ) - .needs(SPOTIFY), - row( - "Clear playlist track search filter", - Binding(|k| k.back), - "Track table (filtered playlist views)", - ) - .needs(SPOTIFY), - row( - "Start playback or enter album/artist/playlist", - Binding(|k| k.submit), - "Selected block", - ), - row( - "Play recommendations for song/artist", - Literal("r"), - "Selected block", - ) - .needs(SPOTIFY), - row( - "Play all tracks for artist", - Literal("e"), - "Library -> Artists", - ) - .needs(SPOTIFY), - row("Search with input text", Literal(""), "Search input").needs(SEARCH), - row( - "Move cursor one space left", - Literal(""), - "Search input", - ), - row( - "Move cursor one space right", - Literal(""), - "Search input", - ), - row("Delete entire input", Literal(""), "Search input"), - row( - "Delete text from cursor to start of input", - Literal(""), - "Search input", - ), - row( - "Delete text from cursor to end of input", - Literal(""), - "Search input", - ), - row("Delete previous word", Literal(""), "Search input"), - row( - "Jump to start of input", - Literal(""), - "Search input", - ), - row("Jump to end of input", Literal(""), "Search input"), - row( - "Escape from the input back to hovered block", - Literal(""), - "Search input", - ), - row("Delete saved album", Literal("D"), "Library -> Albums").needs(SPOTIFY), - row("Delete saved playlist", Literal("D"), "Playlist").needs(PLAYLIST_WRITE), - row("Remove favorite radio station", Literal("D"), "Radio").needs(RADIO), - row("Follow an artist/playlist", Literal("w"), "Search result").needs(SPOTIFY), - row( - "Save (like) album to library", - Literal("w"), - "Search result", - ) - .needs(SPOTIFY), - row( - "Play random song in playlist", - Literal("S"), - "Selected Playlist", - ), - row( - "Toggle sort order of podcast episodes", - Literal("S"), - "Selected Show", - ) - .needs(SPOTIFY), - row( - "Add track to queue", - Binding(|k| k.add_item_to_queue), - "Hovered over track", - ), - row("Show queue", Binding(|k| k.show_queue), "General"), - row( - "Remove selected track from queue", - Binding(|k| k.remove_from_queue), - "Queue", - ), - row( - "Move selected queue item down / up", - Literal("J / K"), - "Queue", - ), - row( - "Play selected queue item (skip ahead to it)", - Literal(""), - "Queue", - ), - row( - "Toggle saved state for currently playing track/episode", - Binding(|k| k.like_track), - "General", - ) - .needs(LIKE), - row( - "Favorite highlighted/playing radio station", - Binding(|k| k.like_track), - "Radio", - ) - .needs(RADIO), - row( - "Generate listening recap card (selected period on Stats, 30 days elsewhere)", - Binding(|k| k.generate_recap), - "General", - ), - row("Open Stats screen", Literal("Library sidebar"), "Stats"), - row("Cycle stats period", Literal("[ / ]"), "Stats"), - row("Play selected top track", Literal(""), "Stats").needs(SESSION), - row("Open sort menu", Literal(","), "Track/Album/Artist list"), - row( - "Open Listening Party menu", - Binding(|k| k.listening_party), - "General", - ) - .needs(SESSION), - #[cfg(feature = "ai-dj")] - row("Open the AI DJ screen", Binding(|k| k.dj_open), "AI DJ"), - #[cfg(feature = "ai-dj")] - row( - "Toggle DJ auto-queue (keep the queue topped up)", - Binding(|k| k.dj_toggle_auto_queue), - "AI DJ", - ), - #[cfg(feature = "ai-dj")] - row( - "Shift the vibe (drop the DJ's queued tracks and re-ask)", - Binding(|k| k.dj_vibe_shift), - "AI DJ", - ), - #[cfg(feature = "ai-dj")] - row( - "Toggle \"only tracks I do not already have\"", - Binding(|k| k.dj_toggle_fresh_only), - "AI DJ", - ), - #[cfg(feature = "ai-dj")] - row( - "Choose which AI and model the DJ uses", - Binding(|k| k.dj_pick_model), - "AI DJ", - ), - ] -} - -/// The help rows the app can serve now, as `[description, key, context]`; -/// an unmet rebindable row stays, marked with the reason. -pub fn help_rows(app: &App) -> Vec> { - let keys = &app.user_config.keys; - help_entries() - .into_iter() - .filter_map(|entry| { - let availability = app.availability(entry.needs); - let description = if availability.is_available() { - entry.description.to_string() - } else if matches!(entry.key, HelpKey::Literal(_)) { - return None; - } else { - format!("{} ({})", entry.description, availability.hint()?) - }; - let key = match entry.key { - HelpKey::Binding(read) => read(keys).to_string(), - HelpKey::Literal(text) => text.to_string(), - HelpKey::Custom(build) => build(app), - }; - Some(vec![description, key, entry.context.to_string()]) - }) - .collect() -} - -/// Where the terminal offers a shared [`Action`]. -// Read by the meta-test only, until the GUI affordance table lands. -#[allow(dead_code)] -#[derive(Debug, Clone, Copy)] -pub enum TuiSurface { - /// A rebindable key from `config.yml`, read like [`HelpKey::Binding`]. - Binding(fn(&KeyBindings) -> Key), - /// A key one screen hard-codes; `context` names the screen like the help table. - Literal { - key: &'static str, - context: &'static str, - }, - /// A pointer gesture with no key at all. - Mouse(&'static str), -} - -/// Whether a frontend offers an [`Action`] at all. -#[allow(dead_code)] -#[derive(Debug, Clone, Copy)] -pub enum Exposure { - Bound(S), - /// No producer in this build, and why. - Unbound(&'static str), -} - -const ENTER: &str = ""; -const ESC: &str = ""; -const PLUGIN_ONLY: &str = "plugin-only: the scripting engine is its only producer"; -#[cfg(not(feature = "ai-dj"))] -const NO_AI_DJ: Exposure = - Exposure::Unbound("the AI DJ keys exist only in ai-dj builds"); - -fn binding(read: fn(&KeyBindings) -> Key) -> Exposure { - Exposure::Bound(TuiSurface::Binding(read)) -} - -fn literal(key: &'static str, context: &'static str) -> Exposure { - Exposure::Bound(TuiSurface::Literal { key, context }) -} - -/// Each [`Action`]'s primary terminal surface; `Bound` means reachable in this build. -// No production caller yet: the meta-test below and the GUI affordance table read it. -#[allow(dead_code)] -pub fn default_binding(action: &Action) -> Exposure { - use Exposure::Unbound; - match action { - Action::Play => Unbound("the terminal binds the play/pause toggle, not the play intent"), - Action::Pause => Unbound("the terminal binds the play/pause toggle, not the pause intent"), - Action::TogglePlayback => binding(|k| k.toggle_playback), - Action::NextTrack => binding(|k| k.next_track), - Action::PreviousTrack => binding(|k| k.previous_track), - Action::ForcePreviousTrack => binding(|k| k.force_previous_track), - Action::SeekTo(_) => Exposure::Bound(TuiSurface::Mouse( - "a click or drag on the playbar progress line", - )), - Action::SeekForward => binding(|k| k.seek_forwards), - Action::SeekBackward => binding(|k| k.seek_backwards), - Action::SetVolume(_) => Unbound("the terminal steps the volume, it never sets it"), - Action::VolumeUp => binding(|k| k.increase_volume), - Action::VolumeDown => binding(|k| k.decrease_volume), - Action::SetShuffle(_) => Unbound("the terminal toggles shuffle, it never sets it"), - Action::ToggleShuffle => binding(|k| k.shuffle), - Action::CycleRepeat => binding(|k| k.repeat), - Action::SetRepeat(_) => Unbound("the terminal cycles repeat, it never sets it"), - Action::PlayUris { .. } => literal( - ENTER, - "Track table / search songs / artist top tracks / recently played", - ), - Action::PlayContext { .. } => literal(ENTER, "Album tracks"), - Action::PlayTrackInContext { .. } => literal(ENTER, "Track table (playlist views)"), - Action::TransferPlayback { .. } => literal(ENTER, "Source and device picker"), - Action::AddToQueue(_) => { - Unbound("the queue key feeds the native queue through QueueTrack, not the Web API queue") - } - Action::QueueTrack(_) => binding(|k| k.add_item_to_queue), - Action::PlayQueueItem { .. } => literal(ENTER, "Queue"), - Action::RemoveFromQueue { .. } => binding(|k| k.remove_from_queue), - Action::MoveQueueItem { .. } => literal("J / K", "Queue"), - Action::Search(_) => Unbound("the search box is source-scoped and produces SearchActiveSource"), - Action::SearchActiveSource(_) => literal(ENTER, "Search input"), - Action::SearchPlaylistTracks { .. } => { - literal(", then ", "Track table (playlist views)") - } - Action::CreatePlaylist { .. } => literal(ENTER, "Create playlist form"), - Action::CreateYouTubePlaylist(_) => literal(ENTER, "Create playlist form"), - Action::SearchTracksForPlaylist(_) => literal(ENTER, "Create playlist form"), - Action::AddTrackToPlaylist { .. } => literal(ENTER, "Add to playlist picker"), - Action::RemoveTrackFromPlaylist { .. } => literal(ENTER, "Remove track confirmation"), - Action::FollowPlaylist(_) => literal("w", "Search result"), - Action::UnfollowPlaylist(_) => literal("D", "Playlist"), - Action::DeletePlaylist(_) => literal("D", "Playlist"), - Action::ToggleSaveTrack(_) => literal("s", "Selected block"), - Action::ToggleSaveCurrentItem => binding(|k| k.like_track), - Action::SaveAlbum(_) => literal("w", "Search result"), - Action::UnsaveAlbum(_) => literal("D", "Library -> Albums"), - Action::SaveShow(_) => literal("w", "Search result"), - Action::UnsaveShow(_) => literal("D", "Library -> Podcasts"), - Action::FollowArtist(_) => literal("w", "Search result"), - Action::UnfollowArtist(_) => literal("D", "Library -> Artists"), - Action::AddFriendByCode(_) => literal(ENTER, "Add friend dialog"), - Action::AddFriendById(_) => literal(ENTER, "Add friend dialog"), - Action::UnfollowFriend(_) => literal("u", "Friends"), - Action::SearchFriendUsers(_) => literal("typing", "Add friend dialog"), - Action::FavoriteRadioStation(_) => binding(|k| k.like_track), - Action::RemoveRadioStation(_) => literal("D", "Radio"), - Action::Notify(..) => Unbound("a status message is a consequence, never a gesture"), - Action::NotifyError(..) => Unbound("a status message is a consequence, never a gesture"), - Action::Navigate(NavTarget::Home) => { - Unbound("the terminal reaches Home by popping the stack, never by a key") - } - Action::Navigate(NavTarget::Queue) => binding(|k| k.show_queue), - Action::Navigate(NavTarget::Settings) => binding(|k| k.open_settings), - Action::Navigate(NavTarget::Devices) => binding(|k| k.manage_devices), - Action::Navigate(NavTarget::Help) => binding(|k| k.help), - Action::Navigate(NavTarget::Lyrics) => binding(|k| k.lyrics_view), - Action::Navigate(NavTarget::RecentlyPlayed) => { - Unbound("the sidebar opens Recently Played through OpenLibrary") - } - Action::Navigate(NavTarget::Party) => binding(|k| k.listening_party), - Action::Navigate(NavTarget::Analysis) => binding(|k| k.audio_analysis), - Action::Navigate(NavTarget::MiniPlayer) => binding(|k| k.miniplayer_view), - Action::Back => Unbound( - "the back key runs the runner's richer path: filter clear, settings prompt, announcement dismissal, search double-pop, exit prompt", - ), - Action::LoadMore(_) => binding(|k| k.next_page), - Action::Sort { - context: SortContext::PlaylistTracks | SortContext::SavedAlbums | SortContext::SavedArtists, - .. - } => literal(ENTER, "Sort menu"), - Action::Sort { - context: SortContext::RecentlyPlayed, - .. - } => Unbound("no sort menu opens on Recently Played"), - Action::ToggleSortOrder( - SortContext::PlaylistTracks | SortContext::SavedAlbums | SortContext::SavedArtists, - ) => literal("uppercase field shortcut", "Sort menu"), - Action::ToggleSortOrder(SortContext::RecentlyPlayed) => { - Unbound("no sort menu opens on Recently Played") - } - Action::Open(_) => literal(ENTER, "Selected block"), - Action::OpenShowEpisodes(_) => literal(ENTER, "Library -> Podcasts"), - Action::OpenLibrary( - LibraryTarget::Discover - | LibraryTarget::RecentlyPlayed - | LibraryTarget::Friends - | LibraryTarget::Stats - | LibraryTarget::LikedSongs - | LibraryTarget::Albums - | LibraryTarget::Artists - | LibraryTarget::Podcasts, - ) => literal(ENTER, "Library sidebar"), - Action::OpenLibrary(LibraryTarget::LocalFiles) => { - #[cfg(feature = "local-files")] - { - literal(ENTER, "Library sidebar") - } - #[cfg(not(feature = "local-files"))] - { - Unbound("the Local Files row exists only in local-files builds") - } - } - Action::OpenLibrary(LibraryTarget::AiDj) => { - #[cfg(feature = "ai-dj")] - { - binding(|k| k.dj_open) - } - #[cfg(not(feature = "ai-dj"))] - { - NO_AI_DJ - } - } - Action::OpenDiscover(_) => literal(ENTER, "Discover"), - Action::SelectSource(_) => literal(ENTER, "Source and device picker"), - Action::OpenAddTrackDialog => literal("w", "Track table"), - Action::OpenAddTrackDialogFor { .. } => literal( - "w", - "Track table / search songs / artist top tracks / recently played", - ), - Action::OpenAddPlayingTrackDialog => literal("W", "Global"), - Action::OpenRemoveTrackDialog => literal("x", "Track table (playlist views)"), - Action::JumpToAlbum => binding(|k| k.jump_to_album), - Action::JumpToArtist => binding(|k| k.jump_to_artist_album), - Action::JumpToContext => binding(|k| k.jump_to_context), - Action::CopyUrl(CopyTarget::CurrentSong) => binding(|k| k.copy_song_url), - Action::CopyUrl(CopyTarget::CurrentAlbum) => binding(|k| k.copy_album_url), - Action::GenerateRecap => binding(|k| k.generate_recap), - Action::CycleStatsPeriod { .. } => literal("[ / ]", "Stats"), - Action::RecommendFromTrack(_) => literal("r", "Selected block"), - Action::RecommendFromArtist { .. } => literal("r", "Selected block"), - Action::RecommendFromTrackId { .. } => literal("r", "Selected block"), - Action::StartParty => literal("h", "Listening Party menu"), - Action::JoinParty { .. } => literal(ENTER, "Listening Party menu"), - Action::LeaveParty => literal("l", "Listening Party menu"), - Action::TogglePartyControlMode => literal("c", "Listening Party menu"), - Action::SetPlaybarSegment { .. } => Unbound(PLUGIN_ONLY), - Action::ShowPopup(_) => Unbound(PLUGIN_ONLY), - Action::ClosePopup => literal("", "Plugin popup"), - Action::SetTheme(_) => Unbound(PLUGIN_ONLY), - Action::SaveSettings => binding(|k| k.save_settings), - Action::CycleVisualizerStyle => literal("V", "Audio analysis"), - Action::SetScreenContent { .. } => Unbound(PLUGIN_ONLY), - Action::ShowScreen(_) => Unbound(PLUGIN_ONLY), - Action::CloseScreen(_) => Unbound(PLUGIN_ONLY), - Action::QueueTracks(_) => Unbound("the DJ and MCP queue tools have no gesture"), - Action::SetDjVibe(_) => Unbound("the DJ and MCP vibe tools have no gesture"), - Action::AskDj(_) => { - #[cfg(feature = "ai-dj")] - { - literal(ENTER, "AI DJ") - } - #[cfg(not(feature = "ai-dj"))] - { - NO_AI_DJ - } - } - Action::DjVibeShift => { - #[cfg(feature = "ai-dj")] - { - binding(|k| k.dj_vibe_shift) - } - #[cfg(not(feature = "ai-dj"))] - { - NO_AI_DJ - } - } - Action::ToggleDjAutoQueue => { - #[cfg(feature = "ai-dj")] - { - binding(|k| k.dj_toggle_auto_queue) - } - #[cfg(not(feature = "ai-dj"))] - { - NO_AI_DJ - } - } - Action::ToggleDjFreshOnly => { - #[cfg(feature = "ai-dj")] - { - binding(|k| k.dj_toggle_fresh_only) - } - #[cfg(not(feature = "ai-dj"))] - { - NO_AI_DJ - } - } - Action::OpenDjSetup => { - #[cfg(feature = "ai-dj")] - { - binding(|k| k.dj_pick_model) - } - #[cfg(not(feature = "ai-dj"))] - { - NO_AI_DJ - } - } - Action::DismissDjSetup => { - #[cfg(feature = "ai-dj")] - { - literal(ESC, "AI DJ") - } - #[cfg(not(feature = "ai-dj"))] - { - NO_AI_DJ - } - } - Action::CommitDjSetup => { - #[cfg(feature = "ai-dj")] - { - literal(ENTER, "AI DJ") - } - #[cfg(not(feature = "ai-dj"))] - { - NO_AI_DJ - } - } - Action::AnswerCommunityPinPrompt { keep: false } => literal("h", "Community pin prompt"), - Action::AnswerCommunityPinPrompt { keep: true } => literal(ENTER, "Community pin prompt"), - Action::OpenRecap => literal(ENTER, "Recap prompt"), - Action::DismissRecapPrompt => literal(ESC, "Recap prompt"), - Action::DisableRecapPrompt => literal("d", "Recap prompt"), - } -} - -#[cfg(test)] -mod tests { - use std::collections::BTreeSet; - use std::path::{Path, PathBuf}; - - use super::*; - use crate::core::action::{DiscoverTarget, ListTarget, OpenTarget, RepeatSetting}; - use crate::core::plugin_api::{PluginPopup, PluginScreenContent, ShowInfo, TrackInfo}; - use crate::core::sort::SortField; - use crate::core::test_helpers::full_track; - use crate::core::theme::{Color, ThemeField}; - - fn descriptions(app: &App) -> Vec { - help_rows(app) - .into_iter() - .map(|mut row| row.swap_remove(0)) - .collect() - } - - #[test] - fn a_connected_spotify_session_lists_every_row_it_can_serve_unmarked() { - let app = App::default_connected(); - let rows = descriptions(&app); - for entry in help_entries() { - if app.availability(entry.needs).is_available() { - assert!( - rows.contains(&entry.description.to_string()), - "{} is missing", - entry.description - ); - } - } - assert!(!rows.iter().any(|row| row.ends_with("(needs Spotify)"))); - // The radio favorite shares a configured key, so it stays, marked. - assert!(rows - .contains(&"Favorite highlighted/playing radio station (Internet Radio only)".to_string())); - assert!(!rows - .iter() - .any(|row| row == "Remove favorite radio station")); - } - - #[test] - fn without_a_session_configured_keys_stay_marked_and_fixed_keys_go() { - let app = App::default(); - let rows = descriptions(&app); - assert!(rows.contains(&"Open Listening Party menu (needs Spotify)".to_string())); - assert!(rows.contains( - &"Toggle saved state for currently playing track/episode (needs Spotify)".to_string() - )); - assert!(!rows.iter().any(|row| row == "Delete saved album")); - assert!(!rows - .iter() - .any(|row| row == "Play recommendations for song/artist")); - assert!(rows.contains(&"Open Stats screen".to_string())); - } - - #[test] - fn a_free_source_names_the_source_in_the_hint() { - let mut app = App::default_connected(); - app.active_source = Source::Local; - let rows = descriptions(&app); - assert!(rows.contains(&"Enter input for search (not for Local Files)".to_string())); - // The copy keys read the Spotify playback, whatever the browse scope. - assert!(rows.contains(&"Copy url to currently playing song/episode".to_string())); - assert!(!rows.iter().any(|row| row == "Search with input text")); - assert!(rows.contains(&"Play random song in playlist".to_string())); - } - - #[test] - fn radio_rows_appear_only_under_the_radio_scope() { - let mut app = App::default_connected(); - assert!(!descriptions(&app) - .iter() - .any(|row| row == "Remove favorite radio station")); - app.active_source = Source::Radio; - let rows = descriptions(&app); - assert!(rows.contains(&"Remove favorite radio station".to_string())); - assert!(rows.contains(&"Favorite highlighted/playing radio station".to_string())); - assert!(rows.contains( - &"Toggle saved state for currently playing track/episode (not for Internet Radio)" - .to_string() - )); - } - - #[test] - fn the_key_column_follows_the_configured_binding() { - let mut app = App::default_connected(); - app.user_config.keys.next_track = Key::Char('N'); - let row = help_rows(&app) - .into_iter() - .find(|row| row[0] == "Skip to next track") - .expect("the next-track row"); - assert_eq!(row[1], "N"); - assert_eq!(row[2], "General"); - } - - /// The variants no terminal gesture produces, in every build. - const UNBOUND: &[&str] = &[ - "AddToQueue", - "Back", - "CloseScreen", - "Notify", - "NotifyError", - "Pause", - "Play", - "QueueTracks", - "Search", - "SetDjVibe", - "SetPlaybarSegment", - "SetRepeat", - "SetScreenContent", - "SetShuffle", - "SetTheme", - "SetVolume", - "ShowPopup", - "ShowScreen", - ]; - - /// Bound only with `ai-dj`: their producers are on disk but compiled out here. - #[cfg(not(feature = "ai-dj"))] - const FEATURE_UNBOUND: &[&str] = &[ - "AskDj", - "CommitDjSetup", - "DismissDjSetup", - "DjVibeShift", - "OpenDjSetup", - "ToggleDjAutoQueue", - "ToggleDjFreshOnly", - ]; - #[cfg(feature = "ai-dj")] - const FEATURE_UNBOUND: &[&str] = &[]; - - fn repo_root() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - } - - fn rs_files(dir: &Path, out: &mut Vec) { - let Ok(entries) = std::fs::read_dir(dir) else { - return; - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - rs_files(&path, out); - } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") { - out.push(path); - } - } - } - - /// `source` without its test items and comment lines: a gated block item - /// runs to its column-0 `}` (rustfmt's shape), a braceless one to its `;`. - fn production_half(source: &str) -> String { - let mut out = String::new(); - let mut gated = false; - let mut in_block = false; - let mut in_item = false; - for line in source.lines() { - if in_block { - in_block = line != "}"; - continue; - } - if in_item { - in_item = !line.ends_with(';'); - continue; - } - if gated { - gated = line.starts_with("#["); - if !gated { - in_block = line.ends_with('{'); - in_item = !in_block && !line.ends_with(';') && !line.ends_with('}'); - } - continue; - } - if line.starts_with("#[cfg(test)]") || line.starts_with("#[cfg(all(test") { - gated = true; - continue; - } - if line.trim_start().starts_with("//") { - continue; - } - out.push_str(line); - out.push('\n'); - } - out - } - - fn leading_ident(text: &str) -> &str { - let end = text - .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) - .unwrap_or(text.len()); - &text[..end] - } - - /// The variant names of `pub enum Action`, read from its source: the lines - /// at exactly two spaces whose identifier is followed by `,`, `(` or ` {`. - fn action_variant_names() -> BTreeSet { - let source = std::fs::read_to_string(repo_root().join("src/core/action/mod.rs")).unwrap(); - source - .lines() - .skip_while(|line| *line != "pub enum Action {") - .skip(1) - .take_while(|line| *line != "}") - .filter_map(|line| { - let rest = line.strip_prefix(" ")?; - let name = leading_ident(rest); - let tail = &rest[name.len()..]; - let is_variant = !name.is_empty() - && (tail.starts_with(',') || tail.starts_with('(') || tail.starts_with(" {")); - is_variant.then(|| name.to_string()) - }) - .collect() - } - - /// `Action::` in `source`, bounded on both sides so `PlaybackAction::` - /// is skipped and `Action::Play` is not found inside `Action::PlayUris`. - fn producers_in(source: &str) -> BTreeSet { - let production = production_half(source); - let mut found = BTreeSet::new(); - for (start, needle) in production.match_indices("Action::") { - let before = start.checked_sub(1).map(|i| production.as_bytes()[i]); - if before.is_some_and(|b| b.is_ascii_alphanumeric() || b == b'_') { - continue; - } - let name = leading_ident(&production[start + needle.len()..]); - if !name.is_empty() { - found.insert(name.to_string()); - } - } - found - } - - /// Every variant some production file under `src/tui/` builds. The - /// registry itself is skipped: it names every variant and produces none. - fn tui_producers() -> BTreeSet { - let mut files = Vec::new(); - rs_files(&repo_root().join("src/tui"), &mut files); - let registry = repo_root().join("src/tui/keymap.rs"); - files - .iter() - .filter(|path| **path != registry) - .flat_map(|path| producers_in(&std::fs::read_to_string(path).unwrap())) - .collect() - } - - /// Derived `Debug` starts with the variant name: `Play`, `SeekTo(0)`, - /// `PlayUris { .. }`. - fn variant_name(action: &Action) -> String { - format!("{action:?}") - .split(['(', ' ']) - .next() - .unwrap() - .to_string() - } - - /// One value per `Action` variant, with a reachable payload where the - /// registry answers per payload. - fn sample_actions() -> Vec { - let text = String::new; - let track = || TrackInfo::from(&full_track("4uLU6hMCjMI75M1A2tKUQC", "T")); - vec![ - Action::Play, - Action::Pause, - Action::TogglePlayback, - Action::NextTrack, - Action::PreviousTrack, - Action::ForcePreviousTrack, - Action::SeekTo(0), - Action::SeekForward, - Action::SeekBackward, - Action::SetVolume(0), - Action::VolumeUp, - Action::VolumeDown, - Action::SetShuffle(false), - Action::ToggleShuffle, - Action::CycleRepeat, - Action::SetRepeat(RepeatSetting::Off), - Action::PlayUris { - uris: vec![], - offset: None, - }, - Action::PlayContext { - uri: text(), - offset: None, - }, - Action::PlayTrackInContext { - context: text(), - track: text(), - }, - Action::TransferPlayback { - device_id: text(), - persist: false, - }, - Action::AddToQueue(text()), - Action::QueueTrack(track()), - Action::PlayQueueItem { - uri: text(), - position: 0, - }, - Action::RemoveFromQueue { - uri: text(), - position: 0, - }, - Action::MoveQueueItem { - uri: text(), - from: 0, - to: 0, - }, - Action::Search(text()), - Action::SearchActiveSource(text()), - Action::SearchPlaylistTracks { - playlist_id: text(), - query: text(), - }, - Action::CreatePlaylist { - name: text(), - track_uris: vec![], - }, - Action::CreateYouTubePlaylist(text()), - Action::SearchTracksForPlaylist(text()), - Action::AddTrackToPlaylist { - playlist: text(), - track: text(), - }, - Action::RemoveTrackFromPlaylist { - playlist: text(), - track: text(), - position: 0, - }, - Action::FollowPlaylist(text()), - Action::UnfollowPlaylist(text()), - Action::DeletePlaylist(text()), - Action::ToggleSaveTrack(text()), - Action::ToggleSaveCurrentItem, - Action::SaveAlbum(text()), - Action::UnsaveAlbum(text()), - Action::SaveShow(text()), - Action::UnsaveShow(text()), - Action::FollowArtist(text()), - Action::UnfollowArtist(text()), - Action::AddFriendByCode(text()), - Action::AddFriendById(text()), - Action::UnfollowFriend(text()), - Action::SearchFriendUsers(text()), - Action::FavoriteRadioStation(track()), - Action::RemoveRadioStation(text()), - Action::Notify(text(), 0), - Action::NotifyError(text(), 0), - Action::Navigate(NavTarget::Queue), - Action::Back, - Action::LoadMore(ListTarget::PlaylistTracks), - Action::Sort { - context: SortContext::PlaylistTracks, - field: SortField::default(), - }, - Action::ToggleSortOrder(SortContext::PlaylistTracks), - Action::Open(OpenTarget::SavedAlbum(text())), - Action::OpenShowEpisodes(ShowInfo::default()), - Action::OpenLibrary(LibraryTarget::Discover), - Action::OpenDiscover(DiscoverTarget::ArtistsMix), - Action::SelectSource(Source::Spotify), - Action::OpenAddTrackDialog, - Action::OpenAddTrackDialogFor { - track_id: None, - track_name: text(), - }, - Action::OpenAddPlayingTrackDialog, - Action::OpenRemoveTrackDialog, - Action::JumpToAlbum, - Action::JumpToArtist, - Action::JumpToContext, - Action::CopyUrl(CopyTarget::CurrentSong), - Action::GenerateRecap, - Action::CycleStatsPeriod { forward: true }, - Action::RecommendFromTrack(track()), - Action::RecommendFromArtist { - id: text(), - name: text(), - }, - Action::RecommendFromTrackId { - id: text(), - name: text(), - }, - Action::StartParty, - Action::JoinParty { - code: text(), - name: text(), - }, - Action::LeaveParty, - Action::TogglePartyControlMode, - Action::SetPlaybarSegment { - plugin: text(), - text: None, - }, - Action::ShowPopup(PluginPopup { - title: text(), - lines: vec![], - }), - Action::ClosePopup, - Action::SetTheme(vec![(ThemeField::Active, Color::Reset)]), - Action::SaveSettings, - Action::CycleVisualizerStyle, - Action::AnswerCommunityPinPrompt { keep: false }, - Action::OpenRecap, - Action::DismissRecapPrompt, - Action::DisableRecapPrompt, - Action::SetScreenContent { - name: text(), - content: PluginScreenContent::default(), - }, - Action::ShowScreen(text()), - Action::CloseScreen(text()), - Action::QueueTracks(vec![]), - Action::SetDjVibe(None), - Action::AskDj(text()), - Action::DjVibeShift, - Action::ToggleDjAutoQueue, - Action::ToggleDjFreshOnly, - Action::OpenDjSetup, - Action::DismissDjSetup, - Action::CommitDjSetup, - ] - } - - #[test] - fn every_action_variant_has_exactly_one_sample() { - let names: Vec = sample_actions().iter().map(variant_name).collect(); - let sampled: BTreeSet = names.iter().cloned().collect(); - assert_eq!(sampled.len(), names.len(), "a variant is sampled twice"); - assert_eq!( - sampled, - action_variant_names(), - "sample_actions() and `pub enum Action` disagree" - ); - } - - #[test] - fn the_unbound_set_is_pinned_by_name() { - let measured: BTreeSet = sample_actions() - .iter() - .filter(|action| matches!(default_binding(action), Exposure::Unbound(_))) - .map(variant_name) - .collect(); - let pinned: BTreeSet = UNBOUND - .iter() - .chain(FEATURE_UNBOUND) - .map(|name| name.to_string()) - .collect(); - assert_eq!(measured, pinned, "move UNBOUND together with the registry"); - } - - #[test] - fn bound_variants_have_a_terminal_producer_and_unbound_ones_have_none() { - let producers = tui_producers(); - for action in sample_actions() { - let name = variant_name(&action); - match default_binding(&action) { - Exposure::Bound(_) => assert!( - producers.contains(&name), - "Action::{name} claims a surface but nothing under src/tui/ builds it" - ), - Exposure::Unbound(reason) => assert!( - FEATURE_UNBOUND.contains(&name.as_str()) || !producers.contains(&name), - "Action::{name} is Unbound ({reason}) but src/tui/ builds it" - ), - } - } - } - - #[test] - fn the_payloads_no_gesture_reaches_are_pinned_by_hand() { - let unreached = [ - Action::Navigate(NavTarget::Home), - Action::Navigate(NavTarget::RecentlyPlayed), - Action::Sort { - context: SortContext::RecentlyPlayed, - field: SortField::default(), - }, - Action::ToggleSortOrder(SortContext::RecentlyPlayed), - ]; - for action in &unreached { - assert!( - matches!(default_binding(action), Exposure::Unbound(_)), - "{action:?}" - ); - } - for target in NavTarget::ALL { - if target != NavTarget::Home && target != NavTarget::RecentlyPlayed { - assert!( - matches!( - default_binding(&Action::Navigate(target)), - Exposure::Bound(_) - ), - "{target:?}" - ); - } - } - let bound = |target| { - matches!( - default_binding(&Action::OpenLibrary(target)), - Exposure::Bound(_) - ) - }; - assert_eq!(bound(LibraryTarget::AiDj), cfg!(feature = "ai-dj")); - assert_eq!( - bound(LibraryTarget::LocalFiles), - cfg!(feature = "local-files") - ); - } - - #[test] - fn the_enum_scanner_keeps_variants_and_drops_docs_attributes_and_fields() { - let names = action_variant_names(); - assert!( - names.contains("QueueTracks"), - "a variant after a cfg_attr line" - ); - assert!(names.contains("DjVibeShift"), "a variant with no doc line"); - assert!(names.contains("PlayUris"), "a struct variant"); - assert!(!names.contains("position"), "a struct field"); - assert!(!names.contains("cfg_attr")); - } - - #[test] - fn the_producer_scan_is_identifier_bounded_and_skips_tests_and_comments() { - let source = "app.apply(Action::PlayUris { uris: vec![] });\n\ - PlaybackAction::Play;\n\ - /// through the shared `Action::Pause` vocabulary\n\ - #[cfg(test)]\n\ - mod tests {\n Action::Back\n}\n\ - #[cfg(test)]\n\ - const HIDDEN: Option = Some(\n Action::Notify(String::new(), 0),\n);\n\ - #[cfg(test)]\n\ - #[allow(dead_code)]\n\ - fn probe() -> Action { Action::Play }\n\ - let seek = Action::Search(query);\n"; - let expected: BTreeSet = ["PlayUris", "Search"] - .into_iter() - .map(str::to_string) - .collect(); - assert_eq!(producers_in(source), expected); - } -} diff --git a/src/tui/layout.rs b/src/tui/layout.rs deleted file mode 100644 index ffae4acc..00000000 --- a/src/tui/layout.rs +++ /dev/null @@ -1,413 +0,0 @@ -use crate::core::app::{ActiveBlock, App}; -use crate::core::state::RuntimeState; -use crate::core::user_config::BehaviorConfig; -use ratatui::layout::{Constraint, Layout, Rect}; - -pub const COMPACT_TOP_ROW_THRESHOLD: u16 = 60; -const COMPACT_HELP_WIDTH: u16 = 6; -const COMPACT_SETTINGS_WIDTH: u16 = 10; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct MainLayoutAreas { - pub input: Option, - pub help: Option, - pub settings: Option, - pub playbar: Rect, - /// The full sidebar (user block) area, including the input row in wide layout. - pub sidebar: Rect, - pub library: Rect, - pub playlists: Rect, - pub content: Rect, -} - -pub fn small_terminal_width(behavior: &BehaviorConfig) -> u16 { - behavior.small_terminal_width.max(1) -} - -pub fn small_terminal_height(behavior: &BehaviorConfig) -> u16 { - behavior.small_terminal_height.max(1) -} - -pub fn main_layout_margin(app: &App) -> u16 { - if app.view.size.height > small_terminal_height(&app.user_config.behavior) { - 1 - } else { - 0 - } -} - -pub fn is_wide_layout(app: &App) -> bool { - app.view.size.width >= small_terminal_width(&app.user_config.behavior) - && !app.user_config.behavior.enforce_wide_search_bar -} - -pub fn compute_main_layout(app: &App) -> Option { - if app.view.size.width == 0 || app.view.size.height == 0 { - return None; - } - - let root = Rect::new(0, 0, app.view.size.width, app.view.size.height); - let margin = main_layout_margin(app); - let wide_layout = is_wide_layout(app); - let behavior = &app.user_config.behavior; - let runtime_state = &app.runtime_state; - - let (input_area, routes_area, playbar_area) = if wide_layout { - if behavior.playbar_position == "top" { - let [playbar_area, routes_area] = root.layout( - &Layout::vertical([playbar_constraint(runtime_state), Constraint::Min(1)]).margin(margin), - ); - (None, routes_area, playbar_area) - } else { - let [routes_area, playbar_area] = root.layout( - &Layout::vertical([Constraint::Min(1), playbar_constraint(runtime_state)]).margin(margin), - ); - (None, routes_area, playbar_area) - } - } else if behavior.playbar_position == "top" { - let [playbar_area, input_area, routes_area] = root.layout( - &Layout::vertical([ - playbar_constraint(runtime_state), - Constraint::Length(3), - Constraint::Min(1), - ]) - .margin(margin), - ); - (Some(input_area), routes_area, playbar_area) - } else { - let [input_area, routes_area, playbar_area] = root.layout( - &Layout::vertical([ - Constraint::Length(3), - Constraint::Min(1), - playbar_constraint(runtime_state), - ]) - .margin(margin), - ); - (Some(input_area), routes_area, playbar_area) - }; - - let (user_area, content_area) = split_routes_area(app, routes_area); - let library = library_constraints(runtime_state); - let (input, help, settings, library_area, playlist_area) = if wide_layout { - let [input_area, library_area, playlist_area] = user_area.layout(&Layout::vertical([ - Constraint::Length(3), - library[0], - library[1], - ])); - let [input_text_area, help_area, settings_area] = - split_input_help_and_settings(app, input_area); - ( - Some(input_text_area), - Some(help_area), - Some(settings_area), - library_area, - playlist_area, - ) - } else { - let [library_area, playlist_area] = - user_area.layout(&Layout::vertical([library[0], library[1]])); - if let Some(input_area) = input_area { - let [input_text_area, help_area, settings_area] = - split_input_help_and_settings(app, input_area); - ( - Some(input_text_area), - Some(help_area), - Some(settings_area), - library_area, - playlist_area, - ) - } else { - (None, None, None, library_area, playlist_area) - } - }; - - Some(MainLayoutAreas { - input, - help, - settings, - playbar: playbar_area, - sidebar: user_area, - library: library_area, - playlists: playlist_area, - content: content_area, - }) -} - -/// Returns horizontal constraints for the [sidebar, content] split based on runtime state. -/// When sidebar_width_percent is 0, the sidebar is hidden (zero length). -/// When sidebar_width_percent is 100, content is hidden. -pub fn sidebar_constraints(state: &RuntimeState) -> [Constraint; 2] { - let sidebar = state.sidebar_width_percent.min(100) as u16; - let content = 100u16.saturating_sub(sidebar); - [ - Constraint::Percentage(sidebar), - Constraint::Percentage(content), - ] -} - -fn split_routes_area(app: &App, routes_area: Rect) -> (Rect, Rect) { - let behavior = &app.user_config.behavior; - let sidebar = if sidebar_is_hidden(app) { - [Constraint::Length(0), Constraint::Min(1)] - } else { - sidebar_constraints(&app.runtime_state) - }; - - match behavior.sidebar_position.as_str() { - "right" => { - let [content_area, user_area] = - routes_area.layout(&Layout::horizontal([sidebar[1], sidebar[0]])); - (user_area, content_area) - } - _ => { - let [user_area, content_area] = - routes_area.layout(&Layout::horizontal([sidebar[0], sidebar[1]])); - (user_area, content_area) - } - } -} - -fn sidebar_is_hidden(app: &App) -> bool { - if app.user_config.behavior.sidebar_position != "hidden" { - return false; - } - - let route = app.get_current_route(); - !matches!( - route.active_block, - ActiveBlock::Library | ActiveBlock::MyPlaylists - ) && !matches!( - route.hovered_block, - ActiveBlock::Library | ActiveBlock::MyPlaylists - ) -} - -pub fn split_input_help_and_settings(app: &App, input_row_area: Rect) -> [Rect; 3] { - let compact_top_row = input_row_area.width < COMPACT_TOP_ROW_THRESHOLD; - - let constraints = if compact_top_row { - [ - Constraint::Min(1), - Constraint::Length(COMPACT_HELP_WIDTH), - Constraint::Length(COMPACT_SETTINGS_WIDTH), - ] - } else if is_wide_layout(app) { - [ - Constraint::Percentage(65), - Constraint::Percentage(18), - Constraint::Percentage(17), - ] - } else { - [ - Constraint::Percentage(80), - Constraint::Percentage(8), - Constraint::Percentage(12), - ] - }; - - input_row_area.layout(&Layout::horizontal(constraints)) -} - -/// Returns the playbar height constraint based on runtime state. -/// When playbar_height_rows is 0, the playbar is hidden. -pub fn playbar_constraint(state: &RuntimeState) -> Constraint { - Constraint::Length(state.playbar_height_rows) -} - -/// Returns vertical constraints for the [library, playlists] split within the sidebar. -pub fn library_constraints(state: &RuntimeState) -> [Constraint; 2] { - let library = state.library_height_percent.min(100) as u16; - let playlists = 100u16.saturating_sub(library); - [ - Constraint::Percentage(library), - Constraint::Percentage(playlists), - ] -} - -/// Returns the fullscreen content/playbar split used by lyrics and cover-art views. -/// -/// When `playbar_height_rows` is 0, the playbar is hidden and the content area fills the screen. -pub fn fullscreen_view_layout(state: &RuntimeState, area: Rect) -> (Rect, Option) { - if state.playbar_height_rows == 0 { - return (area, None); - } - - let chunks = Layout::vertical([ - Constraint::Min(0), - Constraint::Length(state.playbar_height_rows), - ]) - .split(area); - let content_area = chunks[0]; - let playbar_area = chunks[1]; - - (content_area, Some(playbar_area)) -} - -/// Centers a `size` rect inside `bounds`, clamping it to fit. -/// -/// Shared by every cover-art placement (playbar slot, fullscreen view, plugin -/// screen widget): `ratatui-image` renders top-left within the area it is -/// given, so the caller measures the fitted size first and then centers it. -#[cfg(feature = "cover-art")] -pub fn center_rect_within(bounds: Rect, size: Rect) -> Rect { - Rect { - x: bounds.x + bounds.width.saturating_sub(size.width.min(bounds.width)) / 2, - y: bounds.y + bounds.height.saturating_sub(size.height.min(bounds.height)) / 2, - width: size.width.min(bounds.width), - height: size.height.min(bounds.height), - } -} - -/// Returns the compact playbar area used by the full-screen miniplayer. -/// -/// The normal playbar layout is optimized for a short strip. Miniplayer keeps -/// that same playbar renderer, but constrains it to a centered stage so wide -/// terminals do not stretch track metadata across a mostly empty full screen. -pub fn miniplayer_playbar_area(area: Rect) -> Rect { - let horizontal_margin = if area.width >= 100 { 4 } else { 1 }; - let vertical_margin = if area.height >= 30 { 4 } else { 1 }; - - let available_width = area.width.saturating_sub(horizontal_margin * 2); - let available_height = area.height.saturating_sub(vertical_margin * 2); - let width = available_width.min(120).max(available_width.min(1)); - let height = available_height.min(18).max(available_height.min(1)); - - Rect { - x: area.x.saturating_add(area.width.saturating_sub(width) / 2), - y: area - .y - .saturating_add(area.height.saturating_sub(height) / 2), - width, - height, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn make_state_with(sidebar_pct: u8, playbar_rows: u16) -> RuntimeState { - RuntimeState { - sidebar_width_percent: sidebar_pct, - playbar_height_rows: playbar_rows, - ..RuntimeState::default() - } - } - - fn make_state_with_library(library_pct: u8) -> RuntimeState { - RuntimeState { - library_height_percent: library_pct, - ..RuntimeState::default() - } - } - - fn make_state(sidebar_pct: u8) -> RuntimeState { - make_state_with(sidebar_pct, 6) - } - - #[test] - fn default_sidebar_is_20_percent() { - let b = make_state(20); - let [sidebar, content] = sidebar_constraints(&b); - assert_eq!(sidebar, Constraint::Percentage(20)); - assert_eq!(content, Constraint::Percentage(80)); - } - - #[test] - fn hidden_sidebar_gives_zero_percent() { - let b = make_state(0); - let [sidebar, content] = sidebar_constraints(&b); - assert_eq!(sidebar, Constraint::Percentage(0)); - assert_eq!(content, Constraint::Percentage(100)); - } - - #[test] - fn full_sidebar_hides_content() { - let b = make_state(100); - let [sidebar, content] = sidebar_constraints(&b); - assert_eq!(sidebar, Constraint::Percentage(100)); - assert_eq!(content, Constraint::Percentage(0)); - } - - #[test] - fn over_100_percent_is_clamped() { - let mut b = make_state(20); - b.sidebar_width_percent = 255; - let [sidebar, content] = sidebar_constraints(&b); - assert_eq!(sidebar, Constraint::Percentage(100)); - assert_eq!(content, Constraint::Percentage(0)); - } - - #[test] - fn default_playbar_is_6_rows() { - let b = make_state_with(20, 6); - assert_eq!(playbar_constraint(&b), Constraint::Length(6)); - } - - #[test] - fn hidden_playbar_is_zero_rows() { - let b = make_state_with(20, 0); - assert_eq!(playbar_constraint(&b), Constraint::Length(0)); - } - - #[test] - fn default_library_is_30_percent() { - let b = make_state_with_library(30); - let [lib, playlists] = library_constraints(&b); - assert_eq!(lib, Constraint::Percentage(30)); - assert_eq!(playlists, Constraint::Percentage(70)); - } - - #[test] - fn hidden_library_gives_zero_percent() { - let b = make_state_with_library(0); - let [lib, playlists] = library_constraints(&b); - assert_eq!(lib, Constraint::Percentage(0)); - assert_eq!(playlists, Constraint::Percentage(100)); - } - - #[test] - fn library_over_100_percent_is_clamped() { - let mut b = make_state_with_library(30); - b.library_height_percent = 255; - let [lib, playlists] = library_constraints(&b); - assert_eq!(lib, Constraint::Percentage(100)); - assert_eq!(playlists, Constraint::Percentage(0)); - } - - #[test] - fn fullscreen_layout_hides_playbar_when_height_is_zero() { - let b = make_state_with(20, 0); - let area = Rect::new(2, 4, 80, 24); - - let (content, playbar) = fullscreen_view_layout(&b, area); - - assert_eq!(content, area); - assert!(playbar.is_none()); - } - - #[test] - fn fullscreen_layout_splits_content_and_playbar_when_height_is_set() { - let b = make_state_with(20, 6); - let area = Rect::new(2, 4, 80, 24); - - let (content, playbar) = fullscreen_view_layout(&b, area); - - assert_eq!(content, Rect::new(2, 4, 80, 18)); - assert_eq!(playbar, Some(Rect::new(2, 22, 80, 6))); - } - - #[test] - fn miniplayer_playbar_area_is_compact_and_centered_on_large_terminals() { - let area = Rect::new(0, 0, 180, 50); - - assert_eq!(miniplayer_playbar_area(area), Rect::new(30, 16, 120, 18)); - } - - #[test] - fn miniplayer_playbar_area_uses_available_space_on_small_terminals() { - let area = Rect::new(0, 0, 60, 12); - - assert_eq!(miniplayer_playbar_area(area), Rect::new(1, 1, 58, 10)); - } -} diff --git a/src/tui/mod.rs b/src/tui/mod.rs deleted file mode 100644 index 567929df..00000000 --- a/src/tui/mod.rs +++ /dev/null @@ -1,12 +0,0 @@ -#[cfg(feature = "cover-art")] -pub mod cover_art; -pub mod event; -pub mod first_run; -pub mod fuzzy; -pub mod handlers; -pub mod keymap; -pub mod layout; -pub mod onboarding; -pub mod runner; -pub mod theme; -pub mod ui; diff --git a/src/tui/onboarding.rs b/src/tui/onboarding.rs deleted file mode 100644 index 74192795..00000000 --- a/src/tui/onboarding.rs +++ /dev/null @@ -1,59 +0,0 @@ -//! Console implementation of the first-launch surface. -//! -//! Reproduces the pre-trait stdout byte for byte: `info` is a `println!`, -//! `progress` a flushed `print!`, `prompt_line` shows the prompt verbatim and -//! reads one raw line, and `pick_sources` is the terminal source picker in -//! `tui/first_run.rs`. - -use crate::core::onboarding::{ - confirm_answer, Onboarding, OnboardingAnswer, OnboardingPrompt, BANNER_RULE, -}; -use crate::core::source::Source; -use anyhow::Result; -use std::io::{stdin, stdout, Write}; - -pub struct ConsoleOnboarding; - -impl Onboarding for ConsoleOnboarding { - fn is_interactive(&self) -> bool { - use std::io::IsTerminal; - stdin().is_terminal() && stdout().is_terminal() - } - - fn ask(&self, prompt: &OnboardingPrompt) -> Result { - let (title, body, question) = match prompt { - OnboardingPrompt::Confirm { - title, - body, - question, - } => (title, body, question), - }; - println!("\n{BANNER_RULE}\n{title}\n{BANNER_RULE}\n{body}"); - println!("{question}"); - let _ = stdout().flush(); - let mut input = String::new(); - stdin().read_line(&mut input)?; - Ok(confirm_answer(&input)) - } - - fn info(&self, text: &str) { - println!("{text}"); - } - - fn progress(&self, text: &str) { - print!("{text}"); - let _ = stdout().flush(); - } - - fn prompt_line(&self, prompt: &str) -> Result { - print!("{prompt}"); - let _ = stdout().flush(); - let mut input = String::new(); - stdin().read_line(&mut input)?; - Ok(input) - } - - fn pick_sources(&self, options: &[Source]) -> Result>> { - super::first_run::pick_sources(options) - } -} diff --git a/src/tui/runner.rs b/src/tui/runner.rs deleted file mode 100644 index 43aa16cc..00000000 --- a/src/tui/runner.rs +++ /dev/null @@ -1,669 +0,0 @@ -use crate::core::app::{self, ActiveBlock, App, RouteId}; -use crate::core::driver::{DiscordRpcHandle, Driver, MprisHandle, TickEnv}; -use crate::core::user_config::UserConfig; -use crate::infra::network::IoEvent; -use crate::tui::event::{self, Key}; -use crate::tui::handlers; -use crate::tui::ui; -use anyhow::{anyhow, Result}; -use crossterm::{ - event::{ - DisableMouseCapture, EnableMouseCapture, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, - PushKeyboardEnhancementFlags, - }, - execute, - terminal::{supports_keyboard_enhancement, SetTitle}, -}; -use log::info; -use ratatui::backend::Backend; -use std::{ - cmp::{max, min}, - io::stdout, - sync::{atomic::AtomicU64, Arc}, - time::Instant, -}; -use tokio::sync::Mutex; - -/// Whether the terminal renders real pixels. False until the renderer is -/// initialized, and always false in a decode-only build (`art-decode` without -/// `cover-art`), where art is fetched solely for the adaptive theme. -#[cfg(feature = "art-decode")] -fn cover_art_full_image_support() -> bool { - #[cfg(feature = "cover-art")] - { - crate::tui::cover_art::full_image_support() - } - #[cfg(not(feature = "cover-art"))] - { - false - } -} - -fn back_key_clears_playlist_filter(app: &mut App, active_block: ActiveBlock) -> bool { - if active_block == ActiveBlock::TrackTable && app.is_playlist_track_filter_active() { - app.clear_playlist_track_filter(); - true - } else { - false - } -} - -/// The runner normally handles the configurable back key before `handle_app`. -/// Help's inline filter must get first chance while it is being edited (so `q` -/// can be query text), when opening the filter, and when Esc should clear a -/// confirmed filter rather than close Help. -fn help_menu_captures_key_before_back(app: &App, key: Key) -> bool { - app.get_current_route().active_block == ActiveBlock::HelpMenu - && (app.view.help_filter_editing - || key == app.user_config.keys.search - || (key == Key::Esc && !app.view.help_filter.is_empty())) -} - -/// The DJ screen is a typing surface, exactly like `ActiveBlock::Input`, and the -/// runner's back-key branch has to leave it alone for the same reason: the default -/// back key is `q`, and both the prompt ("play me some Queen") and the picker's -/// free-text model step ("qwen2.5-coder") need that character to be text. Without -/// this the key never reaches `handle_app`, so the picker's "swallows everything" -/// guarantee is void — the route pops out from under an open modal. -/// -/// Leaving the screen still works: `ai_dj::handler` pops the stack on Esc once -/// there is nothing left to abandon. -#[cfg(feature = "ai-dj")] -fn dj_captures_key_before_back(app: &App) -> bool { - app.get_current_route().active_block == ActiveBlock::AiDj -} - -/// Blocks that must see the configurable back key *before* the runner acts on it -/// themselves. Ordering is the whole point, so this is one predicate rather than -/// two conditions spelled out at the call site: a screen that is missing here has -/// its route popped instead of receiving the key, however well its own handler -/// copes with it. -fn key_reaches_handlers_before_back(app: &App, key: Key) -> bool { - #[cfg(feature = "ai-dj")] - if dj_captures_key_before_back(app) { - return true; - } - help_menu_captures_key_before_back(app, key) -} - -/// Where the terminal cursor goes in this frame: inside the search box while -/// the input block is focused, `None` (hidden) on every other screen. The -/// frame applies it through `Frame::set_cursor_position`. -fn frame_cursor_position(app: &App, active_block: ActiveBlock) -> Option<(u16, u16)> { - if active_block != ActiveBlock::Input { - return None; - } - let cursor_offset = crate::tui::layout::main_layout_margin(app) + 1; - Some(( - cursor_offset + app.view.input_cursor_position - app.view.input_scroll_offset.get(), - cursor_offset, - )) -} - -/// One keypress, from the runner's event loop. Returns `true` when the user asked -/// to quit and the loop must break. -/// -/// Extracted from the loop so the ordering of these branches is testable: the bug -/// this guards against is not "does the DJ handler cope with `q`" but "does `q` -/// ever get as far as the DJ handler". `Key::Ctrl('c')` stays at the call site, -/// because it is an unconditional quit that predates any of this. -fn dispatch_key(key: Key, app: &mut App) -> bool { - let current_active_block = app.get_current_route().active_block; - - if current_active_block == ActiveBlock::ExitPrompt { - match key { - Key::Enter | Key::Char('y') | Key::Char('Y') => { - app.close_io_channel(); - return true; - } - Key::Esc | Key::Char('n') | Key::Char('N') => { - app.pop_navigation_stack(); - } - _ if key == app.user_config.keys.back => { - app.pop_navigation_stack(); - } - _ => {} - } - } else if current_active_block == ActiveBlock::Input { - handlers::input_handler(key, app); - } else if key_reaches_handlers_before_back(app, key) { - handlers::handle_app(key, app); - } else if key == app.user_config.keys.back { - if !back_key_clears_playlist_filter(app, current_active_block) { - if current_active_block == ActiveBlock::Settings { - handlers::handle_app(key, app); - } else if app.get_current_route().active_block == ActiveBlock::AnnouncementPrompt { - if let Some(dismissed_id) = app.dismiss_active_announcement() { - app.runtime_state.mark_announcement_seen(dismissed_id); - let patch = crate::core::state::PersistedRuntimeState::announcements( - &app.runtime_state.seen_announcement_ids, - &app.runtime_state.dismissed_announcements, - ); - if let Err(error) = app.save_runtime_state(&patch) { - app.handle_error(anyhow!( - "Failed to persist dismissed announcement: {}", - error - )); - } - } - - if app.active_announcement.is_none() { - app.pop_navigation_stack(); - } - } else if app.get_current_route().active_block != ActiveBlock::Input { - let pop_result = match app.pop_navigation_stack() { - Some(ref x) if x.id == RouteId::Search => app.pop_navigation_stack(), - Some(x) => Some(x), - None => None, - }; - if pop_result.is_none() { - app.push_navigation_stack(RouteId::ExitPrompt, ActiveBlock::ExitPrompt); - } - } - } - } else { - handlers::handle_app(key, app); - } - false -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::app::TrackTableContext; - use rspotify::model::idtypes::PlaylistId; - use std::{sync::mpsc::channel, time::SystemTime}; - - fn app() -> App { - let (tx, _rx) = channel(); - App::new( - tx, - crate::core::user_config::UserConfig::new(), - Some(SystemTime::now()), - ) - } - - #[test] - fn back_key_clears_playlist_filter_before_navigation_pop() { - let mut app = app(); - app.track_table.context = Some(TrackTableContext::MyPlaylists); - app.playlist_track_table_id = Some( - PlaylistId::from_id("37i9dQZF1DX4WYpdgoIcn6") - .unwrap() - .into_static(), - ); - app.active_playlist_track_filter = Some("query".to_string()); - app.push_navigation_stack(RouteId::TrackTable, ActiveBlock::TrackTable); - - assert!(back_key_clears_playlist_filter( - &mut app, - ActiveBlock::TrackTable - )); - - assert!(app.active_playlist_track_filter.is_none()); - assert_eq!(app.get_current_route().id, RouteId::TrackTable); - } - - // Both help tests assert through `key_reaches_handlers_before_back`, the function - // the runner actually consults, rather than through - // `help_menu_captures_key_before_back` directly. On the Help route the two agree, - // but only the outer one fails when Help stops being wired into the gate at all, - // which is the way this protection would realistically be lost. - #[test] - fn input_frame_places_the_cursor_after_the_visible_prefix() { - let mut app = app(); - app.view.input_cursor_position = 7; - app.view.input_scroll_offset.set(2); - let offset = crate::tui::layout::main_layout_margin(&app) + 1; - - assert_eq!( - frame_cursor_position(&app, ActiveBlock::Input), - Some((offset + 5, offset)) - ); - } - - #[test] - fn non_input_frames_leave_the_cursor_hidden() { - let mut app = app(); - app.view.input_cursor_position = 7; - - for block in [ - ActiveBlock::Home, - ActiveBlock::TrackTable, - ActiveBlock::Library, - ] { - assert_eq!(frame_cursor_position(&app, block), None); - } - } - - #[test] - fn help_filter_captures_back_key_while_editing() { - let mut app = app(); - app.push_navigation_stack(RouteId::HelpMenu, ActiveBlock::HelpMenu); - app.view.help_filter_editing = true; - - assert!(key_reaches_handlers_before_back( - &app, - app.user_config.keys.back - )); - } - - #[test] - fn confirmed_help_filter_captures_escape_but_not_normal_back_key() { - let mut app = app(); - app.push_navigation_stack(RouteId::HelpMenu, ActiveBlock::HelpMenu); - app.view.help_filter = "volume".to_string(); - - assert!(key_reaches_handlers_before_back(&app, Key::Esc)); - assert!(!key_reaches_handlers_before_back( - &app, - app.user_config.keys.back - )); - } - - /// The default back key is `q`, and the DJ's free-text model step exists so an - /// Ollama user can type `qwen2.5-coder`. If the runner claims the key first, the - /// first character of that name closes the screen instead. - #[cfg(feature = "ai-dj")] - #[test] - fn the_back_key_types_into_the_dj_picker_instead_of_closing_the_screen() { - use crate::infra::dj::setup::{DjSetup, DjSetupStep}; - - let mut app = app(); - app.push_navigation_stack(RouteId::AiDj, ActiveBlock::AiDj); - let mut setup = DjSetup::new(&app.user_config.behavior); - setup.step = DjSetupStep::Custom; - setup.custom.clear(); - app.dj.setup = Some(setup); - - assert_eq!( - app.user_config.keys.back, - Key::Char('q'), - "the default this test is about" - ); - assert!(!dispatch_key(app.user_config.keys.back, &mut app)); - - assert_eq!(app.dj.setup.as_ref().unwrap().custom, "q"); - assert_eq!( - app.get_current_route().id, - RouteId::AiDj, - "and the picker is still open on the screen it belongs to" - ); - } - - /// The other half of the same rule: exempting the DJ must not exempt everything - /// else. `q` on an ordinary screen still goes back. - #[test] - fn the_back_key_still_pops_the_route_outside_the_dj() { - let mut app = app(); - app.push_navigation_stack(RouteId::TrackTable, ActiveBlock::TrackTable); - - assert!(!dispatch_key(app.user_config.keys.back, &mut app)); - - assert_ne!(app.get_current_route().id, RouteId::TrackTable); - } - - /// `y` at the exit prompt is the one keypress that ends the process, and the - /// runner's loop reads that from this return value now that the chain is a - /// function. - #[test] - fn confirming_the_exit_prompt_asks_the_loop_to_break() { - let mut app = app(); - app.push_navigation_stack(RouteId::ExitPrompt, ActiveBlock::ExitPrompt); - - assert!(dispatch_key(Key::Char('y'), &mut app)); - } -} - -#[cfg(feature = "streaming")] -async fn pause_native_playback_before_exit(app: &Arc>) { - let player = { - let mut app = app.lock().await; - if !app.is_streaming_active { - return; - } - - let Some(player) = app.streaming_player.clone() else { - return; - }; - - let is_playing = app.native_is_playing.unwrap_or_else(|| { - app - .current_playback_context - .as_ref() - .map(|context| context.is_playing) - .unwrap_or(false) - }); - - if !is_playing { - return; - } - - app.native_is_playing = Some(false); - if let Some(context) = app.current_playback_context.as_mut() { - context.is_playing = false; - } - - player - }; - - player.pause(); - tokio::time::sleep(std::time::Duration::from_millis(150)).await; -} - -pub async fn start_ui( - user_config: UserConfig, - app: &Arc>, - shared_position: Option>, - mpris_manager: MprisHandle, - discord_rpc_manager: DiscordRpcHandle, - history_collector: crate::infra::history::HistoryCollectorHandle, -) -> Result<()> { - info!("ui thread initialized"); - // The driver owns everything the app must do on a timer (see - // `core::driver`); this loop's job shrinks to drawing frames, reading - // events, and calling `driver.tick` at the configured tick rate. - let mut driver = Driver::new(shared_position, mpris_manager, discord_rpc_manager); - - let mut terminal = ratatui::init(); - // Probe the terminal's image protocol only now that the terminal is set up; - // `App` construction must not touch stdout. - #[cfg(feature = "cover-art")] - crate::tui::cover_art::init_renderer(); - execute!(stdout(), EnableMouseCapture)?; - let keyboard_enhancement_supported = supports_keyboard_enhancement().unwrap_or(false); - let keyboard_enhancement_enabled = keyboard_enhancement_supported - && execute!( - stdout(), - PushKeyboardEnhancementFlags( - KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES - | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS - | KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES - ) - ) - .is_ok(); - if keyboard_enhancement_enabled { - info!("enabled keyboard enhancement flags"); - } - { - let mut app = app.lock().await; - app.view.terminal_input_caps.keyboard_enhancement_supported = keyboard_enhancement_supported; - app.view.terminal_input_caps.keyboard_enhancement_enabled = keyboard_enhancement_enabled; - app.view.terminal_input_caps.ctrl_punct_reliable = app::CapabilityState::Unknown; - } - - let events = event::Events::new(user_config.behavior.tick_rate_milliseconds); - - let mut is_first_render = true; - - loop { - let terminal_size = - terminal - .backend() - .size() - .ok() - .map(|size| crate::core::geometry::Viewport { - width: size.width, - height: size.height, - }); - let title_update = { - let mut app = app.lock().await; - - if let Some(size) = terminal_size { - if is_first_render || app.view.size != size { - app.view.help_menu_offset = 0; - app.view.help_menu_page = 0; - app.view.size = size; - - let potential_limit = max((size.height as i32) - 13, 0) as u32; - let max_limit = min(potential_limit, 50); - let large_search_limit = min((f32::from(size.height) / 1.4) as u32, max_limit); - let small_search_limit = min((f32::from(size.height) / 2.85) as u32, max_limit / 2); - - app.dispatch(IoEvent::UpdateSearchLimits( - large_search_limit, - small_search_limit, - )); - - app.view.help_menu_max_lines = (size.height as u32).saturating_sub(8); - } - }; - - // Rebuild the formatted Help rows before drawing so the render path - // below reads immutable App state only. - if app.get_current_route().active_block == ActiveBlock::HelpMenu { - ui::ensure_help_menu_model(&mut app); - } - - let current_route = app.get_current_route(); - // The banner animates whenever the Home screen is displayed, regardless - // of which block has focus (on Home the focused block is usually Empty - // or Library, not Home), so gate the fast tick on the route. A disabled - // banner gradient renders statically and needs no fast tick. - let animation_active = (current_route.id == RouteId::Home - && app.user_config.behavior.banner_gradient) - || current_route.active_block == ActiveBlock::Analysis - || (current_route.id == RouteId::LyricsView - && app.lyrics_status == crate::core::app::LyricsStatus::Found) - || app.view.liked_song_animation_frame.is_some() - || app.theme_fade_active(); - let current_tick_rate = if animation_active { - app.user_config.behavior.animation_tick_rate_milliseconds - } else { - app.user_config.behavior.tick_rate_milliseconds - }; - events.set_tick_rate(current_tick_rate); - - // Drop protocol caches for art the store no longer holds; rebuilds are - // lazy, so this is a no-op whenever the art is unchanged. - #[cfg(feature = "cover-art")] - crate::tui::cover_art::sync(&app.cover_art); - - // The cursor must be invisible while the frame diff is written. Ratatui - // applies the diff first and shows or hides the cursor afterwards, so a - // cursor left visible by the previous frame travels over every freshly - // written cell run and shows up as a strobing block inside the animated - // banner. Hide it here; the draw closure below re-shows it at the input - // position after the write. - terminal.hide_cursor()?; - - terminal.draw(|f| { - use ratatui::{prelude::Style, widgets::Block}; - f.render_widget( - Block::default().style(Style::default().bg(app.user_config.theme.background.into())), - f.area(), - ); - - match current_route.active_block { - ActiveBlock::HelpMenu => ui::draw_help_menu(f, &app), - ActiveBlock::Queue => ui::draw_queue(f, &app), - ActiveBlock::Party => { - ui::draw_main_layout(f, &app); - ui::draw_party(f, &app); - } - ActiveBlock::Error => ui::draw_error_screen(f, &app), - ActiveBlock::SelectDevice => ui::draw_device_list(f, &app), - ActiveBlock::Analysis => ui::audio_analysis::draw(f, &app), - ActiveBlock::LyricsView => ui::draw_lyrics_view(f, &app), - ActiveBlock::MiniPlayer => ui::draw_miniplayer(f, &app), - #[cfg(feature = "cover-art")] - ActiveBlock::CoverArtView => ui::draw_cover_art_view(f, &app), - ActiveBlock::AnnouncementPrompt => ui::draw_announcement_prompt(f, &app), - ActiveBlock::RecapPrompt => { - ui::draw_main_layout(f, &app); - ui::draw_recap_prompt(f, &app); - } - ActiveBlock::CommunityPinPrompt => { - ui::draw_main_layout(f, &app); - ui::draw_community_pin_prompt(f, &app); - } - ActiveBlock::ExitPrompt => ui::draw_exit_prompt(f, &app), - ActiveBlock::Settings => ui::settings::draw_settings(f, &app), - ActiveBlock::PluginScreen => ui::draw_plugin_screen(f, &app), - ActiveBlock::CreatePlaylistForm => { - ui::draw_main_layout(f, &app); - ui::draw_create_playlist_form(f, &app); - } - _ => ui::draw_main_layout(f, &app), - } - - // Plugin popup overlays every screen. - ui::draw_plugin_popup(f, &app); - - // Cursor management lives inside the frame: ratatui shows and moves the - // terminal cursor when a widget requests a position and hides it when - // none does. The old post-draw hide/show pair ran on every animation - // frame (16 ms on Home), and the alternating toggle reset the - // terminal's blink phase each frame, so the cursor strobed. - if let Some(position) = frame_cursor_position(&app, current_route.active_block) { - f.set_cursor_position(position); - } - })?; - - driver.next_window_title(&app) - }; - if let Some(title) = title_update { - execute!(stdout(), SetTitle(title.as_str()))?; - } - - match events.next()? { - event::Event::Input(key) => { - let mut app = app.lock().await; - if key == Key::Ctrl('c') { - app.close_io_channel(); - break; - } - - // `break` before the scripting hook below, not after: a quit must not run - // one more round of pending plugin commands on its way out. - if dispatch_key(key, &mut app) { - break; - } - driver.run_pending_script_commands(&mut app); - } - event::Event::Mouse(mouse) => { - let mut app = app.lock().await; - if !app.user_config.behavior.disable_mouse_inputs { - handlers::mouse_handler(mouse, &mut app); - } - } - event::Event::Tick(elapsed) => { - // The frontend owns the macOS main thread, so its run loop is pumped - // here rather than by the driver. - #[cfg(all(feature = "macos-media", target_os = "macos"))] - { - use objc2_foundation::{NSDate, NSRunLoop}; - NSRunLoop::currentRunLoop().runUntilDate(&NSDate::dateWithTimeIntervalSinceNow(0.001)); - } - - let mut app = app.lock().await; - // The visualizer bar count is frontend geometry (terminal width and - // layout margin), so it is resolved here and injected. - #[cfg(any(feature = "audio-viz", feature = "audio-viz-cpal"))] - let viz_bars = (app.get_current_route().active_block == ActiveBlock::Analysis).then(|| { - ui::audio_analysis::visualizer_bar_count( - app.user_config.behavior.visualizer_style, - ui::audio_analysis::visualizer_inner_width(&app), - ) - }); - driver.tick( - &mut app, - elapsed, - TickEnv { - now: Instant::now(), - #[cfg(any(feature = "audio-viz", feature = "audio-viz-cpal"))] - viz_bars, - #[cfg(feature = "art-decode")] - cover_art_full_image_support: cover_art_full_image_support(), - }, - ); - } - } - - if is_first_render { - let mut app = app.lock().await; - driver.dispatch_startup(&mut app); - is_first_render = false; - } - } - - // Capture the exact final position of a non-Spotify session on a graceful - // quit (the throttled in-loop save is up to a few seconds stale). Done - // synchronously before teardown so the player is still alive to read from. - { - let session = app.lock().await.current_persisted_session(); - if let Some(session) = session { - if let Ok(path) = crate::core::persisted_playback::default_session_path() { - if let Err(e) = crate::core::persisted_playback::save(&path, &session) { - log::warn!("[session] failed to persist playback session on exit: {e}"); - } - } - } - } - - { - let mut app = app.lock().await; - driver.on_quit(&mut app); - } - - // A volume/resize/shuffle change may still be inside its debounce window; - // persist it before the process exits. - { - let mut app = app.lock().await; - app.flush_state_save(true); - } - - #[cfg(feature = "streaming")] - pause_native_playback_before_exit(app).await; - - // Stop the collector and all network work it owns before the final sync and - // clear. In particular, a pause-triggered now-playing push must not race the - // exit clear and recreate a stale public widget. - history_collector.shutdown().await; - - // Restore the terminal before the exit network calls: there is no reason to - // hold the alternate screen and raw mode while waiting on HTTP. - if let Some(title) = driver.window_title_reset() { - let _ = execute!(stdout(), SetTitle(title)); - } - let _ = execute!(stdout(), DisableMouseCapture); - if keyboard_enhancement_enabled { - let _ = execute!(stdout(), PopKeyboardEnhancementFlags); - } - ratatui::restore(); - - // Sync history to cloud on exit - let sync_token_opt = { - let app_guard = app.lock().await; - app_guard.user_config.behavior.sync_token.clone() - }; - - if let Some(token) = sync_token_opt { - info!("Synchronizing listening history to cloud before exit..."); - // Keep the clear strictly last after the collector has stopped: now-playing - // updates are upserts, so a late push would otherwise recreate a stale - // "paused" card on the public widget. - // - // Each call is bounded separately rather than sharing one budget, so a slow - // history upload cannot starve the clear. Without these the shared client - // allows a 10s connect plus a 30s request, stalling quit for up to a minute - // on a half-open connection. - let history_sync = crate::infra::history::sync_history_to_cloud(&token); - match tokio::time::timeout(std::time::Duration::from_secs(2), history_sync).await { - Ok(Err(e)) => log::warn!("failed to run exit history cloud sync: {}", e), - Err(_) => log::warn!("exit history cloud sync timed out; records will sync next run"), - Ok(Ok(())) => {} - } - - let clear_now_playing = crate::infra::history::clear_now_playing_from_cloud(&token); - match tokio::time::timeout(std::time::Duration::from_secs(1), clear_now_playing).await { - Ok(Err(e)) => log::warn!("failed to clear now-playing on exit: {}", e), - Err(_) => log::warn!("clearing now-playing on exit timed out"), - Ok(Ok(())) => {} - } - } - - driver.clear_presence(); - - Ok(()) -} diff --git a/src/tui/theme.rs b/src/tui/theme.rs deleted file mode 100644 index 9ad2b88b..00000000 --- a/src/tui/theme.rs +++ /dev/null @@ -1,67 +0,0 @@ -//! Render-boundary conversion from the frontend-neutral theme types -//! (`core/theme.rs`) to ratatui's, plus the style helpers that need ratatui -//! types and therefore cannot live on the core structs. - -use ratatui::style::{Modifier, Style}; - -use crate::core::theme::{Color, Theme}; -use crate::core::user_config::BehaviorConfig; - -impl From for ratatui::style::Color { - fn from(color: Color) -> Self { - match color { - Color::Reset => ratatui::style::Color::Reset, - Color::Black => ratatui::style::Color::Black, - Color::Red => ratatui::style::Color::Red, - Color::Green => ratatui::style::Color::Green, - Color::Yellow => ratatui::style::Color::Yellow, - Color::Blue => ratatui::style::Color::Blue, - Color::Magenta => ratatui::style::Color::Magenta, - Color::Cyan => ratatui::style::Color::Cyan, - Color::Gray => ratatui::style::Color::Gray, - Color::DarkGray => ratatui::style::Color::DarkGray, - Color::LightRed => ratatui::style::Color::LightRed, - Color::LightGreen => ratatui::style::Color::LightGreen, - Color::LightYellow => ratatui::style::Color::LightYellow, - Color::LightBlue => ratatui::style::Color::LightBlue, - Color::LightMagenta => ratatui::style::Color::LightMagenta, - Color::LightCyan => ratatui::style::Color::LightCyan, - Color::White => ratatui::style::Color::White, - Color::Rgb(r, g, b) => ratatui::style::Color::Rgb(r, g, b), - Color::Indexed(i) => ratatui::style::Color::Indexed(i), - } - } -} - -/// Ratatui-typed helpers on the core [`Theme`]. -pub trait ThemeExt { - fn base_style(&self) -> Style; -} - -impl ThemeExt for Theme { - fn base_style(&self) -> Style { - Style::default() - .fg(self.text.into()) - .bg(self.background.into()) - } -} - -/// Ratatui-typed helpers on [`BehaviorConfig`]. -pub trait EmphasisExt { - /// Return the emphasis modifier to apply to emphasized text, gated on - /// `enable_text_emphasis`. Callers pass the modifier they *want* - /// (e.g. `Modifier::BOLD`, `Modifier::BOLD | Modifier::ITALIC`) and get - /// `Modifier::empty()` when emphasis is disabled — so a single call site - /// replaces the previous unconditional `Modifier::BOLD`. - fn emphasis(&self, m: Modifier) -> Modifier; -} - -impl EmphasisExt for BehaviorConfig { - fn emphasis(&self, m: Modifier) -> Modifier { - if self.enable_text_emphasis { - m - } else { - Modifier::empty() - } - } -} diff --git a/src/tui/ui/ai_dj.rs b/src/tui/ui/ai_dj.rs deleted file mode 100644 index 2781d174..00000000 --- a/src/tui/ui/ai_dj.rs +++ /dev/null @@ -1,1277 +0,0 @@ -//! The AI DJ screen: a conversation transcript above a prompt. -//! -//! ## Why the transcript is hand-wrapped -//! -//! `Paragraph::scroll` offsets **rendered** lines, so an offset clamped against -//! the *message* count drifts the moment any message wraps to more than one row — -//! you can scroll past the end, or fail to reach it. (That bug is latent in -//! `plugin_popup_scroll`.) Nothing in the repo combines wrapping with scrolling, -//! so this does what the help menu does: wrap to display rows ourselves, then -//! window those rows. Offset and rendered rows are then the same unit. -//! -//! The caret is a painted glyph rather than the terminal cursor, because -//! `runner.rs` only shows the hardware cursor for `ActiveBlock::Input` and then -//! unconditionally moves it to the search box. - -use crate::core::app::{ActiveBlock, App}; -use crate::infra::dj::setup::DjSetupStep; -use crate::infra::dj::DjSpeaker; -use crate::tui::event::Key; -use crate::tui::theme::{EmphasisExt, ThemeExt}; -use crate::tui::ui::popups::centered_modal_rect; -use crate::tui::ui::util::get_color; -use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, Paragraph}; -use ratatui::Frame; -use unicode_width::UnicodeWidthChar; - -/// Rows reserved for the prompt box (one line of text plus its border). -const PROMPT_HEIGHT: u16 = 3; - -/// Column the picker's notes start in, so they line up down the list. -const NOTE_COLUMN: usize = 25; - -/// Indent for a note that has moved to a line of its own, clear of the number. -const NOTE_INDENT: usize = 5; - -/// The width the picker modal *asks* for. What it gets is -/// [`centered_modal_rect`]'s clamp of this against the panel it is drawn in, which -/// is why nothing downstream may assume 70: a 30% sidebar on an 80-column terminal -/// leaves the DJ panel 56 columns and the modal 54. -const MODAL_WIDTH: u16 = 70; - -pub fn draw_ai_dj(f: &mut Frame, app: &App, layout_chunk: Rect) { - // No legend under the picker: the modal swallows every key it names. - let mut hints = if app.dj.setup.is_some() { - Vec::new() - } else { - hint_lines(app, layout_chunk.width as usize) - }; - // The transcript's Min(3) wins the fight for rows on a short panel. - hints.truncate(layout_chunk.height.saturating_sub(PROMPT_HEIGHT + 3) as usize); - let chunks = Layout::default() - .direction(Direction::Vertical) - // Passed by value, not `.as_ref()`: with `palette` in the build (via the - // audio-viz colour gradients) there are two applicable `AsRef` impls and the - // call stops resolving. Matches how every other screen calls this. - .constraints([ - Constraint::Min(3), - Constraint::Length(hints.len() as u16), - Constraint::Length(PROMPT_HEIGHT), - ]) - .split(layout_chunk); - - draw_transcript(f, app, chunks[0]); - if !hints.is_empty() { - f.render_widget(Paragraph::new(hints), chunks[1]); - } - draw_prompt(f, app, chunks[2]); - // Painted last so it covers both. Sized against `layout_chunk`, not `f.area()`: - // AiDj is drawn inside `draw_main_layout`, so the sidebar and playbar stay on - // screen around the overlay. - if app.dj.setup.is_some() { - draw_setup(f, app, layout_chunk); - } -} - -/// The panel title, which is where the DJ's modes are surfaced. -/// -/// All of them matter enough to show: they change what the DJ does with every -/// request, and a mode you cannot see is a mode you forget you left on. -pub(crate) fn title(app: &App) -> String { - let mut title = String::from("AI DJ"); - let mut modes: Vec = Vec::new(); - // The brain is a mode like the others, and a more expensive one to leave set - // wrong: `claude -p` spends a subscription quota per turn. - if let Some(label) = crate::infra::dj::setup::active_label(&app.user_config.behavior) { - modes.push(label); - } - if app.dj.auto_queue { - modes.push("auto-queue on".to_string()); - } - if app.dj.avoid_library { - modes.push( - if app.dj.library_indexing { - "fresh only (indexing…)" - } else { - "fresh only" - } - .to_string(), - ); - } - if !modes.is_empty() { - title.push_str(" — "); - title.push_str(&modes.join(", ")); - } - title -} - -/// The keybind legend above the prompt. -/// -/// The DJ's action keys are Ctrl combinations nobody can guess, and the panel -/// title only names a mode once it is already on. Built from the user's bindings, -/// so a rebind never leaves the legend naming a key that does nothing — and an -/// action rebound to a bare character is left out entirely, because the handler -/// only honours these keys while they carry a modifier: on a typing surface the -/// character types itself. -fn hint_lines(app: &App, width: usize) -> Vec> { - let keys = &app.user_config.keys; - let mut parts: Vec = Vec::new(); - for (key, action) in [ - (keys.dj_toggle_auto_queue, "auto-queue"), - (keys.dj_vibe_shift, "vibe shift"), - (keys.dj_toggle_fresh_only, "fresh only"), - (keys.dj_pick_model, "AI/model"), - ] { - if !matches!(key, Key::Char(_)) { - parts.push(format!("{key} {action}")); - } - } - parts.push("↑↓ scroll".to_string()); - parts.push("Enter send".to_string()); - parts.push("Esc clear/back".to_string()); - - // Wrapped rather than truncated, the same way the picker treats its footer: on - // a narrow panel the tail is the half that would be lost. Packed by whole - // parts, not words — "" on one row and its "AI/model" on the next is a - // legend that reads as two broken entries. - let width = width.max(1); - let mut rows: Vec = Vec::new(); - let mut current = String::new(); - let mut current_width = 0usize; - for part in parts { - let part_width: usize = part.chars().map(|c| c.width().unwrap_or(0)).sum(); - if part_width > width { - // A part wider than the panel cannot stay atomic; break it like a long word. - if !current.is_empty() { - rows.push(std::mem::take(&mut current)); - current_width = 0; - } - rows.extend(wrap_text(&part, width)); - continue; - } - let sep_width = if current.is_empty() { 0 } else { 3 }; - if current_width + sep_width + part_width > width && !current.is_empty() { - rows.push(std::mem::take(&mut current)); - current_width = 0; - } - if !current.is_empty() { - current.push_str(" · "); - current_width += 3; - } - current.push_str(&part); - current_width += part_width; - } - if !current.is_empty() { - rows.push(current); - } - - let style = Style::default().fg(app.user_config.theme.hint.into()); - rows - .into_iter() - .map(|line| Line::from(Span::styled(line, style))) - .collect() -} - -fn draw_transcript(f: &mut Frame, app: &App, area: Rect) { - let highlight_state = ( - app.get_current_route().active_block == ActiveBlock::AiDj, - app.get_current_route().hovered_block == ActiveBlock::AiDj, - ); - let block = Block::default() - .title(Span::styled( - title(app), - get_color(highlight_state, app.user_config.theme), - )) - .borders(Borders::ALL) - .border_style(get_color(highlight_state, app.user_config.theme)); - - let inner = block.inner(area); - f.render_widget(block, area); - if inner.width == 0 || inner.height == 0 { - return; - } - - let rows = wrap_transcript(app, inner.width as usize); - if rows.is_empty() { - f.render_widget(Paragraph::new(empty_hint(app)), inner); - return; - } - - // Window the wrapped rows. `scroll` counts from the bottom so the newest lines - // stay visible as the conversation grows, which is what a chat should do. - let visible = inner.height as usize; - let max_offset = rows.len().saturating_sub(visible); - let offset = max_offset.saturating_sub(app.dj.scroll as usize); - let end = (offset + visible).min(rows.len()); - let window = rows[offset..end].to_vec(); - - f.render_widget(Paragraph::new(window), inner); -} - -fn empty_hint(app: &App) -> Vec> { - let theme = app.user_config.theme; - let mut lines = vec![ - Line::from(Span::styled( - "Ask the DJ for something, or turn auto-queue on.", - Style::default().fg(theme.text.into()), - )), - Line::from(""), - Line::from(Span::styled( - "\"something chill for focusing\"", - Style::default().fg(theme.inactive.into()), - )), - Line::from(Span::styled( - "\"more like this but slower\"", - Style::default().fg(theme.inactive.into()), - )), - Line::from(""), - ]; - - // Which brain is in use is the setting that costs money or quota per turn, so - // both the answer and the way to change it belong on the screen that spends it. - // Two lines rather than one sentence: together they overflow a narrow panel, and - // these are rendered unwrapped. - if let Some(label) = crate::infra::dj::setup::active_label(&app.user_config.behavior) { - lines.push(Line::from(Span::styled( - format!("Using {label}."), - Style::default().fg(theme.inactive.into()), - ))); - } - lines.push(Line::from(Span::styled( - format!( - "{} to change which AI and model the DJ uses.", - app.user_config.keys.dj_pick_model - ), - Style::default().fg(theme.inactive.into()), - ))); - lines -} - -/// An upper bound on how far back the transcript can usefully be scrolled. -/// -/// The exact bound needs the rendered width, which the handler does not have, so -/// this wraps at a deliberately narrow one: narrower produces *more* rows, so the -/// answer is never below the true maximum and `draw_transcript`'s own clamp still -/// picks the final offset. What it buys is a bound proportional to the -/// conversation rather than none at all — unbounded, holding the scroll key on a -/// three-line transcript takes hundreds of presses before anything moves back. -pub(crate) fn max_scroll_bound(app: &App) -> u16 { - const NARROWEST_PLAUSIBLE_WIDTH: usize = 24; - u16::try_from(wrap_transcript(app, NARROWEST_PLAUSIBLE_WIDTH).len()).unwrap_or(u16::MAX) -} - -/// Turn the transcript into display rows, hard-wrapped to `width`. -/// -/// Public for the tests, which assert that scrolling and wrapping stay in the -/// same unit. -pub(crate) fn wrap_transcript(app: &App, width: usize) -> Vec> { - let theme = app.user_config.theme; - let mut rows: Vec> = Vec::new(); - - for line in &app.dj.transcript { - let (prefix, style) = match line.speaker { - DjSpeaker::User => ("you ", Style::default().fg(theme.active.into())), - DjSpeaker::Dj => ("dj ", Style::default().fg(theme.text.into())), - DjSpeaker::System => (" ", Style::default().fg(theme.inactive.into())), - }; - let body_width = width.saturating_sub(prefix.len()).max(1); - for (index, chunk) in wrap_text(&line.text, body_width).into_iter().enumerate() { - // Only the first row of a message carries the speaker label; continuation - // rows are indented to line up under it. - let label = if index == 0 { - prefix.to_string() - } else { - " ".repeat(prefix.len()) - }; - rows.push(Line::from(vec![ - Span::styled(label, Style::default().fg(theme.inactive.into())), - Span::styled(chunk, style), - ])); - } - } - - if app.dj.thinking { - // The step counter is what tells a minutes-long agent-CLI turn apart from a - // hang: each step there is a fresh subprocess. - let label = match app.dj.step { - Some((step, of)) => format!("dj …thinking ({step}/{of})"), - None => "dj …thinking".to_string(), - }; - rows.push(Line::from(Span::styled( - label, - Style::default() - .fg(theme.inactive.into()) - .add_modifier(Modifier::ITALIC), - ))); - } - rows -} - -/// Wrap on word boundaries, falling back to a hard break for a word longer than -/// the line. Uses display width, so CJK and emoji do not overflow. -/// Rendered columns, which is what every layout decision in this file needs. -fn display_width(text: &str) -> usize { - text.chars().map(|c| c.width().unwrap_or(0)).sum() -} - -fn wrap_text(text: &str, width: usize) -> Vec { - if width == 0 { - return vec![text.to_string()]; - } - let mut lines = Vec::new(); - let mut current = String::new(); - let mut current_width = 0usize; - - for word in text.split_whitespace() { - let word_width: usize = word.chars().map(|c| c.width().unwrap_or(0)).sum(); - let space = if current.is_empty() { 0 } else { 1 }; - if current_width + space + word_width > width && !current.is_empty() { - lines.push(std::mem::take(&mut current)); - current_width = 0; - } - if word_width > width { - // A single word longer than the line: break it by display width. - if !current.is_empty() { - lines.push(std::mem::take(&mut current)); - current_width = 0; - } - let mut piece = String::new(); - let mut piece_width = 0usize; - for ch in word.chars() { - let ch_width = ch.width().unwrap_or(0); - if piece_width + ch_width > width && !piece.is_empty() { - lines.push(std::mem::take(&mut piece)); - piece_width = 0; - } - piece.push(ch); - piece_width += ch_width; - } - if !piece.is_empty() { - current = piece; - current_width = piece_width; - } - continue; - } - if !current.is_empty() { - current.push(' '); - current_width += 1; - } - current.push_str(word); - current_width += word_width; - } - if !current.is_empty() { - lines.push(current); - } - if lines.is_empty() { - lines.push(String::new()); - } - lines -} - -/// One picker row: `"› 1. claude (no API key) uses your Claude Pro/Max plan"`. -/// -/// Only the first nine rows are numbered, because only those have a digit shortcut — -/// a "10." that does nothing would be a promise the keymap does not keep. -/// -/// `inner_width` is the modal's real inside width, measured from the rect -/// [`centered_modal_rect`] returned rather than from the width it was asked for: -/// the two differ on any panel narrower than 72 columns, and the whole decision -/// below is "does this fit". The note says what the row costs — "uses your Claude -/// Pro/Max plan", "$5/$25 per MTok" — so it is never the half that gets dropped: -/// it moves to a line of its own, wrapped, rather than being truncated. -fn option_lines( - app: &App, - index: usize, - cursor: usize, - label: &str, - note: &str, - ready: bool, - inner_width: usize, -) -> Vec> { - let theme = app.user_config.theme; - let selected = index == cursor; - let number = if index < 9 { - format!("{}. ", index + 1) - } else { - " ".to_string() - }; - let style = if selected { - Style::default() - .fg(theme.active.into()) - .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)) - } else if ready { - Style::default().fg(theme.text.into()) - } else { - // Dimmed but still selectable: "I am about to install it" is a real answer. - Style::default().fg(theme.inactive.into()) - }; - let note_style = Style::default().fg(theme.inactive.into()); - // Pad the label out to the note column so the notes line up down the list, but - // never past the modal's own edge: on a narrow modal the padding is what would - // push the label itself into truncation. - let gutter = 2 + number.chars().count(); - let pad = NOTE_COLUMN.min(inner_width.saturating_sub(gutter + 1)); - let head = format!( - "{}{number}{label: ( - "Set up the AI DJ", - setup - .backends - .iter() - .map(|row| (row.label.as_str(), row.note.as_str(), row.ready)) - .collect::>(), - setup.backend_index, - ), - DjSetupStep::Model => ( - "Choose a model", - setup - .models - .iter() - .map(|row| (row.label.as_str(), row.note.as_str(), true)) - .collect(), - setup.model_index, - ), - // A typing surface, not a list: one row, and nothing to scroll through. - DjSetupStep::Custom => ("Model name", Vec::new(), 0), - }; - - // Rows, plus where each one starts and ends. The viewport below scrolls by whole - // rows, which it can only do if it knows which lines belong to which row. - let mut rows: Vec> = Vec::new(); - let mut row_spans: Vec<(usize, usize)> = Vec::new(); - for (index, (label, note, ready)) in entries.iter().enumerate() { - let start = rows.len(); - rows.extend(option_lines( - app, - index, - cursor, - label, - note, - *ready, - inner_width, - )); - row_spans.push((start, rows.len())); - } - if setup.step == DjSetupStep::Custom { - rows.push(Line::from(vec![ - Span::styled( - format!(" {}", setup.custom), - Style::default().fg(theme.text.into()), - ), - // A painted caret, the same idiom `draw_prompt` uses: the hardware cursor is - // owned by the search box. - Span::styled("▏", Style::default().fg(theme.active.into())), - ])); - row_spans.push((0, rows.len())); - } - - let mut hints = vec![match setup.step { - // Esc here keeps whatever is already configured, and says so: a modal whose - // escape hatch silently rewrote config would be worse than no modal. - DjSetupStep::Backend => { - "↑↓ select · 1-9 jump · Enter choose · Esc keep the current brain".to_string() - } - // Built from the rows rather than fixed, because the model step is not one - // shape. A command spotatui does not own gets a single row and deliberately no - // free-text row (`models_for`), so a hardcoded "last row types a name" would - // point at a row that is not there, and "↑↓ select" at a list of one. - DjSetupStep::Model => { - let mut parts = Vec::new(); - if setup.models.len() > 1 { - parts.push("↑↓ select"); - } - parts.push("Enter choose"); - parts.push("Esc back"); - if setup.models.last().is_some_and(|model| model.custom) { - parts.push("last row types a name"); - } - parts.join(" · ") - } - DjSetupStep::Custom => "Enter to use it · Esc back".to_string(), - }]; - if setup.step == DjSetupStep::Custom { - if let Some(row) = setup.selected_backend() { - // The bare name, not the row's label: "for claude (no API key)" reads as if the - // key were the subject when the subject is the model being typed. - let name = row.agent.unwrap_or(row.backend); - hints.push(match (row.backend, row.agent) { - // The picker deliberately does not edit the URL, so the one thing a - // self-hosted user still has to do belongs on this screen. - ("openai_compat", _) => format!("for {name} · set behavior.dj_base_url for the endpoint"), - // The Custom… row carries this too, but that row is gone by the time - // anyone is typing here, which is when the format actually matters. - (_, Some("opencode")) => format!("for {name} · as provider/model"), - _ => format!("for {name}"), - }); - } - } - // Wrapped for the same reason a note is: the hint is the modal's only statement - // of what Esc does, and "Esc keep the c" is not that statement. - let mut footer: Vec> = hints - .iter() - .flat_map(|hint| wrap_text(hint, inner_width.max(1))) - .map(|line| Line::from(Span::styled(line, hint_style))) - .collect(); - - // Rows, a blank separator, the footer, and the two borders. The separator is - // asked for rather than emitted, so it is the first thing a short panel drops. - let requested_height = u16::try_from(rows.len() + footer.len() + 3).unwrap_or(u16::MAX); - let rect = centered_modal_rect(layout_chunk, MODAL_WIDTH, requested_height); - f.render_widget(Clear, rect); - let block = Block::default() - .title(Span::styled( - title, - Style::default() - .fg(theme.header.into()) - .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), - )) - .borders(Borders::ALL) - .style(theme.base_style()) - .border_style(Style::default().fg(theme.active.into())); - let inner = block.inner(rect); - f.render_widget(block, rect); - if inner.width == 0 || inner.height == 0 { - return; - } - - // `centered_modal_rect` clamps the height it was asked for, so the list does not - // always fit. The footer is pinned to the bottom rather than scrolling with the - // rows: "Esc keeps the current brain" is exactly what a user who cannot see the - // whole list needs to read, and a footer that scrolls away is gone when it - // matters. The rows still win a fight for the last line, because a modal with no - // visible row is no modal at all. - let inner_height = inner.height as usize; - let mut footer_height = footer.len().min(inner_height.saturating_sub(1)); - footer.truncate(footer_height); - let mut list_height = inner_height - footer_height; - let clipped = rows.len() > list_height && list_height > 1; - if clipped { - // Spend one of the list's own rows saying there is more of it, so a user whose - // cursor walks off the bottom knows it is scrolling rather than stuck. - list_height -= 1; - footer_height += 1; - } - - // Scroll just far enough to bring the selected row's last line into view, and - // never past its first: on a row taller than the window, the head is the half - // that carries the label and the highlight. - let (cursor_start, cursor_end) = row_spans.get(cursor).copied().unwrap_or((0, 0)); - let offset = cursor_end.saturating_sub(list_height).min(cursor_start); - let end = (offset + list_height).min(rows.len()); - if clipped { - let above = row_spans - .iter() - .filter(|(start, _)| *start < offset) - .count(); - let below = row_spans.iter().filter(|(_, stop)| *stop > end).count(); - footer.insert( - 0, - Line::from(Span::styled( - match (above, below) { - (0, below) => format!("… {below} more below"), - (above, 0) => format!("… {above} more above"), - (above, below) => format!("… {above} above, {below} below"), - }, - hint_style, - )), - ); - } - - // Windowed rather than `Paragraph::scroll`, for the reason this module's header - // gives: offset and rendered rows stay the same unit. - f.render_widget( - Paragraph::new(rows[offset..end].to_vec()), - Rect { - height: list_height as u16, - ..inner - }, - ); - if footer_height > 0 { - f.render_widget( - Paragraph::new(footer), - Rect { - y: inner.y + list_height as u16, - height: footer_height as u16, - ..inner - }, - ); - } -} - -fn draw_prompt(f: &mut Frame, app: &App, area: Rect) { - let focused = app.get_current_route().active_block == ActiveBlock::AiDj; - let theme = app.user_config.theme; - let block = Block::default() - .title(Span::styled( - "Ask the DJ", - get_color((focused, false), theme), - )) - .borders(Borders::ALL) - .border_style(get_color((focused, false), theme)); - - let inner = block.inner(area); - f.render_widget(block, area); - if inner.width == 0 { - return; - } - - let typed: String = app.dj.input.iter().collect(); - // Keep the caret in view on a prompt longer than the box, the same way the - // search input does. - let cursor = app.dj.input_cursor as usize; - let scroll = cursor.saturating_sub(inner.width.saturating_sub(1) as usize); - // Dropped by display width, not by character: `cursor` counts columns, and - // `skip(scroll)` counted characters. Twenty CJK glyphs in a twenty-column box - // put `scroll` past the end of a twenty-character string, so the prompt went - // blank while the listener kept typing. - let mut dropped = 0usize; - let visible: String = typed - .chars() - .skip_while(|c| { - if dropped >= scroll { - return false; - } - dropped += c.width().unwrap_or(0); - true - }) - .collect(); - - let mut spans = vec![Span::styled( - visible, - Style::default().fg(theme.text.into()), - )]; - if focused { - // A painted caret: the hardware cursor is owned by the search box. - spans.push(Span::styled("▏", Style::default().fg(theme.active.into()))); - } - f.render_widget(Paragraph::new(Line::from(spans)), inner); -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::app::RouteId; - use crate::core::geometry::Viewport; - use crate::infra::dj::DjLine; - use ratatui::backend::TestBackend; - use ratatui::Terminal; - - fn app_with(lines: Vec, width: u16, height: u16) -> App { - let mut app = App::default(); - app.view.size = Viewport { width, height }; - app.dj.transcript = lines; - app.push_navigation_stack(RouteId::AiDj, ActiveBlock::AiDj); - app - } - - fn render(app: &App, width: u16, height: u16) -> Vec { - render_in(app, width, height, Rect::new(0, 0, width, height)) - } - - /// Render into a `chunk` that is smaller than the terminal, the way the real - /// layout does: the sidebar takes columns off the left and the prompt plus the - /// playbar take rows off the bottom. The picker is sized against that chunk, so - /// a modal that only fits "the terminal" is not a modal that fits. - fn render_in(app: &App, width: u16, height: u16, chunk: Rect) -> Vec { - let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); - terminal.draw(|f| draw_ai_dj(f, app, chunk)).unwrap(); - let buffer = terminal.backend().buffer().clone(); - (0..height) - .map(|y| { - (0..width) - .map(|x| buffer.cell((x, y)).map_or(" ", |c| c.symbol()).to_string()) - .collect::() - }) - .collect() - } - - #[test] - fn wrapping_produces_more_rows_than_messages_for_long_text() { - // The core reason we wrap ourselves: rows != messages, so a scroll offset - // clamped against message count would be wrong. - let long = "word ".repeat(40); - let app = app_with(vec![DjLine::dj(long)], 40, 20); - let rows = wrap_transcript(&app, 30); - assert!(rows.len() > 1, "a long message must occupy several rows"); - } - - #[test] - fn only_the_first_row_of_a_message_is_labelled() { - let app = app_with(vec![DjLine::user("alpha beta gamma delta epsilon")], 40, 20); - let rows = wrap_transcript(&app, 20); - assert!(rows.len() >= 2); - let first: String = rows[0].spans.iter().map(|s| s.content.clone()).collect(); - let second: String = rows[1].spans.iter().map(|s| s.content.clone()).collect(); - assert!(first.starts_with("you")); - assert!( - second.starts_with(" "), - "continuation rows indent under the label, got {second:?}" - ); - } - - #[test] - fn a_word_longer_than_the_line_is_broken_rather_than_overflowing() { - let rows = wrap_text(&"x".repeat(50), 10); - assert!(rows.len() >= 5); - assert!(rows.iter().all(|row| row.chars().count() <= 10)); - } - - #[test] - fn wide_characters_are_measured_by_display_width() { - // Each CJK glyph is two columns wide, so eight of them cannot share a - // ten-column line with much else. - let rows = wrap_text("東京東京東京東京 tail", 10); - assert!(rows.len() >= 2, "got {rows:?}"); - } - - #[test] - fn the_newest_lines_are_visible_by_default() { - let lines: Vec<_> = (0..40).map(|i| DjLine::dj(format!("line {i}"))).collect(); - let app = app_with(lines, 40, 12); - let rendered = render(&app, 40, 12).join("\n"); - assert!(rendered.contains("line 39"), "{rendered}"); - assert!(!rendered.contains("line 0 "), "{rendered}"); - } - - #[test] - fn scrolling_back_reveals_older_lines() { - let lines: Vec<_> = (0..40).map(|i| DjLine::dj(format!("line {i}"))).collect(); - let mut app = app_with(lines, 40, 12); - app.dj.scroll = 20; - let rendered = render(&app, 40, 12).join("\n"); - assert!(rendered.contains("line 1"), "{rendered}"); - assert!(!rendered.contains("line 39"), "{rendered}"); - } - - #[test] - fn the_title_shows_both_modes_and_the_indexing_wait() { - let mut app = app_with(vec![], 40, 12); - - app.dj.avoid_library = true; - app.dj.library_indexing = true; - assert!( - title(&app).contains("indexing"), - "the first crawl takes seconds; silence looks like a hang" - ); - - app.dj.library_indexing = false; - assert!(title(&app).contains("fresh only")); - - app.dj.auto_queue = true; - let both = title(&app); - assert!(both.contains("auto-queue on") && both.contains("fresh only")); - } - - #[test] - fn the_title_names_the_active_backend() { - // The brain is the mode that costs money or quota per turn, so it is the one - // that must never be invisible. `UserConfig::new()` ships `["claude", "-p"]`. - let mut app = app_with(vec![], 40, 12); - assert_eq!(title(&app), "AI DJ — claude"); - - app.user_config.behavior.dj_agent_model = Some("haiku".to_string()); - assert_eq!(title(&app), "AI DJ — claude/haiku"); - - app.dj.avoid_library = true; - assert_eq!(title(&app), "AI DJ — claude/haiku, fresh only"); - } - - #[test] - fn an_empty_transcript_shows_a_hint() { - // 14 rather than 12 rows: the legend above the prompt takes two at this width, - // and this test is about the transcript hint underneath it. - let app = app_with(vec![], 70, 14); - let rendered = render(&app, 70, 14).join("\n"); - assert!(rendered.contains("Ask the DJ"), "{rendered}"); - // "which AI is it using" has to be answerable without pressing anything. - assert!(rendered.contains("Using claude"), "{rendered}"); - // Built from the binding rather than hardcoded, so a rebind does not silently - // leave the hint naming a key that does nothing. - assert!( - rendered.contains(&format!( - "{} to change which AI", - app.user_config.keys.dj_pick_model - )), - "{rendered}" - ); - } - - #[test] - fn the_keybind_legend_sits_above_the_prompt_and_follows_rebinds() { - let mut app = app_with(vec![DjLine::dj("hello")], 80, 24); - let rendered = render(&app, 80, 24).join("\n"); - let keys = app.user_config.keys.clone(); - for (key, action) in [ - (keys.dj_toggle_auto_queue, "auto-queue"), - (keys.dj_vibe_shift, "vibe shift"), - (keys.dj_toggle_fresh_only, "fresh only"), - (keys.dj_pick_model, "AI/model"), - ] { - assert!(rendered.contains(&format!("{key} {action}")), "{rendered}"); - } - assert!(rendered.contains("Enter send"), "{rendered}"); - - app.user_config.keys.dj_vibe_shift = Key::Ctrl('r'); - let rendered = render(&app, 80, 24).join("\n"); - assert!(rendered.contains(" vibe shift"), "{rendered}"); - assert!(!rendered.contains(" vibe shift"), "{rendered}"); - } - - #[test] - fn an_action_rebound_to_a_bare_character_is_left_out_of_the_legend() { - // The handler only honours these keys while they carry a modifier; a bare 'x' - // types itself. A legend entry for it would name a key that does nothing. - let mut app = app_with(vec![DjLine::dj("hello")], 80, 24); - app.user_config.keys.dj_toggle_fresh_only = Key::Char('x'); - let rendered = render(&app, 80, 24).join("\n"); - assert!(!rendered.contains("fresh only"), "{rendered}"); - assert!( - rendered.contains("auto-queue"), - "the others stay: {rendered}" - ); - } - - #[test] - fn the_legend_hides_while_the_picker_is_open() { - // The modal swallows every key the legend names, so showing it underneath - // would be a row of promises none of which hold. - let mut app = app_with_picker(80, 24); - let rendered = render(&app, 80, 24).join("\n"); - assert!(!rendered.contains("auto-queue"), "{rendered}"); - - app.dj.close_setup(); - let rendered = render(&app, 80, 24).join("\n"); - assert!( - rendered.contains("auto-queue"), - "and returns after: {rendered}" - ); - } - - #[test] - fn a_narrow_panel_wraps_the_legend_rather_than_truncating_it() { - let app = app_with(vec![DjLine::dj("hi")], 46, 20); - let rendered = render(&app, 46, 20).join("\n"); - // The tail is the half a truncating panel loses. - assert!(rendered.contains("Esc clear/back"), "{rendered}"); - // And each key stays on the same row as its label: the legend packs whole - // entries, so a wrap never leaves "" pointing at nothing. - assert!( - rendered.contains(&format!( - "{} fresh only", - app.user_config.keys.dj_toggle_fresh_only - )), - "{rendered}" - ); - } - - /// The DJ screen with the picker open on its first step. - fn app_with_picker(width: u16, height: u16) -> App { - let mut app = app_with(vec![DjLine::dj("older conversation")], width, height); - app.dj.setup = Some(crate::infra::dj::setup::DjSetup::new( - &app.user_config.behavior, - )); - app - } - - #[test] - fn the_picker_paints_over_the_transcript() { - // Enough transcript to occupy every row of the panel, so "the modal covers what - // is behind it" is actually observable rather than accidentally true. - let lines: Vec<_> = (0..40).map(|_| DjLine::dj("haystack")).collect(); - let mut app = app_with(lines, 80, 24); - let bare = render(&app, 80, 24) - .iter() - .filter(|row| row.contains("haystack")) - .count(); - - app.dj.setup = Some(crate::infra::dj::setup::DjSetup::new( - &app.user_config.behavior, - )); - let rows = render(&app, 80, 24); - let rendered = rows.join("\n"); - assert!(rendered.contains("Set up the AI DJ"), "{rendered}"); - // The row for the shipped default, and what it bills against — the whole reason - // the picker exists rather than a bare model field. - assert!(rendered.contains("claude"), "{rendered}"); - assert!(rendered.contains("Pro/Max"), "{rendered}"); - let covered = rows.iter().filter(|row| row.contains("haystack")).count(); - assert!( - covered < bare, - "the modal must clear the rows it covers: {rendered}" - ); - } - - #[test] - fn the_model_step_shows_the_price_next_to_each_model() { - let mut app = app_with_picker(80, 24); - { - let setup = app.dj.setup.as_mut().unwrap(); - setup.backend_index = setup - .backends - .iter() - .position(|row| row.backend == "anthropic") - .unwrap(); - } - let behavior = app.user_config.behavior.clone(); - app.dj.setup.as_mut().unwrap().enter_model_step(&behavior); - - let rendered = render(&app, 80, 24).join("\n"); - assert!(rendered.contains("Choose a model"), "{rendered}"); - assert!(rendered.contains("claude-haiku-4-5"), "{rendered}"); - // A model list without prices is as opaque as the config field it replaces. - assert!(rendered.contains("$1/$5"), "{rendered}"); - assert!(rendered.contains("Custom"), "{rendered}"); - } - - #[test] - fn the_model_step_hint_does_not_promise_a_row_that_is_not_there() { - // The one model step that is not a list: a command spotatui does not own gets a - // single row and deliberately no free-text row, because a model named there - // would never reach the CLI. A fixed hint would send the user arrowing down to - // a "Custom…" row that does not exist, on the one screen whose entire job is to - // stop the picker misreporting what is configured. - let mut app = app_with_picker(80, 24); - app.user_config.behavior.dj_agent_command = - vec!["my-agent".to_string(), "--headless".to_string()]; - let behavior = app.user_config.behavior.clone(); - { - let setup = app.dj.setup.as_mut().unwrap(); - *setup = crate::infra::dj::setup::DjSetup::new(&behavior); - setup.enter_model_step(&behavior); - } - - let rendered = render(&app, 80, 24).join("\n"); - assert!(rendered.contains("Keep this command"), "{rendered}"); - assert!(!rendered.contains("types a name"), "{rendered}"); - // Nor "↑↓ select" over a list of one. Esc must still be offered: it is the only - // way back to the backend step. - assert!(!rendered.contains("↑↓"), "{rendered}"); - assert!(rendered.contains("Esc back"), "{rendered}"); - - // The ordinary case still gets the full hint, so this is a branch rather than a - // hint that quietly went missing for everybody. - let listed = app_with_picker(80, 24); - let behavior = listed.user_config.behavior.clone(); - let mut listed = listed; - listed - .dj - .setup - .as_mut() - .unwrap() - .enter_model_step(&behavior); - let rendered = render(&listed, 80, 24).join("\n"); - assert!(rendered.contains("types a name"), "{rendered}"); - } - - #[test] - fn the_custom_step_shows_the_prefilled_value() { - let mut app = app_with_picker(80, 24); - app.user_config.behavior.dj_agent_model = Some("sonnet".to_string()); - let behavior = app.user_config.behavior.clone(); - { - let setup = app.dj.setup.as_mut().unwrap(); - setup.enter_model_step(&behavior); - setup.enter_custom_step(&behavior); - } - let rendered = render(&app, 80, 24).join("\n"); - assert!(rendered.contains("Model name"), "{rendered}"); - assert!(rendered.contains("sonnet"), "{rendered}"); - // The caret is painted here too: the hardware cursor belongs to the search box. - assert!(rendered.contains('▏'), "{rendered}"); - } - - #[test] - fn a_narrow_panel_keeps_the_whole_cost_note() { - // A geometry the app really produces: an 80-column terminal with - // `behavior.sidebar_width_percentage: 30` (supported, clamped 0-50) leaves the - // content panel 56 columns, which `centered_modal_rect` turns into a 54-column - // modal with 52 columns inside — sixteen fewer than the 70 the picker asks for. - let mut app = app_with_picker(80, 22); - { - let setup = app.dj.setup.as_mut().unwrap(); - setup.backends.retain(|row| row.agent == Some("claude")); - let row = setup - .backends - .first_mut() - .expect("claude is a recommended agent and is always offered"); - // Pinned rather than detected: `detect_backends` appends " · not on PATH" on a - // machine without the CLI installed, and a note whose length depends on the - // machine cannot pin a wrapping decision. This is the note the row carries - // wherever `claude` is present. - row.note = "uses your Claude Pro/Max plan".to_string(); - row.ready = true; - setup.backend_index = 0; - } - let rendered = render_in(&app, 80, 22, Rect::new(24, 0, 56, 22)).join("\n"); - assert!(rendered.contains("Set up the AI DJ"), "{rendered}"); - // The note is the half of the row that says what it costs. On one line it is - // 60 columns starting at column 0 of a 52-column inside, so it only survives - // if the wrap decision is made against the rect that was actually returned. - assert!( - rendered.contains("uses your Claude Pro/Max plan"), - "the cost note must survive a narrow panel: {rendered}" - ); - // The hint is the modal's only statement of what Esc does, so it wraps onto a - // second line here rather than stopping at "Esc keep the c". Asserted on the - // tail, which is the half a truncating modal loses. - assert!( - rendered.contains("current brain"), - "the hint must survive a narrow panel: {rendered}" - ); - } - - #[test] - fn a_row_never_drops_its_note_however_narrow_the_modal_is() { - // The note is what the row costs, so it is the one part that may not be lost to - // truncation. Asserted against widths passed in rather than against whatever the - // terminal happens to be: the bug this replaces was a constant that disagreed - // with the rect. - let app = App::default(); - let label = "openai_compat (local or self-hosted)"; - let note = "any OpenAI-compatible endpoint, e.g. Ollama on localhost"; - for inner_width in [68usize, 52, 40, 30] { - let lines = option_lines(&app, 0, 0, label, note, true, inner_width); - let flat: String = lines - .iter() - .flat_map(|line| line.spans.iter()) - .map(|span| span.content.to_string()) - .collect::>() - .join(" "); - for word in note.split_whitespace() { - assert!( - flat.contains(word), - "{word:?} was dropped at inner width {inner_width}: {lines:?}" - ); - } - // The head can be wider than a very narrow modal — a 36-column label is 36 - // columns whatever the padding does — but the lines the note was moved to - // exist precisely so it fits, so those must. - for line in lines.iter().skip(1) { - let width: usize = line - .spans - .iter() - .flat_map(|span| span.content.chars()) - .map(|c| c.width().unwrap_or(0)) - .sum(); - assert!( - width <= inner_width, - "a note line of {width} columns in a {inner_width}-column modal: {line:?}" - ); - } - } - } - - #[test] - fn the_note_column_never_pads_a_row_past_the_modal_edge() { - // The padding that lines the notes up is itself capable of pushing a short label - // off the right edge of a narrow modal, so it yields before the label does. - let app = App::default(); - let lines = option_lines(&app, 0, 0, "agy", "cheapest", true, 20); - let head: String = lines[0].spans.iter().map(|s| s.content.clone()).collect(); - assert!( - head.chars().count() <= 20, - "the head is padded past a 20-column modal: {head:?}" - ); - - // And it yields only where it must: on a modal with room for the note column, - // the notes still line up at it. Without this the clamp above could be - // satisfied by dropping the alignment altogether. - let wide = option_lines(&app, 0, 0, "agy", "cheapest", true, 68); - let head: String = wide[0].spans[0].content.to_string(); - assert_eq!( - head.chars().count(), - // Two gutter columns, "1. ", the padded label, one space. - 2 + 3 + NOTE_COLUMN + 1, - "notes must still line up on a modal that fits them: {head:?}" - ); - } - - #[test] - fn the_selected_row_stays_on_screen_when_the_list_is_taller_than_the_modal() { - let mut app = app_with_picker(80, 22); - let behavior = app.user_config.behavior.clone(); - { - let setup = app.dj.setup.as_mut().unwrap(); - // `agy` has the longest model list, and it is offered whether or not the - // binary is installed, so the row count here does not depend on the machine. - setup.backend_index = setup - .backends - .iter() - .position(|row| row.agent == Some("agy")) - .expect("agy is a recommended agent and is always offered"); - setup.enter_model_step(&behavior); - // The last row, read off the list rather than counted: rows get added to - // `models_for` and a hard-coded index would quietly stop being the last one. - setup.model_index = setup.models.len() - 1; - } - let expected = app - .dj - .setup - .as_ref() - .unwrap() - .models - .last() - .unwrap() - .label - .clone(); - - // 22 rows, less the 3-row prompt and the 6-row playbar, leaves the DJ panel 13. - let rows = render_in(&app, 80, 22, Rect::new(0, 0, 80, 13)); - let cursor_row = rows - .iter() - .find(|row| row.contains('›')) - .unwrap_or_else(|| panic!("the highlighted row was never drawn:\n{}", rows.join("\n"))); - assert!( - cursor_row.contains(&expected), - "the cursor is on {expected:?} but the visible highlight is {cursor_row:?}:\n{}", - rows.join("\n") - ); - - let rendered = rows.join("\n"); - // Scrolled past rows are still accounted for, and the hint is pinned rather than - // scrolled off: a list the user cannot see the whole of is exactly when "Esc - // back" needs to be readable. - assert!( - rendered.contains("more above"), - "hidden rows must be announced: {rendered}" - ); - assert!( - rendered.contains("Esc back"), - "the hint must survive scrolling: {rendered}" - ); - } - - #[test] - fn thinking_is_shown_while_a_turn_is_in_flight() { - let mut app = app_with(vec![DjLine::user("chill")], 40, 12); - app.dj.thinking = true; - let rows = wrap_transcript(&app, 30); - let last: String = rows - .last() - .unwrap() - .spans - .iter() - .map(|s| s.content.clone()) - .collect(); - assert!(last.contains("thinking"), "{last}"); - } - - #[test] - fn the_step_counter_shows_progress_through_a_multi_step_turn() { - // Each agent-CLI step is a fresh subprocess, so a four-step turn is minutes - // of silence; without the counter that is indistinguishable from a hang. - let mut app = app_with(vec![DjLine::user("chill")], 40, 12); - app.dj.thinking = true; - app.dj.step = Some((2, 4)); - let rendered = render(&app, 40, 12).join("\n"); - assert!(rendered.contains("(2/4)"), "{rendered}"); - } - - #[test] - fn the_prompt_shows_typed_text_and_a_painted_caret() { - let mut app = app_with(vec![], 40, 12); - app.dj.input = "mellow".chars().collect(); - app.dj.input_cursor = 6; - let rendered = render(&app, 40, 12).join("\n"); - assert!(rendered.contains("mellow"), "{rendered}"); - // The caret is drawn, not delegated to the terminal cursor. - assert!(rendered.contains('▏'), "{rendered}"); - } - - #[test] - fn a_long_prompt_scrolls_to_keep_the_caret_visible() { - let mut app = app_with(vec![], 20, 12); - let typed = "abcdefghijklmnopqrstuvwxyz"; - app.dj.input = typed.chars().collect(); - app.dj.input_cursor = typed.len() as u16; - let rendered = render(&app, 20, 12).join("\n"); - // The tail is visible; the head has scrolled off. - assert!(rendered.contains("xyz"), "{rendered}"); - assert!(!rendered.contains("abcdef"), "{rendered}"); - } - - #[test] - fn a_zero_width_area_does_not_panic() { - let app = app_with(vec![DjLine::dj("anything")], 1, 3); - let _ = render(&app, 1, 3); - } - - #[test] - fn a_panel_far_too_small_for_the_picker_does_not_panic() { - // The viewport maths slices the row buffer, so every degenerate size has to be - // a size that produces an empty window rather than an out-of-range range. - for (width, height) in [(20u16, 6u16), (10, 4), (4, 3), (1, 1)] { - let mut app = app_with_picker(width.max(1), height.max(1)); - // Deepest cursor there is, so the scroll offset is at its largest where there - // is the least room for it. - let behavior = app.user_config.behavior.clone(); - { - let setup = app.dj.setup.as_mut().unwrap(); - setup.backend_index = setup.backends.len() - 1; - setup.enter_model_step(&behavior); - setup.model_index = setup.models.len() - 1; - } - let _ = render(&app, width, height); - } - } - - #[test] - fn the_title_reflects_auto_queue() { - let mut app = app_with(vec![], 40, 12); - app.dj.auto_queue = true; - let rendered = render(&app, 40, 12).join("\n"); - assert!(rendered.contains("auto-queue on"), "{rendered}"); - } -} diff --git a/src/tui/ui/artist.rs b/src/tui/ui/artist.rs deleted file mode 100644 index 3881c6ab..00000000 --- a/src/tui/ui/artist.rs +++ /dev/null @@ -1,107 +0,0 @@ -use crate::core::app::{App, ArtistBlock}; -use ratatui::{ - layout::{Constraint, Layout, Rect}, - Frame, -}; -use rspotify::model::PlayableItem; -use rspotify::prelude::Id; - -use super::util::{draw_selectable_list, get_artist_highlight_state, join_artist_names}; - -pub fn draw_artist_albums(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { - let [tracks_area, albums_area, related_artists_area] = - layout_chunk.layout(&Layout::horizontal([ - Constraint::Percentage(33), - Constraint::Percentage(33), - Constraint::Percentage(33), - ])); - - if let Some(artist) = &app.artist { - let top_tracks = artist - .top_tracks - .iter() - .map(|top_track| { - let mut name = String::new(); - if let Some(context) = &app.current_playback_context { - let track_id = match &context.item { - Some(PlayableItem::Track(track)) => track.id.as_ref().map(|id| id.id().to_string()), - Some(PlayableItem::Episode(episode)) => Some(episode.id.id().to_string()), - _ => None, - }; - - if track_id.is_some() && track_id == top_track.id { - name.push_str("▶ "); - } - }; - name.push_str(&top_track.name); - name - }) - .collect::>(); - - draw_selectable_list( - f, - app, - tracks_area, - &format!("{} - Top Tracks", artist.artist_name), - &top_tracks, - get_artist_highlight_state(app, ArtistBlock::TopTracks), - Some(artist.selected_top_track_index), - ); - - let albums = &artist - .albums - .items - .iter() - .map(|item| { - let mut album_artist = String::new(); - if let Some(album_id) = &item.id { - if app.saved_album_ids_set.contains(album_id.as_str()) { - album_artist.push_str(&app.user_config.padded_liked_icon()); - } - } - album_artist.push_str(&format!( - "{} - {} ({})", - item.name.to_owned(), - join_artist_names(&item.artists), - item.album_type.as_deref().unwrap_or("unknown") - )); - album_artist - }) - .collect::>(); - - draw_selectable_list( - f, - app, - albums_area, - "Albums", - albums, - get_artist_highlight_state(app, ArtistBlock::Albums), - Some(artist.selected_album_index), - ); - - let related_artists = artist - .related_artists - .iter() - .map(|item| { - let mut artist = String::new(); - if let Some(artist_id) = &item.id { - if app.followed_artist_ids_set.contains(artist_id.as_str()) { - artist.push_str(&app.user_config.padded_liked_icon()); - } - } - artist.push_str(&item.name.to_owned()); - artist - }) - .collect::>(); - - draw_selectable_list( - f, - app, - related_artists_area, - "Related artists", - &related_artists, - get_artist_highlight_state(app, ArtistBlock::RelatedArtists), - Some(artist.selected_related_artist_index), - ); - }; -} diff --git a/src/tui/ui/audio_analysis.rs b/src/tui/ui/audio_analysis.rs deleted file mode 100644 index 5a241739..00000000 --- a/src/tui/ui/audio_analysis.rs +++ /dev/null @@ -1,388 +0,0 @@ -use crate::core::app::App; -use crate::core::user_config::{normalize_tick_rate_milliseconds, VisualizerStyle}; -use crate::tui::layout::main_layout_margin; -use ratatui::{ - layout::{Constraint, Layout, Rect}, - style::{Color, Style}, - symbols::bar, - text::{Line, Span}, - widgets::{Block, Borders, Paragraph}, - Frame, -}; - -use tui_bar_graph::{BarGraph, BarStyle, ColorMode}; - -/// The analysis screen's two rows: the info strip and the visualizer block. -fn analysis_areas(root: Rect, margin: u16) -> [Rect; 2] { - root.layout(&Layout::vertical([Constraint::Length(3), Constraint::Min(10)]).margin(margin)) -} - -/// The visualizer's surrounding block, borders only. `draw` styles and titles -/// it; `visualizer_inner_width` needs it only to measure the border inset. -fn visualizer_block<'a>() -> Block<'a> { - Block::default().borders(Borders::ALL) -} - -/// Inner width of the visualizer block at the current terminal size. The -/// runner picks the analyzer's bar count before a frame exists, so it derives -/// the rect from `app.view.size` through here rather than re-deriving the margin -/// and border inset itself. -/// -/// This and `visualizer_bar_count` are only called behind the audio-viz -/// feature gates; slim builds still compile (and test) them. -#[allow(dead_code)] -pub fn visualizer_inner_width(app: &App) -> u16 { - let root = Rect::new(0, 0, app.view.size.width, app.view.size.height); - let [_, visualizer_area] = analysis_areas(root, main_layout_margin(app)); - visualizer_block().inner(visualizer_area).width -} - -pub fn draw(f: &mut Frame<'_>, app: &App) { - let margin = main_layout_margin(app); - - let [info_area, visualizer_area] = analysis_areas(f.area(), margin); - - let white = Style::default().fg(app.user_config.theme.text.into()); - let gray = Style::default().fg(app.user_config.theme.inactive.into()); - let tick_rate = app.user_config.behavior.animation_tick_rate_milliseconds; - let tick_rate = normalize_tick_rate_milliseconds(tick_rate as i64); - let visualizer_style = app.user_config.behavior.visualizer_style; - - let info_block = Block::default() - .title(Span::styled( - format!("Audio Visualization ({})", visualizer_style.name()), - Style::default().fg(app.user_config.theme.inactive.into()), - )) - .borders(Borders::ALL) - .border_style(Style::default().fg(app.user_config.theme.inactive.into())); - - let bar_chart_title = &format!("Spectrum | {} FPS | Press q to exit", 1000 / tick_rate); - - let bar_chart_block = visualizer_block() - .style(white) - .title(Span::styled(bar_chart_title, gray)) - .border_style(gray); - - // Check if we have spectrum data from local audio capture - if let Some(ref spectrum) = app.spectrum_data { - // Info panel with status - // Use ASCII-safe symbols instead of emojis for Windows compatibility - let status_text = if app.audio_capture_active { - "[>] Capturing audio" - } else { - "[||] Paused" - }; - - let peak = spectrum.bands.iter().copied().fold(0.0f32, f32::max); - let peak_text = format!("Peak: {:.0}%", peak * 100.0); - let style_hint = "Press 'V' to cycle visualizer style"; - - let texts = vec![Line::from(vec![ - Span::styled( - status_text, - Style::default().fg(app.user_config.theme.text.into()), - ), - Span::raw(" "), - Span::styled( - peak_text, - Style::default().fg(app.user_config.theme.inactive.into()), - ), - Span::raw(" | "), - Span::styled( - style_hint, - Style::default().fg(app.user_config.theme.hint.into()), - ), - ])]; - - let p = Paragraph::new(texts) - .block(info_block) - .style(Style::default().fg(app.user_config.theme.text.into())); - f.render_widget(p, info_area); - - // Calculate inner area for visualizer (within the block borders) - let inner_area = bar_chart_block.inner(visualizer_area); - f.render_widget(bar_chart_block, visualizer_area); - - // Render the appropriate visualizer based on user setting - match visualizer_style { - VisualizerStyle::BarGraph => render_bar_graph(f, &spectrum.bands, inner_area), - VisualizerStyle::Cava => render_cava( - f, - &spectrum.bands, - inner_area, - app.user_config.theme.analysis_bar.into(), - ), - } - } else { - // No audio capture available - let no_capture_text = vec![ - Line::from("No audio capture available"), - Line::from(""), - #[cfg(target_os = "linux")] - Line::from("Hint: Ensure PipeWire or PulseAudio is running with a monitor device"), - #[cfg(target_os = "windows")] - Line::from("Hint: Audio loopback should work automatically on Windows"), - #[cfg(target_os = "macos")] - Line::from("Hint: macOS requires a virtual audio device like BlackHole"), - #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))] - Line::from("Hint: Audio capture may not be supported on this platform"), - ]; - - let p = Paragraph::new(no_capture_text) - .block(info_block) - .style(Style::default().fg(app.user_config.theme.text.into())); - f.render_widget(p, info_area); - - // Empty bar chart - let empty_p = Paragraph::new("Waiting for audio input...") - .block(bar_chart_block) - .style(Style::default().fg(app.user_config.theme.text.into())); - f.render_widget(empty_p, visualizer_area); - } -} - -/// Render bar graph-style visualization using tui-bar-graph -/// https://github.com/joshka/tui-widgets/tree/main/tui-bar-graph -/// -/// The tui-bar-graph widget fills the entire area with one bar per column. -fn render_bar_graph(f: &mut Frame<'_>, bands: &[f32], area: Rect) { - if bands.is_empty() || area.width == 0 || area.height == 0 { - return; - } - - let target_width = (area.width as usize) * 2; - let data = interpolate_bands(bands, target_width); - - let bar_graph = BarGraph::new(data) - .with_gradient(colorgrad::preset::turbo()) - .with_bar_style(BarStyle::Braille) // Braille for high-res, Solid for blocks - .with_color_mode(ColorMode::VerticalGradient) - .with_max(1.0); - - f.render_widget(bar_graph, area); -} - -/// Linearly interpolate band values onto `target_width` points. Bands normally -/// already arrive at Braille resolution (2 per cell, requested by the runner), -/// in which case this is an exact copy. They fall short when the analyzer caps -/// the count at what the sample rate supports (513 at 44.1/48 kHz, i.e. -/// terminals wider than ~257 columns) or when a style switch or resize lags a -/// frame; stretching keeps the right side from going blank, since the widget -/// draws left-aligned. -fn interpolate_bands(bands: &[f32], target_width: usize) -> Vec { - if bands.is_empty() { - return vec![0.0; target_width]; - } - if bands.len() == 1 { - return vec![f64::from(bands[0]); target_width]; - } - - let mut result = Vec::with_capacity(target_width); - let scale = (bands.len() - 1) as f64 / (target_width - 1).max(1) as f64; - - for i in 0..target_width { - let pos = i as f64 * scale; - let idx = pos.floor() as usize; - let frac = pos - idx as f64; - - let value = if idx + 1 < bands.len() { - f64::from(bands[idx]) * (1.0 - frac) + f64::from(bands[idx + 1]) * frac - } else { - f64::from(bands[idx.min(bands.len() - 1)]) - }; - - result.push(value); - } - - result -} - -/// Bar glyphs from empty to full block, indexed by filled eighths (0-8): -/// ratatui's own nine-level bar set, with a blank for the empty cell. -const EIGHTH_GLYPHS: [&str; 9] = [ - bar::NINE_LEVELS.empty, - bar::NINE_LEVELS.one_eighth, - bar::NINE_LEVELS.one_quarter, - bar::NINE_LEVELS.three_eighths, - bar::NINE_LEVELS.half, - bar::NINE_LEVELS.five_eighths, - bar::NINE_LEVELS.three_quarters, - bar::NINE_LEVELS.seven_eighths, - bar::NINE_LEVELS.full, -]; - -/// Total filled eighths for a bar value in a column `rows` cells tall, with -/// cava's idle floor of one eighth (the signature resting line). u32 math: -/// rows * 8 would overflow u16 on pathological (>8191-row) terminal heights. -fn bar_eighths(value: f32, rows: u16) -> u32 { - let max_eighths = u32::from(rows) * 8; - let total = (value.clamp(0.0, 1.0) * max_eighths as f32) as u32; - total.clamp(1, max_eighths) -} - -/// Eighths filled (0-8) in the cell `row` rows above the column's bottom. -fn cell_eighths(total_eighths: u32, row: u16) -> u32 { - total_eighths.saturating_sub(u32::from(row) * 8).min(8) -} - -/// Cap on the cava style's bar count: the classic cava look at a normal font. -/// Past this the renderer widens bars to fill the terminal, so dense fonts get -/// chunky columns instead of one pencil bar per 3 columns. -#[allow(dead_code)] -const MAX_CAVA_BARS: usize = 40; - -/// cava's own bar geometry, 2 columns of bar plus a 1-column gap. It is the -/// floor for `cava_bar_layout`, and the divisor `visualizer_bar_count` uses to -/// pick a count that fits. -const CAVA_BAR_FOOTPRINT: u16 = 3; - -/// Bars the analyzer should produce for the visualizer block's `inner_width`. -/// Each style has its own geometry; the analyzer clamps counts its sample rate -/// cannot support, and renderers draw from `bands.len()`, so a stale count -/// during a resize race only mis-sizes one frame. -#[allow(dead_code)] -pub fn visualizer_bar_count(style: VisualizerStyle, inner_width: u16) -> usize { - let inner_width = usize::from(inner_width); - match style { - // Braille has 2x horizontal resolution: one real bar per half-cell. - VisualizerStyle::BarGraph => (inner_width * 2).max(2), - // Below ~120 columns this degrades to exactly cava's auto rule. - VisualizerStyle::Cava => { - ((inner_width + 1) / usize::from(CAVA_BAR_FOOTPRINT)).clamp(2, MAX_CAVA_BARS) - } - } -} - -/// Bar width and spacing for the cava style. The bar count is capped at -/// `MAX_CAVA_BARS`, so bars widen with the terminal to fill the area at -/// roughly cava's 2:1 bar-to-gap ratio, with `CAVA_BAR_FOOTPRINT` as the floor. -fn cava_bar_layout(inner_width: u16, bars: u16) -> (u16, u16) { - if bars == 0 { - return (CAVA_BAR_FOOTPRINT - 1, 1); - } - // u32 internals, footprint capped at the width: keeps the math (and the - // renderer's bar_width + spacing sums) off u16 overflow at any input. - let floor = u32::from(CAVA_BAR_FOOTPRINT); - let footprint = ((u32::from(inner_width) + 1) / u32::from(bars)) - .clamp(floor, u32::from(inner_width).max(floor)); - let spacing = ((footprint + 1) / floor).max(1); - ((footprint - spacing) as u16, spacing as u16) -} - -/// Render cava-style visualization: one independent bar per band drawn in -/// eighth blocks, widened to fill the width (see `cava_bar_layout`), centered. -fn render_cava(f: &mut Frame<'_>, bands: &[f32], area: Rect, color: Color) { - if bands.is_empty() || area.width == 0 || area.height == 0 { - return; - } - - let (bar_width, spacing) = cava_bar_layout(area.width, bands.len() as u16); - let step = bar_width + spacing; - let drawn_width = (bands.len() as u16) * step - spacing; - let x_offset = area.width.saturating_sub(drawn_width) / 2; - let style = Style::default().fg(color); - let buf = f.buffer_mut(); - - for (i, &value) in bands.iter().enumerate() { - let x0 = area.x + x_offset + (i as u16) * step; - // Clip bars that no longer fit (the bar count lags one frame on resize). - if x0 + bar_width > area.x + area.width { - break; - } - - let total_eighths = bar_eighths(value, area.height); - for row in 0..area.height { - let filled = cell_eighths(total_eighths, row); - if filled == 0 { - break; - } - let glyph = EIGHTH_GLYPHS[filled as usize]; - let y = area.y + area.height - 1 - row; - for dx in 0..bar_width { - buf[(x0 + dx, y)].set_symbol(glyph).set_style(style); - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn bar_eighths_has_cavas_idle_floor() { - assert_eq!(bar_eighths(0.0, 10), 1); - } - - #[test] - fn bar_eighths_fills_the_column_at_full_scale() { - assert_eq!(bar_eighths(1.0, 10), 80); - } - - #[test] - fn bar_eighths_clamps_overshoot() { - assert_eq!(bar_eighths(1.7, 10), 80); - } - - #[test] - fn cava_bar_layout_widens_bars_to_fill_dense_terminals() { - // 216 columns, 40 bars: 5 columns per bar -> 3-wide bars, 2-wide gaps. - assert_eq!(cava_bar_layout(216, 40), (3, 2)); - // 400 columns: 10 per bar -> 7-wide bars, 3-wide gaps (~2:1 holds). - assert_eq!(cava_bar_layout(400, 40), (7, 3)); - } - - #[test] - fn cava_bar_layout_floors_at_cavas_own_geometry() { - // Exactly enough room for 40 bars at cava's default 2+1 geometry. - assert_eq!(cava_bar_layout(119, 40), (2, 1)); - // Narrow terminals (fewer bars requested) keep the 2+1 default. - assert_eq!(cava_bar_layout(80, 27), (2, 1)); - // Too narrow to fit: geometry floors at 2+1 and the renderer clips. - assert_eq!(cava_bar_layout(5, 40), (2, 1)); - } - - #[test] - fn cell_glyphs_build_bottom_up() { - // 11 eighths in a 3-row column: full block, then three eighths, then air. - let glyphs: Vec<&str> = (0..3) - .map(|row| EIGHTH_GLYPHS[cell_eighths(11, row) as usize]) - .collect(); - assert_eq!(glyphs, vec!["█", "▃", " "]); - } - - #[test] - fn visualizer_bar_count_follows_each_styles_geometry() { - // BarGraph gets two Braille bars per cell. - assert_eq!(visualizer_bar_count(VisualizerStyle::BarGraph, 100), 200); - - // Cava: one bar per 3 columns (bar width 2 + spacing 1). - assert_eq!(visualizer_bar_count(VisualizerStyle::Cava, 80), 27); - } - - #[test] - fn visualizer_bar_count_clamps_degenerate_widths() { - // Zero-width terminals still request a drawable minimum. - assert_eq!(visualizer_bar_count(VisualizerStyle::Cava, 0), 2); - assert_eq!(visualizer_bar_count(VisualizerStyle::BarGraph, 0), 2); - - // The bar cap holds on wide terminals and dense fonts. - assert_eq!(visualizer_bar_count(VisualizerStyle::Cava, 4000), 40); - assert_eq!(visualizer_bar_count(VisualizerStyle::Cava, 216), 40); - } - - #[test] - fn requested_bar_count_and_drawn_geometry_agree() { - // The two halves of the cava contract: the count the runner asks for must - // fit the width the renderer lays it out in, at every width. - for inner_width in [20u16, 40, 80, 119, 120, 216, 400] { - let bars = visualizer_bar_count(VisualizerStyle::Cava, inner_width) as u16; - let (bar_width, spacing) = cava_bar_layout(inner_width, bars); - let drawn = bars * (bar_width + spacing) - spacing; - assert!( - drawn <= inner_width, - "{bars} bars drawn as {drawn} columns overflow width {inner_width}" - ); - } - } -} diff --git a/src/tui/ui/columns.rs b/src/tui/ui/columns.rs deleted file mode 100644 index 114bbdb4..00000000 --- a/src/tui/ui/columns.rs +++ /dev/null @@ -1,283 +0,0 @@ -use crate::core::user_config::ColumnSpec; - -use super::tables::ColumnId; -use super::util::get_percentage_width; - -#[derive(Clone, Copy, PartialEq, Eq)] -pub enum TableColumnSet { - Songs, - AlbumTracks, - Albums, - Podcasts, - Episodes, - RecentlyPlayed, -} - -#[derive(Clone, Copy, PartialEq)] -enum WidthSpec { - Fixed(u16), - Percent(f32), - PercentMinus(f32, u16), -} - -#[derive(Clone, Copy)] -struct ColumnDef { - id: &'static str, - header: &'static str, - width: WidthSpec, - column_id: ColumnId, -} - -#[derive(Clone)] -pub struct ResolvedColumn { - pub id: String, - pub header: String, - pub width: u16, - pub column_id: ColumnId, -} - -pub fn resolve_columns( - table: TableColumnSet, - layout_width: u16, - configured: &[ColumnSpec], -) -> Vec { - let specs = if configured.is_empty() { - default_specs(table) - } else { - configured.to_vec() - }; - - specs - .iter() - .filter_map(|spec| { - let def = column_def(table, &spec.id)?; - Some(ResolvedColumn { - id: spec.id.clone(), - header: spec - .header - .clone() - .unwrap_or_else(|| def.header.to_string()), - width: spec.width.unwrap_or_else(|| { - spec.width_percent.map_or_else( - || resolve_width(layout_width, def.width), - |pct| get_percentage_width(layout_width, pct / 100.0), - ) - }), - column_id: def.column_id, - }) - }) - .collect() -} - -fn default_specs(table: TableColumnSet) -> Vec { - default_column_ids(table) - .iter() - .map(|id| ColumnSpec { - id: (*id).to_string(), - ..Default::default() - }) - .collect() -} - -fn default_column_ids(table: TableColumnSet) -> &'static [&'static str] { - match table { - TableColumnSet::Songs => &["liked", "title", "artist", "album", "length"], - TableColumnSet::AlbumTracks => &["liked", "index", "title", "artist", "length"], - TableColumnSet::Albums => &["title", "artist", "date"], - TableColumnSet::Podcasts => &["title", "publisher"], - TableColumnSet::Episodes => &["played", "date", "title", "duration"], - TableColumnSet::RecentlyPlayed => &["liked", "title", "artist", "length"], - } -} - -fn resolve_width(layout_width: u16, spec: WidthSpec) -> u16 { - match spec { - WidthSpec::Fixed(width) => width, - WidthSpec::Percent(percent) => get_percentage_width(layout_width, percent), - WidthSpec::PercentMinus(percent, minus) => { - get_percentage_width(layout_width, percent).saturating_sub(minus) - } - } -} - -fn column_def(table: TableColumnSet, id: &str) -> Option { - column_defs(table).iter().find(|def| def.id == id).copied() -} - -fn column_defs(table: TableColumnSet) -> &'static [ColumnDef] { - match table { - TableColumnSet::Songs => &SONG_COLUMNS, - TableColumnSet::AlbumTracks => &ALBUM_TRACK_COLUMNS, - TableColumnSet::Albums => &ALBUM_COLUMNS, - TableColumnSet::Podcasts => &PODCAST_COLUMNS, - TableColumnSet::Episodes => &EPISODE_COLUMNS, - TableColumnSet::RecentlyPlayed => &RECENTLY_PLAYED_COLUMNS, - } -} - -const SONG_COLUMNS: [ColumnDef; 6] = [ - liked_column(), - index_column(), - title_column(WidthSpec::Percent(0.3), "Title"), - artist_column(WidthSpec::Percent(0.3)), - album_column(WidthSpec::Percent(0.3)), - length_column(WidthSpec::Percent(0.1), "Length"), -]; - -const ALBUM_TRACK_COLUMNS: [ColumnDef; 6] = [ - liked_column(), - index_column(), - title_column(WidthSpec::PercentMinus(2.0 / 5.0, 5), "Title"), - artist_column(WidthSpec::Percent(2.0 / 5.0)), - album_column(WidthSpec::Percent(0.3)), - length_column(WidthSpec::Percent(1.0 / 5.0), "Length"), -]; - -const ALBUM_COLUMNS: [ColumnDef; 4] = [ - title_column(WidthSpec::Percent(2.0 / 5.0), "Name"), - artist_column(WidthSpec::Percent(2.0 / 5.0)), - ColumnDef { - id: "date", - header: "Release Date", - width: WidthSpec::Percent(1.0 / 5.0), - column_id: ColumnId::None, - }, - liked_column(), -]; - -const PODCAST_COLUMNS: [ColumnDef; 2] = [ - title_column(WidthSpec::Percent(2.0 / 5.0), "Name"), - ColumnDef { - id: "publisher", - header: "Publisher(s)", - width: WidthSpec::Percent(2.0 / 5.0), - column_id: ColumnId::None, - }, -]; - -const EPISODE_COLUMNS: [ColumnDef; 4] = [ - ColumnDef { - id: "played", - header: "", - width: WidthSpec::Fixed(2), - column_id: ColumnId::None, - }, - ColumnDef { - id: "date", - header: "Date", - width: WidthSpec::PercentMinus(0.5 / 5.0, 2), - column_id: ColumnId::None, - }, - title_column(WidthSpec::Percent(3.5 / 5.0), "Name"), - ColumnDef { - id: "duration", - header: "Duration", - width: WidthSpec::Percent(1.0 / 5.0), - column_id: ColumnId::None, - }, -]; - -const RECENTLY_PLAYED_COLUMNS: [ColumnDef; 6] = [ - liked_column(), - index_column(), - title_column(WidthSpec::PercentMinus(2.0 / 5.0, 2), "Title"), - artist_column(WidthSpec::Percent(2.0 / 5.0)), - album_column(WidthSpec::Percent(0.3)), - length_column(WidthSpec::Percent(1.0 / 5.0), "Length"), -]; - -const fn liked_column() -> ColumnDef { - ColumnDef { - id: "liked", - header: "", - width: WidthSpec::Fixed(2), - column_id: ColumnId::Liked, - } -} - -const fn index_column() -> ColumnDef { - ColumnDef { - id: "index", - header: "#", - width: WidthSpec::Fixed(3), - column_id: ColumnId::None, - } -} - -const fn title_column(width: WidthSpec, header: &'static str) -> ColumnDef { - ColumnDef { - id: "title", - header, - width, - column_id: ColumnId::Title, - } -} - -const fn artist_column(width: WidthSpec) -> ColumnDef { - ColumnDef { - id: "artist", - header: "Artist", - width, - column_id: ColumnId::None, - } -} - -const fn album_column(width: WidthSpec) -> ColumnDef { - ColumnDef { - id: "album", - header: "Album", - width, - column_id: ColumnId::None, - } -} - -const fn length_column(width: WidthSpec, header: &'static str) -> ColumnDef { - ColumnDef { - id: "length", - header, - width, - column_id: ColumnId::None, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn defaults_are_rendered_when_config_is_empty() { - let columns = resolve_columns(TableColumnSet::Songs, 100, &[]); - assert_eq!( - columns.iter().map(|c| c.id.as_str()).collect::>(), - ["liked", "title", "artist", "album", "length"] - ); - } - - #[test] - fn configured_columns_can_reorder_and_override_headers() { - let columns = resolve_columns( - TableColumnSet::Songs, - 100, - &[ - ColumnSpec { - id: "artist".to_string(), - header: Some("Band".to_string()), - width_percent: Some(30.0), - width: None, - }, - ColumnSpec { - id: "title".to_string(), - header: None, - width_percent: None, - width: Some(40), - }, - ], - ); - assert_eq!(columns[0].id, "artist"); - assert_eq!(columns[0].header, "Band"); - assert_eq!(columns[0].width, 29); - assert_eq!(columns[1].id, "title"); - assert_eq!(columns[1].width, 40); - assert_eq!(columns[1].column_id, ColumnId::Title); - } -} diff --git a/src/tui/ui/create_playlist.rs b/src/tui/ui/create_playlist.rs deleted file mode 100644 index fa0c77ae..00000000 --- a/src/tui/ui/create_playlist.rs +++ /dev/null @@ -1,215 +0,0 @@ -use crate::core::app::{App, CreatePlaylistFocus, CreatePlaylistStage}; -use crate::tui::theme::{EmphasisExt, ThemeExt}; -use ratatui::{ - layout::{Constraint, Direction, Layout, Rect}, - style::{Modifier, Style}, - text::Span, - widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}, - Frame, -}; - -fn centered_rect(bounds: Rect, width_pct: u16, height_pct: u16) -> Rect { - let width = (bounds.width * width_pct / 100).max(1); - let height = (bounds.height * height_pct / 100).max(1); - let x = bounds.x + bounds.width.saturating_sub(width) / 2; - let y = bounds.y + bounds.height.saturating_sub(height) / 3; - Rect::new(x, y, width, height) -} - -pub fn draw_create_playlist_form(f: &mut Frame<'_>, app: &App) { - let area = centered_rect(f.area(), 80, 80); - f.render_widget(Clear, area); - - match app.view.create_playlist_stage { - CreatePlaylistStage::Name => draw_name_stage(f, app, area), - CreatePlaylistStage::AddTracks => draw_add_tracks_stage(f, app, area), - } -} - -fn draw_name_stage(f: &mut Frame<'_>, app: &App, area: Rect) { - let theme = &app.user_config.theme; - - let block = Block::default() - .title(Span::styled( - "Create Playlist (Esc to cancel)", - Style::default() - .fg(theme.header.into()) - .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), - )) - .borders(Borders::ALL) - .style(theme.base_style()) - .border_style(Style::default().fg(theme.active.into())); - f.render_widget(block, area); - - let inner = Layout::default() - .direction(Direction::Vertical) - .margin(2) - .constraints([ - Constraint::Length(1), - Constraint::Length(3), - Constraint::Length(1), - ]) - .split(area); - - let label = Paragraph::new("Playlist name:").style(theme.base_style()); - f.render_widget(label, inner[0]); - - let name_text: String = app.view.create_playlist_name.iter().collect(); - let input = Paragraph::new(name_text).style(theme.base_style()).block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme.active.into())), - ); - f.render_widget(input, inner[1]); - f.set_cursor_position(( - inner[1].x + 1 + app.view.create_playlist_name_cursor, - inner[1].y + 1, - )); - - let hint = Paragraph::new("Press Enter to continue, Esc to cancel") - .style(Style::default().fg(theme.inactive.into())); - f.render_widget(hint, inner[2]); -} - -fn draw_add_tracks_stage(f: &mut Frame<'_>, app: &App, area: Rect) { - let theme = &app.user_config.theme; - let name: String = app.view.create_playlist_name.iter().collect(); - let title = format!( - "Add Tracks to \"{}\" (Enter=create, Tab=switch panel, Esc=cancel)", - name - ); - - let block = Block::default() - .title(Span::styled( - title, - Style::default() - .fg(theme.header.into()) - .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), - )) - .borders(Borders::ALL) - .style(theme.base_style()) - .border_style(Style::default().fg(theme.active.into())); - f.render_widget(block, area); - - let inner = Layout::default() - .direction(Direction::Vertical) - .margin(1) - .constraints([Constraint::Length(3), Constraint::Min(5)]) - .split(area); - - // Search input - let search_text: String = app.view.create_playlist_search_input.iter().collect(); - let search_border_style = if app.view.create_playlist_focus == CreatePlaylistFocus::SearchInput { - Style::default().fg(theme.active.into()) - } else { - Style::default().fg(theme.inactive.into()) - }; - let search_input = Paragraph::new(search_text).style(theme.base_style()).block( - Block::default() - .title(Span::styled( - "Search (Enter to search)", - Style::default().fg(theme.header.into()), - )) - .borders(Borders::ALL) - .border_style(search_border_style), - ); - f.render_widget(search_input, inner[0]); - if app.view.create_playlist_focus == CreatePlaylistFocus::SearchInput { - f.set_cursor_position(( - inner[0].x + 1 + app.view.create_playlist_search_cursor, - inner[0].y + 1, - )); - } - - // Two-panel area: results + added tracks - let panels = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(inner[1]); - - // Left: search results - let results_border_style = if app.view.create_playlist_focus == CreatePlaylistFocus::SearchResults - { - Style::default().fg(theme.active.into()) - } else { - Style::default().fg(theme.inactive.into()) - }; - let result_items: Vec = app - .create_playlist_search_results - .iter() - .map(|t| { - let artist = t.artists.first().map(|a| a.as_str()).unwrap_or("Unknown"); - ListItem::new(format!("{} — {}", t.name, artist)).style(theme.base_style()) - }) - .collect(); - - let mut results_state = ListState::default(); - if app.view.create_playlist_focus == CreatePlaylistFocus::SearchResults - && !app.create_playlist_search_results.is_empty() - { - results_state.select(Some(app.view.create_playlist_selected_result)); - } - - let results_list = List::new(result_items) - .block( - Block::default() - .title(Span::styled( - "Results (Enter to add)", - Style::default().fg(theme.header.into()), - )) - .borders(Borders::ALL) - .border_style(results_border_style), - ) - .highlight_style( - Style::default() - .fg(theme.selected.into()) - .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), - ) - .style(theme.base_style()); - f.render_stateful_widget(results_list, panels[0], &mut results_state); - - // Right: added tracks - let added_border_style = if app.view.create_playlist_focus == CreatePlaylistFocus::AddedTracks { - Style::default().fg(theme.active.into()) - } else { - Style::default().fg(theme.inactive.into()) - }; - - let added_items: Vec = app - .create_playlist_tracks - .iter() - .map(|t| { - let artist = t.artists.first().map(|a| a.as_str()).unwrap_or("Unknown"); - ListItem::new(format!("{} — {}", t.name, artist)).style(theme.base_style()) - }) - .collect(); - - let mut added_state = ListState::default(); - if app.view.create_playlist_focus == CreatePlaylistFocus::AddedTracks - && !app.create_playlist_tracks.is_empty() - { - added_state.select(Some(app.view.create_playlist_selected_result)); - } - - let added_tracks_title = format!( - "Added ({}) — d=remove, Enter=create", - app.create_playlist_tracks.len() - ); - let added_list = List::new(added_items) - .block( - Block::default() - .title(Span::styled( - added_tracks_title, - Style::default().fg(theme.header.into()), - )) - .borders(Borders::ALL) - .border_style(added_border_style), - ) - .highlight_style( - Style::default() - .fg(theme.selected.into()) - .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), - ) - .style(theme.base_style()); - f.render_stateful_widget(added_list, panels[1], &mut added_state); -} diff --git a/src/tui/ui/discover.rs b/src/tui/ui/discover.rs deleted file mode 100644 index eab05b65..00000000 --- a/src/tui/ui/discover.rs +++ /dev/null @@ -1,207 +0,0 @@ -use crate::core::app::{ActiveBlock, App}; -use ratatui::{ - layout::{Constraint, Layout, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}, - Frame, -}; - -use super::util::get_color; -use crate::tui::theme::EmphasisExt; - -pub fn draw_discover(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { - let current_route = app.get_current_route(); - let highlight_state = ( - current_route.active_block == ActiveBlock::Discover, - current_route.hovered_block == ActiveBlock::Discover, - ); - - // Split into two sections: playlist list and info panel - let [list_area, info_area] = layout_chunk.layout(&Layout::vertical([ - Constraint::Min(8), - Constraint::Length(8), - ])); - - // Build discover options with status indicators - let artists_mix_status = if app.discover_loading && app.view.discover_selected_index == 0 { - " [Loading...]".to_string() - } else if !app.discover_artists_mix.is_empty() { - format!(" [{} tracks]", app.discover_artists_mix.len()) - } else { - String::new() - }; - - let top_tracks_status = if app.discover_loading && app.view.discover_selected_index == 1 { - " [Loading...]".to_string() - } else if !app.discover_top_tracks.is_empty() { - format!(" [{} tracks]", app.discover_top_tracks.len()) - } else { - String::new() - }; - - let mut state = ListState::default(); - state.select(Some(app.view.discover_selected_index)); - - let list_items: Vec = vec![ - // Top Artists Mix - { - let is_selected = app.view.discover_selected_index == 0; - let prefix = if is_selected { "▸ " } else { " " }; - let text_style = if is_selected { - Style::default().fg(app.user_config.theme.selected.into()) - } else { - Style::default().fg(app.user_config.theme.text.into()) - }; - ListItem::new(Line::from(vec![ - Span::styled( - prefix, - Style::default().fg(app.user_config.theme.selected.into()), - ), - Span::styled( - format!("{} Top Artists Mix", app.user_config.padded_liked_icon()), - text_style, - ), - Span::styled( - artists_mix_status, - Style::default().fg(app.user_config.theme.hint.into()), - ), - ])) - }, - // Top Tracks with time range - { - let is_selected = app.view.discover_selected_index == 1; - let prefix = if is_selected { "▸ " } else { " " }; - let text_style = if is_selected { - Style::default().fg(app.user_config.theme.selected.into()) - } else { - Style::default().fg(app.user_config.theme.text.into()) - }; - let time_range_label = format!(" ({})", app.view.discover_time_range.label()); - ListItem::new(Line::from(vec![ - Span::styled( - prefix, - Style::default().fg(app.user_config.theme.selected.into()), - ), - Span::styled( - format!("{} Top Tracks", app.user_config.padded_liked_icon()), - text_style, - ), - Span::styled( - time_range_label, - if is_selected { - Style::default().fg(app.user_config.theme.active.into()) - } else { - Style::default().fg(app.user_config.theme.inactive.into()) - }, - ), - Span::styled( - top_tracks_status, - Style::default().fg(app.user_config.theme.hint.into()), - ), - ])) - }, - ]; - - let list = List::new(list_items) - .block( - Block::default() - .borders(Borders::ALL) - .title(Span::styled( - "Discover", - get_color(highlight_state, app.user_config.theme), - )) - .border_style(get_color(highlight_state, app.user_config.theme)), - ) - .highlight_style( - get_color(highlight_state, app.user_config.theme) - .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), - ) - .highlight_symbol(Line::from("▶ ").style(get_color(highlight_state, app.user_config.theme))); - - f.render_stateful_widget(list, list_area, &mut state); - - // Info panel at bottom - context-sensitive help - let info_lines = vec![ - Line::from(vec![ - Span::styled( - "↑/↓ ", - Style::default().fg(app.user_config.theme.hint.into()), - ), - Span::styled( - "Navigate", - Style::default().fg(app.user_config.theme.text.into()), - ), - Span::styled( - " Enter ", - Style::default().fg(app.user_config.theme.hint.into()), - ), - Span::styled( - "Select", - Style::default().fg(app.user_config.theme.text.into()), - ), - if app.view.discover_selected_index == 1 { - Span::styled( - " [/] ", - Style::default().fg(app.user_config.theme.hint.into()), - ) - } else { - Span::styled("", Style::default()) - }, - if app.view.discover_selected_index == 1 { - Span::styled( - "Time range", - Style::default().fg(app.user_config.theme.text.into()), - ) - } else { - Span::styled("", Style::default()) - }, - ]), - Line::from(""), - Line::from(vec![ - Span::styled( - "Top Artists Mix: ", - Style::default().fg(app.user_config.theme.text.into()), - ), - Span::styled( - "Shuffled tracks from your top 5 artists", - Style::default().fg(app.user_config.theme.hint.into()), - ), - ]), - Line::from(vec![ - Span::styled( - "Top Tracks: ", - Style::default().fg(app.user_config.theme.text.into()), - ), - Span::styled( - "Your most-listened songs", - Style::default().fg(app.user_config.theme.hint.into()), - ), - ]), - Line::from(""), - Line::from(vec![ - Span::styled( - format!("Time range: {} ", app.view.discover_time_range.label()), - Style::default().fg(app.user_config.theme.text.into()), - ), - Span::styled( - "(4 weeks / 6 months / All time)", - Style::default().fg(app.user_config.theme.inactive.into()), - ), - ]), - ]; - - let info = Paragraph::new(info_lines) - .block( - Block::default() - .borders(Borders::ALL) - .title(Span::styled( - "Help", - Style::default().fg(app.user_config.theme.inactive.into()), - )) - .border_style(Style::default().fg(app.user_config.theme.inactive.into())), - ) - .wrap(Wrap { trim: true }); - - f.render_widget(info, info_area); -} diff --git a/src/tui/ui/friends.rs b/src/tui/ui/friends.rs deleted file mode 100644 index 848f541e..00000000 --- a/src/tui/ui/friends.rs +++ /dev/null @@ -1,559 +0,0 @@ -use crate::core::app::{ActiveBlock, App, FriendAddMode, FriendEntry, FriendFilter}; -use ratatui::{ - layout::{Constraint, Layout, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph}, - Frame, -}; - -use super::util::{get_color, hint_span, truncate_text}; -use crate::tui::theme::EmphasisExt; - -pub fn draw_friends(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { - let current_route = app.get_current_route(); - let highlight_state = ( - current_route.active_block == ActiveBlock::Friends, - current_route.hovered_block == ActiveBlock::Friends, - ); - - let outer_block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(get_color(highlight_state, app.user_config.theme)) - .title(Span::styled( - " Friends ", - get_color(highlight_state, app.user_config.theme) - .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), - )); - - let inner = outer_block.inner(layout_chunk); - f.render_widget(outer_block, layout_chunk); - - // No sync token → show account-required prompt - if app.user_config.behavior.sync_token.is_none() { - draw_no_token_prompt(f, app, inner); - return; - } - - // Split inner area: friend-code card | tabs+list | help bar - let [top_area, list_area, help_area] = inner.layout(&Layout::vertical([ - Constraint::Length(3), - Constraint::Min(1), - Constraint::Length(1), - ])); - - draw_friend_code_card(f, app, top_area); - draw_friends_body(f, app, list_area); - draw_help_bar(f, app, help_area); - - // Add-friend overlay rendered on top - if app.view.friend_add_dialog_visible { - draw_add_friend_dialog(f, app, layout_chunk); - } -} - -// ── No-token prompt ─────────────────────────────────────────────────────────── - -fn draw_no_token_prompt(f: &mut Frame<'_>, app: &App, area: Rect) { - let theme = app.user_config.theme; - - let lines = vec![ - Line::default(), - Line::default(), - Line::from(Span::styled( - "Account Required", - Style::default() - .fg(theme.banner.into()) - .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), - )), - Line::default(), - Line::from(Span::styled( - "Friends require a spotatui web account to sync your", - Style::default().fg(theme.text.into()), - )), - Line::from(Span::styled( - "listening data and connect with other users.", - Style::default().fg(theme.text.into()), - )), - Line::default(), - Line::from(vec![ - Span::styled(" Sign up at: ", Style::default().fg(theme.inactive.into())), - Span::styled( - "https://spotatui.com", - Style::default() - .fg(theme.hint.into()) - .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), - ), - ]), - Line::default(), - Line::from(Span::styled( - "After signing up, copy your sync token from the dashboard", - Style::default().fg(theme.inactive.into()), - )), - Line::from(Span::styled( - "and paste it into Settings → Behavior → sync_token", - Style::default().fg(theme.inactive.into()), - )), - Line::default(), - Line::from(Span::styled( - "Press Esc to go back", - Style::default().fg(theme.hint.into()), - )), - ]; - - let para = Paragraph::new(lines).alignment(ratatui::layout::Alignment::Center); - f.render_widget(para, area); -} - -// ── Friend code card ────────────────────────────────────────────────────────── - -fn draw_friend_code_card(f: &mut Frame<'_>, app: &App, area: Rect) { - let theme = app.user_config.theme; - - let code_text = app.friend_code.as_deref().unwrap_or("Loading..."); - - let hint = " c — copy"; - - let line = Line::from(vec![ - Span::styled( - " YOUR CODE ", - Style::default() - .fg(theme.inactive.into()) - .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), - ), - Span::styled( - code_text, - Style::default() - .fg(theme.hint.into()) - .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), - ), - Span::raw(" "), - Span::styled(hint, Style::default().fg(theme.inactive.into())), - ]); - - let card = Paragraph::new(line).block( - Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(theme.active.into())), - ); - - f.render_widget(card, area); -} - -// ── Main body: filter tabs + friend list ────────────────────────────────────── - -fn draw_friends_body(f: &mut Frame<'_>, app: &App, area: Rect) { - let theme = app.user_config.theme; - - // Split into a thin tab/control row and the list below - let [controls_area, list_area] = area.layout(&Layout::vertical([ - Constraint::Length(1), - Constraint::Min(1), - ])); - - // Controls row: [All (n)] [Online (n)] ... search hint ... [+ Add Friend] - draw_filter_tabs(f, app, controls_area); - - // The actual friends list - let filtered = filtered_friends(app); - - if filtered.is_empty() { - let msg = if !app.view.friend_search_input.is_empty() { - "No friends match your search" - } else if app.friends_loading { - "Loading friends..." - } else if app.friends.is_empty() { - "No friends yet — press 'a' to add one!" - } else { - "No friends online right now" - }; - let para = Paragraph::new(Span::styled( - msg, - Style::default().fg(theme.inactive.into()), - )) - .alignment(ratatui::layout::Alignment::Center); - // Center vertically a bit - let [_, center, _] = list_area.layout(&Layout::vertical([ - Constraint::Percentage(30), - Constraint::Length(1), - Constraint::Min(1), - ])); - f.render_widget(para, center); - return; - } - - draw_friend_list(f, app, list_area, &filtered); -} - -fn draw_filter_tabs(f: &mut Frame<'_>, app: &App, area: Rect) { - let theme = app.user_config.theme; - let online_count = app.friends.iter().filter(|f| f.is_online).count(); - let all_count = app.friends.len(); - - let all_style = if app.view.friend_filter == FriendFilter::All { - Style::default() - .fg(theme.selected.into()) - .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)) - } else { - Style::default().fg(theme.inactive.into()) - }; - let online_style = if app.view.friend_filter == FriendFilter::Online { - Style::default() - .fg(theme.selected.into()) - .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)) - } else { - Style::default().fg(theme.inactive.into()) - }; - - // Build search preview (inline: any unbound key types into the filter) - let search_str: String = app.view.friend_search_input.iter().collect(); - let search_hint = if search_str.is_empty() { - "search…".to_string() - } else { - format!("/{}/", search_str) - }; - - let line = Line::from(vec![ - Span::styled(format!(" All ({}) ", all_count), all_style), - Span::styled(" │ ", Style::default().fg(theme.inactive.into())), - Span::styled(format!(" Online ({}) ", online_count), online_style), - Span::styled(" ", Style::default()), - Span::styled(search_hint, Style::default().fg(theme.inactive.into())), - Span::styled(" ", Style::default()), - Span::styled("+ Add Friend", Style::default().fg(theme.active.into())), - Span::raw(" "), - ]); - - let para = Paragraph::new(line); - f.render_widget(para, area); -} - -fn draw_friend_list(f: &mut Frame<'_>, app: &App, area: Rect, friends: &[&FriendEntry]) { - let theme = app.user_config.theme; - - let items: Vec = friends - .iter() - .enumerate() - .map(|(i, friend)| { - let is_selected = i == app.view.friend_selected_index; - - // Online indicator - let online_span = if friend.is_online { - Span::styled("● ", Style::default().fg(theme.active.into())) - } else { - Span::styled("○ ", Style::default().fg(theme.inactive.into())) - }; - - // Name - let name_style = if is_selected { - Style::default() - .fg(theme.selected.into()) - .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)) - } else { - Style::default().fg(theme.text.into()) - }; - let name_span = Span::styled( - format!("{:<20}", truncate_text(&friend.name, 20)), - name_style, - ); - - // Now-playing or status - let np_spans = if let Some(np) = &friend.now_playing { - vec![ - Span::styled("▶ ", Style::default().fg(theme.active.into())), - Span::styled( - truncate_text(&np.title, 28), - Style::default().fg(theme.hint.into()), - ), - Span::styled( - format!(" — {}", truncate_text(&np.artists, 20)), - Style::default().fg(theme.inactive.into()), - ), - ] - } else if friend.is_online { - vec![Span::styled( - "idle", - Style::default().fg(theme.inactive.into()), - )] - } else { - vec![Span::styled( - "offline", - Style::default().fg(theme.inactive.into()), - )] - }; - - let mut spans = vec![online_span, name_span, Span::raw(" ")]; - spans.extend(np_spans); - - ListItem::new(Line::from(spans)) - }) - .collect(); - - let mut state = ListState::default(); - state.select(Some( - app - .view - .friend_selected_index - .min(friends.len().saturating_sub(1)), - )); - - let list = List::new(items) - .highlight_style( - Style::default() - .fg(theme.selected.into()) - .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), - ) - .highlight_symbol("▶ "); - - f.render_stateful_widget(list, area, &mut state); -} - -// ── Help bar ────────────────────────────────────────────────────────────────── - -fn draw_help_bar(f: &mut Frame<'_>, app: &App, area: Rect) { - let theme = app.user_config.theme; - - let line = Line::from(vec![ - hint_span("↑/↓", theme), - Span::styled(" Navigate ", Style::default().fg(theme.inactive.into())), - hint_span("c", theme), - Span::styled(" Copy code ", Style::default().fg(theme.inactive.into())), - hint_span("a", theme), - Span::styled(" Add friend ", Style::default().fg(theme.inactive.into())), - hint_span("u", theme), - Span::styled(" Unfollow ", Style::default().fg(theme.inactive.into())), - hint_span("Tab", theme), - Span::styled(" Filter ", Style::default().fg(theme.inactive.into())), - Span::styled( - "type to search ", - Style::default().fg(theme.inactive.into()), - ), - hint_span("Esc", theme), - Span::styled(" Back", Style::default().fg(theme.inactive.into())), - ]); - - f.render_widget(Paragraph::new(line), area); -} - -// ── Add-Friend dialog overlay ───────────────────────────────────────────────── - -fn draw_add_friend_dialog(f: &mut Frame<'_>, app: &App, parent: Rect) { - let theme = app.user_config.theme; - - // Center a 50×18 dialog box - let dialog_width = 52u16.min(parent.width.saturating_sub(4)); - let dialog_height = 14u16.min(parent.height.saturating_sub(4)); - let x = parent.x + (parent.width.saturating_sub(dialog_width)) / 2; - let y = parent.y + (parent.height.saturating_sub(dialog_height)) / 2; - let dialog_area = Rect::new(x, y, dialog_width, dialog_height); - - f.render_widget(Clear, dialog_area); - - let block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(theme.active.into())) - .title(Span::styled( - " Add Friend ", - Style::default() - .fg(theme.active.into()) - .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), - )); - - let inner = block.inner(dialog_area); - f.render_widget(block, dialog_area); - - // Mode tabs - let [tabs_area, content_area, help_area] = inner.layout(&Layout::vertical([ - Constraint::Length(1), - Constraint::Min(1), - Constraint::Length(1), - ])); - - // Tab row - let code_style = if app.view.friend_add_mode == FriendAddMode::Code { - Style::default().fg(theme.selected.into()).add_modifier( - app - .user_config - .behavior - .emphasis(Modifier::BOLD | Modifier::UNDERLINED), - ) - } else { - Style::default().fg(theme.inactive.into()) - }; - let search_style = if app.view.friend_add_mode == FriendAddMode::Search { - Style::default().fg(theme.selected.into()).add_modifier( - app - .user_config - .behavior - .emphasis(Modifier::BOLD | Modifier::UNDERLINED), - ) - } else { - Style::default().fg(theme.inactive.into()) - }; - let tabs_line = Line::from(vec![ - Span::styled(" By Friend Code ", code_style), - Span::styled(" │ ", Style::default().fg(theme.inactive.into())), - Span::styled(" Search by Name ", search_style), - ]); - f.render_widget(Paragraph::new(tabs_line), tabs_area); - - match app.view.friend_add_mode { - FriendAddMode::Code => draw_add_by_code(f, app, content_area), - FriendAddMode::Search => draw_add_by_search(f, app, content_area), - } - - // Footer hints - let footer_line = Line::from(vec![ - hint_span("Tab", theme), - Span::styled(" Switch ", Style::default().fg(theme.inactive.into())), - hint_span("Enter", theme), - Span::styled(" Add ", Style::default().fg(theme.inactive.into())), - hint_span("Esc", theme), - Span::styled(" Cancel", Style::default().fg(theme.inactive.into())), - ]); - f.render_widget(Paragraph::new(footer_line), help_area); -} - -fn draw_add_by_code(f: &mut Frame<'_>, app: &App, area: Rect) { - let theme = app.user_config.theme; - - let input: String = app.view.friend_add_input.iter().collect(); - let display = if input.is_empty() { - "Enter friend code...".to_string() - } else { - input.clone() - }; - let style = if input.is_empty() { - Style::default().fg(theme.inactive.into()) - } else { - Style::default() - .fg(theme.hint.into()) - .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)) - }; - - let [_, input_row, hint_row, _] = area.layout(&Layout::vertical([ - Constraint::Length(1), - Constraint::Length(3), - Constraint::Length(1), - Constraint::Min(0), - ])); - - let input_widget = Paragraph::new(Span::styled(display, style)) - .alignment(ratatui::layout::Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(theme.active.into())), - ); - f.render_widget(input_widget, input_row); - - let hint = Paragraph::new(Span::styled( - "Type code then press Enter", - Style::default().fg(theme.inactive.into()), - )) - .alignment(ratatui::layout::Alignment::Center); - f.render_widget(hint, hint_row); -} - -fn draw_add_by_search(f: &mut Frame<'_>, app: &App, area: Rect) { - let theme = app.user_config.theme; - - let [input_row, results_area] = area.layout(&Layout::vertical([ - Constraint::Length(3), - Constraint::Min(1), - ])); - - // Search input field - let search_str: String = app.view.friend_user_search_input.iter().collect(); - let search_display = if search_str.is_empty() { - "Type a username or code...".to_string() - } else { - search_str - }; - let search_style = if app.view.friend_user_search_input.is_empty() { - Style::default().fg(theme.inactive.into()) - } else { - Style::default().fg(theme.text.into()) - }; - let search_widget = Paragraph::new(Span::styled(search_display, search_style)).block( - Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(theme.inactive.into())), - ); - f.render_widget(search_widget, input_row); - - // Results list - if app.friend_user_search_results.is_empty() { - let msg = if app.view.friend_user_search_input.is_empty() { - "" - } else { - "No users found" - }; - f.render_widget( - Paragraph::new(Span::styled( - msg, - Style::default().fg(theme.inactive.into()), - )) - .alignment(ratatui::layout::Alignment::Center), - results_area, - ); - return; - } - - let items: Vec = app - .friend_user_search_results - .iter() - .enumerate() - .map(|(i, r)| { - let is_sel = i == app.view.friend_user_search_selected; - let prefix = if is_sel { "▶ " } else { " " }; - let style = if is_sel { - Style::default() - .fg(theme.selected.into()) - .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)) - } else { - Style::default().fg(theme.text.into()) - }; - let following_tag = if r.is_following { " [following]" } else { "" }; - ListItem::new(Line::from(vec![ - Span::styled(prefix, Style::default().fg(theme.selected.into())), - Span::styled(format!("{}{}", r.name, following_tag), style), - ])) - }) - .collect(); - - let mut state = ListState::default(); - state.select(Some(app.view.friend_user_search_selected)); - let list = List::new(items).highlight_style( - Style::default() - .fg(theme.selected.into()) - .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), - ); - f.render_stateful_widget(list, results_area, &mut state); -} - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -/// Return the subset of friends that pass the active filter and search query. -pub fn filtered_friends(app: &App) -> Vec<&FriendEntry> { - let search_str: String = app.view.friend_search_input.iter().collect(); - let q = search_str.to_lowercase(); - - app - .friends - .iter() - .filter(|f| { - let passes_filter = match app.view.friend_filter { - FriendFilter::All => true, - FriendFilter::Online => f.is_online, - }; - let passes_search = q.is_empty() || f.name_lower.contains(&q); - passes_filter && passes_search - }) - .collect() -} diff --git a/src/tui/ui/help.rs b/src/tui/ui/help.rs deleted file mode 100644 index 98760f44..00000000 --- a/src/tui/ui/help.rs +++ /dev/null @@ -1,193 +0,0 @@ -use crate::core::app::App; - -/// Return Help rows matching the active filter. -/// Whitespace-separated terms are ANDed and matching is case-insensitive unless -/// a term contains an uppercase ASCII character. -pub fn get_filtered_help_docs(app: &App) -> Vec> { - filter_help_docs(crate::tui::keymap::help_rows(app), &app.view.help_filter) -} - -fn filter_help_docs(rows: Vec>, filter: &str) -> Vec> { - let terms = filter.split_whitespace().collect::>(); - if terms.is_empty() { - return rows; - } - - rows - .into_iter() - .filter(|row| { - terms - .iter() - .all(|term| row.iter().any(|field| help_field_contains(field, term))) - }) - .collect() -} - -fn help_field_contains(field: &str, term: &str) -> bool { - if term.chars().any(|c| c.is_ascii_uppercase()) { - field.contains(term) - } else { - field.to_lowercase().contains(&term.to_lowercase()) - } -} - -/// All byte offsets where `needle` starts in `haystack`, including overlapping -/// occurrences, which `str::match_indices` skips (it resumes after each match). -/// After each hit the search resumes one char later, not one match later. -fn overlapping_match_starts(haystack: &str, needle: &str) -> Vec { - let mut starts = Vec::new(); - let mut from = 0; - while let Some(pos) = haystack[from..].find(needle) { - let start = from + pos; - starts.push(start); - from = start + haystack[start..].chars().next().map_or(1, char::len_utf8); - } - starts -} - -/// Byte ranges within a rendered help line where filter terms match, sorted and -/// merged, so the Help table can highlight why each row survived the filter. -/// Case sensitivity follows the same smart-case rule as [`help_field_contains`]. -pub fn help_match_ranges(line: &str, filter: &str) -> Vec<(usize, usize)> { - let mut ranges: Vec<(usize, usize)> = Vec::new(); - // Lowercasing can change byte lengths for non-ASCII text, so offsets into the - // lowercased haystack do not index `line` directly. Record, for every byte of - // the lowercased haystack, the byte range of the original char it came from, - // and map matches back through that table. Built lazily on the first - // case-insensitive term. - let mut haystack = String::new(); - let mut byte_map: Vec<(usize, usize)> = Vec::new(); - for term in filter.split_whitespace() { - if term.chars().any(|c| c.is_ascii_uppercase()) { - for start in overlapping_match_starts(line, term) { - ranges.push((start, start + term.len())); - } - } else { - if haystack.is_empty() { - for (orig_start, c) in line.char_indices() { - let orig_end = orig_start + c.len_utf8(); - for lowered in c.to_lowercase() { - for _ in 0..lowered.len_utf8() { - byte_map.push((orig_start, orig_end)); - } - haystack.push(lowered); - } - } - } - let needle = term.to_lowercase(); - for start in overlapping_match_starts(&haystack, &needle) { - // A match may start or end inside the multi-char lowering of a single - // original char; widen it to whole original chars. - ranges.push((byte_map[start].0, byte_map[start + needle.len() - 1].1)); - } - } - } - ranges.sort_unstable(); - let mut merged: Vec<(usize, usize)> = Vec::new(); - for (start, end) in ranges { - match merged.last_mut() { - Some(last) if start <= last.1 => last.1 = last.1.max(end), - _ => merged.push((start, end)), - } - } - merged -} - -#[cfg(test)] -mod tests { - use super::*; - - fn rows() -> Vec> { - vec![ - vec![ - "Increase volume by 10%".to_string(), - "+".to_string(), - "General".to_string(), - ], - vec![ - "Search tracks in current playlist".to_string(), - "".to_string(), - "Track table (playlist views)".to_string(), - ], - vec![ - "Open settings".to_string(), - "".to_string(), - "General".to_string(), - ], - ] - } - - #[test] - fn help_filter_matches_terms_across_columns() { - let filtered = filter_help_docs(rows(), "ctrl playlist"); - - assert_eq!(filtered.len(), 1); - assert_eq!(filtered[0][0], "Search tracks in current playlist"); - } - - #[test] - fn help_filter_uses_smart_case() { - assert_eq!(filter_help_docs(rows(), "VOLUME").len(), 0); - assert_eq!(filter_help_docs(rows(), "volume").len(), 1); - assert_eq!(filter_help_docs(rows(), "Increase").len(), 1); - } - - #[test] - fn empty_help_filter_preserves_all_rows_and_order() { - let rows = rows(); - - assert_eq!(filter_help_docs(rows.clone(), " "), rows); - } - - #[test] - fn help_match_ranges_finds_every_occurrence_of_every_term() { - let ranges = help_match_ranges("Increase volume by 10% + General", "volume general"); - - assert_eq!(ranges, vec![(9, 15), (27, 34)]); - } - - #[test] - fn help_match_ranges_uses_smart_case() { - assert!(help_match_ranges("Increase volume", "VOLUME").is_empty()); - assert_eq!( - help_match_ranges("Increase volume", "Increase"), - vec![(0, 8)] - ); - } - - #[test] - fn help_match_ranges_merges_overlapping_matches() { - assert_eq!(help_match_ranges("aaaa", "aa aaa"), vec![(0, 4)]); - } - - #[test] - fn help_match_ranges_finds_overlapping_occurrences_of_one_term() { - // `str::match_indices` alone would stop at (0, 2), leaving the last 'a' - // unhighlighted. - assert_eq!(help_match_ranges("aaa", "aa"), vec![(0, 3)]); - assert_eq!(help_match_ranges("AAA", "AA"), vec![(0, 3)]); - } - - #[test] - fn help_match_ranges_maps_offsets_when_lowercasing_changes_byte_lengths() { - // 'İ' (2 bytes) lowercases to "i\u{307}" (3 bytes), shifting every - // lowercased offset after it by one. - assert_eq!(help_match_ranges("İ volume", "volume"), vec![(3, 9)]); - - // 'ẞ' (3 bytes) lowercases to 'ß' (2 bytes), so with a leading 'İ' the - // total byte length is unchanged while offsets in between are still - // shifted — a length check alone cannot catch this. - let line = "İ volume ẞ"; - assert_eq!(line.len(), line.to_lowercase().len()); - let ranges = help_match_ranges(line, "volume"); - assert_eq!(ranges, vec![(3, 9)]); - assert_eq!(&line[3..9], "volume"); - } - - #[test] - fn help_match_ranges_widens_matches_to_original_char_boundaries() { - // "i\u{307}" matches inside the lowering of 'İ'; the highlight covers the - // whole original char rather than slicing mid-char. - assert_eq!(help_match_ranges("İx", "i\u{307}"), vec![(0, 2)]); - } -} diff --git a/src/tui/ui/home.rs b/src/tui/ui/home.rs deleted file mode 100644 index 41f86a90..00000000 --- a/src/tui/ui/home.rs +++ /dev/null @@ -1,664 +0,0 @@ -use crate::core::app::{ActiveBlock, App}; -use crate::core::banner::BANNER; -use colorgrad::{self, Gradient}; -use ratatui::{ - layout::{Constraint, Layout, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span, Text}, - widgets::{Block, BorderType, Borders, Paragraph, Wrap}, - Frame, -}; -use std::sync::{Arc, Mutex, OnceLock}; -use unicode_width::UnicodeWidthStr; - -use super::util::get_color; -use crate::tui::theme::ThemeExt; - -#[derive(Clone, PartialEq)] -struct ChangelogCacheKey { - text: Color, - hint: Color, - inactive: Color, - banner: Color, - active: Color, - changelog_width: u16, -} - -impl ChangelogCacheKey { - fn from_theme(theme: &crate::core::user_config::Theme, changelog_width: u16) -> Self { - Self { - text: theme.text.into(), - hint: theme.hint.into(), - inactive: theme.inactive.into(), - banner: theme.banner.into(), - active: theme.active.into(), - changelog_width, - } - } -} - -struct ChangelogCache { - key: ChangelogCacheKey, - changelog_lines: Arc>>, -} - -static CHANGELOG_CACHE: OnceLock> = OnceLock::new(); -static CLEAN_CHANGELOG: OnceLock = OnceLock::new(); - -pub fn draw_home(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { - let [banner_area, changelog_area] = layout_chunk - .layout(&Layout::vertical([Constraint::Length(7), Constraint::Length(93)]).margin(2)); - - let current_route = app.get_current_route(); - let highlight_state = ( - current_route.active_block == ActiveBlock::Home, - current_route.hovered_block == ActiveBlock::Home, - ); - - let welcome = Block::default() - .title(Span::styled( - "Welcome!", - get_color(highlight_state, app.user_config.theme), - )) - .style(app.user_config.theme.base_style()) - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(get_color(highlight_state, app.user_config.theme)); - f.render_widget(welcome, layout_chunk); - - // Banner gradient is recomputed each frame for animation. When disabled, - // the banner uses the theme's banner color directly so ANSI palettes - // (e.g. pywal with the Terminal preset) restyle it live (#336). - let banner_text = if app.user_config.behavior.banner_gradient { - Text::from(build_banner_gradient_lines( - &app.user_config.theme, - app.view.animation_tick, - )) - } else { - Text::styled( - BANNER, - Style::default().fg(app.user_config.theme.banner.into()), - ) - }; - let base_changelog_lines = - get_changelog_cache(&app.user_config.theme, changelog_area.width, &app.log_path); - - // Contains the banner - let top_text = Paragraph::new(banner_text) - .style(app.user_config.theme.base_style()) - .block(Block::default()); - f.render_widget(top_text, banner_area); - - // Prepend global counter status to the changelog view - let mut changelog_lines = Vec::with_capacity(base_changelog_lines.len() + 4); - - // Listening streak strip (cache read only; populated by the history collector) - if let Some(streaks) = &app.listening_streaks { - if streaks.current_days > 0 { - let mut spans = vec![Span::styled( - format!("{}-day listening streak", streaks.current_days), - Style::default().fg(app.user_config.theme.active.into()), - )]; - if streaks.today_ms > 0 { - spans.push(Span::styled( - format!( - " · {} today", - crate::infra::history::format_duration(streaks.today_ms) - ), - Style::default().fg(app.user_config.theme.hint.into()), - )); - } else { - spans.push(Span::styled( - " · listen today to keep it!", - Style::default().fg(app.user_config.theme.hint.into()), - )); - } - changelog_lines.push(Line::from(spans)); - } - } - let counter_message = if cfg!(feature = "telemetry") { - if app.user_config.behavior.enable_global_song_count { - match app.global_song_count { - Some(count) => format!("Global songs played with spotatui: {}", count), - None if app.global_song_count_failed => { - "Global song counter unavailable right now.".to_string() - } - None => "Loading global song count...".to_string(), - } - } else { - "Global song counter disabled (Settings -> Behavior).".to_string() - } - } else { - "Global song counter unavailable (telemetry disabled in this build).".to_string() - }; - - let counter_style = Style::default().fg(app.user_config.theme.hint.into()); - changelog_lines.push(Line::from(vec![Span::styled( - counter_message, - counter_style, - )])); - changelog_lines.push(Line::from(vec![Span::styled( - "Join the community on Discord: https://spotatui.com/discord", - Style::default().fg(app.user_config.theme.active.into()), - )])); - changelog_lines.push(Line::from("")); - - // The cached changelog lines are pre-wrapped to the area width (one line == - // one row), so slice out the visible window ourselves instead of using - // Paragraph::scroll, which re-composes every line above the offset each - // frame. Only the visible cached lines get re-spanned (borrow_line). - let scroll = usize::from(app.view.home_scroll); - let height = usize::from(changelog_area.height); - let prefix_len = changelog_lines.len(); - let mut visible: Vec = Vec::with_capacity(height); - visible.extend(changelog_lines.drain(..).skip(scroll).take(height)); - let base_start = scroll.saturating_sub(prefix_len); - let remaining = height.saturating_sub(visible.len()); - if remaining > 0 { - if let Some(slice) = base_changelog_lines.get(base_start..) { - visible.extend(slice.iter().take(remaining).map(borrow_line)); - } - } - - // CHANGELOG - let bottom_text = Paragraph::new(Text::from(visible)) - .block(Block::default()) - .style(app.user_config.theme.base_style()) - .wrap(Wrap { trim: false }); - f.render_widget(bottom_text, changelog_area); -} - -fn get_clean_changelog() -> &'static str { - CLEAN_CHANGELOG - .get_or_init(|| { - let changelog = include_str!("../../../CHANGELOG.md"); - if cfg!(debug_assertions) { - changelog.to_string() - } else { - changelog.replace("\n## [Unreleased]\n", "") - } - }) - .as_str() -} - -fn get_changelog_cache( - theme: &crate::core::user_config::Theme, - changelog_width: u16, - log_path: &str, -) -> Arc>> { - let cache = CHANGELOG_CACHE.get_or_init(|| { - let changelog = get_clean_changelog(); - let key = ChangelogCacheKey::from_theme(theme, changelog_width); - Mutex::new(ChangelogCache { - changelog_lines: Arc::new(build_changelog_lines( - changelog, - theme, - changelog_width, - log_path, - )), - key, - }) - }); - let mut cache = cache.lock().expect("changelog cache lock failed"); - let key = ChangelogCacheKey::from_theme(theme, changelog_width); - if cache.key != key { - let changelog = get_clean_changelog(); - cache.changelog_lines = Arc::new(build_changelog_lines( - changelog, - theme, - changelog_width, - log_path, - )); - cache.key = key; - } - Arc::clone(&cache.changelog_lines) -} - -/// Re-spans a cached line borrowing its span content instead of cloning the -/// Strings. Cached changelog lines carry span-level styling only, so copying -/// the spans is lossless. -fn borrow_line<'a>(line: &'a Line<'static>) -> Line<'a> { - Line::from( - line - .spans - .iter() - .map(|span| Span::styled(span.content.as_ref(), span.style)) - .collect::>>(), - ) -} - -struct BannerGradientCache { - key: ( - crate::core::theme::Color, - crate::core::theme::Color, - crate::core::theme::Color, - ), - gradient: colorgrad::LinearGradient, -} - -static BANNER_GRADIENT_CACHE: OnceLock> = OnceLock::new(); - -fn build_banner_gradient(theme: &crate::core::user_config::Theme) -> colorgrad::LinearGradient { - fn to_rgba(color: ratatui::style::Color) -> (u8, u8, u8, u8) { - match color { - ratatui::style::Color::Rgb(r, g, b) => (r, g, b, 255), - ratatui::style::Color::Black => (0, 0, 0, 255), - ratatui::style::Color::Red => (255, 0, 0, 255), - ratatui::style::Color::Green => (0, 255, 0, 255), - ratatui::style::Color::Yellow => (255, 255, 0, 255), - ratatui::style::Color::Blue => (0, 0, 255, 255), - ratatui::style::Color::Magenta => (255, 0, 255, 255), - ratatui::style::Color::Cyan => (0, 255, 255, 255), - ratatui::style::Color::Gray => (128, 128, 128, 255), - ratatui::style::Color::DarkGray => (64, 64, 64, 255), - ratatui::style::Color::LightRed => (255, 128, 128, 255), - ratatui::style::Color::LightGreen => (128, 255, 128, 255), - ratatui::style::Color::LightYellow => (255, 255, 128, 255), - ratatui::style::Color::LightBlue => (128, 128, 255, 255), - ratatui::style::Color::LightMagenta => (255, 128, 255, 255), - ratatui::style::Color::LightCyan => (128, 255, 255, 255), - ratatui::style::Color::White => (255, 255, 255, 255), - _ => (255, 255, 255, 255), - } - } - - let c1 = to_rgba(theme.banner.into()); - let c2 = to_rgba(theme.hovered.into()); - let c3 = to_rgba(theme.active.into()); - - // Build a looping gradient: banner → hovered → active → banner - // This ensures a smooth wrap-around for continuous animation - colorgrad::GradientBuilder::new() - .colors(&[ - colorgrad::Color::from_rgba8(c1.0, c1.1, c1.2, c1.3), - colorgrad::Color::from_rgba8(c2.0, c2.1, c2.2, c2.3), - colorgrad::Color::from_rgba8(c3.0, c3.1, c3.2, c3.3), - colorgrad::Color::from_rgba8(c1.0, c1.1, c1.2, c1.3), - ]) - .build::() - .unwrap() -} - -fn build_banner_gradient_lines( - theme: &crate::core::user_config::Theme, - animation_tick: u64, -) -> Vec> { - // Only the phase changes per frame; the gradient itself depends solely on - // the three theme colors, so rebuild it only when those change. - let key = (theme.banner, theme.hovered, theme.active); - let cache = BANNER_GRADIENT_CACHE.get_or_init(|| { - Mutex::new(BannerGradientCache { - key, - gradient: build_banner_gradient(theme), - }) - }); - let mut cache = cache.lock().expect("banner gradient cache lock failed"); - if cache.key != key { - cache.gradient = build_banner_gradient(theme); - cache.key = key; - } - let grad = &cache.gradient; - - // Phase offset scrolls the gradient over time (~4 seconds per full cycle at 62 FPS) - let phase = animation_tick as f64 * 0.004; - - BANNER - .lines() - .enumerate() - .map(|(row, line)| { - let line_len = line.chars().count().max(1); - let spans: Vec> = line - .char_indices() - .enumerate() - .map(|(col, (byte_idx, ch))| { - // Diagonal gradient: combine column position and row offset - let t = ((col as f64 / line_len as f64) + (row as f64 * 0.08) + phase) % 1.0; - let [r, g, b, _] = grad.at(t as f32).to_rgba8(); - Span::styled( - // Slice of the static banner text: no String allocation per glyph - &line[byte_idx..byte_idx + ch.len_utf8()], - Style::default().fg(ratatui::style::Color::Rgb(r, g, b)), - ) - }) - .collect(); - Line::from(spans) - }) - .collect() -} - -#[derive(Clone)] -struct StyledSegment { - text: String, - style: Style, -} - -fn parse_markdown_inline(text: &str, base_style: Style) -> Vec { - let mut segments: Vec = Vec::new(); - let mut buffer = String::new(); - let mut chars = text.chars().peekable(); - let mut is_bold = false; - - while let Some(ch) = chars.next() { - if ch == '*' && chars.peek() == Some(&'*') { - if !buffer.is_empty() { - let style = if is_bold { - base_style.add_modifier(Modifier::BOLD) - } else { - base_style - }; - segments.push(StyledSegment { - text: std::mem::take(&mut buffer), - style, - }); - } - chars.next(); - is_bold = !is_bold; - } else { - buffer.push(ch); - } - } - - if !buffer.is_empty() { - let style = if is_bold { - base_style.add_modifier(Modifier::BOLD) - } else { - base_style - }; - segments.push(StyledSegment { - text: buffer, - style, - }); - } - - segments -} - -fn segments_to_spans(segments: Vec) -> Vec> { - segments - .into_iter() - .map(|segment| Span::styled(segment.text, segment.style)) - .collect() -} - -fn split_segments_by_whitespace(segments: &[StyledSegment]) -> Vec { - let mut tokens = Vec::new(); - - for segment in segments { - let mut buffer = String::new(); - let mut buffer_is_whitespace: Option = None; - - for ch in segment.text.chars() { - let is_whitespace = ch.is_whitespace(); - match buffer_is_whitespace { - Some(current_state) if current_state == is_whitespace => buffer.push(ch), - Some(_) => { - tokens.push(StyledSegment { - text: std::mem::take(&mut buffer), - style: segment.style, - // Assuming this is fine for now; might need adjustment if logic changes - }); - buffer.push(ch); - buffer_is_whitespace = Some(is_whitespace); - } - None => { - buffer.push(ch); - buffer_is_whitespace = Some(is_whitespace); - } - } - } - - if !buffer.is_empty() { - tokens.push(StyledSegment { - text: buffer, - style: segment.style, - }); - } - } - - tokens -} - -fn wrap_segments_with_indent( - segments: &[StyledSegment], - max_width: usize, - prefix: &str, - prefix_style: Style, - indent: &str, - indent_style: Style, -) -> Vec> { - let prefix_width = UnicodeWidthStr::width(prefix); - let indent_width = UnicodeWidthStr::width(indent); - let mut lines: Vec> = Vec::new(); - let tokens = split_segments_by_whitespace(segments); - let mut current: Vec = Vec::new(); - let mut current_width = 0; - let mut is_first_line = true; - - for token in tokens { - let token_width = UnicodeWidthStr::width(token.text.as_str()); - let is_whitespace = token.text.chars().all(char::is_whitespace); - let available_width = if is_first_line { - max_width.saturating_sub(prefix_width) - } else { - max_width.saturating_sub(indent_width) - }; - - if current_width == 0 && is_whitespace { - continue; - } - - if current_width + token_width > available_width && current_width > 0 { - let prefix_to_use = if is_first_line { prefix } else { indent }; - let style_to_use = if is_first_line { - prefix_style - } else { - indent_style - }; - let mut spans = Vec::with_capacity(current.len() + 1); - spans.push(Span::styled(prefix_to_use.to_string(), style_to_use)); - spans.extend(segments_to_spans(current)); - lines.push(Line::from(spans)); - - current = Vec::new(); - current_width = 0; - is_first_line = false; - - if is_whitespace { - continue; - } - } - - current_width += token_width; - current.push(token); - } - - if !current.is_empty() || lines.is_empty() { - let prefix_to_use = if is_first_line { prefix } else { indent }; - let style_to_use = if is_first_line { - prefix_style - } else { - indent_style - }; - let mut spans = Vec::with_capacity(current.len() + 1); - spans.push(Span::styled(prefix_to_use.to_string(), style_to_use)); - spans.extend(segments_to_spans(current)); - lines.push(Line::from(spans)); - } - - lines -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::app::App; - use ratatui::{backend::TestBackend, Terminal}; - - // draw_home layout at 100x40: margin 2 -> inner rows 2..38, banner rows - // 2..9, changelog rows 9..38. Row strings exclude the outer border columns. - fn rendered_rows(app: &App) -> Vec { - let mut terminal = Terminal::new(TestBackend::new(100, 40)).unwrap(); - terminal.draw(|f| draw_home(f, app, f.area())).unwrap(); - let buffer = terminal.backend().buffer(); - (0..40) - .map(|y| { - (2..98) - .filter_map(|x| buffer.cell((x, y)).map(|c| c.symbol().to_string())) - .collect::() - }) - .collect() - } - - #[test] - fn home_changelog_scroll_shifts_rows_one_to_one() { - let mut app = App::default(); - let rows_at_top = rendered_rows(&app); - assert!( - rows_at_top[9..38].iter().any(|row| !row.trim().is_empty()), - "changelog area should have content" - ); - - app.view.home_scroll = 3; - let rows_scrolled = rendered_rows(&app); - for y in 9..35 { - assert_eq!( - rows_scrolled[y], - rows_at_top[y + 3], - "scrolling by 3 should shift row {} up by exactly 3 rows", - y + 3 - ); - } - } - - #[test] - fn banner_gradient_toggle_switches_between_rgb_and_theme_color() { - fn banner_fg(app: &App) -> Color { - let mut terminal = Terminal::new(TestBackend::new(100, 40)).unwrap(); - terminal.draw(|f| draw_home(f, app, f.area())).unwrap(); - let buffer = terminal.backend().buffer(); - for y in 2..9u16 { - for x in 2..98u16 { - if let Some(cell) = buffer.cell((x, y)) { - if cell.symbol() == "█" { - return cell.fg; - } - } - } - } - panic!("no banner glyph found in banner rows"); - } - - let mut app = App::default(); - assert!( - matches!(banner_fg(&app), Color::Rgb(..)), - "gradient banner should render RGB colors" - ); - - // With the gradient off, the banner uses the theme's banner color as-is, - // so an ANSI color (Terminal preset) passes through to the terminal. - app.user_config.behavior.banner_gradient = false; - app.user_config.theme = crate::core::user_config::ThemePreset::Terminal.to_theme(); - assert_eq!(banner_fg(&app), app.user_config.theme.banner.into()); - } - - #[test] - fn home_changelog_scroll_past_end_renders_blank() { - let mut app = App::default(); - app.view.home_scroll = u16::MAX; - let rows = rendered_rows(&app); - for (y, row) in rows.iter().enumerate().take(38).skip(9) { - assert!( - row.trim().is_empty(), - "row {y} should be blank when scrolled past the end: {row:?}" - ); - } - } -} - -fn build_changelog_lines( - changelog: &str, - theme: &crate::core::user_config::Theme, - max_width: u16, - log_path: &str, -) -> Vec> { - let mut lines: Vec> = vec![]; - let max_width = usize::from(max_width); - - // Every line is wrapped to max_width here so that one cached line is one - // rendered row: draw_home slices this list by the scroll offset instead of - // paying Paragraph's per-frame wrap composition over everything above it. - let push_wrapped = |lines: &mut Vec>, segments: &[StyledSegment]| { - lines.extend(wrap_segments_with_indent( - segments, - max_width, - "", - Style::default(), - "", - Style::default(), - )); - }; - - push_wrapped( - &mut lines, - &[StyledSegment { - text: format!("Log located in {}", log_path), - style: Style::default().fg(theme.hint.into()), - }], - ); - - push_wrapped( - &mut lines, - &[StyledSegment { - text: - "Please report any bugs or missing features to https://github.com/LargeModGames/spotatui" - .to_string(), - style: Style::default().fg(theme.hint.into()), - }], - ); - lines.push(Line::from("")); - - for line in changelog.lines() { - if line.starts_with("- ") { - let content = line.trim_start_matches("- "); - let segments = parse_markdown_inline(content, Style::default().fg(theme.text.into())); - let bullet_prefix = " • "; - let indent = " ".repeat(UnicodeWidthStr::width(bullet_prefix)); - lines.extend(wrap_segments_with_indent( - &segments, - max_width, - bullet_prefix, - Style::default().fg(theme.inactive.into()), - &indent, - Style::default().fg(theme.text.into()), - )); - continue; - } - - if line.starts_with("# ") { - push_wrapped( - &mut lines, - &[StyledSegment { - text: line.trim_start_matches("# ").to_string(), - style: Style::default() - .fg(theme.banner.into()) - .add_modifier(Modifier::BOLD), - }], - ); - } else if line.starts_with("## [") { - push_wrapped( - &mut lines, - &[StyledSegment { - text: format!("═══ {} ═══", line.trim_start_matches("## ")), - style: Style::default() - .fg(theme.active.into()) - .add_modifier(Modifier::BOLD), - }], - ); - } else { - let segments = parse_markdown_inline(line, Style::default().fg(theme.text.into())); - push_wrapped(&mut lines, &segments); - } - } - - lines -} diff --git a/src/tui/ui/library.rs b/src/tui/ui/library.rs deleted file mode 100644 index 681fdbcb..00000000 --- a/src/tui/ui/library.rs +++ /dev/null @@ -1,388 +0,0 @@ -use crate::core::app::{ActiveBlock, App}; -use crate::core::source::Source; -use crate::tui::layout::{is_wide_layout, library_constraints, split_input_help_and_settings}; -use ratatui::{ - layout::{Constraint, Layout, Rect}, - Frame, -}; - -use super::{search::draw_input_and_help_box, util::draw_selectable_list}; - -pub fn draw_library_block(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { - let current_route = app.get_current_route(); - let highlight_state = ( - current_route.active_block == ActiveBlock::Library, - current_route.hovered_block == ActiveBlock::Library, - ); - let rows: Vec<&str> = app - .library_rows() - .iter() - .map(|target| target.name()) - .collect(); - draw_selectable_list( - f, - app, - layout_chunk, - "Library", - &rows, - highlight_state, - Some(app.library_cursor()), - ); -} - -/// Sidebar label for the pinned community playlist. -fn community_pin_label() -> String { - "\u{1F4CC} spotatui community".to_string() -} - -pub fn draw_playlist_block(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { - let highlight_state = { - let current_route = app.get_current_route(); - ( - current_route.active_block == ActiveBlock::MyPlaylists, - current_route.hovered_block == ActiveBlock::MyPlaylists, - ) - }; - - // Local Files: the sidebar Playlists panel lists the local folders for the - // active source instead of Spotify playlists (no write, so no "Add Playlist"). - if app.active_source == Source::Local { - let items: Vec = if app.local_playlists.is_empty() { - vec!["(no folders \u{2014} set music dir, then press `d`)".to_string()] - } else { - app - .local_playlists - .iter() - .map(|p| format!("\u{1F4C1} {}", p.name)) - .collect() - }; - draw_selectable_list( - f, - app, - layout_chunk, - "Local Files", - &items, - highlight_state, - app.view.selected_playlist_index, - ); - return; - } - - // Subsonic: the sidebar Playlists panel lists the server's playlists (no - // local-write support, so no "Add Playlist"). - if app.active_source == Source::Subsonic { - let items: Vec = if app.subsonic_playlists.is_empty() { - vec!["(no playlists \u{2014} configure server, then press `d`)".to_string()] - } else { - app - .subsonic_playlists - .iter() - .map(|p| format!("\u{1F3B5} {}", p.name)) - .collect() - }; - draw_selectable_list( - f, - app, - layout_chunk, - "Subsonic", - &items, - highlight_state, - app.view.selected_playlist_index, - ); - return; - } - - // Qobuz: the sidebar Playlists panel lists the favorites row, the user's - // playlists, and the favorite albums, each opening the shared track table. - if app.active_source == Source::Qobuz { - let items: Vec = if app.qobuz_playlists.is_empty() { - vec!["(not logged in \u{2014} press `d`, pick Qobuz)".to_string()] - } else { - app.qobuz_playlists.iter().map(|p| p.name.clone()).collect() - }; - draw_selectable_list( - f, - app, - layout_chunk, - "Qobuz", - &items, - highlight_state, - app.view.selected_playlist_index, - ); - return; - } - - // Internet Radio: the sidebar Playlists panel lists the configured stations. - // A station is a leaf — Enter plays it directly instead of drilling in. - if app.active_source == Source::Radio { - let items: Vec = if app.radio_stations.is_empty() { - vec!["(no saved stations \u{2014} search to add)".to_string()] - } else { - app - .radio_stations - .iter() - .map(|s| format!("\u{1F4FB} {}", s.name)) - .collect() - }; - draw_selectable_list( - f, - app, - layout_chunk, - "Radio Stations", - &items, - highlight_state, - app.view.selected_playlist_index, - ); - return; - } - - // YouTube: the sidebar Playlists panel lists the user's *local* YouTube - // playlists (youtube_playlists.yml — no Google account), plus a create - // entry. Videos are found via search and added with `w`. - if app.active_source == Source::YouTube { - let mut items: Vec = app - .youtube_playlists - .iter() - .map(|p| format!("\u{1F4FC} {} ({})", p.name, p.track_count)) - .collect(); - items.push("+ New Playlist".to_string()); - draw_selectable_list( - f, - app, - layout_chunk, - "YouTube Playlists", - &items, - highlight_state, - app.view.selected_playlist_index, - ); - return; - } - - let display_items = app.get_playlist_display_items(); - - let playlist_items: Vec = if app.playlist_folder_items.is_empty() { - // Fallback only when folder-aware items are not initialized yet. Keep the - // pin at row 0 so this branch stays consistent with the display-item count - // (which injects the pin) and the Enter/click index math. - let mut names: Vec = Vec::new(); - if app.community_pin_visible() { - names.push(community_pin_label()); - } - if let Some(p) = &app.playlists { - names.extend(p.items.iter().map(|item| item.name.to_owned())); - } - names - } else { - display_items - .iter() - .map(|item| match item { - crate::core::app::PlaylistFolderItem::Folder(folder) => { - if folder.name.starts_with('\u{2190}') { - // Back entry (already has arrow prefix) - folder.name.clone() - } else { - format!("\u{1F4C1} {}", folder.name) - } - } - crate::core::app::PlaylistFolderItem::Playlist { index, .. } => app - .all_playlists - .get(*index) - .map(|p| p.name.clone()) - .unwrap_or_else(|| "Unknown".to_string()), - crate::core::app::PlaylistFolderItem::CommunityPin => community_pin_label(), - }) - .collect() - }; - - // "+ Add Playlist" is the leading row (row 0), above the display items. - let mut display_list = vec!["+ Add Playlist".to_string()]; - display_list.extend(playlist_items); - - draw_selectable_list( - f, - app, - layout_chunk, - "Playlists", - &display_list, - highlight_state, - app.view.selected_playlist_index, - ); -} - -pub fn draw_user_block(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { - let lib_constraints = library_constraints(&app.runtime_state); - if is_wide_layout(app) { - let [input_area, library_area, playlist_area] = layout_chunk.layout(&Layout::vertical([ - Constraint::Length(3), - lib_constraints[0], - lib_constraints[1], - ])); - - // Search input and help - let [input_text_area, help_area, settings_area] = - split_input_help_and_settings(app, input_area); - draw_input_and_help_box(f, app, input_text_area, help_area, settings_area); - draw_library_block(f, app, library_area); - draw_playlist_block(f, app, playlist_area); - } else { - let [library_area, playlist_area] = layout_chunk.layout(&Layout::vertical(lib_constraints)); - draw_library_block(f, app, library_area); - draw_playlist_block(f, app, playlist_area); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::plugin_api::PlaylistInfo; - use ratatui::{backend::TestBackend, Terminal}; - - fn rendered(app: &App, area: Rect) -> String { - let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap(); - terminal.draw(|f| draw_user_block(f, app, area)).unwrap(); - let buffer = terminal.backend().buffer(); - (0..area.height) - .flat_map(|y| (0..area.width).map(move |x| (x, y))) - .filter_map(|(x, y)| buffer.cell((x, y)).map(|c| c.symbol().to_string())) - .collect() - } - - fn folder(name: &str) -> PlaylistInfo { - PlaylistInfo { - uri: format!("file:///music/{name}"), - name: name.to_string(), - owner: "local".to_string(), - track_count: 0, - id: None, - owner_id: None, - collaborative: false, - public: None, - image_url: None, - } - } - - #[test] - fn local_source_sidebar_lists_folders_below_the_free_library_rows() { - let mut app = App::default_connected(); - app.active_source = Source::Local; - app.local_playlists = vec![folder("Jazz")]; - let content = rendered(&app, Rect::new(0, 0, 32, 40)); - assert!( - content.contains("Jazz"), - "local folder should render: {content}" - ); - assert!( - content.contains("Local Files"), - "panel title should be Local Files: {content}" - ); - assert!( - content.contains("Stats"), - "the free library rows stay under Local: {content}" - ); - assert!( - !content.contains("Liked Songs"), - "Spotify library entries must be hidden under Local: {content}" - ); - } - - #[test] - fn spotify_source_sidebar_shows_library() { - let app = App::default_connected(); // Spotify is the default source - let content = rendered(&app, Rect::new(0, 0, 32, 40)); - assert!( - content.contains("Liked Songs"), - "Spotify library entries should render: {content}" - ); - } - - #[test] - fn spotify_scope_without_a_session_keeps_only_the_free_library_rows() { - let app = App::default(); - let content = rendered(&app, Rect::new(0, 0, 32, 40)); - assert!( - content.contains("Stats") && content.contains("Friends"), - "Friends and Stats need no session: {content}" - ); - assert!( - !content.contains("Liked Songs"), - "Spotify rows need a session: {content}" - ); - } - - #[test] - fn community_pin_renders_below_add_and_above_playlists() { - use crate::core::app::PlaylistFolderItem; - use crate::core::test_helpers::playlist_info; - let mut app = App::default(); // Spotify default, toggle on by default - app.all_playlists = vec![playlist_info( - "37i9dQZF1DXcBWIGoYBM5M", - "My Mix", - "me", - false, - )]; - app.playlist_folder_items = vec![PlaylistFolderItem::Playlist { - index: 0, - current_id: 0, - }]; - let content = rendered(&app, Rect::new(0, 0, 40, 40)); - // Top-to-bottom: "+ Add Playlist", then the pin, then the user's playlists. - let add_idx = content - .find("Add Playlist") - .expect("Add Playlist should render"); - let pin_idx = content - .find("spotatui community") - .expect("pin should render for Spotify"); - let mix_idx = content.find("My Mix").expect("real playlist should render"); - assert!(add_idx < pin_idx, "Add must be above the pin: {content}"); - assert!(pin_idx < mix_idx, "pin must be above playlists: {content}"); - } - - #[test] - fn community_pin_absent_under_non_spotify_source() { - let mut app = App::default(); - app.active_source = Source::Local; - app.local_playlists = vec![folder("Jazz")]; - let content = rendered(&app, Rect::new(0, 0, 40, 40)); - assert!( - !content.contains("spotatui community"), - "pin must not render off Spotify: {content}" - ); - } - - #[test] - fn community_pin_absent_when_toggle_off() { - let mut app = App::default(); - app.user_config.behavior.pin_community_playlist = false; - let content = rendered(&app, Rect::new(0, 0, 40, 40)); - assert!( - !content.contains("spotatui community"), - "pin must not render when toggled off: {content}" - ); - } - - #[test] - fn community_pin_absent_when_already_following() { - use crate::core::app::{PlaylistFolderItem, COMMUNITY_PLAYLIST_ID}; - use crate::core::test_helpers::playlist_info; - let mut app = App::default(); - app.all_playlists = vec![playlist_info( - COMMUNITY_PLAYLIST_ID, - "Community Follow", - "spotatui", - false, - )]; - app.playlist_folder_items = vec![PlaylistFolderItem::Playlist { - index: 0, - current_id: 0, - }]; - let content = rendered(&app, Rect::new(0, 0, 40, 40)); - assert!( - content.contains("Community Follow"), - "followed playlist should render: {content}" - ); - assert!( - !content.contains("spotatui community"), - "pin must be suppressed when already following: {content}" - ); - } -} diff --git a/src/tui/ui/lyrics.rs b/src/tui/ui/lyrics.rs deleted file mode 100644 index 9ccc943d..00000000 --- a/src/tui/ui/lyrics.rs +++ /dev/null @@ -1,297 +0,0 @@ -use crate::core::app::{active_lyric_index, App, LyricsStatus}; -use crate::tui::layout::fullscreen_view_layout; -use ratatui::{ - layout::{Alignment, Rect}, - style::{Modifier, Style}, - widgets::{Block, Borders, Paragraph}, - Frame, -}; - -use super::player::draw_playbar; -use crate::tui::theme::EmphasisExt; - -pub fn draw_lyrics_view(f: &mut Frame<'_>, app: &App) { - let (content_area, playbar_area) = fullscreen_view_layout(&app.runtime_state, f.area()); - - draw_lyrics(f, app, content_area); - if let Some(playbar_area) = playbar_area { - draw_playbar(f, app, playbar_area); - } -} - -fn draw_lyrics(f: &mut Frame<'_>, app: &App, area: Rect) { - let theme = &app.user_config.theme; - - let mut notes: Vec = Vec::new(); - if app.lyrics_status == LyricsStatus::Found { - if !app.lyrics_synced { - notes.push("timing estimated".to_string()); - } - let offset_ms = app.view.lyrics_view.timing_offset_ms; - if offset_ms != 0 { - notes.push(format!("offset {:+.1}s", offset_ms as f64 / 1000.0)); - } - } - let title = if notes.is_empty() { - " Lyrics ".to_string() - } else { - format!(" Lyrics ({}) ", notes.join(", ")) - }; - let block = Block::default() - .borders(Borders::ALL) - .title(title) - .style(Style::default().fg(theme.inactive.into())); - f.render_widget(block.clone(), area); - - let inner_area = block.inner(area); - if inner_area.width == 0 || inner_area.height == 0 { - return; - } - - if app.lyrics_status != LyricsStatus::Found { - draw_state_message(f, app, inner_area); - return; - } - - let Some(lyrics) = &app.lyrics else { - return; - }; - if lyrics.is_empty() { - return; - } - - // Reserve the bottom row for the browsing hint while in manual mode. - let manual = app.view.lyrics_view.manual_index.is_some(); - let mut lyric_area = inner_area; - if manual && lyric_area.height > 1 { - lyric_area.height -= 1; - } - - let active_idx = active_lyric_index(lyrics, app.lyric_progress_ms()); - let focus_idx = app - .view - .lyrics_view - .manual_index - .unwrap_or(active_idx) - .min(lyrics.len() - 1); - - // The focused line sits at the vertical center; `scroll_pos` is the eased - // fractional line index, so the whole column glides between lines instead - // of snapping (line spacing stays intact because every line shares the same - // rounding offset). - let target_row = i64::from(lyric_area.y) + i64::from(lyric_area.height / 2); - let scroll_offset = app.view.lyrics_view.scroll_pos; - - for (line_idx, (_, text)) in lyrics.iter().enumerate() { - let y = target_row + (line_idx as f64 - scroll_offset).round() as i64; - if y < i64::from(lyric_area.y) || y >= i64::from(lyric_area.y) + i64::from(lyric_area.height) { - continue; - } - - let style = if line_idx == active_idx { - Style::default() - .fg(theme.highlighted_lyrics.into()) - .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)) - } else if manual && line_idx == focus_idx { - Style::default().fg(theme.hovered.into()) - } else { - Style::default().fg(theme.inactive.into()) - }; - - f.render_widget( - Paragraph::new(text.clone()) - .style(style) - .alignment(Alignment::Center), - Rect { - x: lyric_area.x, - y: y as u16, - width: lyric_area.width, - height: 1, - }, - ); - } - - if manual { - f.render_widget( - Paragraph::new("browsing · ↑/↓ scroll · Esc follow") - .style(Style::default().fg(theme.hint.into())) - .alignment(Alignment::Center), - Rect { - y: inner_area.y + inner_area.height - 1, - height: 1, - ..inner_area - }, - ); - } -} - -/// Centered two-line message for the non-Found lyric states. -fn draw_state_message(f: &mut Frame<'_>, app: &App, inner_area: Rect) { - let (primary, secondary) = match app.lyrics_status { - LyricsStatus::Loading => ("Fetching lyrics…", "from LRCLIB"), - LyricsStatus::NotFound => ("No lyrics for this track", "lyrics provided by LRCLIB"), - LyricsStatus::NotStarted => ("Nothing playing", "start a track to see lyrics"), - LyricsStatus::Found => return, - }; - - let theme = &app.user_config.theme; - let primary_y = inner_area.y + inner_area.height.saturating_sub(1) / 2; - f.render_widget( - Paragraph::new(primary) - .style(Style::default().fg(theme.text.into())) - .alignment(Alignment::Center), - Rect { - y: primary_y, - height: 1, - ..inner_area - }, - ); - let secondary_y = primary_y + 1; - if secondary_y < inner_area.y + inner_area.height { - f.render_widget( - Paragraph::new(secondary) - .style(Style::default().fg(theme.inactive.into())) - .alignment(Alignment::Center), - Rect { - y: secondary_y, - height: 1, - ..inner_area - }, - ); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::app::{ActiveBlock, RouteId}; - use ratatui::{backend::TestBackend, buffer::Buffer, Terminal}; - - fn app_with_lyrics() -> App { - let mut app = App::default(); - app.lyrics = Some(vec![ - (0, "first line".to_string()), - (10_000, "second line".to_string()), - (20_000, "third line".to_string()), - ]); - app.lyrics_status = LyricsStatus::Found; - app.lyrics_synced = true; - app.song_progress_ms = 11_000; - app.view.lyrics_view.scroll_pos = 1.0; - app.push_navigation_stack(RouteId::LyricsView, ActiveBlock::LyricsView); - app - } - - fn render(app: &App) -> Buffer { - let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); - terminal.draw(|f| draw_lyrics_view(f, app)).unwrap(); - terminal.backend().buffer().clone() - } - - fn buffer_text(buffer: &Buffer) -> String { - (0..buffer.area().height) - .map(|y| { - (0..buffer.area().width) - .filter_map(|x| buffer.cell((x, y)).map(|c| c.symbol().to_string())) - .collect::() - }) - .collect::>() - .join("\n") - } - - fn row_of(buffer: &Buffer, needle: &str) -> Option { - (0..buffer.area().height).find(|&y| { - (0..buffer.area().width) - .filter_map(|x| buffer.cell((x, y)).map(|c| c.symbol().to_string())) - .collect::() - .contains(needle) - }) - } - - #[test] - fn renders_all_lines_with_active_centered() { - let app = app_with_lyrics(); - let buffer = render(&app); - let text = buffer_text(&buffer); - assert!(text.contains(" Lyrics "), "missing block title:\n{text}"); - assert!(text.contains("first line"), "missing prior line:\n{text}"); - assert!(text.contains("second line"), "missing active line:\n{text}"); - assert!(text.contains("third line"), "missing next line:\n{text}"); - - // scroll_pos = 1.0 puts the active line at the vertical center of the - // lyric area. - let active_row = row_of(&buffer, "second line").expect("active line row"); - let expected = 1 + (24 - app.runtime_state.playbar_height_rows - 2) / 2; - assert_eq!(active_row, expected); - } - - #[test] - fn fractional_scroll_shifts_the_whole_column() { - let mut app = app_with_lyrics(); - let before = row_of(&render(&app), "second line").expect("active line row"); - app.view.lyrics_view.scroll_pos = 1.6; - let buffer = render(&app); - let after = row_of(&buffer, "second line").expect("active line row"); - assert_eq!(after, before - 1, "column should glide up mid-animation"); - // Line spacing stays intact while gliding. - let third = row_of(&buffer, "third line").expect("next line row"); - assert_eq!(third, after + 1); - } - - #[test] - fn title_notes_estimated_timing_for_plain_lyrics() { - let mut app = app_with_lyrics(); - app.lyrics_synced = false; - let text = buffer_text(&render(&app)); - assert!( - text.contains("Lyrics (timing estimated)"), - "missing timing hint:\n{text}" - ); - } - - #[test] - fn title_shows_timing_offset_and_it_shifts_the_active_line() { - let mut app = app_with_lyrics(); - // progress 11s + 9.5s nudge = 20.5s, past the third line's timestamp. - app.view.lyrics_view.timing_offset_ms = 9_500; - let buffer = render(&app); - let text = buffer_text(&buffer); - assert!( - text.contains("Lyrics (offset +9.5s)"), - "missing offset note:\n{text}" - ); - let active_style = buffer - .cell(( - buffer.area().width / 2, - row_of(&buffer, "third line").expect("third line row"), - )) - .unwrap() - .style(); - assert_eq!( - active_style.fg, - Some(app.user_config.theme.highlighted_lyrics.into()), - "nudged line should be the highlighted one" - ); - } - - #[test] - fn renders_browsing_hint_in_manual_mode() { - let mut app = app_with_lyrics(); - app.view.lyrics_view.manual_index = Some(2); - let text = buffer_text(&render(&app)); - assert!(text.contains("browsing"), "missing browsing hint:\n{text}"); - assert!(text.contains("third line"), "missing browsed line:\n{text}"); - } - - #[test] - fn renders_not_found_state() { - let mut app = app_with_lyrics(); - app.lyrics = None; - app.lyrics_status = LyricsStatus::NotFound; - let text = buffer_text(&render(&app)); - assert!( - text.contains("No lyrics for this track"), - "missing not-found message:\n{text}" - ); - } -} diff --git a/src/tui/ui/mod.rs b/src/tui/ui/mod.rs deleted file mode 100644 index 7868af3a..00000000 --- a/src/tui/ui/mod.rs +++ /dev/null @@ -1,233 +0,0 @@ -#[cfg(feature = "ai-dj")] -pub mod ai_dj; -pub mod artist; -pub mod audio_analysis; -pub mod columns; -pub mod create_playlist; -pub mod discover; -pub mod friends; -pub mod help; -pub mod home; -pub mod library; -pub mod lyrics; -pub mod player; -pub mod plugin_screen; -pub mod popups; -pub mod search; -pub mod settings; -pub mod stats; -pub mod tables; -pub mod util; - -use crate::core::app::{App, RouteId}; -use crate::tui::layout::{compute_main_layout, is_wide_layout}; -use ratatui::{layout::Rect, Frame}; - -pub use self::artist::draw_artist_albums; -pub use self::create_playlist::draw_create_playlist_form; -pub use self::discover::draw_discover; -pub use self::friends::draw_friends; -pub use self::home::draw_home; -pub use self::library::draw_user_block; -pub use self::lyrics::draw_lyrics_view; -#[cfg(feature = "cover-art")] -pub use self::player::draw_cover_art_view; -pub use self::player::draw_miniplayer; -pub use self::player::{draw_device_list, draw_playbar}; -pub use self::plugin_screen::draw_plugin_screen; -pub use self::popups::{ - draw_announcement_prompt, draw_community_pin_prompt, draw_dialog, draw_error_screen, - draw_exit_prompt, draw_help_menu, draw_party, draw_plugin_popup, draw_queue, draw_recap_prompt, - draw_sort_menu, ensure_help_menu_model, -}; -pub use self::search::{draw_input_and_help_box, draw_search_results}; -pub use self::stats::draw_stats; -pub use self::tables::{ - draw_album_list, draw_album_table, draw_artist_table, draw_local_browser, draw_podcast_table, - draw_recently_played_table, draw_recommendations_table, draw_show_episodes, draw_song_table, -}; -pub fn draw_main_layout(f: &mut Frame<'_>, app: &App) { - if let Some(areas) = compute_main_layout(app) { - // In wide layout the input row lives inside the sidebar and is drawn by - // draw_user_block (it varies per source); the top row is only drawn here. - if !is_wide_layout(app) { - if let (Some(input_area), Some(help_area), Some(settings_area)) = - (areas.input, areas.help, areas.settings) - { - draw_input_and_help_box(f, app, input_area, help_area, settings_area); - } - } - draw_user_block(f, app, areas.sidebar); - draw_route_content(f, app, areas.content); - draw_playbar(f, app, areas.playbar); - } - - // Possibly draw confirm dialog - draw_dialog(f, app); - - // Possibly draw sort menu - draw_sort_menu(f, app); -} - -fn draw_route_content(f: &mut Frame<'_>, app: &App, content_area: Rect) { - let current_route = app.get_current_route(); - - match current_route.id { - RouteId::Search => { - draw_search_results(f, app, content_area); - } - RouteId::TrackTable => { - draw_song_table(f, app, content_area); - } - RouteId::AlbumTracks => { - draw_album_table(f, app, content_area); - } - RouteId::RecentlyPlayed => { - draw_recently_played_table(f, app, content_area); - } - RouteId::Artist => { - draw_artist_albums(f, app, content_area); - } - RouteId::AlbumList => { - draw_album_list(f, app, content_area); - } - RouteId::PodcastEpisodes => { - draw_show_episodes(f, app, content_area); - } - RouteId::Home => { - draw_home(f, app, content_area); - } - RouteId::Discover => { - draw_discover(f, app, content_area); - } - RouteId::Friends => { - draw_friends(f, app, content_area); - } - #[cfg(feature = "ai-dj")] - RouteId::AiDj => { - ai_dj::draw_ai_dj(f, app, content_area); - } - RouteId::Stats => { - draw_stats(f, app, content_area); - } - RouteId::Artists => { - draw_artist_table(f, app, content_area); - } - RouteId::LocalBrowser => { - draw_local_browser(f, app, content_area); - } - RouteId::Podcasts => { - draw_podcast_table(f, app, content_area); - } - RouteId::Recommendations => { - draw_recommendations_table(f, app, content_area); - } - RouteId::Error - | RouteId::SelectedDevice - | RouteId::Analysis - | RouteId::LyricsView - | RouteId::CoverArtView - | RouteId::MiniPlayer - | RouteId::AnnouncementPrompt - | RouteId::RecapPrompt - | RouteId::CommunityPinPrompt - | RouteId::ExitPrompt - | RouteId::Settings - | RouteId::HelpMenu - | RouteId::Queue - | RouteId::Party - | RouteId::PluginScreen(_) => {} // These are drawn outside the main routed content area. - RouteId::Dialog => {} // This is handled in draw_dialog. - RouteId::CreatePlaylist => {} // This is drawn as an overlay via draw_create_playlist_form. - }; -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::app::ActiveBlock; - use crate::core::geometry::Viewport; - use ratatui::{backend::TestBackend, buffer::Buffer, Terminal}; - - fn render(width: u16, height: u16) -> (App, Buffer) { - let mut app = App::default(); - app.view.size = Viewport { width, height }; - app.push_navigation_stack(RouteId::Home, ActiveBlock::Home); - let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); - terminal.draw(|f| draw_main_layout(f, &app)).unwrap(); - let buffer = terminal.backend().buffer().clone(); - (app, buffer) - } - - fn row_text(buffer: &Buffer, y: u16) -> String { - (0..buffer.area().width) - .filter_map(|x| buffer.cell((x, y)).map(|c| c.symbol().to_string())) - .collect() - } - - // The drawn input/help/settings boxes and sidebar panels must land exactly - // where `compute_main_layout` puts the mouse hitboxes, and the search box - // must be drawn once (a second copy used to overlay the Library panel in - // wide layout, shifting it down three rows). - #[test] - fn wide_layout_draws_input_row_and_library_at_hitbox_positions() { - let (app, buffer) = render(160, 50); - let areas = compute_main_layout(&app).expect("layout areas"); - let input = areas.input.expect("input area"); - - let input_row = row_text(&buffer, input.y); - assert!(input_row.contains("Search"), "input row: {input_row}"); - assert!(input_row.contains("Help"), "input row: {input_row}"); - assert!(input_row.contains("Settings"), "input row: {input_row}"); - - let library_row = row_text(&buffer, areas.library.y); - assert!( - library_row.contains("Library"), - "library panel not at hitbox row: {library_row}" - ); - let playlists_row = row_text(&buffer, areas.playlists.y); - assert!( - playlists_row.contains("Playlists"), - "playlists panel not at hitbox row: {playlists_row}" - ); - } - - #[test] - fn narrow_layout_draws_input_row_and_library_at_hitbox_positions() { - let (app, buffer) = render(100, 40); - let areas = compute_main_layout(&app).expect("layout areas"); - let input = areas.input.expect("input area"); - - let input_row = row_text(&buffer, input.y); - assert!(input_row.contains("Search"), "input row: {input_row}"); - assert!(input_row.contains("Help"), "input row: {input_row}"); - assert!(input_row.contains("Settings"), "input row: {input_row}"); - - let library_row = row_text(&buffer, areas.library.y); - assert!( - library_row.contains("Library"), - "library panel not at hitbox row: {library_row}" - ); - } - - #[test] - fn stats_route_renders_period_tabs() { - let mut app = App::default(); - app.view.size = Viewport { - width: 160, - height: 50, - }; - app.push_navigation_stack(RouteId::Stats, ActiveBlock::Stats); - let mut terminal = Terminal::new(TestBackend::new(160, 50)).unwrap(); - terminal.draw(|f| draw_main_layout(f, &app)).unwrap(); - let buffer = terminal.backend().buffer().clone(); - - let all_text: String = (0..50).map(|y| row_text(&buffer, y)).collect(); - assert!(all_text.contains("Stats"), "missing Stats title"); - assert!( - all_text.contains("Last 30 Days"), - "missing selected period label" - ); - assert!(all_text.contains("Top Tracks"), "missing Top Tracks panel"); - } -} diff --git a/src/tui/ui/player.rs b/src/tui/ui/player.rs deleted file mode 100644 index 4b0d40dd..00000000 --- a/src/tui/ui/player.rs +++ /dev/null @@ -1,2261 +0,0 @@ -#[cfg(feature = "cover-art")] -use crate::tui::layout::center_rect_within; -#[cfg(feature = "cover-art")] -use crate::tui::layout::fullscreen_view_layout; -use crate::tui::theme::{EmphasisExt, ThemeExt}; -#[cfg(feature = "cover-art")] -use ratatui::layout::Alignment; - -use crate::core::{ - app::{ActiveBlock, App, SourceFocus}, - source::Source, -}; -use crate::tui::layout::miniplayer_playbar_area; -use ratatui::{ - layout::{Constraint, Layout, Position, Rect}, - style::{Modifier, Style}, - text::{Line, Span, Text}, - widgets::{ - canvas::Canvas, Block, BorderType, Borders, LineGauge, List, ListItem, ListState, Paragraph, - Wrap, - }, - Frame, -}; -use rspotify::model::enums::RepeatState; -use rspotify::model::PlayableItem; -use rspotify::prelude::Id; -use unicode_width::UnicodeWidthStr; - -use super::util::{ - create_artist_string, display_track_progress, get_color, get_track_progress_percentage, -}; - -const PLAYBAR_CONTROLS: [PlaybarControl; 8] = [ - PlaybarControl::Prev, - PlaybarControl::PlayPause, - PlaybarControl::Next, - PlaybarControl::Shuffle, - PlaybarControl::Repeat, - PlaybarControl::Like, - PlaybarControl::VolumeDown, - PlaybarControl::VolumeUp, -]; -#[cfg(feature = "cover-art")] -const COVER_ART_CELL_RATIO: f32 = 1.9; -#[cfg(feature = "cover-art")] -const PLAYBAR_TRACK_INFO_ROWS: u16 = 2; -#[cfg(feature = "cover-art")] -const PLAYBAR_PROGRESS_ROWS: u16 = 1; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum PlaybarControl { - Prev, - PlayPause, - Next, - Shuffle, - Repeat, - Like, - VolumeDown, - VolumeUp, -} - -impl PlaybarControl { - /// The built-in (default) label for this control. - const fn default_label(self) -> &'static str { - match self { - Self::Prev => "[Prev]", - Self::PlayPause => "[Play/Pause]", - Self::Next => "[Next]", - Self::Shuffle => "[Shuffle]", - Self::Repeat => "[Repeat]", - Self::Like => "[Like]", - Self::VolumeDown => "[Vol-]", - Self::VolumeUp => "[Vol+]", - } - } - - /// The `playbar_control_labels` map key for this control. - const fn config_key(self) -> &'static str { - match self { - Self::Prev => "prev", - Self::PlayPause => "play_pause", - Self::Next => "next", - Self::Shuffle => "shuffle", - Self::Repeat => "repeat", - Self::Like => "like", - Self::VolumeDown => "vol_down", - Self::VolumeUp => "vol_up", - } - } - - /// The label for this control, honoring a `playbar_control_labels` override - /// from config (falling back to the built-in default). Mouse hit-testing - /// uses the same resolver, so hitboxes always match what's rendered. - fn label( - self, - behavior: &crate::core::user_config::BehaviorConfig, - ) -> std::borrow::Cow<'static, str> { - match behavior.playbar_control_labels.get(self.config_key()) { - Some(override_label) => std::borrow::Cow::Owned(override_label.clone()), - None => std::borrow::Cow::Borrowed(self.default_label()), - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct PlaybarControlHitbox { - control: PlaybarControl, - rect: Rect, -} - -#[derive(Clone, Copy, Debug)] -struct PlaybarLayoutAreas { - artist_area: Rect, - controls_area: Rect, - progress_area: Rect, - #[cfg(feature = "cover-art")] - cover_art: Option, -} - -#[cfg(feature = "cover-art")] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct PlaybarCoverLayout { - text_area: Rect, - slot: Rect, - image_area: Rect, -} - -fn split_playbar_rows(area: Rect) -> (Rect, Rect, Rect) { - if area.width == 0 || area.height == 0 { - let empty = Rect::new(area.x, area.y, area.width, 0); - return (empty, empty, empty); - } - - if area.height == 1 { - let empty = Rect::new(area.x, area.y, area.width, 0); - return (empty, area, empty); - } - - if area.height == 2 { - let [controls_area, progress_area] = area.layout(&Layout::vertical([ - Constraint::Length(1), - Constraint::Length(1), - ])); - let empty = Rect::new(area.x, area.y, area.width, 0); - return (empty, controls_area, progress_area); - } - - let [artist_area, controls_area, progress_area] = area.layout(&Layout::vertical([ - Constraint::Min(1), - Constraint::Length(1), - Constraint::Length(1), - ])); - - (artist_area, controls_area, progress_area) -} - -fn playbar_layout_areas(app: &App, layout_chunk: Rect) -> PlaybarLayoutAreas { - #[cfg(feature = "cover-art")] - { - // first create margins - let [other] = layout_chunk.layout(&Layout::horizontal([Constraint::Fill(1)]).margin(1)); - - let (other, cover_art) = if app - .user_config - .do_draw_cover_art(crate::tui::cover_art::full_image_support()) - { - let cover_layout = playbar_cover_layout( - other, - app.user_config.behavior.playbar_cover_art_size_percent, - ); - if let Some(rendered_size) = - crate::tui::cover_art::size_for(cover_layout.image_area, &app.cover_art) - { - let cover_layout = cover_layout.with_rendered_size(rendered_size); - let (artist_area, controls_area, progress_area) = split_cover_playbar_rows( - other, - cover_layout.text_area, - cover_layout.image_area, - &app.user_config.behavior, - ); - - return PlaybarLayoutAreas { - artist_area, - controls_area, - progress_area, - cover_art: Some(cover_layout.image_area), - }; - } else { - (other, None) - } - } else { - (other, None) - }; - - let (artist_area, controls_area, progress_area) = split_playbar_rows(other); - - PlaybarLayoutAreas { - artist_area, - controls_area, - progress_area, - cover_art, - } - } - - #[cfg(not(feature = "cover-art"))] - { - let _ = app; - let [inner] = layout_chunk.layout(&Layout::horizontal([Constraint::Fill(1)]).margin(1)); - let (artist_area, controls_area, progress_area) = split_playbar_rows(inner); - - PlaybarLayoutAreas { - artist_area, - controls_area, - progress_area, - } - } -} - -#[cfg(feature = "cover-art")] -fn playbar_cover_layout(inner: Rect, size_percent: u16) -> PlaybarCoverLayout { - let size_percent = crate::core::user_config::clamp_playbar_cover_art_size_percent(size_percent); - let image_height = scaled_cover_art_height(inner.height, size_percent); - let requested_width = ((image_height as f32) * COVER_ART_CELL_RATIO).ceil() as u16; - let max_slot_width = if inner.width > 2 { - inner.width.saturating_sub(2) - } else { - inner.width - }; - let slot_width = requested_width.min(max_slot_width); - let separator_width = u16::from(slot_width > 0 && inner.width > slot_width); - - let slot = Rect::new(inner.x, inner.y, slot_width, inner.height); - let text_x = inner - .x - .saturating_add(slot_width.saturating_add(separator_width)); - let text_width = inner - .width - .saturating_sub(slot_width.saturating_add(separator_width)); - let text_area = Rect::new(text_x, inner.y, text_width, inner.height); - let image_area = center_rect_within( - slot, - Rect::new(0, 0, requested_width.min(slot_width), image_height), - ); - - PlaybarCoverLayout { - text_area, - slot, - image_area, - } -} - -#[cfg(feature = "cover-art")] -fn scaled_cover_art_height(available_height: u16, size_percent: u16) -> u16 { - if available_height == 0 { - return 0; - } - - let size_percent = crate::core::user_config::clamp_playbar_cover_art_size_percent(size_percent); - let target_percent = if size_percent <= 100 { - 25 + ((size_percent.saturating_sub(25) as u32 * 35).saturating_add(74) / 75) as u16 - } else { - 60 + (((size_percent - 100) as u32 * 40).saturating_add(99) / 100) as u16 - }; - - (((available_height as u32 * target_percent as u32).saturating_add(99) / 100) as u16) - .clamp(1, available_height) -} - -#[cfg(feature = "cover-art")] -impl PlaybarCoverLayout { - fn with_rendered_size(self, rendered_size: Rect) -> Self { - Self { - image_area: bottom_aligned_rect_within(self.image_area, rendered_size), - ..self - } - } -} - -#[cfg(feature = "cover-art")] -fn bottom_aligned_rect_within(bounds: Rect, size: Rect) -> Rect { - let width = size.width.min(bounds.width); - let height = size.height.min(bounds.height); - - Rect { - x: bounds.x + bounds.width.saturating_sub(width) / 2, - y: bounds.y + bounds.height.saturating_sub(height), - width, - height, - } -} - -#[cfg(feature = "cover-art")] -fn split_cover_playbar_rows( - inner: Rect, - text_area: Rect, - image_area: Rect, - behavior: &crate::core::user_config::BehaviorConfig, -) -> (Rect, Rect, Rect) { - if inner.width == 0 || inner.height == 0 || text_area.width == 0 || text_area.height == 0 { - let empty = Rect::new(text_area.x, text_area.y, text_area.width, 0); - return (empty, empty, empty); - } - - let progress_y = inner - .y - .saturating_add(inner.height.saturating_sub(PLAYBAR_PROGRESS_ROWS)); - let image_bottom = image_area.y.saturating_add(image_area.height); - let progress_area = if image_bottom <= progress_y { - Rect::new(inner.x, progress_y, inner.width, PLAYBAR_PROGRESS_ROWS) - } else { - Rect::new( - text_area.x, - progress_y, - text_area.width, - PLAYBAR_PROGRESS_ROWS, - ) - }; - - let controls_area = - cover_playbar_controls_area(inner, text_area, image_area, progress_area, behavior); - let artist_area = cover_playbar_artist_area(text_area, image_area, controls_area, progress_area); - - (artist_area, controls_area, progress_area) -} - -#[cfg(feature = "cover-art")] -fn cover_playbar_controls_area( - inner: Rect, - text_area: Rect, - image_area: Rect, - progress_area: Rect, - behavior: &crate::core::user_config::BehaviorConfig, -) -> Rect { - // Reserve room for the full control row: this only decides *whether* a controls - // row fits, and which controls actually apply depends on the owning source - // (`playbar_supported_controls`), which this layout pass has no `App` to ask. - // Sizing for the widest case keeps the row appearing in exactly the same - // situations as before, just with fewer buttons drawn in it. - let required_width = playbar_controls_required_width(behavior, &PLAYBAR_CONTROLS); - let controls_y = progress_area.y.saturating_sub(1); - let image_bottom = image_area.y.saturating_add(image_area.height); - - if image_bottom <= controls_y && inner.width >= required_width { - return Rect::new(inner.x, controls_y, inner.width, 1); - } - - let artist_y = image_area.y.max(text_area.y); - let available_text_rows = controls_y.saturating_sub(artist_y); - if text_area.width >= required_width && available_text_rows >= PLAYBAR_TRACK_INFO_ROWS { - Rect::new(text_area.x, controls_y, text_area.width, 1) - } else { - Rect::new(text_area.x, text_area.y, text_area.width, 0) - } -} - -#[cfg(feature = "cover-art")] -fn cover_playbar_artist_area( - text_area: Rect, - image_area: Rect, - controls_area: Rect, - progress_area: Rect, -) -> Rect { - let y = image_area.y.max(text_area.y); - let bottom = if controls_area.height > 0 { - controls_area.y - } else { - progress_area.y - }; - let height = bottom.saturating_sub(y).min(PLAYBAR_TRACK_INFO_ROWS); - - Rect::new(text_area.x, y, text_area.width, height) -} - -/// The controls that apply to whatever currently owns playback. -/// -/// Rendering and hit-testing both go through this, so a button that is drawn is -/// always one that works. They used to disagree — the row drew all eight while -/// hit-testing accepted only Play/Pause — which left inert Shuffle/Repeat -/// buttons sitting on the local playbar, inviting a click that did nothing. -fn playbar_supported_controls(app: &App) -> Vec { - playbar_supported_controls_for( - app.queue_owns_playback(), - non_spotify_source_playback_active(app), - app.active_queueable_decoded_source(), - ) -} - -/// The pure core of [`playbar_supported_controls`], taking the three facts it -/// depends on rather than an [`App`]: a real `LocalPlaybackState` needs an -/// `Arc` holding an open audio device, so the whole support matrix -/// would otherwise be untestable. -/// -/// Deliberately blind to `current_playback_context`: [`draw_playbar`] checks the -/// queue slot and every decoded source *before* it looks at the Spotify context -/// and returns early, so whenever either owns playback the playbar on screen is -/// theirs. Gating these controls on a Spotify context that may merely be cached -/// from an earlier session would offer buttons for a track the user cannot see -/// or hear. -fn playbar_supported_controls_for( - queue_owns_playback: bool, - non_spotify_active: bool, - queueable_decoded: bool, -) -> Vec { - let mut controls = PLAYBAR_CONTROLS.to_vec(); - - // The native queue slot owns the sink, so `draw_playbar` renders the *queued* - // track over a suspended context with the mode indicators hidden. Transport - // still works and stays offered: `App::next_track` advances the queue and - // `App::previous_track` restarts the queued track, both via the queue router. - // Dropped: Shuffle/Repeat have no queue-slot state to show (`App::shuffle` / - // `App::repeat` no-op here), and Like acts on `current_playback_context` — - // under a suspended *Spotify* context that is a different track than the one on - // screen, so the button would save a song the user is not listening to. - // Checked before `non_spotify_active`, which is false in exactly that case (a - // suspended Spotify context sets no `*_playback`) and would otherwise hand back - // all eight controls. - if queue_owns_playback { - controls.retain(|control| { - !matches!( - control, - PlaybarControl::Like | PlaybarControl::Shuffle | PlaybarControl::Repeat - ) - }); - return controls; - } - - // No decoded source: the Spotify playbar is on screen and drives everything. - if !non_spotify_active { - return controls; - } - - // Every decoded source routes play/pause and volume to its own player, rather - // than the paused librespot (see `App::toggle_playback` / `App::increase_volume`). - // Prev/Next/Shuffle/Repeat additionally need a finite track queue to act on, - // which internet radio (an endless stream) and the native queue slot don't - // have. Liking needs a Spotify item id, which no decoded source has. - controls.retain(|control| match control { - PlaybarControl::PlayPause | PlaybarControl::VolumeDown | PlaybarControl::VolumeUp => true, - PlaybarControl::Prev - | PlaybarControl::Next - | PlaybarControl::Shuffle - | PlaybarControl::Repeat => queueable_decoded, - PlaybarControl::Like => false, - }); - controls -} - -fn playbar_control_hitboxes_in_area( - controls_area: Rect, - behavior: &crate::core::user_config::BehaviorConfig, - controls: &[PlaybarControl], -) -> Vec { - if controls_area.width == 0 || controls_area.height == 0 { - return Vec::new(); - } - - let required_width = playbar_controls_required_width(behavior, controls); - let start_x = if controls_area.width > required_width { - controls_area - .x - .saturating_add((controls_area.width - required_width) / 2) - } else { - controls_area.x - }; - - let mut x = start_x; - let y = controls_area.y.saturating_add(controls_area.height / 2); - let right = controls_area.x.saturating_add(controls_area.width); - let mut hitboxes = Vec::with_capacity(controls.len()); - - for control in controls.iter().copied() { - let label = control.label(behavior); - let width = unicode_width::UnicodeWidthStr::width(label.as_ref()) as u16; - if x.saturating_add(width) > right { - break; - } - hitboxes.push(PlaybarControlHitbox { - control, - rect: Rect { - x, - y, - width, - height: 1, - }, - }); - x = x.saturating_add(width.saturating_add(1)); - } - - hitboxes -} - -fn playbar_controls_required_width( - behavior: &crate::core::user_config::BehaviorConfig, - controls: &[PlaybarControl], -) -> u16 { - controls - .iter() - .enumerate() - .fold(0u16, |width, (idx, control)| { - width.saturating_add(u16::from(idx > 0)).saturating_add( - unicode_width::UnicodeWidthStr::width(control.label(behavior).as_ref()) as u16, - ) - }) -} - -pub(crate) fn playbar_control_hitboxes( - app: &App, - playbar_area: Rect, -) -> Vec<(PlaybarControl, Rect)> { - if !playbar_controls_available(app) { - return Vec::new(); - } - - let controls_area = playbar_layout_areas(app, playbar_area).controls_area; - playbar_control_hitboxes_in_area( - controls_area, - &app.user_config.behavior, - &playbar_supported_controls(app), - ) - .into_iter() - .map(|hitbox| (hitbox.control, hitbox.rect)) - .collect() -} - -fn playbar_controls_available(app: &App) -> bool { - // The queue slot owns playback, so `draw_playbar` is rendering the queued track - // and `playbar_supported_controls` offers the controls that drive it. Checked - // first because neither fact below need hold: queueing from an idle app - // suspends nothing, leaving every `*_playback` `None` and (for a user who never - // started Spotify) no context either — which used to draw those controls while - // hit-testing returned no boxes, so they were inert. - if app.queue_owns_playback() { - return true; - } - - if app.current_playback_context.as_ref().is_some_and(|ctx| { - ctx.item.is_some() || (app.is_streaming_active && app.native_device_id.is_some()) - }) { - return true; - } - - non_spotify_source_playback_active(app) -} - -/// True when a non-Spotify source (local/subsonic/radio/youtube) currently -/// owns playback. Each source's playback field is gated behind its own -/// feature flag, so every access here is guarded to match — this must -/// compile in the slim build (no source features) as well as any single- or -/// all-sources build. -fn non_spotify_source_playback_active(app: &App) -> bool { - // Slim builds (no source features) never reference `app` below. - #[cfg(not(feature = "audio-decode"))] - let _ = app; - - #[cfg(feature = "local-files")] - if app.local_playback.is_some() { - return true; - } - - #[cfg(feature = "subsonic")] - if app.subsonic_playback.is_some() { - return true; - } - - #[cfg(feature = "qobuz")] - if app.qobuz_playback.is_some() { - return true; - } - - #[cfg(feature = "internet-radio")] - if app.radio_playback.is_some() { - return true; - } - - #[cfg(feature = "youtube")] - if app.youtube_playback.is_some() { - return true; - } - - false -} - -pub(crate) fn playbar_control_at( - app: &App, - playbar_area: Rect, - x: u16, - y: u16, -) -> Option { - playbar_control_hitboxes(app, playbar_area) - .into_iter() - .find_map(|(control, rect)| rect.contains(Position { x, y }).then_some(control)) -} - -/// Geometry of the seekable region of the playbar progress line, used to translate -/// a mouse column into an absolute playback position. Mirrors ratatui's `LineGauge` -/// layout: the left-aligned label is drawn first, then the gauge line begins one -/// column after it. -#[derive(Clone, Copy, Debug)] -pub(crate) struct PlaybarProgressLine { - /// Row the progress line is rendered on. - pub(crate) row: u16, - /// First column of the gauge line (the cell after the label + 1-column gap). - pub(crate) start: u16, - /// Number of cells in the gauge line. - pub(crate) width: u16, - /// Track duration the line represents, in milliseconds. - pub(crate) duration_ms: u32, -} - -impl PlaybarProgressLine { - /// True when `(x, y)` lands on the seekable gauge line (excludes the time label). - pub(crate) fn contains(&self, x: u16, y: u16) -> bool { - y == self.row && x >= self.start && x < self.start + self.width - } - - /// True when `y` is on the progress row, regardless of column (used for drags, - /// where the column is clamped into range rather than rejected). - pub(crate) fn on_row(&self, y: u16) -> bool { - y == self.row - } - - /// Map a column to an absolute position in milliseconds, clamped to the line. - /// The far-right cell maps to just under the full duration so a click never - /// overshoots into the next track. - pub(crate) fn position_at(&self, x: u16) -> u32 { - let last = self.start + self.width.saturating_sub(1); - let offset = x.clamp(self.start, last) - self.start; - let fraction = f64::from(offset) / f64::from(self.width.max(1)); - (f64::from(self.duration_ms) * fraction).round() as u32 - } -} - -/// Compute the seekable geometry of the playbar progress line for the current -/// playback, or `None` when nothing is playing or the line is not rendered (e.g. -/// the single-row playbar, or a terminal too narrow to fit the gauge). -pub(crate) fn playbar_progress_line(app: &App, playbar_area: Rect) -> Option { - let item = app - .current_playback_context - .as_ref() - .and_then(|ctx| ctx.item.as_ref())?; - - let progress_area = playbar_layout_areas(app, playbar_area).progress_area; - if progress_area.width == 0 || progress_area.height == 0 { - return None; - } - - // Duration as shown on the playbar (native track info preferred). Mirrors - // draw_playbar's `display_duration_ms`, so keep the two in sync (player.rs ~761). - let duration_ms = if let Some(native_info) = &app.native_track_info { - native_info.duration_ms - } else { - match item { - PlayableItem::Track(track) => track.duration.num_milliseconds() as u32, - PlayableItem::Episode(episode) => episode.duration.num_milliseconds() as u32, - _ => return None, - } - }; - - // While the playback position is stale (tick loop stopped), draw_playbar - // swaps the gauge label for a warning, so the geometry below would not - // match the rendered bar; no seeking then. - app.playback_position_ms()?; - - // Recreate the gauge label exactly as draw_playbar does so the computed line - // `start` matches the rendered bar. The label reflects a pending seek if one is - // in flight (see player.rs ~805), otherwise the current progress. - let progress_ms = app.seek_ms.unwrap_or(app.song_progress_ms); - let duration_std = std::time::Duration::from_millis(u64::from(duration_ms)); - let label = display_track_progress(progress_ms, duration_std); - - // LineGauge writes the label (capped at the area width), then starts the line one - // column later: `start = label_end + 1` (see ratatui-widgets LineGauge::render). - let label_width = (UnicodeWidthStr::width(label.as_str()) as u16).min(progress_area.width); - let start = progress_area.x + label_width + 1; - let right = progress_area.x + progress_area.width; - if start >= right { - return None; - } - - Some(PlaybarProgressLine { - row: progress_area.y, - start, - width: right - start, - duration_ms, - }) -} - -fn draw_playbar_controls(f: &mut Frame<'_>, app: &App, controls_area: Rect) { - let controls_style = Style::default().fg(app.user_config.theme.playbar_text.into()); - for hitbox in playbar_control_hitboxes_in_area( - controls_area, - &app.user_config.behavior, - &playbar_supported_controls(app), - ) { - let control = Paragraph::new(Span::styled( - hitbox.control.label(&app.user_config.behavior).into_owned(), - controls_style, - )); - f.render_widget(control, hitbox.rect); - } -} - -#[cfg(feature = "cover-art")] -pub fn draw_cover_art_view(f: &mut Frame<'_>, app: &App) { - let (content_area, playbar_area) = fullscreen_view_layout(&app.runtime_state, f.area()); - - draw_cover_art_content(f, app, content_area); - if let Some(playbar_area) = playbar_area { - draw_playbar(f, app, playbar_area); - } -} - -pub fn draw_miniplayer(f: &mut Frame<'_>, app: &App) { - let area = miniplayer_playbar_area(f.area()); - draw_playbar(f, app, area); -} - -#[cfg(feature = "cover-art")] -fn draw_cover_art_content(f: &mut Frame<'_>, app: &App, area: Rect) { - use ratatui::widgets::Clear; - - // Clear the area to remove any lingering terminal image protocol artifacts - f.render_widget(Clear, area); - - // Extract track info for display below the cover art - let (track_name, artist_str) = extract_track_info(app); - - if !app.cover_art.available() { - // No image is loaded: show an explicit message for the current state rather - // than a blank pane, so "no art" always reads as a deliberate outcome. - let message = crate::tui::cover_art::status_message(app.cover_art.status); - let p = Paragraph::new(message) - .style(Style::default().fg(app.user_config.theme.inactive.into())) - .alignment(Alignment::Center); - - let vertical_center = area.y + area.height / 2; - let center_area = Rect { - x: area.x, - y: vertical_center, - width: area.width, - height: 1, - }; - f.render_widget(p, center_area); - return; - } - - let show_title = track_name.is_some(); - let show_artist = show_title && artist_str.is_some(); - let info_height = if show_title { - 1 + 1 + u16::from(show_artist) - } else { - 0 - }; - let image_bounds = Rect { - x: area.x, - y: area.y, - width: area.width, - height: area.height.saturating_sub(info_height), - }; - let available_image_size = Rect::new( - 0, - 0, - image_bounds.width.saturating_sub(2), - image_bounds.height.saturating_sub(2), - ); - let fitted_image_size = - crate::tui::cover_art::fullscreen_size_for(available_image_size, &app.cover_art) - .unwrap_or(available_image_size); - let centered_area = center_rect_within(image_bounds, fitted_image_size); - - crate::tui::cover_art::render_fullscreen(f, centered_area, &app.cover_art, &app.user_config); - - // Draw song info below the cover art - if let Some(name) = track_name { - let title_y = centered_area.y + centered_area.height + 1; - if title_y < area.y + area.height { - let title = Paragraph::new(name) - .style( - Style::default() - .fg(app.user_config.theme.selected.into()) - .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), - ) - .alignment(Alignment::Center); - f.render_widget( - title, - Rect { - x: area.x, - y: title_y, - width: area.width, - height: 1, - }, - ); - } - - if let Some(artists) = artist_str { - let artist_y = title_y + 1; - if artist_y < area.y + area.height { - let artist = Paragraph::new(artists) - .style(Style::default().fg(app.user_config.theme.playbar_text.into())) - .alignment(Alignment::Center); - f.render_widget( - artist, - Rect { - x: area.x, - y: artist_y, - width: area.width, - height: 1, - }, - ); - } - } - } -} - -#[cfg(feature = "cover-art")] -fn extract_track_info(app: &App) -> (Option, Option) { - // Read from the source-agnostic snapshot so the fullscreen cover view labels - // the current track for every source (Spotify, native streaming, local files, - // Subsonic, radio, YouTube), not just Spotify. - match crate::infra::media_metadata::current_playback_snapshot(app) { - Some(snapshot) => ( - Some(snapshot.metadata.title.clone()), - Some(snapshot.primary_artist()), - ), - None => (None, None), - } -} - -/// Display snapshot for an engine playbar (local files, Subsonic, radio, or the -/// native queue slot). -/// -/// Extracted from the live player so [`render_local_playbar`] is a pure function -/// of plain values and can be unit-tested with `TestBackend` (no audio device). -/// Gated to every build that can render one: the decoded sources plus the -/// native queue slot (`streaming` covers a queued Spotify track). -#[cfg(any(feature = "streaming", feature = "audio-decode"))] -struct LocalPlaybarView { - /// Source name shown in the playbar title, e.g. `"Local"` or `"Subsonic"`. - source_label: &'static str, - name: String, - artists: String, - is_playing: bool, - position_ms: u128, - duration_ms: u64, - volume_percent: u8, - /// 1-based position in the queue and total length, e.g. `(3, 12)` => "3/12". - /// `None` hides the indicator (e.g. a one-track session). - queue_position: Option<(usize, usize)>, - /// An infinite live stream (internet radio): `duration_ms` is meaningless, - /// so the seek bar renders as a full LIVE indicator with elapsed time only. - live: bool, - /// Whether the decoded shuffle/repeat controls apply here. `true` for a - /// queueable source that owns playback (Local/Subsonic/YouTube); `false` for - /// internet radio and native queue slots, whose playbar drops the mode - /// segments entirely rather than showing blank `Shuffle:`/`Repeat:` labels. - show_modes: bool, - /// The delivered audio format (Qobuz, e.g. `FLAC 24/96`), shown after the artists. - quality: Option, -} - -/// Render the playbar for an active local-file playback session. -/// -/// Local playback has no Spotify `current_playback_context`; it renders from its -/// own [`App::local_playback`] state, reading progress and pause state **live** -/// from the player so they never desync from what is actually playing. -#[cfg(feature = "local-files")] -fn draw_local_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { - let Some(local) = app.local_playback.as_ref() else { - return; - }; - let view = LocalPlaybarView { - source_label: "Local", - name: local.name.clone(), - artists: local.artists.clone(), - is_playing: !local.player.is_paused(), - position_ms: local.player.position().as_millis(), - duration_ms: local.duration_ms, - volume_percent: app.runtime_state.volume_percent, - // Only show the indicator for multi-track queues; a single file is noise. - queue_position: (local.queue.len() > 1).then(|| (local.index + 1, local.queue.len())), - live: false, - show_modes: true, - quality: None, - }; - render_local_playbar(f, app, layout_chunk, &view); -} - -/// Render the playbar for an active Subsonic playback session, reading -/// progress/pause live from the player just like the local path. -#[cfg(feature = "subsonic")] -fn draw_subsonic_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { - let Some(subsonic) = app.subsonic_playback.as_ref() else { - return; - }; - let track = subsonic.current(); - let view = LocalPlaybarView { - source_label: "Subsonic", - name: track.map(|t| t.name.clone()).unwrap_or_default(), - artists: track.map(|t| t.artists.join(", ")).unwrap_or_default(), - is_playing: !subsonic.player.is_paused(), - position_ms: subsonic.player.position().as_millis(), - duration_ms: track.map(|t| t.duration_ms).unwrap_or(0), - volume_percent: app.runtime_state.volume_percent, - queue_position: (subsonic.tracks.len() > 1) - .then(|| (subsonic.index + 1, subsonic.tracks.len())), - live: false, - show_modes: true, - quality: None, - }; - render_local_playbar(f, app, layout_chunk, &view); -} - -/// Render the playbar for an active Qobuz playback session; the download -/// window (`advancing`) shows as a loading suffix, like the queue slot. -#[cfg(feature = "qobuz")] -fn draw_qobuz_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { - let Some(qobuz) = app.qobuz_playback.as_ref() else { - return; - }; - let track = qobuz.current(); - let name = track.map(|t| t.name.clone()).unwrap_or_default(); - let view = LocalPlaybarView { - source_label: "Qobuz", - name: if qobuz.advancing { - format!("{name} (loading\u{2026})") - } else { - name - }, - artists: track.map(|t| t.artists.join(", ")).unwrap_or_default(), - is_playing: !qobuz.player.is_paused(), - position_ms: qobuz.player.position().as_millis(), - duration_ms: track.map(|t| t.duration_ms).unwrap_or(0), - volume_percent: app.runtime_state.volume_percent, - queue_position: (qobuz.tracks.len() > 1).then(|| (qobuz.index + 1, qobuz.tracks.len())), - live: false, - show_modes: true, - quality: qobuz.quality.map(|q| q.label()), - }; - render_local_playbar(f, app, layout_chunk, &view); -} - -/// Render the playbar for an active YouTube playback session, reading -/// progress/pause live from the player just like the Subsonic path. -#[cfg(feature = "youtube")] -fn draw_youtube_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { - let Some(youtube) = app.youtube_playback.as_ref() else { - return; - }; - let track = youtube.current(); - let view = LocalPlaybarView { - source_label: "YouTube", - name: track.map(|t| t.name.clone()).unwrap_or_default(), - artists: track.map(|t| t.artists.join(", ")).unwrap_or_default(), - is_playing: !youtube.player.is_paused(), - position_ms: youtube.player.position().as_millis(), - duration_ms: track.map(|t| t.duration_ms).unwrap_or(0), - volume_percent: app.runtime_state.volume_percent, - queue_position: (youtube.tracks.len() > 1).then(|| (youtube.index + 1, youtube.tracks.len())), - live: false, - show_modes: true, - quality: None, - }; - render_local_playbar(f, app, layout_chunk, &view); -} - -/// Render the playbar for an active internet-radio session. The station name is -/// the title line and the ICY now-playing text (when the stream sends it) the -/// subtitle; elapsed time renders as a LIVE indicator since a stream is -/// infinite. -#[cfg(feature = "internet-radio")] -fn draw_radio_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { - let Some(radio) = app.radio_playback.as_ref() else { - return; - }; - // Subtitle preference: live now-playing title, else genre tags from the - // directory row, else the station's country/codec/bitrate summary. - let artists = radio.now_playing_title().unwrap_or_else(|| { - if radio.station.artists.is_empty() { - radio.station.album.clone() - } else { - radio.station.artists.join(", ") - } - }); - let view = LocalPlaybarView { - source_label: "Radio", - name: radio.station.name.clone(), - artists, - is_playing: !radio.player.is_paused(), - position_ms: radio.player.position().as_millis(), - duration_ms: 0, - volume_percent: app.runtime_state.volume_percent, - queue_position: None, - live: true, - show_modes: false, - quality: None, - }; - render_local_playbar(f, app, layout_chunk, &view); -} - -#[cfg(any(feature = "streaming", feature = "audio-decode"))] -fn render_local_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect, view: &LocalPlaybarView) { - let playbar_areas = playbar_layout_areas(app, layout_chunk); - - let play_title = if view.is_playing { "Playing" } else { "Paused" }; - - let current_route = app.get_current_route(); - let highlight_state = ( - matches!( - current_route.active_block, - ActiveBlock::PlayBar | ActiveBlock::MiniPlayer - ), - matches!( - current_route.hovered_block, - ActiveBlock::PlayBar | ActiveBlock::MiniPlayer - ), - ); - - let queue_label = match view.queue_position { - Some((current, total)) => format!(" | {current}/{total}"), - None => String::new(), - }; - // Decoded (Local / Subsonic / YouTube) shuffle + repeat, worded like the - // Spotify playbar (Off / Track / All). Each carries its own ` | Label: value` - // prefix (values pre-padded to the `{:-3}` / `{:-5}` widths so the default - // template reproduces today's output byte-for-byte); contexts without the - // controls (radio, native queue slots) render them empty so the whole segment - // — label included — disappears instead of leaking a blank control. - use crate::infra::queue::RepeatMode; - let (shuffle_text, repeat_text) = if view.show_modes { - let shuffle = if app.decoded_shuffle { "On" } else { "Off" }; - let repeat = match app.decoded_repeat { - RepeatMode::Off => "Off", - RepeatMode::Track => "Track", - RepeatMode::Context => "All", - }; - ( - format!(" | Shuffle: {shuffle:<3}"), - format!(" | Repeat: {repeat:<5}"), - ) - } else { - (String::new(), String::new()) - }; - // Build the title from the configurable playbar_status_source template. - let title = app.user_config.format.playbar_status_source.render(&[ - // state — left-aligned to 7 cols (matches `{:-7}`) - &format!("{:<7}", play_title), - // device / source / queue - "", - view.source_label, - &queue_label, - // shuffle / repeat — self-contained ` | Label: value` segments (or empty) - &shuffle_text, - &repeat_text, - // volume — left-aligned to 2 (matches `{:-2}`) - &format!("{:<2}", view.volume_percent), - "", - ]); - let mut title_spans = vec![Span::styled( - title, - get_color(highlight_state, app.user_config.theme), - )]; - if let Some(message) = app.status_message() { - let msg_style = if app.status_message_is_error() { - Style::default().fg(app.user_config.theme.error_text.into()) - } else { - get_color(highlight_state, app.user_config.theme) - }; - title_spans.push(Span::styled(format!(" | {}", message), msg_style)); - } - - let title_block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .style(Style::default().bg(app.user_config.theme.playbar_background.into())) - .title(Line::from(title_spans)) - .border_style(get_color(highlight_state, app.user_config.theme)); - f.render_widget(title_block, layout_chunk); - - let mut subtitle = vec![Span::styled( - view.artists.clone(), - Style::default().fg(app.user_config.theme.playbar_text.into()), - )]; - if let Some(quality) = view.quality.as_ref() { - let separator = if view.artists.is_empty() { - "" - } else { - " \u{2022} " - }; - subtitle.push(Span::styled( - format!("{separator}{quality}"), - Style::default() - .fg(app.user_config.theme.playbar_text.into()) - .add_modifier(app.user_config.behavior.emphasis(Modifier::DIM)), - )); - } - let lines = Text::from(Line::from(subtitle)); - let artist = Paragraph::new(lines) - .style(Style::default().fg(app.user_config.theme.playbar_text.into())) - .block( - Block::default().title(Span::styled( - view.name.clone(), - Style::default() - .fg(app.user_config.theme.selected.into()) - .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), - )), - ); - f.render_widget(artist, playbar_areas.artist_area); - - draw_playbar_controls(f, app, playbar_areas.controls_area); - - // A live stream has no duration: never feed the zero into the percentage - // math (division by zero); render a full bar with elapsed time instead. - let (perc, song_progress_label) = if view.live { - ( - 100, - format!( - "LIVE \u{2022} {}", - super::util::millis_to_minutes(view.position_ms) - ), - ) - } else { - let duration_std = std::time::Duration::from_millis(view.duration_ms); - ( - get_track_progress_percentage(view.position_ms, duration_std), - display_track_progress(view.position_ms, duration_std), - ) - }; - let modifier = if app.user_config.behavior.enable_text_emphasis { - Modifier::ITALIC | Modifier::BOLD - } else { - Modifier::empty() - }; - let song_progress = LineGauge::default() - .filled_style( - Style::default() - .fg(app.user_config.theme.playbar_progress.into()) - .add_modifier(modifier), - ) - .unfilled_style( - Style::default() - .fg(app.user_config.theme.playbar_background.into()) - .add_modifier(modifier), - ) - .ratio(perc as f64 / 100.0) - .filled_symbol(app.user_config.behavior.gauge_filled_icon.as_str()) - .unfilled_symbol(app.user_config.behavior.gauge_unfilled_icon.as_str()) - .label(Span::styled( - &song_progress_label, - Style::default().fg(app.user_config.theme.playbar_progress_text.into()), - )); - f.render_widget(song_progress, playbar_areas.progress_area); - - // Paint the cover art into the slot `playbar_layout_areas` reserved — the - // layout only carves out the space; without this the sources' playbar shows - // a blank indent where the image belongs (Spotify's path does the same). - #[cfg(feature = "cover-art")] - if let Some(cover_art) = playbar_areas.cover_art { - crate::tui::cover_art::render(f, cover_art, &app.cover_art, &app.user_config); - } -} - -pub fn draw_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { - // The native queue slot owns playback: render the queued track, not the - // suspended context (whose `*_playback` is still `Some`) and not the stale - // Spotify context (still cached when a Spotify context was suspended). - // Checked first so the playbar always shows what is actually audible. - #[cfg(feature = "audio-decode-queue")] - if let Some(crate::infra::queue::QueueNowPlaying::Decoded(d)) = app.queue_now.as_ref() { - let source_label = d - .track - .uri - .as_deref() - .map(crate::core::queue::queue_item_source) - .map(crate::core::queue::source_label) - .unwrap_or("Queue"); - // `advancing` spans the download/decode window (the slot is published - // before the fetch), so surface it instead of a silent frozen bar. - let name = if d.advancing { - format!("{} (loading\u{2026})", d.track.name) - } else { - d.track.name.clone() - }; - let view = LocalPlaybarView { - source_label, - name, - artists: d.track.artists.join(", "), - is_playing: !d.player.is_paused(), - position_ms: d.player.position().as_millis(), - duration_ms: d.track.duration_ms, - volume_percent: app.runtime_state.volume_percent, - queue_position: None, - live: false, - // The native queue ignores the decoded shuffle/repeat modes (they belong - // to the suspended source resumed once the queue drains), so hide them. - show_modes: false, - #[cfg(any(feature = "queue-download", feature = "local-files"))] - quality: d.quality.clone(), - }; - render_local_playbar(f, app, layout_chunk, &view); - return; - } - // A queued Spotify track plays through librespot: progress and play-state - // come from the native event stream, metadata from the queue slot. - #[cfg(feature = "streaming")] - if let Some(crate::infra::queue::QueueNowPlaying::Spotify { track }) = app.queue_now.as_ref() { - let view = LocalPlaybarView { - source_label: "Spotify", - name: track.name.clone(), - artists: track.artists.join(", "), - is_playing: app.native_is_playing.unwrap_or(true), - position_ms: app.song_progress_ms, - duration_ms: track.duration_ms, - volume_percent: app.runtime_state.volume_percent, - queue_position: None, - live: false, - show_modes: false, - quality: None, - }; - render_local_playbar(f, app, layout_chunk, &view); - return; - } - - // Local-file playback owns the session and has no Spotify context to render - // from, so it takes a dedicated path. - #[cfg(feature = "local-files")] - if app.local_playback.is_some() { - draw_local_playbar(f, app, layout_chunk); - return; - } - - // Subsonic playback likewise renders from its own session state. - #[cfg(feature = "subsonic")] - if app.subsonic_playback.is_some() { - draw_subsonic_playbar(f, app, layout_chunk); - return; - } - - // Qobuz playback likewise renders from its own session state. - #[cfg(feature = "qobuz")] - if app.qobuz_playback.is_some() { - draw_qobuz_playbar(f, app, layout_chunk); - return; - } - - // Internet radio likewise renders from its own session state. - #[cfg(feature = "internet-radio")] - if app.radio_playback.is_some() { - draw_radio_playbar(f, app, layout_chunk); - return; - } - - // YouTube likewise renders from its own session state. - #[cfg(feature = "youtube")] - if app.youtube_playback.is_some() { - draw_youtube_playbar(f, app, layout_chunk); - return; - } - - let playbar_areas = playbar_layout_areas(app, layout_chunk); - let artist_area = playbar_areas.artist_area; - let progress_area = playbar_areas.progress_area; - - let mut drew_playbar = false; - - // If no track is playing, render paragraph showing which device is selected, if no selected - // give hint to choose a device - if let Some(current_playback_context) = &app.current_playback_context { - if let Some(track_item) = ¤t_playback_context.item { - // Use native playing state when streaming is active (more reliable for MPRIS controls) - let is_playing = app - .native_is_playing - .filter(|_| app.is_streaming_active) - .unwrap_or(current_playback_context.is_playing); - - let play_title = if is_playing { "Playing" } else { "Paused" }; - - let shuffle_text = if current_playback_context.shuffle_state { - "On" - } else { - "Off" - }; - - let repeat_text = match current_playback_context.repeat_state { - RepeatState::Off => "Off", - RepeatState::Track => "Track", - RepeatState::Context => "All", - }; - - // Build the title from the configurable playbar_status template. - // Values are pre-padded to match the original `{:-N}` format widths so - // the default template reproduces today's output byte-for-byte. - let party_segment = if let Some(session) = &app.party_session { - match session.role { - crate::infra::network::sync::PartyRole::Host => { - format!(" | Party: {} listeners", session.guests.len()) - } - crate::infra::network::sync::PartyRole::Guest => { - format!(" | Party: following {}", session.host_name) - } - } - } else { - String::new() - }; - let title = app.user_config.format.playbar_status.render(&[ - // state — left-aligned to 7 cols (matches `{:-7}`) - &format!("{:<7}", play_title), - // device - ¤t_playback_context.device.name, - // source / queue — unused in the Spotify template - "", - "", - // shuffle — left-aligned to 3 (matches `{:-3}`) - &format!("{:<3}", shuffle_text), - // repeat — left-aligned to 5 (matches `{:-5}`) - &format!("{:<5}", repeat_text), - // volume — left-aligned to 2 (matches `{:-2}`) - &format!("{:<2}", app.desired_volume()), - // party — pre-composed optional segment - &party_segment, - ]); - - let current_route = app.get_current_route(); - let highlight_state = ( - matches!( - current_route.active_block, - ActiveBlock::PlayBar | ActiveBlock::MiniPlayer - ), - matches!( - current_route.hovered_block, - ActiveBlock::PlayBar | ActiveBlock::MiniPlayer - ), - ); - - let mut title_spans = vec![Span::styled( - title, - get_color(highlight_state, app.user_config.theme), - )]; - if let Some(message) = app.status_message() { - let msg_style = if app.status_message_is_error() { - Style::default().fg(app.user_config.theme.error_text.into()) - } else { - get_color(highlight_state, app.user_config.theme) - }; - title_spans.push(Span::styled(format!(" | {}", message), msg_style)); - } - for seg in app.plugin_playbar_segments.values() { - title_spans.push(Span::styled( - format!(" | {}", seg), - Style::default().fg(app.user_config.theme.playbar_text.into()), - )); - } - - let title_block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .style(Style::default().bg(app.user_config.theme.playbar_background.into())) - .title(Line::from(title_spans)) - .border_style(get_color(highlight_state, app.user_config.theme)); - - f.render_widget(title_block, layout_chunk); - - let (item_id, name, duration) = match track_item { - PlayableItem::Track(track) => ( - track - .id - .as_ref() - .map(|id| id.id().to_string()) - .unwrap_or_default(), - track.name.to_owned(), - track.duration, - ), - PlayableItem::Episode(episode) => ( - episode.id.id().to_string(), - episode.name.to_owned(), - episode.duration, - ), - _ => return, - }; - - // Use native track info for instant display when available (e.g., after skipping tracks) - // Falls back to API data when native info is not available - let (display_name, display_artists, display_duration_ms) = - if let Some(ref native_info) = app.native_track_info { - ( - native_info.name.clone(), - native_info.artists.join(", "), - native_info.duration_ms as u64, - ) - } else { - let artists_str = match track_item { - PlayableItem::Track(track) => create_artist_string(&track.artists), - PlayableItem::Episode(episode) => format!("{} - {}", episode.name, episode.show.name), - _ => return, - }; - ( - name.clone(), - artists_str, - duration.num_milliseconds() as u64, - ) - }; - - let track_name = if app.liked_song_ids_set.contains(&item_id) { - format!("{}{}", app.user_config.padded_liked_icon(), display_name) - } else { - display_name - }; - - let lines = Text::from(Span::styled( - display_artists, - Style::default().fg(app.user_config.theme.playbar_text.into()), - )); - - let artist = Paragraph::new(lines) - .style(Style::default().fg(app.user_config.theme.playbar_text.into())) - .block( - Block::default().title(Span::styled( - track_name, - Style::default() - .fg(app.user_config.theme.selected.into()) - .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), - )), - ); - f.render_widget(artist, artist_area); - draw_playbar_controls(f, app, playbar_areas.controls_area); - - let progress_ms = match app.seek_ms { - Some(seek_ms) => seek_ms, - None => app.song_progress_ms, - }; - - let duration_std = std::time::Duration::from_millis(display_duration_ms); - let perc = get_track_progress_percentage(progress_ms, duration_std); - - // The loud-failure guard: a frontend that stops driving the tick would - // otherwise freeze this label at its last value indefinitely. - let song_progress_label = if app.playback_position_ms().is_none() { - "position stale (tick loop stopped)".to_string() - } else { - display_track_progress(progress_ms, duration_std) - }; - let modifier = if app.user_config.behavior.enable_text_emphasis { - Modifier::ITALIC | Modifier::BOLD - } else { - Modifier::empty() - }; - let song_progress = LineGauge::default() - .filled_style( - Style::default() - .fg(app.user_config.theme.playbar_progress.into()) - .add_modifier(modifier), - ) - .unfilled_style( - Style::default() - .fg(app.user_config.theme.playbar_background.into()) - .add_modifier(modifier), - ) - .ratio(perc as f64 / 100.0) - .filled_symbol(app.user_config.behavior.gauge_filled_icon.as_str()) - .unfilled_symbol(app.user_config.behavior.gauge_unfilled_icon.as_str()) - .label(Span::styled( - &song_progress_label, - Style::default().fg(app.user_config.theme.playbar_progress_text.into()), - )); - f.render_widget(song_progress, progress_area); - - // Draw "Like" animation (heart burst) if active - if let Some(frame) = app.view.liked_song_animation_frame { - let total_frames = app.user_config.behavior.like_animation_frames.max(1); - let progress = (total_frames.saturating_sub(frame)) as f64; - let y_base = 20.0 + progress * 5.0; // Rise up - let heart = app.user_config.behavior.liked_icon.clone(); - let heart_color = app.user_config.theme.selected; - - let canvas = Canvas::default() - .block(Block::default()) // No border, transparent - .x_bounds([0.0, 100.0]) - .y_bounds([0.0, 100.0]) - .paint(move |ctx| { - // Center heart - ctx.print( - 50.0, - y_base, - Span::styled(heart.clone(), Style::default().fg(heart_color.into())), - ); - // Left particle (lagging slightly) - ctx.print( - 48.0, - y_base - 3.0, - Span::styled(heart.clone(), Style::default().fg(heart_color.into())), - ); - // Right particle (lagging slightly) - ctx.print( - 52.0, - y_base - 3.0, - Span::styled(heart.clone(), Style::default().fg(heart_color.into())), - ); - }); - - f.render_widget(canvas, layout_chunk); - } - - #[cfg(feature = "cover-art")] - if let Some(cover_art) = playbar_areas.cover_art { - crate::tui::cover_art::render(f, cover_art, &app.cover_art, &app.user_config); - } - - drew_playbar = true; - } else if app.is_streaming_active && app.native_device_id.is_some() { - let shuffle_text = if current_playback_context.shuffle_state { - "On" - } else { - "Off" - }; - let repeat_text = match current_playback_context.repeat_state { - RepeatState::Off => "Off", - RepeatState::Track => "Track", - RepeatState::Context => "All", - }; - let title = format!( - "Ready ({} | Shuffle: {:-3} | Repeat: {:-5} | Volume: {:-2}%)", - current_playback_context.device.name, - shuffle_text, - repeat_text, - app.desired_volume() - ); - let current_route = app.get_current_route(); - let highlight_state = ( - matches!( - current_route.active_block, - ActiveBlock::PlayBar | ActiveBlock::MiniPlayer - ), - matches!( - current_route.hovered_block, - ActiveBlock::PlayBar | ActiveBlock::MiniPlayer - ), - ); - let mut title_spans = vec![Span::styled( - title, - get_color(highlight_state, app.user_config.theme), - )]; - if let Some(message) = app.status_message() { - let msg_style = if app.status_message_is_error() { - Style::default().fg(app.user_config.theme.error_text.into()) - } else { - get_color(highlight_state, app.user_config.theme) - }; - title_spans.push(Span::styled(format!(" | {}", message), msg_style)); - } - - let title_block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .style(Style::default().bg(app.user_config.theme.playbar_background.into())) - .title(Line::from(title_spans)) - .border_style(get_color(highlight_state, app.user_config.theme)); - - f.render_widget(title_block, layout_chunk); - f.render_widget( - Paragraph::new("No active playback") - .style(Style::default().fg(app.user_config.theme.playbar_text.into())), - artist_area, - ); - draw_playbar_controls(f, app, playbar_areas.controls_area); - drew_playbar = true; - } - } - - if !drew_playbar { - if let Some(message) = app.status_message() { - let msg_style = if app.status_message_is_error() { - Style::default().fg(app.user_config.theme.error_text.into()) - } else { - Style::default().fg(app.user_config.theme.playbar_text.into()) - }; - let title_block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .style(Style::default().bg(app.user_config.theme.playbar_background.into())) - .title(Span::styled(format!("Status: {}", message), msg_style)) - .border_style(Style::default().fg(app.user_config.theme.inactive.into())); - f.render_widget(title_block, layout_chunk); - } - } -} - -/// The combined Source & Device picker (the `d` screen): a Source panel -/// (Spotify / Local Files) stacked above the existing Spotify Connect device -/// list. `Tab` toggles focus; the Devices panel is dimmed when Local is active. -pub fn draw_device_list(f: &mut Frame<'_>, app: &App) { - // A small margin (rather than the device screen's old 5) keeps the fixed - // instructions + Source panel from squeezing the Devices list off-screen on - // shorter terminals: instructions(7) + source(4) + devices(>=3) fits from ~18 - // rows up. - let [instructions_area, source_area, devices_area] = f.area().layout( - &Layout::vertical([ - Constraint::Length(7), - Constraint::Length(Source::ALL.len() as u16 + 2), - Constraint::Min(3), - ]) - .margin(2), - ); - - let move_instructions = format!( - "Use `{}`/`{}` or arrow keys to move, `Tab` to switch panels, to select.", - app.user_config.keys.move_down, app.user_config.keys.move_up, - ); - let instructions_text: Vec = vec![ - Line::from(Span::raw( - "Choose your music source and Spotify playback device.", - )), - Line::from(Span::raw(move_instructions)), - Line::from(Span::raw( - "Your choices are cached so you can jump straight back in when you next open `spotatui`.", - )), - Line::from(Span::raw("Reopen this screen any time by pressing `d`.")), - ]; - - let instructions = Paragraph::new(instructions_text) - .style(Style::default().fg(app.user_config.theme.text.into())) - .wrap(Wrap { trim: true }) - .block( - Block::default().borders(Borders::NONE).title(Span::styled( - "Welcome to spotatui!", - Style::default() - .fg(app.user_config.theme.active.into()) - .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), - )), - ); - f.render_widget(instructions, instructions_area); - - // --- Source panel --- - let source_focused = app.view.source_device_focus == SourceFocus::Source; - let source_border = if source_focused { - app.user_config.theme.active - } else { - app.user_config.theme.inactive - }; - let source_items: Vec = Source::ALL - .iter() - .map(|s| { - let is_active = *s == app.active_source; - let marker = if is_active { - app.user_config.behavior.active_source_icon.as_str() - } else { - " " - }; - let suffix = if is_active { " (active)" } else { "" }; - let style = if is_active { - Style::default() - .fg(app.user_config.theme.active.into()) - .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)) - } else { - app.user_config.theme.base_style() - }; - ListItem::new(Span::styled( - format!("{} {}{}", marker, s.label(), suffix), - style, - )) - }) - .collect(); - let mut source_state = ListState::default(); - // Only the focused panel shows the moving cursor; the active source is always - // marked with `●` regardless of focus. - if source_focused { - source_state.select(Some(app.view.source_list_index)); - } - let source_list = List::new(source_items) - .block( - Block::default() - .title(Span::styled( - "Source", - Style::default().fg(source_border.into()), - )) - .borders(Borders::ALL) - .style(app.user_config.theme.base_style()) - .border_style(Style::default().fg(source_border.into())), - ) - .style(app.user_config.theme.base_style()) - .highlight_style( - Style::default() - .fg(app.user_config.theme.active.into()) - .bg(app.user_config.theme.inactive.into()) - .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), - ) - .highlight_symbol( - Line::from("▶ ").style(Style::default().fg(app.user_config.theme.active.into())), - ); - f.render_stateful_widget(source_list, source_area, &mut source_state); - - // --- Devices panel (Spotify Connect only) --- - // Dimmed under any non-Spotify source (Local, Subsonic): device transfer is a - // Spotify Connect feature. - let non_spotify_active = app.active_source != Source::Spotify; - let devices_focused = app.view.source_device_focus == SourceFocus::Devices && !non_spotify_active; - let devices_color = if devices_focused { - app.user_config.theme.active - } else { - app.user_config.theme.inactive - }; - let devices_title = if non_spotify_active { - "Devices (Spotify only)" - } else { - "Devices" - }; - let device_text_style = if non_spotify_active { - Style::default().fg(app.user_config.theme.inactive.into()) - } else { - app.user_config.theme.base_style() - }; - - let no_device_message = Span::raw("No devices found: Make sure a device is active"); - let items: Vec = match &app.devices { - Some(payload) if !payload.devices.is_empty() => payload - .devices - .iter() - .map(|device| ListItem::new(Span::raw(device.name.clone()))) - .collect(), - _ => vec![ListItem::new(no_device_message)], - }; - - let mut state = ListState::default(); - if devices_focused { - state.select(app.view.selected_device_index); - } - let device_list = List::new(items) - .block( - Block::default() - .title(Span::styled( - devices_title, - Style::default().fg(devices_color.into()), - )) - .borders(Borders::ALL) - .style(device_text_style) - .border_style(Style::default().fg(devices_color.into())), - ) - .style(device_text_style) - .highlight_style( - Style::default() - .fg(app.user_config.theme.active.into()) - .bg(app.user_config.theme.inactive.into()) - .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), - ) - .highlight_symbol( - Line::from("▶ ").style(Style::default().fg(app.user_config.theme.active.into())), - ); - f.render_stateful_widget(device_list, devices_area, &mut state); -} - -#[cfg(test)] -mod tests { - use super::*; - use chrono::Utc; - use rspotify::model::{ - context::{Actions, CurrentPlaybackContext}, - device::Device, - enums::{CurrentlyPlayingType, RepeatState}, - DeviceType, - }; - - #[allow(deprecated)] - fn idle_native_app() -> App { - let mut app = App::default(); - app.is_streaming_active = true; - app.native_device_id = Some("native-device".to_string()); - app.current_playback_context = Some(CurrentPlaybackContext { - device: Device { - id: Some("native-device".to_string()), - is_active: true, - is_private_session: false, - is_restricted: false, - name: "spotatui".to_string(), - _type: DeviceType::Computer, - volume_percent: Some(50), - }, - repeat_state: RepeatState::Off, - shuffle_state: false, - context: None, - timestamp: Utc::now(), - progress: None, - is_playing: false, - item: None, - currently_playing_type: CurrentlyPlayingType::Unknown, - actions: Actions::default(), - }); - app.set_current_route_state(Some(ActiveBlock::PlayBar), Some(ActiveBlock::PlayBar)); - app - } - - #[test] - fn spotify_playback_keeps_every_playbar_control() { - // No decoded source owns playback, so the Spotify playbar is on screen. - assert_eq!( - playbar_supported_controls_for(false, false, false), - PLAYBAR_CONTROLS.to_vec() - ); - } - - #[test] - fn a_cached_spotify_context_does_not_resurrect_controls_for_a_decoded_source() { - // Regression: this gate used to bail out to the full control set whenever - // `current_playback_context` was `Some`, which is the common case for anyone - // who has ever signed in to Spotify — so local files got all eight buttons - // back, including Like. `draw_playbar` consults every decoded source before - // the Spotify context and returns early, so the context says nothing about - // which playbar is actually on screen and must not be consulted here. - let local = playbar_supported_controls_for(false, true, true); - assert!(!local.contains(&PlaybarControl::Like)); - let radio = playbar_supported_controls_for(false, true, false); - assert!(!radio.contains(&PlaybarControl::Shuffle)); - } - - #[test] - fn queueable_decoded_source_gets_transport_and_modes_but_not_like() { - // Regression: Shuffle/Repeat were drawn but not clickable for local files, - // because the row rendered every control while hit-testing kept only - // Play/Pause. Both now come from this list, so a drawn button always works. - let controls = playbar_supported_controls_for(false, true, true); - for expected in [ - PlaybarControl::Prev, - PlaybarControl::PlayPause, - PlaybarControl::Next, - PlaybarControl::Shuffle, - PlaybarControl::Repeat, - PlaybarControl::VolumeDown, - PlaybarControl::VolumeUp, - ] { - assert!( - controls.contains(&expected), - "{expected:?} routes to the decoded source, so it should be offered: {controls:?}" - ); - } - // Liking needs a Spotify item id, which a decoded source has no way to supply. - assert!(!controls.contains(&PlaybarControl::Like)); - } - - #[test] - fn radio_only_gets_the_controls_a_live_stream_can_honour() { - // Radio reports `queueable_decoded = false`: there is no finite track list to - // skip through or shuffle, so offering those buttons would invite a click - // that falls through to the wrong player. - let controls = playbar_supported_controls_for(false, true, false); - assert_eq!( - controls, - vec![ - PlaybarControl::PlayPause, - PlaybarControl::VolumeDown, - PlaybarControl::VolumeUp, - ] - ); - } - - #[test] - fn queue_slot_drops_like_and_the_modes_but_keeps_transport() { - // Regression: a queued track playing over a suspended *Spotify* context sets - // no `*_playback`, so `non_spotify_active` was false and the gate handed back - // all eight controls over the queue slot's playbar — whose Like button saves - // `current_playback_context`'s item, i.e. the suspended Spotify track rather - // than the queued one on screen. Both suspended-context shapes must agree. - // - // Prev/Next stay: unlike radio, the queue *can* honour them (`next_track` - // dispatches AdvanceNativeQueue, `previous_track` restarts the queued track), - // so dropping them would hide controls that work. - let over_spotify = playbar_supported_controls_for(true, false, false); - let over_decoded = playbar_supported_controls_for(true, true, false); - let expected = vec![ - PlaybarControl::Prev, - PlaybarControl::PlayPause, - PlaybarControl::Next, - PlaybarControl::VolumeDown, - PlaybarControl::VolumeUp, - ]; - assert_eq!(over_spotify, expected); - assert_eq!(over_decoded, expected); - } - - #[test] - fn control_hitboxes_handle_zero_sized_area() { - let behavior = crate::core::user_config::UserConfig::new().behavior; - assert!( - playbar_control_hitboxes_in_area(Rect::new(0, 0, 0, 0), &behavior, &PLAYBAR_CONTROLS) - .is_empty() - ); - assert!( - playbar_control_hitboxes_in_area(Rect::new(0, 0, 10, 0), &behavior, &PLAYBAR_CONTROLS) - .is_empty() - ); - } - - #[test] - fn control_hitboxes_truncate_for_tiny_widths() { - let behavior = crate::core::user_config::UserConfig::new().behavior; - let hitboxes = - playbar_control_hitboxes_in_area(Rect::new(5, 10, 8, 1), &behavior, &PLAYBAR_CONTROLS); - assert_eq!(hitboxes.len(), 1); - assert_eq!(hitboxes[0].control, PlaybarControl::Prev); - assert_eq!(hitboxes[0].rect, Rect::new(5, 10, 6, 1)); - } - - #[test] - fn control_hitboxes_include_all_controls_when_wide_enough() { - let behavior = crate::core::user_config::UserConfig::new().behavior; - let hitboxes = - playbar_control_hitboxes_in_area(Rect::new(0, 0, 200, 1), &behavior, &PLAYBAR_CONTROLS); - assert_eq!(hitboxes.len(), PLAYBAR_CONTROLS.len()); - assert_eq!(hitboxes[0].control, PlaybarControl::Prev); - assert_eq!( - hitboxes[PLAYBAR_CONTROLS.len() - 1].control, - PlaybarControl::VolumeUp - ); - } - - #[test] - fn playbar_control_hitboxes_include_controls_for_idle_native_device() { - let app = idle_native_app(); - let hitboxes = playbar_control_hitboxes(&app, Rect::new(0, 0, 200, 6)); - - assert_eq!(hitboxes.len(), PLAYBAR_CONTROLS.len()); - } - - #[cfg(feature = "cover-art")] - #[test] - fn center_rect_within_centers_smaller_rect() { - let bounds = Rect::new(10, 20, 100, 50); - let size = Rect::new(0, 0, 80, 40); - - assert_eq!(center_rect_within(bounds, size), Rect::new(20, 25, 80, 40)); - } - - #[cfg(feature = "cover-art")] - #[test] - fn playbar_cover_layout_uses_default_slot_width() { - let layout = playbar_cover_layout(Rect::new(2, 3, 100, 4), 100); - - assert_eq!(layout.slot, Rect::new(2, 3, 6, 4)); - assert_eq!(layout.image_area, Rect::new(2, 3, 6, 3)); - assert_eq!(layout.text_area, Rect::new(9, 3, 93, 4)); - } - - #[cfg(feature = "cover-art")] - #[test] - fn playbar_cover_layout_centers_rendered_image_in_slot() { - let layout = - playbar_cover_layout(Rect::new(2, 3, 100, 6), 200).with_rendered_size(Rect::new(0, 0, 8, 4)); - - assert_eq!(layout.slot, Rect::new(2, 3, 12, 6)); - assert_eq!(layout.image_area, Rect::new(4, 5, 8, 4)); - assert_eq!(layout.text_area, Rect::new(15, 3, 87, 6)); - } - - #[cfg(feature = "cover-art")] - #[test] - fn playbar_cover_layout_bottom_aligns_smaller_rendered_image_at_max_size() { - let layout = - playbar_cover_layout(Rect::new(2, 3, 100, 4), 200).with_rendered_size(Rect::new(0, 0, 6, 3)); - - assert_eq!(layout.image_area, Rect::new(3, 4, 6, 3)); - } - - #[cfg(feature = "cover-art")] - #[test] - fn cover_playbar_progress_reclaims_the_row_below_the_cover() { - let behavior = crate::core::user_config::UserConfig::new().behavior; - let inner = Rect::new(1, 3, 49, 4); - let text_area = Rect::new(10, 3, 40, 4); - let image_area = Rect::new(1, 3, 6, 3); - let (artist_area, controls_area, progress_area) = - split_cover_playbar_rows(inner, text_area, image_area, &behavior); - - assert_eq!(artist_area, Rect::new(10, 3, 40, 2)); - assert_eq!(controls_area, Rect::new(10, 3, 40, 0)); - assert_eq!(progress_area, Rect::new(1, 6, 49, 1)); - } - - #[cfg(feature = "cover-art")] - #[test] - fn cover_playbar_progress_stays_beside_a_full_height_cover() { - let behavior = crate::core::user_config::UserConfig::new().behavior; - let inner = Rect::new(1, 3, 49, 4); - let text_area = Rect::new(10, 3, 40, 4); - let image_area = Rect::new(1, 3, 8, 4); - let (artist_area, controls_area, progress_area) = - split_cover_playbar_rows(inner, text_area, image_area, &behavior); - - assert_eq!(artist_area, Rect::new(10, 3, 40, 2)); - assert_eq!(controls_area, Rect::new(10, 3, 40, 0)); - assert_eq!(progress_area, Rect::new(10, 6, 40, 1)); - } - - #[cfg(feature = "cover-art")] - #[test] - fn cover_playbar_rows_reserve_full_width_controls_below_the_cover() { - let behavior = crate::core::user_config::UserConfig::new().behavior; - let inner = Rect::new(1, 3, 109, 6); - let text_area = Rect::new(14, 3, 96, 6); - let image_area = Rect::new(1, 3, 10, 4); - let (artist_area, controls_area, progress_area) = - split_cover_playbar_rows(inner, text_area, image_area, &behavior); - - assert_eq!(artist_area, Rect::new(14, 3, 96, 2)); - assert_eq!(controls_area, Rect::new(1, 7, 109, 1)); - assert_eq!(progress_area, Rect::new(1, 8, 109, 1)); - } - - #[cfg(feature = "cover-art")] - #[test] - fn playbar_cover_layout_scales_smaller_and_larger_sizes() { - let smaller = playbar_cover_layout(Rect::new(0, 0, 100, 4), 50); - let larger = playbar_cover_layout(Rect::new(0, 0, 100, 4), 200); - - assert_eq!(smaller.slot.width, 4); - assert_eq!(smaller.text_area, Rect::new(5, 0, 95, 4)); - assert_eq!(larger.slot.width, 8); - assert_eq!(larger.text_area, Rect::new(9, 0, 91, 4)); - assert_eq!(smaller.image_area.height, 2); - assert_eq!(larger.image_area.height, 4); - } - - #[cfg(feature = "cover-art")] - #[test] - fn scaled_cover_art_height_maps_200_to_full_available_height() { - assert_eq!(scaled_cover_art_height(5, 25), 2); - assert_eq!(scaled_cover_art_height(5, 100), 3); - assert_eq!(scaled_cover_art_height(5, 200), 5); - } - - #[cfg(feature = "cover-art")] - #[test] - fn playbar_cover_layout_clamps_to_tiny_playbar_area() { - let layout = playbar_cover_layout(Rect::new(0, 0, 10, 6), 200); - - assert_eq!(layout.slot, Rect::new(0, 0, 8, 6)); - assert_eq!(layout.image_area, Rect::new(0, 0, 8, 6)); - assert_eq!(layout.text_area, Rect::new(9, 0, 1, 6)); - - let zero_height = playbar_cover_layout(Rect::new(4, 5, 10, 0), 100); - assert_eq!(zero_height.slot, Rect::new(4, 5, 0, 0)); - assert_eq!(zero_height.text_area, Rect::new(4, 5, 10, 0)); - } - - /// Collect every rendered cell symbol in `area` into one string for substring - /// assertions. - #[cfg(any(feature = "local-files", feature = "qobuz"))] - fn rendered_text(area: Rect, view: &LocalPlaybarView) -> String { - use ratatui::{backend::TestBackend, Terminal}; - - let app = App::default(); - let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap(); - terminal - .draw(|f| render_local_playbar(f, &app, area, view)) - .unwrap(); - let buffer = terminal.backend().buffer(); - (0..area.height) - .flat_map(|y| (0..area.width).map(move |x| (x, y))) - .filter_map(|(x, y)| buffer.cell((x, y)).map(|c| c.symbol().to_string())) - .collect() - } - - /// Regression guard for the "blank playbar / frozen progress" bugs: with a - /// live position the renderer must show the track name, the playing state, and - /// the elapsed/total time — none of which require an audio device to verify. - #[cfg(feature = "local-files")] - #[test] - fn local_playbar_renders_name_state_and_progress() { - let view = LocalPlaybarView { - source_label: "Local", - name: "My Local Song".to_string(), - artists: "Some Artist".to_string(), - is_playing: true, - position_ms: 60_000, // 1:00 - duration_ms: 311_811, // 5:11 - volume_percent: 80, - queue_position: Some((3, 12)), - live: false, - show_modes: true, - quality: None, - }; - let content = rendered_text(Rect::new(0, 0, 160, 6), &view); - - assert!( - content.contains("My Local Song"), - "track name should render: {content}" - ); - assert!( - content.contains("Shuffle:") && content.contains("Repeat:"), - "a queueable source should show the shuffle/repeat controls: {content}" - ); - assert!( - content.contains("Playing"), - "should show Playing: {content}" - ); - assert!( - content.contains("1:00"), - "elapsed should show 1:00 at a 60s position: {content}" - ); - assert!( - content.contains("5:11"), - "total duration should show 5:11: {content}" - ); - assert!( - content.contains("3/12"), - "queue position should render for a multi-track queue: {content}" - ); - } - - #[cfg(feature = "local-files")] - #[test] - fn local_playbar_shows_paused_state() { - let view = LocalPlaybarView { - source_label: "Local", - name: "Track".to_string(), - artists: "Artist".to_string(), - is_playing: false, - position_ms: 0, - duration_ms: 200_000, - volume_percent: 50, - queue_position: None, - live: false, - show_modes: true, - quality: None, - }; - let content = rendered_text(Rect::new(0, 0, 160, 6), &view); - assert!(content.contains("Paused"), "should show Paused: {content}"); - assert!( - !content.contains('/') || !content.contains("1/1"), - "a single-track session should not show a queue indicator: {content}" - ); - } - - #[cfg(any(feature = "local-files", feature = "qobuz"))] - #[test] - fn playbar_renders_delivered_quality_after_artists() { - let view = LocalPlaybarView { - source_label: "Qobuz", - name: "Track".to_string(), - artists: "Artist".to_string(), - is_playing: true, - position_ms: 0, - duration_ms: 200_000, - volume_percent: 50, - queue_position: None, - live: false, - show_modes: true, - quality: Some("FLAC 16/44.1".to_string()), - }; - let content = rendered_text(Rect::new(0, 0, 160, 6), &view); - assert!( - content.contains("Artist \u{2022} FLAC 16/44.1"), - "the delivered format should follow the artists: {content}" - ); - - let no_artists = LocalPlaybarView { - artists: String::new(), - ..view - }; - let content = rendered_text(Rect::new(0, 0, 160, 6), &no_artists); - assert!( - content.contains("FLAC 16/44.1") && !content.contains('\u{2022}'), - "no separator without artists: {content}" - ); - } - - /// A live (radio) view must render the station, the ICY subtitle and a LIVE - /// elapsed label — and must not divide by the zero duration (which would show - /// a bogus `0:00/0:00` countdown instead). - #[cfg(feature = "local-files")] - #[test] - fn live_playbar_renders_live_label_instead_of_duration() { - let view = LocalPlaybarView { - source_label: "Radio", - name: "SomaFM Groove Salad".to_string(), - artists: "Boards of Canada - Olson".to_string(), - is_playing: true, - position_ms: 83_000, // 1:23 listening time - duration_ms: 0, // the LIVE sentinel - volume_percent: 80, - queue_position: None, - live: true, - show_modes: false, - quality: None, - }; - let content = rendered_text(Rect::new(0, 0, 160, 6), &view); - - assert!( - content.contains("SomaFM Groove Salad"), - "station name should render: {content}" - ); - assert!( - !content.contains("Shuffle:") && !content.contains("Repeat:"), - "radio has no queue, so the shuffle/repeat controls must stay hidden: {content}" - ); - assert!( - content.contains("Boards of Canada - Olson"), - "ICY now-playing should render as the subtitle: {content}" - ); - assert!( - content.contains("LIVE") && content.contains("1:23"), - "progress should render as LIVE with elapsed time: {content}" - ); - assert!( - !content.contains("0:00"), - "a live stream must not render a zero duration countdown: {content}" - ); - } - - fn render_picker(app: &App, w: u16, h: u16) -> String { - use ratatui::{backend::TestBackend, Terminal}; - let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap(); - terminal.draw(|f| draw_device_list(f, app)).unwrap(); - let buffer = terminal.backend().buffer(); - (0..h) - .flat_map(|y| (0..w).map(move |x| (x, y))) - .filter_map(|(x, y)| buffer.cell((x, y)).map(|c| c.symbol().to_string())) - .collect() - } - - #[test] - fn source_picker_lists_both_sources_and_marks_active() { - let app = App::default(); // Spotify is the default active source - let content = render_picker(&app, 60, 28); - assert!( - content.contains("Spotify"), - "Spotify source row should render: {content}" - ); - assert!( - content.contains("Local Files"), - "Local Files source row should render: {content}" - ); - assert!( - content.contains("(active)"), - "the active source should be marked: {content}" - ); - // Spotify active: the Devices panel is the normal, non-dimmed title. - assert!( - !content.contains("Spotify only"), - "devices title should be plain when Spotify is active: {content}" - ); - } - - #[test] - fn source_picker_devices_title_notes_spotify_only_under_local() { - let mut app = App::default(); - app.active_source = Source::Local; - let content = render_picker(&app, 60, 28); - assert!( - content.contains("Spotify only"), - "devices title should note Spotify-only when Local is active: {content}" - ); - } - - #[test] - fn source_picker_keeps_source_panel_on_short_terminal() { - // Fixed instructions + Source panel must not squeeze the Source list off a - // short terminal (the reason the margin was trimmed from 5 to 2). - let app = App::default(); - let content = render_picker(&app, 40, 18); - assert!( - content.contains("Spotify") && content.contains("Local Files"), - "both source rows should still render at 40x18: {content}" - ); - } -} diff --git a/src/tui/ui/plugin_screen.rs b/src/tui/ui/plugin_screen.rs deleted file mode 100644 index c9e3d755..00000000 --- a/src/tui/ui/plugin_screen.rs +++ /dev/null @@ -1,589 +0,0 @@ -use crate::core::app::{App, RouteId}; -use crate::core::plugin_api::{ - PluginCoverArtFit, PluginLength, PluginScreenContent, PluginWidget, PluginWidgetKind, PopupLine, -}; -use crate::tui::theme::ThemeExt; -use ratatui::{ - layout::{Alignment, Constraint, Layout, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Gauge, List, ListItem, ListState, Paragraph}, - Frame, -}; - -/// Rows a `gauge` takes when it carries no explicit height. Sized for the -/// bordered block plus one row of bar. -const GAUGE_ROWS: u16 = 3; - -/// The axis along which a container splits its area. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -enum Axis { - Vertical, - Horizontal, -} - -/// Draw the plugin custom screen named by the current route. Retained-mode: -/// this only reads `app.plugin_screens`; plugins update content via effects. -pub fn draw_plugin_screen(f: &mut Frame<'_>, app: &App) { - let name = match &app.get_current_route().id { - RouteId::PluginScreen(name) => name.clone(), - _ => return, - }; - - let area = f.area(); - let content = app.plugin_screens.get(&name); - let title = content - .map(|c| c.title.clone()) - .filter(|t| !t.is_empty()) - .unwrap_or_else(|| name.clone()); - - let outer = Block::default() - .borders(Borders::ALL) - .style(app.user_config.theme.base_style()) - .border_style(Style::default().fg(app.user_config.theme.active.into())) - .title(Span::styled( - title, - Style::default() - .fg(app.user_config.theme.header.into()) - .add_modifier(Modifier::BOLD), - )); - let inner = outer.inner(area); - f.render_widget(outer, area); - - let Some(content) = content else { - // Registered (or mistyped) screen with no content published yet. - let placeholder = Paragraph::new(Line::from(Span::styled( - format!("plugin screen '{name}' has no content yet"), - Style::default().fg(app.user_config.theme.hint.into()), - ))); - f.render_widget(placeholder, inner); - return; - }; - - draw_widgets(f, app, content, inner); -} - -/// The constraint a widget contributes to its parent's split. -/// -/// Only the size hint matching the parent's axis is consulted; the other is -/// ignored. Without one, the widget shares the remaining space evenly - except -/// a gauge stacked vertically, which keeps its fixed row count. -fn constraint_for(widget: &PluginWidget, axis: Axis) -> Constraint { - let length = match axis { - Axis::Vertical => widget.height, - Axis::Horizontal => widget.width, - }; - match length { - Some(PluginLength::Cells(cells)) => Constraint::Length(cells), - Some(PluginLength::Percent(percent)) => Constraint::Percentage(percent), - None if axis == Axis::Vertical && matches!(widget.kind, PluginWidgetKind::Gauge { .. }) => { - Constraint::Length(GAUGE_ROWS) - } - None => Constraint::Fill(1), - } -} - -/// Flatten a widget tree into drawable leaves paired with their rects. -/// -/// `Row`/`Column` are pure layout: they contribute a constraint to their own -/// parent, then split their share among their children along their own axis. -/// Kept free of `Frame` so the nesting and percentage math is unit-testable. -fn layout_tree<'a>( - widgets: &'a [PluginWidget], - area: Rect, - axis: Axis, - out: &mut Vec<(&'a PluginWidget, Rect)>, -) { - if widgets.is_empty() { - return; - } - - let constraints: Vec = widgets.iter().map(|w| constraint_for(w, axis)).collect(); - let chunks = match axis { - Axis::Vertical => Layout::vertical(constraints).split(area), - Axis::Horizontal => Layout::horizontal(constraints).split(area), - }; - - for (widget, chunk) in widgets.iter().zip(chunks.iter()) { - match &widget.kind { - PluginWidgetKind::Row { children } => layout_tree(children, *chunk, Axis::Horizontal, out), - PluginWidgetKind::Column { children } => layout_tree(children, *chunk, Axis::Vertical, out), - _ => out.push((widget, *chunk)), - } - } -} - -fn draw_widgets(f: &mut Frame<'_>, app: &App, content: &PluginScreenContent, area: Rect) { - let mut leaves = Vec::new(); - layout_tree(&content.widgets, area, Axis::Vertical, &mut leaves); - - for (widget, chunk) in leaves { - match &widget.kind { - PluginWidgetKind::Paragraph { lines, scroll } => { - let text: Vec = lines.iter().map(styled_line).collect(); - let offset = if *scroll { - app.view.plugin_screen_scroll - } else { - 0 - }; - let paragraph = Paragraph::new(text).scroll((offset, 0)); - f.render_widget(paragraph, chunk); - } - PluginWidgetKind::List { - title, - items, - selected, - } => { - let list_items: Vec = items - .iter() - .map(|pl| ListItem::new(styled_line(pl))) - .collect(); - let mut block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(app.user_config.theme.inactive.into())); - if let Some(title) = title { - block = block.title(Span::styled( - title.clone(), - Style::default().fg(app.user_config.theme.header.into()), - )); - } - let list = List::new(list_items).block(block).highlight_style( - Style::default() - .fg(app.user_config.theme.selected.into()) - .add_modifier(Modifier::BOLD), - ); - let mut state = ListState::default(); - state.select(selected.filter(|s| *s < items.len())); - f.render_stateful_widget(list, chunk, &mut state); - } - PluginWidgetKind::Gauge { ratio, label } => { - let gauge = Gauge::default() - .block(Block::default().borders(Borders::ALL)) - .gauge_style(Style::default().fg(app.user_config.theme.playbar_progress.into())) - .ratio(ratio.clamp(0.0, 1.0)) - .label(Span::styled( - label.clone().unwrap_or_default(), - Style::default().fg(app.user_config.theme.playbar_progress_text.into()), - )); - f.render_widget(gauge, chunk); - } - PluginWidgetKind::CoverArt { fit } => draw_cover_art(f, app, chunk, *fit), - // Containers are flattened away by `layout_tree`. - PluginWidgetKind::Row { .. } | PluginWidgetKind::Column { .. } => {} - } - } -} - -/// Draw the current track's cover art into a plugin screen slot. -/// -/// The image is measured then centered, because `ratatui-image` renders -/// top-left within the area it is given. -#[cfg(feature = "cover-art")] -fn draw_cover_art(f: &mut Frame<'_>, app: &App, area: Rect, fit: PluginCoverArtFit) { - use crate::tui::layout::center_rect_within; - use ratatui::widgets::Clear; - - if !app.cover_art.available() { - draw_cover_art_message( - f, - app, - area, - crate::tui::cover_art::status_message(app.cover_art.status), - ); - return; - } - - let fitted = crate::tui::cover_art::plugin_size_for(area, fit, &app.cover_art).unwrap_or(area); - let target = center_rect_within(area, fitted); - - // Clear the whole slot, not just `target`: a previous track's image may have - // been larger, and only cells that enter the frame diff repaint over leftover - // terminal graphics. `Clear` resets them to the terminal default, so restore - // the theme background - unlike the fullscreen view, this sits inside a - // themed block and would otherwise leave a hole around the image. - f.render_widget(Clear, area); - f.render_widget( - Block::default().style(app.user_config.theme.base_style()), - area, - ); - crate::tui::cover_art::render_plugin(f, target, fit, &app.cover_art, &app.user_config); -} - -#[cfg(not(feature = "cover-art"))] -fn draw_cover_art(f: &mut Frame<'_>, app: &App, area: Rect, _fit: PluginCoverArtFit) { - // The widget still parses without the feature so a plugin degrades to a - // labelled gap rather than failing at `set_screen`. - draw_cover_art_message(f, app, area, "Built without cover art"); -} - -fn draw_cover_art_message(f: &mut Frame<'_>, app: &App, area: Rect, message: &str) { - if area.height == 0 { - return; - } - let paragraph = Paragraph::new(message) - .style(Style::default().fg(app.user_config.theme.inactive.into())) - .alignment(Alignment::Center); - f.render_widget( - paragraph, - Rect { - x: area.x, - y: area.y + area.height / 2, - width: area.width, - height: 1, - }, - ); -} - -fn styled_line(pl: &PopupLine) -> Line<'static> { - let mut style = Style::default(); - if let Some(fg) = pl.fg { - style = style.fg(fg.into()); - } - if pl.bold { - style = style.add_modifier(Modifier::BOLD); - } - if pl.italic { - style = style.add_modifier(Modifier::ITALIC); - } - Line::from(Span::styled(pl.text.clone(), style)) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn paragraph() -> PluginWidgetKind { - PluginWidgetKind::Paragraph { - lines: Vec::new(), - scroll: true, - } - } - - fn gauge() -> PluginWidgetKind { - PluginWidgetKind::Gauge { - ratio: 0.5, - label: None, - } - } - - fn sized( - kind: PluginWidgetKind, - width: Option, - height: Option, - ) -> PluginWidget { - PluginWidget { - kind, - width, - height, - } - } - - fn rects(widgets: &[PluginWidget], area: Rect) -> Vec { - let mut out = Vec::new(); - layout_tree(widgets, area, Axis::Vertical, &mut out); - out.into_iter().map(|(_, rect)| rect).collect() - } - - #[test] - fn top_level_stacks_vertically_and_splits_fill_evenly() { - let widgets = vec![ - PluginWidget::new(paragraph()), - PluginWidget::new(paragraph()), - ]; - assert_eq!( - rects(&widgets, Rect::new(0, 0, 40, 20)), - vec![Rect::new(0, 0, 40, 10), Rect::new(0, 10, 40, 10)] - ); - } - - #[test] - fn explicit_height_takes_its_rows_before_fill() { - let widgets = vec![ - sized(paragraph(), None, Some(PluginLength::Cells(3))), - PluginWidget::new(paragraph()), - ]; - assert_eq!( - rects(&widgets, Rect::new(0, 0, 40, 20)), - vec![Rect::new(0, 0, 40, 3), Rect::new(0, 3, 40, 17)] - ); - } - - #[test] - fn row_splits_horizontally_by_width_percent() { - let widgets = vec![PluginWidget::new(PluginWidgetKind::Row { - children: vec![ - sized(paragraph(), Some(PluginLength::Percent(40)), None), - sized(paragraph(), Some(PluginLength::Percent(60)), None), - ], - })]; - assert_eq!( - rects(&widgets, Rect::new(0, 0, 100, 20)), - vec![Rect::new(0, 0, 40, 20), Rect::new(40, 0, 60, 20)] - ); - } - - #[test] - fn row_nested_in_column_uses_each_containers_axis() { - // The row is sized vertically by its own `height`; its children are then - // sized horizontally by their `width`. - let widgets = vec![ - sized( - PluginWidgetKind::Row { - children: vec![ - sized(paragraph(), Some(PluginLength::Cells(30)), None), - PluginWidget::new(paragraph()), - ], - }, - None, - Some(PluginLength::Cells(8)), - ), - PluginWidget::new(paragraph()), - ]; - assert_eq!( - rects(&widgets, Rect::new(0, 0, 100, 20)), - vec![ - Rect::new(0, 0, 30, 8), - Rect::new(30, 0, 70, 8), - Rect::new(0, 8, 100, 12), - ] - ); - } - - #[test] - fn gauge_defaults_to_fixed_rows_but_honors_an_explicit_height() { - let widgets = vec![ - PluginWidget::new(gauge()), - sized(gauge(), None, Some(PluginLength::Cells(6))), - PluginWidget::new(paragraph()), - ]; - assert_eq!( - rects(&widgets, Rect::new(0, 0, 40, 20)), - vec![ - Rect::new(0, 0, 40, GAUGE_ROWS), - Rect::new(0, GAUGE_ROWS, 40, 6), - Rect::new(0, GAUGE_ROWS + 6, 40, 11), - ] - ); - } - - #[test] - fn gauge_in_a_row_fills_the_height_and_shares_the_width() { - // The fixed row count only applies on the vertical axis. - let widgets = vec![PluginWidget::new(PluginWidgetKind::Row { - children: vec![PluginWidget::new(gauge()), PluginWidget::new(gauge())], - })]; - assert_eq!( - rects(&widgets, Rect::new(0, 0, 40, 20)), - vec![Rect::new(0, 0, 20, 20), Rect::new(20, 0, 20, 20)] - ); - } - - fn render(content: PluginScreenContent, width: u16, height: u16) -> ratatui::buffer::Buffer { - render_scrolled(content, width, height, 0) - } - - fn render_scrolled( - content: PluginScreenContent, - width: u16, - height: u16, - scroll: u16, - ) -> ratatui::buffer::Buffer { - render_with(content, width, height, |app| { - app.view.plugin_screen_scroll = scroll; - }) - .1 - } - - fn render_with( - content: PluginScreenContent, - width: u16, - height: u16, - setup: impl FnOnce(&mut App), - ) -> (App, ratatui::buffer::Buffer) { - use crate::core::app::ActiveBlock; - use ratatui::{backend::TestBackend, Terminal}; - - let mut app = App::default(); - app.push_navigation_stack( - RouteId::PluginScreen("demo".to_string()), - ActiveBlock::PluginScreen, - ); - app.plugin_screens.insert("demo".to_string(), content); - setup(&mut app); - - let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); - terminal.draw(|f| draw_plugin_screen(f, &app)).unwrap(); - let buffer = terminal.backend().buffer().clone(); - (app, buffer) - } - - fn row_text(buffer: &ratatui::buffer::Buffer, y: u16) -> String { - cols(buffer, y, 0..buffer.area().width) - } - - /// Text in a column range. Ranges are taken over cells, not bytes: the border - /// glyphs are multi-byte, so slicing the joined string would not line up. - fn cols(buffer: &ratatui::buffer::Buffer, y: u16, range: std::ops::Range) -> String { - range - .filter_map(|x| buffer.cell((x, y)).map(|c| c.symbol().to_string())) - .collect() - } - - fn text(line: &str) -> PopupLine { - PopupLine { - text: line.to_string(), - fg: None, - bold: false, - italic: false, - } - } - - /// A `cover_art` beside a paragraph must draw the text in the right-hand - /// column, and label the image slot rather than leaving it blank when no - /// image is loaded (which is also the whole story in a build without the - /// `cover-art` feature). - #[test] - fn cover_art_row_draws_the_sibling_paragraph_and_labels_the_image_slot() { - let content = PluginScreenContent { - title: "Demo".to_string(), - widgets: vec![PluginWidget::new(PluginWidgetKind::Row { - children: vec![ - sized( - PluginWidgetKind::CoverArt { - fit: PluginCoverArtFit::Contain, - }, - Some(PluginLength::Percent(50)), - None, - ), - sized( - PluginWidgetKind::Paragraph { - lines: vec![text("lyric line")], - scroll: true, - }, - Some(PluginLength::Percent(50)), - None, - ), - ], - })], - }; - - // Wide enough that the longest slot message fits in half the inner area. - let buffer = render(content, 100, 12); - // Inner area is x=1..99, y=1..11; the row fills it, so the two 50% columns - // are cells 1..50 and 50..99. - let (left, right) = (1..50, 50..99); - - let paragraph_row = cols(&buffer, 1, right.clone()); - assert!( - paragraph_row.contains("lyric line"), - "paragraph not in the right-hand column: {paragraph_row}" - ); - let left_of_paragraph = cols(&buffer, 1, left.clone()); - assert!( - !left_of_paragraph.contains("lyric line"), - "paragraph leaked into the image column: {left_of_paragraph}" - ); - - // The message differs per build: with the feature on this is the - // "no image loaded yet" status, with it off it is the degraded-build hint. - #[cfg(feature = "cover-art")] - let expected = "No cover art available"; - #[cfg(not(feature = "cover-art"))] - let expected = "Built without cover art"; - - let label_row = cols(&buffer, 1 + 10 / 2, left); - assert!( - label_row.contains(expected), - "expected {expected:?} in the left-hand column: {label_row}" - ); - } - - /// The image slot `Clear`s its cells to strip terminal graphics artifacts, - /// which resets them to the terminal default - inside a themed block that - /// would punch a visible hole. Nothing in the inner area may be left reset. - #[cfg(feature = "cover-art")] - #[test] - fn cover_art_image_slot_restores_the_theme_background() { - crate::tui::cover_art::init_test_renderer(); - let content = PluginScreenContent { - title: "Demo".to_string(), - widgets: vec![PluginWidget::new(PluginWidgetKind::CoverArt { - fit: PluginCoverArtFit::Contain, - })], - }; - - let background = crate::core::theme::Color::Rgb(10, 20, 30); - let (_app, buffer) = render_with(content, 40, 20, |app| { - app.user_config.theme.background = background; - // 64x64 so the fitted image covers several cells whatever the detected - // font size is. `store_decoded` is synchronous; no network involved. - app - .cover_art - .store_decoded("test".to_string(), image::DynamicImage::new_rgb8(64, 64)); - }); - for y in 1..19 { - for x in 1..39 { - let cell = buffer.cell((x, y)).expect("cell in bounds"); - assert_ne!( - cell.bg, - ratatui::style::Color::Reset, - "cell ({x}, {y}) left at the terminal default background" - ); - } - } - } - - /// A `scroll = false` paragraph stays put while the shared PageUp/PageDown - /// offset moves the rest - the case that matters once a header sits beside - /// scrolling content in a `row`. - #[test] - fn scroll_offset_skips_paragraphs_that_opted_out() { - let content = || PluginScreenContent { - title: "Demo".to_string(), - widgets: vec![ - sized( - PluginWidgetKind::Paragraph { - lines: vec![text("pinned header"), text("header line two")], - scroll: false, - }, - None, - Some(PluginLength::Cells(4)), - ), - PluginWidget::new(PluginWidgetKind::Paragraph { - lines: vec![text("body first"), text("body second")], - scroll: true, - }), - ], - }; - - let unscrolled = render(content(), 40, 12); - assert!(row_text(&unscrolled, 1).contains("pinned header")); - assert!(row_text(&unscrolled, 5).contains("body first")); - - let scrolled = render_scrolled(content(), 40, 12, 1); - assert!( - row_text(&scrolled, 1).contains("pinned header"), - "opted-out paragraph scrolled: {}", - row_text(&scrolled, 1) - ); - assert!( - row_text(&scrolled, 5).contains("body second"), - "scrolling paragraph did not move: {}", - row_text(&scrolled, 5) - ); - } - - #[test] - fn empty_container_contributes_no_leaves() { - let widgets = vec![ - PluginWidget::new(PluginWidgetKind::Row { - children: Vec::new(), - }), - PluginWidget::new(paragraph()), - ]; - assert_eq!( - rects(&widgets, Rect::new(0, 0, 40, 20)), - vec![Rect::new(0, 10, 40, 10)] - ); - } -} diff --git a/src/tui/ui/popups.rs b/src/tui/ui/popups.rs deleted file mode 100644 index a25d68bb..00000000 --- a/src/tui/ui/popups.rs +++ /dev/null @@ -1,1452 +0,0 @@ -use crate::core::app::{ - ActiveBlock, AnnouncementLevel, App, DialogContext, HelpMenuModel, PlaylistPickerRow, -}; -use crate::core::plugin_api::PlayableInfo; -use crate::core::plugin_api::PopupLine; -use crate::infra::network::sync::PartyStatus; -use ratatui::{ - layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Row, Table, Wrap}, - Frame, -}; - -use super::help::{get_filtered_help_docs, help_match_ranges}; -use crate::tui::theme::{EmphasisExt, ThemeExt}; - -/// Rebuild [`App::help_menu_model`] if the terminal width, keybindings, or -/// filter changed since the last build. Called from the event loop (and tests) -/// before drawing, so [`draw_help_menu`] renders from immutable `App` state -/// instead of rebuilding ~80 owned Strings (plus per-cell char-count -/// truncation) on every redraw while Help is open. -pub fn ensure_help_menu_model(app: &mut App) { - // Mirrors draw_help_menu's layout: a margin of 2 on each side of the frame. - let total_width = (app.view.size.width as usize).saturating_sub(4); - let stale = app.view.help_menu_model.as_ref().is_none_or(|m| { - m.width != total_width - || m.keys != app.user_config.keys - || m.source != app.active_source - || m.spotify_connected != app.spotify_connected - || m.filter != app.view.help_filter - }); - if !stale { - return; - } - let (header, rows) = build_help_rows(app, total_width); - // The pager counts the rows built here, so a source or session change - // while Help is open cannot leave it paging over a stale count. - app.view.help_docs_size = rows.len() as u32; - let match_ranges = rows - .iter() - .map(|row| help_match_ranges(row, &app.view.help_filter)) - .collect(); - app.view.help_menu_model = Some(HelpMenuModel { - width: total_width, - keys: app.user_config.keys.clone(), - source: app.active_source, - spotify_connected: app.spotify_connected, - filter: app.view.help_filter.clone(), - header, - rows, - match_ranges, - }); -} - -/// Split `text` into spans so every filter match renders in the highlight -/// style, making it obvious why a row survived the filter. `ranges` are -/// ascending, non-overlapping byte ranges (e.g. [`help_match_ranges`] from the -/// help model, or a settings row's fuzzy-match ranges). -pub(crate) fn highlighted_spans<'a>( - text: &'a str, - ranges: &[(usize, usize)], - base: Style, - highlight: Style, -) -> Vec> { - if ranges.is_empty() { - return vec![Span::styled(text, base)]; - } - let mut spans = Vec::with_capacity(ranges.len() * 2 + 1); - let mut cursor = 0; - for &(start, end) in ranges { - if cursor < start { - spans.push(Span::styled(&text[cursor..start], base)); - } - spans.push(Span::styled(&text[start..end], highlight)); - cursor = end; - } - if cursor < text.len() { - spans.push(Span::styled(&text[cursor..], base)); - } - spans -} - -fn build_help_rows(app: &App, total_width: usize) -> (String, Vec) { - // Create a one-column table to avoid flickering due to non-determinism when - // resolving constraints on widths of table columns. - // Calculate column widths based on available terminal width. - let col1_width = (total_width as f32 * 0.40) as usize; - let col2_width = (total_width as f32 * 0.30) as usize; - let col3_width = total_width.saturating_sub(col1_width + col2_width + 2); - - let truncate = |s: &str, max: usize| -> String { - if max == 0 { - return String::new(); - } - if s.chars().count() > max { - let truncated: String = s.chars().take(max.saturating_sub(1)).collect(); - format!("{}…", truncated) - } else { - s.to_string() - } - }; - - let format_row = |r: Vec| -> String { - format!( - "{: