Compare commits

...

48 Commits
cli ... main

Author SHA1 Message Date
5405087434 README.md update 2025-03-26 20:24:03 +03:00
0f52d10198 File system move to parent directory fix 2025-03-07 15:48:34 +03:00
913da0c0ef Large bugfix 2025-03-06 02:09:58 +03:00
d482acca94 Small fixes x2 2025-03-06 01:44:44 +03:00
934bcb9d9e Small fixes 2025-03-06 01:35:39 +03:00
5d94131ad6 Readme edit x2 2025-03-05 01:32:49 +03:00
d3ffb214ca Readme edit 2025-03-05 00:52:56 +03:00
8a398ceb2c youtube download added 2025-03-04 21:38:35 +03:00
d6db006412 small upd x4 2025-02-25 04:09:28 +03:00
a8fab2b721 small upd x3 2025-02-24 21:17:42 +03:00
7702b6dd01 small upd x2 2025-02-24 21:01:20 +03:00
13bb433bfe small upd 2025-02-24 05:02:57 +03:00
a724560d58 artworks implemented for soundcloud 2025-02-22 04:14:26 +03:00
7a75c45fa0 artworks implemented 2025-02-22 00:32:18 +03:00
41ef6bcbb1 upd 2025-02-21 03:19:38 +03:00
a64f77c2a4 FS start 2025-02-20 00:56:08 +03:00
4d89b9e187 Fixed itunesdb bug. Added ability to download single song form playlist. Artwork addition development started, fs mode development started. 2025-02-18 00:08:57 +03:00
3bd8f1c75d Scrolling through list added, splash screen modified. 2025-02-17 19:23:30 +03:00
6b8b4ef355 Small upd 2025-02-17 16:09:56 +03:00
d63296d7b4 Readme upd 2025-02-17 05:58:52 +03:00
fa786d931d start of self-implemented scrollbox. 2025-02-17 05:57:12 +03:00
92d3f7ba77 small update 2025-02-16 03:00:08 +03:00
b29ef4901e test 2025-02-14 22:31:31 +03:00
3a77aff9f1 upddd 2025-02-14 03:51:12 +03:00
e6dcb1bae6 upd 2025-02-13 18:06:09 +03:00
7a0468d0a8 modified: src/main_screen.rs 2025-02-13 06:34:43 +03:00
eee1a4e49d modified: src/main_screen.rs 2025-02-13 05:20:12 +03:00
edfbbce03e checkpoint
modified:   src/file_system.rs
	modified:   src/loading_screen.rs
	modified:   src/main.rs
	modified:   src/main_screen.rs
	modified:   src/screen.rs
	new file:   src/theme.rs
	modified:   src/wait_screen.rs
2025-02-13 05:10:41 +03:00
e4061dde2d modified: Cargo.lock
modified:   Cargo.toml
	modified:   src/db.rs
	modified:   src/loading_screen.rs
	modified:   src/sync.rs
2025-02-13 04:17:07 +03:00
fc23d3a7b6 modified: src/db.rs
modified:   src/dlp.rs
	modified:   src/main.rs
	modified:   src/main_screen.rs
	modified:   src/sync.rs
2025-02-13 02:56:59 +03:00
1657e328fb modified: src/db.rs
new file:   src/file_system.rs
	new file:   src/loading_screen.rs
	modified:   src/main.rs
	modified:   src/main_screen.rs
	modified:   src/sync.rs
2025-02-12 22:56:56 +03:00
b34032d228 modified: Cargo.lock
modified:   Cargo.toml
	modified:   src/db.rs
	modified:   src/main_screen.rs
	modified:   src/sync.rs
2025-02-12 06:16:37 +03:00
a72a7b8a25 modified: Cargo.lock
modified:   Cargo.toml
2025-02-12 05:46:11 +03:00
495f53ef20 modified: Cargo.lock
modified:   Cargo.toml
	modified:   src/config.rs
	modified:   src/db.rs
	modified:   src/main_screen.rs
	modified:   src/sync.rs
2025-02-12 05:45:20 +03:00
980f1da3ac modified: README.md 2025-02-12 02:57:29 +03:00
f6b8faa221 modified: .gitignore
modified:   Cargo.lock
	modified:   Cargo.toml
	modified:   src/config.rs
	new file:   src/db.rs
	modified:   src/main.rs
	modified:   src/sync.rs
2025-02-12 02:56:35 +03:00
8f8a79411b modified: .gitignore
modified:   Cargo.lock
	modified:   Cargo.toml
	modified:   src/dlp.rs
	modified:   src/main.rs
	modified:   src/main_screen.rs
	deleted:    src/playlist_icon.rs
	modified:   src/sync.rs
2025-02-11 19:58:42 +03:00
58e87e615a new file: README.md
modified:   src/main_screen.rs
2025-02-11 18:28:39 +03:00
8c3d87133d modified: src/main_screen.rs
modified:   src/playlist_icon.rs
2025-02-11 17:32:15 +03:00
ded8897ece modified: Cargo.lock
modified:   Cargo.toml
	modified:   src/main.rs
	modified:   src/main_screen.rs
	new file:   src/playlist_icon.rs
2025-02-11 16:07:18 +03:00
f337a6de1f Warnings fix
modified:   src/main.rs
	modified:   src/main_screen.rs
	modified:   src/screen.rs
	modified:   src/sync.rs
	modified:   src/util.rs
	modified:   src/wait_screen.rs
2025-02-11 14:51:13 +03:00
adb52e01ac modified: src/sync.rs 2025-02-11 14:46:23 +03:00
7a32015a05 modified: Cargo.lock
modified:   Cargo.toml
	modified:   src/main.rs
	modified:   src/main_screen.rs
2025-02-10 22:01:22 +03:00
ad2ca1d689 modified: Cargo.lock
modified:   Cargo.toml
	modified:   src/main.rs
2025-02-10 16:04:46 +03:00
30422c28eb modified: Cargo.lock
modified:   Cargo.toml
	modified:   src/dlp.rs
	modified:   src/main.rs
	modified:   src/main_screen.rs
	modified:   src/sync.rs
2025-02-10 15:17:24 +03:00
f016bd754b modified: Cargo.lock
modified:   Cargo.toml
	modified:   src/config.rs
	new file:   src/dlp.rs
	modified:   src/main.rs
	modified:   src/main_screen.rs
	modified:   src/sync.rs
2025-02-10 12:58:17 +03:00
34cf8c0a15 modified: src/main_screen.rs 2025-02-10 03:06:51 +03:00
0e7d9aa8d9 modified: src/config.rs
modified:   src/main.rs
	new file:   src/main_screen.rs
	modified:   src/screen.rs
	new file:   src/sync.rs
	deleted:    src/tabs.rs
	new file:   src/wait_screen.rs
2025-02-10 02:47:15 +03:00
15 changed files with 3716 additions and 413 deletions

1010
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -6,16 +6,32 @@ license = "AGPLv3"
authors = ["Michael Wain <alterwain@protonmail.com>"]
[dependencies]
chrono = "0.4.39"
rusb = "0.9.4"
dirs = "6.0.0"
toml = "0.8.20"
serde = "1.0.217"
serde_json = "1.0"
serde-xml-rs = "0.6.0"
regex = "1.11.1"
ratatui = { version = "0.29.0", features = ["all-widgets"] }
color-eyre = "0.6.3"
crossterm = "0.28.1"
crossterm = { version = "0.28.1", features = ["event-stream"] }
futures = "0.3"
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7.12", features = ["codec"] }
strum = { version = "0.27", features = ["derive"] }
soundcloud = { version = "0.1.1", git = "https://gitea.awain.net/alterwain/soundcloud_api.git" }
itunesdb = { version = "0.1.1", git = "https://gitea.awain.net/alterwain/ITunesDB.git" }
soundcloud = { version = "0.1.11", git = "https://gitea.awain.net/alterwain/soundcloud_api.git" }
youtube-api = { version = "0.1.1", git = "https://gitea.awain.net/alterwain/youtube_api.git" }
itunesdb = { version = "0.1.99", git = "https://gitea.awain.net/alterwain/ITunesDB.git" }
rand = "0.8.5"
tui-big-text = "0.7.1"
throbber-widgets-tui = "0.8.0"
audiotags = "0.5.0"
image = "0.25.5"
twox-hash = "2.1.0"
[profile.release]
strip = true
opt-level = "s"
lto = true
codegen-units = 1

47
README.md Normal file
View File

