Repository: ccgauche/ytermusic Branch: master Commit: 0481657a61a4 Files: 63 Total size: 201.2 KB Directory structure: gitextract_sweneqjg/ ├── .github/ │ └── workflows/ │ ├── check.yml │ ├── ci.yml │ ├── clippy.yml │ ├── fmt.yml │ ├── release.yml │ ├── test-linux.yml │ ├── test-macos.yml │ └── test-windows.yml ├── .gitignore ├── Cargo.toml ├── LICENCE ├── README.md └── crates/ ├── common-structs/ │ ├── Cargo.toml │ └── src/ │ ├── app_status.rs │ ├── lib.rs │ └── music_download_status.rs ├── database/ │ ├── Cargo.toml │ └── src/ │ ├── lib.rs │ ├── reader.rs │ └── writer.rs ├── download-manager/ │ ├── Cargo.toml │ └── src/ │ ├── lib.rs │ └── task.rs ├── player/ │ ├── Cargo.toml │ └── src/ │ ├── error.rs │ ├── lib.rs │ ├── player.rs │ ├── player_data.rs │ └── player_options.rs ├── ytermusic/ │ ├── Cargo.toml │ └── src/ │ ├── config.rs │ ├── consts.rs │ ├── database.rs │ ├── errors.rs │ ├── main.rs │ ├── shutdown.rs │ ├── structures/ │ │ ├── app_status.rs │ │ ├── media.rs │ │ ├── mod.rs │ │ ├── performance.rs │ │ └── sound_action.rs │ ├── systems/ │ │ ├── logger.rs │ │ ├── mod.rs │ │ └── player.rs │ ├── tasks/ │ │ ├── api.rs │ │ ├── clean.rs │ │ ├── last_playlist.rs │ │ ├── local_musics.rs │ │ └── mod.rs │ ├── term/ │ │ ├── device_lost.rs │ │ ├── item_list.rs │ │ ├── list_selector.rs │ │ ├── mod.rs │ │ ├── music_player.rs │ │ ├── playlist.rs │ │ ├── playlist_view.rs │ │ ├── search.rs │ │ └── vertical_gauge.rs │ └── utils.rs └── ytpapi2/ ├── Cargo.toml └── src/ ├── json_extractor.rs ├── lib.rs └── string_utils.rs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/check.yml ================================================ name: Check on: workflow_dispatch: env: CARGO_TERM_COLOR: always jobs: check: name: Check runs-on: ubuntu-latest steps: - name: Checkout sources uses: actions/checkout@v4 - name: Install system dependencies run: | sudo apt-get update sudo apt-get install -y libasound2-dev libdbus-1-dev pkg-config - name: Install stable toolchain uses: dtolnay/rust-toolchain@stable - name: Run cargo check run: cargo check ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push: branches: [ main, master ] pull_request: branches: [ main, master ] env: CARGO_TERM_COLOR: always jobs: quality: name: Code Quality runs-on: ubuntu-latest steps: - name: Checkout sources uses: actions/checkout@v4 - name: Install system dependencies run: | sudo apt-get update sudo apt-get install -y libasound2-dev libdbus-1-dev pkg-config - name: Install stable toolchain uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy - name: Run cargo check run: cargo check - name: Run cargo fmt run: cargo fmt --all -- --check - name: Run cargo clippy run: cargo clippy -- -D warnings ================================================ FILE: .github/workflows/clippy.yml ================================================ name: Clippy on: workflow_dispatch: env: CARGO_TERM_COLOR: always jobs: clippy: name: Clippy runs-on: ubuntu-latest steps: - name: Checkout sources uses: actions/checkout@v4 - name: Install system dependencies run: | sudo apt-get update sudo apt-get install -y libasound2-dev libdbus-1-dev pkg-config - name: Install stable toolchain uses: dtolnay/rust-toolchain@stable with: components: clippy - name: Run cargo clippy run: cargo clippy -- -D warnings ================================================ FILE: .github/workflows/fmt.yml ================================================ name: Format on: workflow_dispatch: env: CARGO_TERM_COLOR: always jobs: fmt: name: Format Check runs-on: ubuntu-latest steps: - name: Checkout sources uses: actions/checkout@v4 - name: Install stable toolchain uses: dtolnay/rust-toolchain@stable with: components: rustfmt - name: Run cargo fmt run: cargo fmt --all -- --check ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: release: types: [published] jobs: release: name: Build and Release (${{ matrix.target }}) runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: include: - os: ubuntu-latest target: x86_64-unknown-linux-gnu archive: tar.gz - os: windows-latest target: x86_64-pc-windows-msvc archive: zip - os: macos-latest target: aarch64-apple-darwin archive: tar.gz - os: macos-14 target: x86_64-apple-darwin archive: tar.gz steps: - name: Checkout repository uses: actions/checkout@v4 - name: Install system dependencies (Linux) if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y libasound2-dev libdbus-1-dev pkg-config - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} - name: Build release binary run: | cargo build --release --target ${{ matrix.target }} - name: Package binary (Unix) if: runner.os != 'Windows' shell: bash run: | # Determine binary name and path if [[ "${{ matrix.target }}" == *"windows"* ]]; then BINARY_NAME="ytermusic.exe" BINARY_PATH="target/${{ matrix.target }}/release/ytermusic.exe" else BINARY_NAME="ytermusic" BINARY_PATH="target/${{ matrix.target }}/release/ytermusic" strip "$BINARY_PATH" fi # Determine archive name ARCHIVE_NAME="ytermusic-${{ github.ref_name }}-${{ matrix.target }}" # Create archive if [[ "${{ matrix.archive }}" == "zip" ]]; then zip "${ARCHIVE_NAME}.zip" "$BINARY_PATH" else tar czf "${ARCHIVE_NAME}.tar.gz" -C "target/${{ matrix.target }}/release" "$BINARY_NAME" fi - name: Package binary (Windows) if: runner.os == 'Windows' shell: pwsh run: | # Determine binary name and path $binaryName = "ytermusic.exe" $binaryPath = "target/${{ matrix.target }}/release/ytermusic.exe" # Determine archive name $archiveName = "ytermusic-${{ github.ref_name }}-${{ matrix.target }}" # Create archive if ("${{ matrix.archive }}" -eq "zip") { Compress-Archive -Path $binaryPath -DestinationPath "${archiveName}.zip" } else { # For tar.gz on Windows, we could use tar if available, or just copy the binary Copy-Item $binaryPath "${archiveName}.tar.gz" } - name: Upload release asset uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ github.event.release.upload_url }} asset_path: ytermusic-${{ github.ref_name }}-${{ matrix.target }}.${{ matrix.archive }} asset_name: ytermusic-${{ github.ref_name }}-${{ matrix.target }}.${{ matrix.archive }} asset_content_type: application/octet-stream ================================================ FILE: .github/workflows/test-linux.yml ================================================ name: Test Linux on: workflow_dispatch: env: CARGO_TERM_COLOR: always jobs: test-linux: name: Test Linux (${{ matrix.rust }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: rust: [stable, beta] steps: - name: Checkout sources uses: actions/checkout@v4 - name: Install system dependencies run: | sudo apt-get update sudo apt-get install -y libasound2-dev libdbus-1-dev pkg-config - name: Install ${{ matrix.rust }} toolchain uses: dtolnay/rust-toolchain@master with: toolchain: ${{ matrix.rust }} - name: Run cargo test run: cargo test --verbose ================================================ FILE: .github/workflows/test-macos.yml ================================================ name: Test macOS on: workflow_dispatch: env: CARGO_TERM_COLOR: always jobs: test-macos: name: Test macOS (stable) runs-on: macos-latest steps: - name: Checkout sources uses: actions/checkout@v4 - name: Install stable toolchain uses: dtolnay/rust-toolchain@stable - name: Run cargo test run: cargo test --verbose ================================================ FILE: .github/workflows/test-windows.yml ================================================ name: Test Windows on: workflow_dispatch: env: CARGO_TERM_COLOR: always jobs: test-windows: name: Test Windows (stable) runs-on: windows-latest steps: - name: Checkout sources uses: actions/checkout@v4 - name: Install stable toolchain uses: dtolnay/rust-toolchain@stable - name: Run cargo test run: cargo test --verbose ================================================ FILE: .gitignore ================================================ /target /data ytermusic.exe headers.txt account_id.txt /player/target /ytpapi/target /ytpapi2/target /rustube/target /log.txt /music_renamer last-playlist.json muex cssparser ================================================ FILE: Cargo.toml ================================================ [workspace] resolver = "3" members = ["crates/common-structs","crates/database", "crates/download-manager","crates/player", "crates/ytermusic", "crates/ytpapi2"] [workspace.dependencies] player = { path = "crates/player" } ytpapi2 = { path = "crates/ytpapi2" } database = { path = "crates/database" } download-manager = { path = "crates/download-manager" } common-structs = { path = "crates/common-structs" } ================================================ FILE: LICENCE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ # YTerMusic ![YTeRMUSiC](./assets/banner/YTeRMUSiC.png "YTeRMUSiC") YTerMusic is a TUI based Youtube Music Player that aims to be as fast and simple as possible. ## Screenshots

Choose a playlist Playlist RGB

