diff --git a/.gitignore b/.gitignore index 196e176..53798a7 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ Cargo.lock # Added by cargo /target +.idea \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 7435f63..aacb406 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tt" -version = "1.0.1" +version = "2.0.0" edition = "2021" license = "MIT" @@ -16,3 +16,4 @@ percent-encoding = "2.3.1" serde_json = "1.0.115" directories = "5.0.1" rand = "0.8.5" +clap = { version = "4.5.4", features = ["derive"] } diff --git a/src/main.rs b/src/main.rs index cdcbafb..b741cca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,13 +4,17 @@ //! * `api_key` //! gasのデプロイID -use chrono::{DateTime, FixedOffset, NaiveTime, Utc}; +use chrono::{DateTime, FixedOffset, NaiveTime, Timelike, Utc}; +use clap::Parser; use directories::ProjectDirs; use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; use rand::seq::SliceRandom; use rusqlite::{params, Connection, Result}; use serde_derive::{Deserialize, Serialize}; -use std::process::{Command, Stdio}; +use std::{ + process::{Command, Stdio}, + u32, +}; #[derive(Serialize, Deserialize)] struct MyConfig { @@ -19,7 +23,7 @@ struct MyConfig { } /// `MyConfig` implements `Default` -impl ::std::default::Default for MyConfig { +impl Default for MyConfig { fn default() -> Self { Self { api_key: "".into(), @@ -106,13 +110,19 @@ struct Request { /// 与えられた単語のリストからvideo_idを取得してmpvで再生します /// * `search_word_list` - 検索する単語のリスト -async fn play_music(search_word_list: [&String; 2]) { +async fn play_music(search_word_list: [&String; 2], mpv_arsg: &Option>) { println!("Playing {} {}", search_word_list[0], search_word_list[1]); let video_id = search_youtube(search_word_list).await; - + let mut mpv_options: Vec = vec![]; + if let Some(mo) = mpv_arsg { + mpv_options = mo.clone(); + } + // デフォルトのオプションを追加 + mpv_options.push("-fs".into()); + mpv_options.push(video_id.into()); match Command::new("mpv") - .args(["-fs", /* "--volume=50", */ &video_id]) + .args(mpv_options) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .output() @@ -125,8 +135,8 @@ async fn play_music(search_word_list: [&String; 2]) { impl Request { /// `play_music()`で再生します - async fn play(&self) { - play_music([&self.song_name, &self.artist_name]).await; + async fn play(&self, mpv_arsg: &Option>) { + play_music([&self.song_name, &self.artist_name], mpv_arsg).await; } fn set_as_played(&self, conn: &Connection) { @@ -231,7 +241,8 @@ fn comp_time(cfg: &MyConfig) -> bool { /// SQLiteから次の流すべきリクエストを判断し`play_song()`で再生 /// * `conn` SQLiteのコネクション -async fn play_next(conn: &Connection) { +/// * `mpv_arsg` MPVへのオプションのオプション +async fn play_next(conn: &Connection, mpv_arsg: &Option>) { // playedがfalseかつ、arrangeが最小(!unique) let mut stmt = conn .prepare("select id, song_name, artist_name from requests where played = 0 and arrange = (select MIN(arrange) from requests where played = 0)") @@ -258,20 +269,193 @@ async fn play_next(conn: &Connection) { let next = requests.choose(&mut rand::thread_rng()).unwrap(); - next.play().await; + next.play(mpv_arsg).await; next.set_as_played(&conn); } +/// ClI引数用のストラクト +#[derive(Parser, Debug)] +#[command(version)] +#[command(about = "A CLI tool for playing music automatically.", long_about = None)] +struct Args { + // "12:30"のように24hで指定する + #[arg( + short, + long, + help = "Specify the end time in 24-hour notation, separated by \":\", for example, \"12:30\" minutes. \nCan't be specified at the same time as the duration." + )] + #[arg(conflicts_with = "duration")] + end_time: Option, + // "1h2m3s"的な感じで指定する + #[arg( + short, + long, + help = "Specify by integer, separated by h, m, and s, as in \"1h2m3s\". Each can be omitted." + )] + duration: Option, + #[arg(last = true, help = "Arguments passed directly to mpv.")] + mpv_arsg: Option>, +} + +/// `12:30`の様に与えられたのを`[12, 30, 0]`の様にパースする +/// `26:40`の様に与えられた場合はパニック +fn parse_end_time(end_time: String) -> [u32; 3] { + let str_words: Vec<&str> = end_time.split(":").collect(); + + let u32_words = str_words + .iter() + .map(|s| s.parse::().unwrap()) + .collect::>(); + + // 正しい数じゃないか確認 + if (u32_words[0] >= 24) | (u32_words[1] >= 60) | (str_words.len() != 2) { + panic!("The end_time option was invalid."); + } + + [u32_words[0], u32_words[1], 0] +} + +/// `[12, 70, 0]`を`[13, 10, 0]`にmodする関数 +fn mod_time(time: [u32; 3]) -> [u32; 3] { + [ + (time[0] + time[1] / 60 + time[2] / 3600) % 24, + (time[1] + time[2] / 60) % 60, + time[2] % 60, + ] +} + +/// `1h2m30s`の様な入力を`[1, 2, 30]`の様に返す +/// それぞれ省略可能で例えば`1h30m`が`[1, 30, 0]` +/// 24時間以上はpanic +fn parse_duration_diff(duration: String) -> [u32; 3] { + let mut h_point = 0; + let mut m_point = 0; + let mut h = 0; + let mut m = 0; + let mut s = 0; + if duration.contains("h") { + h_point = duration.find("h").unwrap(); + h = String::from(&duration[0..h_point]) + .parse() + .expect("The duration option was invalid."); + h_point += 1; + } + if duration.contains("m") { + m_point = duration.find("m").unwrap(); + m = String::from(&duration[h_point..m_point]) + .parse() + .expect("The duration option was invalid."); + m_point += 1; + } else if duration.contains("h") { + m_point = h_point.clone(); + } + if duration.contains("s") { + s = String::from(&duration[m_point..duration.find("s").unwrap()]) + .parse() + .expect("The duration option was invalid."); + } + mod_time([h, m, s]) +} + +/// `parse_duration_diff()`を使用して現在時刻と足しあわせる関数 +fn parse_duration(duration: String) -> [u32; 3] { + let diff = parse_duration_diff(duration); + let now = Utc::now() + .with_timezone(&FixedOffset::east_opt(9 * 3600).unwrap()) + .time(); + mod_time([ + diff[0] + now.hour(), + diff[1] + now.minute(), + diff[2] + now.second(), + ]) +} + #[tokio::main] async fn main() -> Result<(), confy::ConfyError> { - let cfg: MyConfig = confy::load("tt", "tt")?; + let mut cfg: MyConfig = confy::load("tt", "tt")?; + let args = Args::parse(); + // argsをcfgに反映 + if let Some(end_time) = args.end_time { + cfg.end_time = parse_end_time(end_time); + } else if let Some(duration) = args.duration { + cfg.end_time = parse_duration(duration); + } let conn = init_sqlite().unwrap(); sync_backend(&cfg, &conn).await.unwrap(); while comp_time(&cfg) { - play_next(&conn).await; + play_next(&conn, &args.mpv_arsg).await; println!("Comp to time: {}", comp_time(&cfg)); } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + /// 正常な入力をパース出来るか + #[test] + fn check_parse_end_time() { + assert_eq!([12, 30, 0], parse_end_time("12:30".into())); + assert_eq!([6, 6, 0], parse_end_time("6:6".into())); + assert_eq!([13, 5, 0], parse_end_time("13:05".into())); + } + + #[test] + #[should_panic] + fn check_parse_end_time_invaild_h() { + parse_end_time("100:0".into()); + } + #[test] + #[should_panic] + fn check_parse_end_time_invaild_m() { + parse_end_time("0:100".into()); + } + #[test] + #[should_panic] + fn check_parse_end_time_invaild_length() { + parse_end_time("0:0:0".into()); + } + #[test] + #[should_panic] + fn check_parse_end_time_invaild_h_minus() { + parse_end_time("-1:0".into()); + } + + #[test] + fn check_mod_time() { + assert_eq!([2, 0, 0], mod_time([1, 60, 0])); + assert_eq!([1, 0, 0], mod_time([0, 0, 3600])); + } + #[test] + fn check_parse_duration() { + assert_eq!([1, 2, 3], parse_duration_diff("1h2m3s".into())); + assert_eq!([1, 2, 0], parse_duration_diff("1h2m".into())); + assert_eq!([0, 2, 3], parse_duration_diff("2m3s".into())); + assert_eq!([2, 2, 0], parse_duration_diff("1h62m".into())); + assert_eq!([1, 0, 3], parse_duration_diff("1h3s".into())); + } + /// テスト用のインメモリSQLiteのコネクションを作成 + fn create_sqlite_conn() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute( + " + CREATE TABLE requests( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL, + song_name TEXT NOT NULL, + artist_name TEXT NOT NULL, + played INTEGER NOT NULL, + uuid TEXT NOT NULL, + arrange INTEGER NOT NULL, + UNIQUE(uuid) + ); + ", + params![], + ) + .unwrap(); + conn + } +}