use std::path::Path;
use std::process::Stdio;
use std::time::Duration;
use anyhow::{Context, Result};
use tokio::sync::mpsc::UnboundedSender;
use tokio::sync::oneshot;
use crate::chapters::Chapter;
use crate::tools::{self, Tools};
use crate::visualizer::{self, Levels};
use crate::ytdlp::Streams;
const QUIT_GRACE: Duration = Duration::from_secs(3);
const SOUND_AHEAD: &str = "16MiB";
const SOUND_BEHIND: &str = "8MiB";
const FILTER_ADDED: u64 = 1;
#[cfg_attr(not(unix), allow(dead_code))]
const SPECTRUM: u64 = 2;
#[derive(Debug)]
pub struct PlayerEvent {
pub play: u64,
pub kind: PlayerEventKind,
}
#[derive(Debug, PartialEq)]
#[cfg_attr(not(unix), allow(dead_code))]
pub enum PlayerEventKind {
Position(f64),
Duration(f64),
Paused(bool),
Volume(f64),
Muted(bool),
Restarted,
Visualizer(bool),
Spectrum(Levels),
Ended,
Failed,
Exited,
Remote(Remote),
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Remote {
Next,
Previous,
}
#[cfg_attr(not(unix), allow(dead_code))]
const REMOTE_KEYS: [(&str, &str, Remote); 2] = [
("NEXT", "next", Remote::Next),
("PREV", "previous", Remote::Previous),
];
pub const NOW_PLAYING: bool = cfg!(target_os = "macos");
#[cfg_attr(not(unix), allow(dead_code))]
const MESSAGE_FROM: &str = "tuitube";
pub struct Player {
pub play: u64,
controllable: bool,
#[cfg(unix)]
control: Option<tokio::sync::mpsc::UnboundedSender<Control>>,
stop: Option<oneshot::Sender<()>>,
}
pub struct Start<'a> {
pub mpv: &'a Path,
pub tools: &'a Tools,
pub streams: &'a Streams,
pub title: &'a str,
pub start_at: Option<f64>,
pub audio_only: bool,
pub subtitles: Option<&'a str>,
pub subtitles_shown: bool,
pub volume: u32,
pub muted: bool,
pub looped: bool,
pub artist: Option<&'a str>,
pub now_playing: bool,
pub pepper: Option<Pepper<'a>>,
}
pub struct Pepper<'a> {
pub port: &'a std::ffi::OsStr,
pub token: &'a str,
}
impl Player {
pub fn start(
play: u64,
start: Start,
tx: UnboundedSender<crate::app::AppEvent>,
) -> Result<Self> {
let mut command = tokio::process::Command::new(start.mpv);
command
.args(args(&start))
.env_clear()
.envs(tools::child_env(start.tools))
.envs(allocator_env(&start))
.envs(start.pepper.as_ref().map(|p| ("PEPPER_PORT", p.port)))
.envs(video_log(&start).map(|_| ("PEPPER_PLAY_DEBUG", "1")))
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(video_log(&start).map_or_else(Stdio::null, Stdio::from))
.kill_on_drop(true);
if let Some(home) = dirs::home_dir() {
command.current_dir(home);
}
#[cfg(unix)]
let (child, control) = {
use std::os::fd::AsRawFd;
let starting = tools::starting();
let (ours, theirs) = std::os::unix::net::UnixStream::pair()?;
let fd = theirs.as_raw_fd();
unsafe {
command.pre_exec(move || {
let ok = if fd == 3 {
libc::fcntl(3, libc::F_SETFD, 0) != -1
} else {
libc::dup2(fd, 3) != -1
};
if !ok {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
let child = command.spawn().with_context(|| cannot_start(&start))?;
drop(starting);
drop(theirs);
ours.set_nonblocking(true)?;
let stream = tokio::net::UnixStream::from_std(ours)?;
let (control_tx, control_rx) = tokio::sync::mpsc::unbounded_channel();
spawn_ipc(play, stream, control_rx, tx.clone());
for line in commands(&start) {
let _ = control_tx.send(Control::Line(line));
}
(child, control_tx)
};
#[cfg(not(unix))]
let child = {
let _starting = tools::starting();
command.spawn().with_context(|| cannot_start(&start))?
};
let (stop_tx, stop_rx) = oneshot::channel();
tokio::spawn(own(play, child, stop_rx, tx));
Ok(Self {
play,
controllable: cfg!(unix),
#[cfg(unix)]
control: Some(control),
stop: Some(stop_tx),
})
}
pub fn detached(play: u64) -> Self {
Self {
play,
controllable: cfg!(unix) || cfg!(test),
#[cfg(unix)]
control: None,
stop: None,
}
}
#[cfg(test)]
pub fn pretend(play: u64) -> Self {
let (stop, _) = oneshot::channel();
let mut player = Self::detached(play);
player.stop = Some(stop);
player
}
pub fn has_mpv(&self) -> bool {
self.stop.is_some()
}
pub fn controllable(&self) -> bool {
self.controllable
}
#[cfg(test)]
pub fn set_controllable(&mut self, controllable: bool) {
self.controllable = controllable;
}
fn send(&self, command: serde_json::Value) {
#[cfg(unix)]
self.control(Control::Line(line(command)));
#[cfg(not(unix))]
let _ = command;
}
fn send_numbered(&self, command: serde_json::Value, id: u64) {
#[cfg(unix)]
self.control(Control::Line(line_numbered(command, id)));
#[cfg(not(unix))]
let _ = (command, id);
}
#[cfg(unix)]
fn control(&self, message: Control) {
if let Some(control) = &self.control {
let _ = control.send(message);
}
}
pub fn toggle_pause(&self) {
self.send(serde_json::json!(["cycle", "pause"]));
}
pub fn toggle_fullscreen(&self) {
self.send(serde_json::json!(["cycle", "fullscreen"]));
}
pub fn seek(&self, seconds: i32) {
self.send(serde_json::json!(["seek", seconds, "relative"]));
}
pub fn seek_to(&self, seconds: f64) {
if seconds.is_finite() && seconds >= 0.0 {
self.send(serde_json::json!(["seek", seconds, "absolute+exact"]));
}
}
pub fn show_subtitles(&self, shown: bool) {
self.send(serde_json::json!(["set_property", "sub-visibility", shown]));
}
pub fn set_volume(&self, percent: u32) {
self.send(serde_json::json!([
"set_property",
"volume",
percent.min(100)
]));
}
pub fn set_muted(&self, muted: bool) {
self.send(serde_json::json!(["set_property", "mute", muted]));
}
pub fn set_looped(&self, looped: bool) {
let value = if looped { "inf" } else { "no" };
self.send(serde_json::json!(["set_property", "loop-file", value]));
}
pub fn add_visualizer(&self) {
let filter = visualizer::filter();
self.send_numbered(serde_json::json!(["af", "add", filter]), FILTER_ADDED);
}
pub fn remove_visualizer(&self) {
self.follow_spectrum(false);
let label = format!("@{}", visualizer::LABEL);
self.send(serde_json::json!(["af", "remove", label]));
}
pub fn follow_spectrum(&self, on: bool) {
#[cfg(unix)]
self.control(Control::Spectrum(on));
#[cfg(not(unix))]
let _ = on;
}
pub fn set_chapters(&self, chapters: &[Chapter]) {
self.send(chapters_command(chapters));
}
pub fn set_now_playing(&self, on: bool) {
let on = if on { "yes" } else { "no" };
self.send(serde_json::json!(["set", "input-media-keys", on]));
}
pub fn add_cover(&self, path: &Path) {
if let Some(command) = cover_command(path) {
self.send(command);
}
}
pub fn stop(&mut self) {
self.send(serde_json::json!(["quit"]));
#[cfg(unix)]
{
self.control = None;
}
if let Some(stop) = self.stop.take() {
let _ = stop.send(());
}
}
}
impl Drop for Player {
fn drop(&mut self) {
self.stop();
}
}
async fn own(
play: u64,
mut child: tokio::process::Child,
stop: oneshot::Receiver<()>,
tx: UnboundedSender<crate::app::AppEvent>,
) {
tokio::select! {
_ = child.wait() => {}
_ = stop => {
let grace = if cfg!(unix) { QUIT_GRACE } else { Duration::ZERO };
if tokio::time::timeout(grace, child.wait()).await.is_err() {
let _ = child.start_kill();
let _ = child.wait().await;
}
}
}
let _ = tx.send(crate::app::AppEvent::Player(PlayerEvent {
play,
kind: PlayerEventKind::Exited,
}));
}
fn video_log(start: &Start) -> Option<std::fs::File> {
start.pepper.as_ref()?;
let path = crate::config::var("TT_VIDEO_LOG")?;
let mut options = std::fs::OpenOptions::new();
options.create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
}
options.open(path).ok()
}
fn cannot_start(start: &Start) -> String {
let name = if start.pepper.is_some() {
"pepper-play"
} else {
"mpv"
};
format!("cannot start {name}")
}
#[cfg_attr(not(unix), allow(dead_code))]
fn video_token_command(token: &str) -> serde_json::Value {
serde_json::json!(["script-message-to", "main", "pepper-token", token])
}
fn allocator_env(start: &Start) -> Option<(&'static str, &'static str)> {
(start.audio_only && cfg!(target_os = "macos")).then_some(("MallocLargeCache", "0"))
}
fn args(start: &Start) -> Vec<String> {
let mut args: Vec<String> = [
"--no-config",
"--load-scripts=no",
"--terminal=no",
"--ytdl=no",
"--tls-verify=yes",
"--keep-open=no",
"--save-position-on-quit=no",
"--resume-playback=no",
"--hwdec=auto-safe",
]
.map(String::from)
.into();
if cfg!(target_os = "macos") {
args.push("--audio-format=float".into());
}
if NOW_PLAYING && !start.now_playing {
args.push("--input-media-keys=no".into());
}
if start.audio_only {
args.push("--no-video".into());
args.push("--force-window=no".into());
args.push(format!("--demuxer-max-bytes={SOUND_AHEAD}"));
args.push(format!("--demuxer-max-back-bytes={SOUND_BEHIND}"));
} else {
args.push("--force-window=immediate".into());
}
if cfg!(unix) {
args.push("--idle=once".into());
args.push("--input-ipc-client=fd://3".into());
return args;
}
args.push("--idle=no".into());
for (name, value) in settings(start) {
args.push(format!("--{name}={value}"));
}
if let Some(audio) = &start.streams.audio {
args.push(format!("--audio-file={audio}"));
}
if let Some(subtitles) = start.subtitles {
args.push(format!("--sub-file={subtitles}"));
}
args.push("--".into());
args.push(start.streams.video.clone());
args
}
fn settings(start: &Start) -> Vec<(&'static str, String)> {
let title = crate::video::one_line(start.title, 150);
let mut settings = vec![
("force-media-title", title.clone()),
("title", format!("{} — tuitube", title.replace('$', "$$"))),
("volume", start.volume.min(100).to_string()),
("mute", if start.muted { "yes" } else { "no" }.into()),
];
if let Some(ua) = &start.streams.user_agent {
settings.push(("user-agent", crate::video::one_line(ua, 300)));
}
if let Some(at) = start.start_at.filter(|s| s.is_finite() && *s > 0.0) {
settings.push(("start", format!("{at:.0}")));
}
if start.subtitles.is_some() {
let shown = if start.subtitles_shown { "yes" } else { "no" };
settings.push(("sub-visibility", shown.into()));
}
if start.looped {
settings.push(("loop-file", "inf".into()));
}
settings
}
fn cover_command(path: &Path) -> Option<serde_json::Value> {
let path = path.to_str().filter(|_| path.is_absolute())?;
Some(serde_json::json!({
"name": "video-add",
"url": path,
"flags": "auto",
"albumart": true,
}))
}
fn chapters_command(chapters: &[Chapter]) -> serde_json::Value {
let list: Vec<serde_json::Value> = chapters
.iter()
.map(|c| serde_json::json!({ "title": c.title, "time": c.start }))
.collect();
serde_json::json!(["set_property", "chapter-list", list])
}
#[cfg_attr(not(unix), allow(dead_code))]
fn line(command: serde_json::Value) -> String {
format!("{}\n", serde_json::json!({ "command": command }))
}
#[cfg_attr(not(unix), allow(dead_code))]
fn line_numbered(command: serde_json::Value, id: u64) -> String {
format!(
"{}\n",
serde_json::json!({ "command": command, "request_id": id })
)
}
#[cfg(unix)]
enum Control {
Line(String),
Spectrum(bool),
}
#[cfg_attr(not(unix), allow(dead_code))]
fn commands(start: &Start) -> Vec<String> {
let mut commands: Vec<String> = settings(start)
.into_iter()
.map(|(name, value)| line(serde_json::json!(["set", name, value])))
.collect();
commands.extend(
[
(1, "time-pos"),
(2, "duration"),
(3, "pause"),
(4, "volume"),
(5, "mute"),
]
.into_iter()
.map(|(id, property)| line(serde_json::json!(["observe_property", id, property]))),
);
commands.extend(REMOTE_KEYS.iter().map(|(key, word, _)| {
line(serde_json::json!([
"keybind",
key,
format!("script-message {MESSAGE_FROM} {word}")
]))
}));
if let Some(pepper) = &start.pepper {
commands.push(line(video_token_command(pepper.token)));
}
if let Some(audio) = &start.streams.audio {
commands.push(line(serde_json::json!([
"change-list",
"audio-files",
"append",
audio
])));
}
if let Some(subtitles) = start.subtitles {
commands.push(line(serde_json::json!([
"change-list",
"sub-files",
"append",
subtitles
])));
}
commands.push(line(serde_json::json!([
"loadfile",
file_to_load(&start.streams.video, start.artist),
"replace"
])));
commands
}
#[cfg_attr(not(unix), allow(dead_code))]
fn file_to_load(url: &str, artist: Option<&str>) -> String {
let Some(artist) = artist else {
return url.to_string();
};
let field = |text: &str| format!("%{}%{text}", text.len());
let artist = crate::video::one_line(artist, 150);
format!(
"edl://!no_clip;!no_chapters;!global_tags,artist={};{};",
field(&artist),
field(url)
)
}
#[cfg(unix)]
fn spawn_ipc(
play: u64,
stream: tokio::net::UnixStream,
mut control: tokio::sync::mpsc::UnboundedReceiver<Control>,
tx: UnboundedSender<crate::app::AppEvent>,
) {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
let (read, mut write) = stream.into_split();
tokio::spawn(async move {
let ask = line_numbered(
serde_json::json!(["get_property", visualizer::property()]),
SPECTRUM,
);
let mut every = tokio::time::interval(visualizer::EVERY);
every.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut following = false;
loop {
let line = tokio::select! {
message = control.recv() => match message {
Some(Control::Line(line)) => line,
Some(Control::Spectrum(on)) => {
following = on;
continue;
}
None => break,
},
_ = every.tick(), if following => ask.clone(),
};
if write.write_all(line.as_bytes()).await.is_err() {
break;
}
}
});
tokio::spawn(async move {
let mut lines = BufReader::new(read).lines();
while let Ok(Some(line)) = lines.next_line().await {
if line.len() > 64 * 1024 {
continue;
}
if let Some(kind) = parse_event(&line) {
let _ = tx.send(crate::app::AppEvent::Player(PlayerEvent { play, kind }));
}
}
});
}
#[derive(serde::Deserialize)]
#[cfg(any(unix, test))]
struct IpcMessage {
event: Option<String>,
name: Option<String>,
data: Option<serde_json::Value>,
reason: Option<String>,
request_id: Option<u64>,
error: Option<String>,
args: Option<serde_json::Value>,
}
#[cfg(any(unix, test))]
fn remote(args: &serde_json::Value) -> Option<Remote> {
let [from, word] = args.as_array()?.as_slice() else {
return None;
};
if from != MESSAGE_FROM {
return None;
}
REMOTE_KEYS
.iter()
.find(|(_, w, _)| word == *w)
.map(|(_, _, remote)| *remote)
}
#[cfg(any(unix, test))]
fn parse_event(line: &str) -> Option<PlayerEventKind> {
let message: IpcMessage = serde_json::from_str(line).ok()?;
match message.request_id {
Some(FILTER_ADDED) => {
let added = message.error.as_deref() == Some("success");
return Some(PlayerEventKind::Visualizer(added));
}
Some(SPECTRUM) => {
return visualizer::levels(message.data.as_ref()?).map(PlayerEventKind::Spectrum);
}
_ => {}
}
match message.event.as_deref() {
Some("end-file") => {
return match message.reason.as_deref() {
Some("eof") => Some(PlayerEventKind::Ended),
Some("error") => Some(PlayerEventKind::Failed),
_ => None,
};
}
Some("playback-restart") => return Some(PlayerEventKind::Restarted),
Some("client-message") => {
return remote(message.args.as_ref()?).map(PlayerEventKind::Remote);
}
_ => {}
}
if message.event.as_deref() != Some("property-change") {
return None;
}
let data = message.data?;
match message.name.as_deref()? {
"time-pos" => data.as_f64().map(PlayerEventKind::Position),
"duration" => data.as_f64().map(PlayerEventKind::Duration),
"pause" => data.as_bool().map(PlayerEventKind::Paused),
"volume" => data.as_f64().map(PlayerEventKind::Volume),
"mute" => data.as_bool().map(PlayerEventKind::Muted),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn streams() -> Streams {
Streams {
video: "https://rr1.googlevideo.com/videoplayback?v=1".into(),
audio: Some("https://rr1.googlevideo.com/videoplayback?a=1,b=2".into()),
user_agent: Some("Mozilla/5.0\nX-Injected: yes".into()),
duration: Some(10.0),
chapters: Vec::new(),
quality: None,
info: None,
captions: Vec::new(),
}
}
fn start<'a>(tools: &'a Tools, streams: &'a Streams, title: &'a str) -> Start<'a> {
Start {
mpv: Path::new("/usr/bin/mpv"),
tools,
streams,
title,
start_at: Some(42.4),
audio_only: false,
subtitles: None,
subtitles_shown: false,
volume: 80,
muted: false,
looped: false,
artist: None,
now_playing: true,
pepper: None,
}
}
#[cfg(unix)]
#[test]
fn in_pepper_the_token_goes_over_the_socket_before_the_video() {
let (tools, streams) = (Tools::default(), streams());
let port = std::ffi::OsString::from("io.github.erictran308.pepper.1");
let mut start = start(&tools, &streams, "A video");
start.pepper = Some(Pepper {
port: &port,
token: "abc123",
});
let commands = commands(&start);
let at = |needle: &str| commands.iter().position(|c| c.contains(needle)).unwrap();
assert!(commands[at("pepper-token")].contains("abc123"));
assert!(at("pepper-token") < at("loadfile"));
assert!(!commands.iter().any(|c| c.contains("pepper-size")));
assert!(!args(&start).iter().any(|a| a.contains("abc123")));
assert_eq!(cannot_start(&start), "cannot start pepper-play");
}
#[cfg(unix)]
#[test]
fn nothing_about_the_video_is_on_mpvs_command_line() {
let (tools, streams) = (Tools::default(), streams());
let args = args(&start(&tools, &streams, "My secret video"));
assert_eq!(args[0], "--no-config");
for flag in [
"--terminal=no",
"--ytdl=no",
"--tls-verify=yes",
"--idle=once",
] {
assert!(args.contains(&flag.to_string()), "{flag}");
}
let all = args.join(" ");
assert!(!all.contains("secret"), "{all}");
assert!(!all.contains("googlevideo"), "{all}");
assert!(!all.contains("Mozilla"), "{all}");
}
#[test]
fn the_socket_gets_the_video_as_json_strings() {
let (tools, streams) = (Tools::default(), streams());
let title = "\"]}{\"command\":[\"run\",\"sh\"]} ${path} --script=x";
let lines = commands(&start(&tools, &streams, title));
for line in &lines {
let parsed: serde_json::Value = serde_json::from_str(line.trim_end()).unwrap();
assert!(parsed["command"].is_array(), "{line}");
assert!(!line.trim_end().contains('\n'));
}
let set = |name: &str| {
lines
.iter()
.map(|l| serde_json::from_str::<serde_json::Value>(l).unwrap())
.find(|v| v["command"][0] == "set" && v["command"][1] == name)
.map(|v| v["command"][2].as_str().unwrap().to_string())
};
assert_eq!(
set("force-media-title").unwrap(),
title,
"kept whole, as one string"
);
assert!(
set("title").unwrap().contains("$${path}"),
"no property expansion"
);
assert_eq!(
set("user-agent").unwrap(),
"Mozilla/5.0 X-Injected: yes",
"one line"
);
assert_eq!(set("start").unwrap(), "42");
assert_eq!(set("volume").unwrap(), "80");
assert_eq!(set("mute").unwrap(), "no");
let first_followed = lines
.iter()
.position(|l| l.contains("observe_property"))
.unwrap();
let last_set = lines.iter().rposition(|l| l.contains("\"set\"")).unwrap();
assert!(last_set < first_followed, "mpv first says tuitube's volume");
let last: serde_json::Value = serde_json::from_str(lines.last().unwrap()).unwrap();
assert_eq!(last["command"][0], "loadfile");
assert_eq!(last["command"][1], streams.video.as_str());
let audio: serde_json::Value = serde_json::from_str(&lines[lines.len() - 2]).unwrap();
assert_eq!(
audio["command"][2], "append",
"one item: commas don't split it"
);
}
#[test]
fn macos_plays_through_coreaudio_where_volume_changes_at_once() {
let (tools, streams) = (Tools::default(), streams());
let args = args(&start(&tools, &streams, "A video"));
let float = args.iter().any(|a| a == "--audio-format=float");
assert_eq!(float, cfg!(target_os = "macos"));
}
#[test]
fn mpv_stays_out_of_now_playing_when_its_turned_off() {
let (tools, streams) = (Tools::default(), streams());
let mut start = start(&tools, &streams, "A video");
let out = |start: &Start| args(start).iter().any(|a| a == "--input-media-keys=no");
assert!(!out(&start), "on: mpv's own way");
start.now_playing = false;
assert_eq!(out(&start), NOW_PLAYING, "off, where tuitube fills it in");
}
#[test]
fn sound_alone_on_macos_gives_back_what_mpv_frees() {
let (tools, streams) = (Tools::default(), streams());
let mut start = start(&tools, &streams, "A video");
assert_eq!(allocator_env(&start), None, "a picture doesn't grow");
start.audio_only = true;
assert_eq!(
allocator_env(&start).is_some(),
cfg!(target_os = "macos"),
"macOS's allocator"
);
}
#[test]
fn repeat_one_has_mpv_play_the_video_over_and_over() {
let (tools, streams) = (Tools::default(), streams());
let mut start = start(&tools, &streams, "A video");
let loops = |start: &Start| settings(start).contains(&("loop-file", "inf".into()));
assert!(!loops(&start), "it ends unless asked");
start.looped = true;
assert!(loops(&start));
let set = r#"{"command":["set","loop-file","inf"]}"#;
assert!(commands(&start).iter().any(|l| l.trim_end() == set));
}
#[test]
fn subtitles_go_in_with_the_video_shown_or_not() {
let (tools, streams) = (Tools::default(), streams());
let mut start = start(&tools, &streams, "A video");
let vtt = "https://www.youtube.com/api/timedtext?v=x&lang=en,fr&fmt=vtt";
start.subtitles = Some(vtt);
let lines: Vec<serde_json::Value> = commands(&start)
.iter()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
let sub = lines
.iter()
.find(|v| v["command"][1] == "sub-files")
.expect("the subtitles");
assert_eq!(
sub["command"],
serde_json::json!(["change-list", "sub-files", "append", vtt]),
"one item: the comma doesn't split it"
);
let shown = lines
.iter()
.find(|v| v["command"][0] == "set" && v["command"][1] == "sub-visibility")
.unwrap();
assert_eq!(shown["command"][2], "no");
assert_eq!(lines.last().unwrap()["command"][0], "loadfile");
start.subtitles = None;
let none = commands(&start).join("");
assert!(!none.contains("sub-"), "{none}");
}
#[test]
fn chapters_go_to_mpv_as_one_command_of_json_strings() {
let chapters = [
Chapter {
start: 0.0,
title: "Intro".into(),
},
Chapter {
start: 83.5,
title: "\"]}\n{\"command\":[\"run\",\"sh\"]} ${path}".into(),
},
];
let sent = line(chapters_command(&chapters));
assert_eq!(sent.matches('\n').count(), 1, "one line: {sent}");
let parsed: serde_json::Value = serde_json::from_str(sent.trim_end()).unwrap();
let command = &parsed["command"];
assert_eq!(command[0], "set_property");
assert_eq!(command[1], "chapter-list");
assert_eq!(command[2][1]["time"], 83.5);
assert_eq!(
command[2][1]["title"],
chapters[1].title.as_str(),
"kept whole"
);
assert_eq!(command.as_array().unwrap().len(), 3);
}
#[test]
fn only_followed_properties_become_events() {
let pos = r#"{"event":"property-change","id":1,"name":"time-pos","data":12.5}"#;
assert_eq!(parse_event(pos), Some(PlayerEventKind::Position(12.5)));
let pause = r#"{"event":"property-change","id":3,"name":"pause","data":true}"#;
assert_eq!(parse_event(pause), Some(PlayerEventKind::Paused(true)));
let volume = r#"{"event":"property-change","id":4,"name":"volume","data":45.0}"#;
assert_eq!(parse_event(volume), Some(PlayerEventKind::Volume(45.0)));
let mute = r#"{"event":"property-change","id":5,"name":"mute","data":false}"#;
assert_eq!(parse_event(mute), Some(PlayerEventKind::Muted(false)));
assert_eq!(
parse_event(r#"{"event":"property-change","name":"time-pos","data":null}"#),
None
);
assert_eq!(parse_event(r#"{"request_id":0,"error":"success"}"#), None);
assert_eq!(parse_event("not json"), None);
}
#[test]
fn the_systems_next_and_previous_come_back_from_mpv() {
let tools = Tools::default();
let streams = streams();
let lines = commands(&start(&tools, &streams, "a title"));
let sent: Vec<serde_json::Value> = lines
.iter()
.map(|l| {
serde_json::from_str::<serde_json::Value>(l.trim_end()).unwrap()["command"].clone()
})
.collect();
let bound: Vec<_> = sent.iter().filter(|c| c[0] == "keybind").collect();
assert_eq!(bound.len(), 2);
assert_eq!(bound[0][1], "NEXT");
assert_eq!(bound[0][2], "script-message tuitube next");
assert_eq!(bound[1][1], "PREV");
assert_eq!(bound[1][2], "script-message tuitube previous");
let at = |name: &str| sent.iter().position(|c| c[0] == name).unwrap();
assert!(at("keybind") < at("loadfile"), "bound before the video");
let message =
|args: &str| parse_event(&format!(r#"{{"event":"client-message","args":{args}}}"#));
assert_eq!(
message(r#"["tuitube","next"]"#),
Some(PlayerEventKind::Remote(Remote::Next))
);
assert_eq!(
message(r#"["tuitube","previous"]"#),
Some(PlayerEventKind::Remote(Remote::Previous))
);
for other in [
r#"["someone","next"]"#,
r#"["tuitube","quit"]"#,
r#"["tuitube","next","more"]"#,
r#"["tuitube"]"#,
r#"[1,2]"#,
r#""tuitube next""#,
"null",
] {
assert_eq!(message(other), None, "{other}");
}
assert_eq!(parse_event(r#"{"event":"client-message"}"#), None);
let pos = r#"{"event":"property-change","name":"time-pos","data":3.0,"args":{"x":1}}"#;
assert_eq!(parse_event(pos), Some(PlayerEventKind::Position(3.0)));
}
#[test]
fn the_channel_goes_in_as_the_files_artist_tag() {
let url = "https://rr1.googlevideo.com/videoplayback?v=1&x=a,b;c%2C";
assert_eq!(file_to_load(url, None), url, "no artist: the URL as it is");
let edl = file_to_load(url, Some("Kênh; a,b %3%x"));
assert_eq!(
edl,
format!(
"edl://!no_clip;!no_chapters;!global_tags,artist=%15%Kênh; a,b %3%x;%{}%{url};",
url.len()
)
);
let edl = file_to_load(url, Some("two\nlines"));
assert!(edl.contains("artist=%9%two lines;"), "{edl}");
let tools = Tools::default();
let streams = streams();
let mut start = start(&tools, &streams, "a title");
start.artist = Some("A channel");
let lines = commands(&start);
let load = lines.iter().find(|l| l.contains("loadfile")).unwrap();
let load: serde_json::Value = serde_json::from_str(load.trim_end()).unwrap();
assert_eq!(
load["command"][1],
file_to_load(&streams.video, Some("A channel"))
);
}
#[test]
fn the_cover_goes_to_mpv_as_album_art_not_shown() {
let path = std::env::temp_dir().join("p-dQw4w9WgXcQ.jpg");
let command = cover_command(&path).unwrap();
assert_eq!(command["name"], "video-add");
assert_eq!(command["url"], path.to_str().unwrap());
assert_eq!(command["flags"], "auto", "not selected");
assert_eq!(command["albumart"], true);
let sent = line(command);
assert_eq!(sent.matches('\n').count(), 1, "one line: {sent}");
assert_eq!(
cover_command(Path::new("p-dQw4w9WgXcQ.jpg")),
None,
"a relative path, which mpv could read as a URL"
);
}
#[test]
fn mpv_answers_about_the_visualizer_become_events() {
let added = r#"{"request_id":1,"error":"success","data":null}"#;
assert_eq!(parse_event(added), Some(PlayerEventKind::Visualizer(true)));
let refused = r#"{"request_id":1,"error":"error running command"}"#;
assert_eq!(
parse_event(refused),
Some(PlayerEventKind::Visualizer(false))
);
let numbers = r#"{"request_id":2,"error":"success","data":{"lavfi.astats.3.RMS_level":"-20.5","lavfi.astats.4.RMS_level":"-inf"}}"#;
let Some(PlayerEventKind::Spectrum(levels)) = parse_event(numbers) else {
panic!("no spectrum");
};
assert_eq!(levels[0], -20.5);
assert!(levels[1] < -100.0, "silence");
let missing = r#"{"request_id":2,"error":"property unavailable"}"#;
assert_eq!(parse_event(missing), None);
assert_eq!(
parse_event(r#"{"request_id":2,"error":"success","data":"odd"}"#),
None
);
assert_eq!(
parse_event(r#"{"event":"playback-restart"}"#),
Some(PlayerEventKind::Restarted)
);
assert_eq!(parse_event(r#"{"request_id":0,"error":"error"}"#), None);
}
#[test]
fn the_visualizers_commands_are_json_mpv_answers_by_number() {
let add = line_numbered(
serde_json::json!(["af", "add", visualizer::filter()]),
FILTER_ADDED,
);
let parsed: serde_json::Value = serde_json::from_str(add.trim_end()).unwrap();
assert_eq!(parsed["request_id"], 1);
assert_eq!(parsed["command"][2], visualizer::filter().as_str());
assert_eq!(add.matches('\n').count(), 1, "one line");
}
#[test]
fn sound_alone_keeps_minutes_in_memory_not_hours() {
let (tools, streams) = (Tools::default(), streams());
let mut start = start(&tools, &streams, "A video");
let cache = |start: &Start| {
args(start)
.iter()
.filter(|a| a.starts_with("--demuxer-max"))
.cloned()
.collect::<Vec<_>>()
};
assert!(cache(&start).is_empty(), "a video keeps mpv's own");
start.audio_only = true;
assert_eq!(
cache(&start),
["--demuxer-max-bytes=16MiB", "--demuxer-max-back-bytes=8MiB"]
);
assert!(args(&start).contains(&"--no-video".to_string()));
}
#[test]
fn only_playing_to_the_end_counts_as_ended() {
let end = |reason: &str| {
parse_event(&format!(
r#"{{"event":"end-file","reason":"{reason}","playlist_entry_id":1}}"#
))
};
assert_eq!(end("eof"), Some(PlayerEventKind::Ended));
assert_eq!(end("error"), Some(PlayerEventKind::Failed));
for reason in ["quit", "stop", "redirect", "unknown"] {
assert_eq!(end(reason), None, "{reason}");
}
assert_eq!(parse_event(r#"{"event":"end-file"}"#), None);
}
}
#[cfg(all(test, unix))]
mod live {
use super::*;
use crate::app::AppEvent;
#[tokio::test]
#[ignore = "runs mpv"]
async fn live_mpv_plays_what_the_socket_sends_and_stops() {
let tools = Tools::find(None, None, None);
let mpv = tools.mpv.clone().expect("mpv is installed");
let streams = Streams {
video: "av://lavfi:anullsrc=d=5".into(),
audio: Some("av://lavfi:anullsrc=d=5".into()),
user_agent: Some("tuitube-test".into()),
duration: None,
chapters: Vec::new(),
quality: None,
info: None,
captions: Vec::new(),
};
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let start = Start {
mpv: &mpv,
tools: &tools,
streams: &streams,
title: "silence, with \"quotes\" and ${path}",
start_at: Some(1.0),
audio_only: true,
subtitles: None,
subtitles_shown: false,
volume: 40,
muted: true,
looped: false,
artist: None,
now_playing: false,
pepper: None,
};
let mut player = Player::start(7, start, tx).unwrap();
let mut positions = Vec::new();
let mut volumes = Vec::new();
let mut muted = Vec::new();
let mut exited = false;
let mut ended = false;
let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
while let Ok(Some(event)) = tokio::time::timeout_at(deadline, rx.recv()).await {
let AppEvent::Player(event) = event else {
continue;
};
assert_eq!(event.play, 7);
match event.kind {
PlayerEventKind::Position(at) => {
positions.push(at);
if positions.len() == 1 {
player.set_volume(65);
player.set_muted(false);
}
if positions.len() == 3 {
player.stop();
}
}
PlayerEventKind::Volume(volume) => volumes.push(volume),
PlayerEventKind::Muted(m) => muted.push(m),
PlayerEventKind::Ended => ended = true,
PlayerEventKind::Exited => {
exited = true;
break;
}
_ => {}
}
}
assert!(
!positions.is_empty(),
"nothing played: the socket's file didn't load"
);
assert_eq!(volumes, [40.0, 65.0], "never mpv's own 100");
assert_eq!(muted, [true, false]);
assert!(positions[0] >= 0.9, "started at --start: {positions:?}");
assert!(exited, "mpv didn't quit");
assert!(!ended, "stopped isn't played to the end");
}
#[tokio::test]
#[ignore = "runs mpv"]
async fn live_mpv_seeks_to_where_a_skipped_segment_ends() {
let tools = Tools::find(None, None, None);
let mpv = tools.mpv.clone().expect("mpv is installed");
let streams = Streams {
video: "av://lavfi:anullsrc=d=10".into(),
audio: None,
user_agent: None,
duration: None,
chapters: Vec::new(),
quality: None,
info: None,
captions: Vec::new(),
};
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let start = Start {
mpv: &mpv,
tools: &tools,
streams: &streams,
title: "ten seconds of silence",
start_at: None,
audio_only: true,
subtitles: None,
subtitles_shown: false,
volume: 100,
muted: false,
looped: false,
artist: None,
now_playing: false,
pepper: None,
};
let mut player = Player::start(9, start, tx).unwrap();
let mut sought = None;
let mut landed = None;
let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
while let Ok(Some(event)) = tokio::time::timeout_at(deadline, rx.recv()).await {
let AppEvent::Player(event) = event else {
continue;
};
match event.kind {
PlayerEventKind::Position(at) if sought.is_none() && at < 1.0 => {
player.seek_to(6.5);
sought = Some(std::time::Instant::now());
}
PlayerEventKind::Position(at) if at >= 6.5 && landed.is_none() => {
landed = sought.map(|t| (at, t.elapsed()));
player.stop();
}
PlayerEventKind::Exited => break,
_ => {}
}
}
let (at, after) = landed.expect("the seek didn't happen");
assert!(after < Duration::from_secs(3), "{after:?}");
assert!(at < 8.0, "landed at {at}");
}
#[tokio::test]
#[ignore = "runs mpv"]
async fn live_mpv_measures_the_sound_for_the_visualizer() {
let tools = Tools::find(None, None, None);
let mpv = tools.mpv.clone().expect("mpv is installed");
let streams = Streams {
video: "av://lavfi:sine=f=100:d=8".into(),
audio: None,
user_agent: None,
duration: None,
chapters: Vec::new(),
quality: None,
info: None,
captions: Vec::new(),
};
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let start = Start {
mpv: &mpv,
tools: &tools,
streams: &streams,
title: "a hum",
start_at: None,
audio_only: true,
subtitles: None,
subtitles_shown: false,
volume: 100,
muted: true,
looped: false,
artist: None,
now_playing: false,
pepper: None,
};
let mut player = Player::start(6, start, tx).unwrap();
let mut added = None;
let mut spectra: Vec<Levels> = Vec::new();
let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
while let Ok(Some(event)) = tokio::time::timeout_at(deadline, rx.recv()).await {
let AppEvent::Player(event) = event else {
continue;
};
match event.kind {
PlayerEventKind::Restarted if added.is_none() => {
added = Some(false);
player.add_visualizer();
}
PlayerEventKind::Visualizer(ok) => {
added = Some(ok);
assert!(ok, "the filter didn't go in");
player.follow_spectrum(true);
}
PlayerEventKind::Spectrum(levels) => {
spectra.push(levels);
if spectra.len() == 10 {
player.remove_visualizer();
player.stop();
}
}
PlayerEventKind::Exited => break,
_ => {}
}
}
assert_eq!(added, Some(true));
assert!(spectra.len() >= 10, "{} answers", spectra.len());
let last = spectra.last().unwrap();
let loudest = (0..visualizer::BANDS)
.max_by(|&a, &b| last[a].total_cmp(&last[b]))
.unwrap();
assert!(matches!(loudest, 3 | 4), "{loudest}: {last:?}");
assert!(last[loudest] > -60.0, "{last:?}");
assert!(last[19] < last[loudest] - 40.0, "the treble: {last:?}");
}
#[tokio::test]
#[ignore = "runs mpv"]
async fn live_mpv_plays_the_edl_with_the_artist_as_the_bare_file() {
let tools = Tools::find(None, None, None);
let mpv = tools.mpv.clone().expect("mpv is installed");
let dir = std::env::temp_dir().join(format!("tuitube-edl-{}", std::process::id()));
crate::config::private_dir(&dir).unwrap();
let file = dir.join("silence.mka");
let made = std::process::Command::new(&mpv)
.args(["--no-config", "--terminal=no", "--oac=flac"])
.arg(format!("--o={}", file.display()))
.arg("av://lavfi:anullsrc=d=4")
.status()
.unwrap();
assert!(made.success(), "mpv wrote the silence");
let streams = Streams {
video: file.to_str().unwrap().into(),
audio: None,
user_agent: None,
duration: None,
chapters: Vec::new(),
quality: None,
info: None,
captions: Vec::new(),
};
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let start = Start {
mpv: &mpv,
tools: &tools,
streams: &streams,
title: "four seconds of silence",
start_at: Some(1.0),
audio_only: true,
subtitles: None,
subtitles_shown: false,
volume: 0,
muted: true,
looped: false,
artist: Some("a channel; with, %3% Kênh"),
now_playing: false,
pepper: None,
};
let _player = Player::start(9, start, tx).unwrap();
let (mut positions, mut kinds) = (Vec::new(), Vec::new());
let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
while let Ok(Some(event)) = tokio::time::timeout_at(deadline, rx.recv()).await {
if let AppEvent::Player(event) = event {
match event.kind {
PlayerEventKind::Position(at) => positions.push(at),
PlayerEventKind::Ended | PlayerEventKind::Exited => kinds.push(event.kind),
_ => {}
}
if kinds.last() == Some(&PlayerEventKind::Exited) {
break;
}
}
}
let _ = std::fs::remove_dir_all(&dir);
assert!(
positions.first().is_some_and(|&at| at >= 0.9),
"started at --start: {positions:?}"
);
assert_eq!(
kinds,
[PlayerEventKind::Ended, PlayerEventKind::Exited],
"played to its end, then quit by itself"
);
}
#[tokio::test]
#[ignore = "runs mpv"]
async fn live_mpv_says_when_a_video_plays_to_its_end() {
let tools = Tools::find(None, None, None);
let mpv = tools.mpv.clone().expect("mpv is installed");
let streams = Streams {
video: "av://lavfi:anullsrc=d=2".into(),
audio: None,
user_agent: None,
duration: None,
chapters: Vec::new(),
quality: None,
info: None,
captions: Vec::new(),
};
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let start = Start {
mpv: &mpv,
tools: &tools,
streams: &streams,
title: "two seconds of silence",
start_at: None,
audio_only: true,
subtitles: None,
subtitles_shown: false,
volume: 100,
muted: false,
looped: false,
artist: None,
now_playing: false,
pepper: None,
};
let _player = Player::start(8, start, tx).unwrap();
let mut kinds = Vec::new();
let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
while let Ok(Some(event)) = tokio::time::timeout_at(deadline, rx.recv()).await {
if let AppEvent::Player(event) = event {
match event.kind {
PlayerEventKind::Ended | PlayerEventKind::Exited => kinds.push(event.kind),
_ => {}
}
if kinds.last() == Some(&PlayerEventKind::Exited) {
break;
}
}
}
assert_eq!(
kinds,
[PlayerEventKind::Ended, PlayerEventKind::Exited],
"ended, then quit by itself"
);
}
}