Repository: 13unk0wn/Feather
Branch: main
Commit: 1f1d7be2b146
Files: 19
Total size: 69.2 KB
Directory structure:
gitextract__by_cgd2/
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── feather/
│ ├── .gitignore
│ ├── Cargo.toml
│ ├── build.rs
│ ├── src/
│ │ ├── database.rs
│ │ ├── lib.rs
│ │ ├── player.rs
│ │ └── yt.rs
│ └── structure.txt
└── feather_frontend/
├── .gitignore
├── Cargo.toml
└── src/
├── backend.rs
├── history.rs
├── lib.rs
├── main.rs
├── player.rs
└── search.rs
================================================
FILE CONTENTS
================================================
================================================
FILE: CONTRIBUTING.md
================================================
# Contributing to FEATHER
Thank you for considering contributing to FEATHER! 🚀
We welcome all contributions, whether it's fixing bugs, improving documentation, or adding new features.
## 📌 Contribution Guidelines
### ❗ Important Notice
Feather v0.1 (`main` branch) **is only accepting bug fix pull requests**.
- ✅ **Bug Fixes** → v0.1 (`main`)
- ❌ **New Features, Enhancements, or Keybinding Changes** → v0.2 (`0.2` branch)
### 1. Use the `dev` Branch for Pull Requests Made to the `main` Branch
All contributions (except critical bug fixes for `main`) should be made to the `dev` branch.
Before starting, make sure your local `dev` branch is up to date:
### 2. You Can Directly Commit to the `v0.2` Branch for Additional Features
```bash
git checkout dev
git pull origin dev
```
Always create a new feature branch for your changes:
```bash
git checkout -b feature-branch
```
After making changes, commit and push:
```bash
git add .
git commit -m "Describe your changes"
git push origin feature-branch
```
### 2. Submitting a Pull Request (PR)
1. Go to the [GitHub repository](https://github.com/13unk0wn/Feather).
2. Click **"New Pull Request"**.
3. Ensure you are merging **your branch into the correct branch**:
- Bug fixes → `dev` (v0.1)
- New features, enhancements → `0.2` (v0.2)
4. Provide a clear PR description explaining the changes.
5. Request a review from maintainers.
Once approved, your PR will be merged into `dev` and later into `main` for a stable release.
### 3. Code Style & Best Practices
- ✅ Follow the existing code style and formatting.
- ✅ Write meaningful commit messages.
- ✅ Keep PRs small and focused on a single feature/fix.
- ✅ Test your code before submitting.
### 4. Issues & Discussions
- **Bug Reports:** If you find a bug, check if an issue already exists. Otherwise, create a new issue.
- **Feature Requests:** If you have an idea, discuss it in an issue before implementing it.
### 5. Need Help?
If you're unsure about anything, feel free to open a discussion or ask in an issue. We're happy to help!
🙌 **Happy Coding!** 🚀
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2025 13unk0wn
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# Feather 🎵
Feather is a lightweight, efficient, and locally hosted YouTube Music TUI built with Rust. It is designed to provide a minimalistic yet powerful music streaming experience directly from YouTube, using `yt-dlp` and `mpv`.
## 🎯 Aim
A lightweight, ad-less player with only essential features.
## ✨ Features
- 🎶 **Stream YouTube Music** without downloading files.
- ⚡ **Minimal Memory Usage**, targeting **60MB - 80MB RAM**.
- 🚀 **Fast Playback**, with loading times around 3 seconds.
- 🖥️ **Terminal User Interface (TUI)** built using Ratatui.
- 🔄 **Self-Update Feature** (planned).
## 🛠️ Installation
### 📌 Prerequisites
Ensure you have the following installed:
- 🦀 **Rust** (latest stable version)
- 📥 **yt-dlp** (for fetching YouTube data)
- 🎵 **mpv** (for playback)
### 🔧 Build from Source
```sh
git clone https://github.com/13unk0wn/Feather.git
cd Feather/feather_frontend
cargo build --release
```
### ▶️ Run Feather
```sh
./target/release/feather_frontend
```
## 🎮 Usage
Navigate through the TUI to search and play music. Additional controls and keyboard shortcuts will be documented soon.
### 🛠️ Handling YouTube Restrictions
If a song fails to play due to YouTube restrictions, you can bypass them by adding your cookies to the environment:
```sh
export FEATHER_COOKIES="paste your cookies here"
```
- This is **optional** and should only be used if playback errors occur.
- Feather can play songs without cookies, but adding them may help `mpv` bypass certain restrictions.
## 🌄 Screenshot

## 🛠️ Compatibility
Feather has been tested on **Linux Mint (Debian Edition)**, but all libraries used are compatible with other Linux distributions.
Windows and Macos are not officially supported.
## 🛣️ Roadmap
### 🚀 Current Version: v0.1.0
- 🎶 Implement player
- 🔍 Implement search
- �햐 Implement history
### 🔥 Upcoming: v0.2.0
- ⚡ Improve performance
- 🎨 Improve UI
- 🌜 Add support for playing playlists
- 🎼 Add support for creating user playlists
- ⚙️ Add user configuration support
## 🤝 Contributing
Check out [CONTRIBUTION.md](https://github.com/13unk0wn/Feather/blob/main/CONTRIBUTING.md)
If you have any doubts regarding contribution, feel free to reach out via:
- GitHub Issues
- @x: [13unk0wn](https://x.com/13unk0wn)
- Email: [13unk0wn.proton.me](mailto:13unk0wn@proton.me)
## 🌟 Special Thanks
A big thank you to the maintainers and contributors of:
- [RustyPipe](https://codeberg.org/ThetaDev/rustypipe) — for providing essential tools for YouTube playback.
- [mpv](https://github.com/mpv-player/mpv) — for making a great media player that powers Feather's playback.
- [Ratatui](https://github.com/tui-rs-revival/ratatui) — for enabling the terminal-based UI experience.
- [Sled](https://github.com/spacejam/sled) - database
## 🌟 License
Feather is licensed under the MIT License.
---
### 📝 Notes
This project is still in early development. Expect rapid iterations and improvements. Suggestions and feedback are always appreciated!
================================================
FILE: feather/.gitignore
================================================
/target
rustypipe_cache.json
Cargo.lock
================================================
FILE: feather/Cargo.toml
================================================
[package]
name = "feather"
version = "0.1.0"
edition = "2024"
build = "build.rs"
[dependencies]
rustypipe = "0.9.0"
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
bincode = "1.3.3"
sled = { version = "0.34.7",features = ["compression"] }
thiserror = "1.0"
tempfile = "3.16.0"
libmpv2 = "4.1.0"
dirs = "6.0.0"
[build-dependencies]
pkg-config = "0.3"
[lib]
name = "feather"
path = "src/lib.rs"
================================================
FILE: feather/build.rs
================================================
fn main() {
if cfg!(target_os = "macos") && pkg_config::probe_library("mpv").is_err() {
println!("cargo:warning=Could not find mpv via pkg-config. Make sure it is installed");
}
}
================================================
FILE: feather/src/database.rs
================================================
// This file manages the history database and contains all necessary functions related to history management
use crate::{ArtistName, SongId, SongName};
use serde::{Deserialize, Serialize};
use sled::Db;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use thiserror::Error;
/// Represents a history entry for a song that has been played.
#[derive(Serialize, Deserialize, Debug)]
pub struct HistoryEntry {
pub song_name: SongName, // Name of the song
pub song_id: SongId, // Unique identifier for the song
pub artist_name: Vec<ArtistName>, // List of artists associated with the song
time_stamp: u64, // Timestamp when the song was played
}
impl HistoryEntry {
/// Creates a new history entry with the current timestamp.
pub fn new(
song_name: SongName,
song_id: SongId,
artist_name: Vec<ArtistName>,
) -> Result<Self, Box<dyn std::error::Error>> {
let time_stamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
Ok(Self {
song_name,
song_id,
artist_name,
time_stamp,
})
}
}
/// Database handler for managing song history.
pub struct HistoryDB {
db: Db, // Sled database instance
}
/// Represents possible errors that can occur in history operations.
#[derive(Error, Debug)]
pub enum HistoryError {
#[error("Database error: {0}")]
DbError(#[from] sled::Error), // Errors related to the sled database
#[error("Serialization error: {0}")]
SerializationError(#[from] bincode::Error), // Errors during serialization/deserialization
#[error("Basic error: {0}")]
Error(Box<dyn std::error::Error>), // Generic error wrapper
}
impl HistoryDB {
pub fn new() -> Result<Self, sled::Error> {
let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from("/tmp"));
path.push("Feather/history_db");
let db = sled::Config::new()
.path(path)
.cache_capacity(256 * 1024)
.use_compression(true)
.open()?;
Ok(HistoryDB { db })
}
/// Adds a new entry to the history database.
/// Limits the total stored entries to 50.
pub fn add_entry(&self, entry: &HistoryEntry) -> Result<(), HistoryError> {
let key = entry.song_id.as_bytes();
let value = bincode::serialize(entry)?;
self.db.insert(key, value)?;
self.limit_history_size(50)?;
Ok(())
}
/// Ensures the history database does not exceed `max_size` entries.
/// Removes the oldest entries if necessary.
pub fn limit_history_size(&self, max_size: usize) -> Result<(), HistoryError> {
while self.db.len() > max_size {
if let Some((key, _)) = self.db.first()? {
self.db.remove(key)?;
}
}
Ok(())
}
/// Retrieves up to 50 history entries, sorted by most recent first.
pub fn get_history(&self) -> Result<Vec<HistoryEntry>, HistoryError> {
let mut history = Vec::with_capacity(self.db.len().min(50)); // Pre-allocate vector
for item in self.db.iter().take(50) {
let (_, value) = item?;
if let Ok(entry) = bincode::deserialize::<HistoryEntry>(&value) {
history.push(entry);
}
}
history.sort_unstable_by(|e1, e2| e2.time_stamp.cmp(&e1.time_stamp)); // Sort by timestamp descending
Ok(history)
}
/// Deletes a specific history entry by song ID.
pub fn delete_entry(&self, song_id: &str) -> Result<(), HistoryError> {
self.db.remove(song_id.as_bytes())?; // Convert song ID to bytes
Ok(())
}
/// Clears all history entries from the database.
pub fn clear_history(&self) -> Result<(), HistoryError> {
self.db.clear()?;
Ok(())
}
/// Retrieves the most recently played song's ID, if available.
pub fn get_last_played_song(&self) -> Result<Option<SongId>, HistoryError> {
if let Some((_, last_entry)) = self.db.last()? {
let entry: HistoryEntry = bincode::deserialize(&last_entry)?;
Ok(Some(entry.song_id))
} else {
Ok(None)
}
}
}
// Unchanged UserPlaylist and PlaylistManager sections...
// #[derive(Serialize, Deserialize, Debug, Clone)]
// struct UserPlaylist {
// playlist_name: PlaylistName,
// songs: Vec<Song>,
// }
// #[derive(Error, Debug)]
// pub enum PlaylistManagerError {
// #[error("Database error: {0}")]
// DbError(#[from] sled::Error),
// #[error("Serialization error: {0}")]
// SerializationError(#[from] bincode::Error),
// #[error("Playlist '{0}' not found")]
// PlaylistNotFound(String),
// #[error("Song '{0}' not found in playlist '{1}'")]
// SongNotFound(String, String),
// #[error("Duplicate playlist name: '{0}'")]
// DuplicatePlaylist(String),
// #[error("Failed to add song '{0}' to playlist '{1}'")]
// AddSongError(String, String),
// #[error("Failed to remove song '{0}' from playlist '{1}'")]
// RemoveSongError(String, String),
// #[error("Unknown error: {0}")]
// Other(String),
// }
// #[derive(Serialize, Deserialize, Debug, Clone)]
// struct Song {
// song_name: SongName,
// song_id: SongId,
// artist: Vec<ArtistName>,
// }
// struct PlaylistManager {
// db: sled::Db,
// }
// impl PlaylistManager {
// pub fn new(path: &str) -> Result<Self, PlaylistManagerError> {
// let db = sled::open(path)?;
// Ok(Self { db })
// }
// fn create_playlist(&self, name: &str) -> Result<(), PlaylistManagerError> {
// if self.db.get(name)?.is_some() {
// return Err(PlaylistManagerError::DuplicatePlaylist(name.to_string()));
// }
// let playlist = UserPlaylist {
// playlist_name: name.to_string(),
// songs: Vec::new(),
// };
// let value = bincode::serialize(&playlist)?;
// self.db.insert(name, value)?;
// self.db.flush()?;
// Ok(())
// }
// fn add_song_to_playlist(
// &self,
// playlist_name: &str,
// song: Song,
// ) -> Result<(), PlaylistManagerError> {
// let raw_data = self
// .db
// .get(playlist_name)?
// .ok_or_else(|| PlaylistManagerError::Other("Error: In Opening Playlist".to_string()))?
// .to_vec();
// let mut playlist: UserPlaylist = bincode::deserialize(&raw_data)?;
// playlist.songs.retain(|s| s.song_id != song.song_id);
// playlist.songs.push(song);
// let serialized_data = bincode::serialize(&playlist)?;
// self.db.insert(playlist_name, serialized_data)?;
// self.db.flush()?;
// Ok(())
// }
// fn remove_song_from_playlist(
// &self,
// playlist_name: &str,
// song_id: &str,
// ) -> Result<(), PlaylistManagerError> {
// let raw_data = self
// .db
// .get(playlist_name)?
// .ok_or_else(|| PlaylistManagerError::Other("Error: In Opening Playlist".to_string()))?
// .to_vec();
// let mut playlist: UserPlaylist = bincode::deserialize(&raw_data)?;
// playlist.songs.retain(|s| s.song_id != song_id);
// let serialized_data = bincode::serialize(&playlist)?;
// self.db.insert(playlist_name, serialized_data)?;
// self.db.flush()?;
// Ok(())
// }
// fn get_playlist(&self, playlist_name: &str) -> Result<UserPlaylist, PlaylistManagerError> {
// let data = self
// .db
// .get(playlist_name)?
// .ok_or_else(|| PlaylistManagerError::PlaylistNotFound(playlist_name.to_string()))?
// .to_vec();
// let playlist: UserPlaylist = bincode::deserialize(&data)?;
// Ok(playlist)
// }
// fn delete_playlist(&self, playlist_name: &str) -> Result<(), PlaylistManagerError> {
// self.db
// .remove(&playlist_name)?
// .ok_or_else(|| PlaylistManagerError::PlaylistNotFound(playlist_name.to_string()));
// self.db.flush()?;
// Ok(())
// }
// }
// // Tests unchanged...
// #[cfg(test)]
// mod tests {
// use super::*;
// use tempfile::tempdir;
// fn sample_song(name: &str, id: &str) -> Song {
// Song {
// song_name: name.to_string(),
// song_id: id.to_string(),
// artist: vec!["Artist One".to_string(), "Artist Two".to_string()],
// }
// }
// #[test]
// fn test_playlist_manager() {
// let temp_dir = tempdir().unwrap();
// let db_path = temp_dir.path().to_str().unwrap();
// let manager = PlaylistManager::new(db_path).unwrap();
// let playlist_name = "MyPlaylist";
// assert!(manager.create_playlist(playlist_name).is_ok());
// let song1 = sample_song("Song A", "123");
// let song2 = sample_song("Song B", "456");
// assert!(manager
// .add_song_to_playlist(playlist_name, song1.clone())
// .is_ok());
// assert!(manager
// .add_song_to_playlist(playlist_name, song2.clone())
// .is_ok());
// let playlist = manager.get_playlist(playlist_name).unwrap();
// assert_eq!(playlist.songs.len(), 2);
// assert!(playlist.songs.iter().any(|s| s.song_id == "123"));
// assert!(playlist.songs.iter().any(|s| s.song_id == "456"));
// assert!(manager
// .remove_song_from_playlist(playlist_name, "123")
// .is_ok());
// let playlist = manager.get_playlist(playlist_name).unwrap();
// assert_eq!(playlist.songs.len(), 1);
// assert!(playlist.songs.iter().all(|s| s.song_id != "123"));
// assert!(manager.delete_playlist(playlist_name).is_ok());
// let result = manager.get_playlist(playlist_name);
// assert!(matches!(
// result,
// Err(PlaylistManagerError::PlaylistNotFound(_))
// ));
// }
// }
================================================
FILE: feather/src/lib.rs
================================================
pub mod database;
pub mod player;
pub mod yt;
/// Input/Return Types
pub type ArtistName = String;
pub type SongName = String;
pub type SongId = String;
pub type SongUrl = String;
pub type PlaylistName = String;
pub type PlaylistId = String;
pub type ChannelName = String;
================================================
FILE: feather/src/player.rs
================================================
use libmpv2::Mpv; // We are not using libmpv library because it was requiring user to install an old version which was not available in many distros so we decided to opt for libmpv2 which is a fork of it
use std::sync::Arc;
/// The `Player` struct represents a media player using the MPV library.
/// It provides functionalities to control playback, retrieve metadata,
/// and manage audio optimizations.
pub struct Player {
/// An instance of the MPV player wrapped in an `Arc` for thread safety.
pub player: Arc<Mpv>,
}
/// Enum representing possible errors when interacting with the MPV player.
#[derive(Debug, thiserror::Error)]
pub enum MpvError {
#[error("Mpv error: {0}")]
Mpv(#[from] libmpv2::Error),
#[error("Failed to initialize MPV")]
InitializationError,
#[error("Command execution failed: {0}")]
CommandError(String),
#[error("Failed to load file: {0}")]
LoadFileError(String),
#[error("Property retrieval failed: {0}")]
PropertyError(String),
#[error("Unknown error: {0}")]
Other(String),
}
impl Player {
/// Creates a new `Player` instance and configures MPV settings for optimized audio playback.
pub fn new(cookies: Option<String>) -> Result<Self, MpvError> {
let mpv = Mpv::new()?;
if cookies.is_some() {
// setting cookies if given by user
mpv.set_property("cookies-file", cookies.unwrap())?;
}
// Disable video to save memory
mpv.set_property("video", "no")?;
// Optimize caching for lower memory usage
//mpv.set_property("cache-secs", 2)?; // Reduced to 2 seconds
// mpv.set_property("demuxer-readahead-secs", 1)?; // Reduced to 1 second
//mpv.set_property("demuxer-max-bytes", 512 * 1024)?; // 512 KB max buffer
// Configure network request headers for YouTube playback
mpv.set_property("ytdl-raw-options", "no-check-certificate=")?;
mpv.set_property("loop", "inf")?; // Looping enabled (to be removed with autoplay)
mpv.set_property(
"http-header-fields",
"User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
)?;
// Audio optimization
mpv.set_property("audio-buffer", 0.1)?; // 100ms audio buffer
mpv.set_property("audio-channels", "stereo")?; // Force stereo audio
let mpv = Arc::new(mpv);
Ok(Self { player: mpv })
}
/// Loads and plays a media file from a given URL.
pub fn play(&self, url: &str) -> Result<(), MpvError> {
if let Ok(true) = self.player.get_property("pause") {
self.unpause()?;
} // Quick fix will improve
self.player.command("loadfile", &[url])?; // Replace the current playback
Ok(())
}
/// Pauses playback.
pub fn pause(&self) -> Result<(), MpvError> {
self.player.command("set", &["pause", "yes"])?;
Ok(())
}
/// Resumes playback.
pub fn unpause(&self) -> Result<(), MpvError> {
self.player.command("set", &["pause", "no"])?;
Ok(())
}
/// Toggles between play and pause states.
pub fn play_pause(&self) -> Result<(), MpvError> {
match self.player.get_property::<bool>("pause") {
Ok(true) => self.unpause()?,
Ok(false) => self.pause()?,
Err(_) => todo!(),
}
Ok(())
}
/// Seeks forward by 5 seconds in the current track.
pub fn seek_forward(&self) -> Result<(), MpvError> {
self.player.command("seek", &["5", "relative"])?;
Ok(())
}
/// Seeks backward by 5 seconds in the current track.
pub fn seek_backword(&self) -> Result<(), MpvError> {
self.player.command("seek", &["-5", "relative"])?;
Ok(())
}
/// Retrieves the current playback time as a string.
pub fn get_current_time(&self) -> String {
self.player
.get_property("time-pos")
.unwrap_or(0.0)
.to_string()
}
/// Retrieves the duration of the currently playing media.
pub fn duration(&self) -> String {
self.player
.get_property("duration")
.unwrap_or(0.0)
.to_string()
}
/// Returns whether a media file is currently playing.
pub fn is_playing(&self) -> Result<bool, MpvError> {
let pause: bool = self.player.get_property("pause")?;
Ok(!pause)
}
}
================================================
FILE: feather/src/yt.rs
================================================
use crate::{ArtistName, ChannelName, PlaylistId, PlaylistName, SongId, SongName, SongUrl};
use std::path::PathBuf;
use rustypipe::{
client::{RustyPipe, RustyPipeQuery},
model::MusicItem,
param::StreamFilter,
};
use std::collections::HashMap;
/// A client for interacting with YouTube music using RustyPipe.
pub struct YoutubeClient {
client: RustyPipeQuery,
}
impl YoutubeClient {
/// Creates a new instance of `YoutubeClient`.
pub fn new() -> Self {
let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from("/tmp"));
path.push("Feather");
let rp = RustyPipe::builder().storage_dir(path).build().unwrap();
let client = rp.query();
YoutubeClient { client }
}
/// Searches for music based on the given query.
/// Returns a vector of tuples where each entry contains a song name and ID,
/// along with a list of associated artist names.
pub async fn search(
&self,
query: &str,
) -> Result<Vec<((SongName, SongId), Vec<ArtistName>)>, String> {
match self.client.music_search_main(query).await {
Ok(results) => {
let mut search_result = vec![];
for item in results.items.items {
if let MusicItem::Track(data) = item {
let song_id_pair = (data.name, data.id);
let artist_names: Vec<String> =
data.artists.into_iter().map(|id| id.name).collect();
search_result.push((song_id_pair, artist_names));
}
}
Ok(search_result)
}
Err(_) => Err("Error in Search Result".to_string()),
}
}
/// Fetches the audio stream URL for a given song ID.
pub async fn fetch_song_url(&self, id: &SongId) -> Result<SongUrl, String> {
match self.client.player(&id).await {
Ok(player) => match player.select_audio_stream(&StreamFilter::default()) {
Some(stream) => return Ok(stream.url.clone()),
None => return Err("Audio Stream not Found".to_string()),
},
Err(_) => return Err("Link cannot be Found".to_string()),
}
}
/// Searches for playlists based on a given query.
/// Returns a hashmap where the key is the playlist name and the value is a tuple
/// containing the playlist ID and a list of associated channel names.
pub async fn fetch_playlist(
&self,
search_query: &str,
) -> Result<HashMap<PlaylistName, (PlaylistId, Vec<ChannelName>)>, String> {
match self.client.music_search_playlists(search_query, true).await {
Ok(playlists) => {
let mut result = HashMap::new();
for playlist in playlists.items.items {
let playlist_id = playlist.id;
let channel_names: Vec<String> = playlist
.channel
.into_iter()
.map(|channel| channel.name)
.collect();
result.insert(playlist.name, (playlist_id, channel_names));
}
Ok(result)
}
Err(e) => Err(format!("Error in fetching playlists: {}", e)),
}
}
/// Fetches songs from a given playlist ID.
/// Returns a hashmap where each key is a tuple of (song name, song ID), and
/// the value is a list of associated artist names.
pub async fn fetch_playlist_songs(
&self,
playlist_id: PlaylistId,
) -> Result<HashMap<(SongName, SongId), Vec<ArtistName>>, String> {
match self.client.playlist(playlist_id).await {
Ok(playlist_data) => {
let mut song_map = HashMap::new();
for video in playlist_data.videos.items {
let song_key = (video.name, video.id);
let artist_names: Vec<String> = video
.channel
.into_iter()
.map(|channel| channel.name)
.collect();
song_map.insert(song_key, artist_names);
}
Ok(song_map)
}
Err(e) => Err(format!("Error fetching playlist songs: {}", e)),
}
}
/// Fetches related songs for a given song ID.
/// Returns a hashmap where each key is a tuple of (song name, song ID), and
/// the value is a list of associated artist names.
pub async fn fetch_related_song(
&self,
song_id: SongId,
) -> Result<HashMap<(SongName, SongId), Vec<ArtistName>>, String> {
match self.client.music_related(song_id).await {
Ok(music_list) => {
let tracks = music_list.tracks;
let mut results = HashMap::new();
for track in tracks {
let song_id_name = (track.name, track.id);
let artist_names = track
.artists
.into_iter()
.map(|artist| artist.name)
.collect::<Vec<ArtistName>>();
results.insert(song_id_name, artist_names);
}
Ok(results)
}
Err(_) => Err("Error finding related songs".to_string()),
}
}
}
// #[tokio::test]
// async fn test_search() {
// let client = YoutubeClient::new();
// match client.search("Beanie").await {
// Ok(results) => {
// for ((song, id), artists) in results {
// println!("Song: {}", song);
// println!("Id : {}", id);
// println!("{}", client.fetch_song_url(id.clone()).await.unwrap());
// for artist in artists {
// println!(" - Artist: {}", artist);
// }
// test_fetch_related_song(id).await;
// break;
// }
// }
// Err(e) => println!("Search failed: {}", e),
// }
// }
// #[tokio::test]
// async fn test_fetch_playlist() {
// let client = YoutubeClient::new();
// let query = "lofi beats";
// match client.fetch_playlist(query).await {
// Ok(playlists) => {
// for (playlist_name, (playlist_id, channel_names)) in playlists {
// println!("Playlist: {} (ID: {})", playlist_name, playlist_id);
// test_fetch_playlist_songs(playlist_id).await;
// // for channel in channel_names {
// // println!(" - Channel: {}", channel);
// // }
// break;
// }
// }
// Err(e) => eprintln!("Test failed: {}", e),
// }
// }
// async fn test_fetch_playlist_songs(playlist_id : String) {
// let client = YoutubeClient::new();
// match client.fetch_playlist_songs(playlist_id).await {
// Ok(songs) => {
// for ((song_name, song_id), artist_names) in songs {
// println!("Song: {} (ID: {})", song_name, song_id);
// let url = client.fetch_song_url(&song_id).await.unwrap();
// println!("{url:?}");
// for artist in artist_names {
// println!(" - Artist: {}", artist);
// }
// }
// }
// Err(e) => eprintln!("Test failed: {}", e),
// }
// }
// async fn test_fetch_related_song(song_id : String) {
// let client = YoutubeClient::new();
// match client.fetch_related_song(song_id).await {
// Ok(related_songs) => {
// for ((song_name, song_id), artist_names) in related_songs {
// println!("Related Song: {} (ID: {})", song_name, song_id);
// for artist in artist_names {
// println!(" - Artist: {}", artist);
// }
// }
// }
// Err(e) => eprintln!("Test failed: {}", e),
// }
// }
================================================
FILE: feather/structure.txt
================================================
Feather
│── backend
│ │── yt.rs
│ │ │── search(query: &str) -> Vec<String> # Search YouTube and return video URLs. [done]
│ │ │── fetch_url(video_id: &str) -> String # Get the direct link for mpv to play. [done]
│ │ │── fetch_playlist(playlist_id: &str) -> Vec<String> # Fetch all songs in a playlist. [done]
│ │ │── fetch_related(video_id: &str) -> Vec<String> # Get related songs (for autoplay).[unresolved]
│ │
│ │── mpv.rs
│ │ │── play(url: &str) # Play a song using mpv.
│ │ │── pause() # Pause or resume playback.
│ │ │── stop() # Stop the current playback.
│ │ │── skip(seconds: i64) # Skip forward/backward by seconds.
│ │ │── volume(level: u8) # Adjust the volume level.
│ │ │── next() # Play the next song in the queue.
│ │ │── on_song_end() # Triggered when a song ends (handles autoplay).
│ │
│ │── database.rs
│ │ │── add_to_history(song: &str) # Save song to history. [done]
│ │ │── get_history() -> Vec<String> # Retrieve history list. [done]
│ │ │── delete_from_history(index: usize) # Remove a song from history. [done]
│ │ │── save_playlist(name: &str, songs: Vec<String>) # Create or update a playlist.[done]
│ │ │── load_playlist(name: &str) -> Vec<String> # Load songs from a playlist.[done]
│ │ │── delete_playlist(name: &str) # Delete a playlist.[done]
│ │ │── get_last_played() -> Option<String> # Get the last played song.[done]
│ │ │── add_to_queue(song: &str) # Add song to queue.
│ │ │── remove_from_queue(index: usize) # Remove song from queue.
│ │ │── get_next_song() -> Option<String> # Get next song from queue.
│ │ │── clear_queue() # Clear the queue.
│ │
│ │── config.rs
│ │ │── load_config() -> Config # Load settings from `config.lua`.
│ │ │── save_config(config: &Config) # Save updated settings.
│ │ │── watch_config() # Optional: Reload config if changed.
│ │
│── config.lua # User settings (autoplay, volume, history limit, storage path).
================================================
FILE: feather_frontend/.gitignore
================================================
target/
rustypipe_cache.json
================================================
FILE: feather_frontend/Cargo.toml
================================================
[package]
name = "feather_frontend"
version = "0.1.0"
edition = "2024"
authors = ["13unk0wn 13unk0wn@proton.me"]
description = "A lightweight YouTube Music TUI in Rust."
license = "MIT"
categories = ["command-line-utilities", "multimedia"]
keywords = ["music", "youtube", "tui", "rust"]
[dependencies]
color-eyre = "0.6.3"
crossterm = "0.28.1"
ratatui = "0.29.0"
tui-textarea = "0.7.0"
feather = {path = "../feather"}
tokio = "1.43.0"
tui-scrollview = "0.3"
thiserror ="1.0"
wee_alloc = "0.4"
[profile.release]
opt-level = 3 # Maximum optimization
lto = true # Link Time Optimization
codegen-units = 1 # Optimize for binary size
strip = true # Remove debug symbols
panic = 'abort' # Reduce unwinding overhead
# [replace-with]
# global_allocator = "wee_alloc"
================================================
FILE: feather_frontend/src/backend.rs
================================================
use feather::{
ArtistName, SongId, SongName,
database::{HistoryDB, HistoryEntry},
player::{MpvError, Player},
yt::YoutubeClient,
};
use std::sync::Arc;
use std::sync::Mutex;
use std::time::Duration;
use thiserror::Error;
/// The `Backend` struct manages the YouTube client, music player, and history database.
/// It also tracks the currently playing song.
pub struct Backend {
pub yt: YoutubeClient, // YouTube client for fetching song URLs
pub player: Player, // Music player instance
pub history: Arc<HistoryDB>, // Shared history database
pub song: Mutex<Option<Song>>, // Mutex-protected optional current song
}
/// Represents a song with its name, ID, and artist(s).
#[derive(Clone)]
pub struct Song {
pub song_name: SongName, // Name of the song
pub song_id: SongId, // Unique identifier for the song
artist_name: Vec<ArtistName>, // List of artists performing the song
}
/// Implements conversion from `Song` to `HistoryEntry`, ensuring valid history records.
impl From<Song> for HistoryEntry {
fn from(value: Song) -> Self {
HistoryEntry::new(value.song_name, value.song_id, value.artist_name)
.expect("Cannot Form History Entry")
}
}
impl Song {
/// Creates a new `Song` instance.
pub fn new(song_name: SongName, song_id: SongId, artist_name: Vec<ArtistName>) -> Self {
Self {
song_name,
song_id,
artist_name,
}
}
}
/// Defines possible errors that can occur in the `Backend`.
#[derive(Error, Debug)]
pub enum BackendError {
#[error("Player error: {0}")]
Mpv(#[from] MpvError), // Error related to the music player
#[error("Failed to fetch YouTube URL")]
YoutubeFetch(String), // Error when fetching a song URL from YouTube
#[error("Mutex poisoned: {0}")]
MutexPoisoned(String), // Error when accessing a poisoned mutex
#[error("History database error: {0}")]
HistoryError(String), // Error related to history database operations
#[error("Playback error: {0}")]
PlaybackError(String), // Error related to playback issues
}
impl Backend {
/// Creates a new `Backend` instance.
///
/// # Arguments
/// * `history` - Shared reference to the history database.
/// * `cookies` - Optional cookie string for authentication.
///
/// # Returns
/// * `Result<Self, BackendError>` - Returns `Backend` on success or an error on failure.
pub fn new(history: Arc<HistoryDB>, cookies: Option<String>) -> Result<Self, BackendError> {
Ok(Self {
yt: YoutubeClient::new(),
player: Player::new(cookies).map_err(BackendError::Mpv)?,
history,
song: Mutex::new(None),
})
}
/// Plays a song by fetching its URL from YouTube and passing it to the player.
///
/// # Arguments
/// * `song` - The song to be played.
///
/// # Returns
/// * `Result<(), BackendError>` - Returns `Ok(())` on success or an error on failure.
pub async fn play_music(&self, song: Song) -> Result<(), BackendError> {
const MAX_RETRIES: i32 = 8;
let id = song.song_id.to_string();
// Fetch song URL with retry mechanism
let url = {
let mut attempts = 0;
loop {
match self.yt.fetch_song_url(&id).await {
Ok(url) => break url,
Err(_) if attempts < MAX_RETRIES => {
attempts += 1;
tokio::time::sleep(Duration::from_millis(100)).await;
continue;
}
Err(e) => {
return Err(BackendError::YoutubeFetch(format!(
"Failed to fetch URL after {} attempts: {:?}",
MAX_RETRIES, e
)));
}
}
}
};
// Update the currently playing song in a mutex-protected section
{
let mut current_song = self
.song
.lock()
.map_err(|e| BackendError::MutexPoisoned(e.to_string()))?;
*current_song = Some(song.clone());
}
// Play the song
self.player.play(&url).map_err(BackendError::Mpv)?;
// Add the song to history
self.history
.add_entry(&HistoryEntry::from(song))
.map_err(|e| BackendError::HistoryError(e.to_string()))?;
Ok(())
}
}
================================================
FILE: feather_frontend/src/history.rs
================================================
use crate::backend::{Backend, Song};
use crossterm::event::{KeyCode, KeyEvent};
use feather::database::HistoryDB;
use ratatui::prelude::{Buffer, Color, Constraint, Layout, Rect};
use ratatui::style::Style;
use ratatui::text::Span;
use ratatui::widgets::{
Block, Borders, List, ListItem, ListState, Paragraph, Scrollbar, ScrollbarState,
StatefulWidget, Widget,
};
use std::sync::Arc;
use tokio::sync::mpsc;
// Defines a struct to manage playback history UI
pub struct History {
history: Arc<HistoryDB>, // Database connection for history
selected: usize, // Index of currently selected item
vertical_scroll_state: ScrollbarState, // State for vertical scrollbar
max_len: usize, // Total number of history items
selected_song: Option<Song>, // Currently selected song details
backend: Arc<Backend>, // Audio backend for playback
tx_player: mpsc::Sender<bool>, // Channel to communicate with player
}
impl History {
// Constructor initializing the History struct
pub fn new(
history: Arc<HistoryDB>,
backend: Arc<Backend>,
tx_player: mpsc::Sender<bool>,
) -> Self {
Self {
history,
selected: 0,
vertical_scroll_state: ScrollbarState::default(),
max_len: 0,
selected_song: None,
backend,
tx_player,
}
}
// Handles keyboard input for navigation and actions
pub fn handle_keystrokes(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Char('j') | KeyCode::Down => {
// Move selection down
self.select_next();
}
KeyCode::Char('k') | KeyCode::Up => {
// Move selection up
self.select_previous();
}
KeyCode::Char('d') => {
// Delete selected entry
if let Some(song) = &self.selected_song {
let _ = self.history.delete_entry(&song.song_id);
}
}
KeyCode::Enter => {
// Play selected song
if let Some(song) = self.selected_song.clone() {
let backend = Arc::clone(&self.backend);
let tx_player = self.tx_player.clone();
tokio::spawn(async move {
// Spawn async task for playback
if backend.play_music(song).await.is_ok() {
let _ = tx_player.send(true).await;
}
});
}
}
_ => (), // Ignore other keys
}
}
// Moves selection to next item, respecting bounds
fn select_next(&mut self) {
if self.max_len > 0 {
self.selected = (self.selected + 1).min(self.max_len - 1);
self.vertical_scroll_state = self.vertical_scroll_state.position(self.selected);
}
}
// Moves selection to previous item, preventing underflow
fn select_previous(&mut self) {
self.selected = self.selected.saturating_sub(1);
self.vertical_scroll_state = self.vertical_scroll_state.position(self.selected);
}
// Renders the history UI component
pub fn render(&mut self, area: Rect, buf: &mut Buffer) {
let chunks = Layout::default()
.direction(ratatui::layout::Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)]) // Split layout
.split(area);
// Render title bar
Paragraph::new("History")
.style(Style::default().fg(Color::White))
.block(Block::default().borders(Borders::ALL))
.render(chunks[0], buf);
// Setup history list area with scrollbar
let history_area = chunks[1];
let scrollbar = Scrollbar::new(ratatui::widgets::ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("↑"))
.end_symbol(Some("↓"));
scrollbar.render(history_area, buf, &mut self.vertical_scroll_state);
// Fetch and render history items
if let Ok(items) = self.history.get_history() {
self.max_len = items.len();
self.vertical_scroll_state = self.vertical_scroll_state.content_length(self.max_len);
let view_items: Vec<ListItem> = items
.into_iter()
.enumerate()
.map(|(i, item)| {
// Format each item for display
let is_selected = i == self.selected;
if is_selected {
self.selected_song = Some(Song::new(
item.song_name.clone(),
item.song_id.clone(),
item.artist_name.clone(),
));
}
let style = if is_selected {
// Highlight selected item
Style::default().fg(Color::Yellow).bg(Color::Blue)
} else {
Style::default()
};
let text = format!("{} - {}", item.song_name, item.artist_name.join(", "));
ListItem::new(Span::styled(text, style))
})
.collect();
let mut list_state = ListState::default();
list_state.select(Some(self.selected));
StatefulWidget::render(
// Render the list
List::new(view_items)
.block(Block::default().borders(Borders::ALL))
.highlight_symbol("▶"),
history_area,
buf,
&mut list_state,
);
} else {
// Handle history loading failure
self.max_len = 0;
self.selected = 0;
Paragraph::new("Failed to load history").render(history_area, buf);
}
}
}
================================================
FILE: feather_frontend/src/lib.rs
================================================
pub mod backend;
pub mod history;
pub mod player;
pub mod search;
================================================
FILE: feather_frontend/src/main.rs
================================================
use color_eyre::eyre::Result;
use crossterm::event::{Event, KeyCode, KeyEvent, poll, read};
use feather::database::HistoryDB;
use feather_frontend::{backend::Backend, history::History, player::SongPlayer, search::Search};
use ratatui::{
DefaultTerminal,
buffer::Buffer,
layout::{Constraint, Layout, Rect},
widgets::{Block, Borders, Cell, Paragraph, Row, Table, Widget},
};
use std::{env, sync::Arc};
use tokio::{
sync::mpsc,
time::{Duration, interval},
};
/// Entry point for the async runtime.
#[tokio::main]
async fn main() -> Result<()> {
color_eyre::install().unwrap();
let terminal = ratatui::init();
let _app = App::new().render(terminal).await;
ratatui::restore();
Ok(())
}
/// Enum representing different states of the application.
#[derive(Debug)]
enum State {
HelpMode,
Global,
Search,
History,
// UserPlaylist,
// CurrentPlayingPlaylist,
SongPlayer,
}
/// Main application struct managing the state and UI components.
struct App<'a> {
state: State,
search: Search<'a>,
history: History,
// user_playlist: UserPlaylist,
// current_playling_playlist: CurrentPlayingPlaylist,
top_bar: TopBar,
player: SongPlayer,
// backend: Arc<Backend>,
help_mode: bool,
exit: bool,
}
impl App<'_> {
/// Creates a new instance of the application.
fn new() -> Self {
let history = Arc::new(HistoryDB::new().unwrap());
let get_cookies = env::var("FEATHER_COOKIES").ok(); // Fetch cookies from environment variables if available.
let backend = Arc::new(Backend::new(history.clone(), get_cookies).unwrap());
let (tx, rx) = mpsc::channel(32);
App {
state: State::Global,
search: Search::new(backend.clone(), tx.clone()),
history: History::new(history, backend.clone(), tx.clone()),
// user_playlist: UserPlaylist {},
// current_playling_playlist: CurrentPlayingPlaylist {},
top_bar: TopBar::new(),
player: SongPlayer::new(backend.clone(), rx),
// backend,
help_mode: false,
exit: false,
}
}
/// Handles global keystrokes and state transitions.
fn handle_global_keystrokes(&mut self, key: KeyEvent) {
match self.state {
State::Global => match key.code {
KeyCode::Char('s') => self.state = State::Search,
KeyCode::Char('h') => self.state = State::History,
KeyCode::Char('p') => self.state = State::SongPlayer,
KeyCode::Char('?') => {
self.help_mode = true;
self.state = State::HelpMode;
}
KeyCode::Esc => {
self.exit = true;
}
_ => (),
},
State::Search => match key.code {
KeyCode::Esc => self.state = State::Global,
_ => self.search.handle_keystrokes(key),
},
State::HelpMode => match key.code {
KeyCode::Esc => {
self.state = State::Global;
self.help_mode = false;
}
_ => (),
},
State::History => match key.code {
KeyCode::Esc => self.state = State::Global,
_ => self.history.handle_keystrokes(key),
},
State::SongPlayer => match key.code {
KeyCode::Esc => self.state = State::Global,
_ => self.player.handle_keystrokes(key),
},
}
}
/// Main render loop for updating the UI.
async fn render(mut self, mut terminal: DefaultTerminal) {
let mut redraw_interval = interval(Duration::from_millis(250)); // Redraw every 250ms
while !self.exit {
terminal
.draw(|frame| {
let area = frame.area();
let layout = Layout::default()
.direction(ratatui::layout::Direction::Vertical)
.constraints([
Constraint::Percentage(10),
Constraint::Percentage(75),
Constraint::Percentage(15),
])
.split(area);
let middle_layout = Layout::default()
.direction(ratatui::layout::Direction::Horizontal)
.constraints(vec![Constraint::Percentage(50), Constraint::Percentage(50)])
.split(layout[1]);
if !self.help_mode {
self.top_bar
.render(layout[0], frame.buffer_mut(), &self.state);
self.search.render(middle_layout[0], frame.buffer_mut());
self.history.render(middle_layout[1], frame.buffer_mut());
self.player.render(layout[2], frame.buffer_mut());
} else {
let rows = vec![
Row::new(vec![Cell::from("s"), Cell::from("Search")]),
Row::new(vec![Cell::from("h"), Cell::from("History")]),
Row::new(vec![Cell::from("p"), Cell::from("Player")]),
Row::new(vec![Cell::from("?"), Cell::from("Toggle Help Mode")]),
Row::new(vec![
Cell::from("TAB (Search)"),
Cell::from("Toggle between search input and results"),
]),
Row::new(vec![
Cell::from("Esc (Global)"),
Cell::from("Quit application"),
]),
Row::new(vec![
Cell::from("Esc (Non-Global)"),
Cell::from("Switch to Global Mode"),
]),
Row::new(vec![
Cell::from("↑ / k(History/Search)"),
Cell::from("Navigate up in list"),
]),
Row::new(vec![
Cell::from("↓ / j(History/Search)"),
Cell::from("Navigate down in list"),
]),
Row::new(vec![
Cell::from("Space / ; (Player)"),
Cell::from("Pause current song"),
]),
Row::new(vec![
Cell::from("→ (Player)"),
Cell::from("Skip forward 5 seconds"),
]),
Row::new(vec![
Cell::from("← (Player)"),
Cell::from("Rewind 5 seconds"),
]),
];
let help_table = Table::new(
rows,
[Constraint::Percentage(20), Constraint::Percentage(80)],
)
.block(Block::default().borders(Borders::ALL).title("Help"))
.header(Row::new(vec![Cell::from("Key"), Cell::from("Action")]));
help_table.render(area, frame.buffer_mut());
}
})
.unwrap();
tokio::select! {
_ = redraw_interval.tick() => {}
_ = async {
if poll(Duration::from_millis(100)).unwrap() {
if let Event::Key(key) = read().unwrap() {
self.handle_global_keystrokes(key);
}
}
} => {}
}
}
}
}
/// Represents the top bar UI component.
struct TopBar;
impl TopBar {
fn new() -> Self {
Self
}
fn render(&mut self, area: Rect, buf: &mut Buffer, state: &State) {
let s = format!("Feather | Current Mode : {:?}", state);
Paragraph::new(s)
.block(Block::default().borders(Borders::ALL))
.render(area, buf);
}
}
#[allow(unused)]
/// Placeholder struct for user playlists.
struct UserPlaylist {}
#[allow(unused)]
/// Placeholder struct for currently playing playlist.
struct CurrentPlayingPlaylist {}
================================================
FILE: feather_frontend/src/player.rs
================================================
use crate::backend::{Backend, Song};
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::prelude::{Alignment, Buffer, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Widget};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::task;
#[derive(PartialEq, PartialOrd, Debug)]
enum SongState {
Idle, // No song is playing
Playing, // A song is currently playing
Loading, // Song is loading
ErrorPlayingoSong, // An error occurred while playing the song
}
#[derive(Clone)]
pub struct SongDetails {
song: Song, // Information about the song
current_time: String, // Current playback time (formatted as MM:SS)
total_duration: String, // Total duration of the song
}
pub struct SongPlayer {
backend: Arc<Backend>, // Backend reference for controlling playback
songstate: Arc<Mutex<SongState>>, // Current state of the player (Idle, Playing, etc.)
song_playing: Arc<Mutex<Option<SongDetails>>>, // Details of the currently playing song
rx: mpsc::Receiver<bool>, // Receiver to listen for playback events
}
impl SongPlayer {
pub fn new(backend: Arc<Backend>, rx: mpsc::Receiver<bool>) -> Self {
let player = Self {
backend,
songstate: Arc::new(Mutex::new(SongState::Idle)),
song_playing: Arc::new(Mutex::new(None)),
rx,
};
player.observe_time(); // Start observing playback time
player
}
// Function to continuously update the current playback time
fn observe_time(&self) {
let backend = Arc::clone(&self.backend);
let song_playing = Arc::clone(&self.song_playing);
tokio::task::spawn(async move {
loop {
// Try to get the current playback position from MPV
match backend.player.player.get_property::<f64>("time-pos") {
Ok(time) => {
// Lock the song_playing mutex and update the current playback time
if let Ok(mut song_lock) = song_playing.lock() {
if let Some(song) = song_lock.as_mut() {
song.current_time = format!("{:.0}", time);
}
}
}
Err(_) => (), // Ignore errors (e.g., if MPV is not running)
}
tokio::time::sleep(Duration::from_millis(500)).await; // Update every 500ms
}
});
}
// Handle key presses for playback control
pub fn handle_keystrokes(&mut self, key: KeyEvent) {
if let Ok(state) = self.songstate.lock() {
if *state == SongState::Playing {
match key.code {
KeyCode::Char(' ') | KeyCode::Char(';') => {
// Toggle play/pause
if let Ok(_) = self.backend.player.play_pause() {};
}
KeyCode::Right | KeyCode::Char('l') => {
// Seek forward
self.backend.player.seek_forward().ok();
}
KeyCode::Left | KeyCode::Char('j') => {
// Seek backward
self.backend.player.seek_backword().ok();
}
_ => (),
};
}
}
}
// Function to check whether a song is playing
fn check_playing(&mut self) {
let songstate = Arc::clone(&self.songstate);
let backend = Arc::clone(&self.backend);
let song_playing = Arc::clone(&self.song_playing);
task::spawn(async move {
const MAX_IDLE_COUNT: i32 = 5; // Max checks before considering it an error
let mut idle_count = 0;
// Initial delay before checking playback status
tokio::time::sleep(Duration::from_secs(1)).await;
loop {
match backend.player.is_playing() {
Ok(true) => {
if let Ok(mut state) = songstate.lock() {
if let Ok(mut song_lock) = song_playing.lock() {
if let Ok(song) = backend.song.lock() {
if let Some(value) = song.as_ref() {
let total_duration = backend
.player
.duration()
.parse::<f64>()
.map(|d| {
let total = d as i64;
format!("{:02}:{:02}", total / 60, total % 60)
})
.unwrap_or_default();
*song_lock = Some(SongDetails {
song: value.clone(),
current_time: backend.player.get_current_time(),
total_duration,
});
*state = SongState::Playing;
return; // Exit once playing is confirmed
}
}
}
}
idle_count = 0; // Reset idle count since the song is playing
}
Ok(false) => {
// Song is not playing, set state to Idle
if let Ok(mut state) = songstate.lock() {
*state = SongState::Idle;
}
idle_count += 1;
}
Err(_) => idle_count += 1, // Increase idle count if an error occurs
}
// If too many idle checks, assume an error occurred
if idle_count >= MAX_IDLE_COUNT {
if let Ok(mut state) = songstate.lock() {
if *state == SongState::Loading {
*state = SongState::ErrorPlayingoSong;
}
}
}
tokio::time::sleep(Duration::from_secs(2)).await; // Check every 2 seconds
}
});
}
// Render the player UI
pub fn render(&mut self, area: Rect, buf: &mut Buffer) {
// Check for playback event signals
if self.rx.try_recv().is_ok() {
if let Ok(mut state) = self.songstate.lock() {
*state = SongState::Loading;
}
self.check_playing(); // Start checking for playback status
}
let block = Block::default().borders(Borders::ALL);
let inner = block.inner(area);
block.render(area, buf);
if let Ok(state) = self.songstate.lock() {
let text = match *state {
SongState::Idle => vec![Line::from("No song is playing")],
SongState::Playing => {
if let Ok(song_playing) = self.song_playing.lock() {
song_playing.as_ref().map_or_else(
|| vec![Line::from("Loading...")],
|song| {
let current_time = song
.current_time
.parse::<i64>()
.map(|t| format!("{:02}:{:02}", t / 60, t % 60))
.unwrap_or_default();
vec![
Line::from(Span::styled(
song.song.song_name.clone(),
Style::default().add_modifier(Modifier::BOLD),
)),
Line::from(format!("{}/{}", current_time, song.total_duration)),
]
},
)
} else {
vec![Line::from("Error accessing song details")]
}
}
SongState::Loading => {
vec![Line::from("Loading Song")]
}
SongState::ErrorPlayingoSong => {
vec![Line::from("Error Playing Song")]
}
};
Paragraph::new(text)
.alignment(Alignment::Center)
.render(inner, buf);
}
}
}
================================================
FILE: feather_frontend/src/search.rs
================================================
use crate::backend::{Backend, Song};
use crossterm::event::{KeyCode, KeyEvent};
use feather::{ArtistName, SongId, SongName};
use ratatui::{
buffer::Buffer,
layout::{Constraint, Layout, Rect},
style::{Color, Style},
text::Span,
widgets::{
Block, Borders, List, ListItem, ListState, Paragraph, Scrollbar, ScrollbarState,
StatefulWidget, Widget,
},
};
use std::sync::Arc;
use tokio::{
sync::mpsc,
time::{Duration, sleep},
};
use tui_textarea::TextArea;
// Defines possible states for the search interface
enum SearchState {
SearchBar, // When focused on input field
SearchResults, // When browsing search results
}
pub struct Search<'a> {
textarea: TextArea<'a>, // Text input widget for search queries
state: SearchState, // Current UI state
query: String, // Current search query text
tx: mpsc::Sender<Result<Vec<((String, String), Vec<String>)>, String>>, // Sender for search results
rx: mpsc::Receiver<Result<Vec<((String, String), Vec<String>)>, String>>, // Receiver for search results
tx_player: mpsc::Sender<bool>, // Channel to communicate with player
backend: Arc<Backend>, // Audio backend for search and playback
vertical_scroll_state: ScrollbarState, // Vertical scrollbar state
display_content: bool, // Flag to show search results
results: Result<Option<Vec<((SongName, SongId), Vec<ArtistName>)>>, String>, // Search results or error
selected: usize, // Index of selected result
selected_song: Option<Song>, // Currently selected song details
max_len: Option<usize>, // Total number of search results
}
impl Search<'_> {
// Constructor initializing the Search struct
pub fn new(backend: Arc<Backend>, tx_player: mpsc::Sender<bool>) -> Self {
let (tx, rx) = mpsc::channel(32); // Create channel for async search results
Self {
query: String::new(),
state: SearchState::SearchBar,
textarea: TextArea::default(),
tx,
rx,
tx_player,
backend,
vertical_scroll_state: ScrollbarState::default(),
display_content: false,
results: Ok(None),
selected: 0,
selected_song: None,
max_len: None,
}
}
// Handles keyboard input based on current state
pub fn handle_keystrokes(&mut self, key: KeyEvent) {
if let SearchState::SearchBar = self.state {
match key.code {
KeyCode::Tab => {
// Switch to results state
self.change_state();
}
KeyCode::Enter => {
// Execute search
self.display_content = false;
self.selected = 0;
let text = self.textarea.lines();
if !text.is_empty() {
self.query = text[0].trim().to_string();
let tx = self.tx.clone();
let query = self.query.clone();
let backend = self.backend.clone();
tokio::spawn(async move {
// Async task for search
sleep(Duration::from_millis(500)).await; // Debounce
match backend.yt.search(&query).await {
Ok(songs) => {
let _ = tx.send(Ok(songs)).await;
}
Err(e) => {
let _ = tx.send(Err(e)).await;
}
}
});
}
}
_ => {
self.textarea.input(key);
} // Handle text input
}
} else {
// SearchResults state
match key.code {
KeyCode::Tab => {
self.change_state();
} // Switch to search bar
KeyCode::Char('j') | KeyCode::Down => {
// Move selection down
self.selected = self.selected.saturating_add(1);
if let Some(len) = self.max_len {
self.selected = self.selected.min(len - 1);
}
self.vertical_scroll_state = self.vertical_scroll_state.position(self.selected);
}
KeyCode::Char('k') | KeyCode::Up => {
// Move selection up
self.selected = self.selected.saturating_sub(1);
self.vertical_scroll_state = self.vertical_scroll_state.position(self.selected);
}
KeyCode::Enter => {
// Play selected song
if let Some(song) = self.selected_song.clone() {
let backend = self.backend.clone();
let tx_player = self.tx_player.clone();
tokio::spawn(async move {
let _ = backend.play_music(song).await.is_ok();
let _ = tx_player.send(true).await;
});
}
}
_ => {}
}
}
}
// Toggles between search bar and results view
pub fn change_state(&mut self) {
match self.state {
SearchState::SearchResults => self.state = SearchState::SearchBar,
_ => self.state = SearchState::SearchResults,
}
}
// Renders the search UI
pub fn render(&mut self, area: Rect, buf: &mut Buffer) {
let chunks = Layout::default()
.direction(ratatui::layout::Direction::Vertical)
.constraints([
Constraint::Length(3), // Search bar height
Constraint::Min(0), // Results area
Constraint::Length(3), // Bottom bar
])
.split(area);
let searchbar_area = chunks[0];
let results_area = chunks[1];
let bottom_area = chunks[2];
// Check for new search results
if let Ok(response) = self.rx.try_recv() {
if let Ok(result) = response {
self.results = Ok(Some(result));
} else if let Err(e) = response {
self.results = Err(e);
}
self.display_content = true;
}
// Render search bar
let search_block = Block::default().title("Search Music").borders(Borders::ALL);
self.textarea.set_cursor_line_style(Style::default());
self.textarea
.set_placeholder_text("Search Song or Playlist");
self.textarea.set_style(Style::default().fg(Color::White));
self.textarea.set_block(search_block);
self.textarea.render(searchbar_area, buf);
// Render vertical scrollbar
let vertical_scrollbar =
Scrollbar::new(ratatui::widgets::ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("↑"))
.end_symbol(Some("↓"));
vertical_scrollbar.render(results_area, buf, &mut self.vertical_scroll_state);
// Render search results if available
if self.display_content {
if let Ok(result) = self.results.clone() {
if let Some(r) = result {
self.max_len = Some(r.len());
let items: Vec<ListItem> = r
.into_iter()
.enumerate()
.map(|(i, ((song, songid), artists))| {
// Format results
let style = if i == self.selected {
self.selected_song =
Some(Song::new(song.clone(), songid.clone(), artists.clone()));
Style::default().fg(Color::Yellow).bg(Color::Blue)
} else {
Style::default()
};
let text = format!("{} - {}", song, artists.join(", "));
ListItem::new(Span::styled(text, style))
})
.collect();
let mut list_state = ListState::default();
list_state.select(Some(self.selected));
StatefulWidget::render(
// Render results list
List::new(items)
.block(Block::default().title("Results").borders(Borders::ALL))
.highlight_symbol("▶"),
results_area,
buf,
&mut list_state,
);
}
}
}
// Render bottom help bar
let bottom_bar = Paragraph::new("Press '?' for Help in Global Mode")
.style(Style::default().fg(Color::White))
.block(Block::default().borders(Borders::ALL));
bottom_bar.render(bottom_area, buf); // Note: custom_area undefined, likely should be bottom_area
// Render outer border
let outer_block = Block::default().borders(Borders::ALL);
outer_block.render(area, buf);
}
}
gitextract__by_cgd2/
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── feather/
│ ├── .gitignore
│ ├── Cargo.toml
│ ├── build.rs
│ ├── src/
│ │ ├── database.rs
│ │ ├── lib.rs
│ │ ├── player.rs
│ │ └── yt.rs
│ └── structure.txt
└── feather_frontend/
├── .gitignore
├── Cargo.toml
└── src/
├── backend.rs
├── history.rs
├── lib.rs
├── main.rs
├── player.rs
└── search.rs
SYMBOL INDEX (76 symbols across 10 files)
FILE: feather/build.rs
function main (line 1) | fn main() {
FILE: feather/src/database.rs
type HistoryEntry (line 11) | pub struct HistoryEntry {
method new (line 20) | pub fn new(
type HistoryDB (line 36) | pub struct HistoryDB {
method new (line 52) | pub fn new() -> Result<Self, sled::Error> {
method add_entry (line 67) | pub fn add_entry(&self, entry: &HistoryEntry) -> Result<(), HistoryErr...
method limit_history_size (line 77) | pub fn limit_history_size(&self, max_size: usize) -> Result<(), Histor...
method get_history (line 87) | pub fn get_history(&self) -> Result<Vec<HistoryEntry>, HistoryError> {
method delete_entry (line 100) | pub fn delete_entry(&self, song_id: &str) -> Result<(), HistoryError> {
method clear_history (line 106) | pub fn clear_history(&self) -> Result<(), HistoryError> {
method get_last_played_song (line 112) | pub fn get_last_played_song(&self) -> Result<Option<SongId>, HistoryEr...
type HistoryError (line 42) | pub enum HistoryError {
FILE: feather/src/lib.rs
type ArtistName (line 6) | pub type ArtistName = String;
type SongName (line 7) | pub type SongName = String;
type SongId (line 8) | pub type SongId = String;
type SongUrl (line 9) | pub type SongUrl = String;
type PlaylistName (line 10) | pub type PlaylistName = String;
type PlaylistId (line 11) | pub type PlaylistId = String;
type ChannelName (line 12) | pub type ChannelName = String;
FILE: feather/src/player.rs
type Player (line 7) | pub struct Player {
method new (line 31) | pub fn new(cookies: Option<String>) -> Result<Self, MpvError> {
method play (line 63) | pub fn play(&self, url: &str) -> Result<(), MpvError> {
method pause (line 72) | pub fn pause(&self) -> Result<(), MpvError> {
method unpause (line 78) | pub fn unpause(&self) -> Result<(), MpvError> {
method play_pause (line 84) | pub fn play_pause(&self) -> Result<(), MpvError> {
method seek_forward (line 94) | pub fn seek_forward(&self) -> Result<(), MpvError> {
method seek_backword (line 100) | pub fn seek_backword(&self) -> Result<(), MpvError> {
method get_current_time (line 106) | pub fn get_current_time(&self) -> String {
method duration (line 114) | pub fn duration(&self) -> String {
method is_playing (line 122) | pub fn is_playing(&self) -> Result<bool, MpvError> {
type MpvError (line 14) | pub enum MpvError {
FILE: feather/src/yt.rs
type YoutubeClient (line 11) | pub struct YoutubeClient {
method new (line 17) | pub fn new() -> Self {
method search (line 28) | pub async fn search(
method fetch_song_url (line 52) | pub async fn fetch_song_url(&self, id: &SongId) -> Result<SongUrl, Str...
method fetch_playlist (line 65) | pub async fn fetch_playlist(
method fetch_playlist_songs (line 93) | pub async fn fetch_playlist_songs(
method fetch_related_song (line 121) | pub async fn fetch_related_song(
FILE: feather_frontend/src/backend.rs
type Backend (line 15) | pub struct Backend {
method new (line 77) | pub fn new(history: Arc<HistoryDB>, cookies: Option<String>) -> Result...
method play_music (line 93) | pub async fn play_music(&self, song: Song) -> Result<(), BackendError> {
type Song (line 24) | pub struct Song {
method new (line 40) | pub fn new(song_name: SongName, song_id: SongId, artist_name: Vec<Arti...
method from (line 32) | fn from(value: Song) -> Self {
type BackendError (line 51) | pub enum BackendError {
FILE: feather_frontend/src/history.rs
type History (line 15) | pub struct History {
method new (line 27) | pub fn new(
method handle_keystrokes (line 44) | pub fn handle_keystrokes(&mut self, key: KeyEvent) {
method select_next (line 78) | fn select_next(&mut self) {
method select_previous (line 86) | fn select_previous(&mut self) {
method render (line 92) | pub fn render(&mut self, area: Rect, buf: &mut Buffer) {
FILE: feather_frontend/src/main.rs
function main (line 19) | async fn main() -> Result<()> {
type State (line 29) | enum State {
type App (line 40) | struct App<'a> {
function new (line 55) | fn new() -> Self {
function handle_global_keystrokes (line 76) | fn handle_global_keystrokes(&mut self, key: KeyEvent) {
function render (line 114) | async fn render(mut self, mut terminal: DefaultTerminal) {
type TopBar (line 208) | struct TopBar;
method new (line 211) | fn new() -> Self {
method render (line 214) | fn render(&mut self, area: Rect, buf: &mut Buffer, state: &State) {
type UserPlaylist (line 224) | struct UserPlaylist {}
type CurrentPlayingPlaylist (line 227) | struct CurrentPlayingPlaylist {}
FILE: feather_frontend/src/player.rs
type SongState (line 13) | enum SongState {
type SongDetails (line 21) | pub struct SongDetails {
type SongPlayer (line 27) | pub struct SongPlayer {
method new (line 35) | pub fn new(backend: Arc<Backend>, rx: mpsc::Receiver<bool>) -> Self {
method observe_time (line 47) | fn observe_time(&self) {
method handle_keystrokes (line 72) | pub fn handle_keystrokes(&mut self, key: KeyEvent) {
method check_playing (line 95) | fn check_playing(&mut self) {
method render (line 160) | pub fn render(&mut self, area: Rect, buf: &mut Buffer) {
FILE: feather_frontend/src/search.rs
type SearchState (line 22) | enum SearchState {
type Search (line 27) | pub struct Search<'a> {
function new (line 45) | pub fn new(backend: Arc<Backend>, tx_player: mpsc::Sender<bool>) -> Self {
function handle_keystrokes (line 65) | pub fn handle_keystrokes(&mut self, key: KeyEvent) {
function change_state (line 136) | pub fn change_state(&mut self) {
function render (line 144) | pub fn render(&mut self, area: Rect, buf: &mut Buffer) {
Condensed preview — 19 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (75K chars).
[
{
"path": "CONTRIBUTING.md",
"chars": 2152,
"preview": "# Contributing to FEATHER\n\nThank you for considering contributing to FEATHER! 🚀 \nWe welcome all contributions, whether "
},
{
"path": "LICENSE",
"chars": 1066,
"preview": "MIT License\n\nCopyright (c) 2025 13unk0wn\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\no"
},
{
"path": "README.md",
"chars": 3042,
"preview": "# Feather 🎵\n\nFeather is a lightweight, efficient, and locally hosted YouTube Music TUI built with Rust. It is designed t"
},
{
"path": "feather/.gitignore",
"chars": 40,
"preview": "/target\nrustypipe_cache.json\nCargo.lock\n"
},
{
"path": "feather/Cargo.toml",
"chars": 448,
"preview": "[package]\nname = \"feather\"\nversion = \"0.1.0\"\nedition = \"2024\"\nbuild = \"build.rs\"\n\n[dependencies]\nrustypipe = \"0.9.0\"\ntok"
},
{
"path": "feather/build.rs",
"chars": 196,
"preview": "fn main() {\n if cfg!(target_os = \"macos\") && pkg_config::probe_library(\"mpv\").is_err() {\n println!(\"cargo:warn"
},
{
"path": "feather/src/database.rs",
"chars": 10147,
"preview": "// This file manages the history database and contains all necessary functions related to history management\nuse crate::"
},
{
"path": "feather/src/lib.rs",
"chars": 274,
"preview": "pub mod database;\npub mod player;\npub mod yt;\n\n/// Input/Return Types\npub type ArtistName = String;\npub type SongName = "
},
{
"path": "feather/src/player.rs",
"chars": 4421,
"preview": "use libmpv2::Mpv; // We are not using libmpv library because it was requiring user to install an old version which was n"
},
{
"path": "feather/src/yt.rs",
"chars": 8092,
"preview": "use crate::{ArtistName, ChannelName, PlaylistId, PlaylistName, SongId, SongName, SongUrl};\nuse std::path::PathBuf;\nuse r"
},
{
"path": "feather/structure.txt",
"chars": 2444,
"preview": "Feather\n│── backend\n│ │── yt.rs \n│ │ │── search(query: &str) -> Vec<String> # Search YouTube "
},
{
"path": "feather_frontend/.gitignore",
"chars": 29,
"preview": "target/\nrustypipe_cache.json\n"
},
{
"path": "feather_frontend/Cargo.toml",
"chars": 774,
"preview": "[package]\nname = \"feather_frontend\"\nversion = \"0.1.0\"\nedition = \"2024\"\nauthors = [\"13unk0wn 13unk0wn@proton.me\"]\ndescrip"
},
{
"path": "feather_frontend/src/backend.rs",
"chars": 4572,
"preview": "use feather::{\n ArtistName, SongId, SongName,\n database::{HistoryDB, HistoryEntry},\n player::{MpvError, Player}"
},
{
"path": "feather_frontend/src/history.rs",
"chars": 6086,
"preview": "use crate::backend::{Backend, Song};\nuse crossterm::event::{KeyCode, KeyEvent};\nuse feather::database::HistoryDB;\nuse ra"
},
{
"path": "feather_frontend/src/lib.rs",
"chars": 66,
"preview": "pub mod backend;\npub mod history;\npub mod player;\npub mod search;\n"
},
{
"path": "feather_frontend/src/main.rs",
"chars": 8628,
"preview": "use color_eyre::eyre::Result;\nuse crossterm::event::{Event, KeyCode, KeyEvent, poll, read};\nuse feather::database::Histo"
},
{
"path": "feather_frontend/src/player.rs",
"chars": 8943,
"preview": "use crate::backend::{Backend, Song};\nuse crossterm::event::{KeyCode, KeyEvent};\nuse ratatui::prelude::{Alignment, Buffer"
},
{
"path": "feather_frontend/src/search.rs",
"chars": 9480,
"preview": "use crate::backend::{Backend, Song};\nuse crossterm::event::{KeyCode, KeyEvent};\nuse feather::{ArtistName, SongId, SongNa"
}
]
About this extraction
This page contains the full source code of the 13unk0wn/Feather GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 19 files (69.2 KB), approximately 15.7k tokens, and a symbol index with 76 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.