├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug.yml │ ├── install_issue.yml │ └── xfeature.yml ├── apple.png ├── archlinux-icon.svg ├── korben.png ├── pull_request_template.md ├── tux.png └── workflows │ └── release.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── SECURITY.md ├── assets ├── demo_1.gif ├── demo_2.gif └── demo_3.gif ├── config.example.toml ├── hello_toutui.sh ├── known_bugs.md ├── linux └── toutui.desktop ├── macos ├── Info.plist └── launch.command └── src ├── api ├── libraries │ ├── get_all_books.rs │ ├── get_all_libraries.rs │ ├── get_library_perso_view.rs │ ├── get_library_perso_view_pod.rs │ └── mod.rs ├── library_items │ ├── get_pod_ep.rs │ ├── mod.rs │ └── play_lib_item_or_pod.rs ├── me │ ├── get_media_progress.rs │ ├── mod.rs │ └── update_media_progress.rs ├── mod.rs ├── server │ ├── auth_process.rs │ └── mod.rs ├── sessions │ ├── close_open_session.rs │ ├── mod.rs │ └── sync_open_session.rs └── utils │ ├── collect_get_all_books.rs │ ├── collect_get_all_libraries.rs │ ├── collect_get_media_progress.rs │ ├── collect_get_pod_ep.rs │ ├── collect_personalized_view.rs │ ├── collect_personalized_view_pod.rs │ └── mod.rs ├── app.rs ├── config.rs ├── db ├── crud.rs ├── database_struct.rs └── mod.rs ├── logic ├── auth │ ├── auth_input.rs │ └── mod.rs ├── handle_input │ ├── handle_l_book.rs │ ├── handle_l_pod.rs │ ├── handle_l_pod_home.rs │ └── mod.rs ├── mod.rs ├── search │ ├── mod.rs │ └── search_active.rs └── sync_session │ ├── mod.rs │ ├── sync_session_from_database.rs │ └── wait_prev_session_finished.rs ├── login_app.rs ├── main.rs ├── player ├── integrated │ ├── handle_key_player.rs │ ├── mod.rs │ └── player_info.rs ├── mod.rs └── vlc │ ├── exec_nc.rs │ ├── fetch_vlc_data.rs │ ├── mod.rs │ ├── quit_vlc.rs │ └── start_vlc.rs ├── ui ├── login_tui.rs ├── mod.rs ├── player_tui.rs └── tui.rs └── utils ├── changelog.rs ├── check_update.rs ├── clap.rs ├── convert_seconds.rs ├── encrypt_token.rs ├── exit_app.rs ├── logs.rs ├── mod.rs ├── pop_up_message.rs └── vlc_tcp_stream.rs /.gitattributes: -------------------------------------------------------------------------------- 1 | *.sh linguist-vendored 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: albandavid 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: #albdav 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | name: 😬 Bug Report 2 | description: File a bug/issue allow to improve Toutui 3 | title: '[Bug]: ' 4 | labels: ['bug'] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: '🦜 Thanks for taking the time to fill out this bug report!' 9 | - type: markdown 10 | attributes: 11 | value: 'Please first check if the bug is listed into [known bugs](https://github.com/AlbanDAVID/Toutui/blob/main/known_bugs.md) or issues.' 12 | - type: textarea 13 | id: what-happened 14 | attributes: 15 | label: What happened? 16 | placeholder: Tell us what you see and give screenshot if it's applicable. 17 | validations: 18 | required: true 19 | - type: textarea 20 | id: what-was-expected 21 | attributes: 22 | label: What did you expect to happen? 23 | placeholder: Explain what you expected to see, give screenshot if it's applicable. 24 | validations: 25 | required: true 26 | - type: textarea 27 | id: steps-to-reproduce 28 | attributes: 29 | label: Steps to reproduce the issue 30 | value: '1. ' 31 | validations: 32 | required: true 33 | - type: markdown 34 | attributes: 35 | value: '## Install Environment' 36 | - type: input 37 | id: version 38 | attributes: 39 | label: Toutui version 40 | description: Do not put 'Latest version', please put the actual version here 41 | placeholder: 'e.g. v0.1.0-beta' 42 | validations: 43 | required: true 44 | - type: input 45 | id: audiobookshelf-version 46 | attributes: 47 | label: Audiobookshelf version 48 | description: Do not put 'Latest version', please put the actual version here 49 | placeholder: 'e.g. v2.19.4' 50 | validations: 51 | required: true 52 | - type: dropdown 53 | id: install-distro 54 | attributes: 55 | label: On which OS are you running Toutui? 56 | options: 57 | - Arch Linux 58 | - Ubuntu 59 | - Debian 60 | - macOS 61 | - Other (list in "Additional Notes" box) 62 | validations: 63 | required: true 64 | - type: dropdown 65 | id: install-method 66 | attributes: 67 | label: How did you install Toutui? 68 | options: 69 | - Easy installation (option 1, download the binary) 70 | - Easy installation (option 2, compilation) 71 | - From source, local clone 72 | - Other (list in "Additional Notes" box) 73 | validations: 74 | required: true 75 | - type: dropdown 76 | id: terminal-emulator 77 | attributes: 78 | label: On which terminal emulator are you running Toutui? 79 | options: 80 | - Alacritty 81 | - Kitty 82 | - GNOME Terminal 83 | - Konsole 84 | - Other 85 | validations: 86 | required: true 87 | - type: textarea 88 | id: logs 89 | attributes: 90 | label: Logs 91 | description: Logs are present in ~/.config/toutui/toutui.log 92 | placeholder: Paste logs here 93 | - type: textarea 94 | id: panicked-message 95 | attributes: 96 | label: Panicked/Crash message 97 | description: If the app panicked/crashed and left a message 98 | placeholder: Paste message here 99 | - type: textarea 100 | id: additional-notes 101 | attributes: 102 | label: Additional Notes 103 | description: Anything else you want to add? 104 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/install_issue.yml: -------------------------------------------------------------------------------- 1 | name: 🔧 Install / Update / Uninstall Issue 2 | description: If you have any issue during the installation, update or uninstall 3 | title: '[Install / Update / Uninstall Issue]: ' 4 | labels: ['Install / Update / Uninstall Issue'] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: '🦜 Thanks for taking the time to fill out this issue!' 9 | - type: markdown 10 | attributes: 11 | value: 'Please first check if the issue is listed into issues.' 12 | - type: textarea 13 | id: what-happened 14 | attributes: 15 | label: What happened? 16 | placeholder: Tell us what you see and give a screenshot if it's applicable. 17 | validations: 18 | required: true 19 | - type: textarea 20 | id: steps-to-reproduce 21 | attributes: 22 | label: Steps to reproduce the issue 23 | value: '1. ' 24 | - type: markdown 25 | attributes: 26 | value: '## Install Environment' 27 | - type: input 28 | id: version 29 | attributes: 30 | label: Toutui version 31 | description: Do not put 'Latest version', please put the actual version here 32 | placeholder: 'e.g. v0.1.0-beta' 33 | validations: 34 | required: true 35 | - type: dropdown 36 | id: install-distro 37 | attributes: 38 | label: On which OS ? 39 | options: 40 | - Arch Linux 41 | - Ubuntu 42 | - Debian 43 | - macOS 44 | - Other (list in "Additional Notes" box) 45 | validations: 46 | required: true 47 | - type: dropdown 48 | id: install-method 49 | attributes: 50 | label: Which install, update method? 51 | options: 52 | - Easy installation (option 1, download the binary) 53 | - Easy installation (option 2, compilation) 54 | - yay 55 | - From source, local clone 56 | - Other (list in "Additional Notes" box) 57 | validations: 58 | required: true 59 | - type: textarea 60 | id: error-message 61 | attributes: 62 | label: Error message 63 | description: Error message during the installation 64 | placeholder: Paste message here 65 | - type: textarea 66 | id: additional-notes 67 | attributes: 68 | label: Additional Notes 69 | description: Anything else you want to add? 70 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/xfeature.yml: -------------------------------------------------------------------------------- 1 | name: 🚀 Feature Request 2 | description: Request a feature/enhancement 3 | title: '[Enhancement]: ' 4 | labels: ['enhancement'] 5 | body: 6 | - type: dropdown 7 | id: enhancement-type 8 | attributes: 9 | label: Type of Enhancement 10 | options: 11 | - Feature 12 | - Code 13 | - UI 14 | - Documentation 15 | - type: textarea 16 | id: describe 17 | attributes: 18 | label: Describe the Feature/Enhancement 19 | description: Please help us understand what you want. 20 | placeholder: What is your vision? 21 | validations: 22 | required: true 23 | - type: textarea 24 | id: the-why 25 | attributes: 26 | label: Why would this be helpful? 27 | description: Please help us understand why this would enhance your experience. 28 | placeholder: Explain the "why" or "use case". 29 | validations: 30 | required: true 31 | -------------------------------------------------------------------------------- /.github/apple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlbanDAVID/Toutui/62ee88ee61871c9eb31228c6a00d4d9ce1d4a161/.github/apple.png -------------------------------------------------------------------------------- /.github/archlinux-icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/korben.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlbanDAVID/Toutui/62ee88ee61871c9eb31228c6a00d4d9ce1d4a161/.github/korben.png -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | ## Brief summary 8 | 9 | 10 | 11 | ## Which issue is fixed? 12 | 13 | 14 | 15 | ## In-depth Description 16 | 17 | 22 | 23 | ## How have you tested this? 24 | 25 | 26 | 27 | ## Screenshots 28 | 29 | 30 | -------------------------------------------------------------------------------- /.github/tux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlbanDAVID/Toutui/62ee88ee61871c9eb31228c6a00d4d9ce1d4a161/.github/tux.png -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | permissions: 4 | contents: write 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | create-release: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: taiki-e/create-gh-release-action@v1 16 | with: 17 | # (optional) Path to changelog. 18 | #changelog: CHANGELOG.md 19 | # (required) GitHub token for creating GitHub Releases. 20 | draft: true 21 | token: ${{ secrets.GITHUB_TOKEN }} 22 | 23 | upload-assets: 24 | needs: create-release 25 | strategy: 26 | matrix: 27 | include: 28 | - target: aarch64-unknown-linux-gnu 29 | os: ubuntu-latest 30 | # - target: aarch64-apple-darwin 31 | # os: macos-latest 32 | - target: x86_64-unknown-linux-gnu 33 | os: ubuntu-latest 34 | # - target: x86_64-apple-darwin 35 | # os: macos-latest 36 | # Universal macOS binary is supported as universal-apple-darwin. 37 | - target: universal-apple-darwin 38 | os: macos-latest 39 | runs-on: ${{ matrix.os }} 40 | steps: 41 | - uses: actions/checkout@v4 42 | - uses: taiki-e/upload-rust-binary-action@v1 43 | with: 44 | # (required) Comma-separated list of binary names (non-extension portion of filename) to build and upload. 45 | # Note that glob pattern is not supported yet. 46 | bin: toutui 47 | # (optional) Target triple, default is host triple. 48 | target: ${{ matrix.target }} 49 | # (required) GitHub token for uploading assets to GitHub Releases. 50 | token: ${{ secrets.GITHUB_TOKEN }} 51 | 52 | - name: Prepare assets 53 | run: | 54 | mkdir -p dist 55 | cp ./config.example.toml dist/ 56 | cp ./linux/toutui.desktop dist/ 57 | cp ./hello_toutui.sh dist/ 58 | 59 | - name: Create GitHub release and upload files 60 | uses: softprops/action-gh-release@v1 61 | with: 62 | draft: true 63 | files: dist/* 64 | env: 65 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | config.toml 3 | /src/db/db.sqlite3 4 | /src/toutui.log 5 | /src/changelog.txt 6 | /src/macos-sonoma 7 | /src/macos-sonoma.conf 8 | 9 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Toutui 2 | 3 | Thanks for your interest in **Toutui**! 🦜 4 | 5 | ## ⚠️ Beta Version 6 | This project is still in **heavy development**. I built this app to learn Rust. The code isn’t fully optimized or clear. However, feel free to contact me with any questions. There are known bugs (check [known_bugs](https://github.com/AlbanDAVID/Toutui/blob/main/known_bugs.md) and open issues). 7 | 8 | Check the [roadmap](https://github.com/AlbanDAVID/Toutui?tab=readme-ov-file#%EF%B8%8F-roadmap) to see what I'm currently working on. 9 | If you want to contribute new features but don't have any ideas, feel free to check the [future features](https://github.com/AlbanDAVID/Toutui?tab=readme-ov-file#-future-features) section for inspiration. 10 | 11 | ## 🔁 Branching workflow 12 | This project follow this [branching workflow](https://gist.github.com/digitaljhelms/4287848). 13 | 14 | ## 💬 How to Contribute 15 | - **Share your theme**: Check [here](https://github.com/AlbanDAVID/Toutui-theme). 16 | - **Suggestions/feedback**: Open an issue (feature request) or use [discussions](https://github.com/AlbanDAVID/Toutui/discussions). 17 | - **Bugs**: Report bugs not listed in issues or [known bugs](https://github.com/AlbanDAVID/Toutui/blob/main/known_bugs.md). Use the appropriate issue section (Installation issue or bug report). 18 | - **Code**: Fork the repo, create a branch, and submit a pull request. **I encourage you to discuss your ideas with me before a PR** (to ensure no one else is working on it). Code doesn’t need to be perfect—let’s collaborate and improve it together! 19 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "toutui" 3 | version = "0.4.2-beta" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | openssl = { version = "0.10.71", features = ["vendored"] } 8 | tokio = { version = "1", features = ["full"] } 9 | reqwest = { version = "0.11", features = ["json"] } 10 | serde = { version = "1.0", features = ["derive"] } 11 | serde_json = "1.0" 12 | serde_derive = "1.0" 13 | config = "0.15.6" 14 | color-eyre = "0.6.3" 15 | crossterm = "0.28.1" 16 | ratatui = "0.29.0" 17 | vlc-rc = "0.1.1" 18 | tui-textarea = "0.7.0" 19 | rusqlite = { version = "0.33.0", features = ["bundled"] } 20 | regex = "1.11.1" 21 | log = "0.4.25" 22 | fern = "0.7.1" 23 | chrono = "0.4.39" 24 | magic-crypt = "4.0.1" 25 | dotenv = "0.15.0" 26 | dirs = "6.0.0" 27 | clap = "4.5.37" 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![GitHub release](https://img.shields.io/github/v/release/AlbanDAVID/Toutui?label=Latest%20Release&color=green&cacheSeconds=3600)](https://github.com/AlbanDAVID/Toutui/releases/latest) 2 | ![AUR Version](https://img.shields.io/aur/version/toutui-bin?color=green&label=AUR) 3 | [![Release](https://github.com/AlbanDAVID/Toutui/actions/workflows/release.yml/badge.svg)](https://github.com/AlbanDAVID/Toutui/actions/workflows/release.yml) 4 | 5 | # 🦜 Toutui: A TUI Audiobookshelf client for Linux and macOS 6 | 7 |

8 | In French, being "tout ouïe" (toutui) means being all ears. 9 |

10 | 11 |

12 | 🎬 Demo 13 |

