// yt - A fully featured command line YouTube client
//
// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
// SPDX-License-Identifier: GPL-3.0-or-later
//
// This file is part of Yt.
//
// You should have received a copy of the License along with this program.
// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
use std::{
fs::{self, canonicalize},
io::{stderr, stdout, Read},
mem,
os::unix::fs::symlink,
path::PathBuf,
process::Command,
sync::mpsc::{self, Receiver, Sender},
thread::{self, JoinHandle},
};
use anyhow::{bail, Context, Result};
use log::{debug, error, warn};
use url::Url;
use crate::constants::{status_path, CONCURRENT, DOWNLOAD_DIR, MPV_FLAGS, YT_DLP_FLAGS};
#[derive(Debug)]
pub struct Downloadable {
pub url: Url,
pub id: Option<u32>,
}
impl std::fmt::Display for Downloadable {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
write!(
f,
"{}|{}",
self.url.as_str().replace('|', ";"),
self.id.unwrap_or(0),
)
}
}
pub struct Downloader {
sent: usize,
download_thread: JoinHandle<Result<()>>,
orx: Receiver<(PathBuf, Option<u32>)>,
itx: Option<Sender<Downloadable>>,
playspec: Vec<Downloadable>,
}
impl Downloader {
pub fn new(mut playspec: Vec<Downloadable>) -> anyhow::Result<Downloader> {
let (itx, irx): (Sender<Downloadable>, Receiver<Downloadable>) = mpsc::channel();
let (otx, orx) = mpsc::channel();
let jh = thread::spawn(move || -> Result<()> {
while let Ok(pt) = irx.recv() {
debug!("Got '{}' to be downloaded", pt);
let path = download_url(&pt.url)
.with_context(|| format!("Failed to download url: '{}'", &pt.url))?;
otx.send((path, pt.id)).expect("Should not be dropped");
}
debug!("Finished Downloading everything");
Ok(())
});
playspec.reverse();
let mut output = Downloader {
sent: 0,
download_thread: jh,
orx,
itx: Some(itx),
playspec,
};
if output.playspec.len() <= CONCURRENT as usize {
output.add(output.playspec.len() as u32)?;
} else {
output.add(CONCURRENT)?;
}
Ok(output)
}
pub fn add(&mut self, number_to_add: u32) -> Result<()> {
debug!("Adding {} to be downloaded concurrently", number_to_add);
for _ in 0..number_to_add {
let pt = self.playspec.pop().expect("This call should be guarded");
self.itx.as_ref().expect("Should still be valid").send(pt)?;
self.sent += 1;
}
Ok(())
}
/// Return the next video already downloaded, will block until the download is complete
pub fn next(&mut self) -> Option<(PathBuf, Option<u32>)> {
debug!("Requesting next output");
match self.orx.recv() {
Ok(ok) => {
debug!("Output downloaded to: {}", ok.0.display());
if !self.playspec.is_empty() {
self.add(1).ok()?;
} else {
debug!(
"Done sending videos to be downloaded, downoladed: {} videos",
self.sent
);
let itx = mem::take(&mut self.itx);
drop(itx)
}
debug!("Returning: {}|{}", ok.0.display(), ok.1.unwrap_or(0));
Some(ok)
}
Err(err) => {
debug!("Received error while listening: {}", err);
None
}
}
}
pub fn drop(self) -> anyhow::Result<()> {
// Check that we really downloaded everything
assert_eq!(self.playspec.len(), 0);
match self.download_thread.join() {
Ok(ok) => ok,
Err(err) => panic!("Failed to join downloader thread: '{:#?}'", err),
}
}
pub fn consume(mut self) -> anyhow::Result<()> {
while let Some((path, id)) = self.next() {
debug!("Next path to play is: '{}'", path.display());
let mut info_json = canonicalize(&path).context("Failed to canoncialize path")?;
info_json.set_extension("info.json");
if status_path()?.is_symlink() {
fs::remove_file(status_path()?).context("Failed to delete old status file")?;
} else if !status_path()?.exists() {
debug!(
"The status path at '{}' does not exists",
status_path()?.display()
);
} else {
bail!(
"The status path ('{}') is not a symlink but exists!",
status_path()?.display()
);
}
symlink(info_json, status_path()?).context("Failed to symlink")?;
let mut mpv = Command::new("mpv");
mpv.stdout(stdout());
mpv.stderr(stderr());
mpv.args(MPV_FLAGS);
// TODO: Set the title to the name of the video, not the path <2024-02-09>
// mpv.arg(format!("--title="))
mpv.arg(&path);
let status = mpv.status().context("Failed to run mpv")?;
if status.success() {
fs::remove_file(&path)?;
if let Some(id) = id {
println!("\x1b[32;1mMarking {} as watched!\x1b[0m", id);
let mut ytcc = std::process::Command::new("ytcc");
ytcc.stdout(stdout());
ytcc.stderr(stderr());
ytcc.args(["mark"]);
ytcc.arg(id.to_string());
let status = ytcc.status().context("Failed to run ytcc")?;
if let Some(code) = status.code() {
if code != 0 {
bail!("Ytcc failed with status: {}", code);
}
}
}
debug!("mpv exited with: '{}'", status);
} else {
warn!("mpv exited with: '{}'", status);
}
}
self.drop()?;
Ok(())
}
}
fn download_url(url: &Url) -> Result<PathBuf> {
let output_file = tempfile::NamedTempFile::new().context("Failed to create tempfile")?;
output_file
.as_file()
.set_len(0)
.context("Failed to truncate temp-file")?;
if !Into::<PathBuf>::into(DOWNLOAD_DIR).exists() {
fs::create_dir_all(DOWNLOAD_DIR)
.with_context(|| format!("Failed to create download dir at: {}", DOWNLOAD_DIR))?
}
let mut yt_dlp = Command::new("yt-dlp");
yt_dlp.current_dir(DOWNLOAD_DIR);
yt_dlp.stdout(stdout());
yt_dlp.stderr(stderr());
yt_dlp.args(YT_DLP_FLAGS);
yt_dlp.args([
"--output",
"%(channel)s/%(title)s.%(ext)s",
url.as_str(),
"--print-to-file",
"after_move:filepath",
]);
yt_dlp.arg(output_file.path().as_os_str());
let status = yt_dlp.status().context("Failed to run yt-dlp")?;
if !status.success() {
error!("yt-dlp execution failed with error: '{}'", status);
}
let mut path = String::new();
output_file
.as_file()
.read_to_string(&mut path)
.context("Failed to read output file temp file")?;
let path = path.trim();
Ok(path.into())
}