@ -0,0 +1,47 @@
<img align="left" width="100" height="100" src="https://w0n.zip/file/dRDDwa">
**Lyrica**
Lightweight iPod manager, batteries included.
#
<div align="center">
<img src="https://w0n.zip/file/dRgjRe"/>
<p>
<small>Screenshot from MacOS Big Sur terminal</small>
</p>
</div>
## Features
- **Basic operations**: Load songs from filesystem to ipod. Manually delete anything if needed.
- **Streamings**: Download playlists from Soundcloud, Youtube to your iPod.
- **No iTunes**: Finally you can get rid of iTunes or Apple Music. Marvelous!
- **Playlists**: Create/remove/edit playlists on your ipod
## Requirements
- iPod classic
- Mac OS
- [YT-DLP](https://github.com/yt-dlp/yt-dlp) A feature-rich command-line audio/video downloader.
## Install / Update
To install or update Lyrica simply run this command in your Mac terminal:
```bash
curl -sSf https://w0n.zip/raw/egM23b | sh
```
### Uninstall
To uninstall Lyrica (but why?) you need to run this command in your terminal:
```bash
rm /usr/local/bin/lyrica
```
## Usage
Just type lyrica in your terminal from anywhere

89
src/component.rs Normal file
View File

@ -0,0 +1,89 @@
pub mod table {
use ratatui::layout::{Constraint, Rect};
use ratatui::prelude::{Color, Style};
use ratatui::widgets::{Block, Borders, Row, Table};
use ratatui::Frame;
#[derive(Default)]
pub struct SmartTable {
is_checked: bool,
header: Vec<String>,
data: Vec<Vec<String>>,
constraints: Vec<Constraint>,
selected_row: i32,
title: String,
}
impl SmartTable {
pub fn new(header: Vec<String>, constraints: Vec<Constraint>) -> Self {
Self {
is_checked: true,
header,
data: Vec::new(),
constraints,
selected_row: 0,
title: String::new(),
}
}
pub fn set_checked(&mut self, checked: bool) {
self.is_checked = checked;
}
pub fn set_data(&mut self, data: Vec<Vec<String>>) {
self.data = data;
}
pub fn set_title(&mut self, title: String) {
self.title = title;
}
pub fn previous_row(&mut self) {
self.selected_row = (self.selected_row - 1).max(0);
}
pub fn next_row(&mut self) {
self.selected_row = (self.selected_row + 1).min(self.data.len() as i32 - 1);
}
pub fn selected_row(&self) -> usize {
self.selected_row as usize
}
pub fn render(&self, frame: &mut Frame, area: Rect) {
let mut v = vec![Row::new(self.header.clone()).style(Style::default().fg(Color::Gray))];
for (i, entry) in self.data.iter().enumerate() {
v.push(
Row::new(entry.clone()).style(if self.selected_row as usize == i {
Style::default()
.bg(if self.is_checked {
Color::LightBlue
} else {
Color::Gray
})
.fg(Color::White)
} else {
Style::default()
}),
);
}
if self.selected_row as usize > area.rows().count() - 4
&& self.selected_row - ((area.rows().count() - 4) as i32) >= 0
{
v = v[(self.selected_row as usize - (area.rows().count() - 4))..].to_vec();
}
let table = Table::new(v, self.constraints.clone())
.block(
Block::default()
.borders(Borders::ALL)
.title(self.title.as_ref()),
)
.style(Style::default().fg(Color::Black));
frame.render_widget(table, area);
}
}
}

View File

@ -1,19 +1,51 @@
use serde::Deserialize;
use std::path::PathBuf;
#[derive(Debug, Deserialize)]
use serde::{Deserialize, Serialize};
pub fn get_configs_dir() -> PathBuf {
let mut p = dirs::home_dir().unwrap();
p.push(".lyrica");
p
}
pub fn get_temp_dl_dir() -> PathBuf {
let mut p = get_configs_dir();
p.push("tmp");
p
}
pub fn clear_temp_dl_dir() {
let path = get_temp_dl_dir();
let _ = std::fs::remove_dir_all(path);
}
pub fn get_config_path() -> PathBuf {
let mut p = get_configs_dir();
p.push("config");
p.set_extension("toml");
p
}
pub fn get_temp_itunesdb() -> PathBuf {
let mut p = get_configs_dir();
p.push("idb");
p
}
#[derive(Debug, Deserialize, Serialize, Default)]
pub struct YouTubeConfiguration {
pub user_id: u64
pub user_id: String,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, Serialize, Default)]
pub struct SoundCloudConfiguration {
pub user_id: u64
pub user_id: u64,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, Serialize, Default)]
pub struct LyricaConfiguration {
soundcloud: SoundCloudConfiguration,
youtube: YouTubeConfiguration
youtube: YouTubeConfiguration,
}
impl LyricaConfiguration {
@ -24,4 +56,4 @@ impl LyricaConfiguration {
pub fn get_youtube(&self) -> &YouTubeConfiguration {
&self.youtube
}
}
}

260
src/dlp.rs Normal file
View File

