use crate::config::DownloaderType;
use anyhow::{Context, bail};
use clap::{Args, CommandFactory, Parser, Subcommand};
use clap_complete::{Shell, generate};
use cli::handle_cli_command;
use config::{ApiKey, AuthType, Config};
use directories::ProjectDirs;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use ytmapi_rs::auth::OAuthToken;
mod api;
mod app;
mod appevent;
mod async_rodio_sink;
mod cli;
mod config;
mod core;
mod drawutils;
mod keyaction;
mod keybind;
mod widgets;
mod youtube_downloader;
#[cfg(test)]
mod tests;
pub const POTOKEN_FILENAME: &str = "po_token.txt";
pub const COOKIE_FILENAME: &str = "cookie.txt";
pub const OAUTH_FILENAME: &str = "oauth.json";
const BROWSER_AUTH_SETUP_STEPS_URL: &str =
"https://github.com/nick42d/youtui?tab=readme-ov-file#browser-auth-setup-steps";
const OAUTH_SETUP_STEPS_URL: &str =
"https://github.com/nick42d/youtui?tab=readme-ov-file#oauth-setup-steps-optional";
const POTOKEN_INFORMATION_URL: &str =
"https://github.com/nick42d/youtui?tab=readme-ov-file#po-token-information";
const RUNNING_YOUTUI_GUIDE_URL: &str =
"https://github.com/nick42d/youtui?tab=readme-ov-file#running-youtui";
const DIRECTORY_NAME_ERROR_MESSAGE: &str = "Error generating application directory for your host system. See README.md for more information about application directories.";
#[derive(Parser, Debug)]
#[command(author,version,about,long_about=None)]
struct Arguments {
#[arg(short, long, default_value_t = false)]
debug: bool,
#[arg(long, default_value_t = false)]
disable_media_controls: bool,
#[command(flatten)]
cli: Cli,
#[command(subcommand)]
auth_cmd: Option<AuthCmd>,
#[arg(short, long, id = "SHELL", value_enum)]
generate_completions: Option<Shell>,
#[arg(value_enum, short, long)]
auth_type: Option<AuthType>,
#[arg(value_enum, short = 'D', long)]
downloader_type: Option<DownloaderType>,
}
#[derive(Args, Debug, Clone)]
struct Cli {
#[arg(short, long, default_value_t = false)]
show_source: bool,
#[arg(short, long, id = "PATH")]
input_json: Option<Vec<PathBuf>>,
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand, Debug, Clone)]
enum AuthCmd {
SetupOauth {
#[arg(short, long)]
file_name: Option<PathBuf>,
#[arg(short, long, default_value_t = false)]
stdout: bool,
client_id: String,
client_secret: String,
},
}
#[derive(Subcommand, Debug, Clone)]
enum Command {
GetSearchSuggestions {
query: String,
},
GetArtist {
channel_id: String,
},
GetArtistAlbums {
channel_id: String,
browse_params: String,
},
SubscribeArtist {
channel_id: String,
},
UnsubscribeArtists {
channel_ids: Vec<String>,
},
GetAlbum {
browse_id: String,
},
GetPlaylistDetails {
playlist_id: String,
},
GetPlaylistTracks {
playlist_id: String,
#[arg(default_value_t = 1)]
max_pages: usize,
},
GetLibraryPlaylists {
#[arg(default_value_t = 1)]
max_pages: usize,
},
GetLibraryArtists {
#[arg(default_value_t = 1)]
max_pages: usize,
},
GetLibrarySongs {
#[arg(default_value_t = 1)]
max_pages: usize,
},
GetLibraryAlbums {
#[arg(default_value_t = 1)]
max_pages: usize,
},
GetLibraryArtistSubscriptions {
#[arg(default_value_t = 1)]
max_pages: usize,
},
GetLibraryPodcasts {
#[arg(default_value_t = 1)]
max_pages: usize,
},
GetLibraryChannels {
#[arg(default_value_t = 1)]
max_pages: usize,
},
Search {
query: String,
},
SearchArtists {
query: String,
#[arg(default_value_t = 1)]
max_pages: usize,
},
SearchAlbums {
query: String,
#[arg(default_value_t = 1)]
max_pages: usize,
},
SearchSongs {
query: String,
#[arg(default_value_t = 1)]
max_pages: usize,
},
SearchPlaylists {
query: String,
#[arg(default_value_t = 1)]
max_pages: usize,
},
SearchCommunityPlaylists {
query: String,
#[arg(default_value_t = 1)]
max_pages: usize,
},
SearchFeaturedPlaylists {
query: String,
#[arg(default_value_t = 1)]
max_pages: usize,
},
SearchVideos {
query: String,
#[arg(default_value_t = 1)]
max_pages: usize,
},
SearchEpisodes {
query: String,
#[arg(default_value_t = 1)]
max_pages: usize,
},
SearchProfiles {
query: String,
#[arg(default_value_t = 1)]
max_pages: usize,
},
SearchPodcasts {
query: String,
#[arg(default_value_t = 1)]
max_pages: usize,
},
CreatePlaylist {
title: String,
description: Option<String>,
},
DeletePlaylist {
playlist_id: String,
},
RemovePlaylistItems {
playlist_id: String,
video_ids: Vec<String>,
},
AddVideosToPlaylist {
playlist_id: String,
video_ids: Vec<String>,
},
AddPlaylistToPlaylist {
playlist_id: String,
from_playlist_id: String,
},
EditPlaylistTitle {
playlist_id: String,
new_title: String,
},
GetHistory,
RemoveHistoryItems {
feedback_tokens: Vec<String>,
},
RateSong {
video_id: String,
like_status: String,
},
RatePlaylist {
playlist_id: String,
like_status: String,
},
EditSongLibraryStatus {
feedback_tokens: Vec<String>,
},
GetLibraryUploadSongs {
#[arg(default_value_t = 1)]
max_pages: usize,
},
GetLibraryUploadArtists {
#[arg(default_value_t = 1)]
max_pages: usize,
},
GetLibraryUploadAlbums {
#[arg(default_value_t = 1)]
max_pages: usize,
},
GetLibraryUploadArtist {
upload_artist_id: String,
#[arg(default_value_t = 1)]
max_pages: usize,
},
GetLibraryUploadAlbum {
upload_album_id: String,
},
DeleteUploadEntity {
upload_entity_id: String,
},
GetTasteProfile,
SetTasteProfile {
impression_token: String,
selection_token: String,
},
GetMoodCategories,
GetMoodPlaylists {
mood_category_params: String,
},
AddHistoryItem {
song_tracking_url: String,
},
GetSongTrackingUrl {
video_id: String,
},
GetLyrics {
lyrics_id: String,
},
GetLyricsID {
video_id: String,
},
GetWatchPlaylist {
video_id: String,
#[arg(default_value_t = 1)]
max_pages: usize,
},
GetChannel {
channel_id: String,
},
GetChannelEpisodes {
channel_id: String,
podcast_channel_params: String,
},
GetPodcast {
podcast_id: String,
},
GetEpisode {
video_id: String,
},
GetNewEpisodes,
GetUser {
user_channel_id: String,
},
GetUserPlaylists {
user_channel_id: String,
browse_params: String,
},
GetUserVideos {
user_channel_id: String,
browse_params: String,
},
}
pub struct RuntimeInfo {
debug: bool,
disable_media_controls: bool,
config: Config,
api_key: ApiKey,
po_token: Option<String>,
}
#[tokio::main]
async fn main() -> ExitCode {
if let Err(e) = try_main().await {
println!("{e:?}");
return ExitCode::FAILURE;
};
ExitCode::SUCCESS
}
async fn try_main() -> anyhow::Result<()> {
let args = Arguments::parse();
let Arguments {
debug,
cli,
auth_cmd,
auth_type,
generate_completions,
downloader_type,
disable_media_controls,
} = args;
if let Some(c) = auth_cmd {
match c {
AuthCmd::SetupOauth {
file_name,
stdout,
client_id,
client_secret,
} => {
cli::get_and_output_oauth_token(file_name, stdout, client_id, client_secret).await?
}
};
return Ok(());
};
if let Some(shell) = generate_completions {
let mut cmd = Arguments::command();
let bin_name = cmd.get_name().to_string();
eprintln!("Generating completion file for {shell:?}");
generate(shell, &mut cmd, bin_name, &mut std::io::stdout());
return Ok(());
};
initialise_directories().await?;
let mut config = config::Config::new(debug).await?;
if let Some(auth_type) = auth_type {
config.auth_type = auth_type
}
if let Some(downloader_type) = downloader_type {
config.downloader_type = downloader_type
}
let api_key = load_api_key(&config).await?;
let po_token = load_po_token().await.ok();
let rt = RuntimeInfo {
debug,
config,
api_key,
po_token,
disable_media_controls,
};
match cli.command {
None => run_app(rt).await?,
Some(_) => handle_cli_command(cli, rt).await?,
};
Ok(())
}
async fn get_api(config: &Config) -> anyhow::Result<api::DynamicYtMusic> {
let confdir = get_config_dir()?;
let api = match config.auth_type {
config::AuthType::OAuth => {
let mut oauth_loc = confdir;
oauth_loc.push(OAUTH_FILENAME);
let file = tokio::fs::read_to_string(oauth_loc).await?;
let oath_tok: OAuthToken = serde_json::from_str(&file)?;
let mut api = ytmapi_rs::builder::YtMusicBuilder::new_rustls_tls()
.with_auth_token(oath_tok)
.build()?;
api.refresh_token().await?;
api::DynamicYtMusic::OAuth(api)
}
config::AuthType::Browser => {
let mut cookies_loc = confdir;
cookies_loc.push(COOKIE_FILENAME);
let api = ytmapi_rs::builder::YtMusicBuilder::new_rustls_tls()
.with_browser_token_cookie_file(cookies_loc)
.build()
.await?;
api::DynamicYtMusic::Browser(api)
}
config::AuthType::Unauthenticated => {
let api = ytmapi_rs::builder::YtMusicBuilder::new_rustls_tls()
.build()
.await?;
api::DynamicYtMusic::NoAuth(api)
}
};
Ok(api)
}
pub async fn run_app(rt: RuntimeInfo) -> anyhow::Result<()> {
let mut app = app::Youtui::new(rt).await?;
app.run().await?;
Ok(())
}
pub fn get_data_dir() -> anyhow::Result<PathBuf> {
let directory = if let Ok(s) = std::env::var("YOUTUI_DATA_DIR") {
PathBuf::from(s)
} else if let Some(proj_dirs) = ProjectDirs::from("com", "nick42", "youtui") {
proj_dirs.data_local_dir().to_path_buf()
} else {
bail!(DIRECTORY_NAME_ERROR_MESSAGE);
};
Ok(directory)
}
pub fn get_config_dir() -> anyhow::Result<PathBuf> {
let directory = if let Ok(s) = std::env::var("YOUTUI_CONFIG_DIR") {
PathBuf::from(s)
} else if let Some(proj_dirs) = ProjectDirs::from("com", "nick42", "youtui") {
proj_dirs.config_local_dir().to_path_buf()
} else {
bail!(DIRECTORY_NAME_ERROR_MESSAGE);
};
Ok(directory)
}
async fn load_po_token() -> anyhow::Result<String> {
let mut path = get_config_dir()?;
path.push(POTOKEN_FILENAME);
tokio::fs::read_to_string(&path)
.await
.map(|s| s.trim().to_string())
.with_context(|| {
format!(
"Error loading po_token from {}. Does the file exist? See README.md for more information on PO tokens: {}",
path.display(),
POTOKEN_INFORMATION_URL
)
})
}
async fn load_cookie_file() -> anyhow::Result<String> {
let mut path = get_config_dir()?;
path.push(COOKIE_FILENAME);
tokio::fs::read_to_string(&path)
.await
.with_context(|| auth_token_error_message(config::AuthType::Browser, &path))
}
async fn load_oauth_file() -> anyhow::Result<OAuthToken> {
let mut path = get_config_dir()?;
path.push(OAUTH_FILENAME);
let file = tokio::fs::read_to_string(&path)
.await
.with_context(|| auth_token_error_message(config::AuthType::OAuth, &path))?;
serde_json::from_str(&file)
.with_context(|| format!("Error parsing AuthType::OAuth auth token from {}. See README.md for more information on auth tokens: {}", path.display(), OAUTH_SETUP_STEPS_URL))
}
async fn initialise_directories() -> anyhow::Result<()> {
let config_dir = get_config_dir()?;
let data_dir = get_data_dir()?;
tokio::try_join!(
tokio::fs::create_dir_all(config_dir),
tokio::fs::create_dir_all(data_dir),
)?;
Ok(())
}
async fn load_api_key(cfg: &Config) -> anyhow::Result<ApiKey> {
let api_key = match cfg.auth_type {
config::AuthType::OAuth => ApiKey::OAuthToken(load_oauth_file().await?),
config::AuthType::Browser => ApiKey::BrowserToken(load_cookie_file().await?),
config::AuthType::Unauthenticated => ApiKey::None,
};
Ok(api_key)
}
fn auth_token_readme_link(token_type: config::AuthType) -> &'static str {
match token_type {
config::AuthType::OAuth => OAUTH_SETUP_STEPS_URL,
config::AuthType::Browser => BROWSER_AUTH_SETUP_STEPS_URL,
config::AuthType::Unauthenticated => RUNNING_YOUTUI_GUIDE_URL,
}
}
fn auth_token_error_message(token_type: config::AuthType, path: &Path) -> String {
format!(
"Error loading {:?} auth token from {}. Does the file exist? See README.md for more information: {}",
token_type,
path.display(),
auth_token_readme_link(token_type)
)
}