## Features and upcoming features - Play your Youtube Music Playlist and Supermix. - Memory efficient (Around 20MB of RAM while fully loaded) - Cache all downloads and store them - Work even without connection (If musics were already downloaded) - Automatic background download manager ### Check List - [x] Playlist selector - [x] Error message display in the TUI - [x] Enable connection less music playing - [ ] Cache limit to not exceed some given disk space - [x] A download limit to stop downloading after the queue is full - [x] Mouse support - [x] Search - [x] Custom theming (You can use hex! #05313d = ![05313d](./assets/hex/05313d.png "#05313d") ) ## Install > [!TIP] > 3rd party AUR packages are available [here](https://aur.archlinux.org/packages?O=0&K=ytermusic). - Download the latest version from [releases](https://github.com/ccgauche/ytermusic/releases/latest). ### Linux Install the following libraries: ```sh sudo apt install alsa-tools libasound2-dev libdbus-1-dev pkg-config ``` - Use `cargo` to install the latest version ```sh cargo install ytermusic --git https://github.com/ccgauche/ytermusic ``` ## Setup > [!IMPORTANT] > If you're using Firefox enable the "Raw" switch so the cookie isn't mangled. > ![Firefox Raw Switch](./assets/screenshots/Firefox-Raw-Switch.png "Firefox Raw Switch") - Give `ytermusic` authentication to your account, by copying out the headers 1. Open the https://music.youtube.com website in your browser 2. Open the developer tools (F12 or Fn + F12) 3. Go to the Network tab 4. Find the request to the `music.youtube.com` document `/` page 5. Copy the `Cookie` header from the associated response request 6. Create a `headers.txt` file in one of the checked [paths](https://docs.rs/directories/latest/directories/struct.ProjectDirs.html#method.config_dir). 7. Create an entry like this : ``` Cookie: User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36 ``` - Then you can start `ytermusic` ### (Optional) Using a brand account - If you use a second account for youtube music 1. Go to https://myaccount.google.com/ 2. Switch to your brand account 3. copy the number written in the url, after \b\ 4. paste it into a new `account_id.txt` file in the same folder as `headers.txt` ## Building from source - Clone the repository - Install rust `https://rustup.rs` nightly - Run `cargo build --release` - The executable is in `target/release/ytermusic.exe` or `target/release/ytermusic` ## Usage - Use your mouse to click in lists if your terminal has mouse support - Press Space to play/pause - Press Enter to select a playlist or a music - Press f to search - Press s to shuffle - Press r to remove a music from the main playlist - Press Arrow Right or \> to skip 5 seconds - Press Arrow Left or \< to go back 5 seconds - Press CTRL + Arrow Right or CTRL + \> to go to the next song - Press CTRL + Arrow Left or CTRL + \< to go to the previous song - Press + for volume up - Press - for volume down - Press Arrow down to scroll down - Press Arrow up to scroll up - Press ESC to exit the current menu - Press CTRL + C or CTRL + D to exit ## How to fix common issues If you have any issues start by running: ```sh ytermusic --fix-db ``` This will try to fix any issues with the cache database. If you still have issues, you can clear the cache by running: ```sh ytermusic --clear-cache ``` If you need to report an issue or find the files related to ytermusic: ```sh ytermusic --files ``` ## Changelog ``` Beta b0.1.1 - Added `hide_channels_on_homepage` with a default value of `true` to the config file - Added `hide_albums_on_homepage` with a default value of `false` to the config file - Fixed default style to support transparency - Added more color configuration options Beta b0.1.0 - Fixed keyboard handling on windows - Improved error handling - Fixed youtube downloads - Made volume bar optional in config - Improved performance and updated dependencies Alpha a0.0.11 - Added scrollable music view - Added shuffle functionality - Fixed some crashes while resizing the app - Added error messages for invalid headers or cookies - Added error messages for expired cookies Alpha a0.0.10 - Speed up the download process - Fix the download limit - Fix music artists getting smashed together - Fix the download manager not downloading all musics - Improved stability - Improved logs and added timings to better debug Alpha a0.0.9: - Progress info for downloads - Mouse support on time bar - Vertical volume bar - Vertical volume bar supports mouse click - Scroll to change volume and skip in timeline - Improved the scrolling action - Fixed the bug where the time bar would not update - Debouncing the search input - Changed the location of the cache folder to follow the XDG Base Directory Specification (By @FerrahWolfeh #20) - More configuration options (By @ccgauche and @FerrahWolfeh) Alpha a0.0.8 - Fixed scrolling - Fixed audio-glitches - Removed nightly flag use Alpha a0.0.7 - Major changes in the API - Fixed log file bloat issue Alpha a0.0.6 - Fix: Fix a bug where the app would crash when trying to play a song that was not downloaded - Fix: Improve the logger to not print the same error twice - Improved startup time - Fixed linux build - Changed how task are distributed to the thread pool Alpha a0.0.5 - Added local database cache to improve IO accesses - Added searching for musics in the local library - Greatly improved render performance and RAM usage - Error management and error display in specific screen Alpha a0.0.4 - Added menu navigation - Added searching for musics - Added new terminal backend Alpha a0.0.3 - Mouse support to select playlist and music - Download limiter - Connection less music playing Alpha a0.0.2 - Playlist selector - Improved error management - Improved TUI - Performance upgrade - Switch to Rustls instead of openSSL ``` ================================================ FILE: crates/common-structs/Cargo.toml ================================================ [package] name = "common-structs" version = "0.1.0" edition = "2024" [dependencies] ================================================ FILE: crates/common-structs/src/app_status.rs ================================================ #[derive(PartialEq, Debug, Clone)] pub enum AppStatus { Paused, Playing, NoMusic, } ================================================ FILE: crates/common-structs/src/lib.rs ================================================ mod app_status; mod music_download_status; pub use app_status::AppStatus; pub use music_download_status::MusicDownloadStatus; ================================================ FILE: crates/common-structs/src/music_download_status.rs ================================================ #[derive(PartialEq, Debug, Clone, Copy)] pub enum MusicDownloadStatus { NotDownloaded, Downloaded, Downloading(usize), DownloadFailed, } impl MusicDownloadStatus { pub fn character(&self, playing: Option) -> String { match self { Self::NotDownloaded => { if let Some(e) = playing { if e { '▶' } else { '⏸' } } else { ' ' } } Self::Downloaded => ' ', Self::Downloading(progress) => return format!("⭳ [{:02}%]", progress), Self::DownloadFailed => '⚠', } .into() } } ================================================ FILE: crates/database/Cargo.toml ================================================ [package] name = "database" version = "0.1.0" edition = "2024" [dependencies] varuint = "0.7.1" ytpapi2.workspace = true log = "*" serde_json = "1.0.114" ================================================ FILE: crates/database/src/lib.rs ================================================ use std::{fs::OpenOptions, path::PathBuf, sync::RwLock}; mod reader; mod writer; pub use writer::write_video; use ytpapi2::YoutubeMusicVideoRef; pub struct YTLocalDatabase { cache_dir: PathBuf, references: RwLock>, } impl YTLocalDatabase { pub fn new(cache_dir: PathBuf) -> Self { Self { cache_dir, references: RwLock::new(Vec::new()), } } pub fn clone_from(&self, videos: &Vec) { self.references.write().unwrap().clone_from(videos); } pub fn remove_video(&self, video: &YoutubeMusicVideoRef) { let mut database = self.references.write().unwrap(); database.retain(|v| v.video_id != video.video_id); drop(database); self.write(); } pub fn append(&self, video: YoutubeMusicVideoRef) { let mut file = OpenOptions::new() .append(true) .create(true) .open(self.cache_dir.join("db.bin")) .unwrap(); write_video(&mut file, &video); self.references.write().unwrap().push(video); } } ================================================ FILE: crates/database/src/reader.rs ================================================ use std::io::{Cursor, Read}; use varuint::ReadVarint; use ytpapi2::YoutubeMusicVideoRef; use crate::YTLocalDatabase; impl YTLocalDatabase { pub fn read(&self) -> Option> { let mut buffer = Cursor::new(std::fs::read(self.cache_dir.join("db.bin")).ok()?); let mut videos = Vec::new(); while buffer.get_mut().len() > buffer.position() as usize { videos.push(read_video(&mut buffer)?); } Some(videos) } } /// Reads a video from the cursor fn read_video(buffer: &mut Cursor>) -> Option { Some(YoutubeMusicVideoRef { title: read_str(buffer)?, author: read_str(buffer)?, album: read_str(buffer)?, video_id: read_str(buffer)?, duration: read_str(buffer)?, }) } /// Reads a string from the cursor fn read_str(cursor: &mut Cursor>) -> Option { let mut buf = vec![0u8; read_u32(cursor)? as usize]; cursor.read_exact(&mut buf).ok()?; String::from_utf8(buf).ok() } /// Reads a u32 from the cursor fn read_u32(cursor: &mut Cursor>) -> Option { ReadVarint::::read_varint(cursor).ok() } ================================================ FILE: crates/database/src/writer.rs ================================================ use std::{fs::OpenOptions, io::Write}; use varuint::WriteVarint; use ytpapi2::YoutubeMusicVideoRef; use crate::YTLocalDatabase; impl YTLocalDatabase { pub fn write(&self) { let db = self.references.read().unwrap(); let mut file = OpenOptions::new() .write(true) .append(false) .create(true) .truncate(true) .open(self.cache_dir.join("db.bin")) .unwrap(); for video in db.iter() { write_video(&mut file, video) } } } impl YTLocalDatabase { pub fn fix_db(&self) { let mut db = self.references.write().unwrap(); db.clear(); let cache_folder = self.cache_dir.join("downloads"); if !cache_folder.is_dir() { println!( "[WARN] The download folder in the cache wasn't found ({:?})", cache_folder ); return; } for entry in std::fs::read_dir(&cache_folder).unwrap() { let entry = entry.unwrap(); let path = entry.path(); // Check if the file is a json file (+ sloppy check if there is any files or directory) if path.extension().unwrap_or_default() != "json" { continue; } // Read the file if not readable do not add it to the database let content = match std::fs::read_to_string(&path) { Ok(content) => content, Err(e) => { match std::fs::remove_file(&path) { Ok(_) => println!( "[INFO] Removing file {:?} because the file is not readable: {e:?}", path.file_name() ), Err(ef) => println!( "[ERROR] file {:?} is not readable: {e:?}, but could not be deleted: {ef:?}", path.file_name() ), } continue; } }; // Check if the file is a valid json file let video = match serde_json::from_str::(&content) { Ok(parsed) => parsed, Err(e) => { match std::fs::remove_file(&path) { Ok(_) => println!( "[INFO] Removing file {:?} because the file is not a valid json file: {e:?}", path.file_name() ), Err(ef) => println!( "[ERROR] file {:?} is not a valid json file: {e:?}, but could not be deleted: {ef:?}", path.file_name() ), } continue; } }; // Check if the video file exists let video_file = cache_folder.join(format!("{}.mp4", video.video_id)); if !video_file.exists() { match std::fs::remove_file(&path) { Ok(_) => println!( "[INFO] Removing file {:?} because the video file does not exist", path.file_name() ), Err(ef) => println!( "[ERROR] video assocated to file {:?} does not exist, but the file could not be deleted: {ef:?}", path.file_name() ), } continue; } // Read the video file let video_file = match std::fs::read(&video_file) { Ok(video_file) => video_file, Err(e) => { match std::fs::remove_file(&path) { Ok(_) => println!( "[INFO] Removing file {:?} because the video file is not readable: {e:?}", path.file_name() ), Err(ef) => println!( "[ERROR] video associated to file {:?} is not readable: {e:?}, but the file could not be deleted: {ef:?}", path.file_name() ), } continue; } }; // Check if the video file contains the header if !video_file.starts_with(&[ 0, 0, 0, 24, 102, 116, 121, 112, 100, 97, 115, 104, 0, 0, 0, 0, ]) { match std::fs::remove_file(&path) { Ok(_) => println!( "[INFO] Removing file {:?} because the video file does not contain the header", path.file_name() ), Err(ef) => println!( "[ERROR] video associated to file {:?} does not contain the header, but the file could not be deleted: {ef:?}", path.file_name() ), } continue; } db.push(video); } } } /// Writes a video to a file pub fn write_video(buffer: &mut impl Write, video: &YoutubeMusicVideoRef) { write_str(buffer, &video.title); write_str(buffer, &video.author); write_str(buffer, &video.album); write_str(buffer, &video.video_id); write_str(buffer, &video.duration); } /// Writes a string from the cursor fn write_str(cursor: &mut impl Write, value: &str) { write_u32(cursor, value.len() as u32); cursor.write_all(value.as_bytes()).unwrap(); } /// Writes a u32 from the cursor fn write_u32(cursor: &mut impl Write, value: u32) { cursor.write_varint(value).unwrap(); } ================================================ FILE: crates/download-manager/Cargo.toml ================================================ [package] name = "download-manager" version = "0.1.0" edition = "2024" [dependencies] ytpapi2.workspace = true flume = "0.11.0" once_cell = "1.19.0" tokio = { version = "1.36.0", features = ["rt-multi-thread"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" database.workspace = true common-structs.workspace = true # --- YT Download --- rusty_ytdl = { git = "https://github.com/Mithronn/rusty_ytdl/", branch = "main", features = ["rustls", "search", "live"], default-features = false} log = "0.4.20" ================================================ FILE: crates/download-manager/src/lib.rs ================================================ mod task; use std::{ collections::{HashSet, VecDeque}, path::PathBuf, sync::{Arc, Mutex}, time::Duration, }; use database::YTLocalDatabase; use tokio::{select, task::JoinHandle, time::sleep}; use ytpapi2::YoutubeMusicVideoRef; use common_structs::MusicDownloadStatus; pub type MessageHandler = Arc; pub enum DownloadManagerMessage { VideoStatusUpdate(String, MusicDownloadStatus), } pub struct DownloadManager { database: &'static YTLocalDatabase, cache_dir: PathBuf, parallel_downloads: u16, handles: Mutex>>, download_list: Mutex>, in_download: Mutex>, } impl DownloadManager { pub fn new( cache_dir: PathBuf, database: &'static YTLocalDatabase, parallel_downloads: u16, ) -> Self { Self { database, cache_dir, parallel_downloads, handles: Mutex::new(Vec::new()), download_list: Mutex::new(VecDeque::new()), in_download: Mutex::new(HashSet::new()), } } pub fn remove_from_in_downloads(&self, video: &String) { self.in_download.lock().unwrap().remove(video); } fn take(&self) -> Option { self.download_list.lock().unwrap().pop_front() } /// This has to be called as a service stream /// HANDLES.lock().unwrap().push(run_service(async move { /// run_service_stream(sender); /// })); pub fn run_service_stream( &'static self, cancelation: impl Future + Clone + Send + 'static, sender: MessageHandler, ) { let fut = async move { loop { if let Some(id) = self.take() { self.start_download(id, sender.clone()).await; } else { sleep(Duration::from_millis(200)).await; } } }; let service = tokio::task::spawn(async move { select! { _ = fut => {}, _ = cancelation => {}, } }); self.handles.lock().unwrap().push(service); } pub fn spawn_system( &'static self, cancelation: impl Future + Clone + Send + 'static, sender: MessageHandler, ) { for _ in 0..self.parallel_downloads { self.run_service_stream(cancelation.clone(), sender.clone()); } } pub fn clean( &'static self, cancelation: impl Future + Clone + Send + 'static, sender: MessageHandler, ) { self.download_list.lock().unwrap().clear(); self.in_download.lock().unwrap().clear(); { let mut handle = self.handles.lock().unwrap(); for i in handle.iter() { i.abort() } handle.clear(); } self.spawn_system(cancelation, sender); } pub fn set_download_list(&self, to_add: impl IntoIterator) { let mut list = self.download_list.lock().unwrap(); list.clear(); list.extend(to_add); } pub fn add_to_download_list(&self, to_add: impl IntoIterator) { let mut list = self.download_list.lock().unwrap(); list.extend(to_add); } } ================================================ FILE: crates/download-manager/src/task.rs ================================================ use std::sync::Arc; use log::error; use rusty_ytdl::{ DownloadOptions, Video, VideoError, VideoOptions, VideoQuality, VideoSearchOptions, }; use tokio::select; use ytpapi2::YoutubeMusicVideoRef; use crate::{DownloadManager, DownloadManagerMessage, MessageHandler, MusicDownloadStatus}; fn new_video_with_id(id: &str) -> Result, VideoError> { let search_options = VideoSearchOptions::Custom(Arc::new(|format| { format.has_audio && !format.has_video && format.mime_type.container == "mp4" })); let video_options = VideoOptions { quality: VideoQuality::Custom( search_options.clone(), Arc::new(|x, y| x.audio_bitrate.cmp(&y.audio_bitrate)), ), filter: search_options, download_options: DownloadOptions { dl_chunk_size: Some(1024 * 100_u64), }, ..Default::default() }; Video::new_with_options(id, video_options) } pub async fn download>( video: &Video<'_>, path: P, sender: MessageHandler, ) -> Result<(), VideoError> { use std::io::Write; let stream = video.stream().await?; let length = stream.content_length(); let mut file = std::fs::File::create(&path).map_err(|e| VideoError::DownloadError(e.to_string()))?; let mut total = 0; while let Some(chunk) = stream.chunk().await? { total += chunk.len(); sender(DownloadManagerMessage::VideoStatusUpdate( video.get_video_id(), MusicDownloadStatus::Downloading((total as f64 / length as f64 * 100.0) as usize), )); file.write_all(&chunk) .map_err(|e| VideoError::DownloadError(e.to_string()))?; } file.flush() .map_err(|e| VideoError::DownloadError(e.to_string()))?; if total != length || length == 0 { std::fs::remove_file(path).map_err(|e| VideoError::DownloadError(e.to_string()))?; return Err(VideoError::DownloadError(format!( "Downloaded file is not the same size as the content length ({}/{})", total, length ))); } Ok(()) } impl DownloadManager { async fn handle_download(&self, id: &str, sender: MessageHandler) -> Result<(), VideoError> { let idc = id.to_string(); let video = new_video_with_id(id)?; sender(DownloadManagerMessage::VideoStatusUpdate( idc.clone(), MusicDownloadStatus::Downloading(0), )); let file = self.cache_dir.join("downloads").join(format!("{id}.mp4")); download(&video, file, sender.clone()).await?; sender(DownloadManagerMessage::VideoStatusUpdate( idc.clone(), MusicDownloadStatus::Downloading(100), )); Ok(()) } pub async fn start_download(&self, song: YoutubeMusicVideoRef, s: MessageHandler) -> bool { { let mut downloads = self.in_download.lock().unwrap(); if downloads.contains(&song.video_id) { return false; } downloads.insert(song.video_id.clone()); } s(DownloadManagerMessage::VideoStatusUpdate( song.video_id.clone(), MusicDownloadStatus::Downloading(1), )); let download_path_mp4 = self .cache_dir .join(format!("downloads/{}.mp4", &song.video_id)); let download_path_json = self .cache_dir .join(format!("downloads/{}.json", &song.video_id)); if download_path_json.exists() { s(DownloadManagerMessage::VideoStatusUpdate( song.video_id.clone(), MusicDownloadStatus::Downloaded, )); return true; } if download_path_mp4.exists() { std::fs::remove_file(&download_path_mp4).unwrap(); } match self.handle_download(&song.video_id, s.clone()).await { Ok(_) => { std::fs::write(download_path_json, serde_json::to_string(&song).unwrap()).unwrap(); self.database.append(song.clone()); s(DownloadManagerMessage::VideoStatusUpdate( song.video_id.clone(), MusicDownloadStatus::Downloaded, )); self.in_download.lock().unwrap().remove(&song.video_id); true } Err(e) => { if download_path_mp4.exists() { std::fs::remove_file(download_path_mp4).unwrap(); } s(DownloadManagerMessage::VideoStatusUpdate( song.video_id.clone(), MusicDownloadStatus::DownloadFailed, )); error!("Error downloading {}: {e}", song.video_id); false } } } pub fn start_task_unary( &'static self, s: MessageHandler, song: YoutubeMusicVideoRef, cancelation: impl Future + Send + 'static, ) { let fut = async move { self.start_download(song, s).await; }; let service = tokio::task::spawn(async move { select! { _ = fut => {}, _ = cancelation => {}, } }); self.handles.lock().unwrap().push(service); } } #[tokio::test] async fn video_download_test() { let ids = vec!["iFbNzVFgjCk", "ni-xbEK271I"]; //second not working, need checking for id in ids { let video = Video::new(id).unwrap(); let stream = video.stream().await.unwrap(); let content_length = stream.content_length(); let mut total = 0; while let Some(chunk) = stream.chunk().await.unwrap() { total += chunk.len(); } assert_eq!(total, content_length); } } ================================================ FILE: crates/player/Cargo.toml ================================================ [package] name = "player" version = "0.1.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] flume = "0.11.0" rodio = { version = "0.21.1", default-features = false, features = ["playback", "symphonia-aac", "symphonia-isomp4"] } log = "0.4.29" ================================================ FILE: crates/player/src/error.rs ================================================ // Custom Error Enum to handle different failures #[derive(Debug)] pub enum PlayError { Io(std::io::Error), DecoderError(rodio::decoder::DecoderError), StreamError(rodio::StreamError), PlayError(rodio::PlayError), SeekError(rodio::source::SeekError), } impl From for PlayError { fn from(err: rodio::PlayError) -> Self { PlayError::PlayError(err) } } ================================================ FILE: crates/player/src/lib.rs ================================================ mod error; use std::time::Duration; pub use error::PlayError; mod player; pub use player::Player; mod player_options; pub use player_options::PlayerOptions; mod player_data; pub(crate) use player_data::PlayerData; pub(crate) static VOLUME_STEP: u8 = 5; pub(crate) static SEEK_STEP: Duration = Duration::from_secs(5); ================================================ FILE: crates/player/src/player.rs ================================================ use flume::Sender; use rodio::cpal::traits::HostTrait; use rodio::{Decoder, OutputStream, OutputStreamBuilder, Sink, Source}; use std::fs::File; use std::path::Path; use std::time::Duration; use crate::{PlayError, PlayerData, PlayerOptions, SEEK_STEP}; pub struct Player { sink: Sink, stream: OutputStream, data: PlayerData, error_sender: Sender, options: PlayerOptions, } impl Player { fn try_from_device(device: rodio::cpal::Device) -> Result { // In rodio 0.21, try_from_device is available on OutputStream OutputStreamBuilder::default() .with_device(device) .open_stream() .map_err(PlayError::StreamError) } /// Try to create a stream from the default device, falling back to others fn try_default() -> Result { // Use rodio's internal cpal re-export let host = rodio::cpal::default_host(); let default_device = host .default_output_device() .ok_or(PlayError::StreamError(rodio::StreamError::NoDevice))?; Self::try_from_device(default_device).or_else(|original_err| { let devices = host.output_devices().map_err(|_| original_err)?; for d in devices { if let Ok(res) = Self::try_from_device(d) { return Ok(res); } } Err(PlayError::StreamError(rodio::StreamError::NoDevice)) }) } pub fn new(error_sender: Sender, options: PlayerOptions) -> Result { let stream = Self::try_default()?; // sink::try_new requires a reference to the handle let sink = Sink::connect_new(stream.mixer()); sink.set_volume(options.initial_volume_f32()); Ok(Self { sink, stream, error_sender, data: PlayerData::new(options.initial_volume()), options, }) } pub fn update(&self) -> Result { let stream = Self::try_default()?; let sink = Sink::connect_new(stream.mixer()); sink.set_volume(self.data.volume_f32()); Ok(Self { sink, stream, error_sender: self.error_sender.clone(), data: self.data.clone(), options: self.options.clone(), }) } pub fn change_volume(&mut self, positive: bool) { self.data.change_volume(positive); self.sink.set_volume(self.data.volume_f32()); } pub fn is_finished(&self) -> bool { self.sink.empty() } pub fn play_at(&mut self, path: &Path, time: Duration) -> Result<(), PlayError> { log::info!("Playing file: {:?} at time: {:?}", path, time); self.play(path)?; if let Err(e) = self.sink.try_seek(time) { log::error!("Seek error: {}", e); let _ = self.error_sender.send(PlayError::SeekError(e)); } Ok(()) } pub fn play(&mut self, path: &Path) -> Result<(), PlayError> { log::info!("Playing file: {:?}", path); self.data.set_current_file(Some(path.to_path_buf())); self.stop(); let file = File::open(path).map_err(PlayError::Io)?; if file.metadata().map(|m| m.len()).unwrap_or(0) == 0 { return Err(PlayError::Io(std::io::Error::new( std::io::ErrorKind::InvalidData, "File is empty", ))); } let decoder = Decoder::new(file).map_err(PlayError::DecoderError)?; self.data.set_total_duration(decoder.total_duration()); // Check if sink is detached or empty and recreate if necessary if self.sink.empty() { // Using try_new with the stored handle self.sink = Sink::connect_new(self.stream.mixer()); } self.sink.set_volume(self.data.volume_f32()); self.sink.append(decoder); Ok(()) } pub fn stop(&mut self) { // rodio 0.21: To stop, you can clear the sink. if !self.sink.empty() { self.sink.clear(); } } pub fn elapsed(&self) -> Duration { self.sink.get_pos() } pub fn duration(&self) -> Option { self.data .total_duration() .map(|duration| duration.as_secs_f64()) } pub fn toggle_playback(&mut self) { if self.sink.is_paused() { self.sink.play(); } else { self.sink.pause(); } } pub fn seek_fw(&mut self) { let current_elapsed = self.elapsed(); let new_pos = current_elapsed + SEEK_STEP; self.seek_to(new_pos); } pub fn seek_bw(&mut self) { let current_elapsed = self.elapsed(); let new_pos = current_elapsed.saturating_sub(SEEK_STEP); self.seek_to(new_pos); } pub fn seek_to(&mut self, time: Duration) { log::info!("Seek to: {:?}", time); if self.is_finished() { return; } let file = self.data.current_file().expect("Current file not set"); if let Err(e) = self.sink.try_seek(time) { log::error!("Seek error: {}", e); let _ = self.error_sender.send(PlayError::SeekError(e)); } else { // If the sink is finished, we need to reset the music // This happens when the user seeks to the start of the song before the buffer. if self.is_finished() { log::info!("Sink is finished while seeking, resetting the music"); if let Err(e) = self.play_at(&file, time) { log::error!("Error playing file: {:?}", e); let _ = self.error_sender.send(e); } } } } pub fn percentage(&self) -> f64 { self.duration().map_or(0.0, |duration| { let elapsed = self.elapsed().as_secs_f64(); elapsed / duration }) } pub fn volume_percent(&self) -> u8 { self.data.volume() } pub fn volume(&self) -> i32 { self.data.volume().into() } pub fn volume_up(&mut self) { let volume = self.volume() + 5; self.set_volume(volume); } pub fn volume_down(&mut self) { let volume = self.volume() - 5; self.set_volume(volume); } pub fn set_volume(&mut self, mut volume: i32) { volume = volume.clamp(0, 100); self.data.set_volume(volume as u8); self.sink.set_volume((volume as f32) / 100.0); } pub fn pause(&mut self) { if !self.sink.is_paused() { self.toggle_playback(); } } pub fn resume(&mut self) { if self.sink.is_paused() { self.toggle_playback(); } } pub fn is_paused(&self) -> bool { self.sink.is_paused() } pub fn seek(&mut self, secs: i64) { if secs.is_positive() { self.seek_fw(); } else { self.seek_bw(); } } pub fn get_progress(&self) -> (f64, u32, u32) { let position = self.elapsed(); let duration = self.duration().unwrap_or(99.0) as u32; let percent = self.percentage() * 100.0; (percent.min(100.0), position.as_secs() as u32, duration) } } ================================================ FILE: crates/player/src/player_data.rs ================================================ use std::{path::PathBuf, time::Duration}; use crate::VOLUME_STEP; #[derive(Clone)] pub struct PlayerData { total_duration: Option, current_file: Option, volume: u8, } impl PlayerData { pub fn new(volume: u8) -> Self { Self { total_duration: None, current_file: None, volume, } } /// Changes the volume by the volume step. If positive is true, the volume is increased, otherwise it is decreased. pub fn change_volume(&mut self, positive: bool) { if positive { self.set_volume(self.volume().saturating_add(VOLUME_STEP).min(100)); } else { self.set_volume(self.volume().saturating_sub(VOLUME_STEP)); } } /// Returns the volume as a f32 between 0.0 and 1.0 pub fn volume_f32(&self) -> f32 { f32::from(self.volume()) / 100.0 } /// Returns the volume as a u8 between 0 and 100 pub fn volume(&self) -> u8 { self.volume } /// Sets the volume to the given value pub fn set_volume(&mut self, volume: u8) { self.volume = volume; } /// Returns the total duration of the current file pub fn total_duration(&self) -> Option { self.total_duration } /// Sets the total duration of the current file pub fn set_total_duration(&mut self, total_duration: Option) { self.total_duration = total_duration; } /// Returns the current file pub fn current_file(&self) -> Option { self.current_file.clone() } /// Sets the current file pub fn set_current_file(&mut self, current_file: Option) { self.current_file = current_file; } } ================================================ FILE: crates/player/src/player_options.rs ================================================ #[derive(Debug, Clone)] pub struct PlayerOptions { initial_volume: u8, } impl PlayerOptions { /// Creates a new PlayerOptions with the given initial volume pub fn new(initial_volume: u8) -> Self { Self { initial_volume: initial_volume.min(100), } } /// Returns the initial volume as a u8 between 0 and 100 pub fn initial_volume(&self) -> u8 { self.initial_volume } /// Returns the initial volume as a f32 between 0.0 and 1.0 pub fn initial_volume_f32(&self) -> f32 { f32::from(self.initial_volume()) / 100.0 } } ================================================ FILE: crates/ytermusic/Cargo.toml ================================================ [package] edition = "2021" name = "ytermusic" version = "0.1.0" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] ytpapi2.workspace = true # --- Threading & Sync --- flume = "0.11.0" once_cell = "1.19.0" tokio = { version = "1.36.0", features = ["rt-multi-thread"] } # --- Encoding --- bincode = { version = "1.3.3" } directories = "5.0.1" rand = "0.8.5" serde = { version = "1.0.197", features = ["derive"] } serde_json = "1.0.114" urlencoding = "2.1.3" varuint = "0.7.1" # --- UI --- crossterm = "0.27.0" ratatui = { version = "0.26.1", features = ["serde"] } unicode-bidi = "0.3" # --- Player --- player.workspace = true # --- Media Control --- souvlaki = "0.7.3" # --- Alloc --- mimalloc = { version = "0.1.39", default-features = false } # --- Config --- toml = "0.8.11" # --- Logging --- log = "0.4.21" # --- Database --- database.workspace = true download-manager.workspace = true common-structs.workspace = true # -- Cookies auto retreival -- rookie = "0.5.2" ctrlc = "3.5.0" [target."cfg(target_os = \"windows\")".dependencies] raw-window-handle = "0.4.3" winit = "0.26.1" [target."cfg(target_os = \"macos\")".dependencies] winit = "0.26.1" [profile.release] codegen-units = 1 debug = true lto = true opt-level = 3 ================================================ FILE: crates/ytermusic/src/config.rs ================================================ use log::info; use ratatui::style::{Color, Modifier, Style}; use serde::{Deserialize, Serialize}; use crate::utils::get_project_dirs; #[derive(Debug, Default, Deserialize, Serialize)] #[non_exhaustive] pub struct GlobalConfig { /// Maximum number of parallel downloads. /// If your downloads are failing, try lowering /// this. /// Default value is 4. #[serde(default = "parallel_downloads")] pub parallel_downloads: u16, } #[derive(Debug, Deserialize, Serialize)] #[non_exhaustive] pub struct MusicPlayerConfig { /// Initial volume of the player, in percent. /// Default value is 50, clamped at 100. #[serde(default = "default_volume")] pub initial_volume: u8, #[serde(default = "default_true")] pub dbus: bool, #[serde(default = "default_true")] pub hide_channels_on_homepage: bool, #[serde(default = "default_false")] pub hide_albums_on_homepage: bool, #[serde(default = "enable_volume_slider")] pub volume_slider: bool, /// Whether to shuffle playlists before playing #[serde(default)] pub shuffle: bool, #[serde(default = "default_paused_style", with = "StyleDef")] pub gauge_paused_style: Style, #[serde(default = "default_playing_style", with = "StyleDef")] pub gauge_playing_style: Style, #[serde(default = "default_nomusic_style", with = "StyleDef")] pub gauge_nomusic_style: Style, #[serde(default = "default_paused_style", with = "StyleDef")] pub text_paused_style: Style, #[serde(default = "default_playing_style", with = "StyleDef")] pub text_playing_style: Style, #[serde(default = "default_nomusic_style", with = "StyleDef")] pub text_next_style: Style, #[serde(default = "default_nomusic_style", with = "StyleDef")] pub text_waiting_style: Style, #[serde(default = "default_downloading_style", with = "StyleDef")] pub text_downloading_style: Style, #[serde(default = "default_error_style", with = "StyleDef")] pub text_error_style: Style, #[serde(default = "default_searching_style", with = "StyleDef")] pub text_searching_style: Style, } #[derive(Debug, Deserialize, Serialize)] #[serde(remote = "Style")] struct StyleDef { #[serde(default)] fg: Option, #[serde(default)] bg: Option, #[serde(default = "Modifier::empty")] add_modifier: Modifier, #[serde(default = "Modifier::empty")] sub_modifier: Modifier, #[serde(default)] underline_color: Option, } impl Default for MusicPlayerConfig { fn default() -> Self { Self { hide_albums_on_homepage: default_false(), hide_channels_on_homepage: default_true(), dbus: default_true(), initial_volume: default_volume(), shuffle: Default::default(), gauge_paused_style: default_paused_style(), gauge_playing_style: default_playing_style(), gauge_nomusic_style: default_nomusic_style(), text_paused_style: default_paused_style(), text_playing_style: default_playing_style(), text_next_style: default_nomusic_style(), text_waiting_style: default_nomusic_style(), text_error_style: default_error_style(), text_searching_style: default_searching_style(), text_downloading_style: default_downloading_style(), volume_slider: enable_volume_slider(), } } } fn default_searching_style() -> Style { Style::default().fg(Color::LightCyan) } fn default_error_style() -> Style { Style::default().fg(Color::Red) } fn parallel_downloads() -> u16 { 4 } fn default_false() -> bool { false } fn default_true() -> bool { true } fn enable_volume_slider() -> bool { true } fn default_paused_style() -> Style { Style::default().fg(Color::Yellow) } fn default_playing_style() -> Style { Style::default().fg(Color::Green) } fn default_nomusic_style() -> Style { Style::default().fg(Color::White) } fn default_downloading_style() -> Style { Style::default().fg(Color::Blue) } fn default_volume() -> u8 { 50 } #[derive(Debug, Default, Deserialize, Serialize)] #[non_exhaustive] pub struct PlaylistConfig {} #[derive(Debug, Default, Deserialize, Serialize)] #[non_exhaustive] pub struct SearchConfig {} #[allow(unused)] #[derive(Debug, Default, Deserialize, Serialize)] #[non_exhaustive] pub struct Config { #[serde(default)] pub global: GlobalConfig, #[serde(default)] pub player: MusicPlayerConfig, #[serde(default)] pub playlist: PlaylistConfig, #[serde(default)] pub search: SearchConfig, } impl Config { pub fn new() -> Self { // TODO handle errors let opt = || { let project_dirs = get_project_dirs()?; let config_path = project_dirs.config_dir().join("config.toml"); config_path .parent() .map(|p| std::fs::create_dir_all(p).ok()); info!("Loading config from {:?}", config_path); if !config_path.exists() { let default_config = Self::default(); std::fs::write( project_dirs.config_dir().join("config.toml"), toml::to_string_pretty(&default_config).ok()?, ) .ok()?; return Some(default_config); } let config_string = std::fs::read_to_string(config_path).ok()?; let config = toml::from_str::(&config_string).ok()?; std::fs::write( project_dirs.config_dir().join("config.applied.toml"), toml::to_string_pretty(&config).ok()?, ) .ok()?; Some(config) }; opt().unwrap_or_default() } } ================================================ FILE: crates/ytermusic/src/consts.rs ================================================ use std::path::PathBuf; use log::warn; use once_cell::sync::Lazy; use crate::{config, utils::get_project_dirs}; pub const HEADER_TUTORIAL: &str = r#"To configure the YTerMusic: 1. Open the YouTube Music website in your browser 2. Open the developer tools (F12) 3. Go to the Network tab 4. Go to https://music.youtube.com 5. Copy the `cookie` header from the associated request 6. Paste it in the `headers.txt` file in format `Cookie: ` 7. On a newline of `headers.txt` add a user-agent in format `User-Agent: 8. Restart YterMusic"#; pub static CACHE_DIR: Lazy = Lazy::new(|| { let pdir = get_project_dirs(); if let Some(dir) = pdir { return dir.cache_dir().to_path_buf(); }; warn!("Failed to get cache dir! Defaulting to './data'"); PathBuf::from("./data") }); pub static CONFIG: Lazy = Lazy::new(config::Config::new); pub const INTRODUCTION: &str = r#"Usage: ytermusic [options] YTerMusic is a TUI based Youtube Music Player that aims to be as fast and simple as possible. In order to get your music, create a file "headers.txt" in the config folder, and copy the Cookie and User-Agent from request header of the music.youtube.com html document "/" page. More info at: https://github.com/ccgauche/ytermusic Options: -h or --help Show this menu --files Show the location of the ytermusic files --fix-db Fix the database in cache --clear-cache Erase all the files in cache Shortcuts: Use your mouse to click in lists if your terminal has mouse support Space play/pause Enter select a playlist or a music f search s shuffle r remove a music from the main playlist Arrow Right or > skip 5 seconds Arrow Left or < go back 5 seconds CTRL + Arrow Right (>) go to the next song CTRL + Arrow Left (<) go to the previous song + volume up - volume down Arrow down scroll down Arrow up scroll up ESC exit the current menu CTRL + C or CTRL + D quit "#; ================================================ FILE: crates/ytermusic/src/database.rs ================================================ use database::YTLocalDatabase; use once_cell::sync::Lazy; use crate::consts::CACHE_DIR; pub static DATABASE: Lazy = Lazy::new(|| YTLocalDatabase::new(CACHE_DIR.clone())); ================================================ FILE: crates/ytermusic/src/errors.rs ================================================ use flume::Sender; use crate::term::{ManagerMessage, Screens}; /// Utils to handle errors pub fn handle_error_option( updater: &Sender, error_type: &'static str, a: Result, ) -> Option where T: std::fmt::Debug, { match a { Ok(e) => Some(e), Err(a) => { updater .send(ManagerMessage::PassTo( Screens::DeviceLost, Box::new(ManagerMessage::Error( format!("{error_type} {a:?}"), Box::new(None), )), )) .unwrap(); None } } } /// Utils to handle errors pub fn handle_error(updater: &Sender, error_type: &'static str, a: Result<(), T>) where T: std::fmt::Debug, { let _ = handle_error_option(updater, error_type, a); } ================================================ FILE: crates/ytermusic/src/main.rs ================================================ use consts::{CACHE_DIR, INTRODUCTION}; use flume::{Receiver, Sender}; use log::{error, info}; use once_cell::sync::Lazy; use structures::performance::STARTUP_TIME; use term::{Manager, ManagerMessage}; use tokio::select; use std::{ future::Future, panic, path::{Path, PathBuf}, str::FromStr, sync::RwLock, }; use systems::{logger::init, player::player_system}; use crate::{ consts::HEADER_TUTORIAL, structures::{media::run_window_handler, sound_action::download_manager_handler}, systems::{logger::get_log_file_path, DOWNLOAD_MANAGER}, utils::get_project_dirs, }; mod config; mod consts; mod database; mod errors; mod shutdown; mod structures; mod systems; mod term; mod utils; pub use shutdown::{is_shutdown_sent, shutdown, ShutdownSignal}; mod tasks; pub use database::DATABASE; use mimalloc::MiMalloc; // Changes the allocator to improve performance especially on Windows #[global_allocator] static GLOBAL: MiMalloc = MiMalloc; fn run_service(future: T) -> tokio::task::JoinHandle<()> where T: Future + Send + 'static, { tokio::task::spawn(async move { select! { _ = future => {}, _ = ShutdownSignal => {}, } }) } static COOKIES: Lazy>> = Lazy::new(|| RwLock::new(None)); pub fn try_get_cookies() -> Option { let cookies = COOKIES.read().unwrap(); cookies.clone() } fn main() { // Check if the first param is --files if let Some(arg) = std::env::args().nth(1) { match arg.as_str() { "-h" | "--help" => { println!("{}", INTRODUCTION); return; } "--files" => { println!("# Location of ytermusic files"); println!(" - Logs: {}", get_log_file_path().display()); println!(" - Headers: {}", get_header_file().unwrap().1.display()); println!(" - Cache: {}", CACHE_DIR.display()); return; } "--fix-db" => { DATABASE.fix_db(); DATABASE.write(); println!("[INFO] Database fixed"); return; } "--clear-cache" => { match std::fs::remove_dir_all(&*CACHE_DIR) { Ok(_) => { println!("[INFO] Cache cleared"); } Err(e) => { println!("[ERROR] Can't clear cache: {e}"); } } return; } "--with-auto-cookies" => { std::fs::write(get_log_file_path(), "# YTerMusic log file\n\n").unwrap(); init().expect("Failed to initialize logger"); let param = std::env::args().nth(2); if let Some(cookies) = cookies(param) { let mut cookies_guard = COOKIES.write().unwrap(); info!("Cookies: {cookies}"); *cookies_guard = Some(cookies); info!("Cookies loaded"); } else { error!("Can't load cookies"); error!("Maybe rookie didn't find any cookies or any browser"); error!("Please make sure you have cookies in your browser"); return; } } e => { println!("Unknown argument `{e}`"); println!("Here are the available arguments:"); println!(" - --files: Show the location of the ytermusic files"); println!(" - --clear-cache: Erase all the files in cache"); println!(" - --fix-db: Fix the database"); return; } } } else { std::fs::write(get_log_file_path(), "# YTerMusic log file\n\n").unwrap(); init().expect("Failed to initialize logger"); } panic::set_hook(Box::new(|e| { println!("{e}"); error!("{e}"); shutdown(); })); app_start(); } fn cookies(specific_browser: Option) -> Option { let loaded = match specific_browser { Some(browser) => match browser.as_str() { "all" => rookie::load, "firefox" => rookie::firefox, "chrome" => rookie::chrome, "edge" => rookie::edge, "opera" => rookie::opera, "brave" => rookie::brave, "vivaldi" => rookie::vivaldi, "chromium" => rookie::chromium, #[cfg(target_os = "macos")] "safari" => rookie::safari, "arc" => rookie::arc, "librewolf" => rookie::librewolf, "opera-gx" | "opera_gx" => rookie::opera_gx, #[cfg(target_os = "windows")] "internet_explorer" | "internet-explorer" | "ie" => rookie::internet_explorer, #[cfg(target_os = "windows")] "octo_browser" | "octo-browser" => rookie::octo_browser, _ => { println!("Unknown browser `{browser}`"); error!("Unknown browser `{browser}`"); return None; } }, None => rookie::load, }(Some(vec!["youtube.com".to_string()])) .unwrap(); let mut cookies = Vec::new(); let current_timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs(); for cookie in loaded { if cookie.domain != ".youtube.com" && cookie.domain != "music.youtube.com" { continue; } if cookie .expires .map(|e| e < current_timestamp) .unwrap_or(false) { continue; } if cookies.iter().any(|(name, _)| name == &cookie.name) { continue; } cookies.push((cookie.name, cookie.value)); } let cookies = cookies .iter() .map(|(name, value)| format!("{name}={value}")) .collect::>(); let cookies = cookies.join("; "); Some(cookies) } fn get_header_file() -> Result<(String, PathBuf), (std::io::Error, PathBuf)> { let fp = PathBuf::from_str("headers.txt").unwrap(); if let Ok(e) = std::fs::read_to_string(&fp) { return Ok((e, fp)); } let fp = get_project_dirs() .ok_or_else(|| { ( std::io::Error::new( std::io::ErrorKind::NotFound, "Can't find project dir. This is a `directories` crate issue", ), Path::new("./").to_owned(), ) })? .config_dir() .to_owned(); if let Err(e) = std::fs::create_dir_all(&fp) { println!("Can't create app directory {e} in `{}`", fp.display()); } let fp = fp.join("headers.txt"); std::fs::read_to_string(&fp).map_or_else(|e| Err((e, fp.clone())), |e| Ok((e, fp.clone()))) } async fn app_start_main(updater_r: Receiver, updater_s: Sender) { STARTUP_TIME.log("Init"); std::fs::create_dir_all(CACHE_DIR.join("downloads")).unwrap(); if try_get_cookies().is_none() { if let Err((error, filepath)) = get_header_file() { println!("Can't read or find `{}`", filepath.display()); println!("Error: {error}"); println!("{HEADER_TUTORIAL}"); // prevent console window closing on windows, does nothing on linux std::io::stdin().read_line(&mut String::new()).unwrap(); return; } } STARTUP_TIME.log("Startup"); // Spawn the clean task tasks::clean::spawn_clean_task(); STARTUP_TIME.log("Spawned clean task"); // Spawn the player task let (sa, player) = player_system(updater_s.clone()); // Spawn the downloader system DOWNLOAD_MANAGER.spawn_system(ShutdownSignal, download_manager_handler(sa.clone())); STARTUP_TIME.log("Spawned system task"); tasks::last_playlist::spawn_last_playlist_task(updater_s.clone()); STARTUP_TIME.log("Spawned last playlist task"); // Spawn the API task tasks::api::spawn_api_task(updater_s.clone()); STARTUP_TIME.log("Spawned api task"); // Spawn the database getter task tasks::local_musics::spawn_local_musics_task(updater_s); STARTUP_TIME.log("Running manager"); let mut manager = Manager::new(sa, player).await; manager.run(&updater_r).unwrap(); } fn app_start() { let (updater_s, updater_r) = flume::unbounded::(); let updater_s_c = updater_s.clone(); ctrlc::set_handler(move || { info!("CTRL-C received"); shutdown() }) .expect("Error setting Ctrl-C handler"); std::thread::spawn(move || { tokio::runtime::Builder::new_multi_thread() .enable_all() .build() .expect("Failed to build runtime") .block_on(async move { select! { _ = app_start_main(updater_r, updater_s) => {}, _ = ShutdownSignal => {}, }; }); info!("Runtime closed"); }); run_window_handler(&updater_s_c); } ================================================ FILE: crates/ytermusic/src/shutdown.rs ================================================ use std::{ future::Future, pin::Pin, sync::atomic::{AtomicBool, Ordering}, task::{Context, Poll}, }; use log::info; use std::sync::{Condvar, Mutex}; pub struct SharedEvent { lock: Mutex, cvar: Condvar, } impl SharedEvent { // const fn allows this to be called in a static context pub const fn new() -> Self { Self { lock: Mutex::new(false), cvar: Condvar::new(), } } /// Blocks the current thread until notify() is called. pub fn wait(&self) { let mut ready = self.lock.lock().unwrap(); while !*ready { ready = self.cvar.wait(ready).unwrap(); } } /// Wakes up ALL waiting threads. pub fn notify(&self) { let mut ready = self.lock.lock().unwrap(); *ready = true; self.cvar.notify_all(); } } // Global static initialization static SHUTDOWN_WAKER: SharedEvent = SharedEvent::new(); #[allow(dead_code)] pub fn block_until_shutdown() { SHUTDOWN_WAKER.wait(); } static SHUTDOWN_SENT: AtomicBool = AtomicBool::new(false); pub fn is_shutdown_sent() -> bool { SHUTDOWN_SENT.load(Ordering::Relaxed) } #[derive(Clone)] pub struct ShutdownSignal; impl Future for ShutdownSignal { type Output = (); fn poll(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll { if SHUTDOWN_SENT.load(Ordering::Relaxed) { Poll::Ready(()) } else { Poll::Pending } } } pub fn shutdown() { SHUTDOWN_SENT.store(true, Ordering::Relaxed); SHUTDOWN_WAKER.notify(); info!("Shutdown signal sent, waiting for shutdown"); } ================================================ FILE: crates/ytermusic/src/structures/app_status.rs ================================================ use common_structs::{AppStatus, MusicDownloadStatus}; use ratatui::style::{Modifier, Style}; pub trait MusicDownloadStatusExt { fn style(&self, playing: Option) -> Style; } pub trait AppStatusExt { fn style(&self) -> Style; } use crate::consts::CONFIG; impl MusicDownloadStatusExt for MusicDownloadStatus { fn style(&self, playing: Option) -> Style { let k = match self { Self::NotDownloaded => CONFIG.player.text_waiting_style, Self::Downloaded => { if let Some(e) = playing { if e { CONFIG.player.text_playing_style } else { CONFIG.player.text_paused_style } } else { CONFIG.player.text_next_style } } Self::Downloading(_) => CONFIG.player.text_downloading_style, Self::DownloadFailed => CONFIG.player.text_error_style, }; if playing.is_some() { k.add_modifier(Modifier::BOLD) } else { k } } } impl AppStatusExt for AppStatus { fn style(&self) -> Style { match self { Self::Paused => CONFIG.player.gauge_paused_style, Self::Playing => CONFIG.player.gauge_playing_style, Self::NoMusic => CONFIG.player.gauge_nomusic_style, } } } ================================================ FILE: crates/ytermusic/src/structures/media.rs ================================================ use std::time::Duration; use flume::Sender; use log::{error, info}; use player::Player; use souvlaki::{ Error, MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, MediaPosition, SeekDirection, }; use ytpapi2::YoutubeMusicVideoRef; use crate::{consts::CONFIG, shutdown, term::ManagerMessage}; use super::sound_action::SoundAction; pub struct Media { controls: Option, current_meta: Option<(String, String, String, Option)>, current_playback: Option, } impl Media { pub fn new(updater: Sender, soundaction_sender: Sender) -> Self { if !CONFIG.player.dbus { info!("Media controls disabled by config"); return Self { controls: None, current_meta: None, current_playback: None, }; } let mut handle = get_handle(&updater); if let Some(e) = handle.as_mut() { if let Err(e) = connect(e, soundaction_sender) { error!("Media actions are not supported on this platform: {e:?}",); } } else { error!("Media controls are not supported on this platform"); } Self { controls: handle, current_meta: None, current_playback: None, } } pub fn update( &mut self, current: Option, sink: &Player, ) -> Result<(), souvlaki::Error> { if let Some(e) = &mut self.controls { let media_meta = MediaMetadata { title: current.as_ref().map(|video| video.title.as_str()), album: current.as_ref().map(|video| video.album.as_str()), artist: current.as_ref().map(|video| video.author.as_str()), cover_url: None, duration: sink .duration() .map(|duration| Duration::from_secs(duration as u64)), }; if self.current_meta != Some(( media_meta.title.unwrap_or("").to_string(), media_meta.album.unwrap_or("").to_string(), media_meta.artist.unwrap_or("").to_string(), sink.duration() .map(|duration| Duration::from_secs(duration as u64)), )) { self.current_meta = Some(( media_meta.title.unwrap_or("").to_string(), media_meta.album.unwrap_or("").to_string(), media_meta.artist.unwrap_or("").to_string(), sink.duration() .map(|duration| Duration::from_secs(duration as u64)), )); e.set_metadata(media_meta)?; } let playback = if sink.is_finished() { MediaPlayback::Stopped } else if sink.is_paused() { MediaPlayback::Paused { progress: Some(MediaPosition(sink.elapsed())), } } else { MediaPlayback::Playing { progress: Some(MediaPosition(sink.elapsed())), } }; if self.current_playback != Some(playback.clone()) { self.current_playback = Some(playback.clone()); e.set_playback(playback)?; } } Ok(()) } } fn connect(mpris: &mut MediaControls, sender: Sender) -> Result<(), Error> { mpris.attach(move |e| match e { MediaControlEvent::Toggle | MediaControlEvent::Play | MediaControlEvent::Pause => { sender.send(SoundAction::PlayPause).unwrap(); } MediaControlEvent::Next => { sender.send(SoundAction::Next(1)).unwrap(); } MediaControlEvent::Previous => { sender.send(SoundAction::Previous(1)).unwrap(); } MediaControlEvent::Stop => { sender.send(SoundAction::Cleanup).unwrap(); } MediaControlEvent::Seek(a) => match a { souvlaki::SeekDirection::Forward => { sender.send(SoundAction::Forward).unwrap(); } souvlaki::SeekDirection::Backward => { sender.send(SoundAction::Backward).unwrap(); } }, // TODO(functionnality): implement seek amount MediaControlEvent::SeekBy(a, _b) => { if a == SeekDirection::Forward { sender.send(SoundAction::Forward).unwrap(); } else { sender.send(SoundAction::Backward).unwrap(); } } MediaControlEvent::SetPosition(a) => { sender.send(SoundAction::SeekTo(a.0)).unwrap(); } MediaControlEvent::OpenUri(a) => { todo!("Implement URI opening {a:?}") } MediaControlEvent::Raise => { todo!("Implement raise") } MediaControlEvent::Quit => { shutdown(); } MediaControlEvent::SetVolume(e) => { sender.send(SoundAction::SetVolume(e as f32)).unwrap(); } }) } #[cfg(not(target_os = "windows"))] fn get_handle(updater: &Sender) -> Option { use crate::errors::handle_error_option; use souvlaki::PlatformConfig; handle_error_option( updater, "Can't create media controls", MediaControls::new(PlatformConfig { dbus_name: "ytermusic", display_name: "YTerMusic", hwnd: None, }) .map_err(|e| format!("{e:?}")), ) } #[cfg(not(target_os = "macos"))] pub fn run_window_handler(_updater: &Sender) -> Option<()> { use crate::is_shutdown_sent; loop { if !is_shutdown_sent() { crate::shutdown::block_until_shutdown() } else { use std::process::exit; info!("event loop closed"); exit(0); } } } #[cfg(target_os = "macos")] pub fn run_window_handler(updater: &Sender) -> Option<()> { use std::process::exit; use winit::event_loop::EventLoop; use winit::platform::macos::{ActivationPolicy, EventLoopExtMacOS}; use winit::window::WindowBuilder; use crate::errors::handle_error_option; let thread = std::thread::current(); info!("Current Thread Name: {:?}", thread.name()); info!("Current Thread ID: {:?}", thread.id()); // On macOS, winit requires the EventLoop to be created on the main thread. // Unlike Windows, we cannot use `new_any_thread`. // We create a hidden window to ensure NSApplication is active and capable of receiving events. let mut event_loop = EventLoop::new(); event_loop.set_activation_policy(ActivationPolicy::Regular); // Create a hidden window. While souvlaki doesn't need the handle in the config, // the existence of the window helps keep the event loop and application state valid. let _window = handle_error_option( updater, "OS Error while creating media hook window", WindowBuilder::new().with_visible(false).build(&event_loop), )?; event_loop.run(move |_event, _window_target, ctrl_flow| { use crate::is_shutdown_sent; if is_shutdown_sent() { info!("event loop closed"); *ctrl_flow = winit::event_loop::ControlFlow::Exit; exit(0); } }); } #[cfg(target_os = "windows")] fn get_handle(updater: &Sender) -> Option { use raw_window_handle::{HasRawWindowHandle, RawWindowHandle}; use souvlaki::PlatformConfig; use winit::event_loop::EventLoop; use winit::{platform::windows::EventLoopExtWindows, window::WindowBuilder}; use crate::errors::handle_error_option; use crate::term::Screens; let config = PlatformConfig { dbus_name: "ytermusic", display_name: "YTerMusic", hwnd: if let RawWindowHandle::Win32(h) = handle_error_option( updater, "OS Error while creating media hook window", WindowBuilder::new() .with_visible(false) .build(&EventLoop::<()>::new_any_thread()), )? .raw_window_handle() { Some(h.hwnd) } else { updater .send(ManagerMessage::PassTo( Screens::DeviceLost, Box::new(ManagerMessage::Error( "No window handle found".to_string(), Box::new(None), )), )) .unwrap(); return None; }, }; handle_error_option( updater, "Can't create media controls", MediaControls::new(config).map_err(|x| format!("{:?}", x)), ) } ================================================ FILE: crates/ytermusic/src/structures/mod.rs ================================================ pub mod app_status; pub mod media; pub mod performance; pub mod sound_action; ================================================ FILE: crates/ytermusic/src/structures/performance.rs ================================================ use std::time::Instant; use log::info; use once_cell::sync::Lazy; pub struct Performance { pub initial: Instant, } impl Performance { pub fn new() -> Self { Self { initial: Instant::now(), } } pub fn get_ms(&self) -> u128 { self.initial.elapsed().as_millis() } pub fn log(&self, message: &str) { info!(target: "performance", "{}: {}ms", message, self.get_ms()); } } pub fn guard<'a>(name: &'a str) -> PerformanceGuard<'a> { PerformanceGuard::new(name) } pub struct PerformanceGuard<'a> { name: &'a str, start: Performance, } impl<'a> PerformanceGuard<'a> { pub fn new(name: &'a str) -> Self { Self { name, start: Performance::new(), } } } impl<'a> Drop for PerformanceGuard<'a> { fn drop(&mut self) { self.start.log(self.name); } } #[allow(dead_code)] pub fn mesure(name: &str, f: impl FnOnce() -> T) -> T { let start = Instant::now(); let t = f(); let end = Instant::now(); info!(target: "performance", "{}: {}ms", name, end.duration_since(start).as_millis() ); t } pub static STARTUP_TIME: Lazy = Lazy::new(Performance::new); ================================================ FILE: crates/ytermusic/src/structures/sound_action.rs ================================================ use common_structs::MusicDownloadStatus; use download_manager::{DownloadManagerMessage, MessageHandler}; use flume::Sender; use log::{error, trace}; use std::{fs, sync::Arc, time::Duration}; use ytpapi2::YoutubeMusicVideoRef; use crate::{ consts::CACHE_DIR, errors::handle_error_option, systems::{player::PlayerState, DOWNLOAD_MANAGER}, ShutdownSignal, DATABASE, }; /// Actions that can be sent to the player from other services #[derive(Debug, Clone)] pub enum SoundAction { /// Set the volume of the player to the given value SetVolume(f32), Cleanup, PlayPause, RestartPlayer, Plus, Minus, /// Seek to a specific time in the current song in seconds SeekTo(Duration), Previous(usize), Forward, Backward, Next(usize), AddVideosToQueue(Vec), AddVideoUnary(YoutubeMusicVideoRef), DeleteVideoUnary, ReplaceQueue(Vec), VideoStatusUpdate(String, MusicDownloadStatus), } impl SoundAction { fn insert(player: &mut PlayerState, video: String, status: MusicDownloadStatus) { if matches!( player.music_status.get(&video), Some(&MusicDownloadStatus::DownloadFailed) ) { DOWNLOAD_MANAGER.remove_from_in_downloads(&video); } if matches!( player.music_status.get(&video), Some(&MusicDownloadStatus::Downloading(_) | &MusicDownloadStatus::Downloaded) ) && status == MusicDownloadStatus::NotDownloaded { return; } player.music_status.insert(video, status); } pub fn apply_sound_action(self, player: &mut PlayerState) { match self { Self::SetVolume(volume) => player.sink.set_volume((volume * 100.) as i32), Self::SeekTo(time) => player.sink.seek_to(time), Self::Backward => player.sink.seek_bw(), Self::Forward => player.sink.seek_fw(), Self::PlayPause => player.sink.toggle_playback(), Self::Cleanup => { player.list.clear(); player.current = 0; player.music_status.clear(); player.sink.stop(); } Self::Plus => player.sink.volume_up(), Self::Minus => player.sink.volume_down(), Self::Next(a) => { player.sink.stop(); player.set_relative_current(a as _); } Self::VideoStatusUpdate(video, status) => { player.music_status.insert(video, status); } Self::AddVideosToQueue(video) => { let db = DATABASE.read().unwrap(); for v in video { Self::insert( player, v.video_id.clone(), if db.iter().any(|e| e.video_id == v.video_id) { MusicDownloadStatus::Downloaded } else { MusicDownloadStatus::NotDownloaded }, ); player.list.push(v) } } Self::Previous(a) => { player.set_relative_current(-(a as isize)); player.sink.stop(); } Self::RestartPlayer => { player.sink = handle_error_option(&player.updater, "update player", player.sink.update()) .unwrap(); if let Some(e) = player.current().cloned() { Self::AddVideoUnary(e).apply_sound_action(player); } } Self::AddVideoUnary(video) => { Self::insert( player, video.video_id.clone(), if DATABASE .read() .unwrap() .iter() .any(|e| e.video_id == video.video_id) { MusicDownloadStatus::Downloaded } else { MusicDownloadStatus::NotDownloaded }, ); if player.list.is_empty() { player.list.push(video); } else { player.list.insert(player.current + 1, video); } } Self::DeleteVideoUnary => { let index_list = player.list_selector.get_relative_position(); let video = player.relative_current(index_list).cloned().unwrap(); if matches!( player.music_status.get(&video.video_id), // not sure abt conditions, needs testing Some( &MusicDownloadStatus::DownloadFailed | &MusicDownloadStatus::Downloading(_) | &MusicDownloadStatus::NotDownloaded ) ) { DOWNLOAD_MANAGER.remove_from_in_downloads(&video.video_id); } player.music_status.remove(&video.video_id); // maybe not necessary to do it //manage deleting in the list player.list.retain(|vid| *vid != video); player.list_selector.list_size -= 1; if index_list < 0 { player.set_relative_current(-1); } if index_list == 0 { Self::Next(0).apply_sound_action(player); } // manage deleting physically DATABASE.remove_video(&video); let cache_folder = CACHE_DIR.join("downloads"); let json_path = cache_folder.join(format!("{}.json", &video.video_id)); match fs::remove_file(json_path) { Ok(_) => trace!("Deleted JSON file"), Err(e) => error!("Error deleting JSON video file: {}", e), } let mp4_path = cache_folder.join(format!("{}.mp4", &video.video_id)); match fs::remove_file(mp4_path) { Ok(_) => trace!("Deleted MP4 file"), Err(e) => error!("Error deleting MP4 video file: {}", e), } } Self::ReplaceQueue(videos) => { player.list.truncate(player.current + 1); DOWNLOAD_MANAGER.clean( ShutdownSignal, download_manager_handler(player.soundaction_sender.clone()), ); Self::AddVideosToQueue(videos).apply_sound_action(player); Self::Next(1).apply_sound_action(player); } } } } pub fn download_manager_handler(sender: Sender) -> MessageHandler { Arc::new(move |message| match message { DownloadManagerMessage::VideoStatusUpdate(video, status) => { sender .send(SoundAction::VideoStatusUpdate(video, status)) .unwrap(); } }) } ================================================ FILE: crates/ytermusic/src/systems/logger.rs ================================================ use std::{io::Write, path::PathBuf}; use flume::Sender; use once_cell::sync::Lazy; static LOG: Lazy> = Lazy::new(|| { let (tx, rx) = flume::unbounded::(); std::thread::spawn(move || { let mut buffer = String::new(); let filepath = get_log_file_path(); let mut file = std::fs::OpenOptions::new() .create(true) .append(true) .open(filepath) .unwrap(); while let Ok(e) = rx.recv() { buffer.clear(); buffer.push_str(&(e + "\n")); while let Ok(e) = rx.try_recv() { buffer.push_str(&(e + "\n")); } file.write_all(buffer.as_bytes()).unwrap(); } }); tx }); pub fn get_log_file_path() -> PathBuf { if let Some(val) = get_project_dirs() { if let Err(e) = std::fs::create_dir_all(val.cache_dir()) { panic!("Failed to create cache dir: {}", e); } val.cache_dir().join("log.txt") } else { PathBuf::from("log.txt") } } static LOGGER: SimpleLogger = SimpleLogger; static LEVEL: Lazy<(LevelFilter, Level)> = Lazy::new(|| { let logger_env = std::env::var("YTERMUSIC_LOG"); if let Ok(logger_env) = logger_env { if logger_env == "true" { (LevelFilter::Trace, Level::Trace) } else { (LevelFilter::Info, Level::Info) } } else { (LevelFilter::Info, Level::Info) } }); pub fn init() -> Result<(), SetLoggerError> { log::set_logger(&LOGGER).map(|()| log::set_max_level(LEVEL.0))?; info!("Logger mode {}", LEVEL.1); Ok(()) } use log::{info, Level, LevelFilter, Metadata, Record, SetLoggerError}; use crate::utils::get_project_dirs; static FILTER: &[&str] = &["rustls", "tokio-util", "want-", "mio-"]; struct SimpleLogger; impl log::Log for SimpleLogger { fn enabled(&self, metadata: &Metadata) -> bool { metadata.level() <= LEVEL.1 } fn log(&self, record: &Record) { if self.enabled(record.metadata()) { if FILTER.iter().any(|x| record.file().unwrap().contains(x)) { return; } LOG.send(format!( "{} - {} [{}]", record.level(), record.args(), record.file().unwrap_or_default() )) .unwrap(); } } fn flush(&self) {} } ================================================ FILE: crates/ytermusic/src/systems/mod.rs ================================================ use download_manager::DownloadManager; use once_cell::sync::Lazy; use crate::{ consts::{CACHE_DIR, CONFIG}, DATABASE, }; pub mod logger; pub mod player; pub static DOWNLOAD_MANAGER: Lazy = Lazy::new(|| { DownloadManager::new( CACHE_DIR.to_path_buf(), &DATABASE, CONFIG.global.parallel_downloads, ) }); ================================================ FILE: crates/ytermusic/src/systems/player.rs ================================================ use std::{ collections::{HashMap, VecDeque}, sync::atomic::Ordering, }; use common_structs::MusicDownloadStatus; use flume::{unbounded, Receiver, Sender}; use log::error; use player::{PlayError, Player, PlayerOptions}; use ytpapi2::YoutubeMusicVideoRef; use crate::{ consts::{CACHE_DIR, CONFIG}, errors::{handle_error, handle_error_option}, structures::{media::Media, sound_action::SoundAction}, systems::DOWNLOAD_MANAGER, term::{list_selector::ListSelector, playlist::PLAYER_RUNNING, ManagerMessage, Screens}, DATABASE, }; pub struct PlayerState { pub goto: Screens, pub list: Vec, pub current: usize, pub rtcurrent: Option, pub music_status: HashMap, pub list_selector: ListSelector, pub controls: Media, pub sink: Player, pub updater: Sender, pub soundaction_sender: Sender, pub soundaction_receiver: Receiver, pub stream_error_receiver: Receiver, } impl PlayerState { fn new( soundaction_sender: Sender, soundaction_receiver: Receiver, updater: Sender, ) -> Self { let (stream_error_sender, stream_error_receiver) = unbounded::(); let sink = handle_error_option( &updater, "player creation error", Player::new( stream_error_sender, PlayerOptions::new(CONFIG.player.initial_volume), ), ) .unwrap(); Self { controls: Media::new(updater.clone(), soundaction_sender.clone()), soundaction_receiver, list_selector: ListSelector::default(), music_status: HashMap::new(), updater, stream_error_receiver, soundaction_sender, sink, goto: Screens::Playlist, list: Vec::new(), current: 0, rtcurrent: None, } } pub fn current(&self) -> Option<&YoutubeMusicVideoRef> { self.relative_current(0) } pub fn relative_current(&self, n: isize) -> Option<&YoutubeMusicVideoRef> { self.list.get(self.current.saturating_add_signed(n)) } pub fn set_relative_current(&mut self, n: isize) { self.current = self.current.saturating_add_signed(n); } pub fn is_current_download_failed(&self) -> bool { self.current() .as_ref() .map(|x| { self.music_status.get(&x.video_id) == Some(&MusicDownloadStatus::DownloadFailed) }) .unwrap_or(false) } pub fn is_current_downloaded(&self) -> bool { self.current() .as_ref() .map(|x| self.music_status.get(&x.video_id) == Some(&MusicDownloadStatus::Downloaded)) .unwrap_or(false) } pub fn update(&mut self) { PLAYER_RUNNING.store(self.current().is_some(), Ordering::SeqCst); self.update_controls(); self.handle_stream_errors(); if self.current > self.list.len() { self.current = self.list.len(); } while let Ok(e) = self.soundaction_receiver.try_recv() { e.apply_sound_action(self); } if self.is_current_download_failed() { SoundAction::Next(1).apply_sound_action(self); } if self.sink.is_finished() { if self.is_current_downloaded() && self.rtcurrent.as_ref() == self.current() { self.set_relative_current(1); } self.handle_stream_errors(); self.update_controls(); // If the current song is finished, we play the next one but if the next one has failed to download, we skip it // TODO(optimize this) while self .current() .map(|x| { self.music_status.get(&x.video_id) == Some(&MusicDownloadStatus::DownloadFailed) }) .unwrap_or(false) { self.set_relative_current(1); } if self.is_current_downloaded() { if let Some(video) = self.current().cloned() { let k = CACHE_DIR.join(format!("downloads/{}.mp4", &video.video_id)); if let Err(e) = self.sink.play(k.as_path()) { if matches!(e, PlayError::DecoderError(_)) { // Cleaning the file DATABASE.remove_video(&video); handle_error( &self.updater, "invalid cleaning MP4", std::fs::remove_file(k), ); handle_error( &self.updater, "invalid cleaning JSON", std::fs::remove_file( CACHE_DIR.join(format!("downloads/{}.json", &video.video_id)), ), ); self.current = 0; DATABASE.write(); } else { self.updater .send(ManagerMessage::PassTo( Screens::DeviceLost, Box::new(ManagerMessage::Error( format!("{e:?}"), Box::new(None), )), )) .unwrap(); } } } } } else { self.rtcurrent = self.current().cloned(); } let to_download = self .list .iter() .skip(self.current) .chain(self.list.iter().take(self.current).rev()) .filter(|x| { self.music_status.get(&x.video_id) == Some(&MusicDownloadStatus::NotDownloaded) }) .take(12) .cloned() .collect::>(); DOWNLOAD_MANAGER.set_download_list(to_download); } fn handle_stream_errors(&self) { while let Ok(e) = self.stream_error_receiver.try_recv() { error!("Stream error: {:?}", e); handle_error(&self.updater, "audio device stream error", Err(e)); } } fn update_controls(&mut self) { let current = self.current().cloned(); let result = self .controls .update(current, &self.sink) .map_err(|x| format!("{x:?}")); handle_error::(&self.updater, "Can't update finished media control", result); } } pub fn player_system(updater: Sender) -> (Sender, PlayerState) { let (tx, rx) = flume::unbounded::(); (tx.clone(), PlayerState::new(tx, rx, updater)) } ================================================ FILE: crates/ytermusic/src/tasks/api.rs ================================================ use std::sync::{Arc, Mutex}; use flume::Sender; use log::{error, info}; use once_cell::sync::Lazy; use tokio::task::JoinSet; use ytpapi2::{Endpoint, YoutubeMusicInstance, YoutubeMusicPlaylistRef}; use crate::{ consts::CONFIG, get_header_file, run_service, structures::performance, term::{ManagerMessage, Screens}, }; pub fn get_text_cookies_expired_or_invalid() -> String { let (Ok((_, path)) | Err((_, path))) = get_header_file(); format!( "The `{}` file is not configured correctly. \nThe cookies are expired or invalid.", path.display() ) } pub fn spawn_api_task(updater_s: Sender) { run_service(async move { info!("API task on"); let guard = performance::guard("API task"); let client = YoutubeMusicInstance::from_header_file(get_header_file().unwrap().1.as_path()).await; match client { Ok(api) => { let api = Arc::new(api); let mut set = JoinSet::new(); let api_ = api.clone(); let updater_s_ = updater_s.clone(); set.spawn(async move { let search_results = api_.get_home(2).await; match search_results { Ok(e) => { for playlist in e.playlists { spawn_browse_playlist_task( playlist.clone(), api_.clone(), updater_s_.clone(), ) } } Err(e) => { error!("get_home {e:?}") } } }); let api_ = api.clone(); let updater_s_ = updater_s.clone(); set.spawn(async move { let search_results = api_.get_library(&Endpoint::MusicLikedPlaylists, 2).await; match search_results { Ok(e) => { for playlist in e { spawn_browse_playlist_task( playlist.clone(), api_.clone(), updater_s_.clone(), ) } } Err(e) => { error!("MusicLikedPlaylists -> {e:?}"); } } }); let api_ = api.clone(); let updater_s_ = updater_s.clone(); set.spawn(async move { let search_results = api_.get_library(&Endpoint::MusicLibraryLanding, 2).await; match search_results { Ok(e) => { for playlist in e { spawn_browse_playlist_task( playlist.clone(), api_.clone(), updater_s_.clone(), ) } } Err(e) => { error!("MusicLibraryLanding -> {e:?}"); } } }); while let Some(e) = set.join_next().await { e.unwrap(); } } Err(e) => match &e { ytpapi2::YoutubeMusicError::NoCookieAttribute | ytpapi2::YoutubeMusicError::NoSapsidInCookie | ytpapi2::YoutubeMusicError::InvalidCookie(_) | ytpapi2::YoutubeMusicError::NeedToLogin | ytpapi2::YoutubeMusicError::CantFindInnerTubeApiKey(_) | ytpapi2::YoutubeMusicError::CantFindInnerTubeClientVersion(_) | ytpapi2::YoutubeMusicError::CantFindVisitorData(_) | ytpapi2::YoutubeMusicError::IoError(_) => { error!("{}", get_text_cookies_expired_or_invalid()); error!("{e:?}"); updater_s .send( ManagerMessage::Error( get_text_cookies_expired_or_invalid(), Box::new(Some(ManagerMessage::Quit)), ) .pass_to(Screens::DeviceLost), ) .unwrap(); } e => { error!("{e:?}"); } }, } drop(guard); }); } static BROWSED_PLAYLISTS: Lazy>> = Lazy::new(|| Mutex::new(vec![])); fn spawn_browse_playlist_task( playlist: YoutubeMusicPlaylistRef, api: Arc, updater_s: Sender, ) { if playlist.browse_id.starts_with("UC") && CONFIG.player.hide_channels_on_homepage { log::info!( "Skipping channel (CONFIG) {} {}", playlist.name, playlist.browse_id ); return; } if playlist.browse_id.starts_with("MPREb_") && CONFIG.player.hide_albums_on_homepage { log::info!( "Skipping album (CONFIG) {} {}", playlist.name, playlist.browse_id ); return; } { let mut k = BROWSED_PLAYLISTS.lock().unwrap(); if k.iter() .any(|(name, id)| name == &playlist.name && id == &playlist.browse_id) { return; } k.push((playlist.name.clone(), playlist.browse_id.clone())); } run_service(async move { let guard = format!("Browse playlist {} {}", playlist.name, playlist.browse_id); let guard = performance::guard(&guard); match api.get_playlist(&playlist, 5).await { Ok(videos) => { if videos.len() < 2 { info!("Playlist {} is too small so skipped", playlist.name); return; } let _ = updater_s.send( ManagerMessage::AddElementToChooser(( format!("{} ({})", playlist.name, playlist.subtitle), videos, )) .pass_to(Screens::Playlist), ); } Err(e) => { error!("{e:?}"); } } drop(guard); }); } ================================================ FILE: crates/ytermusic/src/tasks/clean.rs ================================================ use crate::{consts::CACHE_DIR, run_service, structures::performance}; /// This function is called on start to clean the database and the files /// that are incompletely downloaded due to a crash. pub fn spawn_clean_task() { run_service(async move { let guard = performance::guard("Clean task"); for i in std::fs::read_dir(CACHE_DIR.join("downloads")).unwrap() { let path = i.unwrap().path(); if path.extension().unwrap_or_default() == "mp4" { let mut path1 = path.clone(); path1.set_extension("json"); if !path1.exists() { std::fs::remove_file(&path).unwrap(); } } } drop(guard); }); } ================================================ FILE: crates/ytermusic/src/tasks/last_playlist.rs ================================================ use flume::Sender; use log::info; use ytpapi2::YoutubeMusicVideoRef; use crate::{ consts::CACHE_DIR, run_service, structures::performance, term::{ManagerMessage, Screens}, }; pub fn spawn_last_playlist_task(updater_s: Sender) { run_service(async move { let guard = performance::guard("Last playlist"); info!("Last playlist task on"); let playlist = std::fs::read_to_string(CACHE_DIR.join("last-playlist.json")).ok()?; let mut playlist: (String, Vec) = serde_json::from_str(&playlist).ok()?; if !playlist.0.starts_with("Last playlist: ") { playlist.0 = format!("Last playlist: {}", playlist.0); } updater_s .send(ManagerMessage::AddElementToChooser(playlist).pass_to(Screens::Playlist)) .unwrap(); drop(guard); Some(()) }); } ================================================ FILE: crates/ytermusic/src/tasks/local_musics.rs ================================================ use flume::Sender; use log::info; use rand::seq::SliceRandom; use ytpapi2::YoutubeMusicVideoRef; use crate::{ consts::{CACHE_DIR, CONFIG}, run_service, structures::performance, term::{ManagerMessage, Screens}, DATABASE, }; pub fn spawn_local_musics_task(updater_s: Sender) { run_service(async move { info!("Database getter task on"); let guard = performance::guard("Local musics"); if let Some(videos) = DATABASE.read() { shuffle_and_send(videos, &updater_s); } else { let mut videos = Vec::new(); for files in std::fs::read_dir(CACHE_DIR.join("downloads")).unwrap() { let path = files.unwrap().path(); if path.as_os_str().to_string_lossy().ends_with(".json") { let video = serde_json::from_str(std::fs::read_to_string(path).unwrap().as_str()) .unwrap(); videos.push(video); } } shuffle_and_send(videos, &updater_s); DATABASE.write(); } drop(guard); }); } fn shuffle_and_send(mut videos: Vec, updater_s: &Sender) { DATABASE.clone_from(&videos); if CONFIG.player.shuffle { videos.shuffle(&mut rand::thread_rng()); } updater_s .send( ManagerMessage::AddElementToChooser(("Local musics".to_owned(), videos)) .pass_to(Screens::Playlist), ) .unwrap(); } ================================================ FILE: crates/ytermusic/src/tasks/mod.rs ================================================ pub mod api; pub mod clean; pub mod last_playlist; pub mod local_musics; ================================================ FILE: crates/ytermusic/src/term/device_lost.rs ================================================ use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ layout::{Alignment, Rect}, widgets::{Block, BorderType, Borders, Paragraph}, Frame, }; use crate::consts::CONFIG; use super::{EventResponse, ManagerMessage, Screen, Screens}; // Audio device not connected! pub struct DeviceLost(pub Vec, pub Option); impl Screen for DeviceLost { fn on_mouse_press(&mut self, _: crossterm::event::MouseEvent, _: &Rect) -> EventResponse { EventResponse::None } fn on_key_press(&mut self, key: KeyEvent, _: &Rect) -> EventResponse { match key.code { KeyCode::Enter | KeyCode::Char(' ') => { if let Some(m) = self.1.take() { EventResponse::Message(vec![m]) } else { ManagerMessage::RestartPlayer .pass_to(Screens::MusicPlayer) .event() } } KeyCode::Esc => ManagerMessage::Quit.event(), _ => EventResponse::None, } } fn render(&mut self, frame: &mut Frame) { frame.render_widget( Paragraph::new(format!( "{}\nPress [Enter] or [Space] to retry.\nOr [Esc] to exit", self.0.join("\n") )) .style(CONFIG.player.text_error_style) .alignment(Alignment::Center) .block( Block::default() .borders(Borders::ALL) .style(CONFIG.player.text_next_style) .title(" Error ") .border_type(BorderType::Plain), ), frame.size(), ); } fn handle_global_message(&mut self, m: ManagerMessage) -> EventResponse { match m { ManagerMessage::Error(a, m) => { self.1 = *m; self.0.push(a); EventResponse::Message(vec![ManagerMessage::ChangeState(Screens::DeviceLost)]) } _ => EventResponse::None, } } fn close(&mut self, _: Screens) -> EventResponse { self.0.clear(); EventResponse::None } fn open(&mut self) -> EventResponse { EventResponse::None } } ================================================ FILE: crates/ytermusic/src/term/item_list.rs ================================================ use crossterm::event::{KeyCode, KeyEvent, MouseEventKind}; use ratatui::{ buffer::Buffer, layout::Rect, style::Style, text::Text, widgets::{Block, Borders, List, ListState, StatefulWidget, Widget}, }; use super::{rect_contains, relative_pos}; pub trait ListItemAction { fn render_style(&self, string: &str, selected: bool) -> Style; } pub struct ListItem { list: Vec<(String, Action)>, current_position: usize, title: String, } impl Default for ListItem { fn default() -> Self { Self { list: Default::default(), current_position: Default::default(), title: Default::default(), } } } impl ListItem { pub fn new(title: String) -> Self { Self { list: Default::default(), current_position: Default::default(), title, } } pub fn on_mouse_press( &mut self, mouse_event: crossterm::event::MouseEvent, frame_data: &Rect, ) -> Option { if let MouseEventKind::Down(_) = mouse_event.kind { let x = mouse_event.column; let y = mouse_event.row; if rect_contains(frame_data, x, y, 1) { let (_, y) = relative_pos(frame_data, x, y, 1); if let Some((i, b)) = self .get_item_frame(frame_data.height as usize) .get(y as usize) .map(|(a, (_, c))| (*a, c.clone())) { self.current_position = i; return Some(b); } } } else if let MouseEventKind::ScrollDown = &mouse_event.kind { self.select_down(); } else if let MouseEventKind::ScrollUp = &mouse_event.kind { self.select_up(); } None } pub fn on_key_press(&mut self, key: KeyEvent) -> Option<&Action> { match key.code { KeyCode::Enter => { if let Some(a) = self.select() { return Some(a); } } KeyCode::Char('+') | KeyCode::Up | KeyCode::Char('k') => self.select_up(), KeyCode::Char('-') | KeyCode::Down | KeyCode::Char('j') => self.select_down(), _ => {} } None } pub fn get_item_frame(&self, height: usize) -> Vec<(usize, &(String, Action))> { let height = height.saturating_sub(2); // Remove the borders // Add a little offset when the list is full let start = self.current_position.saturating_sub(3); let length = self.list.len(); let length_after_start = length.saturating_sub(start); // Tries to take all the space left if length_after_start is smaller than height let start = start.saturating_sub(height.saturating_sub(length_after_start)); self.list .iter() .enumerate() .skip(start) .take(height) .collect::>() } pub fn click_on(&mut self, y_position: usize, height: usize) -> Option<(usize, &Action)> { self.get_item_frame(height) .iter() .enumerate() .find(|(i, _)| *i == y_position) .map(|(_, w)| (w.0, &w.1 .1)) } pub fn select(&self) -> Option<&Action> { self.list .get(self.current_position) .map(|(_, action)| action) } pub fn select_down(&mut self) { if self.current_position == self.list.len() - 1 { self.select_to(0); } else { self.select_to(self.current_position.saturating_add(1)); } } pub fn select_up(&mut self) { if self.current_position == 0 { self.select_to(self.list.len() - 1); } else { self.select_to(self.current_position.saturating_sub(1)); } } pub fn select_to(&mut self, position: usize) { self.current_position = position.min(self.list.len().saturating_sub(1)); } pub fn update(&mut self, list: Vec<(String, Action)>, current: usize) { self.list = list; self.current_position = current.min(self.list.len().saturating_sub(1)); } pub fn update_contents(&mut self, list: Vec<(String, Action)>) { self.list = list; self.current_position = self.current_position.min(self.list.len().saturating_sub(1)); } pub fn clear(&mut self) { self.list.clear(); self.current_position = 0; } pub fn add_element(&mut self, element: (String, Action)) { self.list.push(element); } pub fn set_title(&mut self, a: String) { self.title = a; } pub fn current_position(&self) -> usize { self.current_position } } impl Widget for &ListItem { fn render(self, area: Rect, buf: &mut Buffer) { StatefulWidget::render( List::new( self.get_item_frame(area.height as usize) .iter() .map(|(i, (string, action))| { let style = action.render_style(string, self.current_position == *i); ratatui::widgets::ListItem::new(Text::from(string.as_str())).style(style) }) .collect::>(), ) .block( Block::default() .borders(Borders::ALL) .title(self.title.as_str()), ), area, buf, &mut ListState::default(), ); } } ================================================ FILE: crates/ytermusic/src/term/list_selector.rs ================================================ use ratatui::{ buffer::Buffer, layout::Rect, style::Style, text::Text, widgets::{Block, Borders, List, ListItem, ListState, StatefulWidget}, }; #[derive(Default)] pub struct ListSelector { pub list_size: usize, current_position: usize, scroll_position: usize, } impl ListSelector { pub fn get_item_frame(&self, height: usize) -> (usize, usize) { let height = height.saturating_sub(2); // Remove the borders // Add a little offset when the list is full let start = self.scroll_position.saturating_sub(3); let length = self.list_size; let length_after_start = length.saturating_sub(start); // Tries to take all the space left if length_after_start is smaller than height let start = start.saturating_sub(height.saturating_sub(length_after_start)); ( start.min(self.list_size), (start + height).min(self.list_size), ) } pub fn get_relative_position(&self) -> isize { //Supposing you don't have more than 2^32-1 songs on a 64bit computer. Even if so, panics after. match self.scroll_position.cmp(&self.current_position) { std::cmp::Ordering::Less => { let pos = (self.current_position - self.scroll_position) as isize; -pos } std::cmp::Ordering::Equal => 0, std::cmp::Ordering::Greater => (self.scroll_position - self.current_position) as isize, } } pub fn is_scrolling(&self) -> bool { self.scroll_position != self.current_position } pub fn click_on(&mut self, y_position: usize, height: usize) -> Option { let (a, b) = self.get_item_frame(height); (a..b) .enumerate() .find(|(i, _)| *i == y_position) .map(|(_, w)| w) } pub fn play(&mut self) -> Option { self.current_position = self.scroll_position; self.select() } pub fn scroll_down(&mut self) { self.scroll_to(self.scroll_position.saturating_add(1)); } pub fn scroll_up(&mut self) { self.scroll_to(self.scroll_position.saturating_sub(1)); } pub fn scroll_to(&mut self, position: usize) { self.scroll_position = position.min(self.list_size.saturating_sub(1)); } pub fn select(&self) -> Option { if self.current_position < self.list_size { Some(self.current_position) } else { None } } pub fn update(&mut self, list_size: usize, current: usize) { if !self.is_scrolling() { self.scroll_position = current; } self.current_position = current; self.list_size = list_size; self.current_position = self.current_position.min(self.list_size.saturating_sub(1)); self.scroll_position = self.scroll_position.min(self.list_size.saturating_sub(1)); } pub fn render( &self, area: Rect, buf: &mut Buffer, style_fn: impl Fn(usize, bool, bool) -> (Style, String), render_title: &str, ) { let (a, b) = self.get_item_frame(area.height as usize); StatefulWidget::render( List::new( (a..b) .map(|i| { let (style, text) = style_fn(i, self.current_position == i, self.scroll_position == i); ListItem::new(Text::from(text)).style(style) }) .collect::>(), ) .block(Block::default().borders(Borders::ALL).title(render_title)), area, buf, &mut ListState::default(), ); } } ================================================ FILE: crates/ytermusic/src/term/mod.rs ================================================ pub mod device_lost; pub mod item_list; pub mod list_selector; pub mod music_player; pub mod playlist; pub mod playlist_view; pub mod search; pub mod vertical_gauge; use std::{ io::{self}, time::{Duration, Instant}, }; use crossterm::{ event::{ self, DisableMouseCapture, EnableMouseCapture, Event, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, }, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use flume::{Receiver, Sender}; use ratatui::{backend::CrosstermBackend, layout::Rect, Frame, Terminal}; use ytpapi2::YoutubeMusicVideoRef; use crate::{ is_shutdown_sent, shutdown, structures::sound_action::SoundAction, systems::player::PlayerState, }; use self::{device_lost::DeviceLost, item_list::ListItem, playlist::Chooser, search::Search}; use crate::term::playlist_view::PlaylistView; // A trait to handle the different screens pub trait Screen { fn on_mouse_press(&mut self, mouse_event: MouseEvent, frame_data: &Rect) -> EventResponse; fn on_key_press(&mut self, mouse_event: KeyEvent, frame_data: &Rect) -> EventResponse; fn render(&mut self, frame: &mut Frame); fn handle_global_message(&mut self, message: ManagerMessage) -> EventResponse; fn close(&mut self, new_screen: Screens) -> EventResponse; fn open(&mut self) -> EventResponse; } #[derive(Debug, Clone)] pub enum EventResponse { Message(Vec), None, } // A message that can be sent to the manager #[derive(Debug, Clone)] pub enum ManagerMessage { Error(String, Box>), PassTo(Screens, Box), Inspect(String, Screens, Vec), ChangeState(Screens), SearchFrom(Screens), PlayerFrom(Screens), #[allow(dead_code)] PlaylistFrom(Screens), RestartPlayer, Quit, AddElementToChooser((String, Vec)), } impl ManagerMessage { pub fn pass_to(self, screen: Screens) -> Self { Self::PassTo(screen, Box::new(self)) } pub fn event(self) -> EventResponse { EventResponse::Message(vec![self]) } } // The different screens #[repr(u8)] #[derive(Clone, Copy, Debug, PartialEq)] pub enum Screens { MusicPlayer = 0x0, Playlist = 0x1, Search = 0x2, DeviceLost = 0x3, PlaylistViewer = 0x4, } // The screen manager that handles the different screens pub struct Manager { music_player: PlayerState, chooser: Chooser, search: Search, device_lost: DeviceLost, current_screen: Screens, playlist_viewer: PlaylistView, } impl Manager { pub async fn new(action_sender: Sender, music_player: PlayerState) -> Self { Self { music_player, chooser: Chooser { action_sender: action_sender.clone(), goto: Screens::MusicPlayer, item_list: ListItem::new(" Choose a playlist ".to_owned()), }, playlist_viewer: PlaylistView { sender: action_sender.clone(), items: ListItem::new(" Playlist ".to_owned()), goto: Screens::Playlist, videos: Vec::new(), }, search: Search::new(action_sender).await, current_screen: Screens::Playlist, device_lost: DeviceLost(Vec::new(), None), } } pub fn current_screen(&mut self) -> &mut dyn Screen { self.get_screen(self.current_screen) } pub fn get_screen(&mut self, screen: Screens) -> &mut dyn Screen { match screen { Screens::MusicPlayer => &mut self.music_player, Screens::Playlist => &mut self.chooser, Screens::Search => &mut self.search, Screens::DeviceLost => &mut self.device_lost, Screens::PlaylistViewer => &mut self.playlist_viewer, } } pub fn set_current_screen(&mut self, screen: Screens) { self.current_screen = screen; let k = self.current_screen().open(); self.handle_event(k); } pub fn handle_event(&mut self, event: EventResponse) -> bool { match event { EventResponse::Message(messages) => { for message in messages { if self.handle_manager_message(message) { return true; } } } EventResponse::None => {} } false } pub fn handle_manager_message(&mut self, e: ManagerMessage) -> bool { match e { ManagerMessage::PassTo(e, a) => { let rs = self.get_screen(e).handle_global_message(*a); self.handle_event(rs); } ManagerMessage::Quit => { let c = self.current_screen; self.current_screen().close(c); return true; } ManagerMessage::ChangeState(e) => { self.current_screen().close(e); self.set_current_screen(e); } ManagerMessage::SearchFrom(e) => { self.current_screen().close(Screens::Search); self.search.goto = e; self.set_current_screen(Screens::Search); } ManagerMessage::PlayerFrom(e) => { self.current_screen().close(Screens::MusicPlayer); self.music_player.goto = e; self.set_current_screen(Screens::MusicPlayer); } ManagerMessage::PlaylistFrom(e) => { self.current_screen().close(Screens::Playlist); self.chooser.goto = e; self.set_current_screen(Screens::Playlist); } e => { return self.handle_manager_message(ManagerMessage::PassTo( Screens::DeviceLost, Box::new(ManagerMessage::Error( format!( "Invalid manager message (Forward the message to a screen maybe):\n{e:?}" ), Box::new(None), )), )); } } false } /// The main loop of the manager pub fn run(&mut self, updater: &Receiver) -> Result<(), io::Error> { // setup terminal enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; // create app and run it let tick_rate = Duration::from_millis(250); let mut last_tick = Instant::now(); 'a: loop { if is_shutdown_sent() { break; } while let Ok(e) = updater.try_recv() { if self.handle_manager_message(e) { break 'a; } } let rectsize = terminal.size()?; terminal.draw(|f| { self.music_player.update(); self.current_screen().render(f); })?; let timeout = tick_rate .checked_sub(last_tick.elapsed()) .unwrap_or_else(|| Duration::from_secs(0)); if crossterm::event::poll(timeout)? { match event::read()? { Event::Key(key) if key.kind != KeyEventKind::Release => { if (key.code == event::KeyCode::Char('c') || key.code == event::KeyCode::Char('d')) && key.modifiers == KeyModifiers::CONTROL { break; } let k = self.current_screen().on_key_press(key, &rectsize); if self.handle_event(k) { break; } } Event::Mouse(mouse) => { let k = self.current_screen().on_mouse_press(mouse, &rectsize); if self.handle_event(k) { break; } } _ => (), } } if last_tick.elapsed() >= tick_rate { last_tick = Instant::now(); } } // restore terminal disable_raw_mode()?; execute!( terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture )?; terminal.show_cursor()?; shutdown(); Ok(()) } } // UTILS SECTION TO SPLIT THE TERMINAL INTO DIFFERENT PARTS pub fn split_y_start(f: Rect, start_size: u16) -> [Rect; 2] { let mut rectlistvol = f; rectlistvol.height = start_size; let mut rectprogress = f; rectprogress.y += start_size; rectprogress.height = rectprogress.height.saturating_sub(start_size); [rectlistvol, rectprogress] } pub fn split_y(f: Rect, end_size: u16) -> [Rect; 2] { let mut rectlistvol = f; rectlistvol.height = rectlistvol.height.saturating_sub(end_size); let mut rectprogress = f; rectprogress.y += rectprogress.height.saturating_sub(end_size); rectprogress.height = end_size; [rectlistvol, rectprogress] } pub fn split_x(f: Rect, end_size: u16) -> [Rect; 2] { let mut rectlistvol = f; rectlistvol.width = rectlistvol.width.saturating_sub(end_size); let mut rectprogress = f; rectprogress.x += rectprogress.width.saturating_sub(end_size); rectprogress.width = end_size; [rectlistvol, rectprogress] } pub fn rect_contains(rect: &Rect, x: u16, y: u16, margin: u16) -> bool { rect.x + margin <= x && x <= rect.x + rect.width.saturating_sub(margin) && rect.y + margin <= y && y <= rect.y + rect.height.saturating_sub(margin) } pub fn relative_pos(rect: &Rect, x: u16, y: u16, margin: u16) -> (u16, u16) { ( x.saturating_sub(rect.x + margin), y.saturating_sub(rect.y + margin), ) } ================================================ FILE: crates/ytermusic/src/term/music_player.rs ================================================ use common_structs::{AppStatus, MusicDownloadStatus}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEventKind}; use rand::seq::SliceRandom; use ratatui::widgets::{Block, Borders, Gauge}; use crate::{ consts::CONFIG, structures::{ app_status::{AppStatusExt, MusicDownloadStatusExt}, sound_action::SoundAction, }, systems::{player::PlayerState, DOWNLOAD_MANAGER}, utils::{invert, to_bidi_string}, }; use super::{ rect_contains, relative_pos, split_x, split_y, vertical_gauge::VerticalGauge, EventResponse, ManagerMessage, Screen, Screens, }; impl PlayerState { pub fn activate(&mut self, index: usize) { match index.cmp(&self.current) { std::cmp::Ordering::Less => { SoundAction::Previous(self.current - index).apply_sound_action(self); } std::cmp::Ordering::Equal => { SoundAction::PlayPause.apply_sound_action(self); } std::cmp::Ordering::Greater => { SoundAction::Next(index - self.current).apply_sound_action(self) } } } } impl Screen for PlayerState { fn on_mouse_press( &mut self, mouse_event: crossterm::event::MouseEvent, frame_data: &ratatui::layout::Rect, ) -> EventResponse { let x = mouse_event.column; let y = mouse_event.row; let [top_rect, bottom] = split_y(*frame_data, 3); let [list_rect, volume_rect] = split_x(top_rect, 10); if let MouseEventKind::Down(_) = &mouse_event.kind { if rect_contains(&list_rect, x, y, 1) { let (_, y) = relative_pos(&list_rect, x, y, 1); if let Some(e) = self .list_selector .click_on(y as usize, list_rect.height as usize) { self.activate(e); } } if rect_contains(&bottom, x, y, 1) { let (x, _) = relative_pos(&bottom, x, y, 1); let size = bottom.width as usize - 2; let percent = x as f64 / size as f64; if let Some(duration) = self.sink.duration() { let new_position = (duration * 1000. * percent) as u64; self.sink .seek_to(std::time::Duration::from_millis(new_position)); } } if rect_contains(&volume_rect, x, y, 1) { let (_, y) = relative_pos(&volume_rect, x, y, 1); let size = volume_rect.height as usize - 2; let percent = 100. - y as f64 / size as f64 * 100.; self.sink.set_volume(percent as i32) } } else if let MouseEventKind::ScrollUp = &mouse_event.kind { if rect_contains(&volume_rect, x, y, 1) { SoundAction::Plus.apply_sound_action(self); } else if rect_contains(&bottom, x, y, 1) { SoundAction::Forward.apply_sound_action(self); } else { self.list_selector.scroll_up(); } } else if let MouseEventKind::ScrollDown = &mouse_event.kind { if rect_contains(&volume_rect, x, y, 1) { SoundAction::Minus.apply_sound_action(self); } else if rect_contains(&bottom, x, y, 1) { SoundAction::Backward.apply_sound_action(self); } else { self.list_selector.scroll_down(); } } EventResponse::None } fn on_key_press(&mut self, key: KeyEvent, _: &ratatui::layout::Rect) -> EventResponse { match key.code { KeyCode::Esc => ManagerMessage::ChangeState(self.goto).event(), KeyCode::F(5) => { // Get all musics that have failled to download let mut musics = Vec::new(); self.music_status .iter_mut() .for_each(|(key, music_status)| { if MusicDownloadStatus::DownloadFailed != *music_status { return; } if let Some(e) = self.list.iter().find(|x| &x.video_id == key) { musics.push(e.clone()); *music_status = MusicDownloadStatus::NotDownloaded; } }); // Download them DOWNLOAD_MANAGER.add_to_download_list(musics); EventResponse::None } KeyCode::Char('f') => ManagerMessage::SearchFrom(Screens::MusicPlayer).event(), KeyCode::Char('s') => { self.list.shuffle(&mut rand::thread_rng()); self.current = 0; self.sink.stop(); EventResponse::None } KeyCode::Char('C') => { SoundAction::Cleanup.apply_sound_action(self); EventResponse::None } KeyCode::Char(' ') => { SoundAction::PlayPause.apply_sound_action(self); EventResponse::None } KeyCode::Up | KeyCode::Char('k') => { self.list_selector.scroll_up(); EventResponse::None } KeyCode::Down | KeyCode::Char('j') => { self.list_selector.scroll_down(); EventResponse::None } KeyCode::Enter => { if let Some(e) = self.list_selector.play() { self.activate(e); } EventResponse::None } KeyCode::Char('+') | KeyCode::Char('=') => { SoundAction::Plus.apply_sound_action(self); EventResponse::None } KeyCode::Char('-') => { SoundAction::Minus.apply_sound_action(self); EventResponse::None } KeyCode::Char('<') | KeyCode::Left | KeyCode::Char('h') => { if key.modifiers.contains(KeyModifiers::CONTROL) { SoundAction::Previous(1).apply_sound_action(self); } else { SoundAction::Backward.apply_sound_action(self); } EventResponse::None } KeyCode::Char('>') | KeyCode::Right | KeyCode::Char('l') => { if key.modifiers.contains(KeyModifiers::CONTROL) { SoundAction::Next(1).apply_sound_action(self); } else { SoundAction::Forward.apply_sound_action(self); } EventResponse::None } KeyCode::Char('r') => { SoundAction::DeleteVideoUnary.apply_sound_action(self); EventResponse::None } _ => EventResponse::None, } } fn render(&mut self, f: &mut ratatui::Frame) { let render_volume_slider = CONFIG.player.volume_slider; let [top_rect, progress_rect] = split_y(f.size(), 3); let [list_rect, volume_rect] = split_x(top_rect, if render_volume_slider { 10 } else { 0 }); let colors = if self.sink.is_paused() { AppStatus::Paused } else if self.sink.is_finished() { AppStatus::NoMusic } else { AppStatus::Playing } .style(); if render_volume_slider { f.render_widget( VerticalGauge::default() .block(Block::default().title(" Volume ").borders(Borders::ALL)) .gauge_style(colors) .ratio((self.sink.volume() as f64 / 100.).clamp(0.0, 1.0)), volume_rect, ); } let current_time = self.sink.elapsed().as_secs(); let total_time = self.sink.duration().map(|x| x as u32).unwrap_or(0); f.render_widget( Gauge::default() .block( Block::default() .title( self.current() .map(|x| format!(" {} ", to_bidi_string(&x.to_string()))) .unwrap_or_else(|| " No music playing ".to_owned()), ) .borders(Borders::ALL), ) .gauge_style(colors) .ratio( if self.sink.is_finished() { 0.5 } else { self.sink.percentage().min(100.) } .clamp(0.0, 1.0), ) .label(format!( "{}:{:02} / {}:{:02}", current_time / 60, current_time % 60, total_time / 60, total_time % 60 )), progress_rect, ); // Create a List from all list items and highlight the currently selected one self.list_selector.update(self.list.len(), self.current); self.list_selector.render( list_rect, f.buffer_mut(), |index, select, scroll| { let music_state = self .list .get(index) .and_then(|x| self.music_status.get(&x.video_id)) .copied() .unwrap_or(MusicDownloadStatus::Downloaded); let music_state_c = music_state.character(Some(!self.sink.is_paused())); ( if select { music_state.style(Some(!self.sink.is_paused())) } else if scroll { invert(music_state.style(None)) } else { music_state.style(None) }, if let Some(e) = self.list.get(index) { format!( " {music_state_c} {} | {}", to_bidi_string(&e.author), to_bidi_string(&e.title) ) } else { String::new() }, ) }, " Playlist ", ) } fn handle_global_message(&mut self, message: ManagerMessage) -> EventResponse { match message { ManagerMessage::RestartPlayer => { SoundAction::RestartPlayer.apply_sound_action(self); ManagerMessage::ChangeState(Screens::MusicPlayer).event() } _ => EventResponse::None, } } fn close(&mut self, _: Screens) -> EventResponse { EventResponse::None } fn open(&mut self) -> EventResponse { EventResponse::None } } ================================================ FILE: crates/ytermusic/src/term/playlist.rs ================================================ use std::sync::atomic::AtomicBool; use crossterm::event::{KeyCode, KeyEvent}; use flume::Sender; use ratatui::{layout::Rect, style::Style, Frame}; use ytpapi2::YoutubeMusicVideoRef; use crate::{ consts::{CACHE_DIR, CONFIG}, structures::sound_action::{download_manager_handler, SoundAction}, systems::DOWNLOAD_MANAGER, utils::{invert, to_bidi_string}, ShutdownSignal, DATABASE, }; use super::{ item_list::{ListItem, ListItemAction}, EventResponse, ManagerMessage, Screen, Screens, }; #[derive(Clone)] pub enum ChooserAction { Play(PlayListEntry), } impl ListItemAction for ChooserAction { fn render_style(&self, _: &str, selected: bool) -> Style { if selected { invert(CONFIG.player.text_next_style) } else { CONFIG.player.text_next_style } } } pub struct Chooser { pub item_list: ListItem, pub goto: Screens, pub action_sender: Sender, } #[derive(Clone)] pub struct PlayListEntry { pub name: String, pub videos: Vec, pub text_to_show: String, } impl PlayListEntry { pub fn new(name: String, videos: Vec) -> Self { Self { text_to_show: format_playlist(&name, &videos), name, videos, } } pub fn tupplelize(&self) -> (&String, &Vec) { (&self.name, &self.videos) } } pub fn format_playlist(name: &str, videos: &[YoutubeMusicVideoRef]) -> String { let db = DATABASE.read().unwrap(); let local_videos = videos .iter() .filter(|x| db.iter().any(|y| x.video_id == y.video_id)) .count(); format!( "{} ({}/{} {}%)", to_bidi_string(name), local_videos, videos.len(), (local_videos as f32 / videos.len() as f32 * 100.0) as u8 ) } impl Screen for Chooser { fn on_mouse_press( &mut self, mouse_event: crossterm::event::MouseEvent, frame_data: &Rect, ) -> EventResponse { if let Some(ChooserAction::Play(a)) = self.item_list.on_mouse_press(mouse_event, frame_data) { if PLAYER_RUNNING.load(std::sync::atomic::Ordering::SeqCst) { return EventResponse::Message(vec![ManagerMessage::Inspect( a.name, Screens::Playlist, a.videos, ) .pass_to(Screens::PlaylistViewer)]); } self.play(&a); EventResponse::Message(vec![ManagerMessage::PlayerFrom(Screens::Playlist)]) } else { EventResponse::None } } fn on_key_press(&mut self, key: KeyEvent, _: &Rect) -> EventResponse { if let Some(ChooserAction::Play(a)) = self.item_list.on_key_press(key).cloned() { if PLAYER_RUNNING.load(std::sync::atomic::Ordering::SeqCst) { return EventResponse::Message(vec![ManagerMessage::Inspect( a.name, Screens::Playlist, a.videos, ) .pass_to(Screens::PlaylistViewer)]); } self.play(&a); return EventResponse::Message(vec![ManagerMessage::ChangeState(Screens::MusicPlayer)]); } match key.code { KeyCode::Esc => return ManagerMessage::ChangeState(Screens::MusicPlayer).event(), KeyCode::Char('f') => return ManagerMessage::SearchFrom(Screens::Playlist).event(), _ => {} } EventResponse::None } fn render(&mut self, frame: &mut Frame) { frame.render_widget(&self.item_list, frame.size()); } fn handle_global_message(&mut self, message: super::ManagerMessage) -> EventResponse { if let ManagerMessage::AddElementToChooser(a) = message { self.add_element(a); } EventResponse::None } fn close(&mut self, _: Screens) -> EventResponse { EventResponse::None } fn open(&mut self) -> EventResponse { EventResponse::None } } pub static PLAYER_RUNNING: AtomicBool = AtomicBool::new(false); impl Chooser { fn play(&mut self, a: &PlayListEntry) { if a.name != "Local musics" { std::fs::write( CACHE_DIR.join("last-playlist.json"), serde_json::to_string(&a.tupplelize()).unwrap(), ) .unwrap(); } self.action_sender.send(SoundAction::Cleanup).unwrap(); DOWNLOAD_MANAGER.clean( ShutdownSignal, download_manager_handler(self.action_sender.clone()), ); self.action_sender .send(SoundAction::AddVideosToQueue(a.videos.clone())) .unwrap(); } fn add_element(&mut self, element: (String, Vec)) { let entry = PlayListEntry::new(element.0, element.1); self.item_list .add_element((entry.text_to_show.clone(), ChooserAction::Play(entry))); } } ================================================ FILE: crates/ytermusic/src/term/playlist_view.rs ================================================ use crossterm::event::{KeyCode, KeyEvent}; use flume::Sender; use ratatui::{layout::Rect, style::Style, Frame}; use ytpapi2::YoutubeMusicVideoRef; use crate::{ consts::CONFIG, structures::sound_action::SoundAction, utils::{invert, to_bidi_string}, DATABASE, }; use super::{ item_list::{ListItem, ListItemAction}, EventResponse, ManagerMessage, Screen, Screens, }; #[derive(Clone)] pub struct PlayListAction(usize, bool); impl ListItemAction for PlayListAction { fn render_style(&self, _: &str, selected: bool) -> Style { if selected { if self.1 { invert(CONFIG.player.text_downloading_style) } else { invert(CONFIG.player.text_next_style) } } else if self.1 { CONFIG.player.text_downloading_style } else { CONFIG.player.text_next_style } } } // Audio device not connected! pub struct PlaylistView { pub items: ListItem, pub videos: Vec, pub goto: Screens, pub sender: Sender, } impl Screen for PlaylistView { fn on_mouse_press(&mut self, e: crossterm::event::MouseEvent, r: &Rect) -> EventResponse { if let Some(PlayListAction(v, _)) = self.items.on_mouse_press(e, r) { self.sender .send(SoundAction::ReplaceQueue( self.videos.iter().skip(v).cloned().collect(), )) .unwrap(); EventResponse::Message(vec![ManagerMessage::PlayerFrom(Screens::Playlist)]) } else { EventResponse::None } } fn on_key_press(&mut self, key: KeyEvent, _: &Rect) -> EventResponse { if let Some(PlayListAction(v, _)) = self.items.on_key_press(key) { self.sender .send(SoundAction::ReplaceQueue( self.videos.iter().skip(*v).cloned().collect(), )) .unwrap(); return EventResponse::Message(vec![ManagerMessage::PlayerFrom(Screens::Playlist)]); } match key.code { KeyCode::Esc => ManagerMessage::ChangeState(self.goto).event(), KeyCode::Char('f') => ManagerMessage::SearchFrom(Screens::PlaylistViewer).event(), _ => EventResponse::None, } } fn render(&mut self, frame: &mut Frame) { frame.render_widget(&self.items, frame.size()); } fn handle_global_message(&mut self, m: ManagerMessage) -> EventResponse { match m { ManagerMessage::Inspect(a, screen, m) => { self.items .set_title(format!(" Inspecting {} ", to_bidi_string(&a))); self.goto = screen; let db = DATABASE.read().unwrap(); self.items.update( m.iter() .enumerate() .map(|(i, m)| { ( format!(" {}", to_bidi_string(&m.to_string())), PlayListAction(i, !db.iter().any(|x| x.video_id == m.video_id)), ) }) .collect(), 0, ); self.videos = m; EventResponse::Message(vec![ManagerMessage::ChangeState(Screens::PlaylistViewer)]) } _ => EventResponse::None, } } fn close(&mut self, _: Screens) -> EventResponse { EventResponse::None } fn open(&mut self) -> EventResponse { EventResponse::None } } ================================================ FILE: crates/ytermusic/src/term/search.rs ================================================ use std::sync::{Arc, RwLock}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use flume::Sender; use log::error; use ratatui::{ layout::{Alignment, Rect}, style::Style, widgets::{Block, BorderType, Borders, Paragraph}, Frame, }; use tokio::task::JoinHandle; use ytpapi2::{ HeaderMap, HeaderValue, SearchResults, YoutubeMusicInstance, YoutubeMusicPlaylistRef, YoutubeMusicVideoRef, }; use crate::{ consts::CONFIG, get_header_file, run_service, structures::sound_action::{download_manager_handler, SoundAction}, systems::DOWNLOAD_MANAGER, try_get_cookies, utils::{invert, to_bidi_string}, ShutdownSignal, DATABASE, }; use super::{ item_list::{ListItem, ListItemAction}, playlist::format_playlist, split_y_start, EventResponse, ManagerMessage, Screen, Screens, }; pub struct Search { pub text: String, pub goto: Screens, pub list: Arc>>, pub search_handle: Option>, pub api: Option>, pub action_sender: Sender, } #[derive(Clone, Debug, PartialEq)] pub enum Status { Local(YoutubeMusicVideoRef), Unknown(YoutubeMusicVideoRef), PlayList(YoutubeMusicPlaylistRef, Vec), } impl ListItemAction for Status { fn render_style(&self, _: &str, selected: bool) -> Style { let k = match self { Self::Local(_) => CONFIG.player.text_next_style, Self::Unknown(_) => CONFIG.player.text_downloading_style, Self::PlayList(_, _) => CONFIG.player.text_next_style, }; if selected { invert(k) } else { k } } } impl Screen for Search { fn on_mouse_press( &mut self, mouse_event: crossterm::event::MouseEvent, frame_data: &Rect, ) -> EventResponse { let splitted = split_y_start(*frame_data, 3); if let Some(e) = self .list .write() .unwrap() .on_mouse_press(mouse_event, &splitted[1]) { self.execute_status(e, mouse_event.modifiers) } else { EventResponse::None } } fn on_key_press(&mut self, key: KeyEvent, _: &Rect) -> EventResponse { if KeyCode::Esc == key.code { return ManagerMessage::ChangeState(self.goto).event(); } if let Some(e) = self.list.write().unwrap().on_key_press(key) { return self.execute_status(e.clone(), key.modifiers); } let textbefore = self.text.trim().to_owned(); match key.code { KeyCode::Delete | KeyCode::Backspace => { self.text.pop(); } KeyCode::Char(a) => { self.text.push(a); } _ => {} } if textbefore == self.text.trim() { return EventResponse::None; } if let Some(handle) = self.search_handle.take() { handle.abort(); } let text = self.text.to_lowercase(); let local = DATABASE .read() .unwrap() .iter() .filter(|x| { x.title.to_lowercase().contains(&text) || x.author.to_lowercase().contains(&text) }) .cloned() .map(|video| { ( format!(" {} ", to_bidi_string(&video.to_string())), Status::Local(video), ) }) .take(100) .collect::>(); self.list.write().unwrap().update_contents(local.clone()); if let Some(api) = self.api.clone() { let text = self.text.clone(); let items = self.list.clone(); self.search_handle = Some(run_service(async move { // Sleep to prevent spamming the api tokio::time::sleep(std::time::Duration::from_millis(300)).await; let mut item = Vec::new(); match api .search(&text.replace('\\', "\\\\").replace('\"', "\\\""), 0) .await { Ok(SearchResults { videos: e, playlists: p, }) => { for video in e.into_iter() { let id = video.video_id.clone(); item.push(( format!(" {} ", to_bidi_string(&video.to_string())), if DATABASE.read().unwrap().iter().any(|x| x.video_id == id) { Status::Local(video) } else { Status::Unknown(video) }, )); } for playlist in p.into_iter() { let api = api.clone(); let items = items.clone(); run_service(async move { match api.get_playlist(&playlist, 0).await { Ok(e) => { if e.is_empty() { return; } items.write().unwrap().add_element(( format_playlist( &format!( " [P] {} ({})", to_bidi_string(&playlist.name), to_bidi_string(&playlist.subtitle) ), &e, ), Status::PlayList(playlist, e), )); } Err(e) => { error!("{e:?}"); } }; }); } } Err(e) => { error!("{e:?}"); } } let mut local = local; local.append(&mut item); items.write().unwrap().update_contents(local); })); } EventResponse::None } fn render(&mut self, frame: &mut Frame) { let splitted = split_y_start(frame.size(), 3); frame.render_widget( Paragraph::new(self.text.clone()) .style(CONFIG.player.text_searching_style) .alignment(Alignment::Center) .block( Block::default() .borders(Borders::ALL) .style(CONFIG.player.text_next_style) .title(" Search ") .border_type(BorderType::Plain), ), splitted[0], ); // Select the playlist to play let items = self.list.read().unwrap(); frame.render_widget(&*items, splitted[1]); } fn handle_global_message(&mut self, _: super::ManagerMessage) -> EventResponse { EventResponse::None } fn close(&mut self, _: Screens) -> EventResponse { EventResponse::None } fn open(&mut self) -> EventResponse { EventResponse::None } } impl Search { pub async fn new(action_sender: Sender) -> Self { Self { text: String::new(), list: Arc::new(RwLock::new(ListItem::new( "Select a song to play".to_string(), ))), goto: Screens::MusicPlayer, search_handle: None, api: if let Some(cookies) = try_get_cookies() { let mut headermap = HeaderMap::new(); headermap.insert( "cookie", HeaderValue::from_str(&cookies).unwrap(), ); headermap.insert( "user-agent", HeaderValue::from_static("Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"), ); YoutubeMusicInstance::new(headermap, None).await //don't think we need a brand account for search } else { YoutubeMusicInstance::from_header_file(get_header_file().unwrap().1.as_path()).await } .ok() .map(Arc::new), action_sender, } } pub fn execute_status(&self, e: Status, modifiers: KeyModifiers) -> EventResponse { match e { Status::Local(e) | Status::Unknown(e) => { self.action_sender .send(SoundAction::AddVideoUnary(e.clone())) .unwrap(); DOWNLOAD_MANAGER.start_task_unary( download_manager_handler(self.action_sender.clone()), e, ShutdownSignal, ); if modifiers.contains(KeyModifiers::CONTROL) { EventResponse::None } else { ManagerMessage::PlayerFrom(Screens::Playlist).event() } } Status::PlayList(e, v) => ManagerMessage::Inspect(e.name, Screens::Search, v) .pass_to(Screens::PlaylistViewer) .event(), } } } ================================================ FILE: crates/ytermusic/src/term/vertical_gauge.rs ================================================ use ratatui::{ buffer::Buffer, layout::Rect, style::{Color, Style}, widgets::{Block, Widget}, }; pub struct VerticalGauge<'a> { block: Option>, ratio: f64, style: Style, gauge_style: Style, } impl<'a> Widget for VerticalGauge<'a> { fn render(mut self, area: Rect, buf: &mut Buffer) { buf.set_style(area, self.style); let gauge_area = match self.block.take() { Some(b) => { let inner_area = b.inner(area); b.render(area, buf); inner_area } None => area, }; buf.set_style(gauge_area, self.gauge_style); if gauge_area.height < 1 { return; } // compute label value and its position // label is put at the center of the gauge_area let label = { let pct = f64::round(self.ratio * 100.0); format!("{pct}%") }; let clamped_label_width = gauge_area.width.min(label.len() as u16); let label_col = gauge_area.left() + (gauge_area.width - clamped_label_width) / 2; let label_row = gauge_area.top() + gauge_area.height / 2; // the gauge will be filled proportionally to the ratio let filled_height = f64::from(gauge_area.height) * self.ratio; let end = gauge_area.bottom() - filled_height.round() as u16; for y in gauge_area.top()..end { // render the filled area (left to end) for x in gauge_area.left()..gauge_area.right() { buf.get_mut(x, y) .set_symbol(" ") .set_bg(self.gauge_style.bg.unwrap_or(Color::Reset)) .set_fg(self.gauge_style.fg.unwrap_or(Color::Reset)); } } for y in end..gauge_area.bottom() { // render the empty area (end to right) for x in gauge_area.left()..gauge_area.right() { buf.get_mut(x, y) .set_symbol(" ") .set_bg(self.gauge_style.fg.unwrap_or(Color::Reset)) .set_fg(self.gauge_style.bg.unwrap_or(Color::Reset)); } } for x in label_col..label_col + clamped_label_width { if gauge_area.height / 2 > end.saturating_sub(2) { buf.get_mut(x, label_row) .set_symbol(&label[(x - label_col) as usize..(x - label_col + 1) as usize]) .set_bg(self.gauge_style.fg.unwrap_or(Color::Reset)) .set_fg(self.gauge_style.bg.unwrap_or(Color::Reset)); } else { buf.get_mut(x, label_row) .set_symbol(&label[(x - label_col) as usize..(x - label_col + 1) as usize]) .set_bg(self.gauge_style.bg.unwrap_or(Color::Reset)) .set_fg(self.gauge_style.fg.unwrap_or(Color::Reset)); } } } } impl<'a> Default for VerticalGauge<'a> { fn default() -> VerticalGauge<'a> { VerticalGauge { block: None, ratio: 0.0, style: Style::default(), gauge_style: Style::default(), } } } impl<'a> VerticalGauge<'a> { pub fn block(mut self, block: Block<'a>) -> VerticalGauge<'a> { self.block = Some(block); self } /// Sets ratio ([0.0, 1.0]) directly. pub fn ratio(mut self, ratio: f64) -> VerticalGauge<'a> { assert!( (0.0..=1.0).contains(&ratio), "Ratio should be between 0 and 1 inclusively." ); self.ratio = ratio; self } pub fn gauge_style(mut self, style: Style) -> VerticalGauge<'a> { self.gauge_style = style; self } } ================================================ FILE: crates/ytermusic/src/utils.rs ================================================ use directories::ProjectDirs; use ratatui::style::{Color, Style}; use unicode_bidi::{BidiInfo, Level}; /// Get directories for the project for config, cache, etc. pub fn get_project_dirs() -> Option { ProjectDirs::from("com", "ccgauche", "ytermusic") } /// Invert a style pub fn invert(style: Style) -> Style { if style.bg.is_none() { return Style { fg: Some(color_contrast(style.fg.unwrap_or(Color::Reset))), bg: style.fg, add_modifier: style.add_modifier, sub_modifier: style.sub_modifier, underline_color: style.underline_color, }; } Style { fg: style.bg, bg: style.fg, add_modifier: style.add_modifier, sub_modifier: style.sub_modifier, underline_color: style.underline_color, } } /// Returns a color with a high contrast to the input color (white or black) pub fn color_contrast(color: Color) -> Color { match color { Color::Black => Color::White, Color::White => Color::Black, Color::Red => Color::White, Color::Green => Color::Black, Color::Yellow => Color::Black, Color::Blue => Color::White, Color::Magenta => Color::White, Color::Cyan => Color::Black, Color::Gray => Color::White, Color::DarkGray => Color::Black, Color::LightRed => Color::White, Color::LightGreen => Color::Black, Color::LightYellow => Color::Black, Color::LightBlue => Color::White, Color::LightMagenta => Color::White, Color::LightCyan => Color::Black, Color::Indexed(v) => { if v < 8 { Color::White } else { Color::Black } } Color::Rgb(r, g, b) => { if r as u32 + g as u32 + b as u32 > 382 { Color::Black } else { Color::White } } Color::Reset => Color::Black, } } /// Reorder a string using the Unicode Bidirectional Algorithm. /// This ensures RTL text (e.g. Hebrew, Arabic) displays correctly in the terminal. pub fn to_bidi_string(s: &str) -> String { let bidi_info = BidiInfo::new(s, None); if let Some(para) = bidi_info.paragraphs.first() { if para.level != Level::ltr() { return bidi_info.reorder_line(para, para.range.clone()).to_string(); } // Check if any character has RTL level even in an LTR paragraph let start = para.range.start; let end = para.range.end; if bidi_info.levels[start..end] .iter() .any(|l| *l != Level::ltr()) { return bidi_info.reorder_line(para, para.range.clone()).to_string(); } } s.to_string() } ================================================ FILE: crates/ytpapi2/Cargo.toml ================================================ [package] name = "ytpapi2" version = "0.1.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] reqwest = { version = "0.11.24", features = [ "gzip", "deflate", "cookies", "rustls-tls", ], default-features = false } serde = { version = "1.0.196", features = ["derive"] } serde_json = "1.0.113" tokio = { version = "1.36.0", features = ["full"] } sha1 = "0.10.6" log = "0.4.20" ================================================ FILE: crates/ytpapi2/src/json_extractor.rs ================================================ use std::fmt::Display; use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::YoutubeMusicPlaylistRef; /// Applies recursively the `transformer` function to the given json value /// and returns the transformed values. pub(crate) fn from_json( json: &Value, transformer: impl Fn(&Value) -> Option, ) -> crate::Result> { /// Execute a function on each element of a json value recursively. /// When the function returns something, the value is added to the result. pub(crate) fn inner_crawl( value: &Value, playlists: &mut Vec, transformer: &impl Fn(&Value) -> Option, ) { if let Some(e) = transformer(value) { // Maybe an hashset would be better if !playlists.contains(&e) { playlists.push(e); } return; } match value { Value::Array(a) => a .iter() .for_each(|x| inner_crawl(x, playlists, transformer)), Value::Object(a) => a .values() .for_each(|x| inner_crawl(x, playlists, transformer)), _ => (), } } let mut playlists = Vec::new(); inner_crawl(json, &mut playlists, &transformer); Ok(playlists) } #[derive(Debug, Clone, PartialOrd, Eq, Ord, PartialEq, Hash, Serialize, Deserialize)] pub struct YoutubeMusicVideoRef { pub title: String, pub author: String, pub album: String, pub video_id: String, pub duration: String, } impl Display for YoutubeMusicVideoRef { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{} | {}", self.author, self.title) } } /// Tries to extract a playlist from a json value. /// Quite flexible to reduce odds of API change breaking this. pub(crate) fn get_playlist(value: &Value) -> Option { let object = value.as_object()?; let title_text = get_text(object.get("title")?, true, false)?; let subtitle = object .get("subtitle") .and_then(|x| get_text(x, false, false)); let browse_id = &object .get("navigationEndpoint") .and_then(|x| x.get("browseEndpoint")) .and_then(|x| x.get("browseId")) .and_then(Value::as_str)?; Some(YoutubeMusicPlaylistRef { name: title_text, subtitle: subtitle.unwrap_or_default(), browse_id: browse_id.to_string(), }) } #[derive(Debug, Clone, PartialOrd, Eq, Ord, PartialEq, Hash, Serialize, Deserialize)] pub struct Continuation { pub(crate) continuation: String, pub(crate) click_tracking_params: String, } pub fn get_continuation(value: &Value) -> Option { let continuation = value .get("nextContinuationData") .and_then(|x| x.get("continuation")) .and_then(Value::as_str)?; let click_tracking_params = value .get("nextContinuationData") .and_then(|x| x.get("clickTrackingParams")) .and_then(Value::as_str)?; Some(Continuation { continuation: continuation.to_string(), click_tracking_params: click_tracking_params.to_string(), }) } pub fn get_playlist_search(value: &Value) -> Option { let browse_id = &value .get("navigationEndpoint") .and_then(|x| x.get("browseEndpoint")) .and_then(|x| x.get("browseId")) .and_then(Value::as_str)?; let titles: Vec = value .get("flexColumns")? .as_array()? .iter() .flat_map(|x| { x.get("musicResponsiveListItemFlexColumnRenderer") .and_then(|x| x.get("text")) .and_then(|x| get_text(x, false, false)) }) .collect(); Some(YoutubeMusicPlaylistRef { name: titles.first()?.clone(), subtitle: titles.get(1)?.clone(), browse_id: browse_id.to_string(), }) } pub fn extract_playlist_info(value: &Value) -> Option<(String, String)> { let header = value.get("header")?.get("musicDetailHeaderRenderer")?; let title = get_text(header.get("title")?, false, false)?; let subtitles = header .get("subtitle")? .get("runs")? .as_array()? .iter() .flat_map(|x| get_text(x, false, false)) .filter(|x| x != " • ") .collect::>(); Some((title, subtitles.get(1)?.clone())) } pub fn get_video_from_album(value: &Value) -> Option { let video_id = value .get("playlistItemData") .and_then(|x| x.get("videoId")) .and_then(Value::as_str)?; let title: Vec = value .get("flexColumns")? .as_array()? .iter() .flat_map(|x| { x.get("musicResponsiveListItemFlexColumnRenderer") .and_then(|x| x.get("text")) .and_then(|x| get_text(x, false, false)) }) .collect(); Some(YoutubeMusicVideoRef { title: title.first()?.clone(), author: String::new(), album: String::new(), video_id: video_id.to_string(), duration: String::new(), }) } /// Tries to extract the text from a json value. /// text_clean: Weather to include singleton text. /// dot: Weather to use the dotted text instead of the space fn get_text(value: &Value, text_clean: bool, dot: bool) -> Option { if let Some(e) = value.as_str() { Some(e.to_string()) } else { let obj = value.as_object()?; if let Some(e) = obj.get("text") { if text_clean && obj.values().count() == 1 { return None; } get_text(e, text_clean, dot) } else if let Some(e) = obj.get("runs") { let k = e .as_array()? .iter() .flat_map(|x| get_text(x, text_clean, dot)) .collect::>(); if k.is_empty() { None } else { Some(join_clean(&k, dot)) } } else { None } } } fn join_clean(strings: &[String], dot: bool) -> String { strings .iter() .map(|x| x.trim()) .filter(|x| !x.is_empty()) .collect::>() .join(if dot { " • " } else { " " }) } /// Tries to find a video id in the json pub fn get_videoid(value: &Value) -> Option { match value { Value::Array(e) => e.iter().find_map(get_videoid), Value::Object(e) => e .get("videoId") .and_then(Value::as_str) .map(|x| x.to_string()) .or_else(|| e.values().find_map(get_videoid)), _ => None, } } /// Tries to extract a video from a json value. /// Quite flexible to reduce odds of API change breaking this. pub(crate) fn get_video(value: &Value) -> Option { // Extract the text part (title, author, album) from a json value. let mut texts = value .as_object()? .get("flexColumns")? .as_array()? .iter() .flat_map(|x| { x.as_object() .and_then(|x| x.values().next()) .and_then(|x| get_text(x, true, true)) }); Some(YoutubeMusicVideoRef { video_id: get_videoid(value)?, title: texts.next()?, author: texts.next()?, album: texts.next().unwrap_or_default(), duration: String::new(), }) } ================================================ FILE: crates/ytpapi2/src/lib.rs ================================================ use std::{ path::Path, string::FromUtf8Error, time::{SystemTime, UNIX_EPOCH}, }; use json_extractor::{ extract_playlist_info, from_json, get_continuation, get_playlist, get_playlist_search, get_video, get_video_from_album, Continuation, }; use log::{debug, error, trace}; pub use reqwest::header::HeaderMap; pub use reqwest::header::*; use serde::{Deserialize, Serialize}; use serde_json::Value; use sha1::{Digest, Sha1}; use string_utils::StringUtils; mod json_extractor; mod string_utils; pub use json_extractor::YoutubeMusicVideoRef; pub type Result = std::result::Result; const YTM_DOMAIN: &str = "https://music.youtube.com"; #[cfg(test)] fn get_headers() -> HeaderMap { let mut headers = HeaderMap::new(); let file = std::fs::read_to_string("../headers.txt").unwrap(); for header in file.lines() { if header.trim().is_empty() { continue; } let (key, value) = header.split_once(": ").unwrap(); headers.insert( match key { "Cookie" => reqwest::header::COOKIE, "User-Agent" => reqwest::header::USER_AGENT, _ => { println!("Unknown header key: {}", key); continue; } }, value.parse().unwrap(), ); } headers } #[cfg(test)] fn get_account_id() -> Option { let file = std::fs::read_to_string("../account_id.txt").unwrap(); let account_id = match std::fs::read_to_string(file) { Ok(id) => Some(id), Err(_) => None, }; return account_id; } #[test] fn advanced_like() { use tokio::runtime::Runtime; Runtime::new().unwrap().block_on(async { let ytm = YoutubeMusicInstance::new(get_headers(), get_account_id()) .await .unwrap(); println!("{}", ytm.compute_sapi_hash()); let search = ytm .get_library(&Endpoint::MusicLibraryLanding, 0) .await .unwrap(); assert_eq!(search.is_empty(), false); println!("{:?}", search[1]); println!("{:?}", ytm.get_playlist(&search[1], 0).await.unwrap()); }); } #[test] fn advanced_test() { use tokio::runtime::Runtime; Runtime::new().unwrap().block_on(async { let ytm = YoutubeMusicInstance::new(get_headers(), get_account_id()) .await .unwrap(); let search = ytm.search("j'ai la danse qui va avec", 0).await.unwrap(); assert_eq!(search.videos.is_empty(), false); assert_eq!(search.playlists.is_empty(), false); let playlist_contents = ytm.get_playlist(&search.playlists[1], 0).await.unwrap(); println!("{:?}", playlist_contents); }); } #[test] fn home_test() { use tokio::runtime::Runtime; Runtime::new().unwrap().block_on(async { let ytm = YoutubeMusicInstance::new(get_headers(), get_account_id()) .await .unwrap(); let search = ytm.get_home(0).await.unwrap(); println!("{:?}", search.playlists); assert_eq!(search.playlists.is_empty(), false); let playlist_contents = ytm.get_playlist(&search.playlists[0], 0).await.unwrap(); println!("{:?}", playlist_contents); }); } #[derive(Debug, Clone, PartialOrd, Eq, Ord, PartialEq, Hash, Serialize, Deserialize)] pub struct YoutubeMusicPlaylistRef { pub name: String, pub subtitle: String, pub browse_id: String, } pub struct YoutubeMusicInstance { sapisid: String, innertube_api_key: String, client_version: String, cookies: String, account_id: Option, } impl YoutubeMusicInstance { pub async fn from_header_file(path: &Path) -> Result { let mut headers = HeaderMap::new(); for header in tokio::fs::read_to_string(path) .await .map_err(YoutubeMusicError::IoError)? .lines() { if let Some((key, value)) = header.split_once(": ") { headers.insert( match key.to_lowercase().as_str() { "cookie" => reqwest::header::COOKIE, "user-agent" => reqwest::header::USER_AGENT, _ => { #[cfg(test)] println!("Unknown header key: {key}"); continue; } }, value.parse().unwrap(), ); } } if !headers.contains_key(reqwest::header::COOKIE) { return Err(YoutubeMusicError::InvalidHeaders); } if !headers.contains_key(reqwest::header::USER_AGENT) { headers.insert( reqwest::header::USER_AGENT, "Mozilla/5.0 (X11; Linux x86_64; rv:108.0) Gecko/20100101 Firefox/108.0" .parse() .unwrap(), ); } let account_path = path .parent() .unwrap_or(Path::new("../")) .join("account_id.txt"); let account_id = match tokio::fs::read_to_string(account_path).await { Ok(mut id) => { if id.ends_with("\n") { id.pop(); if id.ends_with("\r") { id.pop(); } } Some(id) } Err(_) => None, //don't care if there is no files or nothing in the file }; Self::new(headers, account_id).await } pub async fn new(headers: HeaderMap, account_id: Option) -> Result { trace!("Creating new YoutubeMusicInstance"); let rest_client = reqwest::ClientBuilder::default() .default_headers(headers.clone()) .build() .map_err(YoutubeMusicError::RequestError)?; trace!("Fetching YoutubeMusic homepage"); let response: String = rest_client .get(YTM_DOMAIN) .headers(headers.clone()) .send() .await .map_err(YoutubeMusicError::RequestError)? .text() .await .map_err(YoutubeMusicError::RequestError)?; trace!("Fetched"); if response.contains("") || response.contains("") { error!("Need to login"); return Err(YoutubeMusicError::NeedToLogin); } trace!("Parsing cookies"); let cookies = headers .get("Cookie") .ok_or(YoutubeMusicError::NoCookieAttribute)?; let cookies_bytes = cookies.as_bytes(); let cookies = String::from_utf8(cookies_bytes.to_vec()) .map_err(YoutubeMusicError::InvalidCookie)? .to_string(); let sapisid = cookies .between("SAPISID=", ";") .ok_or_else(|| YoutubeMusicError::NoSapsidInCookie)?; trace!("Cookies parsed! SAPISID: {}", sapisid); let innertube_api_key = response .between("INNERTUBE_API_KEY\":\"", "\"") .ok_or_else(|| YoutubeMusicError::CantFindInnerTubeApiKey(response.to_string()))?; trace!("Innertube API key: {}", innertube_api_key); let client_version = response .between("INNERTUBE_CLIENT_VERSION\":\"", "\"") .ok_or_else(|| { YoutubeMusicError::CantFindInnerTubeClientVersion(response.to_string()) })?; trace!("Innertube client version: {}", client_version); // New file for brand accounts, maybe put it in config or headers.txt is better but more complex. trace!("account id {:?}", account_id); Ok(Self { sapisid: sapisid.to_string(), innertube_api_key: innertube_api_key.to_string(), client_version: client_version.to_string(), cookies, account_id, }) } fn compute_sapi_hash(&self) -> String { let start = SystemTime::now(); let since_the_epoch = start .duration_since(UNIX_EPOCH) .expect("Time went backwards"); let timestamp = since_the_epoch.as_secs(); let mut hasher = Sha1::new(); hasher.update(format!("{timestamp} {} {YTM_DOMAIN}", self.sapisid)); let result = hasher.finalize(); let mut hex = String::with_capacity(40); for byte in result { hex.push_str(&format!("{byte:02x}")); } trace!("Computed SAPI Hash{timestamp}_{hex}"); format!("{timestamp}_{hex}") } async fn browse_continuation( &self, continuation: &Continuation, continuations: bool, ) -> Result<(Value, Vec)> { let playlist_json: Value = serde_json::from_str(&self.browse_continuation_raw(continuation).await?) .map_err(YoutubeMusicError::SerdeJson)?; debug!("Browse continuation response: {playlist_json}"); if playlist_json.get("error").is_some() { error!("Error in browse_continuation"); error!("{:?}", playlist_json); return Err(YoutubeMusicError::YoutubeMusicError(playlist_json)); } let continuation = if continuations { from_json(&playlist_json, get_continuation)? } else { Vec::new() }; Ok((playlist_json, continuation)) } async fn browse_continuation_raw( &self, Continuation { continuation, click_tracking_params, }: &Continuation, ) -> Result { trace!("Browse continuation {continuation}"); let url = format!( "https://music.youtube.com/youtubei/v1/browse?ctoken={continuation}&continuation={continuation}&type=next&itct={click_tracking_params}&key={}&prettyPrint=false", self.innertube_api_key ); // let body = format!( // r#"{{"context":{{"client":{{"clientName":"WEB_REMIX","clientVersion":"{}"}}}}}}"#, // self.client_version // ); let body = match &self.account_id { Some(id) => format!( r#"{{"context":{{"client":{{"clientName":"WEB_REMIX","clientVersion":"{}"}},"user":{{"onBehalfOfUser":"{id}"}}}}}}"#, self.client_version ), None => format!( r#"{{"context":{{"client":{{"clientName":"WEB_REMIX","clientVersion":"{}"}}}}}}"#, self.client_version ), }; reqwest::Client::new() .post(&url) .header("Content-Type", "application/json") .header( "Authorization", format!("SAPISIDHASH {}", self.compute_sapi_hash()), ) .header("X-Origin", "https://music.youtube.com") .header("Cookie", &self.cookies) .body(body) .send() .await .map_err(YoutubeMusicError::RequestError)? .text() .await .map_err(YoutubeMusicError::RequestError) } async fn browse_raw( &self, endpoint_route: &str, endpoint_key: &str, endpoint_param: &str, ) -> Result { trace!("Browse {endpoint_route}"); let url = format!( "https://music.youtube.com/youtubei/v1/{endpoint_route}?key={}&prettyPrint=false", self.innertube_api_key ); let body = match &self.account_id { Some(id) => format!( r#"{{"context":{{"client":{{"clientName":"WEB_REMIX","clientVersion":"{}"}},"user":{{"onBehalfOfUser":"{id}"}}}},"{endpoint_key}":"{endpoint_param}"}}"#, self.client_version ), None => format!( r#"{{"context":{{"client":{{"clientName":"WEB_REMIX","clientVersion":"{}"}}}},"{endpoint_key}":"{endpoint_param}"}}"#, self.client_version ), }; reqwest::Client::new() .post(&url) .header("Content-Type", "application/json") .header( "Authorization", format!("SAPISIDHASH {}", self.compute_sapi_hash()), ) .header("X-Origin", "https://music.youtube.com") .header("Cookie", &self.cookies) .body(body) .send() .await .map_err(YoutubeMusicError::RequestError)? .text() .await .map_err(YoutubeMusicError::RequestError) } async fn browse( &self, endpoint: &Endpoint, continuations: bool, ) -> Result<(serde_json::Value, Vec)> { let playlist_json: Value = serde_json::from_str( &self .browse_raw( &endpoint.get_route(), &endpoint.get_key(), &endpoint.get_param(), ) .await?, ) .map_err(YoutubeMusicError::SerdeJson)?; debug!("Browse response: {playlist_json}"); if playlist_json.get("error").is_some() { error!("Error in browse ({endpoint:?})"); error!("{:?}", playlist_json); return Err(YoutubeMusicError::YoutubeMusicError(playlist_json)); } let continuation = if continuations { from_json(&playlist_json, get_continuation)? } else { Vec::new() }; Ok((playlist_json, continuation)) } pub async fn get_library( &self, endpoint: &Endpoint, mut n_continuations: usize, ) -> Result> { let (library_json, mut continuations) = self.browse(endpoint, n_continuations > 0).await?; trace!("Fetched library"); debug!("Library response: {library_json}"); debug!("Continuations: {continuations:?}"); let mut library = from_json(&library_json, get_playlist)?; debug!("Library: {library:?}"); while let Some(continuation) = continuations.pop() { n_continuations -= 1; trace!("Fetching continuation {continuation:?} ({endpoint:?})"); let (library_json, new_continuations) = self .browse_continuation(&continuation, (n_continuations - 1) > 0) .await?; debug!("Library response: {library_json}"); continuations.extend(new_continuations); let new_library = from_json(&library_json, get_playlist)?; trace!("Fetched {} playlists", new_library.len()); debug!("Library response: {library_json}"); library.extend(new_library); if n_continuations == 0 { break; } } Ok(library) } pub async fn get_playlist( &self, playlist: &YoutubeMusicPlaylistRef, n_continuations: usize, ) -> Result> { self.get_playlist_raw(&playlist.browse_id, n_continuations) .await } pub async fn get_playlist_raw( &self, playlist_id: &str, mut n_continuations: usize, ) -> Result> { let (playlist_json, mut continuations) = self .browse( &Endpoint::Playlist(playlist_id.to_string()), n_continuations > 0, ) .await?; trace!("Fetched playlist"); debug!("Playlist response: {playlist_json}"); debug!("Continuations: {continuations:?}"); let mut videos = parse_playlist(&playlist_json)?; debug!("Videos: {videos:?}"); while let Some(continuation) = continuations.pop() { n_continuations -= 1; trace!("Fetching continuation {continuation:?}"); let (playlist_json, new_continuations) = self .browse_continuation(&continuation, (n_continuations - 1) > 0) .await?; debug!("Playlist response: {playlist_json}"); continuations.extend(new_continuations); let new_videos = parse_playlist(&playlist_json)?; trace!("Fetched {} videos", new_videos.len()); debug!("Playlist response: {playlist_json}"); videos.extend(new_videos); if n_continuations == 0 { break; } } Ok(videos) } pub async fn search( &self, search_query: &str, mut n_continuations: usize, ) -> Result { let (search_json, mut continuations) = self .browse(&Endpoint::Search(search_query.to_string()), false) .await?; debug!("Search response: {search_json}"); let mut videos = from_json(&search_json, get_video)?; debug!("Videos: {videos:?}"); let mut playlists = from_json(&search_json, get_playlist_search)?; debug!("Playlists: {playlists:?}"); while let Some(continuation) = continuations.pop() { n_continuations -= 1; trace!("Fetching continuation {continuation:?}"); let (search_json, new_continuations) = self.browse_continuation(&continuation, false).await?; trace!("Search response: {search_json}"); continuations.extend(new_continuations); let new_videos = from_json(&search_json, get_video)?; debug!("Videos: {videos:?}"); let new_playlists = from_json(&search_json, get_playlist_search)?; debug!("Playlists: {playlists:?}"); videos.extend(new_videos); playlists.extend(new_playlists); if n_continuations == 0 { break; } } Ok(SearchResults { videos, playlists }) } pub async fn get_home(&self, mut n_continuations: usize) -> Result { let (home_json, mut continuations) = self .browse(&Endpoint::MusicHome, n_continuations > 0) .await?; debug!("Home response: {home_json}"); let mut videos = from_json(&home_json, get_video)?; debug!("Videos: {videos:?}"); let mut playlists = from_json(&home_json, get_playlist)?; debug!("Playlists: {playlists:?}"); while let Some(continuation) = continuations.pop() { n_continuations -= 1; trace!("Fetching continuation {continuation:?}"); let (home_json, new_continuations) = self .browse_continuation(&continuation, n_continuations > 0) .await?; debug!("Home response: {home_json}"); continuations.extend(new_continuations); let new_videos = from_json(&home_json, get_video)?; debug!("Videos: {videos:?}"); let new_playlists = from_json(&home_json, get_playlist)?; debug!("Playlists: {playlists:?}"); videos.extend(new_videos); playlists.extend(new_playlists); if n_continuations == 0 { break; } } Ok(SearchResults { playlists, videos }) } } fn parse_playlist(playlist_json: &Value) -> Result> { let mut videos = from_json(playlist_json, get_video)?; let info = extract_playlist_info(playlist_json); for mut video in from_json(playlist_json, get_video_from_album)? { if videos.iter().any(|x| x.video_id == video.video_id) { continue; } if let Some((title, artist)) = info.as_ref() { if video.album.is_empty() { video.album = title.to_string(); } if video.author.is_empty() { video.author = artist.to_string(); } } videos.push(video); } Ok(videos) } #[derive(Debug, Clone, PartialOrd, Eq, Ord, PartialEq, Hash)] pub struct SearchResults { pub videos: Vec, pub playlists: Vec, } #[derive(Debug, Clone, PartialOrd, Eq, Ord, PartialEq, Hash)] pub enum Endpoint { MusicLikedPlaylists, MusicHome, MusicLibraryLanding, Playlist(String), Search(String), } impl Endpoint { fn get_key(&self) -> String { match self { Endpoint::MusicLikedPlaylists => "browseId".to_owned(), Endpoint::MusicLibraryLanding => "browseId".to_owned(), Endpoint::Playlist(_) => "browseId".to_owned(), Endpoint::MusicHome => "browseId".to_owned(), Endpoint::Search(_) => "query".to_owned(), } } fn get_param(&self) -> String { match self { Endpoint::MusicLikedPlaylists => "FEmusic_liked_playlists".to_owned(), Endpoint::MusicLibraryLanding => "FEmusic_library_landing".to_owned(), Endpoint::Playlist(id) => id.to_owned(), Endpoint::Search(query) => query.to_owned(), Endpoint::MusicHome => "FEmusic_home".to_owned(), } } fn get_route(&self) -> String { match self { Endpoint::MusicLikedPlaylists => "browse".to_owned(), Endpoint::MusicLibraryLanding => "browse".to_owned(), Endpoint::Playlist(_) => "browse".to_owned(), Endpoint::Search(_) => "search".to_owned(), Endpoint::MusicHome => "browse".to_owned(), } } } #[derive(Debug)] pub enum YoutubeMusicError { RequestError(reqwest::Error), Other(String), NoCookieAttribute, NoSapsidInCookie, InvalidCookie(FromUtf8Error), NeedToLogin, CantFindInnerTubeApiKey(String), CantFindInnerTubeClientVersion(String), CantFindVisitorData(String), SerdeJson(serde_json::Error), IoError(std::io::Error), YoutubeMusicError(Value), InvalidHeaders, } ================================================ FILE: crates/ytpapi2/src/string_utils.rs ================================================ use std::str::FromStr; #[allow(dead_code)] pub trait StringUtils { fn after(&self, needle: &str) -> Option<&str>; fn before(&self, needle: &str) -> Option<&str>; fn between(&self, start: &str, end: &str) -> Option<&str>; fn to_owned_(&self) -> Option; fn parse_(&self) -> Option; fn trim_(&self) -> Option<&str>; } impl StringUtils for &str { fn after(&self, needle: &str) -> Option<&str> { Some(&self[self.find(needle)? + needle.len()..]) } fn before(&self, needle: &str) -> Option<&str> { Some(&self[..self.find(needle)?]) } fn between(&self, start: &str, end: &str) -> Option<&str> { let string: &str = &self[self.find(start)? + start.len()..]; Some(&string[..string.find(end)?]) } fn to_owned_(&self) -> Option { Some(self.to_string()) } fn parse_(&self) -> Option { T::from_str(self).ok() } fn trim_(&self) -> Option<&str> { Some(str::trim(self)) } } impl StringUtils for String { fn after(&self, needle: &str) -> Option<&str> { Some(&self[self.find(needle)? + needle.len()..]) } fn before(&self, needle: &str) -> Option<&str> { Some(&self[..self.find(needle)?]) } fn between(&self, start: &str, end: &str) -> Option<&str> { let string: &str = &self[self.find(start)? + start.len()..]; Some(&string[..string.find(end)?]) } fn to_owned_(&self) -> Option { Some(self.to_string()) } fn parse_(&self) -> Option { T::from_str(self).ok() } fn trim_(&self) -> Option<&str> { Some(str::trim(self)) } } impl StringUtils for Option { fn after(&self, needle: &str) -> Option<&str> { self.as_ref().and_then(|string| string.after(needle)) } fn before(&self, needle: &str) -> Option<&str> { self.as_ref().and_then(|string| string.before(needle)) } fn between(&self, start: &str, end: &str) -> Option<&str> { self.as_ref().and_then(|string| string.between(start, end)) } fn to_owned_(&self) -> Option { self.as_ref().and_then(|string| string.to_owned_()) } fn parse_(&self) -> Option { self.as_ref().and_then(|string| string.parse_::()) } fn trim_(&self) -> Option<&str> { self.as_ref().and_then(|string| string.trim_()) } }