@ -0,0 +1,260 @@
use ratatui::style::Color;
use regex::Regex;
use serde::Deserialize;
use std::{io, path::PathBuf, process::Stdio};
use tokio::{
io::{AsyncBufReadExt, BufReader},
process::Command,
sync::mpsc::Sender,
};
use crate::sync::AppEvent;
#[derive(Debug, Deserialize)]
pub struct DownloadProgress {
pub progress_percentage: String,
pub progress_total: String,
pub eta: String,
}
pub async fn download_track_from_youtube(
track_url: &str,
download_dir: &PathBuf,
sender: Sender<AppEvent>,
) -> io::Result<()> {
let _ = sender
.send(AppEvent::SwitchScreen(crate::AppState::LoadingScreen))
.await;
if download_dir.exists() {
let _ = std::fs::remove_dir_all(download_dir);
}
let _ = std::fs::create_dir_all(download_dir);
let args = &[
"-f",
"bestaudio",
"-x",
"--audio-format",
"mp3",
"--audio-quality",
"0",
"-o",
"%(id)s.%(ext)s",
"--ignore-errors",
"--newline",
"--progress-template",
"{\"progress_percentage\":\"%(progress._percent_str)s\",\"progress_total\":\"%(progress._total_bytes_str)s\",\"speed\":\"%(progress._speed_str)s\",\"eta\":\"%(progress._eta_str)s\"}",
"--write-thumbnail",
&*["https://youtube.com/watch?v=", track_url].concat()
];
let mut command = Command::new("yt-dlp");
command.args(args);
command.stdout(Stdio::piped());
command.stderr(Stdio::null());
command.current_dir(download_dir);
let mut child = command.spawn()?;
let stdout = child.stdout.take().unwrap();
let mut reader = BufReader::new(stdout).lines();
while let Ok(Some(line)) = reader.next_line().await {
if line.starts_with("{") {
let progress: DownloadProgress = serde_json::from_str(&line).unwrap();
let _ = sender
.send(AppEvent::OverallProgress((0, 1, Color::Green)))
.await;
let _ = sender.send(AppEvent::CurrentProgress(progress)).await;
}
}
let _ = sender
.send(AppEvent::OverallProgress((1, 1, Color::Green)))
.await;
Ok(())
}
pub async fn download_track_from_soundcloud(
track_url: &str,
download_dir: &PathBuf,
sender: Sender<AppEvent>,
) -> io::Result<()> {
let _ = sender
.send(AppEvent::SwitchScreen(crate::AppState::LoadingScreen))
.await;
if download_dir.exists() {
let _ = std::fs::remove_dir_all(download_dir);
}
let _ = std::fs::create_dir_all(download_dir);
let args = &[
"-f",
"mp3",
"--ignore-errors",
"--newline",
"--progress-template",
"{\"progress_percentage\":\"%(progress._percent_str)s\",\"progress_total\":\"%(progress._total_bytes_str)s\",\"speed\":\"%(progress._speed_str)s\",\"eta\":\"%(progress._eta_str)s\"}",
"-o",
"%(id)i.%(ext)s",
"--write-thumbnail",
track_url
];
let mut command = Command::new("yt-dlp");
command.args(args);
command.stdout(Stdio::piped());
command.stderr(Stdio::null());
command.current_dir(download_dir);
let mut child = command.spawn()?;
let stdout = child.stdout.take().unwrap();
let mut reader = BufReader::new(stdout).lines();
while let Ok(Some(line)) = reader.next_line().await {
if line.starts_with("{") {
let progress: DownloadProgress = serde_json::from_str(&line).unwrap();
let _ = sender
.send(AppEvent::OverallProgress((0, 1, Color::Green)))
.await;
let _ = sender.send(AppEvent::CurrentProgress(progress)).await;
}
}
let _ = sender
.send(AppEvent::OverallProgress((1, 1, Color::Green)))
.await;
Ok(())
}
pub async fn download_from_youtube(
playlist_url: &str,
download_dir: &PathBuf,
sender: Sender<AppEvent>,
) -> io::Result<()> {
let _ = sender
.send(AppEvent::SwitchScreen(crate::AppState::LoadingScreen))
.await;
let dl_rx: Regex = Regex::new(r"\[download\] Downloading item \d+ of \d+").unwrap();
if download_dir.exists() {
let _ = std::fs::remove_dir_all(download_dir);
}
let _ = std::fs::create_dir_all(download_dir);
let args = &[
"-f",
"bestaudio",
"-x",
"--audio-format",
"mp3",
"--audio-quality",
"0",
"-o",
"%(id)s.%(ext)s",
"--ignore-errors",
"--newline",
"--progress-template",
"{\"progress_percentage\":\"%(progress._percent_str)s\",\"progress_total\":\"%(progress._total_bytes_str)s\",\"speed\":\"%(progress._speed_str)s\",\"eta\":\"%(progress._eta_str)s\"}",
"--write-thumbnail",
&*["https://youtube.com", playlist_url].concat()
];
let mut command = Command::new("yt-dlp");
command.args(args);
command.stdout(Stdio::piped());
command.stderr(Stdio::null());
command.current_dir(download_dir);
let mut child = command.spawn()?;
let stdout = child.stdout.take().unwrap();
let mut reader = BufReader::new(stdout).lines();
while let Ok(Some(line)) = reader.next_line().await {
match dl_rx.find(&line) {
Some(m) => {
let mut s = m.as_str();
s = s.split("Downloading item ").last().unwrap();
let s: Vec<&str> = s.split(' ').collect();
let cur = s.first().unwrap().trim().parse().unwrap();
let max = s.last().unwrap().trim().parse().unwrap();
let _ = sender
.send(AppEvent::OverallProgress((cur, max, Color::Green)))
.await;
}
None => {
if line.starts_with("{") {
let progress: DownloadProgress = serde_json::from_str(&line).unwrap();
let _ = sender.send(AppEvent::CurrentProgress(progress)).await;
}
}
}
}
Ok(())
}
pub async fn download_from_soundcloud(
playlist_url: &str,
download_dir: &PathBuf,
sender: Sender<AppEvent>,
) -> io::Result<()> {
let _ = sender
.send(AppEvent::SwitchScreen(crate::AppState::LoadingScreen))
.await;
let dl_rx: Regex = Regex::new(r"\[download\] Downloading item \d+ of \d+").unwrap();
if download_dir.exists() {
let _ = std::fs::remove_dir_all(download_dir);
}
let _ = std::fs::create_dir_all(download_dir);
let args = &[
"-f",
"mp3",
"--ignore-errors",
"--newline",
"--progress-template",
"{\"progress_percentage\":\"%(progress._percent_str)s\",\"progress_total\":\"%(progress._total_bytes_str)s\",\"speed\":\"%(progress._speed_str)s\",\"eta\":\"%(progress._eta_str)s\"}",
"-o",
"%(id)i.%(ext)s",
"--write-thumbnail",
playlist_url
];
let mut command = Command::new("yt-dlp");
command.args(args);
command.stdout(Stdio::piped());
command.stderr(Stdio::null());
command.current_dir(download_dir);
let mut child = command.spawn()?;
let stdout = child.stdout.take().unwrap();
let mut reader = BufReader::new(stdout).lines();
while let Ok(Some(line)) = reader.next_line().await {
match dl_rx.find(&line) {
Some(m) => {
let mut s = m.as_str();
s = s.split("Downloading item ").last().unwrap();
let s: Vec<&str> = s.split(' ').collect();
let cur = s.first().unwrap().trim().parse().unwrap();
let max = s.last().unwrap().trim().parse().unwrap();
let _ = sender
.send(AppEvent::OverallProgress((cur, max, Color::Green)))
.await;
}
None => {
if line.starts_with("{") {
let progress: DownloadProgress = serde_json::from_str(&line).unwrap();
let _ = sender.send(AppEvent::CurrentProgress(progress)).await;
}
}
}
}
Ok(())
}

260
src/file_system.rs Normal file
View File

@ -0,0 +1,260 @@
use crate::component::table::SmartTable;
use crate::sync::AppEvent;
use crate::{screen::AppScreen, AppState};
use chrono::{DateTime, Utc};
use crossterm::event::KeyCode;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::prelude::{Line, Stylize};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use std::cmp::Ordering;
use std::ffi::OsStr;
use std::fs::DirEntry;
use std::os::unix::fs::MetadataExt;
use std::path::PathBuf;
use tokio::sync::mpsc::UnboundedSender;
pub struct FileSystem {
files: Vec<DirEntry>,
current_path: PathBuf,
table: SmartTable,
sender: UnboundedSender<AppEvent>,
}
fn check_extension_compatibility(ext: &str) -> bool {
matches!(
ext.to_lowercase().as_str(),
"mp3" | "m4a" | "wav" | "aiff" | "aif"
)
}
fn get_extension_from_filename(file_name: Option<&OsStr>) -> String {
if let Some(fname) = file_name {
let file_name = fname.to_str().unwrap();
let index = file_name
.chars()
.enumerate()
.filter(|(_i, c)| *c == '.')
.map(|(i, _c)| i)
.last();
if let Some(index) = index {
let extension: String = file_name.chars().skip(index).collect();
return extension;
}
}
"none".to_string()
}
fn list_files_recursively(p: PathBuf) -> Vec<PathBuf> {
let mut files = Vec::new();
let paths = std::fs::read_dir(p).unwrap();
for path in paths {
if path.is_err() {
continue;
}
let a = path.unwrap().path();
if a.is_file()
&& check_extension_compatibility(
a.extension()
.map_or(&get_extension_from_filename(a.file_name()), |s| {
s.to_str().unwrap()
}),
)
{
files.push(a.clone());
}
if a.is_dir() {
files.append(&mut list_files_recursively(a));
}
}
files
}
impl AppScreen for FileSystem {
fn handle_key_event(&mut self, key_event: crossterm::event::KeyEvent) {
match key_event.code {
KeyCode::Up => self.table.previous_row(),
KeyCode::Down => self.table.next_row(),
KeyCode::F(4) => {
let _ = self
.sender
.send(AppEvent::SwitchScreen(AppState::MainScreen));
}
KeyCode::F(5) => self.download_as_is(),
KeyCode::F(6) => self.download_as_playlist(),
KeyCode::Enter => self.enter_directory(),
_ => {}
}
}
fn render(&self, frame: &mut Frame) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0), // Main content area
Constraint::Length(1), // Status bar
])
.split(frame.area());
self.render_main(frame, chunks[0]);
// Render Status Bar
let status_bar = Paragraph::new(Line::from(vec![
"<F4> SWITCH TO NORMAL".bold(),
" | ".dark_gray(),
"<F5> SAVE AS IS".bold(),
" | ".dark_gray(),
"<F6> SAVE AS PLAYLIST".bold(),
" | ".dark_gray(),
"<Q> QUIT".bold(),
]))
.centered();
frame.render_widget(status_bar, chunks[1]); // Render into third chunk
}
fn as_any(&mut self) -> &mut dyn std::any::Any {
self
}
}
impl FileSystem {
pub fn new(sender: UnboundedSender<AppEvent>) -> Self {
let table = SmartTable::new(
["Name", "Type", "Size", "Modified"]
.iter_mut()
.map(|s| s.to_string())
.collect(),
vec![
Constraint::Percentage(50),
Constraint::Length(5),
Constraint::Percentage(20),
Constraint::Percentage(30),
],
);
let mut a = Self {
table,
sender,
files: Vec::new(),
current_path: dirs::document_dir().unwrap(),
};
a.get_path(dirs::document_dir().unwrap());
a
}
fn get_path(&mut self, p: PathBuf) {
self.current_path = p.clone();
let paths = std::fs::read_dir(&p).unwrap();
let mut dir = paths
.filter_map(|res| res.ok())
.filter(|p| {
p.path().extension().map_or(false, |s| {
check_extension_compatibility(s.to_str().unwrap_or("none"))
}) || p.path().is_dir()
})
.collect::<Vec<DirEntry>>();
dir.sort_by(|a, b| {
if a.file_type().unwrap().is_dir() == b.file_type().unwrap().is_dir() {
let af = a.file_name();
let bf = b.file_name();
let ac = af.to_str().unwrap_or("a");
let bc = bf.to_str().unwrap_or("a");
return ac.cmp(bc);
}
if a.file_type().unwrap().is_dir() {
Ordering::Less
} else {
Ordering::Greater
}
});
let data = dir
.iter()
.map(|entry| {
let datetime: DateTime<Utc> = entry.metadata().unwrap().modified().unwrap().into();
let datetime = datetime.format("%d/%m/%Y %T").to_string();
let size = entry.metadata().unwrap().size().to_string();
let file_type = if entry.file_type().unwrap().is_file() {
"FILE"
} else {
"DIR"
}
.to_string();
vec![
entry.file_name().to_str().unwrap().to_string(),
file_type,
size,
datetime,
]
})
.collect::<Vec<Vec<String>>>();
let data = [vec![vec![String::from("..")]], data].concat();
self.files = dir;
self.table.set_data(data);
self.table
.set_title(p.iter().last().unwrap().to_str().unwrap().to_string());
}
fn download_as_is(&self) {
if let 1.. = self.table.selected_row() {
let entry = self.files.get(self.table.selected_row() - 1).unwrap();
if entry.path().is_dir() {
let files = list_files_recursively(entry.path());
let _ = self.sender.send(AppEvent::LoadFromFSVec(files));
} else {
let _ = self.sender.send(AppEvent::LoadFromFS(entry.path()));
}
}
}
fn download_as_playlist(&self) {
if let 1.. = self.table.selected_row() {
let entry = self.files.get(self.table.selected_row() - 1).unwrap();
if entry.path().is_dir() {
let files = list_files_recursively(entry.path());
let _ = self.sender.send(AppEvent::LoadFromFSPL((
files,
entry
.path()
.file_name()
.unwrap()
.to_str()
.unwrap()
.to_string(),
)));
}
}
}
fn move_up(&mut self) {
let p = self.current_path.parent();
if p.is_none() {
return;
}
let p: PathBuf = p.unwrap().to_path_buf();
self.get_path(p);
}
fn enter_directory(&mut self) {
match self.table.selected_row() {
0 => self.move_up(),
_ => {
let entry = self.files.get(self.table.selected_row() - 1).unwrap();
if !entry.path().is_dir() {
return;
}
self.get_path(entry.path());
}
}
}
fn render_main(&self, frame: &mut Frame, area: Rect) {
self.table.render(frame, area);
}
}

