diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5f4695..775b0a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,8 +7,31 @@ on: branches: [develop] jobs: - check: - name: Check & lint (${{ matrix.os }}) + lint: + name: Lint & Format (Linux) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Install Linux dependencies + run: | + sudo apt-get update + sudo apt-get install -y pkg-config libasound2-dev + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: cargo fmt + run: cargo fmt --check + + - name: cargo clippy + run: cargo clippy --all-features -- -D warnings + + test: + name: Test (${{ matrix.os }}) + needs: lint runs-on: ${{ matrix.os }} strategy: @@ -17,7 +40,7 @@ jobs: os: [windows-latest, macos-latest, ubuntu-latest] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Install Linux dependencies if: runner.os == 'Linux' @@ -27,21 +50,9 @@ jobs: - name: Install Rust stable uses: dtolnay/rust-toolchain@stable - with: - components: clippy, rustfmt - - - name: cargo fmt - run: cargo fmt --check - - name: Cache cargo registry - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - target - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - restore-keys: ${{ runner.os }}-cargo- + - name: Rust Cache + uses: swatinem/rust-cache@v2 - name: cargo check run: cargo check --all-features @@ -49,12 +60,15 @@ jobs: - name: cargo test run: cargo test --all-features - - name: cargo clippy - run: cargo clippy --all-features -- -D warnings - required_check: name: Check & lint - needs: check + needs: [lint, test] + if: ${{ always() }} runs-on: ubuntu-latest steps: - - run: echo "All checks passed!" + - name: Verify required jobs + run: | + if [ "${{ needs.lint.result }}" != "success" ] || \ + [ "${{ needs.test.result }}" != "success" ]; then + exit 1 + fi diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 55d720a..0bc647c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Install Linux dependencies run: | @@ -27,7 +27,7 @@ jobs: sudo apt-get install -y pkg-config libasound2-dev - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@v4.37.9 with: languages: rust @@ -35,6 +35,6 @@ jobs: run: cargo build --all-features - name: Perform CodeQL analysis - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@v4.37.9 with: category: "/language:rust" diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index 4227739..c42235e 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -14,7 +14,7 @@ jobs: sync: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: EndBug/label-sync@v2 with: config-file: .github/labels.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a8ba02b..8e18b80 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: runs-on: windows-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Install Rust stable uses: dtolnay/rust-toolchain@stable @@ -22,7 +22,7 @@ jobs: targets: x86_64-pc-windows-msvc - name: Cache cargo registry - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | ~/.cargo/registry @@ -42,7 +42,7 @@ jobs: Copy-Item "target\x86_64-pc-windows-msvc\release\reverbic.exe" "dist\reverbic-$version-x86_64-windows.exe" - name: Upload Windows artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: release-windows path: dist/* @@ -61,7 +61,7 @@ jobs: asset_arch: aarch64 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Install Rust stable uses: dtolnay/rust-toolchain@stable @@ -69,7 +69,7 @@ jobs: targets: ${{ matrix.target }} - name: Cache cargo registry - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | ~/.cargo/registry @@ -94,7 +94,7 @@ jobs: tar -C "$staging" -czf "dist/$archive" reverbic - name: Upload macOS artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: release-macos-${{ matrix.target }} path: dist/*.tar.gz @@ -106,7 +106,7 @@ jobs: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Install Linux dependencies run: | @@ -119,7 +119,7 @@ jobs: targets: x86_64-unknown-linux-gnu - name: Cache cargo registry - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | ~/.cargo/registry @@ -144,7 +144,7 @@ jobs: tar -C "$staging" -czf "dist/$archive" reverbic - name: Upload Linux artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: release-linux-x86_64 path: dist/*.tar.gz @@ -160,7 +160,7 @@ jobs: steps: - name: Download release artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: path: dist merge-multiple: true diff --git a/CHANGELOG.es.md b/CHANGELOG.es.md index e3cbd63..ad39cbc 100644 --- a/CHANGELOG.es.md +++ b/CHANGELOG.es.md @@ -10,6 +10,20 @@ Versionado: [Semantic Versioning](https://semver.org/) ## [Sin publicar] +## [1.5.8] - 2026-09-07 + +### Added +- Soporte oficial de instalación mediante Winget: Reverbic ahora se puede instalar en Windows con `winget install Sewandev.Reverbic` como método recomendado junto al script de PowerShell. + +### Fixed +- Los comandos de pista siguiente/anterior en el modo remoto de Spotify ahora se serializan a través de una cola FIFO, permitiendo una sola solicitud en curso a la vez. Esto evita condiciones de carrera y ejecuciones fuera de orden al saltar pistas rápidamente, descarta respuestas obsoletas de dispositivos anteriores y mantiene sincronizado el estado de reproducción remoto. +- La detección de descargas estancadas en streams ahora utiliza un reloj monotónico (`ProgressClock` con `std::time::Instant`) en lugar de marcas de tiempo del reloj del sistema. Esto evita falsas alertas de estancamiento o demoras en la reconexión provocadas por correcciones de NTP, cambios manuales de hora o ajustes de zona horaria. +- El flujo de CI ahora propaga de forma fiable los fallos de lint y formato al check de estado obligatorio, garantizando que las comprobaciones de PR fallen cuando el análisis de código o formato detecta problemas. + +### Changed +- Flujo de CI optimizado: se separaron las revisiones de formato y clippy en un trabajo inicial de fallo rápido y se integró `swatinem/rust-cache` para acelerar notablemente las ejecuciones de compilación y pruebas. +- Actualización general de dependencias: Ratatui 0.30.2, Zip 8.6.0, Bytes 1.12.0, Clap 4.6.6, Rand 0.10.2, Thiserror 2.0.20, y actualización de acciones en los workflows de GitHub. + ## [1.5.7] - 2026-06-21 ### Added @@ -332,7 +346,12 @@ Versionado: [Semantic Versioning](https://semver.org/) - Templates de issues (bug, feature, pregunta) - Logo y assets embebidos en el ejecutable (sin dependencias externas) -[Sin publicar]: https://github.com/sewandev/Reverbic/compare/v1.5.3...HEAD +[Sin publicar]: https://github.com/sewandev/Reverbic/compare/v1.5.8...HEAD +[1.5.8]: https://github.com/sewandev/Reverbic/compare/v1.5.7...v1.5.8 +[1.5.7]: https://github.com/sewandev/Reverbic/compare/v1.5.6...v1.5.7 +[1.5.6]: https://github.com/sewandev/Reverbic/compare/v1.5.5...v1.5.6 +[1.5.5]: https://github.com/sewandev/Reverbic/compare/v1.5.4...v1.5.5 +[1.5.4]: https://github.com/sewandev/Reverbic/compare/v1.5.3...v1.5.4 [1.5.3]: https://github.com/sewandev/Reverbic/compare/v1.5.2...v1.5.3 [1.5.2]: https://github.com/sewandev/Reverbic/compare/v1.5.1...v1.5.2 [1.5.1]: https://github.com/sewandev/Reverbic/compare/v1.5.0...v1.5.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index c20b822..0efd79e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,20 @@ Versioning: [Semantic Versioning](https://semver.org/) ## [Unreleased] +## [1.5.8] - 2026-09-07 + +### Added +- Official Winget installation support: Reverbic can now be installed on Windows with `winget install Sewandev.Reverbic` as the recommended package manager method alongside the PowerShell script. + +### Fixed +- Spotify remote next/previous commands are now serialized through a FIFO queue, allowing only one skip request in flight at a time. This eliminates race conditions and out-of-order execution when rapidly skipping tracks, ignores stale responses from previously active devices, and keeps remote playback state accurately in sync. +- Stream download stall detection now uses a monotonic clock (`ProgressClock` backed by `std::time::Instant`) instead of wall-clock Unix timestamps. This prevents false underruns or delayed stream reconnects caused by NTP adjustments, manual clock updates, or timezone changes. +- CI workflows now reliably propagate lint and formatting failures to the required status check, ensuring PR checks fail whenever upstream linting fails. + +### Changed +- Streamlined CI workflow: separated format and clippy checks into an initial fast-fail lint job and integrated `swatinem/rust-cache` to significantly accelerate build and test pipelines. +- Dependency updates across the board: Ratatui 0.30.2, Zip 8.6.0, Bytes 1.12.0, Clap 4.6.6, Rand 0.10.2, Thiserror 2.0.20, and updated GitHub Actions steps. + ## [1.5.7] - 2026-06-21 ### Added @@ -332,7 +346,12 @@ Versioning: [Semantic Versioning](https://semver.org/) - Issue templates (bug, feature, question) - Logo and assets embedded in the executable (no external dependencies) -[Unreleased]: https://github.com/sewandev/Reverbic/compare/v1.5.3...HEAD +[Unreleased]: https://github.com/sewandev/Reverbic/compare/v1.5.8...HEAD +[1.5.8]: https://github.com/sewandev/Reverbic/compare/v1.5.7...v1.5.8 +[1.5.7]: https://github.com/sewandev/Reverbic/compare/v1.5.6...v1.5.7 +[1.5.6]: https://github.com/sewandev/Reverbic/compare/v1.5.5...v1.5.6 +[1.5.5]: https://github.com/sewandev/Reverbic/compare/v1.5.4...v1.5.5 +[1.5.4]: https://github.com/sewandev/Reverbic/compare/v1.5.3...v1.5.4 [1.5.3]: https://github.com/sewandev/Reverbic/compare/v1.5.2...v1.5.3 [1.5.2]: https://github.com/sewandev/Reverbic/compare/v1.5.1...v1.5.2 [1.5.1]: https://github.com/sewandev/Reverbic/compare/v1.5.0...v1.5.1 diff --git a/Cargo.lock b/Cargo.lock index eb2e34a..f82a2ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -142,15 +142,6 @@ 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" @@ -310,9 +301,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" [[package]] name = "castaway" @@ -351,6 +342,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.45" @@ -377,9 +379,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.1" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -387,9 +389,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -399,14 +401,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.4", ] [[package]] @@ -798,17 +800,6 @@ 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 2.0.117", -] - [[package]] name = "derive_builder" version = "0.20.2" @@ -1077,6 +1068,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -1244,6 +1236,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -1838,7 +1831,7 @@ checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" dependencies = [ "hashbrown 0.16.1", "portable-atomic", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -1913,7 +1906,7 @@ dependencies = [ "librespot-core", "log", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", ] @@ -1931,7 +1924,7 @@ dependencies = [ "protobuf", "rand 0.9.4", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-stream", "uuid", @@ -1983,7 +1976,7 @@ dependencies = [ "sha1", "shannon", "sysinfo", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "tokio", "tokio-stream", @@ -2008,7 +2001,7 @@ dependencies = [ "protobuf", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "uuid", ] @@ -2022,7 +2015,7 @@ dependencies = [ "oauth2", "open", "reqwest", - "thiserror 2.0.18", + "thiserror 2.0.20", "url", ] @@ -2045,7 +2038,7 @@ dependencies = [ "rodio 0.21.1", "shell-words", "symphonia", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "zerocopy", ] @@ -3046,7 +3039,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -3067,7 +3060,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -3129,6 +3122,17 @@ dependencies = [ "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" @@ -3167,6 +3171,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_distr" version = "0.5.1" @@ -3179,14 +3189,15 @@ dependencies = [ [[package]] name = "ratatui" -version = "0.30.1" +version = "0.30.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1695748e3a735b34968c887ceea5a380b43545903868ae8f5b666593100f6b68" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" dependencies = [ "instability", "ratatui-core", "ratatui-crossterm", "ratatui-macros", + "ratatui-termina", "ratatui-termwiz", "ratatui-widgets", "serde", @@ -3194,22 +3205,21 @@ dependencies = [ [[package]] name = "ratatui-core" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3603f354bba8c595fa47860e60142d7372b7210c27044c6a7d0e1a4336b44" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" dependencies = [ "bitflags 2.13.0", "compact_str", "critical-section", "hashbrown 0.17.1", - "indoc", "itertools", "kasuari", "lru", "palette", "serde", "strum", - "thiserror 2.0.18", + "thiserror 2.0.20", "unicode-segmentation", "unicode-truncate", "unicode-width", @@ -3217,9 +3227,9 @@ dependencies = [ [[package]] name = "ratatui-crossterm" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b2867bedcbd6a690ca4f8672a687b730ec07660c79844517b084311b529980c" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" dependencies = [ "cfg-if", "crossterm", @@ -3229,19 +3239,30 @@ dependencies = [ [[package]] name = "ratatui-macros" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80fac59720679490d89d200df411faa249be728681adcabed3d047ae72c48f1d" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" dependencies = [ "ratatui-core", "ratatui-widgets", ] +[[package]] +name = "ratatui-termina" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + [[package]] name = "ratatui-termwiz" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "386b8ff8f74ed749509391c56d549761a2fcdb408e1f42e467286bcb7dac8967" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" dependencies = [ "ratatui-core", "termwiz", @@ -3249,9 +3270,9 @@ dependencies = [ [[package]] name = "ratatui-widgets" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef4f17dd7ac3abf5adc2b920a03c61eee4bfe6a88fa5191936895525371d79c" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" dependencies = [ "bitflags 2.13.0", "hashbrown 0.17.1", @@ -3284,7 +3305,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -3367,7 +3388,7 @@ dependencies = [ [[package]] name = "reverbic" -version = "1.5.7" +version = "1.5.8" dependencies = [ "arboard", "base64 0.21.7", @@ -3388,7 +3409,7 @@ dependencies = [ "librespot-metadata", "librespot-playback", "nucleo-matcher", - "rand 0.9.4", + "rand 0.10.2", "ratatui", "reqwest", "rodio 0.22.2", @@ -3396,7 +3417,7 @@ dependencies = [ "serde_json", "sha2 0.11.0", "tar", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tracing", "tracing-appender", @@ -3441,7 +3462,7 @@ dependencies = [ "dasp_sample", "num-rational", "symphonia", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -4137,6 +4158,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -4216,6 +4248,19 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.0", + "parking_lot", + "rustix 1.1.4", + "signal-hook", + "windows-sys 0.61.2", +] + [[package]] name = "terminfo" version = "0.9.0" @@ -4290,11 +4335,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.20", ] [[package]] @@ -4310,13 +4355,13 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.4", ] [[package]] @@ -4609,7 +4654,7 @@ checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" dependencies = [ "crossbeam-channel", "symlink", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "tracing-subscriber", ] @@ -4686,10 +4731,16 @@ dependencies = [ "rustls", "rustls-pki-types", "sha1", - "thiserror 2.0.18", + "thiserror 2.0.20", "utf-8", ] +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typenum" version = "1.20.1" @@ -6020,21 +6071,24 @@ dependencies = [ [[package]] name = "zip" -version = "2.4.2" +version = "8.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" dependencies = [ - "arbitrary", "crc32fast", - "crossbeam-utils", - "displaydoc", "flate2", "indexmap", "memchr", - "thiserror 2.0.18", + "typed-path", "zopfli", ] +[[package]] +name = "zlib-rs" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "977347db8caa080403f6b6b7c1cda9479a8e869316f7e13a59b19076a40f94e3" + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index f902d3f..bd864e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reverbic" -version = "1.5.7" +version = "1.5.8" edition = "2021" description = "Terminal player for radio, Spotify and YouTube with gaming overlay and Discord Rich Presence" repository = "https://github.com/sewandev/Reverbic" @@ -29,10 +29,10 @@ serde_json = "1" chrono = "0.4" sha2 = "0.11" tar = "0.4" -rand = { version = "0.9", default-features = false, features = ["std", "std_rng"] } +rand = { version = "0.10", default-features = false, features = ["std", "std_rng", "thread_rng"] } base64 = "0.21" dunce = "1" -zip = { version = "2", default-features = false, features = ["deflate"] } +zip = { version = "8", default-features = false, features = ["deflate"] } librespot-core = { version = "0.8", default-features = false, features = ["rustls-tls-native-roots"] } librespot-playback = { version = "0.8", default-features = false, features = ["rodio-backend", "rustls-tls-native-roots"] } librespot-audio = { version = "0.8", default-features = false, features = ["rustls-tls-native-roots"] } diff --git a/README.es.md b/README.es.md index dce12bf..e5a2b6c 100644 --- a/README.es.md +++ b/README.es.md @@ -28,6 +28,9 @@ ```bash # Instalación rápida (Windows) +winget install Sewandev.Reverbic + +# O, mediante script de PowerShell (Windows) irm https://raw.githubusercontent.com/sewandev/Reverbic/main/install.ps1 | iex # Instalación rápida (macOS / Linux) diff --git a/README.md b/README.md index 059a10b..42a301a 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,9 @@ ```bash # Quick install (Windows) +winget install Sewandev.Reverbic + +# Or, via PowerShell script (Windows) irm https://raw.githubusercontent.com/sewandev/Reverbic/main/install.ps1 | iex # Quick install (macOS / Linux) diff --git a/src/app/integrations.rs b/src/app/integrations.rs index 0e73a53..01faa79 100644 --- a/src/app/integrations.rs +++ b/src/app/integrations.rs @@ -1,5 +1,7 @@ use super::modal::{SearchMode, SpotifyAuthStatus, SpotifyPlayerStatus}; -use super::spotify_state::{SpotifyPlaybackBackend, SpotifySearchPage}; +use super::spotify_state::{ + SpotifyPlaybackBackend, SpotifyRemoteSkipDirection, SpotifyRemoteSkipResult, SpotifySearchPage, +}; use super::{abort_task, App, SpotifyControlTarget}; use crate::config::{Config, SpotifyPlaybackMode}; @@ -748,6 +750,8 @@ impl App { self.config.save(); self.spotify.token_refreshed_at = Some(std::time::Instant::now()); self.start_playback_polling(); + self.spotify.remote_skip_paused_for_token_refresh = false; + self.start_next_spotify_remote_skip(); tracing::info!("spotify: access_token refreshed"); } Ok(Err(e)) => { @@ -767,61 +771,137 @@ impl App { } pub fn poll_spotify_play_result(&mut self) { - use crate::integrations::spotify::SpotifyError; if let Some(rx) = self.spotify.play_result_rx.take() { match rx.try_recv() { - Ok(Ok(())) => { - if self.spotify.active_backend == Some(SpotifyPlaybackBackend::Remote) { - self.spotify.player_status = SpotifyPlayerStatus::Playing; - } + Ok(result) => self.handle_spotify_remote_command_result(result), + Err(std::sync::mpsc::TryRecvError::Empty) => { + self.spotify.play_result_rx = Some(rx); } - Ok(Err(SpotifyError::Unauthorized)) => { - if self.spotify.active_backend == Some(SpotifyPlaybackBackend::Remote) { - self.spotify.active_backend = None; - } - self.spotify.player_status = SpotifyPlayerStatus::Error(crate::i18n::t( - "integrations.spotify.error.generic", - )); - tracing::warn!("spotify play: token expirado, renovando"); - self.spotify.token_refreshed_at = - Some(std::time::Instant::now() - std::time::Duration::from_secs(60 * 60)); + Err(std::sync::mpsc::TryRecvError::Disconnected) => {} + } + } + } + + fn handle_spotify_remote_command_result( + &mut self, + result: Result<(), crate::integrations::spotify::SpotifyError>, + ) { + use crate::integrations::spotify::SpotifyError; + match result { + Ok(()) => { + if self.spotify.active_backend == Some(SpotifyPlaybackBackend::Remote) { + self.spotify.player_status = SpotifyPlayerStatus::Playing; } - Ok(Err(SpotifyError::DeviceUnavailable)) => { - if self.spotify.active_backend == Some(SpotifyPlaybackBackend::Remote) { - self.spotify.active_backend = None; + } + Err(SpotifyError::Unauthorized) => { + if self.spotify.active_backend == Some(SpotifyPlaybackBackend::Remote) { + self.spotify.active_backend = None; + } + self.spotify.player_status = SpotifyPlayerStatus::Error(crate::i18n::t( + "integrations.spotify.error.generic", + )); + tracing::warn!("spotify command: token expirado, renovando"); + self.spotify.token_refreshed_at = + Some(std::time::Instant::now() - std::time::Duration::from_secs(60 * 60)); + } + Err(SpotifyError::DeviceUnavailable) => { + if self.spotify.active_backend == Some(SpotifyPlaybackBackend::Remote) { + self.spotify.active_backend = None; + } + if let Some(dead_id) = self.spotify.active_device_id.take() { + tracing::warn!( + device_id = dead_id, + "spotify command: device did not respond, evicting it" + ); + self.spotify.failed_device_ids.insert(dead_id.clone()); + self.spotify + .devices + .retain(|d| d.id.as_deref() != Some(&dead_id)); + } + self.spotify.active_device_id = resolve_active_spotify_device( + &self.spotify.devices, + self.spotify.active_device_id.as_deref(), + ); + let message = crate::i18n::t("integrations.spotify.error.device_gone"); + self.spotify.player_status = SpotifyPlayerStatus::Error(message.clone()); + self.notify_error(format!("Spotify: {message}")); + self.fetch_spotify_devices(); + } + Err(e) => { + tracing::warn!("spotify command: {e}"); + if self.spotify.active_backend == Some(SpotifyPlaybackBackend::Remote) { + self.spotify.active_backend = None; + } + self.spotify.player_status = SpotifyPlayerStatus::Error(crate::i18n::t( + "integrations.spotify.error.generic", + )); + } + } + } + + pub fn poll_spotify_remote_skip_result(&mut self) { + let Some(rx) = self.spotify.remote_skip_result_rx.take() else { + return; + }; + match rx.try_recv() { + Ok(outcome) => { + self.spotify.remote_skip_task = None; + if !self + .spotify + .remote_skip_queue + .complete(outcome.id, &outcome.device_id) + { + tracing::debug!( + operation_id = outcome.id, + device_id = outcome.device_id, + "spotify remote skip: ignoring stale result" + ); + return; + } + match self.spotify_control_target() { + SpotifyControlTarget::Remote { device_id, .. } + if device_id == outcome.device_id => + { + let unauthorized = matches!( + &outcome.result, + Err(crate::integrations::spotify::SpotifyError::Unauthorized) + ); + if unauthorized { + self.spotify.remote_skip_paused_for_token_refresh = true; + } + self.handle_spotify_remote_command_result(outcome.result); + self.start_playback_polling(); + if !unauthorized { + self.start_next_spotify_remote_skip(); + } } - if let Some(dead_id) = self.spotify.active_device_id.take() { - tracing::warn!( - device_id = dead_id, - "spotify play_on_device: device did not respond, evicting it" + SpotifyControlTarget::Remote { .. } => { + tracing::debug!( + operation_id = outcome.id, + device_id = outcome.device_id, + "spotify remote skip: device changed, ignoring result state" ); - self.spotify.failed_device_ids.insert(dead_id.clone()); - self.spotify - .devices - .retain(|d| d.id.as_deref() != Some(&dead_id)); + self.start_playback_polling(); + self.start_next_spotify_remote_skip(); } - self.spotify.active_device_id = resolve_active_spotify_device( - &self.spotify.devices, - self.spotify.active_device_id.as_deref(), - ); - let message = crate::i18n::t("integrations.spotify.error.device_gone"); - self.spotify.player_status = SpotifyPlayerStatus::Error(message.clone()); - self.notify_error(format!("Spotify: {message}")); - self.fetch_spotify_devices(); - } - Ok(Err(e)) => { - tracing::warn!("spotify play_on_device: {e}"); - if self.spotify.active_backend == Some(SpotifyPlaybackBackend::Remote) { - self.spotify.active_backend = None; + SpotifyControlTarget::Native | SpotifyControlTarget::None => { + tracing::debug!( + operation_id = outcome.id, + device_id = outcome.device_id, + "spotify remote skip: remote control is no longer active" + ); + self.cancel_spotify_remote_skips(); } - self.spotify.player_status = SpotifyPlayerStatus::Error(crate::i18n::t( - "integrations.spotify.error.generic", - )); - } - Err(std::sync::mpsc::TryRecvError::Empty) => { - self.spotify.play_result_rx = Some(rx); } - Err(std::sync::mpsc::TryRecvError::Disconnected) => {} + } + Err(std::sync::mpsc::TryRecvError::Empty) => { + self.spotify.remote_skip_result_rx = Some(rx); + } + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + self.spotify.remote_skip_task = None; + tracing::warn!("spotify remote skip: result channel disconnected"); + self.spotify.remote_skip_queue.abandon_current(); + self.start_next_spotify_remote_skip(); } } } @@ -960,6 +1040,7 @@ impl App { abort_task(&mut self.spotify.playback_task); self.spotify.playback_rx = None; self.spotify.playback = None; + self.cancel_spotify_remote_skips(); if self.spotify.active_backend == Some(SpotifyPlaybackBackend::Remote) { self.spotify.active_backend = None; } @@ -1126,24 +1207,74 @@ impl App { self.spotify.player_status = SpotifyPlayerStatus::Loading; } - fn spotify_remote_skip(&mut self, token: String, device_id: String, forward: bool) { + fn spotify_remote_skip(&mut self, device_id: String, direction: SpotifyRemoteSkipDirection) { + self.spotify.remote_skip_queue.enqueue(device_id, direction); + self.start_next_spotify_remote_skip(); + } + + pub(super) fn cancel_spotify_remote_skips(&mut self) { + abort_task(&mut self.spotify.remote_skip_task); + self.spotify.remote_skip_queue.invalidate(); + self.spotify.remote_skip_paused_for_token_refresh = false; + self.spotify.remote_skip_result_rx = None; + } + + fn start_next_spotify_remote_skip(&mut self) { + if self.spotify.remote_skip_paused_for_token_refresh { + return; + } + let (token, active_device_id) = match self.spotify_control_target() { + SpotifyControlTarget::Remote { token, device_id } => (token, device_id), + SpotifyControlTarget::Native | SpotifyControlTarget::None => { + self.cancel_spotify_remote_skips(); + return; + } + }; + let operation = loop { + let Some(operation) = self.spotify.remote_skip_queue.begin_next() else { + return; + }; + if active_device_id == operation.device_id { + break operation; + } + tracing::debug!( + operation_id = operation.id, + device_id = operation.device_id, + "spotify remote skip: device changed, dropping queued operation" + ); + self.spotify + .remote_skip_queue + .complete(operation.id, &operation.device_id); + }; let (tx, rx) = std::sync::mpsc::channel(); - self.spotify.play_result_rx = Some(rx); - tokio::spawn(async move { - let result = if forward { - crate::integrations::spotify::devices::next_track(&token, &device_id).await - } else { - crate::integrations::spotify::devices::previous_track(&token, &device_id).await + self.spotify.remote_skip_result_rx = Some(rx); + let handle = tokio::spawn(async move { + let result = match operation.direction { + SpotifyRemoteSkipDirection::Next => { + crate::integrations::spotify::devices::next_track(&token, &operation.device_id) + .await + } + SpotifyRemoteSkipDirection::Previous => { + crate::integrations::spotify::devices::previous_track( + &token, + &operation.device_id, + ) + .await + } }; - let _ = tx.send(result); + let _ = tx.send(SpotifyRemoteSkipResult { + id: operation.id, + device_id: operation.device_id, + result, + }); }); - self.start_playback_polling(); + self.spotify.remote_skip_task = Some(handle); } pub(super) async fn spotify_play_next(&mut self) { match self.spotify_control_target() { - super::SpotifyControlTarget::Remote { token, device_id } => { - self.spotify_remote_skip(token, device_id, true); + super::SpotifyControlTarget::Remote { device_id, .. } => { + self.spotify_remote_skip(device_id, SpotifyRemoteSkipDirection::Next); } super::SpotifyControlTarget::Native => self.native_next(), super::SpotifyControlTarget::None => { @@ -1154,8 +1285,8 @@ impl App { pub(super) async fn spotify_play_previous(&mut self) { match self.spotify_control_target() { - super::SpotifyControlTarget::Remote { token, device_id } => { - self.spotify_remote_skip(token, device_id, false); + super::SpotifyControlTarget::Remote { device_id, .. } => { + self.spotify_remote_skip(device_id, SpotifyRemoteSkipDirection::Previous); } super::SpotifyControlTarget::Native => self.native_prev(), super::SpotifyControlTarget::None => { @@ -1680,6 +1811,162 @@ mod tests { } } + #[tokio::test] + async fn stopping_remote_polling_cancels_current_and_queued_skips() { + let mut app = App::new().await; + app.spotify + .remote_skip_queue + .enqueue("device".to_string(), SpotifyRemoteSkipDirection::Next); + app.spotify + .remote_skip_queue + .enqueue("device".to_string(), SpotifyRemoteSkipDirection::Previous); + let operation = app + .spotify + .remote_skip_queue + .begin_next() + .expect("remote skip starts"); + let (tx, rx) = std::sync::mpsc::channel(); + app.spotify.remote_skip_result_rx = Some(rx); + let task = tokio::spawn(async { std::future::pending::<()>().await }); + let abort_handle = task.abort_handle(); + app.spotify.remote_skip_task = Some(task); + + app.stop_playback_polling(); + tokio::task::yield_now().await; + + assert!(abort_handle.is_finished()); + assert!(app.spotify.remote_skip_task.is_none()); + assert!(app.spotify.remote_skip_result_rx.is_none()); + assert!(app.spotify.remote_skip_queue.begin_next().is_none()); + assert!( + tx.send(SpotifyRemoteSkipResult { + id: operation.id, + device_id: operation.device_id, + result: Ok(()), + }) + .is_err(), + "remote skip result receiver was dropped" + ); + } + + #[tokio::test] + async fn remote_skip_result_from_previous_device_does_not_update_player_state() { + let mut app = App::new().await; + app.spotify.access_token = Some("token".to_string()); + app.spotify.active_device_id = Some("new-device".to_string()); + app.spotify.active_backend = Some(SpotifyPlaybackBackend::Remote); + app.spotify.player_status = SpotifyPlayerStatus::Loading; + + app.spotify + .remote_skip_queue + .enqueue("old-device".to_string(), SpotifyRemoteSkipDirection::Next); + let operation = app + .spotify + .remote_skip_queue + .begin_next() + .expect("skip starts"); + let (tx, rx) = std::sync::mpsc::channel(); + app.spotify.remote_skip_result_rx = Some(rx); + tx.send(SpotifyRemoteSkipResult { + id: operation.id, + device_id: operation.device_id, + result: Err(crate::integrations::spotify::SpotifyError::Unauthorized), + }) + .expect("receiver is alive"); + + app.poll_spotify_remote_skip_result(); + + assert_eq!( + app.spotify.active_backend, + Some(SpotifyPlaybackBackend::Remote) + ); + assert!(matches!( + app.spotify.player_status, + SpotifyPlayerStatus::Loading + )); + } + + #[tokio::test] + async fn unauthorized_remote_skip_waits_for_token_refresh_before_dispatching_next() { + let mut app = App::new().await; + app.config.spotify.playback_mode = SpotifyPlaybackMode::Remote; + app.spotify.access_token = Some("expired-token".to_string()); + app.spotify.active_device_id = Some("device".to_string()); + app.spotify.active_backend = Some(SpotifyPlaybackBackend::Remote); + app.spotify + .remote_skip_queue + .enqueue("device".to_string(), SpotifyRemoteSkipDirection::Next); + app.spotify + .remote_skip_queue + .enqueue("device".to_string(), SpotifyRemoteSkipDirection::Previous); + let operation = app + .spotify + .remote_skip_queue + .begin_next() + .expect("first skip starts"); + let (tx, rx) = std::sync::mpsc::channel(); + app.spotify.remote_skip_result_rx = Some(rx); + tx.send(SpotifyRemoteSkipResult { + id: operation.id, + device_id: operation.device_id, + result: Err(crate::integrations::spotify::SpotifyError::Unauthorized), + }) + .expect("receiver is alive"); + + app.poll_spotify_remote_skip_result(); + + assert!(app.spotify.remote_skip_paused_for_token_refresh); + assert!(app.spotify.remote_skip_task.is_none()); + assert!(app.spotify.remote_skip_result_rx.is_none()); + app.spotify_play_next().await; + assert!( + app.spotify.remote_skip_task.is_none(), + "new input must not resume the queue with the expired token" + ); + let pending = app + .spotify + .remote_skip_queue + .begin_next() + .expect("queued skip waits for refreshed credentials"); + assert_eq!(pending.direction, SpotifyRemoteSkipDirection::Previous); + } + + #[tokio::test] + async fn remote_skip_result_in_native_mode_does_not_restart_polling_or_dispatch_queue() { + let mut app = App::new().await; + app.config.spotify.playback_mode = SpotifyPlaybackMode::Native; + app.spotify.access_token = Some("token".to_string()); + app.spotify.active_device_id = Some("device".to_string()); + app.spotify.active_backend = Some(SpotifyPlaybackBackend::Native); + + app.spotify + .remote_skip_queue + .enqueue("device".to_string(), SpotifyRemoteSkipDirection::Next); + app.spotify + .remote_skip_queue + .enqueue("device".to_string(), SpotifyRemoteSkipDirection::Previous); + let operation = app + .spotify + .remote_skip_queue + .begin_next() + .expect("skip starts"); + let (tx, rx) = std::sync::mpsc::channel(); + app.spotify.remote_skip_result_rx = Some(rx); + tx.send(SpotifyRemoteSkipResult { + id: operation.id, + device_id: operation.device_id, + result: Ok(()), + }) + .expect("receiver is alive"); + + app.poll_spotify_remote_skip_result(); + + assert!(app.spotify.playback_task.is_none()); + assert!(app.spotify.playback_rx.is_none()); + assert!(app.spotify.remote_skip_result_rx.is_none()); + assert!(app.spotify.remote_skip_queue.begin_next().is_none()); + } + #[tokio::test] async fn poll_playlist_tracks_does_not_backfill_playlist_total_from_loaded_tracks() { let mut app = App::new().await; diff --git a/src/app/mod.rs b/src/app/mod.rs index cb3e065..b199bcb 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1071,6 +1071,21 @@ mod tests { app.spotify.playback_task = Some(tokio::spawn(async { std::future::pending::<()>().await; })); + app.spotify.remote_skip_queue.enqueue( + "device".to_string(), + spotify_state::SpotifyRemoteSkipDirection::Next, + ); + app.spotify.remote_skip_queue.enqueue( + "device".to_string(), + spotify_state::SpotifyRemoteSkipDirection::Previous, + ); + let skip_operation = app + .spotify + .remote_skip_queue + .begin_next() + .expect("remote skip starts"); + let (skip_tx, skip_rx) = std::sync::mpsc::channel(); + app.spotify.remote_skip_result_rx = Some(skip_rx); app.set_spotify_playback_mode(SpotifyPlaybackMode::Native); @@ -1082,6 +1097,18 @@ mod tests { assert!(app.spotify.playback.is_none()); assert!(app.spotify.playback_rx.is_none()); assert!(app.spotify.playback_task.is_none()); + assert!(app.spotify.remote_skip_result_rx.is_none()); + assert!(app.spotify.remote_skip_queue.begin_next().is_none()); + assert!( + skip_tx + .send(spotify_state::SpotifyRemoteSkipResult { + id: skip_operation.id, + device_id: skip_operation.device_id, + result: Ok(()), + }) + .is_err(), + "skip result receiver was dropped" + ); } #[tokio::test] diff --git a/src/app/spotify_state.rs b/src/app/spotify_state.rs index 4552825..66442ac 100644 --- a/src/app/spotify_state.rs +++ b/src/app/spotify_state.rs @@ -21,6 +21,137 @@ type PlaylistsResultRx = std::sync::mpsc::Receiver, bool), SpotifyError>>; type AlbumsResultRx = std::sync::mpsc::Receiver, bool), SpotifyError>>; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum SpotifyRemoteSkipDirection { + Next, + Previous, +} + +#[derive(Debug)] +pub(super) struct SpotifyRemoteSkipOperation { + pub(super) id: u64, + pub(super) device_id: String, + pub(super) direction: SpotifyRemoteSkipDirection, +} + +#[derive(Debug)] +pub(super) struct SpotifyRemoteSkipResult { + pub(super) id: u64, + pub(super) device_id: String, + pub(super) result: Result<(), SpotifyError>, +} + +#[derive(Debug, PartialEq, Eq)] +struct SpotifyRemoteSkipInFlight { + id: u64, + device_id: String, +} + +#[derive(Debug, Default)] +pub(super) struct SpotifyRemoteSkipQueue { + next_id: u64, + pending: VecDeque, + in_flight: Option, +} + +impl SpotifyRemoteSkipQueue { + pub(super) fn enqueue( + &mut self, + device_id: String, + direction: SpotifyRemoteSkipDirection, + ) -> u64 { + let id = self.next_id; + self.next_id = self + .next_id + .checked_add(1) + .expect("Spotify remote skip operation ID exhausted"); + self.pending.push_back(SpotifyRemoteSkipOperation { + id, + device_id, + direction, + }); + id + } + + pub(super) fn begin_next(&mut self) -> Option { + if self.in_flight.is_some() { + return None; + } + let operation = self.pending.pop_front()?; + self.in_flight = Some(SpotifyRemoteSkipInFlight { + id: operation.id, + device_id: operation.device_id.clone(), + }); + Some(operation) + } + + pub(super) fn complete(&mut self, id: u64, device_id: &str) -> bool { + let is_current = self + .in_flight + .as_ref() + .is_some_and(|current| current.id == id && current.device_id == device_id); + if is_current { + self.in_flight = None; + } + is_current + } + + pub(super) fn abandon_current(&mut self) { + self.in_flight = None; + } + + pub(super) fn invalidate(&mut self) { + self.pending.clear(); + self.in_flight = None; + } +} + +#[cfg(test)] +mod remote_skip_queue_tests { + use super::*; + + #[test] + fn remote_skips_are_started_one_at_a_time_in_input_order() { + let mut queue = SpotifyRemoteSkipQueue::default(); + let first_id = queue.enqueue("device".to_string(), SpotifyRemoteSkipDirection::Next); + let second_id = queue.enqueue("device".to_string(), SpotifyRemoteSkipDirection::Previous); + + let first = queue.begin_next().expect("first skip starts"); + assert_eq!(first.id, first_id); + assert_eq!(first.direction, SpotifyRemoteSkipDirection::Next); + assert!(queue.begin_next().is_none(), "only one skip may run"); + + assert!(queue.complete(first.id, &first.device_id)); + let second = queue.begin_next().expect("second skip starts next"); + assert_eq!(second.id, second_id); + assert_eq!(second.direction, SpotifyRemoteSkipDirection::Previous); + } + + #[test] + fn stale_result_cannot_release_the_current_operation() { + let mut queue = SpotifyRemoteSkipQueue::default(); + queue.enqueue("device".to_string(), SpotifyRemoteSkipDirection::Next); + let current = queue.begin_next().expect("skip starts"); + + assert!(!queue.complete(current.id + 1, ¤t.device_id)); + assert!(!queue.complete(current.id, "different-device")); + assert!(queue.begin_next().is_none()); + assert!(queue.complete(current.id, ¤t.device_id)); + } + + #[test] + fn invalidating_remote_skips_drops_current_and_pending_operations() { + let mut queue = SpotifyRemoteSkipQueue::default(); + queue.enqueue("device".to_string(), SpotifyRemoteSkipDirection::Next); + queue.enqueue("device".to_string(), SpotifyRemoteSkipDirection::Previous); + queue.begin_next().expect("first skip starts"); + + queue.invalidate(); + + assert!(queue.begin_next().is_none()); + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum SpotifyPlaybackBackend { Remote, @@ -82,6 +213,10 @@ pub struct SpotifyState { Option>>, pub(super) play_result_rx: Option>>, + pub(super) remote_skip_queue: SpotifyRemoteSkipQueue, + pub(super) remote_skip_paused_for_token_refresh: bool, + pub(super) remote_skip_task: Option>, + pub(super) remote_skip_result_rx: Option>, pub(super) save_track_rx: Option>>, pub playback_queue: VecDeque, @@ -165,6 +300,7 @@ impl SpotifyState { abort(&mut self.search_more_task); abort(&mut self.devices_task); abort(&mut self.playback_task); + abort(&mut self.remote_skip_task); abort(&mut self.token_refresh_task); abort(&mut self.radio_task); abort(&mut self.liked_task); @@ -225,6 +361,10 @@ impl Default for SpotifyState { token_refresh_task: None, token_refresh_rx: None, play_result_rx: None, + remote_skip_queue: SpotifyRemoteSkipQueue::default(), + remote_skip_paused_for_token_refresh: false, + remote_skip_task: None, + remote_skip_result_rx: None, save_track_rx: None, playback_queue: VecDeque::new(), radio_queue: VecDeque::new(), diff --git a/src/audio/player.rs b/src/audio/player.rs index 90e79a1..8ddd520 100644 --- a/src/audio/player.rs +++ b/src/audio/player.rs @@ -10,7 +10,7 @@ use tokio::sync::{mpsc, watch}; use tracing::{error, info, warn}; use crate::audio::meter::rms_to_db; -use crate::audio::stream::{FileBackedDownload, FileBackedReader, StreamReader}; +use crate::audio::stream::{FileBackedDownload, FileBackedReader, ProgressClock, StreamReader}; use crate::station::Station; pub enum PlayerCommand { @@ -388,7 +388,7 @@ struct AudioLoopState { reconnect_count: u32, stream_retry_at: Option<(u32, std::time::Instant)>, od: OnDemandTracker, - stream_last_chunk: Option>, + stream_progress: Option, stream_download_done: Option>, download_complete: bool, file_backed: bool, @@ -416,7 +416,7 @@ impl AudioLoopState { reconnect_count: 0, stream_retry_at: None, od: OnDemandTracker::inactive(), - stream_last_chunk: None, + stream_progress: None, stream_download_done: None, download_complete: false, file_backed: false, @@ -433,7 +433,7 @@ struct StreamConnection { player: Player, duration_secs: Option, title_rx: std_mpsc::Receiver, - last_chunk: Arc, + progress_clock: ProgressClock, download_done: Arc, file_backed: bool, written: Option>, @@ -583,7 +583,7 @@ fn cancel_file_backed_download( .file_download .take() .and_then(|download| download.cancel(handle)); - st.stream_last_chunk = None; + st.stream_progress = None; st.stream_download_done = None; st.file_backed = false; st.stream_written = None; @@ -601,7 +601,7 @@ fn cancel_file_backed_download( fn check_download_done(st: &mut AudioLoopState) -> bool { if let Some(ref arc) = st.stream_download_done { if arc.load(Ordering::Acquire) { - st.stream_last_chunk = None; + st.stream_progress = None; st.stream_download_done = None; st.download_complete = true; st.file_download = None; @@ -649,29 +649,28 @@ fn check_on_demand_finished(st: &mut AudioLoopState, state_tx: &watch::Sender stall_threshold * 1000 { + if stalled_for > std::time::Duration::from_secs(stall_threshold) { let delay = backoff_duration( st.reconnect_count, BASE_RECONNECT_DELAY_SECS, @@ -683,8 +682,8 @@ fn check_stream_stall(st: &mut AudioLoopState) { delay.as_secs_f32(), st.reconnect_count + 1 ); - st.stream_last_chunk = None; - st.reconnect_at = Some(std::time::Instant::now() + delay); + st.stream_progress = None; + st.reconnect_at = Some(now + delay); st.reconnect_count += 1; } } @@ -711,7 +710,7 @@ fn open_stream( station.custom_headers.clone(), handle.clone(), ); - let last_chunk = stream_reader.last_chunk_arc(); + let progress_clock = stream_reader.progress_clock(); let download_done = stream_reader.download_done_arc(); let dead_url_arc = stream_reader.dead_url_arc(); @@ -745,7 +744,7 @@ fn open_stream( player, duration_secs, title_rx, - last_chunk, + progress_clock, download_done, file_backed: false, written: None, @@ -804,7 +803,7 @@ fn open_file_backed_stream( Err(e) => return Err(Some(format!("cache file: {e}"))), }; - let last_chunk = reader.last_chunk_arc(); + let progress_clock = reader.progress_clock(); let download_done = reader.download_done_arc(); let dead_url_arc = reader.dead_url_arc(); let written = reader.written_arc(); @@ -836,7 +835,7 @@ fn open_file_backed_stream( player, duration_secs, title_rx, - last_chunk, + progress_clock, download_done, file_backed: true, written: Some(written), @@ -947,7 +946,7 @@ fn handle_play_cmd( Ok(conn) => { st.stream_retry_at = None; st.title_rx = Some(conn.title_rx); - st.stream_last_chunk = Some(conn.last_chunk); + st.stream_progress = Some(conn.progress_clock); st.stream_download_done = Some(conn.download_done); st.file_backed = conn.file_backed; st.stream_written = conn.written; @@ -1002,7 +1001,7 @@ fn handle_crossfade_cmd( Ok(conn) => { st.stream_retry_at = None; st.title_rx = Some(conn.title_rx); - st.stream_last_chunk = Some(conn.last_chunk); + st.stream_progress = Some(conn.progress_clock); st.stream_download_done = Some(conn.download_done); st.file_backed = conn.file_backed; st.stream_written = conn.written; @@ -1172,7 +1171,7 @@ fn handle_seek_cmd( let (mut stream_reader, new_title_rx) = StreamReader::connect(url, byte_offset, 4096, custom_headers, handle.clone()); st.title_rx = Some(new_title_rx); - st.stream_last_chunk = Some(stream_reader.last_chunk_arc()); + st.stream_progress = Some(stream_reader.progress_clock()); st.stream_download_done = Some(stream_reader.download_done_arc()); st.download_complete = false; let prebuffer_secs = st @@ -1259,7 +1258,7 @@ fn handle_device_change( stop_crossfade_out(st); cancel_file_backed_download(st, handle, true); st.title_rx = None; - st.stream_last_chunk = None; + st.stream_progress = None; st.stream_download_done = None; st.download_complete = false; st.file_backed = false; @@ -1554,7 +1553,7 @@ fn audio_loop( st.reconnect_at = None; st.reconnect_count = 0; st.stream_retry_at = None; - st.stream_last_chunk = None; + st.stream_progress = None; st.stream_download_done = None; st.download_complete = false; st.file_backed = false; @@ -1612,6 +1611,43 @@ mod tests { assert_eq!(on_demand_byte_offset(10.0, &station), 160_000); } + #[test] + fn live_stream_stall_reconnects_only_after_thirty_seconds() { + let started = std::time::Instant::now(); + let mut st = AudioLoopState::new(); + st.current_station = Some(station_with_bitrate(None)); + st.stream_progress = Some(ProgressClock::with_last_progress_at(started)); + + check_stream_stall_at(&mut st, started + std::time::Duration::from_secs(30)); + assert!(st.reconnect_at.is_none()); + + let stalled_at = started + std::time::Duration::from_millis(30_001); + check_stream_stall_at(&mut st, stalled_at); + + assert!(st.reconnect_at.is_some_and(|at| at > stalled_at)); + assert!(st.stream_progress.is_none()); + assert_eq!(st.reconnect_count, 1); + } + + #[test] + fn on_demand_stall_reconnects_only_after_sixty_seconds() { + let started = std::time::Instant::now(); + let mut st = AudioLoopState::new(); + st.current_station = Some(station_with_bitrate(None)); + st.od.active = true; + st.stream_progress = Some(ProgressClock::with_last_progress_at(started)); + + check_stream_stall_at(&mut st, started + std::time::Duration::from_secs(60)); + assert!(st.reconnect_at.is_none()); + + let stalled_at = started + std::time::Duration::from_millis(60_001); + check_stream_stall_at(&mut st, stalled_at); + + assert!(st.reconnect_at.is_some_and(|at| at > stalled_at)); + assert!(st.stream_progress.is_none()); + assert_eq!(st.reconnect_count, 1); + } + #[test] fn conditional_preview_stop_ignores_stale_preview_timer() { let mut st = AudioLoopState::new(); @@ -1680,7 +1716,7 @@ mod tests { FileBackedDownload::from_parts_for_test(task, path.clone(), Arc::clone(&done)); let mut st = AudioLoopState::new(); st.file_backed = true; - st.stream_last_chunk = Some(Arc::new(AtomicU64::new(1))); + st.stream_progress = Some(ProgressClock::default()); st.stream_download_done = Some(done); st.stream_written = Some(Arc::new(AtomicU64::new(1))); st.stream_total = Some(Arc::new(AtomicU64::new(10))); @@ -1690,7 +1726,7 @@ mod tests { assert!(st.file_download.is_none()); assert!(!st.file_backed); - assert!(st.stream_last_chunk.is_none()); + assert!(st.stream_progress.is_none()); assert!(st.stream_download_done.is_none()); assert!(st.stream_written.is_none()); assert!(st.stream_total.is_none()); diff --git a/src/audio/stream.rs b/src/audio/stream.rs index ddd9880..4d04d03 100644 --- a/src/audio/stream.rs +++ b/src/audio/stream.rs @@ -5,15 +5,54 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; use std::sync::{mpsc, Mutex}; +use std::time::{Duration, Instant}; use crate::metadata::parse_icy_title; +#[derive(Clone, Default)] +pub(crate) struct ProgressClock { + last_progress: Arc>>, +} + +impl ProgressClock { + pub(crate) fn record_progress(&self) { + self.record_progress_at(Instant::now()); + } + + fn record_progress_at(&self, now: Instant) { + let mut last_progress = self + .last_progress + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *last_progress = Some(now); + } + + pub(crate) fn elapsed(&self) -> Option { + self.elapsed_at(Instant::now()) + } + + pub(crate) fn elapsed_at(&self, now: Instant) -> Option { + let last_progress = *self + .last_progress + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + last_progress.map(|last| now.saturating_duration_since(last)) + } + + #[cfg(test)] + pub(crate) fn with_last_progress_at(last_progress: Instant) -> Self { + Self { + last_progress: Arc::new(Mutex::new(Some(last_progress))), + } + } +} + pub struct StreamReader { rx: Mutex>, chunks: VecDeque, offset: usize, buffered: usize, - last_chunk_at: Arc, + progress_clock: ProgressClock, download_done: Arc, dead_url: Arc, } @@ -23,16 +62,12 @@ impl StreamReader { if chunk.is_empty() { return; } - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0); - self.last_chunk_at.store(now, Ordering::Release); + self.progress_clock.record_progress(); self.buffered += chunk.len(); self.chunks.push_back(chunk); } - pub fn last_chunk_arc(&self) -> Arc { - Arc::clone(&self.last_chunk_at) + pub(crate) fn progress_clock(&self) -> ProgressClock { + self.progress_clock.clone() } pub fn connect( @@ -66,13 +101,13 @@ impl StreamReader { } }); - let last_chunk_at = Arc::new(AtomicU64::new(0)); + let progress_clock = ProgressClock::default(); let reader = Self { rx: Mutex::new(audio_rx), chunks: VecDeque::new(), offset: 0, buffered: 0, - last_chunk_at, + progress_clock, download_done, dead_url, }; @@ -96,13 +131,13 @@ impl StreamReader { } }); - let last_chunk_at = Arc::new(AtomicU64::new(0)); + let progress_clock = ProgressClock::default(); Self { rx: Mutex::new(audio_rx), chunks: VecDeque::new(), offset: 0, buffered: 0, - last_chunk_at, + progress_clock, download_done: Arc::new(AtomicBool::new(false)), dead_url: Arc::new(AtomicBool::new(false)), } @@ -186,13 +221,10 @@ impl Seek for StreamReader { const MAX_DOWNLOAD_RETRIES: u32 = 5; const DOWNLOAD_RETRY_PAUSE: std::time::Duration = std::time::Duration::from_secs(2); const FILE_READ_STARVATION_SECS: u64 = 30; -const FILE_READ_STALL_MS: u64 = 12_000; +const FILE_READ_STALL_SECS: u64 = 12; -fn unix_now_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0) +fn file_read_is_stalled(stalled_for: Option) -> bool { + stalled_for.is_some_and(|elapsed| elapsed >= Duration::from_secs(FILE_READ_STALL_SECS)) } pub fn youtube_cache_dir() -> std::path::PathBuf { @@ -213,7 +245,7 @@ pub struct FileBackedReader { pos: u64, written: Arc, total_len: Arc, - last_chunk_at: Arc, + progress_clock: ProgressClock, download_done: Arc, dead_url: Arc, } @@ -302,13 +334,13 @@ impl FileBackedReader { let (title_tx, title_rx) = mpsc::sync_channel::(1); let written = Arc::new(AtomicU64::new(0)); let total_len = Arc::new(AtomicU64::new(0)); - let last_chunk_at = Arc::new(AtomicU64::new(0)); + let progress_clock = ProgressClock::default(); let download_done = Arc::new(AtomicBool::new(false)); let dead_url = Arc::new(AtomicBool::new(false)); let written_task = Arc::clone(&written); let total_task = Arc::clone(&total_len); - let last_chunk_task = Arc::clone(&last_chunk_at); + let progress_clock_task = progress_clock.clone(); let done_task = Arc::clone(&download_done); let dead_task = Arc::clone(&dead_url); let task = handle.spawn(async move { @@ -318,7 +350,7 @@ impl FileBackedReader { write_file, written_task, total_task, - last_chunk_task, + progress_clock_task, done_task, dead_task, ) @@ -340,7 +372,7 @@ impl FileBackedReader { pos: 0, written, total_len, - last_chunk_at, + progress_clock, download_done, dead_url, }, @@ -349,8 +381,8 @@ impl FileBackedReader { )) } - pub fn last_chunk_arc(&self) -> Arc { - Arc::clone(&self.last_chunk_at) + pub(crate) fn progress_clock(&self) -> ProgressClock { + self.progress_clock.clone() } pub fn download_done_arc(&self) -> Arc { @@ -415,16 +447,15 @@ impl Read for FileBackedReader { ); return Ok(0); } - let last_chunk = self.last_chunk_at.load(Ordering::Acquire); - let stalled_ms = unix_now_ms().saturating_sub(last_chunk); - let stalled = last_chunk != 0 && stalled_ms >= FILE_READ_STALL_MS; + let stalled_for = self.progress_clock.elapsed(); + let stalled = file_read_is_stalled(stalled_for); if stalled || wait_start.elapsed() > std::time::Duration::from_secs(FILE_READ_STARVATION_SECS) { tracing::warn!( pos = self.pos, written, - stalled_ms, + stalled_ms = stalled_for.map(|elapsed| elapsed.as_millis()), "File-backed read underrun: download not progressing, signaling EOF" ); return Ok(0); @@ -468,7 +499,7 @@ async fn download_to_file( mut file: std::fs::File, written: Arc, total_len: Arc, - last_chunk_at: Arc, + progress_clock: ProgressClock, download_done: Arc, dead_url: Arc, ) -> Result<(), reqwest::Error> { @@ -567,7 +598,7 @@ async fn download_to_file( } current_offset += bytes.len() as u64; written.store(current_offset, Ordering::Release); - last_chunk_at.store(unix_now_ms(), Ordering::Release); + progress_clock.record_progress(); retry_count = 0; } Ok(Some(Err(e))) => { @@ -1103,6 +1134,60 @@ mod tests { use super::*; use std::sync::mpsc; + #[test] + fn progress_clock_starts_without_progress() { + let clock = ProgressClock::default(); + + assert_eq!(clock.elapsed_at(Instant::now()), None); + } + + #[test] + fn progress_clock_measures_elapsed_time_monotonically() { + let started = Instant::now(); + let clock = ProgressClock::with_last_progress_at(started); + + assert_eq!( + clock.elapsed_at(started + Duration::from_secs(11)), + Some(Duration::from_secs(11)) + ); + assert_eq!( + clock.elapsed_at(started + Duration::from_secs(FILE_READ_STALL_SECS)), + Some(Duration::from_secs(FILE_READ_STALL_SECS)) + ); + + clock.record_progress_at(started + Duration::from_secs(20)); + assert_eq!( + clock.elapsed_at(started + Duration::from_secs(25)), + Some(Duration::from_secs(5)) + ); + } + + #[test] + fn file_read_stall_uses_the_twelve_second_threshold() { + assert!(!file_read_is_stalled(None)); + assert!(!file_read_is_stalled(Some(Duration::from_secs(11)))); + assert!(file_read_is_stalled(Some(Duration::from_secs(12)))); + } + + #[test] + fn progress_clock_recovers_from_a_poisoned_lock() { + let clock = ProgressClock::default(); + let poisoned_clock = clock.clone(); + let _ = std::thread::spawn(move || { + let _guard = poisoned_clock + .last_progress + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + panic!("poison progress clock for test"); + }) + .join(); + let now = Instant::now(); + + clock.record_progress_at(now); + + assert_eq!(clock.elapsed_at(now), Some(Duration::ZERO)); + } + fn make_stripper(metaint: usize) -> (IcyStripper, mpsc::Receiver) { let (tx, rx) = mpsc::sync_channel(8); (IcyStripper::new(metaint, tx), rx) diff --git a/src/integrations/spotify/oauth.rs b/src/integrations/spotify/oauth.rs index 4971cfd..6df31ab 100644 --- a/src/integrations/spotify/oauth.rs +++ b/src/integrations/spotify/oauth.rs @@ -1,5 +1,5 @@ use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; -use rand::RngCore; +use rand::Rng; use sha2::{Digest, Sha256}; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, diff --git a/src/main.rs b/src/main.rs index 6f692e6..a87999c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -194,6 +194,7 @@ async fn run(tui: &mut terminal::Tui) -> Result<()> { app.poll_spotify_auth(); app.poll_token_refresh(); app.poll_spotify_play_result(); + app.poll_spotify_remote_skip_result(); app.poll_spotify_search(); app.poll_spotify_search_more(); app.poll_spotify_player_events();