diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index db1159aea..cd2357285 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -7,22 +7,33 @@ assignees: '' --- -**Describe the bug** -A clear and concise description of what the bug is. +### Look for similar bugs +Please check if there's [already an issue](https://github.com/librespot-org/librespot/issues) for your problem. +If you've only a "me too" comment to make, consider if a :+1: [reaction](https://github.blog/news-insights/product-news/add-reactions-to-pull-requests-issues-and-comments/) +will suffice. -**To reproduce** -Steps to reproduce the behavior: +### Description +A clear and concise description of what the problem is. + +### Version +What version(s) of *librespot* does this problem exist in? + +### How to reproduce +Steps to reproduce the behavior in *librespot* e.g. 1. Launch `librespot` with '...' 2. Connect with '...' 3. In the client click on '...' -4. See error +4. See some error/problem -**Log** -A full log so we may trace your problem (launch `librespot` with `--verbose`). Format the log as code. +### Log +* A *full* **debug** log so we may trace your problem (launch `librespot` with `--verbose`). +* Ideally contains your above steps to reproduce. +* Format the log as code ([help](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/creating-and-highlighting-code-blocks)) or use a *non-expiring* [pastebin](https://pastebin.com/). +* Redact data you consider personal but do not remove/trim anything else. -**Host (what you are running `librespot` on):** +### Host (what you are running `librespot` on): - OS: [e.g. Linux] - Platform: [e.g. RPi 3B+] -**Additional context** +### Additional context Add any other context about the problem here. If your issue is related to sound playback, at a minimum specify the type and make of your output device. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5b705b3a6..e0a0203ac 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -51,7 +51,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4.2.0 + uses: actions/checkout@v4.2.1 - name: Install toolchain run: curl https://sh.rustup.rs -sSf | sh -s -- --profile default --default-toolchain stable -y - run: cargo fmt --all -- --check @@ -68,7 +68,7 @@ jobs: toolchain: [stable] steps: - name: Checkout code - uses: actions/checkout@v4.2.0 + uses: actions/checkout@v4.2.1 - name: Install toolchain run: curl https://sh.rustup.rs -sSf | sh -s -- --profile default --default-toolchain ${{ matrix.toolchain }} -y @@ -79,7 +79,7 @@ jobs: shell: bash - name: Cache Rust dependencies - uses: actions/cache@v4.0.2 + uses: actions/cache@v4.1.1 with: path: | ~/.cargo/registry/index @@ -119,7 +119,7 @@ jobs: experimental: true steps: - name: Checkout code - uses: actions/checkout@v4.2.0 + uses: actions/checkout@v4.2.1 - name: Install toolchain run: curl https://sh.rustup.rs -sSf | sh -s -- --profile minimal --default-toolchain ${{ matrix.toolchain }} -y @@ -130,7 +130,7 @@ jobs: shell: bash - name: Cache Rust dependencies - uses: actions/cache@v4.0.2 + uses: actions/cache@v4.1.1 with: path: | ~/.cargo/registry/index @@ -168,7 +168,7 @@ jobs: - stable steps: - name: Checkout code - uses: actions/checkout@v4.2.0 + uses: actions/checkout@v4.2.1 # hyper-rustls >=0.27 uses aws-lc as default backend which requires NASM to build - name: Install NASM @@ -183,7 +183,7 @@ jobs: shell: bash - name: Cache Rust dependencies - uses: actions/cache@v4.0.2 + uses: actions/cache@v4.1.1 with: path: | ~/.cargo/registry/index @@ -219,7 +219,7 @@ jobs: - stable steps: - name: Checkout code - uses: actions/checkout@v4.2.0 + uses: actions/checkout@v4.2.1 - name: Install toolchain run: curl https://sh.rustup.rs -sSf | sh -s -- --profile minimal --default-toolchain ${{ matrix.toolchain }} -y @@ -230,7 +230,7 @@ jobs: shell: bash - name: Cache Rust dependencies - uses: actions/cache@v4.0.2 + uses: actions/cache@v4.1.1 with: path: | ~/.cargo/registry/index diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fb79f055..fdedb5544 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,26 @@ 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) since v0.2.0. -## [0.5.0-dev] - YYYY-MM-DD +## [Unreleased] -This version will be a major departure from the architecture up until now. It +### Changed + +- [core] The `access_token` for http requests is now acquired by `login5` + +### Added + +- [core] Add `login` (mobile) and `auth_token` retrieval via login5 +- [core] Add `OS` and `os_version` to `config.rs` + +### Removed + +### Fixed + +- [connect] Fixes initial volume showing zero despite playing in full volume instead + +## [0.5.0] - 2024-10-15 + +This version is be a major departure from the architecture up until now. It focuses on implementing the "new Spotify API". This means moving large parts of the Spotify protocol from Mercury to HTTP. A lot of this was reverse engineered before by @devgianlu of librespot-java. It was long overdue that we @@ -17,7 +34,7 @@ hopefully upcoming Spotify HiFi depend on it. Splitting up the work on the new Spotify API, v0.5.0 brings HTTP-based file downloads and metadata access. Implementing the "dealer" (replacing the current Mercury-based SPIRC message bus with WebSockets, also required for social plays) -is separate large effort, to be targeted for v0.6.0. +is a large and separate effort, slated for some later release. While at it, we are taking the liberty to do some major refactoring to make librespot more robust. Consequently not only the Spotify API changed but large @@ -39,6 +56,7 @@ https://github.com/librespot-org/librespot - [all] Use a single `player` instance. Eliminates occasional `player` and `audio backend` restarts, which can cause issues with some playback configurations. +- [all] Updated and removed unused dependencies - [audio] Files are now downloaded over the HTTPS CDN (breaking) - [audio] Improve file opening and seeking performance (breaking) - [core] MSRV is now 1.74 (breaking) @@ -46,6 +64,7 @@ https://github.com/librespot-org/librespot - [connect] Update and expose all `spirc` context fields (breaking) - [connect] Add `Clone, Defaut` traits to `spirc` contexts - [connect] Autoplay contexts are now retrieved with the `spclient` (breaking) +- [contrib] Updated Docker image - [core] Message listeners are registered before authenticating. As a result there now is a separate `Session::new` and subsequent `session.connect`. (breaking) @@ -59,6 +78,7 @@ https://github.com/librespot-org/librespot - [core] `Credentials.username` is now an `Option` (breaking) - [core] `Session::connect` tries multiple access points, retrying each one. - [core] Each access point connection now timesout after 3 seconds. +- [core] Listen on both IPV4 and IPV6 on non-windows hosts - [main] `autoplay {on|off}` now acts as an override. If unspecified, `librespot` now follows the setting in the Connect client that controls it. (breaking) - [metadata] Most metadata is now retrieved with the `spclient` (breaking) @@ -76,6 +96,7 @@ https://github.com/librespot-org/librespot - [all] Check that array indexes are within bounds (panic safety) - [all] Wrap errors in librespot `Error` type (breaking) +- [audio] Make audio fetch parameters tunable - [connect] Add option on which zeroconf will bind. Defaults to all interfaces. Ignored by DNS-SD. - [connect] Add session events - [connect] Add `repeat`, `set_position_ms` and `set_volume` to `spirc.rs` @@ -95,6 +116,8 @@ https://github.com/librespot-org/librespot - [core] Support parsing `SpotifyId` for local files - [core] Support parsing `SpotifyId` for named playlists - [core] Add checks and handling for stale server connections. +- [core] Fix potential deadlock waiting for audio decryption keys. +- [discovery] Add option to show playback device as a group - [main] Add all player events to `player_event_handler.rs` - [main] Add an event worker thread that runs async to the main thread(s) but sync to itself to prevent potential data races for event consumers @@ -118,11 +141,15 @@ https://github.com/librespot-org/librespot - [connect] Loading previous or next tracks, or looping back on repeat, will only start playback when we were already playing - [connect, playback] Clean up and de-noise events and event firing +- [core] Fixed frequent disconnections for some users +- [core] More strict Spotify ID parsing +- [discovery] Update active user field upon connection - [playback] Handle invalid track start positions by just starting the track from the beginning - [playback] Handle disappearing and invalid devices better - [playback] Handle seek, pause, and play commands while loading - [playback] Handle disabled normalisation correctly when using fixed volume +- [playback] Do not stop sink in gapless mode - [metadata] Fix missing colon when converting named spotify IDs to URIs ## [0.4.2] - 2022-07-29 @@ -284,16 +311,17 @@ v0.4.x as a stable branch until then. ## [0.1.0] - 2019-11-06 -[0.5.0-dev]: https://github.com/librespot-org/librespot/compare/v0.4.1..HEAD -[0.4.2]: https://github.com/librespot-org/librespot/compare/v0.4.1..v0.4.2 -[0.4.1]: https://github.com/librespot-org/librespot/compare/v0.4.0..v0.4.1 -[0.4.0]: https://github.com/librespot-org/librespot/compare/v0.3.1..v0.4.0 -[0.3.1]: https://github.com/librespot-org/librespot/compare/v0.3.0..v0.3.1 -[0.3.0]: https://github.com/librespot-org/librespot/compare/v0.2.0..v0.3.0 -[0.2.0]: https://github.com/librespot-org/librespot/compare/v0.1.6..v0.2.0 -[0.1.6]: https://github.com/librespot-org/librespot/compare/v0.1.5..v0.1.6 -[0.1.5]: https://github.com/librespot-org/librespot/compare/v0.1.3..v0.1.5 -[0.1.3]: https://github.com/librespot-org/librespot/compare/v0.1.2..v0.1.3 -[0.1.2]: https://github.com/librespot-org/librespot/compare/v0.1.1..v0.1.2 -[0.1.1]: https://github.com/librespot-org/librespot/compare/v0.1.0..v0.1.1 +[unreleased]: https://github.com/librespot-org/librespot/compare/v0.5.0...HEAD +[0.5.0]: https://github.com/librespot-org/librespot/compare/v0.4.2...v0.5.0 +[0.4.2]: https://github.com/librespot-org/librespot/compare/v0.4.1...v0.4.2 +[0.4.1]: https://github.com/librespot-org/librespot/compare/v0.4.0...v0.4.1 +[0.4.0]: https://github.com/librespot-org/librespot/compare/v0.3.1...v0.4.0 +[0.3.1]: https://github.com/librespot-org/librespot/compare/v0.3.0...v0.3.1 +[0.3.0]: https://github.com/librespot-org/librespot/compare/v0.2.0...v0.3.0 +[0.2.0]: https://github.com/librespot-org/librespot/compare/v0.1.6...v0.2.0 +[0.1.6]: https://github.com/librespot-org/librespot/compare/v0.1.5...v0.1.6 +[0.1.5]: https://github.com/librespot-org/librespot/compare/v0.1.3...v0.1.5 +[0.1.3]: https://github.com/librespot-org/librespot/compare/v0.1.2...v0.1.3 +[0.1.2]: https://github.com/librespot-org/librespot/compare/v0.1.1...v0.1.2 +[0.1.1]: https://github.com/librespot-org/librespot/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/librespot-org/librespot/releases/tag/v0.1.0 diff --git a/Cargo.lock b/Cargo.lock index 0a97b0f87..a20c2d43c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 3 [[package]] name = "addr2line" -version = "0.24.1" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5fb1d8e4442bd405fdfd1dacb42792696b0cf9cb15882e5d097b742a676d375" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" dependencies = [ "gimli", ] @@ -166,9 +166,9 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "aws-lc-rs" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f95446d919226d587817a7d21379e6eb099b97b45110a7f272a444ca5c54070" +checksum = "cdd82dba44d209fddb11c190e0a94b78651f95299598e472215667417a03ff1d" dependencies = [ "aws-lc-sys", "mirai-annotations", @@ -178,11 +178,11 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.21.2" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3ddc4a5b231dd6958b140ff3151b6412b3f4321fab354f399eec8f14b06df62" +checksum = "df7a4168111d7eb622a31b214057b8509c0a7e1794f44c546d742330dc793972" dependencies = [ - "bindgen 0.69.4", + "bindgen 0.69.5", "cc", "cmake", "dunce", @@ -232,9 +232,9 @@ checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" [[package]] name = "bindgen" -version = "0.69.4" +version = "0.69.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00dc851838a2120612785d195287475a3ac45514741da670b735818822129a0" +checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" dependencies = [ "bitflags 2.6.0", "cexpr", @@ -300,9 +300,9 @@ checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" [[package]] name = "bytemuck" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94bbb0ad554ad961ddc5da507a12a29b14e4ae5bda06b19f575a3e6079d2e2ae" +checksum = "8334215b81e418a0a7bdb8ef0849474f40bb10c8b71f1c4ed315cff49f32494d" [[package]] name = "byteorder" @@ -318,9 +318,9 @@ checksum = "428d9aa8fbc0670b7b8d6030a7fadd0f86151cae55e4dbbece15f3780a3dfaf3" [[package]] name = "cc" -version = "1.1.23" +version = "1.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bbb537bb4a30b90362caddba8f360c0a56bc13d3a5570028e7197204cb54a17" +checksum = "b16803a61b81d9eabb7eae2588776c4c1e584b738ede45fdbb4c972cec1e9945" dependencies = [ "jobserver", "libc", @@ -582,18 +582,18 @@ dependencies = [ [[package]] name = "derive_builder" -version = "0.20.1" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd33f37ee6a119146a1781d3356a7c26028f83d779b2e04ecd45fdc75c76877b" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" dependencies = [ "derive_builder_macro", ] [[package]] name = "derive_builder_core" -version = "0.20.1" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7431fa049613920234f22c47fdc33e6cf3ee83067091ea4277a3f8c4587aae38" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ "darling", "proc-macro2", @@ -603,9 +603,9 @@ dependencies = [ [[package]] name = "derive_builder_macro" -version = "0.20.1" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4abae7035bf79b9877b779505d8cf3749285b80c43941eda66604841889451dc" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", "syn 2.0.79", @@ -727,9 +727,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ "futures-channel", "futures-core", @@ -742,9 +742,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", "futures-sink", @@ -752,15 +752,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] name = "futures-executor" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" dependencies = [ "futures-core", "futures-task", @@ -769,15 +769,15 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" [[package]] name = "futures-macro" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", @@ -786,15 +786,15 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" [[package]] name = "futures-task" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" [[package]] name = "futures-timer" @@ -804,9 +804,9 @@ checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" [[package]] name = "futures-util" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-channel", "futures-core", @@ -854,9 +854,9 @@ dependencies = [ [[package]] name = "gimli" -version = "0.31.0" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32085ea23f3234fc7846555e85283ba4de91e21016dc0455a16286d87a292d64" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] name = "gio-sys" @@ -1112,9 +1112,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.14.5" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb" [[package]] name = "headers" @@ -1245,9 +1245,9 @@ dependencies = [ [[package]] name = "httparse" -version = "1.9.4" +version = "1.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fcc0b4a115bf80b728eb8ea024ad5bd707b615bfed49e0665b6e0f86fd082d9" +checksum = "7d71d3574edd2771538b901e6549113b4006ece66150fb69c0fb6d9a2adae946" [[package]] name = "httpdate" @@ -1263,9 +1263,9 @@ checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" [[package]] name = "hyper" -version = "0.14.30" +version = "0.14.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a152ddd61dfaec7273fe8419ab357f33aee0d914c5f4efbf0d96fa749eea5ec9" +checksum = "8c08302e8fa335b151b788c775ff56e7a03ae64ff85c548ee820fecb70356e85" dependencies = [ "bytes", "futures-channel", @@ -1287,9 +1287,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.4.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50dfd22e0e76d0f662d429a5f80fcaf3855009297eab6a0a9f8543834744ba05" +checksum = "bbbff0a806a4728c99295b254c8838933b5b082d75e3cb70c8dab21fdfbcfa9a" dependencies = [ "bytes", "futures-channel", @@ -1316,7 +1316,7 @@ dependencies = [ "futures-util", "headers", "http 1.1.0", - "hyper 1.4.1", + "hyper 1.5.0", "hyper-rustls 0.26.0", "hyper-util", "pin-project-lite", @@ -1335,7 +1335,7 @@ checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" dependencies = [ "futures-util", "http 0.2.12", - "hyper 0.14.30", + "hyper 0.14.31", "rustls 0.21.12", "tokio", "tokio-rustls 0.24.1", @@ -1349,7 +1349,7 @@ checksum = "a0bea761b46ae2b24eb4aef630d8d1c398157b6fc29e6350ecf090a0b70c952c" dependencies = [ "futures-util", "http 1.1.0", - "hyper 1.4.1", + "hyper 1.5.0", "hyper-util", "log", "rustls 0.22.4", @@ -1368,10 +1368,10 @@ checksum = "08afdbb5c31130e3034af566421053ab03787c640246a446327f550d11bcb333" dependencies = [ "futures-util", "http 1.1.0", - "hyper 1.4.1", + "hyper 1.5.0", "hyper-util", "log", - "rustls 0.23.13", + "rustls 0.23.14", "rustls-native-certs 0.8.0", "rustls-pki-types", "tokio", @@ -1390,7 +1390,7 @@ dependencies = [ "futures-util", "http 1.1.0", "http-body 1.0.1", - "hyper 1.4.1", + "hyper 1.5.0", "pin-project-lite", "socket2", "tokio", @@ -1449,9 +1449,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5" +checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" dependencies = [ "equivalent", "hashbrown", @@ -1468,9 +1468,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.10.0" +version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "187674a687eed5fe42285b40c6291f9a01517d415fad1c3cbc6a9f778af7fcd4" +checksum = "ddc24109865250148c2e0f3d25d4f0f479571723792d3802153c60922a4fb708" [[package]] name = "is_terminal_polyfill" @@ -1562,9 +1562,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.70" +version = "0.3.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1868808506b929d7b0cfa8f75951347aa71bb21144b7791bae35d9bccfcfe37a" +checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9" dependencies = [ "wasm-bindgen", ] @@ -1684,7 +1684,7 @@ dependencies = [ [[package]] name = "librespot" -version = "0.5.0-dev" +version = "0.5.0" dependencies = [ "data-encoding", "env_logger", @@ -1708,14 +1708,14 @@ dependencies = [ [[package]] name = "librespot-audio" -version = "0.5.0-dev" +version = "0.5.0" dependencies = [ "aes", "bytes", "ctr", "futures-util", "http-body-util", - "hyper 1.4.1", + "hyper 1.5.0", "hyper-util", "librespot-core", "log", @@ -1727,7 +1727,7 @@ dependencies = [ [[package]] name = "librespot-connect" -version = "0.5.0-dev" +version = "0.5.0" dependencies = [ "form_urlencoded", "futures-util", @@ -1746,7 +1746,7 @@ dependencies = [ [[package]] name = "librespot-core" -version = "0.5.0-dev" +version = "0.5.0" dependencies = [ "aes", "base64 0.22.1", @@ -1762,7 +1762,7 @@ dependencies = [ "http 1.1.0", "http-body-util", "httparse", - "hyper 1.4.1", + "hyper 1.5.0", "hyper-proxy2", "hyper-rustls 0.27.3", "hyper-util", @@ -1777,6 +1777,7 @@ dependencies = [ "once_cell", "parking_lot", "pbkdf2", + "pin-project-lite", "priority-queue", "protobuf", "quick-xml", @@ -1800,7 +1801,7 @@ dependencies = [ [[package]] name = "librespot-discovery" -version = "0.5.0-dev" +version = "0.5.0" dependencies = [ "aes", "base64 0.22.1", @@ -1814,7 +1815,7 @@ dependencies = [ "hex", "hmac", "http-body-util", - "hyper 1.4.1", + "hyper 1.5.0", "hyper-util", "libmdns", "librespot-core", @@ -1828,7 +1829,7 @@ dependencies = [ [[package]] name = "librespot-metadata" -version = "0.5.0-dev" +version = "0.5.0" dependencies = [ "async-trait", "bytes", @@ -1844,7 +1845,7 @@ dependencies = [ [[package]] name = "librespot-oauth" -version = "0.5.0-dev" +version = "0.5.0" dependencies = [ "env_logger", "log", @@ -1855,7 +1856,7 @@ dependencies = [ [[package]] name = "librespot-playback" -version = "0.5.0-dev" +version = "0.5.0" dependencies = [ "alsa", "cpal", @@ -1887,7 +1888,7 @@ dependencies = [ [[package]] name = "librespot-protocol" -version = "0.5.0-dev" +version = "0.5.0" dependencies = [ "protobuf", "protobuf-codegen", @@ -2192,9 +2193,9 @@ dependencies = [ [[package]] name = "object" -version = "0.36.4" +version = "0.36.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "084f1a5821ac4c651660a94a7153d27ac9d8a53736203f58b31945ded098070a" +checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" dependencies = [ "memchr", ] @@ -2233,12 +2234,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.20.1" +version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82881c4be219ab5faaf2ad5e5e5ecdff8c66bd7402ca3160975c93b24961afd1" -dependencies = [ - "portable-atomic", -] +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" [[package]] name = "openssl-probe" @@ -2435,18 +2433,18 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.86" +version = "1.0.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +checksum = "b3e4daa0dcf6feba26f985457cdf104d4b4256fc5a09547140f3631bb076b19a" dependencies = [ "unicode-ident", ] [[package]] name = "protobuf" -version = "3.5.1" +version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bcc343da15609eaecd65f8aa76df8dc4209d325131d8219358c0aaaebab0bf6" +checksum = "3018844a02746180074f621e847703737d27d89d7f0721a7a4da317f88b16385" dependencies = [ "once_cell", "protobuf-support", @@ -2455,9 +2453,9 @@ dependencies = [ [[package]] name = "protobuf-codegen" -version = "3.5.1" +version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4d0cde5642ea4df842b13eb9f59ea6fafa26dcb43e3e1ee49120e9757556189" +checksum = "411c15a212b4de05eb8bc989fd066a74c86bd3c04e27d6e86bd7703b806d7734" dependencies = [ "anyhow", "once_cell", @@ -2470,9 +2468,9 @@ dependencies = [ [[package]] name = "protobuf-parse" -version = "3.5.1" +version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b0e9b447d099ae2c4993c0cbb03c7a9d6c937b17f2d56cfc0b1550e6fcfdb76" +checksum = "06f45f16b522d92336e839b5e40680095a045e36a1e7f742ba682ddc85236772" dependencies = [ "anyhow", "indexmap", @@ -2486,9 +2484,9 @@ dependencies = [ [[package]] name = "protobuf-support" -version = "3.5.1" +version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0766e3675a627c327e4b3964582594b0e8741305d628a98a5de75a1d15f99b9" +checksum = "faf96d872914fcda2b66d66ea3fff2be7c66865d31c7bb2790cff32c0e714880" dependencies = [ "thiserror", ] @@ -2604,7 +2602,7 @@ dependencies = [ "h2 0.3.26", "http 0.2.12", "http-body 0.4.6", - "hyper 0.14.30", + "hyper 0.14.31", "hyper-rustls 0.24.2", "ipnet", "js-sys", @@ -2729,9 +2727,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.13" +version = "0.23.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2dabaac7466917e566adb06783a81ca48944c6898a1b08b9374106dd671f4c8" +checksum = "415d9944693cb90382053259f89fbb077ea730ad7273047ec63b19bc9b160ba8" dependencies = [ "aws-lc-rs", "log", @@ -2788,9 +2786,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e696e35370c65c9c541198af4543ccd580cf17fc25d8e05c5a242b202488c55" +checksum = "16f1201b3c9a7ee8039bcadc17b7e605e2945b27eee7631788c1bd2b0643674b" [[package]] name = "rustls-webpki" @@ -2816,9 +2814,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" +checksum = "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248" [[package]] name = "ryu" @@ -2837,9 +2835,9 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.24" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9aaafd5a2b6e3d657ff009d82fbd630b6bd54dd4eb06f21693925cdf80f9b8b" +checksum = "01227be5826fa0690321a2ba6c5cd57a19cf3f6a09e76973b58e61de6ab9d1c1" dependencies = [ "windows-sys 0.59.0", ] @@ -3405,7 +3403,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" dependencies = [ - "rustls 0.23.13", + "rustls 0.23.14", "rustls-pki-types", "tokio", ] @@ -3429,7 +3427,7 @@ checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" dependencies = [ "futures-util", "log", - "rustls 0.23.13", + "rustls 0.23.14", "rustls-native-certs 0.8.0", "rustls-pki-types", "tokio", @@ -3528,7 +3526,7 @@ dependencies = [ "httparse", "log", "rand", - "rustls 0.23.13", + "rustls 0.23.14", "rustls-pki-types", "sha1", "thiserror", @@ -3543,9 +3541,9 @@ checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" [[package]] name = "unicode-bidi" -version = "0.3.15" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" +checksum = "5ab17db44d7388991a428b2ee655ce0c212e862eff1768a455c58f9aad6e7893" [[package]] name = "unicode-ident" @@ -3691,9 +3689,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.93" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5" +checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" dependencies = [ "cfg-if", "once_cell", @@ -3702,9 +3700,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.93" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b" +checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" dependencies = [ "bumpalo", "log", @@ -3717,9 +3715,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.43" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61e9300f63a621e96ed275155c108eb6f843b6a26d053f122ab69724559dc8ed" +checksum = "cc7ec4f8827a71586374db3e87abdb5a2bb3a15afed140221307c3ec06b1f63b" dependencies = [ "cfg-if", "js-sys", @@ -3729,9 +3727,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.93" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf" +checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3739,9 +3737,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.93" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" +checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" dependencies = [ "proc-macro2", "quote", @@ -3752,15 +3750,15 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.93" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484" +checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" [[package]] name = "web-sys" -version = "0.3.70" +version = "0.3.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26fdeaafd9bd129f65e7c031593c24d62186301e0c72c8978fa1678be7d532c0" +checksum = "f6488b90108c040df0fe62fa815cbdee25124641df01814dd7282749234c6112" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/Cargo.toml b/Cargo.toml index d23be2e10..fa5da5c38 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "librespot" -version = "0.5.0-dev" +version = "0.5.0" rust-version = "1.74" authors = ["Librespot Org"] license = "MIT" @@ -23,35 +23,35 @@ doc = false [dependencies.librespot-audio] path = "audio" -version = "0.5.0-dev" +version = "0.5.0" [dependencies.librespot-connect] path = "connect" -version = "0.5.0-dev" +version = "0.5.0" [dependencies.librespot-core] path = "core" -version = "0.5.0-dev" +version = "0.5.0" [dependencies.librespot-discovery] path = "discovery" -version = "0.5.0-dev" +version = "0.5.0" [dependencies.librespot-metadata] path = "metadata" -version = "0.5.0-dev" +version = "0.5.0" [dependencies.librespot-playback] path = "playback" -version = "0.5.0-dev" +version = "0.5.0" [dependencies.librespot-protocol] path = "protocol" -version = "0.5.0-dev" +version = "0.5.0" [dependencies.librespot-oauth] path = "oauth" -version = "0.5.0-dev" +version = "0.5.0" [dependencies] data-encoding = "2.5" @@ -98,3 +98,6 @@ assets = [ ["contrib/librespot.service", "lib/systemd/system/", "644"], ["contrib/librespot.user.service", "lib/systemd/user/", "644"] ] + +[workspace.package] +rust-version = "1.74" diff --git a/README.md b/README.md index acd78d630..3ccb19cf2 100644 --- a/README.md +++ b/README.md @@ -111,10 +111,10 @@ This is a non exhaustive list of projects that either use or have modified libre - [raspotify](https://github.com/dtcooper/raspotify) - A Spotify Connect client that mostly Just Works™ - [Spotifyd](https://github.com/Spotifyd/spotifyd) - A stripped down librespot UNIX daemon. - [rpi-audio-receiver](https://github.com/nicokaiser/rpi-audio-receiver) - easy Raspbian install scripts for Spotifyd, Bluetooth, Shairport and other audio receivers -- [Spotcontrol](https://github.com/badfortrains/spotcontrol) - A golang implementation of a Spotify Connect controller. No playback -functionality. +- [Spotcontrol](https://github.com/badfortrains/spotcontrol) - A golang implementation of a Spotify Connect controller. No Playback functionality. - [librespot-java](https://github.com/devgianlu/librespot-java) - A Java port of librespot. - [ncspot](https://github.com/hrkfdn/ncspot) - Cross-platform ncurses Spotify client. - [ansible-role-librespot](https://github.com/xMordax/ansible-role-librespot/tree/master) - Ansible role that will build, install and configure Librespot. - [Spot](https://github.com/xou816/spot) - Gtk/Rust native Spotify client for the GNOME desktop. - [Snapcast](https://github.com/badaix/snapcast) - synchronised multi-room audio player that uses librespot as its source for Spotify content +- [MuPiBox](https://mupibox.de/) - Portable music box for Spotify and local media based on Raspberry Pi. Operated via touchscreen. Suitable for children and older people. diff --git a/audio/Cargo.toml b/audio/Cargo.toml index 4874d9633..713a499b5 100644 --- a/audio/Cargo.toml +++ b/audio/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "librespot-audio" -version = "0.5.0-dev" -rust-version = "1.74" +version = "0.5.0" +rust-version.workspace = true authors = ["Paul Lietar "] description = "The audio fetching logic for librespot" license = "MIT" @@ -10,7 +10,7 @@ edition = "2021" [dependencies.librespot-core] path = "../core" -version = "0.5.0-dev" +version = "0.5.0" [dependencies] aes = "0.8" diff --git a/connect/Cargo.toml b/connect/Cargo.toml index 9035002e7..bb055db16 100644 --- a/connect/Cargo.toml +++ b/connect/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "librespot-connect" -version = "0.5.0-dev" -rust-version = "1.74" +version = "0.5.0" +rust-version.workspace = true authors = ["Paul Lietar "] description = "The discovery and Spotify Connect logic for librespot" license = "MIT" @@ -22,12 +22,12 @@ tokio-stream = "0.1" [dependencies.librespot-core] path = "../core" -version = "0.5.0-dev" +version = "0.5.0" [dependencies.librespot-playback] path = "../playback" -version = "0.5.0-dev" +version = "0.5.0" [dependencies.librespot-protocol] path = "../protocol" -version = "0.5.0-dev" +version = "0.5.0" diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 200c3f830..c39426516 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -337,6 +337,9 @@ impl Spirc { }), ); + // pre-acquire client_token, preventing multiple request while running + let _ = session.spclient().client_token().await?; + // Connect *after* all message listeners are registered session.connect(credentials, true).await?; @@ -490,7 +493,22 @@ impl SpircTask { }, connection_id_update = self.connection_id_update.next() => match connection_id_update { Some(result) => match result { - Ok(connection_id) => self.handle_connection_id_update(connection_id), + Ok(connection_id) => { + self.handle_connection_id_update(connection_id); + + // pre-acquire access_token, preventing multiple request while running + // pre-acquiring for the access_token will only last for one hour + // + // we need to fire the request after connecting, but can't do it right + // after, because by that we would miss certain packages, like this one + match self.session.login5().auth_token().await { + Ok(_) => debug!("successfully pre-acquire access_token and client_token"), + Err(why) => { + error!("{why}"); + break + } + } + }, Err(e) => error!("could not parse connection ID update: {}", e), } None => { @@ -1509,7 +1527,7 @@ impl SpircTask { fn set_volume(&mut self, volume: u16) { let old_volume = self.device.volume(); let new_volume = volume as u32; - if old_volume != new_volume { + if old_volume != new_volume || self.mixer.volume() != volume { self.device.set_volume(new_volume); self.mixer.set_volume(volume); if let Some(cache) = self.session.cache() { @@ -1534,7 +1552,7 @@ struct CommandSender<'a> { } impl<'a> CommandSender<'a> { - fn new(spirc: &'a mut SpircTask, cmd: MessageType) -> CommandSender<'_> { + fn new(spirc: &'a mut SpircTask, cmd: MessageType) -> Self { let mut frame = protocol::spirc::Frame::new(); // frame version frame.set_version(1); @@ -1549,13 +1567,13 @@ impl<'a> CommandSender<'a> { CommandSender { spirc, frame } } - fn recipient(mut self, recipient: &'a str) -> CommandSender<'_> { + fn recipient(mut self, recipient: &'a str) -> Self { self.frame.recipient.push(recipient.to_owned()); self } #[allow(dead_code)] - fn state(mut self, state: protocol::spirc::State) -> CommandSender<'a> { + fn state(mut self, state: protocol::spirc::State) -> Self { *self.frame.state.mut_or_insert_default() = state; self } diff --git a/contrib/Dockerfile b/contrib/Dockerfile index 377ece7ac..cf9725823 100644 --- a/contrib/Dockerfile +++ b/contrib/Dockerfile @@ -13,17 +13,18 @@ # $ docker run -v /tmp/librespot-build:/build librespot-cross cargo build --release --target arm-unknown-linux-gnueabi --no-default-features --features alsa-backend # $ docker run -v /tmp/librespot-build:/build librespot-cross cargo build --release --target aarch64-unknown-linux-gnu --no-default-features --features alsa-backend -FROM debian:stretch +FROM debian:bookworm -RUN echo "deb http://archive.debian.org/debian stretch main" > /etc/apt/sources.list -RUN echo "deb http://archive.debian.org/debian stretch-proposed-updates main" >> /etc/apt/sources.list -RUN echo "deb http://archive.debian.org/debian-security stretch/updates main" >> /etc/apt/sources.list +RUN echo "deb http://deb.debian.org/debian bookworm main" > /etc/apt/sources.list +RUN echo "deb http://deb.debian.org/debian bookworm-updates main" >> /etc/apt/sources.list +RUN echo "deb http://deb.debian.org/debian-security bookworm-security main" >> /etc/apt/sources.list RUN dpkg --add-architecture arm64 RUN dpkg --add-architecture armhf RUN dpkg --add-architecture armel RUN apt-get update +RUN apt-get install -y cmake libclang-dev RUN apt-get install -y curl git build-essential crossbuild-essential-arm64 crossbuild-essential-armel crossbuild-essential-armhf pkg-config RUN apt-get install -y libasound2-dev libasound2-dev:arm64 libasound2-dev:armel libasound2-dev:armhf RUN apt-get install -y libpulse0 libpulse0:arm64 libpulse0:armel libpulse0:armhf @@ -33,14 +34,15 @@ ENV PATH="/root/.cargo/bin/:${PATH}" RUN rustup target add aarch64-unknown-linux-gnu RUN rustup target add arm-unknown-linux-gnueabi RUN rustup target add arm-unknown-linux-gnueabihf +RUN cargo install bindgen-cli RUN mkdir /.cargo && \ echo '[target.aarch64-unknown-linux-gnu]\nlinker = "aarch64-linux-gnu-gcc"' > /.cargo/config && \ echo '[target.arm-unknown-linux-gnueabihf]\nlinker = "arm-linux-gnueabihf-gcc"' >> /.cargo/config && \ echo '[target.arm-unknown-linux-gnueabi]\nlinker = "arm-linux-gnueabi-gcc"' >> /.cargo/config -ENV CARGO_TARGET_DIR /build -ENV CARGO_HOME /build/cache +ENV CARGO_TARGET_DIR=/build +ENV CARGO_HOME=/build/cache ENV PKG_CONFIG_ALLOW_CROSS=1 ENV PKG_CONFIG_PATH_aarch64-unknown-linux-gnu=/usr/lib/aarch64-linux-gnu/pkgconfig/ ENV PKG_CONFIG_PATH_arm-unknown-linux-gnueabihf=/usr/lib/arm-linux-gnueabihf/pkgconfig/ diff --git a/core/Cargo.toml b/core/Cargo.toml index 1c8728ece..93357f938 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "librespot-core" -version = "0.5.0-dev" -rust-version = "1.74" +version = "0.5.0" +rust-version.workspace = true authors = ["Paul Lietar "] build = "build.rs" description = "The core functionality provided by librespot" @@ -11,11 +11,11 @@ edition = "2021" [dependencies.librespot-oauth] path = "../oauth" -version = "0.5.0-dev" +version = "0.5.0" [dependencies.librespot-protocol] path = "../protocol" -version = "0.5.0-dev" +version = "0.5.0" [dependencies] aes = "0.8" @@ -44,6 +44,7 @@ num-traits = "0.2" once_cell = "1" parking_lot = { version = "0.12", features = ["deadlock_detection"] } pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] } +pin-project-lite = "0.2" priority-queue = "2.0" protobuf = "3.5" quick-xml = { version = "0.36.1", features = ["serialize"] } @@ -58,7 +59,7 @@ thiserror = "1.0" time = { version = "0.3", features = ["formatting", "parsing"] } tokio = { version = "1", features = ["io-util", "macros", "net", "parking_lot", "rt", "sync", "time"] } tokio-stream = "0.1" -tokio-tungstenite = { version = "*", default-features = false, features = ["rustls-tls-native-roots"] } +tokio-tungstenite = { version = "0.24", default-features = false, features = ["rustls-tls-native-roots"] } tokio-util = { version = "0.7", features = ["codec"] } url = "2" uuid = { version = "1", default-features = false, features = ["fast-rng", "v4"] } diff --git a/core/src/audio_key.rs b/core/src/audio_key.rs index 74be42588..0ffa83830 100644 --- a/core/src/audio_key.rs +++ b/core/src/audio_key.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, io::Write}; +use std::{collections::HashMap, io::Write, time::Duration}; use byteorder::{BigEndian, ByteOrder, WriteBytesExt}; use bytes::Bytes; @@ -20,6 +20,8 @@ pub enum AudioKeyError { Packet(u8), #[error("sequence {0} not pending")] Sequence(u32), + #[error("audio key response timeout")] + Timeout, } impl From for Error { @@ -29,6 +31,7 @@ impl From for Error { AudioKeyError::Channel => Error::aborted(err), AudioKeyError::Sequence(_) => Error::aborted(err), AudioKeyError::Packet(_) => Error::unimplemented(err), + AudioKeyError::Timeout => Error::aborted(err), } } } @@ -89,7 +92,14 @@ impl AudioKeyManager { }); self.send_key_request(seq, track, file)?; - rx.await? + const KEY_RESPONSE_TIMEOUT: Duration = Duration::from_millis(1500); + match tokio::time::timeout(KEY_RESPONSE_TIMEOUT, rx).await { + Err(_) => { + error!("Audio key response timeout"); + Err(AudioKeyError::Timeout.into()) + } + Ok(k) => k?, + } } fn send_key_request(&self, seq: u32, track: SpotifyId, file: FileId) -> Result<(), Error> { diff --git a/core/src/config.rs b/core/src/config.rs index 674c5020f..1160c0f56 100644 --- a/core/src/config.rs +++ b/core/src/config.rs @@ -6,6 +6,20 @@ pub(crate) const KEYMASTER_CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd"; pub(crate) const ANDROID_CLIENT_ID: &str = "9a8d2f0ce77a4e248bb71fefcb557637"; pub(crate) const IOS_CLIENT_ID: &str = "58bd3c95768941ea9eb4350aaa033eb3"; +// Easily adjust the current platform to mock the behavior on it. If for example +// android or ios needs to be mocked, the `os_version` has to be set to a valid version. +// Otherwise, client-token or login5 requests will fail with a generic invalid-credential error. +/// See [std::env::consts::OS] +pub const OS: &str = std::env::consts::OS; + +// valid versions for some os: +// 'android': 30 +// 'ios': 17 +/// See [sysinfo::System::os_version] +pub fn os_version() -> String { + sysinfo::System::os_version().unwrap_or("0".into()) +} + #[derive(Clone, Debug)] pub struct SessionConfig { pub client_id: String, @@ -39,7 +53,7 @@ impl SessionConfig { impl Default for SessionConfig { fn default() -> Self { - Self::default_for_os(std::env::consts::OS) + Self::default_for_os(OS) } } diff --git a/core/src/connection/handshake.rs b/core/src/connection/handshake.rs index d18f3df1d..03b355985 100644 --- a/core/src/connection/handshake.rs +++ b/core/src/connection/handshake.rs @@ -110,7 +110,7 @@ where let mut client_nonce = vec![0; 0x10]; thread_rng().fill_bytes(&mut client_nonce); - let platform = match std::env::consts::OS { + let platform = match crate::config::OS { "android" => Platform::PLATFORM_ANDROID_ARM, "freebsd" | "netbsd" | "openbsd" => match ARCH { "x86_64" => Platform::PLATFORM_FREEBSD_X86_64, diff --git a/core/src/connection/mod.rs b/core/src/connection/mod.rs index c46e5ad8f..2e9bbdb43 100644 --- a/core/src/connection/mod.rs +++ b/core/src/connection/mod.rs @@ -112,7 +112,7 @@ pub async fn authenticate( _ => CpuFamily::CPU_UNKNOWN, }; - let os = match std::env::consts::OS { + let os = match crate::config::OS { "android" => Os::OS_ANDROID, "freebsd" | "netbsd" | "openbsd" => Os::OS_FREEBSD, "ios" => Os::OS_IPHONE, diff --git a/core/src/http_client.rs b/core/src/http_client.rs index 4b500cd6a..8645d3a38 100644 --- a/core/src/http_client.rs +++ b/core/src/http_client.rs @@ -1,6 +1,5 @@ use std::{ collections::HashMap, - env::consts::OS, time::{Duration, Instant}, }; @@ -21,11 +20,11 @@ use hyper_util::{ use nonzero_ext::nonzero; use once_cell::sync::OnceCell; use parking_lot::Mutex; -use sysinfo::System; use thiserror::Error; use url::Url; use crate::{ + config::{os_version, OS}, date::Date, version::{spotify_version, FALLBACK_USER_AGENT, VERSION_STRING}, Error, @@ -106,7 +105,7 @@ pub struct HttpClient { impl HttpClient { pub fn new(proxy_url: Option<&Url>) -> Self { let zero_str = String::from("0"); - let os_version = System::os_version().unwrap_or_else(|| zero_str.clone()); + let os_version = os_version(); let (spotify_platform, os_version) = match OS { "android" => ("Android", os_version), diff --git a/core/src/lib.rs b/core/src/lib.rs index f0ee345cf..9894bb708 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -22,6 +22,7 @@ pub mod diffie_hellman; pub mod error; pub mod file_id; pub mod http_client; +pub mod login5; pub mod mercury; pub mod packet; mod proxytunnel; diff --git a/core/src/login5.rs b/core/src/login5.rs new file mode 100644 index 000000000..dca8f27ea --- /dev/null +++ b/core/src/login5.rs @@ -0,0 +1,265 @@ +use crate::config::OS; +use crate::spclient::CLIENT_TOKEN; +use crate::token::Token; +use crate::{util, Error, SessionConfig}; +use bytes::Bytes; +use http::{header::ACCEPT, HeaderValue, Method, Request}; +use librespot_protocol::login5::login_response::Response; +use librespot_protocol::{ + client_info::ClientInfo, + credentials::{Password, StoredCredential}, + hashcash::HashcashSolution, + login5::{ + login_request::Login_method, ChallengeSolution, LoginError, LoginOk, LoginRequest, + LoginResponse, + }, +}; +use protobuf::well_known_types::duration::Duration as ProtoDuration; +use protobuf::{Message, MessageField}; +use std::time::{Duration, Instant}; +use thiserror::Error; +use tokio::time::sleep; + +const MAX_LOGIN_TRIES: u8 = 3; +const LOGIN_TIMEOUT: Duration = Duration::from_secs(3); + +component! { + Login5Manager : Login5ManagerInner { + auth_token: Option = None, + } +} + +#[derive(Debug, Error)] +enum Login5Error { + #[error("Login request was denied: {0:?}")] + FaultyRequest(LoginError), + #[error("Code challenge is not supported")] + CodeChallenge, + #[error("Tried to acquire token without stored credentials")] + NoStoredCredentials, + #[error("Couldn't successfully authenticate after {0} times")] + RetriesFailed(u8), + #[error("Login via login5 is only allowed for android or ios")] + OnlyForMobile, +} + +impl From for Error { + fn from(err: Login5Error) -> Self { + match err { + Login5Error::NoStoredCredentials | Login5Error::OnlyForMobile => { + Error::unavailable(err) + } + Login5Error::RetriesFailed(_) | Login5Error::FaultyRequest(_) => { + Error::failed_precondition(err) + } + Login5Error::CodeChallenge => Error::unimplemented(err), + } + } +} + +impl Login5Manager { + async fn request(&self, message: &LoginRequest) -> Result { + let client_token = self.session().spclient().client_token().await?; + let body = message.write_to_bytes()?; + + let request = Request::builder() + .method(&Method::POST) + .uri("https://login5.spotify.com/v3/login") + .header(ACCEPT, HeaderValue::from_static("application/x-protobuf")) + .header(CLIENT_TOKEN, HeaderValue::from_str(&client_token)?) + .body(body.into())?; + + self.session().http_client().request_body(request).await + } + + async fn login5_request(&self, login: Login_method) -> Result { + let client_id = match OS { + "macos" | "windows" => self.session().client_id(), + _ => SessionConfig::default().client_id, + }; + + let mut login_request = LoginRequest { + client_info: MessageField::some(ClientInfo { + client_id, + device_id: self.session().device_id().to_string(), + special_fields: Default::default(), + }), + login_method: Some(login), + ..Default::default() + }; + + let mut response = self.request(&login_request).await?; + let mut count = 0; + + loop { + count += 1; + + let message = LoginResponse::parse_from_bytes(&response)?; + if let Some(Response::Ok(ok)) = message.response { + break Ok(ok); + } + + if message.has_error() { + match message.error() { + LoginError::TIMEOUT | LoginError::TOO_MANY_ATTEMPTS => { + sleep(LOGIN_TIMEOUT).await + } + others => return Err(Login5Error::FaultyRequest(others).into()), + } + } + + if message.has_challenges() { + // handles the challenges, and updates the login context with the response + Self::handle_challenges(&mut login_request, message)?; + } + + if count < MAX_LOGIN_TRIES { + response = self.request(&login_request).await?; + } else { + return Err(Login5Error::RetriesFailed(MAX_LOGIN_TRIES).into()); + } + } + } + + /// Login for android and ios + /// + /// This request doesn't require a connected session as it is the entrypoint for android or ios + /// + /// This request will only work when: + /// - client_id => android or ios | can be easily adjusted in [SessionConfig::default_for_os] + /// - user-agent => android or ios | has to be adjusted in [HttpClient::new](crate::http_client::HttpClient::new) + pub async fn login( + &self, + id: impl Into, + password: impl Into, + ) -> Result<(Token, Vec), Error> { + if !matches!(OS, "android" | "ios") { + // by manipulating the user-agent and client-id it can be also used/tested on desktop + return Err(Login5Error::OnlyForMobile.into()); + } + + let method = Login_method::Password(Password { + id: id.into(), + password: password.into(), + ..Default::default() + }); + + let token_response = self.login5_request(method).await?; + let auth_token = Self::token_from_login( + token_response.access_token, + token_response.access_token_expires_in, + ); + + Ok((auth_token, token_response.stored_credential)) + } + + /// Retrieve the access_token via login5 + /// + /// This request will only work when the store credentials match the client-id. Meaning that + /// stored credentials generated with the keymaster client-id will not work, for example, with + /// the android client-id. + pub async fn auth_token(&self) -> Result { + let auth_data = self.session().auth_data(); + if auth_data.is_empty() { + return Err(Login5Error::NoStoredCredentials.into()); + } + + let auth_token = self.lock(|inner| { + if let Some(token) = &inner.auth_token { + if token.is_expired() { + inner.auth_token = None; + } + } + inner.auth_token.clone() + }); + + if let Some(auth_token) = auth_token { + return Ok(auth_token); + } + + let method = Login_method::StoredCredential(StoredCredential { + username: self.session().username().to_string(), + data: auth_data, + ..Default::default() + }); + + let token_response = self.login5_request(method).await?; + let auth_token = Self::token_from_login( + token_response.access_token, + token_response.access_token_expires_in, + ); + + let token = self.lock(|inner| { + inner.auth_token = Some(auth_token.clone()); + inner.auth_token.clone() + }); + + trace!("Got auth token: {:?}", auth_token); + + token.ok_or(Login5Error::NoStoredCredentials.into()) + } + + fn handle_challenges( + login_request: &mut LoginRequest, + message: LoginResponse, + ) -> Result<(), Error> { + let challenges = message.challenges(); + debug!( + "Received {} challenges, solving...", + challenges.challenges.len() + ); + + for challenge in &challenges.challenges { + if challenge.has_code() { + return Err(Login5Error::CodeChallenge.into()); + } else if !challenge.has_hashcash() { + debug!("Challenge was empty, skipping..."); + continue; + } + + let hash_cash_challenge = challenge.hashcash(); + + let mut suffix = [0u8; 0x10]; + let duration = util::solve_hash_cash( + &message.login_context, + &hash_cash_challenge.prefix, + hash_cash_challenge.length, + &mut suffix, + )?; + + let (seconds, nanos) = (duration.as_secs() as i64, duration.subsec_nanos() as i32); + debug!("Solving hashcash took {seconds}s {nanos}ns"); + + let mut solution = ChallengeSolution::new(); + solution.set_hashcash(HashcashSolution { + suffix: Vec::from(suffix), + duration: MessageField::some(ProtoDuration { + seconds, + nanos, + ..Default::default() + }), + ..Default::default() + }); + + login_request + .challenge_solutions + .mut_or_insert_default() + .solutions + .push(solution); + } + + login_request.login_context = message.login_context; + + Ok(()) + } + + fn token_from_login(token: String, expires_in: i32) -> Token { + Token { + access_token: token, + expires_in: Duration::from_secs(expires_in.try_into().unwrap_or(3600)), + token_type: "Bearer".to_string(), + scopes: vec![], + timestamp: Instant::now(), + } + } +} diff --git a/core/src/session.rs b/core/src/session.rs index f934ed7b3..defdf61be 100644 --- a/core/src/session.rs +++ b/core/src/session.rs @@ -12,14 +12,18 @@ use std::{ use byteorder::{BigEndian, ByteOrder}; use bytes::Bytes; use futures_core::TryStream; -use futures_util::{future, ready, StreamExt, TryStreamExt}; +use futures_util::StreamExt; use librespot_protocol::authentication::AuthenticationType; use num_traits::FromPrimitive; use once_cell::sync::OnceCell; use parking_lot::RwLock; +use pin_project_lite::pin_project; use quick_xml::events::Event; use thiserror::Error; -use tokio::{sync::mpsc, time::Instant}; +use tokio::{ + sync::mpsc, + time::{sleep, Duration as TokioDuration, Instant as TokioInstant, Sleep}, +}; use tokio_stream::wrappers::UnboundedReceiverStream; use crate::{ @@ -31,6 +35,7 @@ use crate::{ config::SessionConfig, connection::{self, AuthenticationError, Transport}, http_client::HttpClient, + login5::Login5Manager, mercury::MercuryManager, packet::PacketType, protocol::keyexchange::ErrorCode, @@ -82,7 +87,6 @@ struct SessionData { time_delta: i64, invalid: bool, user_data: UserData, - last_ping: Option, } struct SessionInternal { @@ -98,6 +102,7 @@ struct SessionInternal { mercury: OnceCell, spclient: OnceCell, token_provider: OnceCell, + login5: OnceCell, cache: Option>, handle: tokio::runtime::Handle, @@ -138,6 +143,7 @@ impl Session { mercury: OnceCell::new(), spclient: OnceCell::new(), token_provider: OnceCell::new(), + login5: OnceCell::new(), handle: tokio::runtime::Handle::current(), })) } @@ -240,6 +246,8 @@ impl Session { } } + // This channel serves as a buffer for packets and serializes access to the TcpStream, such + // that `self.send_packet` can return immediately and needs no additional synchronization. let (tx_connection, rx_connection) = mpsc::unbounded_channel(); self.0 .tx_connection @@ -250,17 +258,20 @@ impl Session { let sender_task = UnboundedReceiverStream::new(rx_connection) .map(Ok) .forward(sink); - let receiver_task = DispatchTask(stream, self.weak()); - let timeout_task = Session::session_timeout(self.weak()); - + let session_weak = self.weak(); tokio::spawn(async move { - let result = future::try_join3(sender_task, receiver_task, timeout_task).await; - - if let Err(e) = result { + if let Err(e) = sender_task.await { error!("{}", e); + if let Some(session) = session_weak.try_upgrade() { + if !session.is_invalid() { + session.shutdown(); + } + } } }); + tokio::spawn(DispatchTask::new(self.weak(), stream)); + Ok(()) } @@ -302,31 +313,10 @@ impl Session { .get_or_init(|| TokenProvider::new(self.weak())) } - /// Returns an error, when we haven't received a ping for too long (2 minutes), - /// which means that we silently lost connection to Spotify servers. - async fn session_timeout(session: SessionWeak) -> io::Result<()> { - // pings are sent every 2 minutes and a 5 second margin should be fine - const SESSION_TIMEOUT: Duration = Duration::from_secs(125); - - while let Some(session) = session.try_upgrade() { - if session.is_invalid() { - break; - } - let last_ping = session.0.data.read().last_ping.unwrap_or_else(Instant::now); - if last_ping.elapsed() >= SESSION_TIMEOUT { - session.shutdown(); - // TODO: Optionally reconnect (with cached/last credentials?) - return Err(io::Error::new( - io::ErrorKind::TimedOut, - "session lost connection to server", - )); - } - // drop the strong reference before sleeping - drop(session); - // a potential timeout cannot occur at least until SESSION_TIMEOUT after the last_ping - tokio::time::sleep_until(last_ping + SESSION_TIMEOUT).await; - } - Ok(()) + pub fn login5(&self) -> &Login5Manager { + self.0 + .login5 + .get_or_init(|| Login5Manager::new(self.weak())) } pub fn time_delta(&self) -> i64 { @@ -361,96 +351,6 @@ impl Session { } } - fn dispatch(&self, cmd: u8, data: Bytes) -> Result<(), Error> { - use PacketType::*; - - let packet_type = FromPrimitive::from_u8(cmd); - let cmd = match packet_type { - Some(cmd) => cmd, - None => { - trace!("Ignoring unknown packet {:x}", cmd); - return Err(SessionError::Packet(cmd).into()); - } - }; - - match packet_type { - Some(Ping) => { - let server_timestamp = BigEndian::read_u32(data.as_ref()) as i64; - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or(Duration::ZERO) - .as_secs() as i64; - - { - let mut data = self.0.data.write(); - data.time_delta = server_timestamp.saturating_sub(timestamp); - data.last_ping = Some(Instant::now()); - } - - self.debug_info(); - self.send_packet(Pong, vec![0, 0, 0, 0]) - } - Some(CountryCode) => { - let country = String::from_utf8(data.as_ref().to_owned())?; - info!("Country: {:?}", country); - self.0.data.write().user_data.country = country; - Ok(()) - } - Some(StreamChunkRes) | Some(ChannelError) => self.channel().dispatch(cmd, data), - Some(AesKey) | Some(AesKeyError) => self.audio_key().dispatch(cmd, data), - Some(MercuryReq) | Some(MercurySub) | Some(MercuryUnsub) | Some(MercuryEvent) => { - self.mercury().dispatch(cmd, data) - } - Some(ProductInfo) => { - let data = std::str::from_utf8(&data)?; - let mut reader = quick_xml::Reader::from_str(data); - - let mut buf = Vec::new(); - let mut current_element = String::new(); - let mut user_attributes: UserAttributes = HashMap::new(); - - loop { - match reader.read_event_into(&mut buf) { - Ok(Event::Start(ref element)) => { - std::str::from_utf8(element)?.clone_into(&mut current_element) - } - Ok(Event::End(_)) => { - current_element = String::new(); - } - Ok(Event::Text(ref value)) => { - if !current_element.is_empty() { - let _ = user_attributes - .insert(current_element.clone(), value.unescape()?.to_string()); - } - } - Ok(Event::Eof) => break, - Ok(_) => (), - Err(e) => warn!( - "Error parsing XML at position {}: {:?}", - reader.buffer_position(), - e - ), - } - } - - trace!("Received product info: {:#?}", user_attributes); - Self::check_catalogue(&user_attributes); - - self.0.data.write().user_data.attributes = user_attributes; - Ok(()) - } - Some(PongAck) - | Some(SecretBlock) - | Some(LegacyWelcome) - | Some(UnknownDataAllZeros) - | Some(LicenseVersion) => Ok(()), - _ => { - trace!("Ignoring {:?} packet with data {:#?}", cmd, data); - Err(SessionError::Packet(cmd as u8).into()) - } - } - } - pub fn send_packet(&self, cmd: PacketType, data: Vec) -> Result<(), Error> { match self.0.tx_connection.get() { Some(tx) => Ok(tx.send((cmd as u8, data))?), @@ -614,50 +514,277 @@ impl Drop for SessionInternal { } } -struct DispatchTask(S, SessionWeak) +#[derive(Clone, Copy, Default, Debug, PartialEq)] +enum KeepAliveState { + #[default] + // Expecting a Ping from the server, either after startup or after a PongAck. + ExpectingPing, + + // We need to send a Pong at the given time. + PendingPong, + + // We just sent a Pong and wait for it be ACK'd. + ExpectingPongAck, +} + +const INITIAL_PING_TIMEOUT: TokioDuration = TokioDuration::from_secs(20); +const PING_TIMEOUT: TokioDuration = TokioDuration::from_secs(80); // 60s expected + 20s buffer +const PONG_DELAY: TokioDuration = TokioDuration::from_secs(60); +const PONG_ACK_TIMEOUT: TokioDuration = TokioDuration::from_secs(20); + +impl KeepAliveState { + fn debug(&self, sleep: &Sleep) { + let delay = sleep + .deadline() + .checked_duration_since(TokioInstant::now()) + .map(|t| t.as_secs_f64()) + .unwrap_or(f64::INFINITY); + + trace!("keep-alive state: {:?}, timeout in {:.1}", self, delay); + } +} + +pin_project! { + struct DispatchTask + where + S: TryStream + { + session: SessionWeak, + keep_alive_state: KeepAliveState, + #[pin] + stream: S, + #[pin] + timeout: Sleep, + } + + impl PinnedDrop for DispatchTask + where + S: TryStream + { + fn drop(_this: Pin<&mut Self>) { + debug!("drop Dispatch"); + } + } +} + +impl DispatchTask where - S: TryStream + Unpin; + S: TryStream, +{ + fn new(session: SessionWeak, stream: S) -> Self { + Self { + session, + keep_alive_state: KeepAliveState::ExpectingPing, + stream, + timeout: sleep(INITIAL_PING_TIMEOUT), + } + } + + fn dispatch( + mut self: Pin<&mut Self>, + session: &Session, + cmd: u8, + data: Bytes, + ) -> Result<(), Error> { + use KeepAliveState::*; + use PacketType::*; + + let packet_type = FromPrimitive::from_u8(cmd); + let cmd = match packet_type { + Some(cmd) => cmd, + None => { + trace!("Ignoring unknown packet {:x}", cmd); + return Err(SessionError::Packet(cmd).into()); + } + }; + + match packet_type { + Some(Ping) => { + trace!("Received Ping"); + if self.keep_alive_state != ExpectingPing { + warn!("Received unexpected Ping from server") + } + let mut this = self.as_mut().project(); + *this.keep_alive_state = PendingPong; + this.timeout + .as_mut() + .reset(TokioInstant::now() + PONG_DELAY); + this.keep_alive_state.debug(&this.timeout); + + let server_timestamp = BigEndian::read_u32(data.as_ref()) as i64; + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or(Duration::ZERO) + .as_secs() as i64; + { + let mut data = session.0.data.write(); + data.time_delta = server_timestamp.saturating_sub(timestamp); + } + + session.debug_info(); + + Ok(()) + } + Some(PongAck) => { + trace!("Received PongAck"); + if self.keep_alive_state != ExpectingPongAck { + warn!("Received unexpected PongAck from server") + } + let mut this = self.as_mut().project(); + *this.keep_alive_state = ExpectingPing; + this.timeout + .as_mut() + .reset(TokioInstant::now() + PING_TIMEOUT); + this.keep_alive_state.debug(&this.timeout); + + Ok(()) + } + Some(CountryCode) => { + let country = String::from_utf8(data.as_ref().to_owned())?; + info!("Country: {:?}", country); + session.0.data.write().user_data.country = country; + Ok(()) + } + Some(StreamChunkRes) | Some(ChannelError) => session.channel().dispatch(cmd, data), + Some(AesKey) | Some(AesKeyError) => session.audio_key().dispatch(cmd, data), + Some(MercuryReq) | Some(MercurySub) | Some(MercuryUnsub) | Some(MercuryEvent) => { + session.mercury().dispatch(cmd, data) + } + Some(ProductInfo) => { + let data = std::str::from_utf8(&data)?; + let mut reader = quick_xml::Reader::from_str(data); + + let mut buf = Vec::new(); + let mut current_element = String::new(); + let mut user_attributes: UserAttributes = HashMap::new(); + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref element)) => { + std::str::from_utf8(element)?.clone_into(&mut current_element) + } + Ok(Event::End(_)) => { + current_element = String::new(); + } + Ok(Event::Text(ref value)) => { + if !current_element.is_empty() { + let _ = user_attributes + .insert(current_element.clone(), value.unescape()?.to_string()); + } + } + Ok(Event::Eof) => break, + Ok(_) => (), + Err(e) => warn!( + "Error parsing XML at position {}: {:?}", + reader.buffer_position(), + e + ), + } + } + + trace!("Received product info: {:#?}", user_attributes); + Session::check_catalogue(&user_attributes); + + session.0.data.write().user_data.attributes = user_attributes; + Ok(()) + } + Some(SecretBlock) + | Some(LegacyWelcome) + | Some(UnknownDataAllZeros) + | Some(LicenseVersion) => Ok(()), + _ => { + trace!("Ignoring {:?} packet with data {:#?}", cmd, data); + Err(SessionError::Packet(cmd as u8).into()) + } + } + } +} impl Future for DispatchTask where - S: TryStream + Unpin, + S: TryStream, ::Ok: std::fmt::Debug, { type Output = Result<(), S::Error>; fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - let session = match self.1.try_upgrade() { + use KeepAliveState::*; + + let session = match self.session.try_upgrade() { Some(session) => session, None => return Poll::Ready(Ok(())), }; + // Process all messages that are immediately ready loop { - let (cmd, data) = match ready!(self.0.try_poll_next_unpin(cx)) { - Some(Ok(t)) => t, - None => { + match self.as_mut().project().stream.try_poll_next(cx) { + Poll::Ready(Some(Ok((cmd, data)))) => { + let result = self.as_mut().dispatch(&session, cmd, data); + if let Err(e) = result { + debug!("could not dispatch command: {}", e); + } + } + Poll::Ready(None) => { warn!("Connection to server closed."); session.shutdown(); return Poll::Ready(Ok(())); } - Some(Err(e)) => { + Poll::Ready(Some(Err(e))) => { error!("Connection to server closed."); session.shutdown(); return Poll::Ready(Err(e)); } - }; + Poll::Pending => break, + } + } - if let Err(e) = session.dispatch(cmd, data) { - debug!("could not dispatch command: {}", e); + // Handle the keep-alive sequence, returning an error when we haven't received a + // Ping/PongAck for too long. + // + // The expected keepalive sequence is + // - Server: Ping + // - wait 60s + // - Client: Pong + // - Server: PongAck + // - wait 60s + // - repeat + // + // This means that we silently lost connection to Spotify servers if + // - we don't receive Ping immediately after connecting, + // - we don't receive a Ping 60s after the last PongAck or + // - we don't receive a PongAck immediately after our Pong. + // + // Currently, we add a safety margin of 20s to these expected deadlines. + let mut this = self.as_mut().project(); + if let Poll::Ready(()) = this.timeout.as_mut().poll(cx) { + match this.keep_alive_state { + ExpectingPing | ExpectingPongAck => { + if !session.is_invalid() { + session.shutdown(); + } + // TODO: Optionally reconnect (with cached/last credentials?) + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::TimedOut, + format!( + "session lost connection to server ({:?})", + this.keep_alive_state + ), + ))); + } + PendingPong => { + trace!("Sending Pong"); + // TODO: Ideally, this should flush the `Framed as Sink` + // before starting the timeout. + let _ = session.send_packet(PacketType::Pong, vec![0, 0, 0, 0]); + *this.keep_alive_state = ExpectingPongAck; + this.timeout + .as_mut() + .reset(TokioInstant::now() + PONG_ACK_TIMEOUT); + this.keep_alive_state.debug(&this.timeout); + } } } - } -} -impl Drop for DispatchTask -where - S: TryStream + Unpin, -{ - fn drop(&mut self) { - debug!("drop Dispatch"); + Poll::Pending } } diff --git a/core/src/spclient.rs b/core/src/spclient.rs index 414f5a484..0fb85d1cd 100644 --- a/core/src/spclient.rs +++ b/core/src/spclient.rs @@ -1,10 +1,8 @@ use std::{ - env::consts::OS, fmt::Write, time::{Duration, Instant}, }; -use byteorder::{BigEndian, ByteOrder}; use bytes::Bytes; use data_encoding::HEXUPPER_PERMISSIVE; use futures_util::future::IntoStream; @@ -17,10 +15,10 @@ use hyper::body::Body; use hyper_util::client::legacy::ResponseFuture; use protobuf::{Enum, Message, MessageFull}; use rand::RngCore; -use sha1::{Digest, Sha1}; use sysinfo::System; use thiserror::Error; +use crate::config::{os_version, OS}; use crate::{ apresolve::SocketAddress, cdn_url::CdnUrl, @@ -37,6 +35,7 @@ use crate::{ login5::{LoginRequest, LoginResponse}, }, token::Token, + util, version::spotify_semantic_version, Error, FileId, SpotifyId, }; @@ -53,7 +52,7 @@ component! { pub type SpClientResult = Result; #[allow(clippy::declare_interior_mutable_const)] -const CLIENT_TOKEN: HeaderName = HeaderName::from_static("client-token"); +pub const CLIENT_TOKEN: HeaderName = HeaderName::from_static("client-token"); #[derive(Debug, Error)] pub enum SpClientError { @@ -292,7 +291,7 @@ impl SpClient { .platform_specific_data .mut_or_insert_default(); - let os_version = System::os_version().unwrap_or_else(|| String::from("0")); + let os_version = os_version(); let kernel_version = System::kernel_version().unwrap_or_else(|| String::from("0")); match os { @@ -381,7 +380,7 @@ impl SpClient { let length = hash_cash_challenge.length; let mut suffix = [0u8; 0x10]; - let answer = Self::solve_hash_cash(&ctx, &prefix, length, &mut suffix); + let answer = util::solve_hash_cash(&ctx, &prefix, length, &mut suffix); match answer { Ok(_) => { @@ -556,7 +555,7 @@ impl SpClient { .body(body.to_owned().into())?; // Reconnection logic: keep getting (cached) tokens because they might have expired. - let auth_token = self.auth_token().await?; + let token = self.session().login5().auth_token().await?; let headers_mut = request.headers_mut(); if let Some(ref hdrs) = headers { diff --git a/core/src/util.rs b/core/src/util.rs index a01f8b565..31cdd962b 100644 --- a/core/src/util.rs +++ b/core/src/util.rs @@ -1,12 +1,16 @@ +use crate::Error; +use byteorder::{BigEndian, ByteOrder}; +use futures_core::ready; +use futures_util::{future, FutureExt, Sink, SinkExt}; +use hmac::digest::Digest; +use sha1::Sha1; +use std::time::{Duration, Instant}; use std::{ future::Future, mem, pin::Pin, task::{Context, Poll}, }; - -use futures_core::ready; -use futures_util::{future, FutureExt, Sink, SinkExt}; use tokio::{task::JoinHandle, time::timeout}; /// Returns a future that will flush the sink, even if flushing is temporarily completed. @@ -120,3 +124,44 @@ impl SeqGenerator { mem::replace(&mut self.0, value) } } + +pub fn solve_hash_cash( + ctx: &[u8], + prefix: &[u8], + length: i32, + dst: &mut [u8], +) -> Result { + // after a certain number of seconds, the challenge expires + const TIMEOUT: u64 = 5; // seconds + let now = Instant::now(); + + let md = Sha1::digest(ctx); + + let mut counter: i64 = 0; + let target: i64 = BigEndian::read_i64(&md[12..20]); + + let suffix = loop { + if now.elapsed().as_secs() >= TIMEOUT { + return Err(Error::deadline_exceeded(format!( + "{TIMEOUT} seconds expired" + ))); + } + + let suffix = [(target + counter).to_be_bytes(), counter.to_be_bytes()].concat(); + + let mut hasher = Sha1::new(); + hasher.update(prefix); + hasher.update(&suffix); + let md = hasher.finalize(); + + if BigEndian::read_i64(&md[12..20]).trailing_zeros() >= (length as u32) { + break suffix; + } + + counter += 1; + }; + + dst.copy_from_slice(&suffix); + + Ok(now.elapsed()) +} diff --git a/core/src/version.rs b/core/src/version.rs index d3870473d..3439662ca 100644 --- a/core/src/version.rs +++ b/core/src/version.rs @@ -29,14 +29,14 @@ pub const SPOTIFY_MOBILE_VERSION: &str = "8.6.84"; pub const FALLBACK_USER_AGENT: &str = "Spotify/117300517 Linux/0 (librespot)"; pub fn spotify_version() -> String { - match std::env::consts::OS { + match crate::config::OS { "android" | "ios" => SPOTIFY_MOBILE_VERSION.to_owned(), _ => SPOTIFY_VERSION.to_string(), } } pub fn spotify_semantic_version() -> String { - match std::env::consts::OS { + match crate::config::OS { "android" | "ios" => SPOTIFY_MOBILE_VERSION.to_owned(), _ => SPOTIFY_SEMANTIC_VERSION.to_string(), } diff --git a/discovery/Cargo.toml b/discovery/Cargo.toml index 15e2777cf..863f5d8f0 100644 --- a/discovery/Cargo.toml +++ b/discovery/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "librespot-discovery" -version = "0.5.0-dev" -rust-version = "1.74" +version = "0.5.0" +rust-version.workspace = true authors = ["Paul Lietar "] description = "The discovery logic for librespot" license = "MIT" @@ -31,7 +31,7 @@ tokio = { version = "1", features = ["parking_lot", "sync", "rt"] } [dependencies.librespot-core] path = "../core" -version = "0.5.0-dev" +version = "0.5.0" [dev-dependencies] futures = "0.3" diff --git a/discovery/src/server.rs b/discovery/src/server.rs index 0e235f775..f3c979b9d 100644 --- a/discovery/src/server.rs +++ b/discovery/src/server.rs @@ -2,7 +2,7 @@ use std::{ borrow::Cow, collections::BTreeMap, convert::Infallible, - net::{Ipv4Addr, SocketAddr, TcpListener}, + net::{Ipv4Addr, Ipv6Addr, SocketAddr, TcpListener}, pin::Pin, sync::{Arc, Mutex}, task::{Context, Poll}, @@ -266,7 +266,12 @@ pub struct DiscoveryServer { impl DiscoveryServer { pub fn new(config: Config, port: &mut u16) -> Result { let (discovery, cred_rx) = RequestHandler::new(config); - let address = SocketAddr::new(Ipv4Addr::UNSPECIFIED.into(), *port); + let address = if cfg!(windows) { + SocketAddr::new(Ipv4Addr::UNSPECIFIED.into(), *port) + } else { + // this creates a dual stack socket on non-windows systems + SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), *port) + }; let (close_tx, close_rx) = oneshot::channel(); diff --git a/metadata/Cargo.toml b/metadata/Cargo.toml index a5d90460d..813be7835 100644 --- a/metadata/Cargo.toml +++ b/metadata/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "librespot-metadata" -version = "0.5.0-dev" -rust-version = "1.74" +version = "0.5.0" +rust-version.workspace = true authors = ["Paul Lietar "] description = "The metadata logic for librespot" license = "MIT" @@ -20,8 +20,8 @@ serde_json = "1.0" [dependencies.librespot-core] path = "../core" -version = "0.5.0-dev" +version = "0.5.0" [dependencies.librespot-protocol] path = "../protocol" -version = "0.5.0-dev" +version = "0.5.0" diff --git a/oauth/Cargo.toml b/oauth/Cargo.toml index 646f08799..3d52555ed 100644 --- a/oauth/Cargo.toml +++ b/oauth/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "librespot-oauth" -version = "0.5.0-dev" -rust-version = "1.73" +version = "0.5.0" +rust-version.workspace = true authors = ["Nick Steel "] description = "OAuth authorization code flow with PKCE for obtaining a Spotify access token" license = "MIT" diff --git a/playback/Cargo.toml b/playback/Cargo.toml index 053f7aa5c..4a9f31093 100644 --- a/playback/Cargo.toml +++ b/playback/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "librespot-playback" -version = "0.5.0-dev" -rust-version = "1.74" +version = "0.5.0" +rust-version.workspace = true authors = ["Sasha Hilton "] description = "The audio playback logic for librespot" license = "MIT" @@ -10,15 +10,15 @@ edition = "2021" [dependencies.librespot-audio] path = "../audio" -version = "0.5.0-dev" +version = "0.5.0" [dependencies.librespot-core] path = "../core" -version = "0.5.0-dev" +version = "0.5.0" [dependencies.librespot-metadata] path = "../metadata" -version = "0.5.0-dev" +version = "0.5.0" [dependencies] futures-util = "0.3" diff --git a/protocol/Cargo.toml b/protocol/Cargo.toml index b58ebcbe1..60c488662 100644 --- a/protocol/Cargo.toml +++ b/protocol/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "librespot-protocol" -version = "0.5.0-dev" -rust-version = "1.74" +version = "0.5.0" +rust-version.workspace = true authors = ["Paul Liétar "] build = "build.rs" description = "The protobuf logic for communicating with Spotify servers" diff --git a/publish.sh b/publish.sh index c9982c97c..d1d8f7835 100755 --- a/publish.sh +++ b/publish.sh @@ -6,17 +6,21 @@ DRY_RUN='false' WORKINGDIR="$( cd "$(dirname "$0")" ; pwd -P )" cd $WORKINGDIR -crates=( "protocol" "core" "discovery" "oauth" "audio" "metadata" "playback" "connect" "librespot" ) +crates=( "protocol" "oauth" "core" "discovery" "audio" "metadata" "playback" "connect" "librespot" ) -OS=`uname` function replace_in_file() { - if [ "$OS" == 'darwin' ]; then - # for MacOS - sed -i '' -e "$1" "$2" - else - # for Linux and Windows - sed -i'' -e "$1" "$2" - fi + OS=`uname` + shopt -s nocasematch + case "$OS" in + darwin) + # for macOS + sed -i '' -e "$1" "$2" + ;; + *) + # for Linux and Windows + sed -i'' -e "$1" "$2" + ;; + esac } function switchBranch { diff --git a/src/main.rs b/src/main.rs index a836e84d0..575c65b93 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1110,6 +1110,8 @@ fn get_setup() -> Setup { tmp_dir }); + let enable_oauth = opt_present(ENABLE_OAUTH); + let cache = { let volume_dir = opt_str(SYSTEM_CACHE) .or_else(|| opt_str(CACHE)) @@ -1157,16 +1159,20 @@ fn get_setup() -> Setup { ); } - match Cache::new(cred_dir, volume_dir, audio_dir, limit) { + let cache = match Cache::new(cred_dir.clone(), volume_dir, audio_dir, limit) { Ok(cache) => Some(cache), Err(e) => { warn!("Cannot create cache: {}", e); None } + }; + + if enable_oauth && (cache.is_none() || cred_dir.is_none()) { + warn!("Credential caching is unavailable, but advisable when using OAuth login."); } - }; - let enable_oauth = opt_present(ENABLE_OAUTH); + cache + }; let credentials = { let cached_creds = cache.as_ref().and_then(Cache::credentials);