128
src/loading_screen.rs Normal file
View File

@ -0,0 +1,128 @@
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style, Stylize},
text::Line,
widgets::{Block, Borders, Gauge, Paragraph},
Frame,
};
use crate::{dlp::DownloadProgress, screen::AppScreen};
#[derive(Default)]
pub struct LoadingScreen {
pub progress: Option<(u32, u32, ratatui::style::Color)>,
pub artwork_progress: Option<(u32, u32)>,
pub s_progress: Option<DownloadProgress>,
}
impl AppScreen for LoadingScreen {
fn handle_key_event(&mut self, _key_event: crossterm::event::KeyEvent) {}
fn render(&self, frame: &mut ratatui::Frame) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0), // Main content area
Constraint::Length(1), // Status bar
])
.split(frame.area());
self.render_progress(frame, chunks[0]);
// Render Status Bar
let status_bar = Paragraph::new(Line::from(vec!["<Q> QUIT".bold()])).centered();
frame.render_widget(status_bar, chunks[1]); // Render into third chunk
}
fn as_any(&mut self) -> &mut dyn std::any::Any {
self
}
}
impl LoadingScreen {
fn render_progress(&self, frame: &mut Frame, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0), // Main content
Constraint::Length(6), // Progress bar
Constraint::Length(6), // Progress bar
])
.split(area);
let main_content = Paragraph::new("Please wait").block(
Block::default()
.borders(Borders::ALL)
.title("Downloading has started!"),
);
frame.render_widget(main_content, chunks[0]);
if self.progress.is_some() {
self.render_overall(frame, chunks[1]);
}
if self.artwork_progress.is_some() {
self.render_artwork_progress(frame, chunks[2]);
} else if self.s_progress.is_some() {
self.render_current(frame, chunks[2]);
}
}
fn render_artwork_progress(&self, frame: &mut Frame, area: Rect) {
let gauge = Gauge::default()
.block(
Block::default()
.borders(Borders::ALL)
.title(" Generating album covers "),
)
.gauge_style(Style::default().fg(Color::LightBlue))
.ratio(
self.artwork_progress.unwrap().0 as f64 / self.artwork_progress.unwrap().1 as f64,
)
.label("Generating album covers...");
frame.render_widget(gauge, area);
}
fn render_current(&self, frame: &mut Frame, area: Rect) {
let s: String = self
.s_progress
.as_ref()
.unwrap()
.progress_percentage
.chars()
.filter(|c| c.is_ascii_digit() || *c == '.')
.collect();
let ratio: f64 = s.parse::<f64>().unwrap_or(0.0);
let gauge = Gauge::default()
.block(Block::default().borders(Borders::ALL).title(format!(
" Downloading Item (ETA: {}) ",
self.s_progress.as_ref().unwrap().eta
)))
.gauge_style(Style::default().fg(Color::Green))
.ratio(ratio / 100.0)
.label(self.s_progress.as_ref().unwrap().progress_total.to_string());
frame.render_widget(gauge, area);
}
fn render_overall(&self, frame: &mut Frame, area: Rect) {
let gauge = Gauge::default()
.block(
Block::default()
.borders(Borders::ALL)
.title(" Downloading Playlist "),
)
.gauge_style(Style::default().fg(self.progress.unwrap().2))
.ratio(self.progress.unwrap().0 as f64 / self.progress.unwrap().1 as f64)
.label(format!(
"{:}/{:}",
self.progress.unwrap().0,
self.progress.unwrap().1
));
frame.render_widget(gauge, area);
}
}

View File

