mod auth;
mod cdp;
mod client;
mod layout;
mod plugin;
mod protocol;
mod server;
mod timeparse;
use anyhow::{Result, anyhow};
use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand};
use serde_json::{Value, json};
use crate::layout::Layout;
use crate::protocol::{PlayerAction, Request, Switch, WindowOpts};
use crate::timeparse::Seek;
const ABOUT: &str = "Play videos and music (YouTube, Spotify, Netflix, Prime Video, ...) in windows and control them from the command line.";
const LONG_ABOUT: &str = "\
Play videos and music (YouTube, Spotify, Netflix, Prime Video, ...) in windows and control them from the command line.
Workflow:
1. `tvctl open <URL>` opens a player window and prints its `id`.
2. Control it with `tvctl <command> <ID> ...`; `tvctl list` shows open players.
3. `tvctl close <ID>`. A background server starts on demand and stops a few
seconds after the last player closes; no setup is needed.
Each service is a plugin. `tvctl plugins` shows, per plugin, the URL domains it
handles, its `actions` (supported player commands) and `unsupported_actions` per
backend. Unsupported commands fail with an error that lists the supported ones.";
const AFTER_LONG_HELP: &str = "\
Output:
Every successful command prints one JSON document to stdout and exits 0.
Player commands (open, status, play, seek, ...) print:
{\"id\": \"1\", \"service\": \"youtube\", \"backend\": \"webview\" | \"chrome\",
\"player\": {...}, \"window\": {...}}
`player` comes from the plugin. Common fields: `state` (\"playing\", \"paused\",
\"buffering\", \"ended\", \"unstarted\", \"cued\"), `title`, `current_time` and
`duration` (seconds), `volume` (0-100), `muted`, `playback_rate`, `subtitles`,
`quality`, `available_qualities`. Fields a plugin cannot report are null or absent.
`window`: `position` {x, y} and `outer_size` of the window frame, `size` of the
video area, `fullscreen`, `on_top`, `work_area` (usable screen area). Coordinates
are logical pixels from the top-left of the main screen.
Errors:
On failure, a message starting with `error:` is printed to stderr and the exit
code is 1 (no JSON on stdout).
Examples:
tvctl open 'https://www.youtube.com/watch?v=iG9CE55wbtY' --subtitles ja
tvctl open 'https://youtu.be/jNQXAC9IVRw' --layout bottom-right --size 480x270 --on-top
tvctl open 'https://open.spotify.com/album/5Z9iiGl2FcIfa3BMiv6OIw'
tvctl open 'https://www.netflix.com/watch/81716297' --subtitles ja
tvctl open 'https://www.amazon.com/gp/video/detail/B0GHXVBQLM' --subtitles ja
tvctl status 1 # read state, time, title, window
tvctl seek 1 +30 # relative; `seek 1 1:30` is absolute
tvctl subtitles 1 ja # Japanese subtitles (auto-translated if needed)
tvctl window 1 --layout left # snap to the left half of the screen
tvctl screenshot 1 /tmp/now.png # see what the window shows
tvctl auth login youtube # one-time login (needed for Spotify, Netflix, Prime Video, Crunchyroll)";
const ID: &str = "Player id, from the output of `open` or `list`";
#[derive(Parser)]
#[command(version, about = ABOUT, long_about = LONG_ABOUT, after_long_help = AFTER_LONG_HELP)]
struct Cli {
#[command(subcommand)]
command: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
Open {
url: String,
#[arg(long, value_enum)]
backend: Option<plugin::BackendKind>,
#[arg(long, value_name = "TIME")]
start: Option<String>,
#[arg(long)]
paused: bool,
#[arg(long, value_name = "LANG")]
subtitles: Option<String>,
#[command(flatten)]
window: GeometryArgs,
#[arg(long)]
fullscreen: bool,
#[arg(long)]
on_top: bool,
},
List,
Status {
#[arg(help = ID)]
id: String,
},
Play {
#[arg(help = ID)]
id: String,
},
Pause {
#[arg(help = ID)]
id: String,
},
Toggle {
#[arg(help = ID)]
id: String,
},
Next {
#[arg(help = ID)]
id: String,
},
Prev {
#[arg(help = ID)]
id: String,
},
Seek {
#[arg(help = ID)]
id: String,
#[arg(allow_hyphen_values = true)]
position: String,
},
Forward {
#[arg(help = ID)]
id: String,
#[arg(default_value = "10")]
duration: String,
},
Back {
#[arg(help = ID)]
id: String,
#[arg(default_value = "10")]
duration: String,
},
Subtitles {
#[arg(help = ID)]
id: String,
mode: String,
},
Audio {
#[arg(help = ID)]
id: String,
lang: String,
},
Volume {
#[arg(help = ID)]
id: String,
#[arg(value_parser = clap::value_parser!(u8).range(0..=100))]
level: u8,
},
Mute {
#[arg(help = ID)]
id: String,
},
Unmute {
#[arg(help = ID)]
id: String,
},
Speed {
#[arg(help = ID)]
id: String,
rate: f64,
},
Quality {
#[arg(help = ID)]
id: String,
level: String,
},
Window {
#[arg(help = ID)]
id: String,
#[command(flatten)]
geometry: GeometryArgs,
#[arg(long, value_enum)]
fullscreen: Option<Switch>,
#[arg(long, value_enum)]
on_top: Option<Switch>,
},
Fullscreen {
#[arg(help = ID)]
id: String,
#[arg(value_enum, default_value = "toggle")]
mode: Switch,
},
Screenshot {
#[arg(help = ID)]
id: String,
path: std::path::PathBuf,
},
Close {
#[arg(help = ID)]
id: String,
},
Auth {
#[command(subcommand)]
action: AuthCmd,
},
Plugins {
name: Option<String>,
},
Server {
#[command(subcommand)]
action: ServerCmd,
},
#[command(name = "__serve", hide = true)]
Serve {
#[arg(long)]
persistent: bool,
},
#[command(name = "__eval", hide = true)]
Eval { id: String, script: String },
}
#[derive(Subcommand)]
enum ServerCmd {
Up,
Down,
Status,
}
#[derive(Subcommand)]
enum AuthCmd {
Login {
service: String,
#[arg(long)]
site: Option<String>,
},
Status {
service: Option<String>,
},
}
#[derive(Args)]
struct GeometryArgs {
#[arg(long, value_parser = parse_pair::<'x'>)]
size: Option<(f64, f64)>,
#[arg(long, value_parser = parse_pair::<','>)]
position: Option<(f64, f64)>,
#[arg(long, value_enum)]
layout: Option<Layout>,
}
fn parse_pair<const SEP: char>(s: &str) -> Result<(f64, f64), String> {
let (a, b) = s
.split_once(SEP)
.ok_or_else(|| format!("expected two numbers separated by '{SEP}'"))?;
let parse = |v: &str| v.trim().parse::<f64>().map_err(|e| format!("{v:?}: {e}"));
Ok((parse(a)?, parse(b)?))
}
fn player(id: String, action: PlayerAction) -> Request {
Request::Player { id, action }
}
fn seek_request(id: String, seek: Seek) -> Request {
let (seconds, relative) = match seek {
Seek::Absolute(s) => (s, false),
Seek::Relative(s) => (s, true),
};
player(id, PlayerAction::Seek { seconds, relative })
}
#[derive(Debug)]
enum Plan {
Serve(bool),
ServerUp,
ServerDown,
ServerStatus,
Plugins(Option<String>),
Remote(Request),
}
fn plan(cmd: Cmd) -> Result<Plan> {
let req = match cmd {
Cmd::Serve { persistent } => return Ok(Plan::Serve(persistent)),
Cmd::Server { action } => {
return Ok(match action {
ServerCmd::Up => Plan::ServerUp,
ServerCmd::Down => Plan::ServerDown,
ServerCmd::Status => Plan::ServerStatus,
});
}
Cmd::Plugins { name } => return Ok(Plan::Plugins(name)),
Cmd::Open {
url,
backend,
start,
paused,
subtitles,
window,
fullscreen,
on_top,
} => {
let start = start.map(|s| timeparse::parse_duration(&s)).transpose()?;
Request::Open {
url,
backend,
start,
paused,
subtitles,
window: WindowOpts {
size: window.size,
position: window.position,
layout: window.layout,
fullscreen: fullscreen.then_some(Switch::On),
on_top: on_top.then_some(Switch::On),
},
}
}
Cmd::Auth { action } => match action {
AuthCmd::Login { service, site } => Request::AuthLogin { service, site },
AuthCmd::Status { service } => Request::AuthStatus { service },
},
Cmd::List => Request::List,
Cmd::Audio { id, lang } => player(id, PlayerAction::Audio { lang }),
Cmd::Status { id } => player(id, PlayerAction::Status),
Cmd::Play { id } => player(id, PlayerAction::Play),
Cmd::Pause { id } => player(id, PlayerAction::Pause),
Cmd::Toggle { id } => player(id, PlayerAction::Toggle),
Cmd::Next { id } => player(id, PlayerAction::Next),
Cmd::Prev { id } => player(id, PlayerAction::Previous),
Cmd::Eval { id, script } => Request::Eval { id, script },
Cmd::Seek { id, position } => seek_request(id, timeparse::parse_seek(&position)?),
Cmd::Forward { id, duration } => {
seek_request(id, Seek::Relative(timeparse::parse_duration(&duration)?))
}
Cmd::Back { id, duration } => {
seek_request(id, Seek::Relative(-timeparse::parse_duration(&duration)?))
}
Cmd::Subtitles { id, mode } => player(id, PlayerAction::Subtitles { lang: mode }),
Cmd::Volume { id, level } => player(
id,
PlayerAction::Volume {
level: Some(level),
muted: None,
},
),
Cmd::Mute { id } => player(
id,
PlayerAction::Volume {
level: None,
muted: Some(true),
},
),
Cmd::Unmute { id } => player(
id,
PlayerAction::Volume {
level: None,
muted: Some(false),
},
),
Cmd::Speed { id, rate } => {
if !(0.0625..=16.0).contains(&rate) {
return Err(anyhow!("speed must be between 0.0625 and 16"));
}
player(id, PlayerAction::Speed { rate })
}
Cmd::Quality { id, level } => player(id, PlayerAction::Quality { level }),
Cmd::Window {
id,
geometry,
fullscreen,
on_top,
} => Request::Window {
id,
opts: WindowOpts {
size: geometry.size,
position: geometry.position,
layout: geometry.layout,
fullscreen,
on_top,
},
},
Cmd::Fullscreen { id, mode } => Request::Window {
id,
opts: WindowOpts {
fullscreen: Some(mode),
..Default::default()
},
},
Cmd::Close { id } => Request::Close { id },
Cmd::Screenshot { id, path } => {
let path = std::path::absolute(&path)?;
Request::Screenshot {
id,
path: path.to_string_lossy().into_owned(),
}
}
};
Ok(Plan::Remote(req))
}
fn run(cmd: Cmd) -> Result<Value> {
match plan(cmd)? {
Plan::Serve(persistent) => {
server::run(persistent)?;
Ok(Value::Null)
}
Plan::ServerUp => client::server_up(),
Plan::ServerDown => client::server_down(),
Plan::ServerStatus => client::server_status(),
Plan::Plugins(name) => {
let registry = plugin::Registry::load()?;
match name {
Some(name) => registry.describe(&name),
None => Ok(json!({
"external_dir": plugin::external_dir(),
"plugins": registry.plugins().iter().map(plugin::Plugin::summary).collect::<Vec<_>>(),
})),
}
}
Plan::Remote(req) => {
if let Request::AuthLogin { service, site } = &req {
let service = match site {
Some(s) => format!("{service} ({s})"),
None => service.clone(),
};
eprintln!(
"Sign in to {service} in the Chrome window that opens, then quit that Chrome (Cmd+Q) \
(for some services, closing the login window is enough)."
);
}
match req {
Request::Open { .. } | Request::AuthLogin { .. } | Request::AuthStatus { .. } => {
client::send_autostart(&req)
}
Request::List => client::list(),
_ => client::send(&req),
}
}
}
}
fn command() -> clap::Command {
let services = match plugin::Registry::load() {
Ok(r) => r.help_section(),
Err(e) => format!("Services: could not load plugins: {e:#}"),
};
Cli::command().after_long_help(format!("{AFTER_LONG_HELP}\n\n{services}"))
}
fn main() {
let cli = match Cli::from_arg_matches(&command().get_matches()) {
Ok(cli) => cli,
Err(e) => e.exit(),
};
let is_serve = matches!(cli.command, Cmd::Serve { .. });
match run(cli.command) {
Ok(v) if !is_serve => {
println!(
"{}",
serde_json::to_string_pretty(&v).expect("serializable")
);
}
Ok(_) => {}
Err(e) => {
eprintln!("error: {e:#}");
std::process::exit(1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn req(args: &[&str]) -> Value {
let mut argv = vec!["tvctl"];
argv.extend_from_slice(args);
let cli = Cli::try_parse_from(argv).unwrap_or_else(|e| panic!("{args:?}: {e}"));
match plan(cli.command).unwrap_or_else(|e| panic!("{args:?}: {e}")) {
Plan::Remote(r) => serde_json::to_value(r).unwrap(),
other => panic!("{args:?}: expected a remote request, got {other:?}"),
}
}
fn rejects(args: &[&str]) {
let mut argv = vec!["tvctl"];
argv.extend_from_slice(args);
let ok = Cli::try_parse_from(argv)
.map(|c| plan(c.command).is_ok())
.unwrap_or(false);
assert!(!ok, "{args:?} should be rejected");
}
fn action(id: &str, action: Value) -> Value {
json!({ "cmd": "player", "id": id, "action": action })
}
#[test]
fn local_commands() {
for (args, expect) in [
(vec!["server", "up"], "ServerUp"),
(vec!["server", "down"], "ServerDown"),
(vec!["server", "status"], "ServerStatus"),
(vec!["plugins"], "Plugins(None)"),
(vec!["plugins", "netflix"], "Plugins(Some(\"netflix\"))"),
(vec!["__serve"], "Serve(false)"),
(vec!["__serve", "--persistent"], "Serve(true)"),
] {
let mut argv = vec!["tvctl"];
argv.extend(args.iter());
let p = plan(Cli::try_parse_from(argv).unwrap().command).unwrap();
assert_eq!(format!("{p:?}"), expect);
}
}
#[test]
fn open() {
assert_eq!(
req(&["open", "https://youtu.be/x"]),
json!({
"cmd": "open", "url": "https://youtu.be/x", "backend": null, "start": null,
"paused": false, "subtitles": null,
"window": { "size": null, "position": null, "layout": null, "fullscreen": null, "on_top": null },
})
);
assert_eq!(
req(&[
"open",
"u",
"--backend",
"chrome",
"--start",
"1:30",
"--paused",
"--subtitles",
"ja",
"--size",
"640x360",
"--position",
"10,20",
"--layout",
"bottom-right",
"--fullscreen",
"--on-top",
]),
json!({
"cmd": "open", "url": "u", "backend": "chrome", "start": 90.0, "paused": true, "subtitles": "ja",
"window": { "size": [640.0, 360.0], "position": [10.0, 20.0], "layout": "bottom-right",
"fullscreen": "on", "on_top": "on" },
})
);
rejects(&["open"]);
rejects(&["open", "u", "--start", "abc"]);
rejects(&["open", "u", "--size", "640"]);
rejects(&["open", "u", "--position", "1x2"]);
rejects(&["open", "u", "--layout", "middle"]);
rejects(&["open", "u", "--backend", "firefox"]);
}
#[test]
fn playback() {
assert_eq!(
req(&["status", "1"]),
action("1", json!({ "action": "status" }))
);
assert_eq!(
req(&["play", "1"]),
action("1", json!({ "action": "play" }))
);
assert_eq!(
req(&["pause", "2"]),
action("2", json!({ "action": "pause" }))
);
assert_eq!(
req(&["toggle", "3"]),
action("3", json!({ "action": "toggle" }))
);
assert_eq!(
req(&["next", "1"]),
action("1", json!({ "action": "next" }))
);
assert_eq!(
req(&["prev", "1"]),
action("1", json!({ "action": "previous" }))
);
assert_eq!(
req(&["__eval", "1", "1+1"]),
json!({ "cmd": "eval", "id": "1", "script": "1+1" })
);
rejects(&["play"]);
}
#[test]
fn seeking() {
let seek = |s: f64, r: bool| {
action(
"1",
json!({ "action": "seek", "seconds": s, "relative": r }),
)
};
assert_eq!(req(&["seek", "1", "90"]), seek(90.0, false));
assert_eq!(req(&["seek", "1", "1:02:03"]), seek(3723.0, false));
assert_eq!(req(&["seek", "1", "+10"]), seek(10.0, true));
assert_eq!(req(&["seek", "1", "-1m"]), seek(-60.0, true));
assert_eq!(req(&["forward", "1"]), seek(10.0, true));
assert_eq!(req(&["forward", "1", "30"]), seek(30.0, true));
assert_eq!(req(&["back", "1"]), seek(-10.0, true));
assert_eq!(req(&["back", "1", "1:00"]), seek(-60.0, true));
rejects(&["seek", "1", "soon"]);
rejects(&["forward", "1", "-5"]);
}
#[test]
fn tracks_and_audio_settings() {
assert_eq!(
req(&["subtitles", "1", "ja"]),
action("1", json!({ "action": "subtitles", "lang": "ja" }))
);
assert_eq!(
req(&["subtitles", "1", "off"]),
action("1", json!({ "action": "subtitles", "lang": "off" }))
);
assert_eq!(
req(&["audio", "1", "list"]),
action("1", json!({ "action": "audio", "lang": "list" }))
);
assert_eq!(
req(&["quality", "1", "hd1080"]),
action("1", json!({ "action": "quality", "level": "hd1080" }))
);
assert_eq!(
req(&["quality", "1", "very-high"]),
action("1", json!({ "action": "quality", "level": "very-high" }))
);
assert_eq!(
req(&["volume", "1", "30"]),
action(
"1",
json!({ "action": "volume", "level": 30, "muted": null })
)
);
rejects(&["volume", "1", "101"]);
assert_eq!(
req(&["mute", "1"]),
action(
"1",
json!({ "action": "volume", "level": null, "muted": true })
)
);
assert_eq!(
req(&["unmute", "1"]),
action(
"1",
json!({ "action": "volume", "level": null, "muted": false })
)
);
assert_eq!(
req(&["speed", "1", "1.5"]),
action("1", json!({ "action": "speed", "rate": 1.5 }))
);
rejects(&["speed", "1", "0"]);
rejects(&["speed", "1", "32"]);
}
#[test]
fn window_commands() {
let opts = |v: Value| {
let mut o = json!({ "size": null, "position": null, "layout": null, "fullscreen": null, "on_top": null });
for (k, val) in v.as_object().unwrap() {
o[k] = val.clone();
}
o
};
assert_eq!(
req(&["window", "1", "--size", "800x450", "--position", "5,6"]),
json!({ "cmd": "window", "id": "1", "opts": opts(json!({ "size": [800.0, 450.0], "position": [5.0, 6.0] })) })
);
for l in [
"left",
"right",
"top",
"bottom",
"top-left",
"top-right",
"bottom-left",
"bottom-right",
"center",
"maximize",
] {
assert_eq!(
req(&["window", "1", "--layout", l]),
json!({ "cmd": "window", "id": "1", "opts": opts(json!({ "layout": l })) })
);
}
assert_eq!(
req(&["window", "1", "--fullscreen", "off", "--on-top", "toggle"]),
json!({ "cmd": "window", "id": "1", "opts": opts(json!({ "fullscreen": "off", "on_top": "toggle" })) })
);
assert_eq!(
req(&["fullscreen", "1"]),
json!({ "cmd": "window", "id": "1", "opts": opts(json!({ "fullscreen": "toggle" })) })
);
assert_eq!(
req(&["fullscreen", "1", "on"]),
json!({ "cmd": "window", "id": "1", "opts": opts(json!({ "fullscreen": "on" })) })
);
rejects(&["fullscreen", "1", "maybe"]);
}
#[test]
fn management_commands() {
assert_eq!(req(&["list"]), json!({ "cmd": "list" }));
assert_eq!(req(&["close", "4"]), json!({ "cmd": "close", "id": "4" }));
assert_eq!(
req(&["auth", "login", "youtube"]),
json!({ "cmd": "auth_login", "service": "youtube", "site": null })
);
assert_eq!(
req(&["auth", "login", "primevideo", "--site", "jp"]),
json!({ "cmd": "auth_login", "service": "primevideo", "site": "jp" })
);
assert_eq!(
req(&["auth", "status"]),
json!({ "cmd": "auth_status", "service": null })
);
assert_eq!(
req(&["auth", "status", "netflix"]),
json!({ "cmd": "auth_status", "service": "netflix" })
);
let shot = req(&["screenshot", "1", "shot.png"]);
let path = shot["path"].as_str().unwrap();
assert!(
std::path::Path::new(path).is_absolute() && path.ends_with("shot.png"),
"{path}"
);
}
#[test]
fn help_is_complete() {
fn check(cmd: &clap::Command, path: &str) {
for sub in cmd
.get_subcommands()
.filter(|c| !c.is_hide_set() && c.get_name() != "help")
{
let name = format!("{path} {}", sub.get_name());
assert!(sub.get_about().is_some(), "{name}: missing about");
for arg in sub.get_arguments().filter(|a| !a.is_hide_set()) {
let id = arg.get_id().as_str();
if id == "help" || id == "version" {
continue;
}
assert!(
arg.get_help().is_some() || arg.get_long_help().is_some(),
"{name}: argument {id} has no help"
);
}
check(sub, &name);
}
}
let cli = Cli::command();
cli.clone().debug_assert();
check(&cli, "tvctl");
let long = cli.clone().render_long_help().to_string();
for needle in [
"Workflow:",
"Output:",
"Errors:",
"Examples:",
"tvctl plugins",
"exit",
] {
assert!(long.contains(needle), "long help lacks {needle:?}");
}
let long = command().render_long_help().to_string();
for needle in [
"Services (installed plugins)",
"tvctl plugins <name>",
"netflix",
"primevideo",
"(login required)",
"e.g. tvctl open 'https://www.netflix.com/",
] {
assert!(long.contains(needle), "long help lacks {needle:?}");
}
}
}