14 | 15 |
16 | 🎨 Explore and try various themes here. 17 |
18 | 19 | ## ✨ Features 20 | **Cross-platform** – Tux (Linux) Linux and Apple (macOS) macOS 21 | **Lightweight & Fast** – A minimalist terminal user interface (TUI) written in Rust 🦀 22 | **Supports Books & Podcasts** – Enjoy both audiobooks and podcasts 23 | **Sync Progress & Stats** – Keep your listening progress in sync 24 | **Streaming Support** – Play directly without downloading 25 | **Customizable Color Theme** – A config file will allow you to customize the color theme. Explore and try various themes [here](https://github.com/AlbanDAVID/Toutui-theme). 26 | 27 | ## 📰 Media 28 | Korben Featured on [Korben](https://korben.info/toutui-client-terminal-audiobookshelf.html), a well-known French tech blog covering open source and technology. 29 | 30 | 31 | ## 🛠️ Roadmap 32 | **Short-term Goals** 33 | - Since this is a beta version, the main focus is on tracking and fixing bugs. 34 | - Improve the design of the integrated player. 35 | - **Currently working on the next release: [v0.4.3-beta].** 36 | 37 | 38 | **Mid-term Goals** 39 | - CI/CD Implementation 40 | - Add future features described bellow. 41 | 42 | ## 🔮 Future features 43 | Here are some features that could be added in future releases: 44 | - Ability to add new podcasts from the app 45 | - Add stats 46 | - Offline mode 47 | 48 | ## ⚠️ Caution: Beta Version 49 | This beta app is still in **heavy development and contains bugs**. 50 | ❗Please check [here](https://github.com/AlbanDAVID/Toutui/blob/main/known_bugs.md) for known bugs especially **MAJOR BUGS** before using the app, so you can use it with full awareness of any known issues. 51 | If you encounter any issues that are **not yet listed** in the Issues section or into [known bugs](https://github.com/AlbanDAVID/Toutui/blob/main/known_bugs.md), please **open a new issue** to report them. 52 | 53 | 🔐 Although it's a beta version, you can use this app with **minimal risk** to your Audiobookshelf library. 54 | At worst, you may experience **sync issues**, but there is **no risk** of data loss, deletion, or irreversible changes (API is just used to retrieve books and sync them). 55 | 56 | ## 📝 Notes 57 | ### 🐛 **Issues** 58 | For any issues, check first the [wiki](https://github.com/AlbanDAVID/Toutui/wiki/) and [issues](https://github.com/AlbanDAVID/Toutui/issues). Otherwise, open a new one. 59 | 60 | ### 🤝 **Contributing** 61 | Do not hesitate to contribute to this project by submitting your code, ideas, or feedback. Please make sure to read the [contributing guidelines](https://github.com/AlbanDAVID/Toutui/blob/main/CONTRIBUTING.md) first. 62 | 63 | ### 🔁 Branching workflow 64 | This project follow this [branching workflow](https://gist.github.com/digitaljhelms/4287848). 65 | 66 | ### 🎨 **UI** 67 | Explore and share themes [here](https://github.com/AlbanDAVID/Toutui-theme). 68 | The **font** and **emojis** may vary depending on the terminal you are using. 69 | To ensure the best experience, it's recommended to use **Kitty** or **Alacritty** terminal. 70 | 71 | 72 | 73 | ## 🚨 Installation Instructions 74 | 75 | >[!WARNING] 76 | > - **This is a beta app, please read [this](https://github.com/AlbanDAVID/Toutui?tab=readme-ov-file#%EF%B8%8F-caution-beta-version).** 77 | > - For any issues, check first the [wiki](https://github.com/AlbanDAVID/Toutui/wiki/) and [issues](https://github.com/AlbanDAVID/Toutui/issues). Otherwise, open a new one. 78 | 79 | ### (Arch Linux) Arch Linux 80 | [![GitHub release](https://img.shields.io/github/v/release/AlbanDAVID/Toutui?label=Latest%20Release&color=green&cacheSeconds=3600)](https://github.com/AlbanDAVID/Toutui/releases/latest) 81 | ![AUR Version](https://img.shields.io/aur/version/toutui-bin?color=green&label=AUR) 82 | 83 | Installation and initial configuration 84 | ``` 85 | yay -S toutui 86 | mkdir -p ~/.config/toutui 87 | cp /usr/share/toutui/config.example.toml ~/.config/toutui/config.toml 88 | # Token encryption in the database (NOTE: replace 'secret'): 89 | echo 'TOUTUI_SECRET_KEY=secret' >> ~/.config/toutui/.env 90 | ``` 91 | Update 92 | ``` 93 | yay -S toutui 94 | ``` 95 | Uninstall 96 | ``` 97 | yay -R toutui-bin 98 | ``` 99 | 100 | ### ⚡ Easy installation 101 | 102 | **Run the following in your terminal, then follow the on-screen instructions:** 103 | 104 | [![GitHub release](https://img.shields.io/github/v/release/AlbanDAVID/Toutui?label=Latest%20Release&color=green&cacheSeconds=3600)](https://github.com/AlbanDAVID/Toutui/releases/latest) 105 | 106 | 107 | ```bash 108 | bash -c 'expected_sha256="b5c41bcd3c480fd2ca6ec0031ccecf2cf7cf4ae01f591cad64a320fa7d72331d" export expected_sha256 tmpfile=$(mktemp) && curl -LsSf https://github.com/AlbanDAVID/Toutui/raw/stable/hello_toutui.sh -o "$tmpfile" && bash "$tmpfile" install && rm -f "$tmpfile"' 109 | ``` 110 | 111 | #### **Update** 112 | 113 | > [!IMPORTANT] 114 | > `toutui --update` is not working. You can do this instead: 115 | > ``` 116 | > bash -c 'expected_sha256="b5c41bcd3c480fd2ca6ec0031ccecf2cf7cf4ae01f591cad64a320fa7d72331d" export expected_sha256 tmpfile=$(mktemp) && curl -LsSf https://github.com/AlbanDAVID/Toutui/raw/stable/hello_toutui.sh -o "$tmpfile" && bash "$tmpfile" update && rm -f "$tmpfile"' 117 | > ``` 118 | 119 | Quit the app and run the following in your terminal 120 | 121 | ```bash 122 | bash -c 'expected_sha256="b5c41bcd3c480fd2ca6ec0031ccecf2cf7cf4ae01f591cad64a320fa7d72331d" export expected_sha256 tmpfile=$(mktemp) && curl -LsSf https://github.com/AlbanDAVID/Toutui/raw/stable/hello_toutui.sh -o "$tmpfile" && bash "$tmpfile" update && rm -f "$tmpfile"' 123 | ``` 124 | 125 | #### **Uninstall** 126 | 127 | > [!IMPORTANT] 128 | > `toutui --uninstall` is not working. You can do this instead: 129 | > ``` 130 | > bash -c 'expected_sha256="b5c41bcd3c480fd2ca6ec0031ccecf2cf7cf4ae01f591cad64a320fa7d72331d" export expected_sha256 tmpfile=$(mktemp) && curl -LsSf https://github.com/AlbanDAVID/Toutui/raw/stable/hello_toutui.sh -o "$tmpfile" && bash "$tmpfile" uninstall && rm -f "$tmpfile"' 131 | > ``` 132 | 133 | Quit the app and run the following in your terminal 134 | 135 | 136 | ```bash 137 | bash -c 'expected_sha256="b5c41bcd3c480fd2ca6ec0031ccecf2cf7cf4ae01f591cad64a320fa7d72331d" export expected_sha256 tmpfile=$(mktemp) && curl -LsSf https://github.com/AlbanDAVID/Toutui/raw/stable/hello_toutui.sh -o "$tmpfile" && bash "$tmpfile" uninstall && rm -f "$tmpfile"' 138 | ``` 139 | 140 | #### **Notes** 141 | 142 | ##### Files installed: 143 | In `/usr/local/bin` (option 1, from install script) or `~/.cargo/bin` (option 2, from install script) or `/usr/bin` (yay) : 144 | - `toutui` — The binary file. 145 | 146 | In `~/.config/toutui` for Linux or `~/Library/Preferences` for macOS: 147 | **Note**: This is the default path if `XDG_CONFIG_HOME` is empty. 148 | - `.env` — Contains the secret key. 149 | - `config.toml` — Configuration file. 150 | - `toutui.log` — Log file. 151 | - `db.sqlite3` — SQLite database file. 152 | 153 | In `~/.local/share/applications` (option 1, from install script) or `/usr/share/applications` (yay) for Linux: 154 | - `toutui.desktop` — Config file to launch Toutui from a launcher app. 155 | 156 | In `/usr/share/toutui` (yay): 157 | - `config.example.toml` — Configuration file. 158 | 159 | ### Install from source 160 | 161 | >[!WARNING] 162 | > This is a beta app, please read [this](https://github.com/AlbanDAVID/Toutui?tab=readme-ov-file#%EF%B8%8F-caution-beta-version). 163 | 164 | #### **Requirements** 165 | - `Rust` 166 | - `Netcat` 167 | - `VLC` 168 | 169 | [![GitHub release](https://img.shields.io/github/v/release/AlbanDAVID/Toutui?label=Latest%20Release&color=green&cacheSeconds=3600)](https://github.com/AlbanDAVID/Toutui/releases/latest) 170 | 171 | Note: `main` might be unstable. Prefer `git clone --branch stable --single-branch https://github.com/AlbanDAVID/Toutui` if you want to have the last stable release. 172 | ```bash 173 | git clone https://github.com/AlbanDAVID/Toutui 174 | cd Toutui/ 175 | mkdir -p ~/.config/toutui 176 | cp config.example.toml ~/.config/toutui/config.toml 177 | ``` 178 | 179 | Token encryption in the database (**NOTE**: replace `secret`) 180 | ```bash 181 | echo TOUTUI_SECRET_KEY=secret >> ~/.config/toutui/.env 182 | ``` 183 | 184 | ```bash 185 | cargo run --release 186 | ``` 187 | 188 | #### **Update** 189 | 190 | When a new release is available, follow these steps: 191 | 192 | ```bash 193 | git pull https://github.com/AlbanDAVID/Toutui 194 | cargo run --release 195 | ``` 196 | 197 | #### **Notes** 198 | ##### Exec the binary: 199 | ```bash 200 | cd target/release 201 | ./Toutui 202 | ``` 203 | 204 | ##### Files installed: 205 | After installation, you will have the following files in `~/.config/toutui` 206 | - `.env` — Contains the secret key. 207 | - `config.toml` — Configuration file. 208 | - `toutui.log` — Log file. 209 | - `db.sqlite3` — SQLite database file. 210 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | Contact me or use the GitHub Security Advisories to report any vulnerabilities. 4 | -------------------------------------------------------------------------------- /assets/demo_1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlbanDAVID/Toutui/62ee88ee61871c9eb31228c6a00d4d9ce1d4a161/assets/demo_1.gif -------------------------------------------------------------------------------- /assets/demo_2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlbanDAVID/Toutui/62ee88ee61871c9eb31228c6a00d4d9ce1d4a161/assets/demo_2.gif -------------------------------------------------------------------------------- /assets/demo_3.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlbanDAVID/Toutui/62ee88ee61871c9eb31228c6a00d4d9ce1d4a161/assets/demo_3.gif -------------------------------------------------------------------------------- /config.example.toml: -------------------------------------------------------------------------------- 1 | #### PLAYER OPTIONS #### 2 | [player] 3 | # By default, Toutui integrated player is launched (works with cvlc, launched in background, the command line 4 | # version of vlc). 5 | # 0: No, 1: Yes 6 | # Launch Toutui integrated player: 7 | # Set to 0 if you want to have vlc GUI. 8 | cvlc = "1" 9 | # Launch a terminal to control cvlc: 10 | # NOTE: `cvlc` must be set to 1 if you set `cvlc_term` to 1. 11 | cvlc_term = "0" 12 | 13 | # #### PLAYER NETWORK #### 14 | # By default, the player is launched to "localhost:1234" 15 | # Address: 16 | address = "localhost" 17 | # Port: 18 | port = "1234" 19 | 20 | #### COLORS (RGB) #### 21 | [colors] 22 | # General background color of the app: 23 | background_color = [40, 40, 40] 24 | # Login background color: 25 | log_background_color = [40, 40, 40] 26 | # Background color for section headers (e.g., "Continue Listening", "Library", etc.): 27 | header_background_color = [60, 60, 60] 28 | # Line color surrounding the header title: 29 | line_header_color = [180, 180, 180] 30 | # Background color of the book list: 31 | list_background_color = [50, 50, 50] 32 | # Background color for alternate rows of the book list: 33 | list_background_color_alt_row = [60, 60, 60] 34 | # Background color of the selected row of the book list: 35 | list_selected_background_color = [80, 80, 80] 36 | # Text color of the selected row of the book list: 37 | list_selected_foreground_color = [180, 180, 180] 38 | # Text and border color of the search bar: 39 | search_bar_foreground_color = [180, 180, 180] 40 | # Text and border color of the login section: 41 | login_foreground_color = [180, 180, 180] 42 | # Background color of the player: 43 | player_background_color = [80, 80, 80] 44 | -------------------------------------------------------------------------------- /known_bugs.md: -------------------------------------------------------------------------------- 1 | **MAJOR** 2 | 3 | No major bug for the moment 🙏 4 | 5 | **MINOR** 6 | 7 | `bug_id: 255b86` 8 | **Losing config after an update**: Ex: You change colors in config file and after an update, this configuration is lost and replaced by the config from main version. 9 | 10 | `bug_id: 4b3045` 11 | **Authentification Bug:** Even if you fill in valid credentials, the database sync can be buggy, and authentication may fail. Normally, it works on the second try. 12 | 13 | `bug_id: 2eb9e3` 14 | **Display:** At the launch, the app is not displayed and no error message appears (especially if you change user, quit and restart the app). Solution: quit the terminal and try it again. 15 | 16 | `bug_id: 2d358c53` 17 | **Mark as finished:** When a title reach the end, mark as finished not always work. 18 | 19 | `bug_id: a49eza` 20 | **cvlc error sync with ctrl vlc from a terminal:** If you use other command that `shutdown` to quit `cvlc` it may result of a sync issue. 21 | 22 | 23 | **FIXED** 24 | `bug_id: 9bacac` 25 | **Sync**: If you open VLC to listen X, close VLC and quickly open VLC again to listen Y: X will still be sync — according to Y (normally, only Y has to be sync in this case). 26 | `bug_id: 86384e` 27 | **Sync**: Rarely and especially if you open VLC to listen X, close VLC and quickly open VLC again to listen Y: the progress of X is set to 0 seconds. 28 | `bug_id: 06e548` 29 | **Terminal broken**: The terminal is broken after the app is quit. 30 | `bug_id: 6ac5d8` 31 | **Data loss if app crash or disgracefully quit**: If app crash, the last session is not closed. 32 | `bug_id: bf10cd` 33 | **Launch a new media**: Have to close manually VLC to close and sync a session. 34 | `bug_id: 3f729c` 35 | **Loading time**: for now, not optimized for a library with a lot of items (long start loading and refresh time) 36 | `bug_id: dd9a649` 37 | **Listening Session:** Sometimes, the session (that you can see in `yourserveraddress/audiobookshelf/config/sessions`) does not close correctly, especially if you open VLC, quit it quickly, and start another book. 38 | `bug_id: e0b61c` 39 | **VLC:** `VLC` continue to run after the app is quit. 40 | `bug_id: fc695f` 41 | **Listening session:** The session (that you can see in `yourserveraddress/audiobookshelf/config/sessions`) does not close when the app is quit. 42 | `bug_id: 40f48d` 43 | **Cursor:** When you quit the app, terminal cursor disappear. 44 | `bug_id: fe4116` 45 | **cvlc macOS:** `cvlc` option is not available for now in macOS. 46 | -------------------------------------------------------------------------------- /linux/toutui.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Toutui 3 | GenericName=Audiobookshelf client 4 | Exec=toutui 5 | Icon=utilities-terminal 6 | Type=Application 7 | Categories=Utility; 8 | Terminal=true 9 | 10 | -------------------------------------------------------------------------------- /macos/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | CFBundleName 7 | Toutui 8 | CFBundleIdentifier 9 | com.example.toutui 10 | CFBundleVersion 11 | 1.0 12 | CFBundleExecutable 13 | launch.command 14 | 15 | 16 | -------------------------------------------------------------------------------- /macos/launch.command: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | terminal="Terminal" 3 | open -a "$terminal" "$HOME/.cargo/bin/toutui" 4 | -------------------------------------------------------------------------------- /src/api/libraries/get_all_books.rs: -------------------------------------------------------------------------------- 1 | use reqwest::Client; 2 | use serde_json::Value; 3 | use reqwest::header::AUTHORIZATION; 4 | use color_eyre::eyre::{Result, Report}; 5 | use serde::Deserialize; 6 | use serde::Serialize; 7 | 8 | /// Get all books or podcasts from a library 9 | /// https://api.audiobookshelf.org/#get-a-library-39-s-items 10 | 11 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 12 | #[serde(rename_all = "camelCase")] 13 | pub struct Root { 14 | pub results: Option>, 15 | pub total: Option, 16 | pub limit: Option, 17 | pub page: Option, 18 | pub sort_by: Option, 19 | pub sort_desc: Option, 20 | pub filter_by: Option, 21 | pub media_type: Option, 22 | pub minified: Option, 23 | pub collapseseries: Option, 24 | pub include: Option, 25 | } 26 | 27 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 28 | #[serde(rename_all = "camelCase")] 29 | pub struct LibraryItem { 30 | pub id: Option, 31 | pub ino: Option, 32 | pub library_id: Option, 33 | pub folder_id: Option, 34 | pub path: Option, 35 | pub rel_path: Option, 36 | pub is_file: Option, 37 | pub mtime_ms: Option, 38 | pub ctime_ms: Option, 39 | pub birthtime_ms: Option, 40 | pub added_at: Option, 41 | pub updated_at: Option, 42 | pub is_missing: Option, 43 | pub is_invalid: Option, 44 | pub media_type: Option, 45 | pub media: Option, 46 | pub num_files: Option, 47 | pub size: Option, 48 | pub collapsed_series: Option, 49 | } 50 | 51 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 52 | #[serde(rename_all = "camelCase")] 53 | pub struct Media { 54 | pub metadata: Option, 55 | pub cover_path: Option, 56 | pub tags: Option>, 57 | pub num_tracks: Option, 58 | pub num_audio_files: Option, 59 | pub num_chapters: Option, 60 | pub duration: Option, 61 | pub size: Option, 62 | pub ebook_file_format: Option, 63 | } 64 | 65 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 66 | #[serde(rename_all = "camelCase")] 67 | pub struct Metadata { 68 | pub title: Option, 69 | pub title_ignore_prefix: Option, 70 | pub subtitle: Option, 71 | pub author_name: Option, 72 | pub author: Option, 73 | pub narrator_name: Option, 74 | pub series_name: Option, 75 | pub genres: Option>, 76 | pub published_year: Option, 77 | pub published_date: Option, 78 | pub publisher: Option, 79 | pub description: Option, 80 | pub isbn: Option, 81 | pub asin: Option, 82 | pub language: Option, 83 | pub explicit: Option, 84 | } 85 | 86 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 87 | #[serde(rename_all = "camelCase")] 88 | pub struct CollapsedSeries { 89 | pub id: Option, 90 | pub name: Option, 91 | pub name_ignore_prefix: Option, 92 | pub num_books: Option, 93 | } 94 | 95 | // get all books or podcasts 96 | pub async fn get_all_books(token: &str, id_selected_lib: &String, server_address: String) -> Result { 97 | let client = Client::new(); 98 | let url = format!("{}/api/libraries/{}/items?limit=0", server_address, id_selected_lib); 99 | 100 | 101 | // Send GET request 102 | let response = client 103 | .get(url) 104 | .header(AUTHORIZATION, format!("Bearer {}", token)) 105 | .send() 106 | .await?; 107 | 108 | // Check response status 109 | if !response.status().is_success() { 110 | return Err(Report::new(std::io::Error::new( 111 | std::io::ErrorKind::Other, 112 | "Failed to fetch data from the API", 113 | ))); 114 | } 115 | 116 | // Deserialize JSON response into Vec 117 | let library: Root = response.json().await?; 118 | 119 | Ok(library) 120 | } 121 | 122 | -------------------------------------------------------------------------------- /src/api/libraries/get_all_libraries.rs: -------------------------------------------------------------------------------- 1 | use reqwest::Client; 2 | use serde_json::Value; 3 | use reqwest::header::AUTHORIZATION; 4 | use color_eyre::eyre::{Result, Report}; 5 | use serde::Deserialize; 6 | use serde::Serialize; 7 | 8 | 9 | /// Get All Libraries (can be a podcast or book library (shelf)) 10 | /// https://api.audiobookshelf.org/#get-all-libraries 11 | 12 | 13 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 14 | #[serde(rename_all = "camelCase")] 15 | pub struct Root { 16 | pub libraries: Vec, 17 | } 18 | 19 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 20 | #[serde(rename_all = "camelCase")] 21 | pub struct Library { 22 | pub id: String, 23 | pub name: String, 24 | pub folders: Vec, 25 | pub display_order: i64, 26 | pub icon: String, 27 | pub media_type: String, 28 | pub provider: String, 29 | pub settings: Settings, 30 | pub last_scan: Option, 31 | pub last_scan_version: Option, 32 | pub created_at: i64, 33 | pub last_update: i64, 34 | } 35 | 36 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 37 | #[serde(rename_all = "camelCase")] 38 | pub struct Folder { 39 | pub id: String, 40 | pub full_path: String, 41 | pub library_id: String, 42 | pub added_at: i64, 43 | } 44 | 45 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 46 | #[serde(rename_all = "camelCase")] 47 | pub struct Settings { 48 | pub cover_aspect_ratio: i64, 49 | pub disable_watcher: bool, 50 | pub auto_scan_cron_expression: Value, 51 | pub skip_matching_media_with_asin: Option, 52 | pub skip_matching_media_with_isbn: Option, 53 | pub audiobooks_only: Option, 54 | pub epubs_allow_scripted_content: Option, 55 | pub hide_single_book_series: Option, 56 | pub only_show_later_books_in_continue_series: Option, 57 | pub metadata_precedence: Option>, 58 | #[serde(default)] 59 | pub mark_as_finished_percent_complete: Value, 60 | #[serde(default)] 61 | pub mark_as_finished_time_remaining: i64, 62 | pub podcast_search_region: Option, 63 | } 64 | 65 | // get all libraries (shelf). A library can be a Podcast or a Book type 66 | pub async fn get_all_libraries(token: &str, server_address: String) -> Result { 67 | let client = Client::new(); 68 | let url = format!("{}/api/libraries", server_address); 69 | 70 | // Send GET request 71 | let response = client 72 | .get(url) 73 | .header(AUTHORIZATION, format!("Bearer {}", token)) 74 | .send() 75 | .await?; 76 | 77 | // Check response status 78 | if !response.status().is_success() { 79 | return Err(Report::new(std::io::Error::new( 80 | std::io::ErrorKind::Other, 81 | "Failed to fetch data from the API", 82 | ))); 83 | } 84 | 85 | // Deserialize JSON response into Vec 86 | let libraries: Root = response.json().await?; 87 | 88 | Ok(libraries) 89 | } 90 | -------------------------------------------------------------------------------- /src/api/libraries/get_library_perso_view.rs: -------------------------------------------------------------------------------- 1 | use reqwest::Client; 2 | use serde_json::Value; 3 | use reqwest::header::AUTHORIZATION; 4 | use color_eyre::eyre::{Result, Report}; 5 | use serde::Deserialize; 6 | use serde::Serialize; 7 | 8 | /// Get a PersonalizedView's Personalized View for book (allow to have continue linstening) 9 | /// https://api.audiobookshelf.org/#get-a-library-39-s-personalized-view 10 | 11 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 12 | #[serde(rename_all = "camelCase")] 13 | pub struct Root { 14 | pub id: Option, 15 | pub label: String, 16 | pub entities: Option>, 17 | } 18 | 19 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 20 | #[serde(rename_all = "camelCase")] 21 | pub struct Entity { 22 | pub id: Option, 23 | pub library_id: Option, 24 | pub folder_id: Option, 25 | pub path: Option, 26 | pub media: Option, 27 | pub name: Option, 28 | #[serde(default)] 29 | pub books: Option>, 30 | pub in_progress: Option, 31 | pub has_active_book: Option, 32 | pub hide_from_continue_listening: Option, 33 | pub book_in_progress_last_update: Option, 34 | } 35 | 36 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 37 | #[serde(rename_all = "camelCase")] 38 | pub struct Media { 39 | pub metadata: Option, 40 | pub cover_path: Option, 41 | pub tags: Option>, 42 | pub num_tracks: Option, 43 | pub num_audio_files: Option, 44 | pub num_chapters: Option, 45 | pub duration: Option, 46 | pub size: Option, 47 | } 48 | 49 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 50 | #[serde(rename_all = "camelCase")] 51 | pub struct Metadata { 52 | pub title: Option, 53 | pub title_ignore_prefix: Option, 54 | pub author_name: Option, 55 | pub narrator_name: Option, 56 | pub series_name: Option, 57 | pub genres: Option>, 58 | pub published_year: Option, 59 | pub publisher: Option, 60 | pub description: Option, 61 | pub asin: Option, 62 | pub explicit: Option, 63 | pub series: Option, 64 | } 65 | 66 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 67 | #[serde(rename_all = "camelCase")] 68 | pub struct Series { 69 | pub id: Option, 70 | pub name: Option, 71 | pub sequence: Option, 72 | } 73 | 74 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 75 | #[serde(rename_all = "camelCase")] 76 | pub struct Book { 77 | pub id: Option, 78 | pub ino: Option, 79 | pub library_id: Option, 80 | pub folder_id: Option, 81 | pub path: Option, 82 | pub rel_path: Option, 83 | pub is_file: Option, 84 | pub mtime_ms: Option, 85 | pub ctime_ms: Option, 86 | pub birthtime_ms: Option, 87 | pub added_at: Option, 88 | pub updated_at: Option, 89 | pub is_missing: Option, 90 | pub is_invalid: Option, 91 | pub media_type: Option, 92 | pub num_files: Option, 93 | pub size: Option, 94 | pub series_sequence: Option, 95 | } 96 | 97 | // filter only book continue to listening from personalized view 98 | pub async fn get_continue_listening(token: &str, server_address: String, id_selected_lib: &String) -> Result> { 99 | let client = Client::new(); 100 | let url = format!("{}/api/libraries/{}/personalized", server_address, id_selected_lib); 101 | 102 | // Send GET request 103 | let response = client 104 | .get(url) 105 | .header(AUTHORIZATION, format!("Bearer {}", token)) 106 | .send() 107 | .await?; 108 | 109 | // Check response status 110 | if !response.status().is_success() { 111 | return Err(Report::new(std::io::Error::new( 112 | std::io::ErrorKind::Other, 113 | "Failed to fetch data from the API", 114 | ))); 115 | } 116 | 117 | // Deserialize JSON response into Vec 118 | let libraries: Vec = response.json().await?; 119 | 120 | // Filter libraries to keep only those with label "Continue Listening" 121 | let continue_listening: Vec = libraries 122 | .into_iter() 123 | .filter(|lib| lib.label == "Continue Listening") 124 | .collect(); 125 | 126 | Ok(continue_listening) 127 | } 128 | 129 | -------------------------------------------------------------------------------- /src/api/libraries/get_library_perso_view_pod.rs: -------------------------------------------------------------------------------- 1 | use reqwest::Client; 2 | use serde_json::Value; 3 | use reqwest::header::AUTHORIZATION; 4 | use color_eyre::eyre::{Result, Report}; 5 | use serde::Deserialize; 6 | use serde::Serialize; 7 | 8 | /// Get a PersonalizedView's Personalized View for podcast(allow to have continue linstening) 9 | /// https://api.audiobookshelf.org/#get-a-library-39-s-personalized-view 10 | 11 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 12 | #[serde(rename_all = "camelCase")] 13 | pub struct Root { 14 | pub id: Option, 15 | pub label: String, 16 | pub label_string_key: Option, 17 | #[serde(rename = "type")] 18 | pub type_field: Option, 19 | pub entities: Option>, 20 | pub total: Option, 21 | } 22 | 23 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 24 | #[serde(rename_all = "camelCase")] 25 | pub struct Entity { 26 | pub id: Option, 27 | pub ino: Option, 28 | pub old_library_item_id: Option, 29 | pub library_id: Option, 30 | pub folder_id: Option, 31 | pub path: Option, 32 | pub rel_path: Option, 33 | pub is_file: Option, 34 | pub mtime_ms: Option, 35 | pub ctime_ms: Option, 36 | pub birthtime_ms: Option, 37 | pub added_at: Option, 38 | pub updated_at: Option, 39 | pub is_missing: Option, 40 | pub is_invalid: Option, 41 | pub media_type: Option, 42 | pub media: Option, 43 | pub num_files: Option, 44 | pub size: Option, 45 | pub recent_episode: Option, 46 | } 47 | 48 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 49 | #[serde(rename_all = "camelCase")] 50 | pub struct Media { 51 | pub id: Option, 52 | pub metadata: Option, 53 | pub cover_path: Option, 54 | pub tags: Option>, 55 | pub num_episodes: Option, 56 | pub auto_download_episodes: Option, 57 | pub auto_download_schedule: Option, 58 | pub last_episode_check: Option, 59 | pub max_episodes_to_keep: Option, 60 | pub max_new_episodes_to_download: Option, 61 | pub size: Option, 62 | } 63 | 64 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 65 | #[serde(rename_all = "camelCase")] 66 | pub struct Metadata { 67 | pub title: Option, 68 | pub author: Option, 69 | pub description: Option, 70 | pub release_date: Option, 71 | pub genres: Option>, 72 | pub feed_url: Option, 73 | pub image_url: Option, 74 | pub itunes_page_url: Option, 75 | pub itunes_id: Option, 76 | pub itunes_artist_id: Option, 77 | pub explicit: Option, 78 | pub language: Option, 79 | #[serde(rename = "type")] 80 | pub type_field: Option, 81 | pub title_ignore_prefix: Option, 82 | } 83 | 84 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 85 | #[serde(rename_all = "camelCase")] 86 | pub struct RecentEpisode { 87 | pub library_item_id: Option, 88 | pub podcast_id: Option, 89 | pub id: Option, 90 | pub old_episode_id: Option, 91 | pub index: Option, 92 | pub season: Option, 93 | pub episode: Option, 94 | pub episode_type: Option, 95 | pub title: Option, 96 | pub subtitle: Option, 97 | pub description: Option, 98 | pub enclosure: Option, 99 | pub guid: Option, 100 | pub pub_date: Option, 101 | pub chapters: Option>, 102 | pub audio_file: Option, 103 | pub published_at: Option, 104 | pub added_at: Option, 105 | pub updated_at: Option, 106 | } 107 | 108 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 109 | #[serde(rename_all = "camelCase")] 110 | pub struct Enclosure { 111 | pub url: Option, 112 | #[serde(rename = "type")] 113 | pub type_field: Option, 114 | pub length: Option, 115 | } 116 | 117 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 118 | #[serde(rename_all = "camelCase")] 119 | pub struct Chapter { 120 | pub start: Option, 121 | pub end: Option, 122 | pub title: Option, 123 | pub id: Option, 124 | } 125 | 126 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 127 | #[serde(rename_all = "camelCase")] 128 | pub struct AudioFile { 129 | pub index: Option, 130 | pub ino: Option, 131 | pub added_at: Option, 132 | pub updated_at: Option, 133 | pub track_num_from_meta: Option, 134 | pub disc_num_from_meta: Option, 135 | pub track_num_from_filename: Option, 136 | pub disc_num_from_filename: Option, 137 | pub manually_verified: Option, 138 | pub exclude: Option, 139 | pub error: Option, 140 | pub format: Option, 141 | pub duration: Option, 142 | pub bit_rate: Option, 143 | pub language: Option, 144 | pub codec: Option, 145 | pub time_base: Option, 146 | pub channels: Option, 147 | pub channel_layout: Option, 148 | pub embedded_cover_art: Option, 149 | pub mime_type: Option, 150 | } 151 | 152 | // filter only podcast continue to listening from personalized view 153 | pub async fn get_continue_listening_pod(token: &str, server_address: String, id_selected_lib: &String) -> Result> { 154 | let client = Client::new(); 155 | let url = format!("{}/api/libraries/{}/personalized", server_address, id_selected_lib); 156 | 157 | // Send GET request 158 | let response = client 159 | .get(url) 160 | .header(AUTHORIZATION, format!("Bearer {}", token)) 161 | .send() 162 | .await?; 163 | 164 | // Check response status 165 | if !response.status().is_success() { 166 | return Err(Report::new(std::io::Error::new( 167 | std::io::ErrorKind::Other, 168 | "Failed to fetch data from the API", 169 | ))); 170 | } 171 | 172 | // Deserialize JSON response into Vec 173 | let libraries: Vec = response.json().await?; 174 | 175 | // Filter libraries to keep only those with label "Continue Listening" 176 | let continue_listening: Vec = libraries 177 | .into_iter() 178 | .filter(|lib| lib.label == "Continue Listening") 179 | .collect(); 180 | 181 | Ok(continue_listening) 182 | } 183 | 184 | -------------------------------------------------------------------------------- /src/api/libraries/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod get_library_perso_view; 2 | pub mod get_library_perso_view_pod; 3 | pub mod get_all_books; 4 | pub mod get_all_libraries; 5 | -------------------------------------------------------------------------------- /src/api/library_items/get_pod_ep.rs: -------------------------------------------------------------------------------- 1 | use reqwest::Client; 2 | use serde_json::Value; 3 | use reqwest::header::AUTHORIZATION; 4 | use color_eyre::eyre::{Result, Report}; 5 | use serde::Deserialize; 6 | use serde::Serialize; 7 | 8 | 9 | /// Get a Library Item, used for collect podact info (allow in particular to retrieve all podcast episode id) 10 | /// This endpoint retrieves a library item, allow in particular to retrieve all podcast episode id. 11 | /// https://api.audiobookshelf.org/#get-a-library-item 12 | 13 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 14 | #[serde(rename_all = "camelCase")] 15 | pub struct Root { 16 | pub id: Option, 17 | pub ino: Option, 18 | pub old_library_item_id: Option, 19 | pub library_id: Option, 20 | pub folder_id: Option, 21 | pub path: Option, 22 | pub rel_path: Option, 23 | pub is_file: Option, 24 | pub mtime_ms: Option, 25 | pub ctime_ms: Option, 26 | pub birthtime_ms: Option, 27 | pub added_at: Option, 28 | pub updated_at: Option, 29 | pub scan_version: Option, 30 | pub is_missing: Option, 31 | pub is_invalid: Option, 32 | pub media_type: Option, 33 | pub media: Option, 34 | pub library_files: Option>, 35 | } 36 | 37 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 38 | #[serde(rename_all = "camelCase")] 39 | pub struct Media { 40 | pub id: Option, 41 | pub library_item_id: Option, 42 | pub metadata: Option, 43 | pub cover_path: Option, 44 | pub tags: Option>, 45 | pub episodes: Option>, 46 | pub auto_download_episodes: Option, 47 | pub auto_download_schedule: Option, 48 | pub last_episode_check: Option, 49 | pub max_episodes_to_keep: Option, 50 | pub max_new_episodes_to_download: Option, 51 | } 52 | 53 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 54 | #[serde(rename_all = "camelCase")] 55 | pub struct Metadata { 56 | pub title: Option, 57 | pub author: Option, 58 | pub description: Option, 59 | pub release_date: Option, 60 | pub genres: Option>, 61 | pub feed_url: Option, 62 | pub image_url: Option, 63 | pub itunes_page_url: Option, 64 | pub itunes_id: Option, 65 | pub itunes_artist_id: Option, 66 | pub explicit: Option, 67 | pub language: Option, 68 | #[serde(rename = "type")] 69 | pub type_field: Option, 70 | } 71 | 72 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 73 | #[serde(rename_all = "camelCase")] 74 | pub struct Episode { 75 | pub library_item_id: Option, 76 | pub podcast_id: Option, 77 | pub id: Option, 78 | pub old_episode_id: Option, 79 | pub index: Option, 80 | pub season: Option, 81 | pub episode: Option, 82 | pub episode_type: Option, 83 | pub title: Option, 84 | pub subtitle: Option, 85 | pub description: Option, 86 | pub enclosure: Option, 87 | pub guid: Option, 88 | pub pub_date: Option, 89 | pub chapters: Option>, 90 | pub audio_file: Option, 91 | pub published_at: Option, 92 | pub added_at: Option, 93 | pub updated_at: Option, 94 | } 95 | 96 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 97 | #[serde(rename_all = "camelCase")] 98 | pub struct Enclosure { 99 | pub url: Option, 100 | pub length: Option, 101 | pub mime_type: Option, 102 | } 103 | 104 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 105 | #[serde(rename_all = "camelCase")] 106 | pub struct AudioFile { 107 | pub path: Option, 108 | pub duration: Option, 109 | } 110 | 111 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 112 | #[serde(rename_all = "camelCase")] 113 | pub struct LibraryFile { 114 | pub file_name: Option, 115 | pub file_path: Option, 116 | } 117 | 118 | 119 | 120 | pub async fn get_pod_ep(token: &str, server_address: String, id: &str) -> Result { 121 | let client = Client::new(); 122 | let url = format!("{}/api/items/{}", server_address, id); 123 | 124 | 125 | // Send GET request 126 | let response = client 127 | .get(url) 128 | .header(AUTHORIZATION, format!("Bearer {}", token)) 129 | .send() 130 | .await?; 131 | 132 | // Check response status 133 | if !response.status().is_success() { 134 | return Err(Report::new(std::io::Error::new( 135 | std::io::ErrorKind::Other, 136 | "Failed to fetch data from the API", 137 | ))); 138 | } 139 | 140 | // Deserialize JSON response into Vec 141 | let item: Root = response.json().await?; 142 | 143 | Ok(item) 144 | } 145 | 146 | 147 | -------------------------------------------------------------------------------- /src/api/library_items/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod play_lib_item_or_pod; 2 | pub mod get_pod_ep; 3 | -------------------------------------------------------------------------------- /src/api/library_items/play_lib_item_or_pod.rs: -------------------------------------------------------------------------------- 1 | use reqwest::Client; 2 | use color_eyre::eyre::Result; 3 | use reqwest::header::AUTHORIZATION; 4 | use serde_json::Value; 5 | use serde_json::json; 6 | use crate::player::vlc::fetch_vlc_data::get_vlc_version; 7 | 8 | 9 | const VERSION: &str = env!("CARGO_PKG_VERSION"); 10 | 11 | /// Play a Library Item or Podcast Episode 12 | /// This endpoint starts a playback session for a library item or podcast episode. 13 | /// https://api.audiobookshelf.org/#play-a-library-item-or-podcast-episode 14 | 15 | // play book 16 | pub async fn post_start_playback_session_book(token: Option<&String>, id_library_item: &str, server_address: String) -> Result, reqwest::Error> { 17 | let mut vlc_version = String::new(); 18 | match get_vlc_version().await { 19 | Ok(version) => {vlc_version = version;} 20 | Err(e) => { 21 | log::error!("[get_vlc_version] {}",e); 22 | } 23 | } 24 | let client = Client::new(); 25 | 26 | let params = json!({ 27 | "forceDirectPlay": true, // avoid latency load, allow view chapter, cover etc.(the .m3u8 stream the original format, ex: .m4b) when playing with vlc 28 | "mediaPlayer": format!("VLC v{}", vlc_version), 29 | "deviceInfo": { 30 | "clientName": "Toutui", 31 | "clientVersion": format!("v{}", VERSION), 32 | // to have OS displayed in user activity pannel (audiobookshelf/config/users/) 33 | "manufacturer": format!("{}", std::env::consts::OS), 34 | "model": format!("{}", std::env::consts::ARCH), 35 | }}); 36 | 37 | let response = client 38 | .post(format!( 39 | "{}/api/items/{}/play", 40 | server_address, 41 | id_library_item 42 | )) 43 | .header("Content-Type", "application/json") 44 | .header(AUTHORIZATION, format!("Bearer {}", token.unwrap())) 45 | .json(¶ms) 46 | .send() 47 | .await?; 48 | 49 | // Retrieve JSON response 50 | let v: Value = response.json().await?; 51 | 52 | // Retrieve data 53 | let current_time = v["currentTime"] 54 | .as_f64() 55 | .unwrap_or(0.0); 56 | let content_url = v["audioTracks"][0]["contentUrl"] 57 | .as_str() 58 | .unwrap_or(""); 59 | let duration = v["audioTracks"][0]["duration"] 60 | .as_f64() 61 | .unwrap_or(0.0); 62 | let duration: u32 = duration as u32; 63 | let id_session = v["id"] 64 | .as_str() 65 | .unwrap_or(""); 66 | let title = v["mediaMetadata"]["title"] 67 | .as_str() 68 | .unwrap_or("N/A"); 69 | let subtitle = v["mediaMetadata"]["title"] 70 | .as_str() 71 | .unwrap_or("N/A"); 72 | let author = v["displayAuthor"] 73 | .as_str() 74 | .unwrap_or("N/A"); 75 | 76 | let info_item = vec![ 77 | current_time.to_string(), 78 | content_url.to_string(), 79 | duration.to_string(), 80 | id_session.to_string(), 81 | title.to_string(), 82 | subtitle.to_string(), 83 | author.to_string() 84 | ]; 85 | 86 | Ok(info_item) 87 | } 88 | // play podcast episode 89 | pub async fn post_start_playback_session_pod(token: Option<&String>, id_library_item: &str, pod_ep_id: &str, server_address: String) -> Result, reqwest::Error> { 90 | let mut vlc_version = String::new(); 91 | match get_vlc_version().await { 92 | Ok(version) => {vlc_version = version;} 93 | Err(_e) => { 94 | //eprintln!("{}", e), 95 | } 96 | } 97 | let client = Client::new(); 98 | 99 | let params = json!({ 100 | "forceDirectPlay": true, // avoid latency load, allow view chapter, cover etc.(the .m3u8 stream the original format, ex: .m4b) when playing with vlc 101 | "mediaPlayer": format!("VLC v{}", vlc_version), 102 | "deviceInfo": { 103 | "clientName": "Toutui", 104 | "clientVersion": format!("v{}", VERSION), 105 | // to have OS displayed in user activity pannel (audiobookshelf/config/users/) 106 | "manufacturer": format!("{}", std::env::consts::OS), 107 | "model": format!("{}", std::env::consts::ARCH), 108 | }}); 109 | 110 | let response = client 111 | .post(format!( 112 | "{}/api/items/{}/play/{}", 113 | server_address, 114 | id_library_item, 115 | pod_ep_id, 116 | )) 117 | .header("Content-Type", "application/json") 118 | .header(AUTHORIZATION, format!("Bearer {}", token.unwrap())) 119 | .json(¶ms) 120 | .send() 121 | .await?; 122 | 123 | // Retrieve JSON response 124 | let v: Value = response.json().await?; 125 | 126 | // Retrieve data 127 | let current_time = v["currentTime"] 128 | .as_f64() 129 | .unwrap_or(0.0); 130 | let content_url = v["audioTracks"][0]["contentUrl"] 131 | .as_str() 132 | .unwrap_or(""); 133 | let duration = v["audioTracks"][0]["duration"] 134 | .as_f64() 135 | .unwrap_or(0.0); 136 | let duration: u32 = duration as u32; 137 | let id_session = v["id"] 138 | .as_str() 139 | .unwrap_or(""); 140 | let title = v["mediaMetadata"]["title"] 141 | .as_str() 142 | .unwrap_or("N/A"); 143 | let subtitle = v["displayTitle"] 144 | .as_str() 145 | .unwrap_or("N/A"); 146 | let author = v["displayAuthor"] 147 | .as_str() 148 | .unwrap_or("N/A"); 149 | 150 | let info_item = vec![ 151 | current_time.to_string(), 152 | content_url.to_string(), 153 | duration.to_string(), 154 | id_session.to_string(), 155 | title.to_string(), 156 | subtitle.to_string(), 157 | author.to_string() 158 | ]; 159 | 160 | Ok(info_item) 161 | } 162 | -------------------------------------------------------------------------------- /src/api/me/get_media_progress.rs: -------------------------------------------------------------------------------- 1 | use reqwest::Client; 2 | use serde_json::Value; 3 | use reqwest::header::AUTHORIZATION; 4 | use color_eyre::eyre::{Result, Report}; 5 | use serde::Deserialize; 6 | use serde::Serialize; 7 | 8 | 9 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 10 | #[serde(rename_all = "camelCase")] 11 | pub struct Root { 12 | pub id: String, 13 | pub user_id: String, 14 | pub library_item_id: String, 15 | pub episode_id: Value, 16 | pub media_item_id: String, 17 | pub media_item_type: String, 18 | pub duration: f64, 19 | pub progress: f64, 20 | pub current_time: f64, 21 | pub is_finished: bool, 22 | pub hide_from_continue_listening: bool, 23 | pub ebook_location: Value, 24 | pub ebook_progress: i64, 25 | pub last_update: i64, 26 | pub started_at: i64, 27 | pub finished_at: Value, 28 | } 29 | 30 | /// This endpoint retrieves your media progress that is associated with the given library item ID or podcast episode ID. 31 | /// https://api.audiobookshelf.org/#get-a-media-progress 32 | 33 | // get progress for a book 34 | pub async fn get_book_progress(token: &str, book_id: &String, server_address: String) -> Result { 35 | let client = Client::new(); 36 | let url = format!("{}/api/me/progress/{}", server_address, book_id); 37 | 38 | // Send GET request 39 | let response = client 40 | .get(url) 41 | .header(AUTHORIZATION, format!("Bearer {}", token)) 42 | .send() 43 | .await?; 44 | 45 | // Check response status 46 | if !response.status().is_success() { 47 | return Err(Report::new(std::io::Error::new( 48 | std::io::ErrorKind::Other, 49 | "Failed to fetch data from the API", 50 | ))); 51 | } 52 | 53 | // Deserialize JSON response into Vec 54 | let book_progress: Root = response.json().await?; 55 | Ok(book_progress) 56 | } 57 | 58 | -------------------------------------------------------------------------------- /src/api/me/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod update_media_progress; 2 | pub mod get_media_progress; 3 | -------------------------------------------------------------------------------- /src/api/me/update_media_progress.rs: -------------------------------------------------------------------------------- 1 | use reqwest::header::{AUTHORIZATION, CONTENT_TYPE}; 2 | use serde_json::json; 3 | use std::error::Error; 4 | 5 | /// Create/Update Media Progress 6 | /// This endpoint creates/updates your media progress for a library item or podcast episode. 7 | /// https://api.audiobookshelf.org/#create-update-media-progress 8 | 9 | // for a book 10 | pub async fn update_media_progress_book(id_library_item: &str, token: Option<&String>, current_time: Option, duration: &String, server_adress: String) -> Result<(), Box> { 11 | 12 | // Build client reqwest 13 | let client = reqwest::Client::new(); 14 | 15 | // convert data before init progress (float) 16 | let duration_f32 = duration.parse::().unwrap(); 17 | let current_time_f32: f32 = current_time.unwrap() as f32; 18 | 19 | // init progress 20 | let progress = current_time_f32 / duration_f32 ; 21 | 22 | // json bosy 23 | let body = json!({ 24 | "progress" : progress, 25 | "currentTime": current_time, 26 | }); 27 | 28 | // Patch request 29 | let _response = client 30 | .patch(format!( 31 | "{}/api/me/progress/{}", 32 | server_adress, 33 | id_library_item 34 | )) 35 | .header(AUTHORIZATION, format!("Bearer {}", token.unwrap())) 36 | .header(CONTENT_TYPE, "application/json") 37 | .json(&body) 38 | .send() 39 | .await?; 40 | 41 | // 42 | //let status = response.status(); 43 | //let response_text = response.text().await?; 44 | 45 | // println!("Statut: {}", status); 46 | // println!("Réponse: {}", response_text); 47 | 48 | Ok(()) 49 | } 50 | 51 | // for a book (to mark as finished) 52 | pub async fn update_media_progress2_book(id_library_item: &str, token: Option<&String>, current_time: Option, duration: &String, is_finished: bool, server_adress: String) -> Result<(), Box> { 53 | 54 | // Build client reqwest 55 | let client = reqwest::Client::new(); 56 | 57 | // convert data before init progress (float) 58 | let duration_f32 = duration.parse::().unwrap(); 59 | let current_time_f32: f32 = current_time.unwrap() as f32; 60 | 61 | // init progress 62 | let progress = current_time_f32 / duration_f32 ; 63 | 64 | // json bosy 65 | let body = json!({ 66 | "progress" : progress, 67 | "isFinished" : is_finished, 68 | "currentTime": current_time, 69 | }); 70 | 71 | // Patch request 72 | let _response = client 73 | .patch(format!( 74 | "{}/api/me/progress/{}", 75 | server_adress, 76 | id_library_item 77 | )) 78 | .header(AUTHORIZATION, format!("Bearer {}", token.unwrap())) 79 | .json(&body) 80 | .send() 81 | .await?; 82 | 83 | // 84 | //let status = response.status(); 85 | //let response_text = response.text().await?; 86 | 87 | // println!("Statut: {}", status); 88 | // println!("Réponse: {}", response_text); 89 | 90 | Ok(()) 91 | } 92 | 93 | // for a podcast : 94 | pub async fn update_media_progress_pod(id_library_item: &str , token: Option<&String>, current_time: Option, duration: &String, ep_id : &str, server_adress: String) -> Result<(), Box> { 95 | 96 | // Build client reqwest 97 | let client = reqwest::Client::new(); 98 | 99 | // convert data before init progress (float) 100 | let duration_f32 = duration.parse::().unwrap(); 101 | let current_time_f32: f32 = current_time.unwrap() as f32; 102 | 103 | // init progress 104 | let progress = current_time_f32 / duration_f32 ; 105 | 106 | // json bosy 107 | let body = json!({ 108 | "progress" : progress, 109 | "currentTime": current_time, 110 | }); 111 | 112 | // Patch request 113 | let _response = client 114 | .patch(format!( 115 | "{}/api/me/progress/{}/{}", 116 | server_adress, 117 | id_library_item, ep_id 118 | )) 119 | .header(AUTHORIZATION, format!("Bearer {}", token.unwrap())) 120 | .header(CONTENT_TYPE, "application/json") 121 | .json(&body) 122 | .send() 123 | .await?; 124 | 125 | // 126 | //let status = response.status(); 127 | //let response_text = response.text().await?; 128 | 129 | // println!("Statut: {}", status); 130 | // println!("Réponse: {}", response_text); 131 | 132 | Ok(()) 133 | } 134 | 135 | // for a podcast (to mark as finished) : 136 | pub async fn update_media_progress2_pod(id_library_item: &str, token: Option<&String>, current_time: Option, duration: &String, is_finished: bool, ep_id: &str, server_adress: String) -> Result<(), Box> { 137 | 138 | // Build client reqwest 139 | let client = reqwest::Client::new(); 140 | 141 | // convert data before init progress (float) 142 | let duration_f32 = duration.parse::().unwrap(); 143 | let current_time_f32: f32 = current_time.unwrap() as f32; 144 | 145 | // init progress 146 | let progress = current_time_f32 / duration_f32 ; 147 | 148 | // json bosy 149 | let body = json!({ 150 | "progress" : progress, 151 | "isFinished" : is_finished, 152 | "currentTime": current_time, 153 | }); 154 | 155 | // Patch request 156 | let _response = client 157 | .patch(format!( 158 | "{}/api/me/progress/{}/{}", 159 | server_adress, 160 | id_library_item, 161 | ep_id 162 | )) 163 | .header(AUTHORIZATION, format!("Bearer {}", token.unwrap())) 164 | .json(&body) 165 | .send() 166 | .await?; 167 | 168 | // 169 | //let status = response.status(); 170 | //let response_text = response.text().await?; 171 | 172 | // println!("Statut: {}", status); 173 | // println!("Réponse: {}", response_text); 174 | 175 | Ok(()) 176 | } 177 | 178 | -------------------------------------------------------------------------------- /src/api/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod libraries; 2 | pub mod me; 3 | pub mod utils; 4 | pub mod library_items; 5 | pub mod server; 6 | pub mod sessions; 7 | -------------------------------------------------------------------------------- /src/api/server/auth_process.rs: -------------------------------------------------------------------------------- 1 | use reqwest::Client; 2 | use serde::{Deserialize, Serialize}; 3 | use color_eyre::eyre::{Result, Report}; 4 | use crate::db::crud::*; 5 | use crate::db::database_struct::User; 6 | use crate::api::libraries::get_all_libraries::*; 7 | use crate::api::utils::collect_get_all_libraries::*; 8 | use crate::utils::encrypt_token::*; 9 | use log::info; 10 | 11 | 12 | #[derive(Serialize)] 13 | struct LoginRequest { 14 | username: String, 15 | password: String, 16 | } 17 | 18 | #[derive(Deserialize, Debug)] 19 | struct LoginResponse { 20 | user: UserInfo, 21 | } 22 | 23 | #[derive(Deserialize, Debug)] 24 | struct UserInfo { 25 | token: String, 26 | } 27 | 28 | /// Login 29 | /// https://api.audiobookshelf.org/#server 30 | 31 | /// The login function takes a username, password, url ans makes a POST request and returns a token. 32 | /// After, some data are fetched with this token and written in database 33 | pub async fn auth_process(username: &str, password: &str, server_address: &str) -> Result<()> { 34 | let login_url = format!("{}/login", server_address); 35 | let client = Client::new(); 36 | 37 | // Struct for data request 38 | let login_data = LoginRequest { 39 | username: username.to_string(), 40 | password: password.to_string(), 41 | }; 42 | 43 | // Send POST request 44 | let response = client 45 | .post(login_url) 46 | .header("Content-Type", "application/json") 47 | .json(&login_data) 48 | .send() 49 | .await?; 50 | 51 | // Checking the status of the response and fetch data 52 | if response.status().is_success() { 53 | let login_response: LoginResponse = response.json().await?; 54 | 55 | let all_libraries = get_all_libraries(login_response.user.token.as_str(), server_address.to_string()).await?; 56 | let library_names = collect_library_names(&all_libraries).await; 57 | let _media_types = collect_media_types(&all_libraries).await; 58 | let library_ids = collect_library_ids(&all_libraries).await; 59 | 60 | // Token encryption before insert it in the database 61 | let _token_to_encrypt = login_response.user.token.as_str(); 62 | let mut token_encrypted = "".to_string(); 63 | match encrypt_token(_token_to_encrypt) { 64 | Ok(encrypted_token) => { 65 | token_encrypted = encrypted_token; 66 | info!("Token successfully encrypted") 67 | } 68 | Err(e) => { 69 | println!("Error: {}", e); 70 | } 71 | } 72 | 73 | // Init for handle_l 74 | let is_loop_break = "0".to_string(); 75 | let is_vlc_running = "0".to_string(); 76 | let is_vlc_launched_first_time = "1".to_string(); 77 | 78 | 79 | // Writting in database : 80 | 81 | // init a new user 82 | let users = vec![ 83 | User { 84 | server_address: server_address.to_string(), 85 | username: username.to_string(), 86 | token: token_encrypted, 87 | is_default_usr: true, 88 | name_selected_lib: library_names[0].clone(), // by default we take the first library 89 | id_selected_lib: library_ids[0].clone(), 90 | is_loop_break: is_loop_break, 91 | is_vlc_launched_first_time: is_vlc_launched_first_time, 92 | speed_rate: 1.0, 93 | is_vlc_running: is_vlc_running, 94 | is_show_key_bindings: "1".to_string() 95 | } 96 | ]; 97 | 98 | // insert the new user in database 99 | let _ = db_insert_usr(&users); 100 | 101 | Ok(()) 102 | } else { 103 | Err(Report::new(std::io::Error::new(std::io::ErrorKind::Other, "Login failed"))) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/api/server/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod auth_process; 2 | -------------------------------------------------------------------------------- /src/api/sessions/close_open_session.rs: -------------------------------------------------------------------------------- 1 | use reqwest::Client; 2 | use color_eyre::eyre::Result; 3 | use reqwest::header::AUTHORIZATION; 4 | 5 | // This endpoint closes an open listening session. Optionally provide sync data to update the session before closing it. 6 | // https://api.audiobookshelf.org/#close-an-open-session 7 | 8 | pub async fn close_session_without_send_prg_data(token: Option<&String>, session_id: &str, server_address: String) -> Result<(), reqwest::Error> { 9 | let client = Client::new(); 10 | 11 | let _response = client 12 | .post(format!( 13 | "{}/api/session/{}/close", 14 | server_address, 15 | session_id 16 | )) 17 | .header("Content-Type", "application/json") 18 | .header(AUTHORIZATION, format!("Bearer {}", token.unwrap())) 19 | .send() 20 | .await?; 21 | 22 | Ok(()) 23 | } 24 | -------------------------------------------------------------------------------- /src/api/sessions/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod sync_open_session; 2 | pub mod close_open_session; 3 | 4 | -------------------------------------------------------------------------------- /src/api/sessions/sync_open_session.rs: -------------------------------------------------------------------------------- 1 | use reqwest::Client; 2 | use color_eyre::eyre::Result; 3 | use reqwest::header::AUTHORIZATION; 4 | use serde_json::json; 5 | 6 | /// This endpoint syncs the position of an open listening session from the client to the server and returns the session. 7 | /// https://api.audiobookshelf.org/#sync-an-open-session 8 | 9 | // sync a session 10 | pub async fn sync_session(token: Option<&String>, session_id: &str, current_time: Option, time_listened: u32, server_address: String) -> Result<(), reqwest::Error> { 11 | let client = Client::new(); 12 | 13 | let params = json!({ 14 | "currentTime": format!("{}", current_time.unwrap_or(0)), 15 | "timeListened": format!("{}", time_listened), 16 | }); 17 | 18 | let _response = client 19 | .post(format!( 20 | "{}/api/session/{}/sync", 21 | server_address, 22 | session_id 23 | )) 24 | .header("Content-Type", "application/json") 25 | .header(AUTHORIZATION, format!("Bearer {}", token.unwrap())) 26 | .json(¶ms) 27 | .send() 28 | .await?; 29 | 30 | Ok(()) 31 | } 32 | -------------------------------------------------------------------------------- /src/api/utils/collect_get_all_books.rs: -------------------------------------------------------------------------------- 1 | use crate::api::libraries::get_all_books::Root; 2 | 3 | /// collect titles 4 | pub async fn collect_titles_library(library: &Root) -> Vec { 5 | let mut titles_library = Vec::new(); 6 | 7 | if let Some(results) = &library.results { 8 | for item in results { 9 | if let Some(media) = &item.media { 10 | if let Some(metadata) = &media.metadata { 11 | if let Some(title) = &metadata.title { 12 | titles_library.push(title.clone()); 13 | } else { 14 | titles_library.push("N/A".to_string()); 15 | } 16 | } 17 | } 18 | } 19 | } 20 | 21 | titles_library 22 | } 23 | 24 | /// collect ID of library items 25 | pub async fn collect_ids_library(library: &Root) -> Vec { 26 | let mut ids_library = Vec::new(); 27 | 28 | if let Some(results) = &library.results { 29 | for item in results { 30 | if let Some(id) = &item.id { 31 | ids_library.push(id.clone()); 32 | } else { 33 | ids_library.push("N/A".to_string()); 34 | } 35 | 36 | } 37 | } 38 | 39 | ids_library 40 | } 41 | 42 | /// collect author name for book 43 | pub async fn collect_auth_names_library(library: &Root) -> Vec { 44 | let mut auth_names_library = Vec::new(); 45 | 46 | if let Some(results) = &library.results { 47 | for item in results { 48 | if let Some(media) = &item.media { 49 | if let Some(metadata) = &media.metadata { 50 | if let Some(author_name) = &metadata.author_name { 51 | auth_names_library.push(author_name.clone()); 52 | } else { 53 | auth_names_library.push("N/A".to_string()); 54 | } 55 | 56 | } 57 | } 58 | } 59 | } 60 | 61 | auth_names_library 62 | } 63 | 64 | /// collect author name for podcast 65 | pub async fn collect_auth_names_library_pod(library: &Root) -> Vec { 66 | let mut auth_names_library_pod = Vec::new(); 67 | 68 | if let Some(results) = &library.results { 69 | for item in results { 70 | if let Some(media) = &item.media { 71 | if let Some(metadata) = &media.metadata { 72 | if let Some(author) = &metadata.author { 73 | auth_names_library_pod.push(author.clone()); 74 | } else { 75 | auth_names_library_pod.push("N/A".to_string()); 76 | } 77 | 78 | } 79 | } 80 | } 81 | } 82 | 83 | auth_names_library_pod 84 | } 85 | /// collect published year 86 | pub async fn collect_published_year_library(library: &Root) -> Vec { 87 | let mut published_year_library = Vec::new(); 88 | 89 | if let Some(results) = &library.results { 90 | for item in results { 91 | if let Some(media) = &item.media { 92 | if let Some(metadata) = &media.metadata { 93 | if let Some(pub_year) = &metadata.published_year { 94 | published_year_library.push(pub_year.clone()); 95 | } else { 96 | published_year_library.push("N/A".to_string()); 97 | } 98 | 99 | } 100 | } 101 | } 102 | } 103 | 104 | published_year_library 105 | } 106 | 107 | /// collect description 108 | pub async fn collect_desc_library(library: &Root) -> Vec { 109 | let mut desc_library = Vec::new(); 110 | 111 | if let Some(results) = &library.results { 112 | for item in results { 113 | if let Some(media) = &item.media { 114 | if let Some(metadata) = &media.metadata { 115 | if let Some(desc) = &metadata.description { 116 | desc_library.push(desc.clone()); 117 | } else { 118 | desc_library.push("No description available".to_string()); 119 | } 120 | } 121 | } 122 | } 123 | } 124 | 125 | desc_library 126 | } 127 | 128 | /// collect duration 129 | pub async fn collect_duration_library(library: &Root) -> Vec { 130 | let mut duration = vec![]; 131 | 132 | if let Some(results) = &library.results { 133 | for item in results { 134 | if let Some(media) = &item.media { 135 | if let Some(dur) = &media.duration { 136 | duration.push(dur.clone()); 137 | } else { 138 | duration.push(0.0); 139 | } 140 | 141 | } 142 | } 143 | } 144 | 145 | duration 146 | } 147 | -------------------------------------------------------------------------------- /src/api/utils/collect_get_all_libraries.rs: -------------------------------------------------------------------------------- 1 | use crate::api::libraries::get_all_libraries::Root; 2 | 3 | 4 | /// collect media_type (podcast or book) 5 | pub async fn collect_media_types(library: &Root) -> Vec { 6 | let mut media_types = Vec::new(); 7 | 8 | for lib in &library.libraries { 9 | media_types.push(lib.media_type.clone()); 10 | } 11 | 12 | media_types 13 | } 14 | 15 | /// library_names 16 | pub async fn collect_library_names(library: &Root) -> Vec { 17 | let mut library_names = Vec::new(); 18 | 19 | for lib in &library.libraries { 20 | library_names.push(lib.name.clone()); 21 | } 22 | 23 | library_names 24 | } 25 | 26 | /// collect library_ids 27 | pub async fn collect_library_ids(library: &Root) -> Vec { 28 | let mut library_ids = Vec::new(); 29 | 30 | for lib in &library.libraries { 31 | library_ids.push(lib.id.clone()); 32 | } 33 | 34 | library_ids 35 | } 36 | 37 | 38 | -------------------------------------------------------------------------------- /src/api/utils/collect_get_media_progress.rs: -------------------------------------------------------------------------------- 1 | use crate::api::me::get_media_progress::Root; 2 | 3 | // no need to handle null values here (there are handeled in `app.rs`) // 4 | 5 | pub async fn collect_progress_percentage_book(root: &Root) -> String { 6 | format!("{}", (root.progress * 100.0).round() as i64) 7 | } 8 | 9 | pub async fn collect_is_finished_book(item: &Root) -> String { 10 | if item.is_finished { 11 | "Finished".to_string() 12 | } else { 13 | "Not finished".to_string() 14 | } 15 | } 16 | 17 | pub async fn collect_current_time_prg(item: &Root) -> f64 { 18 | item.current_time 19 | } 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/api/utils/collect_get_pod_ep.rs: -------------------------------------------------------------------------------- 1 | use crate::api::library_items::get_pod_ep::Root; 2 | use crate::utils::convert_seconds::*; 3 | 4 | /// collect title podact episode 5 | pub async fn collect_titles_pod_ep(item: &Root) -> Vec { 6 | let mut titles_pod_ep = Vec::new(); 7 | 8 | if let Some(media) = &item.media { 9 | if let Some(episodes) = &media.episodes { 10 | for episode in episodes { 11 | if let Some(title) = &episode.title { 12 | titles_pod_ep.push(title.clone()); 13 | } else { 14 | titles_pod_ep.push("N/A".to_string()); 15 | } 16 | } 17 | } 18 | } 19 | 20 | titles_pod_ep 21 | } 22 | 23 | /// collect ID of podcast episode 24 | pub async fn collect_ids_pod_ep(item: &Root) -> Vec { 25 | let mut ids_pod_ep = Vec::new(); 26 | 27 | if let Some(media) = &item.media { 28 | if let Some(episodes) = &media.episodes { 29 | for episode in episodes { 30 | if let Some(id) = &episode.id { 31 | ids_pod_ep.push(id.clone()); 32 | } else { 33 | ids_pod_ep.push("N/A".to_string()); 34 | } 35 | 36 | } 37 | } 38 | } 39 | 40 | ids_pod_ep 41 | } 42 | 43 | 44 | /// collect subtiles 45 | pub async fn collect_subtitles_pod_ep(item: &Root) -> Vec { 46 | let mut subtitles_pod_ep = Vec::new(); 47 | 48 | if let Some(media) = &item.media { 49 | if let Some(episodes) = &media.episodes { 50 | for episode in episodes { 51 | if let Some(sub) = &episode.subtitle { 52 | subtitles_pod_ep.push(sub.clone()); 53 | } else { 54 | subtitles_pod_ep.push("N/A".to_string()); 55 | } 56 | 57 | } 58 | } 59 | } 60 | 61 | subtitles_pod_ep 62 | } 63 | 64 | /// collect seasons 65 | pub async fn collect_seasons_pod_ep(item: &Root) -> Vec { 66 | let mut seasons_pod_ep = Vec::new(); 67 | 68 | if let Some(media) = &item.media { 69 | if let Some(episodes) = &media.episodes { 70 | for episode in episodes { 71 | if let Some(season) = &episode.season { 72 | seasons_pod_ep.push(season.clone()); 73 | } else { 74 | seasons_pod_ep.push("N/A".to_string()); 75 | } 76 | 77 | } 78 | } 79 | } 80 | 81 | seasons_pod_ep 82 | } 83 | 84 | /// collect episodes 85 | pub async fn collect_episodes_pod_ep(item: &Root) -> Vec { 86 | let mut episodes_pod_ep = Vec::new(); 87 | 88 | if let Some(media) = &item.media { 89 | if let Some(episodes) = &media.episodes { 90 | for episode in episodes { 91 | if let Some(episode) = &episode.episode { 92 | episodes_pod_ep.push(episode.clone()); 93 | } 94 | else { 95 | episodes_pod_ep.push("N/A".to_string()); 96 | } 97 | } 98 | } 99 | } 100 | 101 | episodes_pod_ep 102 | } 103 | 104 | /// collect authors 105 | pub async fn collect_authors_pod_ep(item: &Root) -> Vec { 106 | let mut authors_pod_ep = Vec::new(); 107 | 108 | if let Some(media) = &item.media { 109 | if let Some(metadata) = &media.metadata { 110 | if let Some(author) = &metadata.author { 111 | authors_pod_ep.push(author.clone()); 112 | } else { 113 | authors_pod_ep.push("N/A".to_string()); 114 | } 115 | 116 | } 117 | } 118 | 119 | authors_pod_ep 120 | } 121 | 122 | /// collect desc 123 | pub async fn collect_descs_pod_ep(item: &Root) -> Vec { 124 | let mut descs_pod_ep = Vec::new(); 125 | 126 | if let Some(media) = &item.media { 127 | if let Some(metadata) = &media.metadata { 128 | if let Some(desc) = &metadata.description { 129 | descs_pod_ep.push(desc.clone()); 130 | } else { 131 | descs_pod_ep.push("N/A".to_string()); 132 | } 133 | 134 | } 135 | } 136 | 137 | descs_pod_ep 138 | } 139 | 140 | /// collect title of podcast (no of podcast episode) 141 | pub async fn collect_titles_pod(item: &Root) -> Vec { 142 | let mut titles_pod = Vec::new(); 143 | 144 | if let Some(media) = &item.media { 145 | if let Some(metadata) = &media.metadata { 146 | if let Some(title) = &metadata.title { 147 | titles_pod.push(title.clone()); 148 | } else { 149 | titles_pod.push("N/A".to_string()); 150 | } 151 | 152 | } 153 | } 154 | 155 | titles_pod 156 | } 157 | 158 | // collect duration 159 | pub async fn collect_durations_pod_ep(item: &Root) -> Vec { 160 | let mut durations = Vec::new(); 161 | 162 | if let Some(media) = &item.media { 163 | if let Some(episodes) = &media.episodes { 164 | for episode in episodes { 165 | if let Some(audio_file) = &episode.audio_file { 166 | if let Some(duration) = audio_file.duration { 167 | durations.push(duration); 168 | } else { 169 | durations.push(0.0); 170 | } 171 | 172 | } 173 | } 174 | } 175 | } 176 | 177 | let durations_pod_ep = convert_seconds(durations); 178 | durations_pod_ep 179 | } 180 | 181 | -------------------------------------------------------------------------------- /src/api/utils/collect_personalized_view.rs: -------------------------------------------------------------------------------- 1 | use crate::api::libraries::get_library_perso_view::Root; 2 | 3 | /// collect titles 4 | pub async fn collect_titles_cnt_list(continue_listening: &[Root]) -> Vec { 5 | let mut titles_cnt_list = Vec::new(); 6 | 7 | for library in continue_listening { 8 | if let Some(entities) = &library.entities { 9 | for entity in entities { 10 | if let Some(media) = &entity.media { 11 | if let Some(metadata) = &media.metadata { 12 | if let Some(title) = &metadata.title { 13 | titles_cnt_list.push(title.clone()); 14 | } else { 15 | titles_cnt_list.push("N/A".to_string()); 16 | } 17 | } 18 | } 19 | } 20 | } 21 | } 22 | 23 | titles_cnt_list 24 | } 25 | 26 | /// collect author name 27 | pub async fn collect_auth_names_cnt_list(continue_listening: &[Root]) -> Vec { 28 | let mut auth_names_cnt_list = Vec::new(); 29 | 30 | for library in continue_listening { 31 | if let Some(entities) = &library.entities { 32 | for entity in entities { 33 | if let Some(media) = &entity.media { 34 | if let Some(metadata) = &media.metadata { 35 | if let Some(author_name) = &metadata.author_name { 36 | auth_names_cnt_list.push(author_name.clone()); 37 | } else { 38 | auth_names_cnt_list.push("N/A".to_string()); 39 | } 40 | 41 | } 42 | } 43 | } 44 | } 45 | } 46 | 47 | auth_names_cnt_list 48 | } 49 | 50 | /// collect published year 51 | pub async fn collect_pub_year_cnt_list(continue_listening: &[Root]) -> Vec { 52 | let mut pub_year_cnt_list = Vec::new(); 53 | 54 | for library in continue_listening { 55 | if let Some(entities) = &library.entities { 56 | for entity in entities { 57 | if let Some(media) = &entity.media { 58 | if let Some(metadata) = &media.metadata { 59 | if let Some(published_year) = &metadata.published_year { 60 | pub_year_cnt_list.push(published_year.clone()); 61 | } else { 62 | pub_year_cnt_list.push("N/A".to_string()); 63 | } 64 | 65 | } 66 | } 67 | } 68 | } 69 | } 70 | 71 | pub_year_cnt_list 72 | } 73 | 74 | /// collect duration 75 | pub async fn collect_duration_cnt_list(continue_listening: &[Root]) -> Vec { 76 | 77 | let mut duration_cnt_list = vec![]; 78 | 79 | for library in continue_listening { 80 | if let Some(entities) = &library.entities { 81 | for entity in entities { 82 | if let Some(media) = &entity.media { 83 | if let Some(duration) = &media.duration { 84 | duration_cnt_list.push(duration.clone()); 85 | } else { 86 | duration_cnt_list.push(0.0); 87 | } 88 | 89 | } 90 | } 91 | } 92 | } 93 | 94 | duration_cnt_list 95 | 96 | } 97 | 98 | /// collect description 99 | pub async fn collect_desc_cnt_list(continue_listening: &[Root]) -> Vec { 100 | let mut desc_cnt_list = Vec::new(); 101 | 102 | for library in continue_listening { 103 | if let Some(entities) = &library.entities { 104 | for entity in entities { 105 | if let Some(media) = &entity.media { 106 | if let Some(metadata) = &media.metadata { 107 | if let Some(description) = &metadata.description { 108 | desc_cnt_list.push(description.clone()); 109 | } else { 110 | desc_cnt_list.push("N/A".to_string()); 111 | } 112 | 113 | } 114 | } 115 | } 116 | } 117 | } 118 | 119 | desc_cnt_list 120 | } 121 | 122 | /// collect ID of the library item 123 | pub async fn collect_ids_cnt_list(continue_listening: &[Root]) -> Vec { 124 | let mut ids_cnt_list = Vec::new(); 125 | 126 | for library in continue_listening { 127 | if let Some(entities) = &library.entities { 128 | for entity in entities { 129 | if let Some(id) = &entity.id { 130 | ids_cnt_list.push(id.clone()); 131 | } else { 132 | ids_cnt_list.push("N/A".to_string()); 133 | } 134 | 135 | } 136 | } 137 | } 138 | 139 | ids_cnt_list 140 | } 141 | -------------------------------------------------------------------------------- /src/api/utils/collect_personalized_view_pod.rs: -------------------------------------------------------------------------------- 1 | use crate::api::libraries::get_library_perso_view_pod::Root; 2 | use crate::utils::convert_seconds::*; 3 | 4 | /// collect id pod for continue listening 5 | pub async fn collect_ids_pod_cnt_list(roots: &[Root]) -> Vec { 6 | let mut ids_pod_cnt_list = Vec::new(); 7 | 8 | for root in roots { 9 | if let Some(entities) = &root.entities { 10 | for entity in entities { 11 | if let Some(recent_episode) = &entity.recent_episode { 12 | if let Some(library_item_id) = recent_episode.library_item_id.clone() { 13 | ids_pod_cnt_list.push(library_item_id); 14 | } else { 15 | ids_pod_cnt_list.push("N/A".to_string()); 16 | } 17 | } 18 | } 19 | } 20 | } 21 | 22 | ids_pod_cnt_list 23 | } 24 | 25 | /// Collect subtitles from recent episodes 26 | pub async fn collect_subtitles_pod_cnt_list(roots: &[Root]) -> Vec { 27 | let mut subtitles_pod_cnt_list = Vec::new(); 28 | 29 | for root in roots { 30 | if let Some(entities) = &root.entities { 31 | for entity in entities { 32 | if let Some(recent_episode) = &entity.recent_episode { 33 | if let Some(subtitle) = &recent_episode.subtitle { 34 | subtitles_pod_cnt_list.push(subtitle.clone()); 35 | } else { 36 | subtitles_pod_cnt_list.push("N/A".to_string()); 37 | } 38 | 39 | } 40 | } 41 | } 42 | } 43 | 44 | subtitles_pod_cnt_list 45 | } 46 | 47 | /// Collect num episode 48 | pub async fn collect_nums_ep_pod_cnt_list(roots: &[Root]) -> Vec { 49 | let mut nums_ep_pod_cnt_list = Vec::new(); 50 | 51 | for root in roots { 52 | if let Some(entities) = &root.entities { 53 | for entity in entities { 54 | if let Some(recent_episode) = &entity.recent_episode { 55 | if let Some(episode) = &recent_episode.episode { 56 | nums_ep_pod_cnt_list.push(episode.clone()); 57 | } else { 58 | nums_ep_pod_cnt_list.push("N/A".to_string()) 59 | } 60 | } 61 | } 62 | } 63 | } 64 | 65 | nums_ep_pod_cnt_list 66 | } 67 | 68 | /// collect season 69 | pub async fn collect_seasons_pod_cnt_list(roots: &[Root]) -> Vec { 70 | let mut seasons_pod_cnt_list = Vec::new(); 71 | 72 | for root in roots { 73 | if let Some(entities) = &root.entities { 74 | for entity in entities { 75 | if let Some(recent_episode) = &entity.recent_episode { 76 | if let Some(season) = &recent_episode.season { 77 | seasons_pod_cnt_list.push(season.clone()); 78 | } else { 79 | seasons_pod_cnt_list.push("N/A".to_string()); 80 | } 81 | 82 | } 83 | } 84 | } 85 | } 86 | 87 | seasons_pod_cnt_list 88 | } 89 | 90 | /// Collect authors 91 | pub async fn collect_authors_pod_cnt_list(roots: &[Root]) -> Vec { 92 | let mut authors_pod_cnt_list = Vec::new(); 93 | 94 | for root in roots { 95 | if let Some(entities) = &root.entities { 96 | for entity in entities { 97 | if let Some(_recent_episode) = &entity.recent_episode { 98 | if let Some(media) = &entity.media { 99 | if let Some(metadata) = &media.metadata { 100 | if let Some(author) = &metadata.author { 101 | authors_pod_cnt_list.push(author.clone()); 102 | } else { 103 | authors_pod_cnt_list.push("N/A".to_string()); 104 | } 105 | 106 | } 107 | } 108 | } 109 | } 110 | } 111 | } 112 | 113 | authors_pod_cnt_list 114 | } 115 | 116 | /// Collect description 117 | pub async fn collect_descs_pod_cnt_list(roots: &[Root]) -> Vec { 118 | let mut descs_pod_cnt_list = Vec::new(); 119 | 120 | for root in roots { 121 | if let Some(entities) = &root.entities { 122 | for entity in entities { 123 | if let Some(_recent_episode) = &entity.recent_episode { 124 | if let Some(media) = &entity.media { 125 | if let Some(metadata) = &media.metadata { 126 | if let Some(desc) = &metadata.description { 127 | descs_pod_cnt_list.push(desc.clone()); 128 | } else { 129 | descs_pod_cnt_list.push("N/A".to_string()); 130 | } 131 | 132 | } 133 | } 134 | } 135 | } 136 | } 137 | } 138 | 139 | descs_pod_cnt_list 140 | } 141 | 142 | /// Collect podcast title 143 | pub async fn collect_titles_pod_cnt_list(roots: &[Root]) -> Vec { 144 | let mut titles_pod_cnt_list = Vec::new(); 145 | 146 | for root in roots { 147 | if let Some(entities) = &root.entities { 148 | for entity in entities { 149 | if let Some(_recent_episode) = &entity.recent_episode { 150 | if let Some(media) = &entity.media { 151 | if let Some(metadata) = &media.metadata { 152 | if let Some(title) = &metadata.title { 153 | titles_pod_cnt_list.push(title.clone()); 154 | } else { 155 | titles_pod_cnt_list.push("N/A".to_string()); 156 | } 157 | 158 | } 159 | } 160 | } 161 | } 162 | } 163 | } 164 | 165 | titles_pod_cnt_list 166 | } 167 | 168 | pub async fn collect_durations_pod_cnt_list(roots: &[Root]) -> Vec { 169 | let mut durations = Vec::new(); 170 | 171 | for root in roots { 172 | if let Some(entities) = &root.entities { 173 | for entity in entities { 174 | if let Some(recent_episode) = &entity.recent_episode { 175 | if let Some(audio_file) = &recent_episode.audio_file { 176 | if let Some(duration) = audio_file.duration { 177 | durations.push(duration); 178 | } else { 179 | durations.push(0.0); 180 | } 181 | 182 | } 183 | } 184 | } 185 | } 186 | } 187 | 188 | let durations_pod_cnt_list = convert_seconds(durations); 189 | durations_pod_cnt_list 190 | } 191 | 192 | /// collect ids ep 193 | pub async fn collect_ids_ep_pod_cnt_list(roots: &[Root]) -> Vec { 194 | let mut ids_ep_pod_cnt_list = Vec::new(); 195 | 196 | for root in roots { 197 | if let Some(entities) = &root.entities { 198 | for entity in entities { 199 | if let Some(recent_episode) = &entity.recent_episode { 200 | if let Some(id) = recent_episode.id.clone() { 201 | ids_ep_pod_cnt_list.push(id); 202 | } else { 203 | ids_ep_pod_cnt_list.push("N/A".to_string()); 204 | } 205 | 206 | } 207 | } 208 | } 209 | } 210 | 211 | ids_ep_pod_cnt_list 212 | } 213 | 214 | /// collect titles pod for continue listening 215 | pub async fn collect_titles_cnt_list_pod(roots: &[Root]) -> Vec { 216 | let mut titles_cnt_list = Vec::new(); 217 | 218 | 219 | for root in roots { 220 | if let Some(entities) = &root.entities { 221 | for entity in entities { 222 | if let Some(recent_episode) = &entity.recent_episode { 223 | if let Some(title) = recent_episode.title.clone() { 224 | titles_cnt_list.push(title); 225 | } else { 226 | titles_cnt_list.push("N/A".to_string()); 227 | } 228 | 229 | } 230 | } 231 | } 232 | } 233 | 234 | titles_cnt_list 235 | } 236 | -------------------------------------------------------------------------------- /src/api/utils/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod collect_personalized_view; 2 | pub mod collect_personalized_view_pod; 3 | pub mod collect_get_all_books; 4 | pub mod collect_get_pod_ep; 5 | pub mod collect_get_all_libraries; 6 | pub mod collect_get_media_progress; 7 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use config::{Config as ConfigLib, File}; 2 | use serde::Deserialize; 3 | use color_eyre::eyre::{Result, Report}; 4 | use std::env; 5 | use std::path::PathBuf; 6 | 7 | #[derive(Debug, Deserialize)] 8 | pub struct ConfigFile { 9 | pub colors: Colors, 10 | pub player: Player, 11 | } 12 | 13 | #[derive(Debug, Deserialize)] 14 | pub struct Colors { 15 | pub background_color: Vec, 16 | pub log_background_color: Vec, 17 | pub header_background_color: Vec, 18 | pub line_header_color: Vec, 19 | pub list_background_color: Vec, 20 | pub list_background_color_alt_row: Vec, 21 | pub list_selected_background_color: Vec, 22 | pub list_selected_foreground_color: Vec, 23 | pub search_bar_foreground_color: Vec, 24 | pub login_foreground_color: Vec, 25 | pub player_background_color: Vec, 26 | } 27 | 28 | #[derive(Debug, Deserialize)] 29 | pub struct Player { 30 | pub cvlc: String, 31 | pub cvlc_term: String, 32 | pub address: String, 33 | pub port: String, 34 | } 35 | 36 | /// load config from `config.toml` file 37 | pub fn load_config() -> Result { 38 | let config_home_path = env::var("XDG_CONFIG_HOME") 39 | .map(PathBuf::from) 40 | .unwrap_or_else(|_| { 41 | let mut path = dirs::home_dir().expect("Unable to find the user's home directory"); 42 | 43 | if cfg!(target_os = "macos") { 44 | path.push("Library/Preferences"); 45 | } else { 46 | path.push(".config"); 47 | } 48 | 49 | path 50 | }); 51 | 52 | let config_path = config_home_path.join("toutui/config.toml"); 53 | let config_path_str = config_path.to_str().unwrap().to_string(); 54 | 55 | let config = ConfigLib::builder() 56 | .add_source(File::with_name(&config_path_str)) 57 | .build() 58 | .map_err(|e| Report::new(e))?; 59 | 60 | let colors: Colors = config.get("colors") 61 | .map_err(|e| Report::new(e))?; 62 | let player: Player = config.get("player") 63 | .map_err(|e| Report::new(e))?; 64 | 65 | Ok(ConfigFile { colors, player }) 66 | } 67 | 68 | -------------------------------------------------------------------------------- /src/db/database_struct.rs: -------------------------------------------------------------------------------- 1 | use serde::{Serialize, Deserialize}; 2 | use crate::db::crud::*; 3 | use color_eyre::Result; 4 | 5 | pub struct Database { 6 | pub users: Vec, 7 | pub default_usr: Vec, 8 | pub listening_session: ListeningSession, 9 | pub others: Others, 10 | } 11 | 12 | #[derive(Serialize, Deserialize, Debug)] 13 | pub struct User { 14 | pub server_address: String, 15 | pub username: String, 16 | pub token: String, 17 | pub is_default_usr: bool, 18 | pub name_selected_lib: String, 19 | pub id_selected_lib: String, 20 | pub is_loop_break: String, 21 | pub is_vlc_launched_first_time: String, 22 | pub speed_rate: f32, 23 | pub is_vlc_running: String, 24 | pub is_show_key_bindings: String, 25 | } 26 | 27 | #[derive(Serialize, Deserialize, Debug)] 28 | // currently use for close listening session when app is quit 29 | // but in future could be used to sync offline items 30 | pub struct ListeningSession { 31 | pub id_session: String, 32 | pub id_item: String, 33 | pub current_time: u32, 34 | pub duration: String, 35 | pub is_finished: bool, 36 | pub id_pod: String, 37 | pub elapsed_time: u32, 38 | pub title: String, 39 | pub author: String, 40 | pub is_playback: bool, 41 | pub chapter: String, 42 | } 43 | 44 | pub struct Others { 45 | pub login_err: String, 46 | } 47 | 48 | 49 | impl Database { 50 | pub async fn new() -> Result { 51 | // open db and create table if there is none 52 | let _ = init_db(); 53 | 54 | // init empty Vec for future add of users 55 | let users: Vec = vec![]; 56 | 57 | // retrieve default user 58 | let mut default_usr: Vec = Vec::new(); 59 | 60 | if let Ok(result) = select_default_usr() { 61 | default_usr = result; 62 | } 63 | 64 | 65 | // init listening_session 66 | let listening_session = ListeningSession { 67 | id_session: String::new(), 68 | id_item: String::new(), 69 | current_time: 0, 70 | duration: String::new(), 71 | is_finished: false, 72 | id_pod: String::new(), 73 | elapsed_time: 0, 74 | title: String::new(), 75 | author: String::new(), 76 | is_playback: false, 77 | chapter: String::new(), 78 | }; 79 | 80 | let others = Others { 81 | login_err: String::new(), 82 | }; 83 | 84 | Ok(Self { 85 | users, 86 | default_usr, 87 | listening_session, 88 | others 89 | }) 90 | } 91 | } 92 | 93 | -------------------------------------------------------------------------------- /src/db/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod crud; 2 | pub mod database_struct; 3 | -------------------------------------------------------------------------------- /src/logic/auth/auth_input.rs: -------------------------------------------------------------------------------- 1 | use crate::login_app::AppLogin; 2 | use ratatui::backend::CrosstermBackend; 3 | use ratatui::widgets::{Block, Borders}; 4 | use ratatui::text::Line; 5 | use ratatui::Terminal; 6 | use std::io; 7 | use tui_textarea::TextArea; 8 | use ratatui::{ 9 | layout::Rect, 10 | style::{Color, Style}, 11 | }; 12 | use crate::api::server::auth_process::*; 13 | use crossterm::event::{self, KeyEvent, KeyCode}; 14 | use log::{info, error}; 15 | use crate::utils::exit_app::*; 16 | use crate::utils::pop_up_message::*; 17 | use crate::db::crud::*; 18 | 19 | const VERSION: &str = env!("CARGO_PKG_VERSION"); 20 | 21 | 22 | impl AppLogin { 23 | pub fn auth(&mut self) -> io::Result<()> { 24 | info!("[auth_input] Login"); 25 | 26 | // init input area 27 | let stdout = io::stdout(); 28 | let stdout = stdout.lock(); 29 | 30 | let backend = CrosstermBackend::new(stdout); 31 | let mut term = Terminal::new(backend)?; 32 | 33 | let fg_color = self.config.colors.login_foreground_color.clone(); 34 | 35 | let mut textarea1 = TextArea::default(); 36 | textarea1.set_block( 37 | Block::default() 38 | .borders(Borders::ALL) 39 | .title("Server address") 40 | .title_bottom(Line::from(format!("🦜Toutui v{} - Esc to quit.", VERSION)).right_aligned()) 41 | .border_style(Style::default() 42 | .fg(Color::Rgb(fg_color[0], fg_color[1], fg_color[2]))) 43 | ); 44 | 45 | textarea1.set_placeholder_text("http:// or https:// required"); 46 | 47 | let mut textarea2 = TextArea::default(); 48 | textarea2.set_block( 49 | Block::default() 50 | .borders(Borders::ALL) 51 | .title("Username") 52 | .title_bottom(Line::from(format!("🦜Toutui v{} - Esc to quit.", VERSION)).right_aligned()) 53 | .border_style(Style::default() 54 | .fg(Color::Rgb(fg_color[0], fg_color[1], fg_color[2]))) 55 | ); 56 | 57 | let mut textarea3 = TextArea::default(); 58 | textarea3.set_block( 59 | Block::default() 60 | .borders(Borders::ALL) 61 | .title("Password") 62 | .title_bottom(Line::from(format!("🦜Toutui v{} - Esc to quit.", VERSION)).right_aligned()) 63 | .border_style(Style::default() 64 | .fg(Color::Rgb(fg_color[0], fg_color[1], fg_color[2]))) 65 | ); 66 | textarea3.set_mask_char('\u{2022}'); 67 | 68 | // display 69 | let size = term.size()?; 70 | let input_area = Rect { 71 | x: (size.width - size.width / 2) / 2, 72 | y: (size.height - 3) / 2, 73 | width: size.width / 2, 74 | height: 3, 75 | }; 76 | 77 | // init variables 78 | let mut textareas = vec![textarea1, textarea2, textarea3]; 79 | let mut current_index = 0; 80 | let mut collected_data : Vec = Vec::new(); 81 | let log_bg_color = self.config.colors.log_background_color.clone(); 82 | 83 | loop { 84 | term.draw(|f| { 85 | let background = Block::default() 86 | .style(Style::default() 87 | .bg(Color::Rgb( 88 | log_bg_color[0], 89 | log_bg_color[1], 90 | log_bg_color[2], 91 | ))); 92 | f.render_widget(&textareas[current_index], input_area); 93 | f.render_widget(background, f.area()); 94 | })?; 95 | 96 | // display error message (in any) 97 | let mut stdout = std::io::stdout(); 98 | let error_message_login = match get_others() { 99 | Ok(Some(value)) => value.login_err, 100 | Ok(None) => { 101 | "".to_string() 102 | } 103 | Err(e) => { 104 | info!("ERROR: Failed to get login error: {}", e); 105 | "".to_string() 106 | }}; 107 | let _ = pop_message(&mut stdout, 6, error_message_login.as_str()); 108 | 109 | match crossterm::event::read()? { 110 | event::Event::Key(KeyEvent { code: KeyCode::Enter, .. }) => { 111 | if current_index < textareas.len() - 1 { 112 | // will just take textarea 1 and 2, 3 will take after break loop 113 | 114 | collected_data.push(textareas[current_index].lines().join("\n")); 115 | current_index += 1; 116 | } else { 117 | break; 118 | } 119 | } 120 | 121 | event::Event::Key(KeyEvent { code: KeyCode::Esc, .. }) => { 122 | let _ = update_login_err(""); 123 | clean_exit(); 124 | } 125 | 126 | event::Event::Key(input) => { 127 | if let Some(active_textarea) = textareas.get_mut(current_index) { 128 | active_textarea.input(input); 129 | } 130 | } 131 | _ => {} 132 | } 133 | } 134 | 135 | // save the last input (from textearea3) 136 | collected_data.push(textareas[current_index].lines().join("\n")); 137 | 138 | // make disappear search_area (the input bar) after the break loop 139 | term.draw(|f| { 140 | let empty_block = Block::default(); 141 | f.render_widget(empty_block, input_area); 142 | 143 | })?; 144 | 145 | 146 | // Fetch data from api and insert them in database 147 | 148 | // send result 149 | if let Some(_active_textarea) = textareas.get(current_index) { 150 | let collected_data_clone = collected_data.clone(); 151 | tokio::spawn(async move { 152 | // println!("Wait..."); 153 | match auth_process( 154 | collected_data_clone[1].as_str(), // username 155 | collected_data_clone[2].as_str(), // password 156 | collected_data_clone[0].as_str(), // server_address 157 | ).await { 158 | Ok(_response) => { 159 | info!("[auth_process] Login successful"); 160 | println!("Login successful"); 161 | let _ = update_login_err(""); 162 | } 163 | Err(e) => { 164 | error!("[auth_process] Login failed: {}", e); 165 | eprintln!("ERROR: {}", e); 166 | let err = format!("ERROR: {}", e.to_string()); 167 | let _ = update_login_err(err.as_str()); 168 | } 169 | }}); 170 | 171 | // to quit the current thread and back to login or home (if connection is successful) 172 | // should_exit allow to quit the terminal in login_app.rs 173 | print!("\x1B[2J\x1B[1;1H"); // clean all prints displayed 174 | self.should_exit = true; 175 | 176 | Ok(()) 177 | } else { 178 | Err(io::Error::new(io::ErrorKind::Other, "Invalid textarea")) 179 | } 180 | } 181 | } 182 | 183 | -------------------------------------------------------------------------------- /src/logic/auth/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod auth_input; 2 | -------------------------------------------------------------------------------- /src/logic/handle_input/handle_l_book.rs: -------------------------------------------------------------------------------- 1 | use crate::player::vlc::start_vlc::*; 2 | use crate::player::vlc::fetch_vlc_data::*; 3 | use crate::player::vlc::exec_nc::*; 4 | use crate::utils::pop_up_message::*; 5 | use crate::api::me::update_media_progress::*; 6 | use crate::api::library_items::play_lib_item_or_pod::*; 7 | use crate::api::sessions::sync_open_session::*; 8 | use crate::api::sessions::close_open_session::*; 9 | use std::io::stdout; 10 | use log::{info, error}; 11 | use crate::db::crud::*; 12 | use crate::utils::vlc_tcp_stream::*; 13 | use crate::player::vlc::quit_vlc::*; 14 | 15 | pub async fn handle_l_book( 16 | token: Option<&String>, 17 | ids_library_items: Vec, 18 | selected: Option, 19 | port: String, 20 | address_player: String, 21 | server_address: String, 22 | program: String, 23 | is_cvlc_term: String, 24 | username: String, 25 | ) { 26 | 27 | // need to pkill VLC for macos users 28 | pkill_vlc(); 29 | 30 | if let Some(index) = selected { 31 | if let Some(id) = ids_library_items.get(index) { 32 | if let Some(token) = token { 33 | if let Ok(info_item) = post_start_playback_session_book(Some(&token), id, server_address.clone()).await { 34 | 35 | // converting current time 36 | let mut current_time: u32 = info_item[0].parse::().unwrap().round() as u32; 37 | 38 | info!("[handle_l_book][post_start_playback_session_book] OK"); 39 | info!("[handle_l_book][post_start_playback_session_book] Item {} started at {}s", id, current_time); 40 | 41 | 42 | // insert variables in databse (`listening_session` table) for sync session when app is quit 43 | let _ = insert_listening_session( 44 | info_item[3].clone(), // id_session 45 | id.to_string(), // id_item 46 | current_time, // current time 47 | info_item[2].clone(), // total item duration 48 | "".to_string(), // empty here, because it's for podcasts 49 | 0, // elapsed time start at 0 seconds 50 | info_item[4].clone(), // title 51 | info_item[6].clone(), // author 52 | true, // is_playback 53 | "".to_string(), // chapter 54 | ); 55 | 56 | // clone otherwise, these variable will be consumed and not available anymore 57 | // for use outside start_vlc spawn 58 | let token_clone = token.clone(); 59 | let port_clone = port.clone(); 60 | let info_item_clone = info_item.clone() ; 61 | let server_address_clone = server_address.clone() ; 62 | let address_player_clone = address_player.clone() ; 63 | let username_clone = username.clone(); 64 | 65 | // start_vlc is launched in a spawn to allow fetch_vlc_data to start at the same time 66 | tokio::spawn(async move { 67 | // this info! is not the most reliable to know is VLC is really launched 68 | info!("[handle_l_book][start_vlc] VLC successfully launched"); 69 | start_vlc( 70 | &info_item_clone[0], // current_time 71 | &port_clone, // player port 72 | address_player_clone, // player address 73 | &info_item_clone[1], // content url 74 | Some(&token_clone), //token 75 | info_item_clone[4].clone(), //title 76 | info_item_clone[5].clone(), // subtitle 77 | info_item_clone[6].clone(), //title 78 | server_address_clone.clone(), // server address 79 | program.clone(), 80 | username_clone 81 | ).await; 82 | }); 83 | 84 | 85 | if is_cvlc_term == "1" { 86 | let port_clone = port.clone(); 87 | let address_player_clone = address_player.clone(); 88 | tokio::spawn(async move { 89 | exec_nc(&port_clone, address_player_clone).await; 90 | }); 91 | } 92 | 93 | 94 | 95 | // clear loading message (from app.rs) when vlc is launched 96 | let mut stdout = stdout(); 97 | let _ = clear_message(&mut stdout, 3); 98 | 99 | 100 | // Important, sleep time to 1s minimum otherwise connection to vlc player will not have time to connect 101 | tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; 102 | 103 | // init var for decide to send 0 sec in sync session if player is in pause 104 | // 3 sec is not very "pro" but it's because i'm sure for this first iteration 105 | // data_fetched_from_vlc will not be = to 3 (because a little delay is given 106 | // before sync progress, in my case 5 secs, others apps a little bit more) 107 | // futhermore, in the worst case, if data_fetched_from_vlc is equal ti 3 for 108 | // the first iteration, it will shift the progress sync to 5 secondes 109 | let mut last_current_time: u32 = 3; 110 | let mut progress_sync: u32 = 3; 111 | 112 | let _ = update_is_vlc_running("1", username.as_str()); 113 | 114 | let mut trigger = 1; 115 | 116 | loop { 117 | match fetch_vlc_data(port.clone(), address_player.clone()).await { 118 | Ok(Some(data_fetched_from_vlc)) => { 119 | // println!("Fetched data: {}", data_fetched_from_vlc.to_string()); 120 | 121 | // update current_time in database (`listening_session` table) 122 | let _ = update_current_time(data_fetched_from_vlc, info_item[3].as_str()); 123 | 124 | // Important, sleep time to 1s minimum, otherwise connection to vlc player will not have time to connect 125 | tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; 126 | // println!("last_curr: {}", last_current_time); 127 | if data_fetched_from_vlc == last_current_time { 128 | progress_sync = 0; // the track is in pause 129 | } else { 130 | let speed_rate_str = get_speed_rate(username.as_str()); 131 | let speed_rate = speed_rate_str.parse::().unwrap_or(1.0); 132 | let current_time_adjusted = current_time as f64 / speed_rate as f64; 133 | let data_fetched_from_vlc_adjusted = data_fetched_from_vlc as f64 / speed_rate as f64; 134 | let diff = data_fetched_from_vlc_adjusted as u32 - current_time_adjusted as u32; 135 | // if > 20 means that new current_time is not take into account 136 | // so we need to temporarly, put 1 sec if it happens (not the 137 | // most accurate...) 138 | // happen when a new jump/back of a chapter, or jump/back 10s 139 | // the difference is between data_fetched_from_vlc_adjusted, 140 | // and old currentitime_adjusted. This last one don't have time 141 | // to be the accurate version, because trigger is not equal to 142 | // 10 (so, it can't reach current_time = data_fetched_from_vlc in fetch_vlc_is_playing function bellow)) 143 | if diff > 20 { 144 | progress_sync += 1; 145 | } else { 146 | progress_sync = diff; 147 | } 148 | } 149 | last_current_time = data_fetched_from_vlc; 150 | 151 | // get current chapter 152 | match vlc_tcp_stream(address_player.as_str(), port.as_str(), "chapter") { 153 | Ok(response) => { 154 | let _ = update_chapter(response.as_str(), info_item[3].as_str()); 155 | } 156 | Err(e) => info!("Error: {}", e), 157 | } 158 | 159 | 160 | match fetch_vlc_is_playing(port.clone(), address_player.clone()).await { 161 | Ok(true) => { 162 | // to sync progress in the server each 10 seconds 163 | if trigger == 10 { 164 | let _ = sync_session(Some(&token), &info_item[3],Some(data_fetched_from_vlc), progress_sync, server_address.clone()).await; 165 | let _ = update_media_progress_book(id, Some(&token), Some(data_fetched_from_vlc), &info_item[2], server_address.clone()).await; 166 | 167 | // update elapsed_time in database (`listening_session` table) 168 | let _ = update_elapsed_time(progress_sync, info_item[3].as_str()); 169 | 170 | current_time = data_fetched_from_vlc; 171 | progress_sync = 0; 172 | trigger = 0; 173 | 174 | } else if progress_sync != 0 { 175 | trigger += 1; 176 | } else if progress_sync == 0 { 177 | trigger += 0; 178 | } 179 | }, 180 | // `Ok(false)` means that the track is stopped but VLC still 181 | // open. Allow to track when the audio reached the end. And 182 | // differ from the case where the user just close VLC 183 | // during a playing (in this case we don't want to mark the 184 | // track as finished) 185 | Ok(false) => { 186 | let is_finised = true; 187 | info!("[handle_l_book][Finished] Track finished"); 188 | 189 | // update is_finished in database (`listening_session` table) 190 | let _ = update_is_finished("1", info_item[3].as_str()); 191 | 192 | let _ = close_session_without_send_prg_data(Some(&token), &info_item[3], server_address.clone()).await; 193 | info!("[handle_l_book][Finished] Session successfully closed"); 194 | let _ = update_media_progress2_book(id, Some(&token), Some(data_fetched_from_vlc), &info_item[2], is_finised, server_address).await; 195 | info!("[handle_l_book][Finished] VLC stopped"); 196 | info!("[handle_l_book][Finished] Item {} closed at {}s", id, data_fetched_from_vlc); 197 | let _ = update_is_loop_break("1", username.as_str()); 198 | 199 | let _ = update_is_vlc_running("0", username.as_str()); 200 | break; 201 | }, 202 | // `Err` means : VLC is close (because if VLC is not playing 203 | // anymore an error is send by `fetch_vlc_is_playing`). 204 | // The track is not finished. VLC is just stopped by the user. 205 | // Differ from the case above where the track reched the end. 206 | Err(_) => { 207 | let _ = update_is_vlc_running("0", username.as_str()); 208 | info!("[handle_l_book][Quit]"); 209 | // close session when VLC is quitted 210 | let _ = close_session_without_send_prg_data(Some(&token), &info_item[3], server_address.clone()).await; 211 | info!("[handle_l_book][Quit] Session successfully closed"); 212 | // send one last time media progress (bug to retrieve media 213 | // progress otherwise) 214 | let _ = update_media_progress_book(id, Some(&token), Some(data_fetched_from_vlc), &info_item[2], server_address).await; 215 | info!("[handle_l_book][Quit] VLC closed"); 216 | info!("[handle_l_book][Quit] Item {} closed at {}s", id, data_fetched_from_vlc); 217 | //eprintln!("Error fetching play status: {}", e); 218 | let _ = update_is_loop_break("1", username.as_str()); 219 | break; 220 | } 221 | } 222 | 223 | } 224 | // when no data in fetched (generaly when VLC is launched and quit 225 | // quickly) Indeed, in this case, data does not have enough time to be 226 | // fetched 227 | Ok(None) => { 228 | let _ = update_is_vlc_running("0", username.as_str()); 229 | info!("[handle_l_book][None]"); 230 | let _ = close_session_without_send_prg_data(Some(&token), &info_item[3], server_address.clone()).await; 231 | info!("[handle_l_book][None] Session successfully closed"); 232 | let _ = update_media_progress_book(id, Some(&token), Some(current_time), &info_item[2], server_address.clone()).await; 233 | info!("[handle_l_book][None] VLC closed"); 234 | info!("[handle_l_book][None] Item {} closed at {}s", id, current_time); 235 | 236 | let _ = update_is_loop_break("1", username.as_str()); 237 | break; // Exit if no data available 238 | } 239 | Err(e) => { 240 | error!("[handle_l_book][Err(e)]{}", e); 241 | break; // Exit on error 242 | } 243 | } 244 | } 245 | } else { 246 | error!("[handle_l_book] Failed to start playback session"); 247 | eprintln!("Failed to start playback session"); 248 | } 249 | } 250 | } 251 | } 252 | } 253 | 254 | -------------------------------------------------------------------------------- /src/logic/handle_input/handle_l_pod.rs: -------------------------------------------------------------------------------- 1 | use crate::player::vlc::start_vlc::*; 2 | use crate::player::vlc::fetch_vlc_data::*; 3 | use crate::api::me::update_media_progress::*; 4 | use crate::api::library_items::play_lib_item_or_pod::*; 5 | use crate::api::sessions::sync_open_session::*; 6 | use crate::api::sessions::close_open_session::*; 7 | use crate::player::vlc::exec_nc::*; 8 | use crate::utils::pop_up_message::*; 9 | use std::io::stdout; 10 | use log::{info, error}; 11 | use crate::db::crud::*; 12 | use crate::utils::vlc_tcp_stream::*; 13 | use crate::player::vlc::quit_vlc::*; 14 | 15 | 16 | // handle l for App::View PodcastEpisode // 17 | 18 | pub async fn handle_l_pod( 19 | token: Option<&String>, 20 | ids_library_items: &Vec, 21 | selected: Option, 22 | port: String, 23 | address_player: String, 24 | id_pod: &str, 25 | server_address: String, 26 | program: String, 27 | is_cvlc_term: String, 28 | username: String, 29 | 30 | ) { 31 | 32 | // need to pkill VLC for macos users 33 | pkill_vlc(); 34 | 35 | if let Some(index) = selected { 36 | if let Some(id) = ids_library_items.get(index) { 37 | // id is id of the podcast episode and id_pod is the id of the podcast 38 | if let Some(token) = token { 39 | if let Ok(info_item) = post_start_playback_session_pod(Some(&token),id_pod, &id, server_address.clone()).await { 40 | 41 | // converting current time 42 | let mut current_time: u32 = info_item[0].parse::().unwrap().round() as u32; 43 | 44 | info!("[handle_l_pod][post_start_playback_session_pod] OK"); 45 | info!("[handle_l_pod][post_start_playback_session_pod] Item {} started at {}s", id_pod, current_time); 46 | 47 | 48 | // insert variables in databse (`listening_session` table) for sync session when app is quit 49 | let _ = insert_listening_session( 50 | info_item[3].clone(), // id_session 51 | id_pod.to_string(), // id of the podcast (not the episode) 52 | current_time, // current time 53 | info_item[2].clone(), 54 | id.to_string(), // id (the episode of the podcast) 55 | 0, // elapsed time start at 0 seconds 56 | info_item[4].clone(), // title 57 | info_item[6].clone(), // author 58 | true, // is_playback 59 | "".to_string(), // chapter 60 | ); 61 | 62 | 63 | // clone otherwise, these variable will be consumed and not available anymore 64 | // for use outside start_vlc spawn 65 | let token_clone = token.clone(); 66 | let port_clone = port.clone(); 67 | let info_item_clone = info_item.clone() ; 68 | let server_address_clone = server_address.clone() ; 69 | let address_player_clone = address_player.clone() ; 70 | let username_clone = username.clone(); 71 | 72 | // start_vlc is launched in a spawn to allow fetch_vlc_data to start at the same time 73 | tokio::spawn(async move { 74 | // this info! is not the most reliable to know is VLC is really launched 75 | info!("[handle_l_pod][start_vlc] VLC successfully launched"); 76 | start_vlc( 77 | &info_item_clone[0], // current_time 78 | &port_clone, // player port 79 | address_player_clone, // player address 80 | &info_item_clone[1], // content url 81 | Some(&token_clone), //token 82 | info_item_clone[4].clone(), //title 83 | info_item_clone[5].clone(), // subtitle 84 | info_item_clone[6].clone(), //title 85 | server_address_clone.clone(), // server address 86 | program.clone(), 87 | username_clone 88 | ).await; 89 | }); 90 | 91 | if is_cvlc_term == "1" { 92 | let port_clone = port.clone(); 93 | let address_player_clone = address_player.clone(); 94 | tokio::spawn(async move { 95 | exec_nc(&port_clone, address_player_clone).await; 96 | }); 97 | } 98 | 99 | // clear loading message (from app.rs) when vlc is launched 100 | let mut stdout = stdout(); 101 | let _ = clear_message(&mut stdout, 3); 102 | 103 | 104 | // Important, sleep time to 1s otherwise connection to vlc player will not have time to connect 105 | tokio::time::sleep(std::time::Duration::from_secs(1)).await; 106 | 107 | // init var for decide to send 0 sec in sync session if player is in pause 108 | // 3 sec is not very "pro" but it's because i'm sure for this first iteration 109 | // data_fetched_from_vlc will not be = to 3 (because a little delay is given 110 | // before sync progress, in my case 5 secs, others apps a little bit more) 111 | // futhermore, in the worst case, if data_fetched_from_vlc is equal ti 3 for 112 | // the first iteration, it will shift the progress sync to 5 secondes 113 | let mut last_current_time: u32 = 3; 114 | let mut progress_sync: u32 = 3; 115 | 116 | let _ = update_is_vlc_running("1", username.as_str()); 117 | 118 | let mut trigger = 1; 119 | 120 | 121 | loop { 122 | match fetch_vlc_data(port.clone(), address_player.clone()).await { 123 | Ok(Some(data_fetched_from_vlc)) => { 124 | // println!("Fetched data: {}", data_fetched_from_vlc.to_string()); 125 | 126 | // update current_time in database (`listening_session` table) 127 | let _ = update_current_time(data_fetched_from_vlc, info_item[3].as_str()); 128 | 129 | // Important, sleep time to 1s minimum, otherwise connection to vlc player will not have time to connect 130 | tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; 131 | // println!("last_curr: {}", last_current_time); 132 | if data_fetched_from_vlc == last_current_time { 133 | progress_sync = 0; // the track is in pause 134 | } else { 135 | let speed_rate_str = get_speed_rate(username.as_str()); 136 | let speed_rate = speed_rate_str.parse::().unwrap_or(1.0); 137 | let current_time_adjusted = current_time as f64 / speed_rate as f64; 138 | let data_fetched_from_vlc_adjusted = data_fetched_from_vlc as f64 / speed_rate as f64; 139 | let diff = data_fetched_from_vlc_adjusted as u32 - current_time_adjusted as u32; 140 | // if > 20 means that new current_time is not take into account 141 | // so we need to temporarly, put 1 sec if it happens (not the 142 | // most accurate...) 143 | // happen when a new jump/back of a chapter, or jump/back 10s 144 | // the difference is between data_fetched_from_vlc_adjusted, 145 | // and old currentitime_adjusted. This last one don't have time 146 | // to be the accurate version, because trigger is not equal to 147 | // 10 (so, it can't reach current_time = data_fetched_from_vlc in fetch_vlc_is_playing function bellow)) 148 | if diff > 20 { 149 | progress_sync += 1; 150 | } else { 151 | progress_sync = diff; 152 | } 153 | } 154 | last_current_time = data_fetched_from_vlc; 155 | 156 | // get current chapter 157 | match vlc_tcp_stream(address_player.as_str(), port.as_str(), "chapter") { 158 | Ok(response) => { 159 | let _ = update_chapter(response.as_str(), info_item[3].as_str()); 160 | } 161 | Err(e) => info!("Error: {}", e), 162 | } 163 | 164 | 165 | match fetch_vlc_is_playing(port.clone(), address_player.clone()).await { 166 | Ok(true) => { 167 | // to sync progress in the server each 10 seconds 168 | if trigger == 10 { 169 | let _ = update_media_progress_pod(id_pod, Some(&token), Some(data_fetched_from_vlc), &info_item[2], &id, server_address.clone()).await; 170 | let _ = sync_session(Some(&token), &info_item[3],Some(data_fetched_from_vlc), progress_sync, server_address.clone()).await; 171 | 172 | // update elapsed_time in database (`listening_session` table) 173 | let _ = update_elapsed_time(progress_sync, info_item[3].as_str()); 174 | 175 | current_time = data_fetched_from_vlc; 176 | progress_sync = 0; 177 | trigger = 0; 178 | 179 | } else if progress_sync != 0 { 180 | trigger += 1; 181 | } else if progress_sync == 0 { 182 | trigger += 0; 183 | } 184 | }, 185 | // `Ok(false)` means that the track is stopped but VLC still 186 | // open. Allow to track when the audio reached the end. And 187 | // differ from the case where the user just close VLC 188 | // during a playing (in this case we don't want to mark the 189 | // track as finished) 190 | Ok(false) => { 191 | let is_finised = true; 192 | info!("[handle_l_pod][Finished] Track finished"); 193 | 194 | // update is_finished in database (`listening_session` table) 195 | let _ = update_is_finished("1", info_item[3].as_str()); 196 | 197 | let _ = close_session_without_send_prg_data(Some(&token), &info_item[3], server_address.clone()).await; 198 | info!("[handle_l_pod][Finished] Session successfully closed"); 199 | 200 | let _ = update_media_progress2_pod(id_pod, Some(&token), Some(data_fetched_from_vlc), &info_item[2], is_finised, &id, server_address).await; 201 | info!("[handle_l_pod][Finished] VLC stopped"); 202 | info!("[handle_l_pod][Finished] Item {} closed at {}s", id_pod, data_fetched_from_vlc); 203 | let _ = update_is_loop_break("1", username.as_str()); 204 | 205 | let _ = update_is_vlc_running("0", username.as_str()); 206 | break; 207 | }, 208 | // `Err` means : VLC is close (because if VLC is not playing 209 | // anymore an error is send by `fetch_vlc_is_playing`). 210 | // The track is not finished. VLC is just stopped by the user. 211 | // Differ from the case above where the track reched the end. 212 | Err(_e) => { 213 | let _ = update_is_vlc_running("0", username.as_str()); 214 | info!("[handle_l_pod][Quit]"); 215 | // close session when VLC is quitted 216 | let _ = close_session_without_send_prg_data(Some(&token), &info_item[3], server_address.clone()).await; 217 | info!("[handle_l_pod][Quit] Session successfully closed"); 218 | // send one last time media progress (bug to retrieve media 219 | // progress otherwise) 220 | let _ = update_media_progress_pod(id_pod, Some(&token), Some(data_fetched_from_vlc), &info_item[2], &id, server_address).await; 221 | info!("[handle_l_pod][Quit] VLC closed"); 222 | info!("[handle_l_pod][Quit] Item {} closed at {}s", id_pod, data_fetched_from_vlc); 223 | //eprintln!("Error fetching play status: {}", e); 224 | let _ = update_is_loop_break("1", username.as_str()); 225 | break; 226 | } 227 | } 228 | 229 | } 230 | // when no data in fetched (generaly when VLC is launched and quit 231 | // quickly) Indeed, in this case, data does not have enough time to be 232 | // fetched 233 | Ok(None) => { 234 | let _ = update_is_vlc_running("0", username.as_str()); 235 | info!("[handle_l_pod][None]"); 236 | let _ = close_session_without_send_prg_data(Some(&token), &info_item[3], server_address.clone()).await; 237 | info!("[handle_l_pod][None] Session successfully closed"); 238 | let _ = update_media_progress_pod(id_pod, Some(&token), Some(current_time), &info_item[2], &id, server_address).await; 239 | info!("[handle_l_pod][None] VLC closed"); 240 | info!("[handle_l_pod][None] Item {} closed at {}s", id_pod, current_time); 241 | 242 | let _ = update_is_loop_break("1", username.as_str()); 243 | break; // Exit if no data available 244 | } 245 | Err(e) => { 246 | error!("[handle_l_pod][Err(e)]{}", e); 247 | break; // Exit on error 248 | } 249 | } 250 | } 251 | } else { 252 | error!("[handle_l_pod] Failed to start playback session"); 253 | eprintln!("Failed to start playback session"); 254 | } 255 | } 256 | } 257 | } 258 | } 259 | 260 | -------------------------------------------------------------------------------- /src/logic/handle_input/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod handle_l_book; 2 | pub mod handle_l_pod; 3 | pub mod handle_l_pod_home; 4 | -------------------------------------------------------------------------------- /src/logic/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod handle_input; 2 | pub mod search; 3 | pub mod auth; 4 | pub mod sync_session; 5 | -------------------------------------------------------------------------------- /src/logic/search/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod search_active; 2 | -------------------------------------------------------------------------------- /src/logic/search/search_active.rs: -------------------------------------------------------------------------------- 1 | use crate::App; 2 | use crate::app::AppView; 3 | use ratatui::backend::CrosstermBackend; 4 | use ratatui::widgets::{Block, Borders}; 5 | use ratatui::Terminal; 6 | use std::io; 7 | use tui_textarea::{Input, Key, TextArea}; 8 | use ratatui::{ 9 | layout::Rect, 10 | style::{Color, Style}, 11 | }; 12 | 13 | 14 | impl App { 15 | pub fn search_active(&mut self) -> io::Result { 16 | let stdout = io::stdout(); 17 | let stdout = stdout.lock(); 18 | 19 | let backend = CrosstermBackend::new(stdout); 20 | let mut term = Terminal::new(backend)?; 21 | 22 | let bg_color = self.config.colors.background_color.clone(); 23 | let fg_color = self.config.colors.search_bar_foreground_color.clone(); 24 | 25 | let mut textarea = TextArea::default(); 26 | textarea.set_block( 27 | Block::default() 28 | .borders(Borders::ALL) 29 | .title("Search") 30 | .border_style(Style::default() 31 | .fg(Color::Rgb(fg_color[0], fg_color[1], fg_color[2]))) 32 | .style(Style::default() 33 | .bg(Color::Rgb(bg_color[0], bg_color[1], bg_color[2]))) 34 | 35 | ); 36 | 37 | let size = term.size()?; 38 | let search_area = Rect { 39 | x: 1, 40 | y: size.height - 5, 41 | width: size.width - 2, 42 | height: 3, 43 | }; 44 | 45 | loop { 46 | 47 | term.draw(|f| { 48 | f.render_widget(&textarea, search_area); 49 | })?; 50 | match crossterm::event::read()?.into() { 51 | Input { key: Key::Enter, .. } => { 52 | self.search_mode = false; 53 | self.search_query = textarea.lines().join("\n"); 54 | self.view_state = AppView::SearchBook; 55 | self.list_state_search_results.select(Some(0)); 56 | break; 57 | } 58 | Input { key: Key::Esc, .. } => { 59 | self.search_mode = false; 60 | break; 61 | } 62 | input => { 63 | textarea.input(input); 64 | } 65 | } 66 | } 67 | term.draw(|f| { 68 | let empty_block = Block::default().style(Style::default().bg(Color::Rgb(bg_color[0], bg_color[1], bg_color[2]))); 69 | f.render_widget(empty_block, search_area); 70 | })?; 71 | 72 | Ok(textarea.lines().join("\n")) 73 | 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/logic/sync_session/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod wait_prev_session_finished; 2 | pub mod sync_session_from_database; 3 | -------------------------------------------------------------------------------- /src/logic/sync_session/sync_session_from_database.rs: -------------------------------------------------------------------------------- 1 | use crate::db::crud::*; 2 | use crate::api::sessions::close_open_session::*; 3 | use log::info; 4 | use crate::api::me::update_media_progress::*; 5 | use crate::player::vlc::quit_vlc::*; 6 | use crate::utils::exit_app::*; 7 | 8 | // close and sync listening session before quit the app 9 | pub async fn sync_session_from_database(token: Option, server_address: String, username: String, app_quit: bool, handle_key: &str, player_address: String, port: String) { 10 | 11 | // quit vlc before close and sync session (or close the app) 12 | let _ = quit_vlc(player_address.as_str(), port.as_str()); 13 | 14 | match get_listening_session() { 15 | Ok(Some(session)) => { 16 | 17 | let _ = close_session_without_send_prg_data( 18 | token.as_ref(), 19 | session.id_session.as_str(), 20 | server_address.clone()).await; 21 | 22 | match handle_key { 23 | "Q" => info!("[handle_key (Q)][Quit] Session successfully closed"), 24 | "l" => info!("[handle_key (l)] Session successfully closed"), 25 | _ => {} 26 | } 27 | 28 | if session.id_pod.is_empty() { 29 | if !session.is_finished { 30 | let _ = update_media_progress_book( 31 | session.id_item.as_str(), 32 | token.as_ref(), 33 | Some(session.current_time), 34 | &session.duration, 35 | server_address.clone()).await; 36 | 37 | match handle_key { 38 | "Q" => info!("[handle_key (Q)][book][Quit] Item {} closed at {:?}s (not finished)", session.id_item, session.current_time), 39 | "l" => info!("[handle_key (l)] Item {} closed at {:?}s (not finished)", session.id_item, session.current_time), 40 | _ => {} 41 | } 42 | } 43 | 44 | else { 45 | let is_finished = true; 46 | let _ = update_media_progress2_book( 47 | session.id_item.as_str(), 48 | token.as_ref(), 49 | Some(session.current_time), 50 | &session.duration, 51 | is_finished, 52 | server_address).await; 53 | 54 | match handle_key { 55 | "Q" => info!("[handle_key (Q)][book][Quit] Item {} closed at {:?}s (finished)", session.id_item, session.current_time), 56 | "l" => info!("[handle_key (l)] Item {} closed at {:?}s (finished)", session.id_item, session.current_time), 57 | _ => {} 58 | } 59 | } 60 | 61 | } else { 62 | if !session.is_finished { 63 | let _ = update_media_progress_pod( 64 | session.id_item.as_str(), 65 | token.as_ref(), 66 | Some(session.current_time), 67 | &session.duration, 68 | session.id_pod.as_str(), 69 | server_address.clone()).await; 70 | 71 | 72 | match handle_key { 73 | "Q" => info!("[handle_key (Q)][podcast][Quit] Item {} closed at {:?}s", session.id_pod, session.current_time), 74 | "l" => info!("[handle_key (l)] Item {} closed at {:?}s", session.id_pod, session.current_time), 75 | _ => {} 76 | } 77 | } else { 78 | let is_finished = true; 79 | let _ = update_media_progress2_pod( 80 | session.id_item.as_str(), 81 | token.as_ref(), 82 | Some(session.current_time), 83 | &session.duration, 84 | is_finished, 85 | session.id_pod.as_str(), 86 | server_address.clone()).await; 87 | 88 | match handle_key { 89 | "Q" => info!("[handle_key (Q)][podcast][Quit] Item {} closed at {:?}s (finished)", session.id_pod, session.current_time), 90 | "l" => info!("[handle_key (l)] Item {} closed at {:?}s (finished)", session.id_pod, session.current_time), 91 | _ => {} 92 | } 93 | } 94 | } 95 | 96 | if app_quit { 97 | // update is_vlc_launched_first_time 98 | let _ = update_is_vlc_launched_first_time("1", username.as_str()); 99 | let value = get_is_vlc_launched_first_time(username.as_str()); 100 | info!("[exit][is_vlc_launched_first_time] {}", value); 101 | 102 | // exit app 103 | info!("App successfully quit"); 104 | clean_exit(); 105 | 106 | } 107 | } 108 | 109 | Ok(None) => { 110 | let value = get_is_vlc_launched_first_time(username.as_str()); 111 | if value == "1" { 112 | info!("[handle_key] Quit with no listening session"); 113 | clean_exit(); 114 | } else { 115 | info!("[handle_key] First session launched"); 116 | } 117 | } 118 | Err(e) => { 119 | info!("[handle_key] Error during fetching session: {:?}", e); 120 | } 121 | } 122 | } 123 | 124 | -------------------------------------------------------------------------------- /src/logic/sync_session/wait_prev_session_finished.rs: -------------------------------------------------------------------------------- 1 | use crate::db::crud::*; 2 | use log::info; 3 | use crate::utils::pop_up_message::*; 4 | use std::io::stdout; 5 | 6 | pub fn wait_prev_session_finished(username: String) { 7 | 8 | // pop message 9 | let message = "Syncing your last listening session. Please wait..."; 10 | let mut stdout = stdout(); 11 | 12 | // check if previous play is finished 13 | let is_vlc_first_launch = get_is_vlc_launched_first_time(&username); 14 | info!("[AppView::Home][is_vlc_first_launch]{}", is_vlc_first_launch); 15 | 16 | if is_vlc_first_launch != "1" { 17 | let mut is_loop_break = get_is_loop_break(&username); 18 | info!("[AppView::Home][is_loop_break]{}", is_loop_break); 19 | 20 | while is_loop_break != "1" { 21 | std::thread::sleep(std::time::Duration::from_secs(1)); 22 | info!("[AppView::Home][loop][is_loop_break]"); 23 | is_loop_break = get_is_loop_break(&username); 24 | let _ = pop_message(&mut stdout, 3, message); 25 | } 26 | 27 | } 28 | 29 | // update database 30 | let _ = update_is_loop_break("0", &username); 31 | let value = get_is_loop_break(username.as_str()); 32 | info!("[AppView::Home][update_is_loop_break]{}", value); 33 | let _ = update_is_vlc_launched_first_time("0", &username); 34 | let value = get_is_vlc_launched_first_time(username.as_str()); 35 | info!("[AppView::Home][update_is_vlc_first_launch]{}", value); 36 | 37 | // clear pop up message 38 | let _ = clear_message(&mut stdout, 3); 39 | 40 | } 41 | -------------------------------------------------------------------------------- /src/login_app.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::Result; 2 | use ratatui::DefaultTerminal; 3 | use crate::config::*; 4 | 5 | 6 | pub enum AppViewLogin { 7 | Auth, 8 | } 9 | 10 | pub struct AppLogin { 11 | pub view_state: AppViewLogin, 12 | pub should_exit: bool, 13 | pub config: ConfigFile, 14 | } 15 | 16 | /// Init app 17 | impl AppLogin { 18 | pub async fn new() -> Result { 19 | // init config 20 | let config = load_config()?; 21 | 22 | // init view_state 23 | let view_state = AppViewLogin::Auth; 24 | Ok(Self { 25 | should_exit: false, 26 | view_state, 27 | config, 28 | }) 29 | } 30 | 31 | 32 | /// handle events 33 | pub fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> { 34 | while !self.should_exit { 35 | terminal.draw(|frame| frame.render_widget(&mut self, frame.area()))?; 36 | } 37 | Ok(()) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod login_app; 2 | mod app; 3 | mod config; 4 | mod api; 5 | mod ui; 6 | mod player; 7 | mod logic; 8 | mod db; 9 | mod utils; 10 | 11 | use login_app::AppLogin; 12 | use app::App; 13 | use crate::db::database_struct::Database; 14 | use color_eyre::Result; 15 | use std::time::Duration; 16 | use crossterm::event::{self, KeyCode}; 17 | use std::io::stdout; 18 | use crate::utils::pop_up_message::*; 19 | use crate::utils::logs::*; 20 | use log::info; 21 | use crate::db::crud::*; 22 | use ratatui::{ 23 | style::{Color, Style}, 24 | widgets::Block 25 | }; 26 | use crate::player::integrated::player_info::*; 27 | use crate::ui::player_tui::*; 28 | use std::env; 29 | use std::path::PathBuf; 30 | use crate::utils::clap::*; 31 | 32 | #[tokio::main] 33 | async fn main() -> Result<()> { 34 | 35 | // clap 36 | clap(); 37 | 38 | // this function allow to write all the logs in a file 39 | setup_logs().expect("Failed to execute logger"); 40 | 41 | // set dotenv to ~/.config.toutui/.env for linux 42 | // Library/Application Support/toutui/.env for macos 43 | // (dotenv will be use in `encrypt_token.rs`) 44 | let home_dir = dirs::home_dir().expect("Unable to find the user's home directory"); 45 | // if env::var("XDG_CONFIG_HOME") is not empty env_path will take designed path 46 | // else, env_path will be set to default path 47 | let config_path = env::var("XDG_CONFIG_HOME") 48 | .map(PathBuf::from) 49 | .unwrap_or_else(|_| { 50 | if cfg!(target_os = "macos") { 51 | // If XDG_CONFIG_HOME is not defined on macOS, use the default directory 52 | home_dir.join("Library").join("Preferences") 53 | } else { 54 | // Otherwise, use ~/.config for other systems (like Linux) 55 | home_dir.join(".config") 56 | } 57 | }); 58 | // Construct the dotenv 59 | let env_path = config_path.join("toutui").join(".env"); 60 | dotenv::from_filename(&env_path.clone()).ok(); 61 | 62 | // Init database 63 | let mut _database = Database::new().await?; 64 | let mut _database_ready = false; 65 | 66 | // Wait for the database to be ready, waiting for the user to enter their credentials 67 | loop { 68 | _database = Database::new().await?; 69 | if _database.default_usr.is_empty() { 70 | let app_login = AppLogin::new().await?; 71 | let terminal = ratatui::init(); 72 | let _app_result = app_login.run(terminal); 73 | // Process login result here 74 | // Wait for 1 second before checking again 75 | // If database is reinit to quickly before `auth_process.rs` is finished 76 | // it can be buggy and mark as failed. Maybe add more time to be sure (like 6 sec). 77 | // But normally, even it's failed, data are written in db. It will work at the second 78 | // attempt... 79 | tokio::time::sleep(Duration::from_secs(1)).await; 80 | } else { 81 | // If the database is ready, exit the loop 82 | print!("\x1B[2J\x1B[1;1H"); // clear all stdout (avoid to sill have the previous print when the app is launched) 83 | _database_ready = true; 84 | info!("Database ready"); 85 | break; 86 | } 87 | } 88 | 89 | // Once the database is ready, initialize the app 90 | if _database_ready { 91 | 92 | // init current username 93 | let mut username: String = String::new(); 94 | if let Some(var_username) = _database.default_usr.get(0) { 95 | username = var_username.clone(); 96 | } 97 | // init is_vlc_launched_first_time 98 | let _ = update_is_vlc_launched_first_time("1", username.as_str()); 99 | let value = get_is_vlc_launched_first_time(username.as_str()); 100 | info!("[main][is_vlc_launched_first_time] {}", value); 101 | 102 | let mut app = App::new().await?; 103 | let mut terminal = ratatui::init(); 104 | 105 | // Running the app in a loop 106 | loop { 107 | 108 | let is_playing = get_is_vlc_running(app.username.as_str()); 109 | let player_info = player_info(app.username.as_str()); 110 | 111 | terminal.draw(|frame| { 112 | let bg_color = app.config.colors.background_color.clone(); 113 | let bg_color_player = app.config.colors.player_background_color.clone(); 114 | // global background 115 | let background = Block::default() 116 | .style(Style::default() 117 | .bg(Color::Rgb(bg_color[0], bg_color[1], bg_color[2]))); 118 | 119 | frame.render_widget(background, frame.area()); 120 | 121 | if is_playing == "1" { 122 | let area = frame.area(); 123 | // render for the player (automatically refreshed) 124 | render_player(area, frame.buffer_mut(), player_info, bg_color_player, app.username.as_str()); 125 | } 126 | 127 | // render widget for general app : 128 | // Will be manually refresh by pressing `R` 129 | // If `app` variable is reinitialized below (`app = App::new().await?`), it will be taken into account and data will be refreshed 130 | // Otherwise, the current `app` variable will still be used. 131 | frame.render_widget(&mut app, frame.area()); 132 | })?; 133 | 134 | 135 | // Checking if any key is pressed (waiting for events with a 200ms delay here) 136 | if crossterm::event::poll(Duration::from_millis(200))? { 137 | if let event::Event::Key(key) = crossterm::event::read()? { 138 | app.handle_key(key); 139 | match key.code { 140 | // If the 'R' key is pressed, refresh the app 141 | KeyCode::Char('R') => { 142 | // pop up message 143 | let mut stdout = stdout(); 144 | let _ = clear_message(&mut stdout, 3); // clear a message, if any, before print the message bellow 145 | let _ = pop_message(&mut stdout, 3, "Refreshing app..."); 146 | // Reinitialize app to refresh 147 | app = App::new().await?; 148 | // clear message above 149 | let _ = clear_message(&mut stdout, 3); 150 | } 151 | _ => {} 152 | } 153 | } 154 | } 155 | 156 | // Short pause between event checks 157 | tokio::time::sleep(Duration::from_millis(50)).await; 158 | } 159 | } 160 | 161 | // Restore the terminal state before exiting the application 162 | ratatui::restore(); 163 | Ok(()) 164 | } 165 | -------------------------------------------------------------------------------- /src/player/integrated/handle_key_player.rs: -------------------------------------------------------------------------------- 1 | use std::io::{self, Write}; 2 | use std::net::TcpStream; 3 | use crate::db::crud::*; 4 | use std::thread; 5 | use std::time::Duration; 6 | 7 | pub fn handle_key_player(key: &str, address: &str, port: &str, is_playback: &mut bool, username: &str) -> io::Result<()> { 8 | let mut stream = TcpStream::connect(format!("{}:{}", address, port))?; 9 | 10 | let jump = "10"; 11 | 12 | match key { 13 | // toggle playback/pause 14 | " " => { 15 | match get_listening_session() { 16 | Ok(Some(session)) => { 17 | if session.is_playback { 18 | let _ = update_is_playback("0", session.id_session.as_str()); 19 | } else { 20 | let _ = update_is_playback("1", session.id_session.as_str()); 21 | } 22 | } 23 | Ok(None) => { 24 | 25 | } 26 | Err(_e) => { 27 | 28 | } 29 | } 30 | if *is_playback { 31 | writeln!(stream, "pause")?; 32 | } else { 33 | writeln!(stream, "play")?; 34 | } 35 | *is_playback = !*is_playback; 36 | } 37 | 38 | // For some cmd, below, need pause => cmd => play 39 | // Allow vlc buffer issue. 40 | // Futhermore, need a thread for macos otherwise vlc buffer issue 41 | // Otherwise buffer issue and the player freeze 42 | // But maybe it's not necessary because I test toutui on macos with a VM 43 | // and maybe the VM add a little delay.. but for now I try like this 44 | 45 | // jump forward 46 | "p" => { 47 | writeln!(stream, "pause")?; 48 | writeln!(stream, "seek +{}", jump)?; 49 | if cfg!(target_os = "macos") { 50 | thread::sleep(Duration::from_millis(500)); 51 | } 52 | writeln!(stream, "play")?; 53 | } 54 | // jump backward 55 | "u" => { 56 | writeln!(stream, "pause")?; 57 | writeln!(stream, "seek -{}", jump)?; 58 | if cfg!(target_os = "macos") { 59 | thread::sleep(Duration::from_millis(500)); 60 | } 61 | writeln!(stream, "play")?; 62 | } 63 | // next chapter 64 | "P" => { 65 | writeln!(stream, "pause")?; 66 | writeln!(stream, "chapter_n")?; 67 | if cfg!(target_os = "macos") { 68 | thread::sleep(Duration::from_millis(500)); 69 | } 70 | writeln!(stream, "play")?; 71 | } 72 | // previous chapter 73 | "U" => { 74 | writeln!(stream, "pause")?; 75 | writeln!(stream, "chapter_p")?; 76 | if cfg!(target_os = "macos") { 77 | thread::sleep(Duration::from_millis(500)); 78 | } 79 | writeln!(stream, "play")?; 80 | } 81 | // volume up 82 | "o" => { 83 | writeln!(stream, "volup")?; 84 | } 85 | // volume down 86 | "i" => { 87 | writeln!(stream, "voldown")?; 88 | } 89 | // speed rate up 90 | "O" => { 91 | let _ = update_speed_rate(username, true); 92 | let speed_rate = get_speed_rate(username); 93 | writeln!(stream, "rate {}", speed_rate)?; 94 | } 95 | // speed rate down 96 | "I" => { 97 | let _ = update_speed_rate(username, false); 98 | let speed_rate = get_speed_rate(username); 99 | writeln!(stream, "rate {}", speed_rate)?; 100 | } 101 | // shutdown 102 | "Y" => { 103 | writeln!(stream, "shutdown")?; 104 | } 105 | _ => {} 106 | } 107 | 108 | Ok(()) 109 | } 110 | 111 | -------------------------------------------------------------------------------- /src/player/integrated/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod handle_key_player; 2 | pub mod player_info; 3 | -------------------------------------------------------------------------------- /src/player/integrated/player_info.rs: -------------------------------------------------------------------------------- 1 | use log::info; 2 | use crate::db::crud::*; 3 | 4 | pub fn player_info(username: &str) -> Vec { 5 | let mut player_info = Vec::new(); 6 | 7 | match get_listening_session() { 8 | Ok(Some(session)) => { 9 | player_info.push(session.title); 10 | player_info.push(session.author); 11 | 12 | if let Ok(num) = session.chapter.trim().parse::() { 13 | let new_chapter = format!("Chapter {}", num + 1); 14 | player_info.push(new_chapter); 15 | } else { 16 | player_info.push(session.chapter.clone()); 17 | } 18 | 19 | player_info.push(session.is_playback.to_string()); 20 | player_info.push(format_time(session.current_time)); 21 | 22 | let speed_rate_str = get_speed_rate(username); 23 | let speed_rate: f32 = speed_rate_str.parse().unwrap_or(1.0); 24 | let original_duration = session.duration.parse::().unwrap_or(0); 25 | let adjusted_duration = (original_duration as f32 / speed_rate) as u32; 26 | player_info.push(format_time(adjusted_duration)); 27 | 28 | let remaining_time = adjusted_duration.saturating_sub(session.current_time); 29 | player_info.push(format_time(session.elapsed_time)); 30 | player_info.push(format_time(remaining_time)); 31 | 32 | let percent_progress = (session.current_time as f32 / adjusted_duration as f32) * 100.0; 33 | player_info.push(format!("{}", percent_progress.round() as u32)); 34 | } 35 | Ok(None) => { 36 | player_info.push(format!("N/A")); 37 | } 38 | Err(e) => { 39 | player_info.push(format!("Error")); 40 | info!("[player_info] Error retrieving data: {}", e); 41 | } 42 | } 43 | 44 | player_info.push(get_speed_rate(username)); 45 | 46 | player_info 47 | } 48 | 49 | fn format_time(seconds: u32) -> String { 50 | let hours = seconds / 3600; 51 | let minutes = (seconds % 3600) / 60; 52 | let secs = seconds % 60; 53 | 54 | if hours > 0 { 55 | format!("{}:{:02}:{:02}", hours, minutes, secs) 56 | } else if minutes > 0 { 57 | format!("{}:{:02}", minutes, secs) 58 | } else { 59 | format!("0:{}", secs) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/player/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod vlc; 2 | pub mod integrated; 3 | -------------------------------------------------------------------------------- /src/player/vlc/exec_nc.rs: -------------------------------------------------------------------------------- 1 | use std::process::Command; 2 | use std::process::Output; 3 | 4 | pub async fn exec_nc(port: &str, address: String) -> Output { 5 | let output: Output = Command::new("kitty") 6 | .arg("nc") 7 | .arg(format!("{}", address)) 8 | .arg(format!("{}", port)) 9 | .output() 10 | .expect("Failed to execute program"); 11 | 12 | output 13 | } 14 | 15 | -------------------------------------------------------------------------------- /src/player/vlc/fetch_vlc_data.rs: -------------------------------------------------------------------------------- 1 | use std::io::{self}; 2 | use vlc_rc::Client; 3 | use tokio::net::TcpStream; 4 | use std::process::Command; 5 | use std::str; 6 | use regex::Regex; 7 | use log::{info, warn, error}; 8 | 9 | /// This function : 10 | /// allow to connect and remotely ctrl VLC (with vlc-rc crate) on the port that was provided (Client::connect(format!("{}:{}", port))) 11 | /// if connection is successul, fecth data thanks to remotly control 12 | /// this fn is in the loop and run while vlc is running (bu checking if the port is still open) 13 | pub async fn fetch_vlc_data(port: String, address: String) -> Result, io::Error> { 14 | 15 | loop { 16 | // Check if VLC is running, if not, break the loop 17 | if !is_vlc_running(port.clone(), address.clone()).await { 18 | break Ok(None); // Exit loop if VLC is not running anymore 19 | } 20 | 21 | // Connect to VLC and fetch data 22 | let mut player = match Client::connect(format!("{}:{}", address, &port)) { 23 | Ok(player) => player, 24 | Err(e) => { 25 | error!("[fetch_vlc_data] {}", e); 26 | // if let Err(file_error) = log_error_to_file(&e.to_string()) { 27 | // eprintln!("Failed to log to vlc: {}", file_error); 28 | // error!("Failed to log to vlc: {}", file_error); 29 | // } 30 | continue; 31 | } 32 | }; 33 | // Fetch VLC current time (if connection is successful) 34 | let seconds = match player.get_time() { 35 | Ok(Some(value)) => Some(value), 36 | Ok(None) => None, 37 | Err(e) => { 38 | eprintln!("Failed to fetch time from VLC: {}", e); 39 | error!("Failed to fetch time from VLC: {}", e); 40 | None 41 | } 42 | }; 43 | 44 | // Print and return the fetched seconds 45 | if let Some(sec) = seconds { 46 | if sec > 0 { 47 | return Ok(Some(sec)); // Return seconds once fetched 48 | } else { 49 | info!("[is_vlc_running][check_seconds]: {:?}", sec); 50 | } 51 | } 52 | 53 | // Sleep to fetch data every second and avoid CPU overload 54 | tokio::time::sleep(std::time::Duration::from_secs(1)).await; 55 | } 56 | 57 | } 58 | 59 | // fetch if vlc is playing or stopped (return true if vlc is paused) 60 | pub async fn fetch_vlc_is_playing(port: String, address: String) -> Result { 61 | // Tentative de connexion à VLC 62 | let mut player = match Client::connect(format!("{}:{}", address, &port)) { 63 | Ok(player) => player, 64 | Err(e) => { 65 | warn!("[fetch_vlc_is_playing] Failed to connect to VLC at port {}: {}", port, e); 66 | return Err(format!("Failed to connect to VLC at port {}: {}", port, e)); 67 | }}; 68 | 69 | // Tentative de récupération du statut "is_playing" 70 | let is_playing = match player.is_playing() { 71 | Ok(true) => { 72 | //println!("The track is currently playing."); 73 | true 74 | } 75 | Ok(false) => { 76 | // vlc is still open but we have reached the end of the audio playback 77 | // allow to be check is the track is finished. But different from the case where VLC is 78 | // stopped by the user. 79 | //println!("The track is currently stopped."); 80 | false 81 | } 82 | Err(e) => { 83 | // vlc is closed ba the the user, as VLC is not open anymore. Indeed, match Client::connect(format!("{}:{}", &port)) 84 | // will send an error because VLC is not open anymore. Allow to differenciate from an 85 | // reach the end of audio just above. Here, the VLC vlc is closed be the user so we 86 | // want to make sur to differienciate from a normal reached of the audio playback 87 | error!("Failed to check the play status of VLC: {}", e); 88 | return Err(format!("Failed to check the play status of VLC: {}", e)) 89 | } 90 | }; 91 | 92 | Ok(is_playing) 93 | } 94 | 95 | 96 | 97 | // check if VLC is running by checking if the port used by the app to open VLC is open 98 | pub async fn is_vlc_running(port: String, address: String) -> bool { 99 | match TcpStream::connect(format!("{}:{}", address, port)).await { 100 | Ok(_) => { 101 | //println!("VLC is still running (port {} is open).", port); 102 | true 103 | } 104 | Err(_) => { 105 | info!("[is_vlc_running] VLC is not running (port {} is closed).", port); 106 | //println!("VLC is not running (port {} is closed).", port); 107 | false 108 | } 109 | } 110 | } 111 | 112 | // get vlc version 113 | pub async fn get_vlc_version() -> Result { 114 | 115 | let command: &str; 116 | if cfg!(target_os = "macos") { 117 | command = "/Applications/VLC.app/Contents/MacOS/VLC" 118 | } else { 119 | command = "vlc" 120 | } 121 | 122 | let output = Command::new(command) 123 | .arg("--version") 124 | .output()?; 125 | 126 | let version_output = str::from_utf8(&output.stdout) 127 | .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; 128 | 129 | let re = Regex::new(r"VLC (?:media player |version )?([\d.]+)").unwrap(); 130 | 131 | if let Some(captures) = re.captures(version_output) { 132 | if let Some(version) = captures.get(1) { 133 | return Ok(version.as_str().to_string()); 134 | } 135 | } 136 | 137 | Err(io::Error::new( 138 | io::ErrorKind::InvalidData, 139 | "Could not extract VLC version", 140 | )) 141 | } 142 | 143 | //fn log_error_to_file(error_message: &str) -> io::Result<()> { 144 | // let mut file = OpenOptions::new() 145 | // .create(true) 146 | // .append(true) 147 | // .open("vlc_errors.txt")?; 148 | // writeln!(file, "{}", error_message)?; 149 | // Ok(()) 150 | //} 151 | //#[allow(dead_code)] 152 | //fn write_to_file(file_path: &str, content: &str) -> io::Result<()> { 153 | // let mut file = OpenOptions::new() 154 | // .create(true) 155 | // .append(true) 156 | // .open(file_path)?; 157 | // writeln!(file, "{}", content)?; 158 | // Ok(()) 159 | //} 160 | -------------------------------------------------------------------------------- /src/player/vlc/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod start_vlc; 2 | pub mod quit_vlc; 3 | pub mod fetch_vlc_data; 4 | pub mod exec_nc; 5 | 6 | -------------------------------------------------------------------------------- /src/player/vlc/quit_vlc.rs: -------------------------------------------------------------------------------- 1 | use std::net::TcpStream; 2 | use std::io::{self, Write}; 3 | use log::{info, error}; 4 | use std::process::Command; 5 | 6 | // to quit quit VLC with shutdown cmd in cvlc 7 | pub fn quit_vlc(address: &str, port: &str) -> io::Result<()> { 8 | let mut stream = TcpStream::connect(format!("{}:{}", address, port))?; 9 | 10 | writeln!(stream, "shutdown")?; 11 | 12 | info!("[quit_vlc.rs] VLC successfully quit"); 13 | 14 | Ok(()) 15 | } 16 | 17 | // need also to pkill VLC for macos - otherwise, issue: 18 | // impossible to launch another track in the same session. 19 | pub fn pkill_vlc() { 20 | if cfg!(target_os = "macos") { 21 | let status = Command::new("pkill") 22 | .arg("VLC") 23 | .status() 24 | .expect("Failed to execute pkill"); 25 | 26 | if status.success() { 27 | info!("VLC pkill success"); 28 | } else { 29 | error!("VLC pkill error"); 30 | } 31 | } 32 | } 33 | 34 | -------------------------------------------------------------------------------- /src/player/vlc/start_vlc.rs: -------------------------------------------------------------------------------- 1 | use std::process::Command; 2 | use std::process::Output; 3 | use crate::db::crud::*; 4 | 5 | pub async fn start_vlc( 6 | current_time: &String, 7 | port: &str, 8 | address: String, 9 | content_url: &String, 10 | token: Option<&String>, 11 | title: String, 12 | subtitle: String, 13 | author: String, 14 | server_address: String, 15 | program: String, 16 | username: String, 17 | ) -> Output { 18 | 19 | let speed_rate = get_speed_rate(username.as_str()); 20 | 21 | let output: Output = Command::new(format!("{}", program)) 22 | .arg("-I") // for macos 23 | .arg("dummy") // for macos 24 | .arg(format!("--start-time={}", current_time)) 25 | .arg("--extraintf") 26 | .arg("rc") 27 | .arg("--rc-host") 28 | .arg(format!("{}:{}",address, port)) 29 | .arg(format!("{}{}?token={}", server_address, content_url, token.unwrap())) 30 | .arg("--rate") 31 | .arg(speed_rate) 32 | .arg("--meta-description") 33 | .arg(author) 34 | .arg("--meta-title") 35 | .arg(subtitle) 36 | .arg("--meta-artist") 37 | .arg(title) 38 | .output() 39 | .expect("Failed to execute program"); 40 | 41 | output 42 | } 43 | 44 | -------------------------------------------------------------------------------- /src/ui/login_tui.rs: -------------------------------------------------------------------------------- 1 | use crate::login_app::AppLogin; 2 | use crate::login_app::AppViewLogin; 3 | use ratatui::{ 4 | buffer::Buffer, 5 | layout::Rect, 6 | widgets::Widget, 7 | }; 8 | 9 | 10 | /// init widget for selected AppView 11 | impl Widget for &mut AppLogin { 12 | fn render(self, area: Rect, buf: &mut Buffer) { 13 | match self.view_state { 14 | AppViewLogin::Auth => self.render_auth(area, buf), 15 | } 16 | } 17 | } 18 | 19 | 20 | /// Rendering logic 21 | 22 | impl AppLogin { 23 | 24 | fn render_auth(&mut self, _area: Rect, _buf: &mut Buffer) { 25 | 26 | let _ = self.auth(); 27 | 28 | 29 | } 30 | 31 | } 32 | 33 | -------------------------------------------------------------------------------- /src/ui/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod tui; 2 | pub mod login_tui; 3 | pub mod player_tui; 4 | 5 | -------------------------------------------------------------------------------- /src/ui/player_tui.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | layout::Rect, 3 | style::{Color, Style}, 4 | widgets::{Block, Paragraph, Widget}, 5 | }; 6 | use crate::db::crud::*; 7 | 8 | 9 | pub fn render_player(area: Rect, buf: &mut ratatui::buffer::Buffer, player_info: Vec, bg_color: Vec, username: &str) { 10 | let block_width = area.width; 11 | let new_y = area.y + area.height.saturating_sub(9); // the line number where player start 12 | let block_height = 4; // number of line of the player (in lines) 13 | 14 | // Create the background block with background color 15 | let bg_color_player = Color::Rgb(bg_color[0], bg_color[1], bg_color[2]); 16 | let block_area = Rect::new(area.x, new_y, block_width, block_height); 17 | let block = Block::default() 18 | .style(Style::default().bg(bg_color_player)); 19 | 20 | // Text area 21 | let text_area_width = block_width - 6; 22 | let text_area_x = (area.width.saturating_sub(text_area_width)) / 2; // Center the text 23 | let text_area = Rect::new(text_area_x, new_y, text_area_width, block_height); 24 | 25 | 26 | let mut key_bindings = "".to_string(); 27 | let is_show_key_bindings = get_is_show_key_bindings(username); 28 | if is_show_key_bindings == "1" { 29 | key_bindings = format!("Spc: pause/play | p/u: +/−10s | P/U: nxt/prev ch. | O/I: spd +/− | o/i: vol +/− | Y: quit"); 30 | } 31 | 32 | // Create the paragraph 33 | let paragraph = Paragraph::new(format!( 34 | "\n{} by {} | {} \n {} {} / {} | Elapsed: {} | Left: {} ({}%) | Speed: {}x\n{}", 35 | player_info[0], // Title 36 | player_info[1], // Author 37 | player_info[2], // Chapter 38 | match player_info[3].as_str() { 39 | "false" => "⏸".to_string(), 40 | "true" => "▶".to_string(), 41 | _ => "".to_string(), 42 | 43 | }, 44 | player_info[4], // Current time 45 | player_info[5], // Total duration 46 | player_info[6], // Elapsed time 47 | player_info[7], // Remaining time 48 | player_info[8], // Percent progress 49 | player_info[9], // Speed rate 50 | key_bindings 51 | )) 52 | .centered() 53 | .block(Block::default()); 54 | 55 | // Render the paragraph and background block 56 | paragraph.render(text_area, buf); 57 | block.render(block_area, buf); 58 | } 59 | 60 | -------------------------------------------------------------------------------- /src/utils/changelog.rs: -------------------------------------------------------------------------------- 1 | const VERSION: &str = env!("CARGO_PKG_VERSION"); 2 | 3 | pub fn changelog() -> String { 4 | let mut changelog = String::new(); 5 | 6 | let changelog_01 = format!( 7 | "Changelog Toutui v0.1.0-beta (02/21/2025) \n\ 8 | Fixed:\n\ 9 | \n\ 10 | First release. 11 | \n\ 12 | Changed:\n\ 13 | \n\ 14 | First release. 15 | \n\ 16 | Enjoy!\n 17 | ####\n" 18 | ); 19 | let changelog_02 = format!( 20 | "Changelog Toutui v0.1.1-beta (02/24/2025) \n\ 21 | Fixed:\n\ 22 | \n\ 23 | - App crash (out of bounds) when API send empty values. 24 | - Close listening session not always working (bug_id: fixed_dd9a64) 25 | \n\ 26 | Changed:\n\ 27 | \n\ 28 | No change. 29 | \n\ 30 | Enjoy and be toutui!\n 31 | ####\n", 32 | ); 33 | let changelog_03 = format!( 34 | "Changelog Toutui v0.1.2-beta (02/24/2025) \n\ 35 | Fixed:\n\ 36 | \n\ 37 | - Partially fixed, becsause not optimal: bug_id: 9bacac Sync: If you open VLC to listen X, close VLC and quickly open VLC again to listen Y: X will still be sync — according to Y (normally, only Y has to be sync in this case). 38 | 39 | \n\ 40 | Changed:\n\ 41 | \n\ 42 | No change. 43 | \n\ 44 | Enjoy and be toutui!\n 45 | ####\n", 46 | ); 47 | let changelog_04 = format!( 48 | "Changelog Toutui v0.1.3-beta (02/03/2025) \n\ 49 | Fixed:\n\ 50 | \n\ 51 | - Fix bug_id: 3f729c Loading time not optimized for library with a lot of items (long start loading and refresh time) 52 | \n\ 53 | Changed:\n\ 54 | \n\ 55 | - Script `hello_toutui` to make installation easier. 56 | \n\ 57 | Contributors:\n\ 58 | \n\ 59 | - dougy147, dhonus 60 | \n\ 61 | Enjoy and be toutui!\n 62 | ####\n", 63 | ); 64 | let changelog_05 = format!( 65 | "Changelog Toutui v0.2.0-beta (07/03/2025) \n\ 66 | CAUTION: This version is not compatible with the previous one. 67 | You need to remove the database in ~/.config/toutui before proceeding. 68 | Fixed:\n\ 69 | \n\ 70 | - From known_bugs.md, fixed: 71 | 72 | Find a robust solution for bug_id: 9bacac 73 | Fix bug_id: 86384e 74 | Fix bug_id: 6ac5d8 75 | Fix bug_id: 06e548 76 | Fix bug_id: e0b61c 77 | Fix bug_id: fc695f 78 | Fix bug_id: 40f48d 79 | Fix bug_id: bf10cd 80 | 81 | \n\ 82 | Changed:\n\ 83 | \n\ 84 | - 85 | \n\ 86 | Contributors:\n\ 87 | \n\ 88 | - AlbanDAVID 89 | \n\ 90 | Enjoy and be toutui!\n 91 | ####\n", 92 | ); 93 | let changelog_06 = format!( 94 | "Changelog Toutui v0.3.0-beta (24/03/2025) \n\ 95 | CAUTION: This version is not compatible with the previous one. 96 | To make it work properly, perform a fresh reinstall. 97 | \n\ 98 | Added:\n\ 99 | - Integrated player. Keep calm and stay in your terminal! :) 100 | \n\ 101 | Fixed:\n\ 102 | \n\ 103 | - Fixed: issue where pressing R twice was required to refresh the app. 104 | - Fixed: issue causing the cursor to disappear when the application is closed. 105 | - Fixed: issue if app is quitted for the first time and that listening session is empty. 106 | \n\ 107 | Changed:\n\ 108 | \n\ 109 | - Faster loading time to play an item. 110 | - Improved synchronization accurary. 111 | - Removed warning during compilation time. 112 | \n\ 113 | Contributors:\n\ 114 | \n\ 115 | - AlbanDAVID, dougy147 116 | \n\ 117 | Enjoy and be toutui!\n 118 | ####\n", 119 | ); 120 | let changelog_07 = format!( 121 | "Changelog Toutui v0.3.1-beta (25/03/2025) \n\ 122 | CAUTION: This version is not compatible with v0.2.0-beta and bellow. 123 | To make it work properly, perform a fresh reinstall. 124 | \n\ 125 | Fixed:\n\ 126 | \n\ 127 | - Fixed: incorrect merge 128 | \n\ 129 | Contributors:\n\ 130 | \n\ 131 | - AlbanDAVID 132 | \n\ 133 | Enjoy and be toutui!\n 134 | ####\n", 135 | ); 136 | let changelog_08 = format!( 137 | "Changelog Toutui v0.3.2-beta (26/03/2025) \n\ 138 | Added:\n\ 139 | \n\ 140 | - macOS compatibility. 141 | \n\ 142 | Fixed:\n\ 143 | \n\ 144 | - Issue with VLC buffer (if a chapter is manually changed or during jump/backward). 145 | - Display issue on small monitors. 146 | \n\ 147 | Changed:\n\ 148 | \n\ 149 | - hello_toutui script improved 150 | \n\ 151 | Contributors:\n\ 152 | \n\ 153 | - AlbanDAVID, dougy147 154 | \n\ 155 | Enjoy and be toutui!\n 156 | ####\n", 157 | ); 158 | let changelog_09 = format!( 159 | "Changelog Toutui v0.3.3-beta (02/04/2025) \n\ 160 | \n\ 161 | Changed:\n\ 162 | \n\ 163 | - Adding a login placeholder to specify the use of http:// or https:// for the server address. 164 | - Display error login message without time limit. 165 | \n\ 166 | Contributors:\n\ 167 | \n\ 168 | - AlbanDAVID 169 | \n\ 170 | Enjoy and be toutui!\n 171 | ####\n", 172 | ); 173 | let changelog_10 = format!( 174 | "Changelog Toutui v0.3.4-beta (23/04/2025) \n\ 175 | \n\ 176 | Fix:\n\ 177 | \n\ 178 | Handle empty podcast episode lists gracefully. Prevent panic and show 'No episodes' message. by @denispol in https://github.com/AlbanDAVID/Toutui/pull/22\n\ 179 | Contributors:\n\ 180 | \n\ 181 | - AlbanDAVID, denispol 182 | \n\ 183 | Enjoy and be toutui!\n 184 | ####\n", 185 | ); 186 | let changelog_11 = format!( 187 | "Changelog Toutui v0.3.5-beta (27/04/2025) \n\ 188 | \n\ 189 | Added:\n\ 190 | - Display number of total items for continue listening, library and library settings (for books and podcasts) 191 | - Clap crate and a function to display the version in the CLI (e.g. `toutui --version`) 192 | \n\ 193 | Fixed:\n\ 194 | \n\ 195 | - [macos] vlc version not displayed in listening sessions (from ABS web browser) 196 | - Out of bounds in Library Settings 197 | \n\ 198 | Contributors:\n\ 199 | \n\ 200 | - AlbanDAVID 201 | \n\ 202 | Enjoy and be toutui!\n 203 | ####\n", 204 | ); 205 | let changelog_12 = format!( 206 | "Changelog Toutui v0.4.0-beta (10/05/2025) \n\ 207 | \n\ 208 | Warning:\n\ 209 | - If you're already using the app, please follow the upgrade instructions here: => 210 | https://github.com/AlbanDAVID/Toutui/wiki/Major-upgrade-instruction#v--035-beta-to-v040-beta 211 | 212 | Added:\n\ 213 | - Simplified installation and updates by: 214 | - Downloading the binary. 215 | - Compiling it from source (no local clone needed). 216 | 217 | - New commands available: 218 | - toutui --update and toutui --uninstall cmd added. 219 | 220 | - Notify if an update is available directly in the app. 221 | 222 | - [Linux only] The app can now be launched via an app launcher. 223 | \n\ 224 | Contributors:\n\ 225 | \n\ 226 | - AlbanDAVID, dougy147 227 | \n\ 228 | Enjoy and be toutui!\n 229 | ####\n", 230 | ); 231 | let changelog_13 = format!( 232 | "Changelog Toutui v0.4.1-beta (14/05/2025) \n\ 233 | \n\ 234 | Warning:\n\ 235 | - If you're already using the app v0.3.5 or bellow, please follow the upgrade instructions here: => 236 | https://github.com/AlbanDAVID/Toutui/wiki/Major-upgrade-instruction#v--035-beta-to-v040-beta 237 | 238 | Added:\n\ 239 | - Archlinux users: the app is now available in the AUR (yay -S toutui) 240 | 241 | Changed:\n\ 242 | - Minor changes in the installation process. 243 | 244 | \n\ 245 | Contributors:\n\ 246 | \n\ 247 | - AlbanDAVID 248 | \n\ 249 | Enjoy and be toutui!\n 250 | ####\n", 251 | ); 252 | let changelog_14 = format!( 253 | "Changelog Toutui v{} (15/05/2025) \n\ 254 | \n\ 255 | Warning:\n\ 256 | - If you're already using the app v0.3.5 or bellow, please follow the upgrade instructions here: => 257 | https://github.com/AlbanDAVID/Toutui/wiki/Major-upgrade-instruction#v--035-beta-to-v040-beta 258 | 259 | Added:\n\ 260 | - Verifying file integrity using SHA-256 before installation via curl script 261 | 262 | Changed:\n\ 263 | - Clarification of update/uninstall instructions 264 | 265 | \n\ 266 | Contributors:\n\ 267 | \n\ 268 | - AlbanDAVID 269 | \n\ 270 | Enjoy and be toutui!\n 271 | ####\n", 272 | VERSION 273 | ); 274 | 275 | 276 | changelog.push_str(&changelog_14); 277 | changelog.push_str(&changelog_13); 278 | changelog.push_str(&changelog_12); 279 | changelog.push_str(&changelog_11); 280 | changelog.push_str(&changelog_10); 281 | changelog.push_str(&changelog_09); 282 | changelog.push_str(&changelog_08); 283 | changelog.push_str(&changelog_07); 284 | changelog.push_str(&changelog_06); 285 | changelog.push_str(&changelog_05); 286 | changelog.push_str(&changelog_04); 287 | changelog.push_str(&changelog_03); 288 | changelog.push_str(&changelog_02); 289 | changelog.push_str(&changelog_01); 290 | 291 | 292 | changelog 293 | } 294 | -------------------------------------------------------------------------------- /src/utils/check_update.rs: -------------------------------------------------------------------------------- 1 | use serde_json::Value; 2 | use reqwest::header::USER_AGENT; 3 | use reqwest::Client; 4 | 5 | const LOCAL_VERSION: &str = env!("CARGO_PKG_VERSION"); 6 | 7 | pub async fn check_update() -> Option { 8 | match get_latest_release_gh().await { 9 | Ok(latest_version_gh) => { 10 | if latest_version_gh != LOCAL_VERSION { 11 | log::warn!( 12 | "You are not up-to-date. Current: {} / Available: {}", 13 | LOCAL_VERSION, 14 | latest_version_gh 15 | ); 16 | Some(format!( 17 | "🔄 Update to v{} available (go to settings > update)", 18 | latest_version_gh 19 | )) 20 | } else { 21 | None 22 | } 23 | } 24 | Err(e) => { 25 | log::error!("{}", e); 26 | None 27 | } 28 | } 29 | } 30 | 31 | pub async fn get_latest_release_gh() -> Result> { 32 | let client = Client::new(); 33 | let response = client 34 | .get("https://api.github.com/repos/AlbanDAVID/Toutui/releases/latest") 35 | .header(USER_AGENT, "Toutui-Updater") 36 | .send() 37 | .await?; 38 | let text = response.text().await?; 39 | 40 | let v: Value = serde_json::from_str(&text)?; 41 | 42 | if let Some(tag_name) = v["tag_name"].as_str() { 43 | Ok(tag_name.trim_start_matches('v').to_string()) 44 | } else { 45 | Err("[get_latest_release_gh] couldn't find last release".into()) 46 | } 47 | } 48 | 49 | -------------------------------------------------------------------------------- /src/utils/clap.rs: -------------------------------------------------------------------------------- 1 | use clap::{Arg, Command}; 2 | 3 | pub fn clap() { 4 | let matches = Command::new("toutui") 5 | .version(env!("CARGO_PKG_VERSION")) 6 | .arg( 7 | Arg::new("update") 8 | .long("update") 9 | .help("Run update script via curl") 10 | .action(clap::ArgAction::SetTrue), 11 | ) 12 | .arg( 13 | Arg::new("uninstall") 14 | .long("uninstall") 15 | .help("Run uninstall script via curl") 16 | .action(clap::ArgAction::SetTrue), 17 | ) 18 | .get_matches(); 19 | 20 | if matches.get_flag("uninstall") { 21 | std::process::Command::new("sh") 22 | .arg("-c") 23 | .arg( 24 | r#"bash -c 'expected_sha256="b5c41bcd3c480fd2ca6ec0031ccecf2cf7cf4ae01f591cad64a320fa7d72331d" export expected_sha256 tmpfile=$(mktemp) && curl -LsSf https://github.com/AlbanDAVID/Toutui/raw/stable/hello_toutui.sh -o "$tmpfile" && bash "$tmpfile" uninstall && rm -f "$tmpfile"'"#, 25 | ) 26 | .status() 27 | .expect("failed to run uninstall script"); 28 | std::process::exit(0); 29 | } 30 | if matches.get_flag("update") { 31 | std::process::Command::new("sh") 32 | .arg("-c") 33 | .arg( 34 | r#"bash -c 'expected_sha256="b5c41bcd3c480fd2ca6ec0031ccecf2cf7cf4ae01f591cad64a320fa7d72331d" export expected_sha256 tmpfile=$(mktemp) && curl -LsSf https://github.com/AlbanDAVID/Toutui/raw/stable/hello_toutui.sh -o "$tmpfile" && bash "$tmpfile" update && rm -f "$tmpfile"'"#, 35 | ) 36 | .status() 37 | .expect("failed to run update script"); 38 | std::process::exit(0); 39 | } 40 | 41 | } 42 | 43 | -------------------------------------------------------------------------------- /src/utils/convert_seconds.rs: -------------------------------------------------------------------------------- 1 | pub fn convert_seconds(vec_seconds: Vec) -> Vec { 2 | vec_seconds.iter() 3 | .map(|&s| { 4 | let total_minutes = (s / 60.0).round() as i64; 5 | let hours = total_minutes / 60; 6 | let minutes = total_minutes % 60; 7 | 8 | if hours == 0 { 9 | format!("{}m", minutes) 10 | } else if minutes == 0 { 11 | format!("{}h", hours) 12 | } else { 13 | format!("{}h{}m", hours, minutes) 14 | } 15 | }) 16 | .collect() 17 | } 18 | 19 | 20 | pub fn convert_seconds_for_prg(duration: f64, current_time: f64) -> String { 21 | let time_left_s = duration - current_time; 22 | let total_minutes = (time_left_s / 60.0).round() as i64; 23 | let hours = total_minutes / 60; 24 | let minutes = total_minutes % 60; 25 | 26 | if current_time == 0.0 { 27 | format!("") 28 | } 29 | else if hours == 0 { 30 | format!("{}m left,", minutes) 31 | } else if minutes == 0 { 32 | format!("{}h left,", hours) 33 | } else { 34 | format!("{}h{}m left,", hours, minutes) 35 | } 36 | } 37 | 38 | -------------------------------------------------------------------------------- /src/utils/encrypt_token.rs: -------------------------------------------------------------------------------- 1 | use magic_crypt::{new_magic_crypt, MagicCryptTrait}; 2 | use std::env; 3 | use log::error; 4 | 5 | pub fn encrypt_token(token_to_encrypt: &str) -> Result { 6 | 7 | 8 | // Load .env variables (`env::var` will read ~.config/toutui/.env) 9 | // check `main.rs` to see the init process for dotenv 10 | // Retrieve secret key from .env 11 | let _secret_key = match env::var("TOUTUI_SECRET_KEY") { 12 | Ok(key) => { 13 | 14 | // Create magic crypt object 15 | let mc = new_magic_crypt!(key, 256); 16 | 17 | // Token encryption 18 | let encrypted_token = mc.encrypt_str_to_base64(token_to_encrypt); 19 | 20 | return Ok(encrypted_token) 21 | } 22 | Err(_) => { 23 | error!("No secret found in .env. Do this:\n 24 | mkdir -p ~/.config/toutui\n 25 | echo 'TOUTUI_SECRET_KEY=secret' >> ~/.config/toutui/.env"); 26 | return Err("No secret found in .env. Do this:\n 27 | mkdir -p ~/.config/toutui\n 28 | echo 'TOUTUI_SECRET_KEY=secret' >> ~/.config/toutui/.env".to_string()); 29 | }, 30 | }; 31 | } 32 | 33 | 34 | pub fn decrypt_token(encrypted_token: &str) -> Result { 35 | // Load .env variables (`env::var` will read ~.config/toutui/.env) 36 | // check `main.rs` to see the init process for dotenv 37 | // Retrieve secret key from .env 38 | let secret_key = match env::var("TOUTUI_SECRET_KEY") { 39 | Ok(key) => { 40 | // Create magic crypt object 41 | let mc = new_magic_crypt!(key, 256); 42 | 43 | // Token decryption 44 | match mc.decrypt_base64_to_string(encrypted_token) { 45 | Ok(decrypted_token) => Ok(decrypted_token), 46 | Err(_) => { 47 | error!("Failed to decrypt the token."); 48 | Err("Failed to decrypt the token.".to_string()) 49 | } 50 | } 51 | } 52 | Err(_) => { 53 | error!("No secret found in .env. Do this:\n 54 | mkdir -p ~/.config/toutui\n 55 | echo 'TOUTUI_SECRET_KEY=secret' >> ~/.config/toutui/.env"); 56 | Err("No secret found in .env. Do this:\n 57 | mkdir -p ~/.config/toutui\n 58 | echo 'TOUTUI_SECRET_KEY=secret' >> ~/.config/toutui/.env".to_string()) 59 | }, 60 | }; 61 | 62 | secret_key 63 | } 64 | -------------------------------------------------------------------------------- /src/utils/exit_app.rs: -------------------------------------------------------------------------------- 1 | use crossterm::terminal::{disable_raw_mode, LeaveAlternateScreen}; 2 | use std::io::{self, Write}; 3 | use std::process; 4 | use crossterm::cursor::Show; 5 | 6 | // exit the app 7 | pub fn clean_exit() { 8 | let _ = disable_raw_mode(); 9 | let mut stdout = io::stdout(); 10 | let _ = crossterm::execute!(stdout, Show, LeaveAlternateScreen); 11 | let _ = stdout.flush(); 12 | process::exit(0); 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/logs.rs: -------------------------------------------------------------------------------- 1 | use log::LevelFilter; 2 | use fern::Dispatch; 3 | use chrono::Local; 4 | use std::fs::OpenOptions; 5 | use std::env; 6 | use std::path::PathBuf; 7 | 8 | pub fn setup_logs() -> Result<(), fern::InitError> { 9 | 10 | let config_home_path = env::var("XDG_CONFIG_HOME") 11 | .map(PathBuf::from) 12 | .unwrap_or_else(|_| { 13 | let mut path = dirs::home_dir().expect("Unable to find the user's home directory"); 14 | 15 | if cfg!(target_os = "macos") { 16 | path.push("Library/Preferences"); 17 | } else { 18 | path.push(".config"); 19 | } 20 | 21 | path 22 | }); 23 | 24 | let log_path = config_home_path.join("toutui/toutui.log"); 25 | 26 | // Create or append into the file 27 | let log_file = OpenOptions::new() 28 | .create(true) 29 | .write(true) 30 | .append(true) 31 | .open(log_path) // path and name 32 | .unwrap(); 33 | 34 | Dispatch::new() 35 | .format(|out, message, record| { 36 | out.finish(format_args!( 37 | "{} [{}] - {}", 38 | Local::now().format("%Y-%m-%d %H:%M:%S%.3f"), 39 | record.level(), 40 | message 41 | )) 42 | }) 43 | .level(LevelFilter::Info) 44 | .chain(log_file) // redirect logs to the file 45 | .apply()?; 46 | 47 | Ok(()) 48 | } 49 | -------------------------------------------------------------------------------- /src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod convert_seconds; 2 | pub mod pop_up_message; 3 | pub mod logs; 4 | pub mod changelog; 5 | pub mod encrypt_token; 6 | pub mod exit_app; 7 | pub mod vlc_tcp_stream; 8 | pub mod clap; 9 | pub mod check_update; 10 | -------------------------------------------------------------------------------- /src/utils/pop_up_message.rs: -------------------------------------------------------------------------------- 1 | use std::io::{Result, Stdout}; 2 | use crossterm::{ 3 | execute, 4 | style::{Color, SetBackgroundColor}, 5 | terminal, cursor, 6 | }; 7 | use crate::config::*; 8 | 9 | // pop up message 10 | pub fn pop_message(stdout: &mut Stdout, lines_from_bottom: u16, message: &str) -> Result<()> { 11 | // import backgorund color 12 | let mut color = Vec::new(); 13 | if let Ok(cfg) = load_config() { 14 | color = cfg.colors.background_color; 15 | } 16 | 17 | let (_cols, rows) = terminal::size()?; 18 | let target_row = rows.saturating_sub(lines_from_bottom); 19 | let bg_color = Color::Rgb { r: color[0], g: color[1], b: color[2] }; 20 | 21 | execute!( 22 | stdout, 23 | cursor::MoveTo(0, target_row), 24 | SetBackgroundColor(bg_color), 25 | 26 | )?; 27 | 28 | println!("{}", message); 29 | 30 | Ok(()) 31 | } 32 | 33 | 34 | 35 | // to clear a pop up message 36 | pub fn clear_message(stdout: &mut Stdout, lines_from_bottom: u16) -> Result<()> { 37 | // import backgorund color 38 | let mut color = Vec::new(); 39 | if let Ok(cfg) = load_config() { 40 | color = cfg.colors.background_color; 41 | } 42 | let (_cols, rows) = terminal::size()?; 43 | let target_row = rows.saturating_sub(lines_from_bottom); 44 | let bg_color = Color::Rgb { r: color[0], g: color[1], b: color[2] }; 45 | 46 | 47 | execute!( 48 | stdout, 49 | cursor::MoveTo(0, target_row), 50 | SetBackgroundColor(bg_color), 51 | terminal::Clear(terminal::ClearType::CurrentLine), 52 | )?; 53 | 54 | Ok(()) 55 | } 56 | 57 | -------------------------------------------------------------------------------- /src/utils/vlc_tcp_stream.rs: -------------------------------------------------------------------------------- 1 | use std::io::{self, Write, BufRead, BufReader}; 2 | use std::net::TcpStream; 3 | 4 | pub fn vlc_tcp_stream(address: &str, port: &str, cmd: &str) -> io::Result { 5 | let mut stream = TcpStream::connect(format!("{}:{}", address, port))?; 6 | 7 | // Send command 8 | writeln!(stream, "{}", cmd)?; 9 | 10 | // Read response 11 | let reader = BufReader::new(stream); 12 | // we need to select third line (the one where the repsonse is displayed) 13 | let response = reader 14 | .lines() 15 | .nth(2) 16 | .unwrap_or(Ok("N/A".to_string()))? 17 | .replace("> ", "") // Remove " > " 18 | .trim() // Trim any extra spaces 19 | .to_string(); 20 | 21 | Ok(response) 22 | 23 | } 24 | 25 | 26 | --------------------------------------------------------------------------------