@ -1,103 +1,50 @@
use std::{error::Error, io, path::{Path, PathBuf}};
use crate::file_system::FileSystem;
use color_eyre::Result;
use config::LyricaConfiguration;
use crossterm::{event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}};
use ratatui::{buffer::Buffer, layout::{Layout, Rect}, prelude::{Backend, CrosstermBackend}, style::{Color, Stylize}, symbols::border, text::{Line, Text}, widgets::{Block, Paragraph, Tabs, Widget}, DefaultTerminal, Frame, Terminal};
use screen::MainScreen;
use soundcloud::sobjects::CloudPlaylists;
use strum::IntoEnumIterator;
use tokio::{fs::File, io::AsyncReadExt, sync::mpsc::{self, Receiver, Sender, UnboundedReceiver, UnboundedSender}};
use crossterm::{
event::{
DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyCode, KeyEvent,
KeyEventKind,
},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use futures::StreamExt;
use loading_screen::LoadingScreen;
use main_screen::MainScreen;
use ratatui::{
prelude::{Backend, CrosstermBackend},
Frame, Terminal,
};
use screen::AppScreen;
use std::time::Duration;
use std::{collections::HashMap, error::Error, io};
use sync::AppEvent;
use tokio::sync::mpsc::{self, Receiver, UnboundedSender};
use tokio_util::sync::CancellationToken;
use itunesdb::xobjects::XDatabase;
use ratatui::prelude::Constraint::{Length, Min};
use wait_screen::WaitScreen;
mod util;
mod component;
mod config;
mod tabs;
mod dlp;
mod file_system;
mod loading_screen;
mod main_screen;
mod screen;
mod sync;
mod util;
mod wait_screen;
fn get_configs_dir() -> PathBuf {
let mut p = dirs::home_dir().unwrap();
p.push(".lyrica");
p
}
#[derive(Debug, Clone)]
#[derive(Eq, Hash, PartialEq)]
enum AppState {
IPodWait,
MainScreen(crate::screen::MainScreen)
MainScreen,
LoadingScreen,
FileSystem,
}
enum AppEvent {
SearchIPod,
IPodFound(String),
IPodNotFound,
ParseItunes(String),
ITunesParsed(XDatabase),
SoundcloudGot(CloudPlaylists)
}
fn initialize_async_service(sender: Sender<AppEvent>, receiver: UnboundedReceiver<AppEvent>, token: CancellationToken) {
tokio::spawn(async move {
let mut receiver = receiver;
loop {
tokio::select! {
_ = token.cancelled() => { return; }
r = receiver.recv() => {
if let Some(request) = r {
match request {
AppEvent::SearchIPod => {
/*if let Some(p) = util::search_ipod() {
let _ = sender.send(AppEvent::IPodFound(p)).await;
} else {
let _ = sender.send(AppEvent::IPodNotFound).await;
}*/
let _ = sender.send(AppEvent::IPodFound("D:\\Documents\\RustroverProjects\\itunesdb\\ITunesDB\\two_tracks".to_string())).await;
},
AppEvent::ParseItunes(path) => {
// todo: parse itunes
let _ = std::fs::create_dir_all(get_configs_dir());
let mut cd = get_configs_dir();
cd.push("idb");
let mut p: PathBuf = Path::new(&path).into();
// p.push("iPod_Control");
// p.push("iTunes");
// p.set_file_name("iTunesDB");
let _ = std::fs::copy(p, &cd);
let mut file = File::open(cd).await.unwrap();
let mut contents = vec![];
file.read_to_end(&mut contents).await.unwrap();
let xdb = itunesdb::deserializer::parse_bytes(&contents);
let _ = sender.send(AppEvent::ITunesParsed(xdb)).await;
let mut p = get_configs_dir();
p.push("config");
p.set_extension(".toml");
if !p.exists() { return; }
let mut file = File::open(p).await.unwrap();
let mut content = String::new();
file.read_to_string(&mut content).await.unwrap();
let config: LyricaConfiguration = toml::from_str(&content).unwrap();
let app_version = soundcloud::get_app().await.unwrap().unwrap();
let client_id = soundcloud::get_client_id().await.unwrap().unwrap();
let playlists = soundcloud::get_playlists(config.get_soundcloud().user_id, client_id, app_version).await.unwrap();
let _ = sender.send(AppEvent::SoundcloudGot(playlists)).await;
},
_ => {}
}
}
}
}
}
});
}
#[derive(Debug)]
pub struct App {
state: AppState,
screens: HashMap<AppState, Box<dyn AppScreen>>,
receiver: Receiver<AppEvent>,
sender: UnboundedSender<AppEvent>,
token: CancellationToken,
@ -105,140 +52,133 @@ pub struct App {
impl Default for App {
fn default() -> Self {
let (tx, mut rx) = mpsc::channel(1);
let (jx, mut jr) = mpsc::unbounded_channel();
let (tx, rx) = mpsc::channel(10);
let (jx, jr) = mpsc::unbounded_channel();
let token = CancellationToken::new();
initialize_async_service(tx, jr, token.clone());
sync::initialize_async_service(tx, jr, token.clone());
let _ = jx.send(AppEvent::SearchIPod);
Self { state: AppState::IPodWait, receiver: rx, sender: jx, token }
let mut screens: HashMap<AppState, Box<dyn AppScreen>> = HashMap::new();
screens.insert(AppState::IPodWait, Box::new(WaitScreen::default()));
screens.insert(AppState::MainScreen, Box::new(MainScreen::new(jx.clone())));
screens.insert(AppState::LoadingScreen, Box::new(LoadingScreen::default()));
screens.insert(AppState::FileSystem, Box::new(FileSystem::new(jx.clone())));
Self {
receiver: rx,
sender: jx,
token,
state: AppState::IPodWait,
screens,
}
}
}
impl App {
pub fn run<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> io::Result<()> {
pub async fn run<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> io::Result<()> {
let mut reader = EventStream::new();
while !self.token.is_cancelled() {
let _ = self.handle_events(&mut reader).await;
terminal.draw(|frame| self.draw(frame))?;
self.handle_events()?;
}
Ok(())
}
fn draw(&self, frame: &mut Frame) {
frame.render_widget(self.state.clone(), frame.area());
fn draw(&mut self, frame: &mut Frame) {
self.screens.get(&self.state).unwrap().render(frame);
}
fn handle_events(&mut self) -> io::Result<()> {
match event::read()? {
Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
self.handle_key_event(key_event)
}
_ => {}
};
if let Ok(event) = self.receiver.try_recv() {
match event {
AppEvent::IPodFound(path) => {
self.state = AppState::MainScreen(MainScreen::new());
let _ = self.sender.send(AppEvent::ParseItunes(path));
},
AppEvent::IPodNotFound => {
let _ = self.sender.send(AppEvent::SearchIPod);
},
AppEvent::ITunesParsed(xdb) => {
},
AppEvent::SoundcloudGot(playlists) => {
if let AppState::MainScreen(screen) = &self.state {
let mut screen = screen.clone();
screen.soundcloud = Some(playlists);
self.state = AppState::MainScreen(screen);
async fn handle_events(&mut self, reader: &mut EventStream) {
tokio::select! {
Some(Ok(event)) = reader.next() => {
match event {
Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
self.handle_key_event(key_event);
}
_ => {}
}
_ => {}
},
Some(event) = self.receiver.recv() => {
match event {
AppEvent::IPodNotFound => {
let _ = self.sender.send(AppEvent::SearchIPod);
},
AppEvent::ITunesParsed(playlists) => {
let screen: &mut MainScreen = self.get_screen(&AppState::MainScreen);
screen.set_itunes(playlists);
},
AppEvent::SoundcloudGot(playlists) => {
let screen: &mut MainScreen = self.get_screen(&AppState::MainScreen);
screen.set_soundcloud_playlists(playlists);
},
AppEvent::YoutubeGot(playlists) => {
let screen: &mut MainScreen = self.get_screen(&AppState::MainScreen);
screen.set_youtube_playlists(playlists);
},
AppEvent::OverallProgress((c, max, color)) => {
self.state = AppState::LoadingScreen;
let screen: &mut LoadingScreen = self.get_screen(&AppState::LoadingScreen);
screen.progress = Some((c, max, color));
screen.artwork_progress = None;
},
AppEvent::CurrentProgress(progress) => {
let screen: &mut LoadingScreen = self.get_screen(&AppState::LoadingScreen);
screen.artwork_progress = None;
screen.s_progress = Some(progress);
},
AppEvent::ArtworkProgress((c, max)) => {
let screen: &mut LoadingScreen = self.get_screen(&AppState::LoadingScreen);
screen.artwork_progress = Some((c, max));
screen.s_progress = None;
},
AppEvent::SwitchScreen(screen) => {
self.state = screen;
}
_ => {}
}
}
_ = tokio::time::sleep(Duration::from_millis(200)) => {
}
}
Ok(())
}
fn handle_key_event(&mut self, key_event: KeyEvent) {
if let AppState::MainScreen(screen) = &self.state {
let mut screen = screen.clone();
screen.handle_key_event(key_event);
self.state = AppState::MainScreen(screen);
}
match key_event.code {
KeyCode::Char('q') => self.exit(),
_ => {}
self.screens
.get_mut(&self.state)
.unwrap()
.handle_key_event(key_event);
if let KeyCode::Char('q') = key_event.code {
self.exit()
}
}
fn exit(&mut self) {
self.token.cancel();
}
}
impl AppState {
fn render_main_screen(area: Rect, buf: &mut Buffer, screen: &mut MainScreen) {
let vertical = Layout::vertical([Length(1), Min(0), Length(1)]);
let [header_area, inner_area, footer_area] = vertical.areas(area);
let horizontal = Layout::horizontal([Min(0), Length(7)]);
let [tabs_area, title_area] = horizontal.areas(header_area);
MainScreen::render_title(title_area, buf);
screen.render_tabs(tabs_area, buf);
screen.selected_tab.render(inner_area, buf);
MainScreen::render_footer(footer_area, buf);
}
fn render_waiting_screen(area: Rect, buf: &mut Buffer) {
let title = Line::from(" Lyrica ".bold());
let instructions = Line::from(vec![
" Quit ".into(),
"<Q> ".red().bold(),
]);
let block = Block::bordered()
.title(title.centered())
.title_bottom(instructions.centered())
.border_set(border::ROUNDED);
let counter_text = Text::from(
vec![
Line::from(
vec![
"Searching for iPod...".into()
]
)
]
);
Paragraph::new(counter_text)
.centered()
.block(block)
.render(area, buf);
}
}
impl Widget for AppState {
fn render(self, area: Rect, buf: &mut Buffer) {
match self {
AppState::IPodWait => AppState::render_waiting_screen(area, buf),
AppState::MainScreen(mut s) => AppState::render_main_screen(area, buf, &mut s),
_ => {}
}
fn get_screen<T>(&mut self, state: &AppState) -> &mut T
where
T: 'static + AppScreen,
{
let a = self.screens.get_mut(state).unwrap();
a.as_any().downcast_mut::<T>().unwrap()
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
enable_raw_mode()?;
let mut stderr = io::stderr(); // This is a special case. Normally using stdout is fine
let mut stderr = io::stdout();
execute!(stderr, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stderr);
let mut terminal = Terminal::new(backend)?;
// create app and run it
let mut app = App::default();
app.run(&mut terminal);
let _ = app.run(&mut terminal).await;
// restore terminal
disable_raw_mode()?;
@ -250,4 +190,4 @@ async fn main() -> Result<(), Box<dyn Error>> {
terminal.show_cursor()?;
Ok(())
}
}

525
src/main_screen.rs Normal file
View File

@ -0,0 +1,525 @@
use chrono::{DateTime, TimeZone, Utc};
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style, Stylize},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Tabs},
Frame,
};
use soundcloud::sobjects::{CloudPlaylist, CloudPlaylists};
use tokio::sync::mpsc::UnboundedSender;
use crate::component::table::SmartTable;
use crate::sync::{DBPlaylist, YTPlaylist};
use crate::{screen::AppScreen, sync::AppEvent, AppState};
pub struct MainScreen {
mode: bool,
selected_tab: i8,
pl_table: SmartTable,
song_table: SmartTable,
tab_titles: Vec<String>,
youtube: Option<Vec<YTPlaylist>>,
soundcloud: Option<Vec<CloudPlaylist>>,
playlists: Option<Vec<DBPlaylist>>,
sender: UnboundedSender<AppEvent>,
}
impl AppScreen for MainScreen {
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event.code {
KeyCode::Right => self.next_tab(),
KeyCode::Left => self.previous_tab(),
KeyCode::Up => self.previous_row(),
KeyCode::Down => self.next_row(),
KeyCode::F(5) => self.download_row(),
KeyCode::F(8) => self.remove_row(),
KeyCode::F(9) => self.remove_completely(),
KeyCode::Tab => self.switch_mode(),
KeyCode::F(4) => {
let _ = self
.sender
.send(AppEvent::SwitchScreen(AppState::FileSystem));
}
_ => {}
}
}
fn render(&self, frame: &mut Frame) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Tabs
Constraint::Min(0), // Main content area
Constraint::Length(1), // Status bar
])
.split(frame.area());
let tabs = Tabs::new(
self.tab_titles
.iter()
.map(|t| Span::raw(t.clone()))
.collect::<Vec<Span>>(),
)
.block(Block::default().borders(Borders::ALL))
.highlight_style(
Style::default()
.fg(Color::LightBlue)
.add_modifier(Modifier::BOLD),
)
.select(self.selected_tab as usize)
.style(Style::default().fg(Color::Black));
frame.render_widget(tabs, chunks[0]);
self.render_tab(frame, chunks[1]);
// Render Status Bar
let status_bar = Paragraph::new(Line::from(vec![
"<TAB> SWITCH PANEL".bold(),
" | ".dark_gray(),
"<F4> FS MODE".bold(),
" | ".dark_gray(),
"<F5> DOWNLOAD".bold(),
" | ".dark_gray(),
"<F8> DEL".bold(),
" | ".dark_gray(),
"<F9> DEL REC".bold(),
" | ".dark_gray(),
"<Q> QUIT".bold(),
]))
.centered();
frame.render_widget(status_bar, chunks[2]);
}
fn as_any(&mut self) -> &mut dyn std::any::Any {
self
}
}
impl MainScreen {
pub fn new(sender: UnboundedSender<AppEvent>) -> Self {
MainScreen {
mode: false,
pl_table: SmartTable::default(),
song_table: SmartTable::default(),
soundcloud: None,
youtube: None,
playlists: None,
selected_tab: 0,
tab_titles: vec![
"YouTube".to_string(),
"SoundCloud".to_string(),
"iPod".to_string(),
"Settings".to_string(),
],
sender,
}
}
fn switch_mode(&mut self) {
self.set_mode(!self.mode);
}
fn set_mode(&mut self, mode: bool) {
self.mode = mode;
self.pl_table.set_checked(!self.mode);
self.song_table.set_checked(self.mode);
}
fn next_tab(&mut self) {
self.selected_tab = std::cmp::min(
self.selected_tab + 1,
(self.tab_titles.len() - 1).try_into().unwrap(),
);
self.update_tables();
}
fn previous_tab(&mut self) {
self.selected_tab = std::cmp::max(0, self.selected_tab - 1);
self.update_tables();
}
fn previous_row(&mut self) {
match self.mode {
true => self.song_table.previous_row(),
false => {
self.pl_table.previous_row();
self.update_songs();
}
}
}
fn next_row(&mut self) {
match self.mode {
true => self.song_table.next_row(),
false => {
self.pl_table.next_row();
self.update_songs();
}
}
}
fn remove_row(&mut self) {
if self.selected_tab != 2 {
return;
}
let pl_id = self
.playlists
.as_ref()
.unwrap()
.get(self.pl_table.selected_row())
.unwrap()
.id;
match self.mode {
false => {
let _ = self.sender.send(AppEvent::RemovePlaylist((pl_id, false)));
}
true => {
let track_id = self
.playlists
.as_ref()
.unwrap()
.get(self.pl_table.selected_row())
.unwrap()
.tracks
.get(self.song_table.selected_row())
.unwrap()
.data
.unique_id;
let _ = self
.sender
.send(AppEvent::RemoveTrackFromPlaylist((track_id, pl_id)));
}
}
}
fn remove_completely(&mut self) {
if self.selected_tab != 2 {
return;
}
match self.mode {
false => {
let pl_id = self
.playlists
.as_ref()
.unwrap()
.get(self.pl_table.selected_row())
.unwrap()
.id;
let _ = self.sender.send(AppEvent::RemovePlaylist((pl_id, true)));
}
true => {
let track = self
.playlists
.as_ref()
.unwrap()
.get(self.pl_table.selected_row())
.unwrap()
.tracks
.get(self.song_table.selected_row())
.unwrap()
.clone();
let _ = self
.sender
.send(AppEvent::RemoveTrack(track.data.unique_id));
}
}
}
fn download_row(&mut self) {
match self.selected_tab {
0 => {
// YT
match self.mode {
false => {
let playlist = self
.youtube
.as_ref()
.unwrap()
.get(self.pl_table.selected_row())
.unwrap()
.clone();
let _ = self.sender.send(AppEvent::DownloadYTPlaylist(playlist));
}
true => {
let track = self
.youtube
.as_ref()
.unwrap()
.get(self.pl_table.selected_row())
.unwrap()
.videos
.get(self.song_table.selected_row())
.unwrap()
.clone();
let _ = self.sender.send(AppEvent::DownloadYTTrack(track));
}
}
}
1 => {
// SC
match self.mode {
false => {
let playlist = self
.soundcloud
.as_ref()
.unwrap()
.get(self.pl_table.selected_row())
.unwrap()
.clone();
let _ = self.sender.send(AppEvent::DownloadPlaylist(playlist));
}
true => {
let track = self
.soundcloud
.as_ref()
.unwrap()
.get(self.pl_table.selected_row())
.unwrap()
.tracks
.get(self.song_table.selected_row())
.unwrap()
.clone();
let _ = self.sender.send(AppEvent::DownloadTrack(track));
}
}
}
_ => {}
}
}
pub fn set_youtube_playlists(&mut self, pls: Vec<YTPlaylist>) {
self.youtube = Some(pls);
if self.selected_tab == 0 {
self.update_tables();
}
}
pub fn set_soundcloud_playlists(&mut self, pl: CloudPlaylists) {
self.soundcloud = Some(pl.collection);
if self.selected_tab == 1 {
self.update_tables();
}
}
pub fn set_itunes(&mut self, pl: Vec<DBPlaylist>) {
self.playlists = Some(pl);
if self.selected_tab == 2 {
self.update_tables();
}
}
fn update_tables(&mut self) {
self.set_mode(false);
self.pl_table = SmartTable::new(
["Id", "Title", "Songs Count", "Date", "IS"]
.iter_mut()
.map(|s| s.to_string())
.collect(),
[
Constraint::Length(3), // ID column
Constraint::Percentage(50), // Playlist name column
Constraint::Percentage(20), // Song count column
Constraint::Percentage(30),
Constraint::Length(2),
]
.to_vec(),
);
let data = match self.selected_tab {
0 => {
if let Some(yt) = &self.youtube {
yt.iter()
.map(|playlist| {
vec![
0.to_string(),
playlist.title.clone(),
[playlist.videos.len().to_string(), " songs".to_string()].concat(),
String::new(),
"NO".to_string(),
]
})
.collect::<Vec<Vec<String>>>()
} else {
Vec::new()
}
}
1 => {
if let Some(sc) = &self.soundcloud {
sc.iter()
.map(|playlist| {
let date: DateTime<Utc> = playlist.created_at.parse().unwrap();
vec![
playlist.id.to_string(),
playlist.title.clone(),
[playlist.track_count.to_string(), " songs".to_string()].concat(),
format!("{}", date.format("%Y-%m-%d %H:%M")),
"NO".to_string(),
]
})
.collect::<Vec<Vec<String>>>()
} else {
Vec::new()
}
}
2 => {
if let Some(it) = &self.playlists {
it.iter()
.map(|playlist| {
let date = Utc.timestamp_millis_opt(playlist.timestamp as i64).unwrap();
vec![
playlist.id.to_string(),
playlist.title.clone(),
playlist.tracks.len().to_string(),
format!("{}", date.format("%Y-%m-%d %H:%M")),
"YES".to_string(),
]
})
.collect::<Vec<Vec<String>>>()
} else {
Vec::new()
}
}
_ => {
self.pl_table = SmartTable::default();
Vec::new()
}
};
self.pl_table.set_data(data);
self.pl_table.set_title("Playlists".to_string());
self.update_songs();
}
fn update_songs(&mut self) {
let constraints = [
Constraint::Length(3), // ID column
Constraint::Percentage(50), // Playlist name column
Constraint::Percentage(20), // Song count column
Constraint::Length(5),
Constraint::Min(0),
]
.to_vec();
match self.selected_tab {
0 => {
self.song_table = SmartTable::new(
["Id", "Title", "Artist", "Duration", ""]
.iter_mut()
.map(|s| s.to_string())
.collect(),
constraints,
);
self.set_mode(self.mode);
if let Some(pls) = &self.youtube {
if let Some(ypl) = &pls.get(self.pl_table.selected_row()) {
let y = ypl.videos.clone();
let data = y
.iter()
.map(|video| {
vec![
video.videoId.clone(),
video.title.clone(),
video.publisher.clone(),
video.lengthSeconds.to_string(),
String::new(),
]
})
.collect::<Vec<Vec<String>>>();
self.song_table.set_data(data);
}
}
self.song_table.set_title(" Songs ".to_string());
}
1 => {
self.song_table = SmartTable::new(
["Id", "Title", "Artist", "Duration", "Genre"]
.iter_mut()
.map(|s| s.to_string())
.collect(),
constraints,
);
self.set_mode(self.mode);
if let Some(pls) = &self.soundcloud {
let s = &pls.get(self.pl_table.selected_row()).unwrap().tracks;
let data = s
.iter()
.map(|track| {
vec![
track.id.to_string(),
track.title.as_deref().unwrap().to_string(),
track
.user
.clone()
.unwrap()
.username
.unwrap_or(track.user.as_ref().unwrap().permalink.clone()),
track.duration.unwrap_or(0).to_string(),
track.genre.as_ref().unwrap_or(&String::new()).to_string(),
]
})
.collect::<Vec<Vec<String>>>();
self.song_table.set_data(data);
}
self.song_table.set_title(" Songs ".to_string());
}
2 => {
self.song_table = SmartTable::new(
["Id", "Title", "Artist", "Bitrate", "Genre"]
.iter_mut()
.map(|s| s.to_string())
.collect(),
constraints,
);
self.set_mode(self.mode);
if let Some(pls) = &self.playlists {
let s = &pls.get(self.pl_table.selected_row()).unwrap().tracks;
let data = s
.iter()
.map(|track| {
vec![
track.data.unique_id.to_string(),
track.get_title(),
track.get_artist(),
track.data.bitrate.to_string(),
track.get_genre(),
]
})
.collect::<Vec<Vec<String>>>();
self.song_table.set_data(data);
}
self.song_table.set_title(" Songs ".to_string());
}
_ => {
self.song_table = SmartTable::default();
self.set_mode(self.mode);
}
}
}
fn render_tab(&self, frame: &mut Frame, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(30), // Playlists
Constraint::Min(0), // Tracks
])
.split(area);
self.pl_table.render(frame, chunks[0]);
self.song_table.render(frame, chunks[1]);
}
}

View File

@ -1,56 +1,12 @@
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{buffer::Buffer, layout::Rect, style::{Color, Stylize}, text::Line, widgets::{Tabs, Widget}};
use soundcloud::sobjects::CloudPlaylists;
use strum::IntoEnumIterator;
use std::any::Any;
use crate::tabs::SelectedTab;
use crossterm::event::KeyEvent;
use ratatui::Frame;
#[derive(Debug, Clone)]
pub struct MainScreen {
pub selected_tab: SelectedTab,
pub soundcloud: Option<CloudPlaylists>
pub trait AppScreen {
fn handle_key_event(&mut self, key_event: KeyEvent);
fn render(&self, frame: &mut Frame);
fn as_any(&mut self) -> &mut dyn Any;
}
impl MainScreen {
pub fn new() -> Self {
MainScreen { selected_tab: SelectedTab::Playlists, soundcloud: None }
}
pub fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event.code {
KeyCode::Char('l') | KeyCode::Right => self.next_tab(),
KeyCode::Char('h') | KeyCode::Left => self.previous_tab(),
_ => {}
}
}
pub fn render_title(area: Rect, buf: &mut Buffer) {
"Lyrica".bold().render(area, buf);
}
pub fn render_footer(area: Rect, buf: &mut Buffer) {
Line::raw("◄ ► to change tab | <Q> to quit")
.centered()
.render(area, buf);
}
pub fn render_tabs(&self, area: Rect, buf: &mut Buffer) {
let titles = SelectedTab::iter().map(SelectedTab::title);
let highlight_style = (Color::default(), self.selected_tab.palette().c700);
let selected_tab_index = self.selected_tab.to_usize();
Tabs::new(titles)
.highlight_style(highlight_style)
.select(selected_tab_index)
.padding("", "")
.divider(" ")
.render(area, buf);
}
fn next_tab(&mut self) {
self.selected_tab = self.selected_tab.next();
}
fn previous_tab(&mut self) {
self.selected_tab = self.selected_tab.previous();
}
}

1095
src/sync.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,97 +0,0 @@
use ratatui::{buffer::Buffer, layout::Rect, style::{palette::tailwind, Stylize}, symbols, text::Line, widgets::{Block, Padding, Paragraph, Widget}};
use soundcloud::sobjects::CloudPlaylists;
use strum::{AsRefStr, Display, EnumIter, FromRepr, IntoEnumIterator};
use crate::screen::MainScreen;
#[derive(Debug, Default, Clone, Display, FromRepr, EnumIter, AsRefStr)]
pub enum SelectedTab {
#[default]
#[strum(to_string = "Playlists")]
Playlists,
#[strum(to_string = "Albums")]
Albums,
#[strum(to_string = "Soundcloud")]
Soundcloud(Option<CloudPlaylists>),
#[strum(to_string = "Youtube")]
Youtube,
}
impl Widget for SelectedTab {
fn render(self, area: Rect, buf: &mut Buffer) {
let block = self.block();
match self {
Self::Albums => self.render_albums(area, buf),
Self::Playlists => self.render_playlists(area, buf),
Self::Soundcloud(playlists) => SelectedTab::render_soundcloud(block,area, buf, playlists),
Self::Youtube => self.render_youtube(area, buf),
}
}
}
impl SelectedTab {
/// Return tab's name as a styled `Line`
pub fn title(self) -> Line<'static> {
format!(" {self} ")
.fg(tailwind::SLATE.c200)
.bg(self.palette().c900)
.into()
}
fn render_albums(self, area: Rect, buf: &mut Buffer) {
Paragraph::new("Hello, World!")
.block(self.block())
.render(area, buf);
}
fn render_playlists(self, area: Rect, buf: &mut Buffer) {
Paragraph::new("Welcome to the Ratatui tabs example!")
.block(self.block())
.render(area, buf);
}
fn render_soundcloud(block: Block<'static>, area: Rect, buf: &mut Buffer, playlists: Option<CloudPlaylists>) {
Paragraph::new("Your playlists from soundcloud:")
.block(block)
.render(area, buf);
}
fn render_youtube(self, area: Rect, buf: &mut Buffer) {
Paragraph::new("I know, these are some basic changes. But I think you got the main idea.")
.block(self.block())
.render(area, buf);
}
/// A block surrounding the tab's content
fn block(&self) -> Block<'static> {
Block::bordered()
.border_set(symbols::border::THICK)
.padding(Padding::horizontal(1))
.border_style(self.palette().c700)
}
pub fn palette(&self) -> tailwind::Palette {
match self {
Self::Albums => tailwind::INDIGO,
Self::Playlists => tailwind::EMERALD,
Self::Soundcloud(_) => tailwind::ORANGE,
Self::Youtube => tailwind::RED,
}
}
pub fn previous(self) -> Self {
let current_index = self.clone().to_usize();
let previous_index = current_index.saturating_sub(1);
Self::from_repr(previous_index).unwrap_or(self)
}
pub fn next(self) -> Self {
let current_index = self.clone().to_usize();
let next_index = current_index.saturating_add(1);
Self::from_repr(next_index).unwrap_or(self)
}
pub fn to_usize(self) -> usize {
SelectedTab::iter().enumerate().find(|(_i, el)| el.as_ref() == self.as_ref()).unwrap().0
}
}

View File

@ -1,5 +1,9 @@
use std::{str, process::Command, error::Error, str::FromStr};
use image::DynamicImage;
use regex::Regex;
use std::io::Write;
use std::path::PathBuf;
use std::{error::Error, process::Command, str, str::FromStr};
use twox_hash::XxHash3_64;
const VENDOR_ID: u16 = 1452;
const PRODUCT_ID: u16 = 4617;
@ -8,7 +12,7 @@ pub fn search_ipod() -> Option<String> {
for device in rusb::devices().unwrap().iter() {
let device_desc = device.device_descriptor().unwrap();
if VENDOR_ID == device_desc.vendor_id() && PRODUCT_ID == device_desc.product_id() {
return get_ipod_path()
return get_ipod_path();
}
}
None
@ -18,28 +22,33 @@ fn list() -> Result<Vec<String>, Box<dyn Error>> {
let mut disks = Vec::new();
let r = match Command::new("diskutil").arg("list").output() {
Ok(s) => s,
Err(e) => return Err(Box::new(e))
Err(e) => return Err(Box::new(e)),
};
if !r.status.success() { return Ok(disks); }
if !r.status.success() {
return Ok(disks);
}
let rg = Regex::new(r"\d:.+ [a-zA-Z0-9].+").unwrap();
let a = match str::from_utf8(&r.stdout) {
Ok(r) => r,
Err(e) => return Err(Box::new(e))
Err(e) => return Err(Box::new(e)),
};
for cap in Regex::new(r"\/dev\/.+\(external\, physical\):").unwrap().find_iter(a) {
for cap in Regex::new(r"\/dev\/.+\(external\, physical\):")
.unwrap()
.find_iter(a)
{
let mut b = &a[cap.end()..];
let i = match b.find("\n\n") {
Some(r) => r,
None => return Ok(disks)
None => return Ok(disks),
};
b = &b[..i];
for gap in rg.find_iter(b) {
let j = match gap.as_str().rfind(" ") {
Some(r) => r + 1,
None => return Ok(disks)
None => return Ok(disks),
};
let g= &gap.as_str()[j..];
let g = &gap.as_str()[j..];
disks.push(String::from_str(g).unwrap());
}
}
@ -49,18 +58,20 @@ fn list() -> Result<Vec<String>, Box<dyn Error>> {
fn is_ipod(name: &str) -> bool {
let r = match Command::new("diskutil").arg("info").arg(name).output() {
Ok(s) => s,
Err(_e) => return false
Err(_e) => return false,
};
if !r.status.success() { return false; }
if !r.status.success() {
return false;
}
let a = match str::from_utf8(&r.stdout) {
Ok(r) => r,
Err(_e) => return false
Err(_e) => return false,
};
let cap = Regex::new(r"Media Type:.+\n").unwrap().find(a);
if let Some(g) = cap {
let mut b = g.as_str();
let f = b.rfind(" ").unwrap() + 1;
b = &b[f..b.len()-1];
b = &b[f..b.len() - 1];
return b == "iPod";
}
false
@ -69,32 +80,94 @@ fn is_ipod(name: &str) -> bool {
fn get_mount_point(name: &str) -> Option<String> {
let r = match Command::new("diskutil").arg("info").arg(name).output() {
Ok(s) => s,
Err(_e) => return None
Err(_e) => return None,
};
if !r.status.success() { return None; }
if !r.status.success() {
return None;
}
let a = match str::from_utf8(&r.stdout) {
Ok(r) => r,
Err(_e) => return None
Err(_e) => return None,
};
let cap = Regex::new(r"Mount Point:.+\n").unwrap().find(a);
match cap {
Some(g) => {
let i = g.as_str();
let j = i.rfind(" ").unwrap() + 1;
Some(i[j..i.len()-1].to_string())
},
None => None
Some(i[j..i.len() - 1].to_string())
}
None => None,
}
}
fn get_ipod_path() -> Option<String> {
match list() {
Ok(l) => l.iter()
Ok(l) => l
.iter()
.filter(|d| is_ipod(d))
.map(|d| get_mount_point(d))
.filter(|d| d.is_some())
.map(|d| d.unwrap())
.filter_map(|d| get_mount_point(d))
.last(),
Err(_e) => None
Err(_e) => None,
}
}
}
// note: this hash function is used to make unique ids for each track. It doesn't aim to generate secure ones.
pub fn hash(data: &[u8]) -> u64 {
XxHash3_64::oneshot(data)
}
pub fn hash_from_path(path: PathBuf) -> u64 {
hash(&std::fs::read(path).unwrap())
}
pub struct IPodImage {
pixels: Vec<u16>,
}
impl IPodImage {
pub fn write(&self, p: PathBuf) {
let mut file = std::fs::File::create(p).unwrap();
let _ = file.write(&self.convert_to_u8());
}
fn convert_to_u8(&self) -> Vec<u8> {
self.pixels
.iter()
.flat_map(|f| [*f as u8, (*f >> 8) as u8])
.collect()
}
}
impl From<DynamicImage> for IPodImage {
fn from(value: DynamicImage) -> Self {
let img_rgba = value.to_rgba8();
let (width, height) = img_rgba.dimensions();
let mut rgb565_data: Vec<u16> = Vec::new();
for y in 0..height {
for x in 0..width {
let pixel = img_rgba.get_pixel(x, y).0;
let r = pixel[0];
let g = pixel[1];
let b = pixel[2];
rgb565_data.push(rgb_to_rgb565(r, g, b));
}
}
Self {
pixels: rgb565_data,
}
}
}
fn rgb_to_rgb565(r: u8, g: u8, b: u8) -> u16 {
let r_565 = (r >> 3) & 0x1F; // Extract top 5 bits
let g_565 = (g >> 2) & 0x3F; // Extract top 6 bits
let b_565 = (b >> 3) & 0x1F; // Extract top 5 bits
((r_565 as u16) << 11) | ((g_565 as u16) << 5) | (b_565 as u16) // Combine to RGB565
}

55
src/wait_screen.rs Normal file
View File

@ -0,0 +1,55 @@
use crate::screen::AppScreen;
use ratatui::layout::{Constraint, Direction, Flex, Layout};
use ratatui::widgets::Paragraph;
use ratatui::{
style::{Style, Stylize},
text::Line,
Frame,
};
use throbber_widgets_tui::{ThrobberState, BOX_DRAWING};
use tui_big_text::{BigText, PixelSize};
#[derive(Debug, Clone, Default)]
pub struct WaitScreen {}
impl AppScreen for WaitScreen {
fn handle_key_event(&mut self, _key_event: crossterm::event::KeyEvent) {}
fn render(&self, frame: &mut Frame) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(33); 3])
.split(frame.area());
let simple = throbber_widgets_tui::Throbber::default()
.label("Searching for your iPod")
.throbber_set(BOX_DRAWING);
let bottom =
Paragraph::new(vec![Line::from(vec![" Quit ".into(), "<Q> ".red().bold()])]).centered();
let bottom_l =
Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).split(chunks[2]);
let [throbber_l] = Layout::horizontal([Constraint::Length(
simple.to_line(&ThrobberState::default()).width() as u16,
)])
.flex(Flex::Center)
.areas(bottom_l[0]);
frame.render_widget(simple, throbber_l);
frame.render_widget(bottom, bottom_l[1]);
let title = BigText::builder()
.pixel_size(PixelSize::Full)
.style(Style::new().blue())
.lines(vec!["Lyrica".light_blue().into()])
.centered()
.build();
frame.render_widget(title, chunks[1]);
}
fn as_any(&mut self) -> &mut dyn std::any::Any {
self
}
}