use std::collections::HashSet;
use std::io::Read;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use serde::Deserialize;
use crate::config;
use crate::ids::{ChannelId, PlaylistId, VideoId};
use crate::links::{self, Link};
use crate::store::Store;
use crate::video::{MAX_CHANNEL, MAX_TITLE, Video, one_line};
const MAX_BYTES: u64 = 8 * 1024 * 1024;
const MAX_HISTORY_BYTES: u64 = 256 * 1024 * 1024;
pub const MAX_CHANNELS: usize = 5_000;
const MAX_PLAYLIST: usize = 5_000;
const MAX_HISTORY: usize = 50_000;
const MAX_DEPTH: usize = 4;
const MAX_ENTRIES: usize = 2_000;
const WATCH_LATER: [&str; 16] = [
"watch later",
"später ansehen",
"à regarder plus tard",
"ver más tarde",
"guarda più tardi",
"assistir mais tarde",
"ver mais tarde",
"later bekijken",
"do obejrzenia",
"смотреть позже",
"後で見る",
"나중에 볼 동영상",
"稍后观看",
"稍後觀看",
"daha sonra izle",
"xem sau",
];
pub type Added = Vec<(VideoId, Option<i64>)>;
#[derive(Debug, PartialEq)]
pub enum Takeout {
Subscriptions(Vec<(ChannelId, String)>),
Playlist {
name: String,
watch_later: bool,
videos: Added,
},
History(Vec<(Video, i64)>),
HistoryPage,
}
pub fn find(path: &Path) -> Result<Vec<Takeout>> {
local(path)?;
if std::fs::metadata(path).is_ok_and(|m| m.is_dir()) {
let found = read_folder(path)?;
if found.is_empty() {
bail!(
"{} has no Takeout files tuitube reads: subscriptions.csv, playlists' \
<name>-videos.csv, watch-history.json",
config::shown(path)
);
}
return Ok(found.into_iter().map(|(_, takeout)| takeout).collect());
}
Ok(vec![read(path)?])
}
pub fn read(path: &Path) -> Result<Takeout> {
let shown = config::shown(path);
let json = extension_is(path, "json");
if extension_is(path, "html") || extension_is(path, "htm") {
let start = read_file(path, MAX_BYTES, true)?;
if memchr_str(&start, b"youtube.com/watch?v=") {
return Ok(Takeout::HistoryPage);
}
bail!("{shown} isn't a YouTube watch history");
}
let max = if json { MAX_HISTORY_BYTES } else { MAX_BYTES };
let data = read_file(path, max, false)?;
if json || data.iter().find(|b| !b.is_ascii_whitespace()) == Some(&b'[') {
let history = history(&data);
if history.is_empty() {
bail!(
"{shown} lists no videos watched: it should be watch-history.json from Google Takeout"
);
}
return Ok(Takeout::History(history));
}
let channels = subscriptions(&data);
if channels.len() > MAX_CHANNELS {
bail!("{shown} lists more than {MAX_CHANNELS} channels");
}
if !channels.is_empty() {
return Ok(Takeout::Subscriptions(channels));
}
if let Some((name, videos)) = playlist(&data) {
let name = name.unwrap_or_else(|| playlist_name(path));
let watch_later = WATCH_LATER.contains(&name.to_lowercase().as_str());
return Ok(Takeout::Playlist {
name,
watch_later,
videos,
});
}
bail!(
"{shown} isn't a Takeout file tuitube reads: subscriptions.csv, a playlist's \
<name>-videos.csv, or watch-history.json"
)
}
pub fn read_folder(dir: &Path) -> Result<Vec<(PathBuf, Takeout)>> {
local(dir)?;
let mut found = Vec::new();
let mut folders = vec![(dir.to_path_buf(), 0)];
let mut looked = 0;
while let Some((folder, depth)) = folders.pop() {
let Ok(entries) = std::fs::read_dir(&folder) else {
continue;
};
for entry in entries.flatten() {
looked += 1;
if looked > MAX_ENTRIES {
return Ok(found);
}
let Ok(kind) = entry.file_type() else {
continue;
};
let path = entry.path();
if kind.is_dir() && depth < MAX_DEPTH {
folders.push((path, depth + 1));
} else if kind.is_file()
&& ["csv", "json", "html"]
.iter()
.any(|e| extension_is(&path, e))
&& let Ok(takeout) = read(&path)
{
found.push((path, takeout));
}
}
}
found.sort_by(|a, b| a.0.cmp(&b.0));
Ok(found)
}
#[derive(Debug, Default, PartialEq)]
pub struct Imported {
pub channels: usize,
pub new_channels: usize,
pub watch_later: usize,
pub playlists: Vec<(String, usize)>,
pub history: usize,
pub history_off: bool,
pub history_page: bool,
}
impl Imported {
pub fn summary(&self) -> String {
let count = |n: usize, one: &str| {
let s = if n == 1 { "" } else { "s" };
format!("{n} {one}{s}")
};
let mut parts = Vec::new();
if self.channels > 0 {
parts.push(format!(
"{} ({} new)",
count(self.channels, "subscription"),
self.new_channels
));
}
if self.watch_later > 0 {
parts.push(format!(
"{} to Watch later",
count(self.watch_later, "video")
));
}
match self.playlists.as_slice() {
[] => {}
[(name, n)] => parts.push(format!("the playlist “{name}” ({})", count(*n, "video"))),
lists => parts.push(count(lists.len(), "playlist")),
}
if self.history > 0 {
parts.push(format!("{} of history", count(self.history, "video")));
}
let mut said = match parts.as_slice() {
[] => "Nothing new to import".to_string(),
[one] => format!("Imported {one}"),
[rest @ .., last] => format!("Imported {} and {last}", rest.join(", ")),
};
if self.history_off {
said.push_str(". History is off, so it wasn't imported");
} else if self.history_page {
said.push_str(
". For your history, export it again as JSON (History: “Multiple formats”)",
);
}
said
}
}
pub fn save(store: &mut Store, found: Vec<Takeout>, history: bool) -> Result<Imported> {
let mut imported = Imported::default();
for takeout in found {
match takeout {
Takeout::Subscriptions(channels) => {
imported.new_channels += store.subscribe_all(&channels)?;
imported.channels += channels.len();
}
Takeout::Playlist {
watch_later: true,
videos,
..
} => imported.watch_later += store.import_watch_later(&videos)?,
Takeout::Playlist { name, videos, .. } => {
let added = store.import_playlist(&name, &videos)?;
imported.playlists.push((name, added));
}
Takeout::History(_) if !history => imported.history_off = true,
Takeout::History(watched) => imported.history += store.import_history(&watched)?,
Takeout::HistoryPage => imported.history_page = true,
}
}
Ok(imported)
}
fn extension_is(path: &Path, extension: &str) -> bool {
path.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case(extension))
}
fn local(path: &Path) -> Result<()> {
if config::maybe_remote(path) {
bail!(
"{} is on another computer, or may be (on Windows, use a path with a drive letter)",
config::shown(path)
);
}
Ok(())
}
fn memchr_str(haystack: &[u8], needle: &[u8]) -> bool {
haystack.windows(needle.len()).any(|w| w == needle)
}
fn read_file(path: &Path, max: u64, start: bool) -> Result<Vec<u8>> {
let shown = config::shown(path);
local(path)?;
let mut options = std::fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::custom_flags(&mut options, libc::O_NONBLOCK);
let file = options
.open(path)
.with_context(|| format!("cannot read {shown}"))?;
if !file.metadata()?.is_file() {
bail!("{shown} isn't a file");
}
let mut data = Vec::new();
file.take(max + 1)
.read_to_end(&mut data)
.with_context(|| format!("cannot read {shown}"))?;
if data.len() as u64 > max {
if !start {
bail!("{shown} is too big to be from Takeout");
}
data.truncate(max as usize);
}
Ok(data)
}
fn csv_rows(data: &[u8]) -> impl Iterator<Item = csv::StringRecord> + '_ {
csv::ReaderBuilder::new()
.has_headers(false)
.flexible(true)
.from_reader(data)
.into_records()
.flatten()
}
fn subscriptions(data: &[u8]) -> Vec<(ChannelId, String)> {
let mut channels: Vec<(ChannelId, String)> = Vec::new();
let mut seen = HashSet::new();
for record in csv_rows(data) {
let field = |i| record.get(i).unwrap_or("").trim();
let by_url = ChannelId::from_url(field(1));
let by_id = ChannelId::parse(field(0)).filter(|_| record.len() <= 3);
let Some(id) = by_url.or(by_id) else {
continue;
};
if !seen.insert(id.clone()) {
continue;
}
channels.push((id, one_line(field(2), MAX_CHANNEL)));
if channels.len() > MAX_CHANNELS {
break;
}
}
channels
}
fn playlist(data: &[u8]) -> Option<(Option<String>, Added)> {
let mut name = None;
let mut videos = Vec::new();
let mut seen = HashSet::new();
for (i, record) in csv_rows(data).enumerate() {
let field = |i| record.get(i).unwrap_or("").trim();
if i <= 1 && record.len() >= 5 && PlaylistId::parse(field(0)).is_some() {
name = Some(one_line(field(4), MAX_TITLE)).filter(|n| !n.is_empty());
continue;
}
let Some(id) = VideoId::parse(field(0)).filter(|_| record.len() <= 2) else {
continue;
};
if seen.insert(id.clone()) {
videos.push((id, time(field(1))));
}
if videos.len() >= MAX_PLAYLIST {
break;
}
}
(!videos.is_empty()).then_some((name, videos))
}
fn playlist_name(path: &Path) -> String {
let stem = path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let name = match stem.rsplit_once('-') {
Some((name, suffix)) if !name.is_empty() && suffix.chars().all(char::is_alphabetic) => name,
_ => &stem,
};
let name = one_line(name, MAX_TITLE);
if name.is_empty() {
"Imported playlist".into()
} else {
name
}
}
fn time(text: &str) -> Option<i64> {
let at = chrono::DateTime::parse_from_rfc3339(text).ok()?.timestamp();
(0..4_102_444_800).contains(&at).then_some(at)
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct Watch {
header: String,
title: String,
#[serde(rename = "titleUrl")]
title_url: String,
#[serde(deserialize_with = "crate::ytdlp::lenient")]
subtitles: Vec<Subtitle>,
#[serde(deserialize_with = "crate::ytdlp::lenient")]
details: Vec<serde::de::IgnoredAny>,
time: String,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct Subtitle {
name: String,
url: String,
}
fn history(data: &[u8]) -> Vec<(Video, i64)> {
#[derive(Deserialize)]
struct All(#[serde(deserialize_with = "crate::ytdlp::lenient")] Vec<Watch>);
let Ok(All(entries)) = serde_json::from_slice::<All>(data) else {
return Vec::new();
};
let mut seen = HashSet::new();
let mut watched = Vec::new();
for entry in entries {
if entry.header != "YouTube" || !entry.details.is_empty() {
continue;
}
let (Some(Link::Video { id, short, .. }), Some(at)) =
(links::parse(&entry.title_url), time(&entry.time))
else {
continue;
};
if !seen.insert(id.clone()) {
continue;
}
let channel = entry.subtitles.first();
let title = entry
.title
.strip_prefix("Watched ")
.map(|t| one_line(t, MAX_TITLE))
.filter(|t| !t.is_empty() && !t.starts_with("https://"));
let mut video = Video::placeholder(id, short);
if let Some(title) = title {
video.title = title;
}
if let Some(channel) = channel {
video.channel_id = ChannelId::from_url(&channel.url);
video.channel = one_line(&channel.name, MAX_CHANNEL);
}
watched.push((video, at));
if watched.len() >= MAX_HISTORY {
break;
}
}
watched
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rows_are_read_by_position_whatever_the_headers_language() {
let csv = "ID des chaînes,URL des chaînes,Titres des chaînes\n\
UC7EVSn5inapL20oPSwAwEUg,http://www.youtube.com/channel/UC7EVSn5inapL20oPSwAwEUg,BekBrace\n\
UCsBjURrPoezykLs9EqgamOA,http://www.youtube.com/channel/UCsBjURrPoezykLs9EqgamOA,\"Fireship, the \"\"real\"\" one\"\n\
\n\
not-an-id,https://evil.example/,Evil\n\
UC7EVSn5inapL20oPSwAwEUg,http://www.youtube.com/channel/UC7EVSn5inapL20oPSwAwEUg,Again\n";
let channels = subscriptions(csv.as_bytes());
let names: Vec<_> = channels.iter().map(|(_, name)| name.as_str()).collect();
assert_eq!(names, ["BekBrace", "Fireship, the \"real\" one"]);
}
#[test]
fn names_are_cleaned_and_kept_to_one_line() {
let csv = "UC7EVSn5inapL20oPSwAwEUg,,\"Bek\u{1b}[31m\nBrace\u{202E}\"\n";
assert_eq!(subscriptions(csv.as_bytes())[0].1, "Bek[31m Brace");
}
#[test]
fn your_own_channel_and_uploads_arent_subscriptions_or_a_playlist() {
let channel = "Channel ID,Channel Title (Original),Channel Visibility,Channel Country\n\
UC7EVSn5inapL20oPSwAwEUg,Me,Public,FR\n";
assert!(subscriptions(channel.as_bytes()).is_empty());
assert!(playlist(channel.as_bytes()).is_none());
let uploads = "Video ID,Approx Duration (ms),Video Audio Language,Video Title\n\
dQw4w9WgXcQ,212000,en,Mine\n";
assert!(playlist(uploads.as_bytes()).is_none());
}
#[test]
fn a_playlists_videos_in_either_kind_of_export() {
let new = "Video ID,Playlist Video Creation Timestamp\n\
dQw4w9WgXcQ,2024-03-01T12:00:00+00:00\n\
9bZkp7q19f0,not a time\n\
dQw4w9WgXcQ,2024-03-02T12:00:00+00:00\n\
-oops,2024-03-01T12:00:00+00:00\n";
let (name, videos) = playlist(new.as_bytes()).unwrap();
assert_eq!(name, None);
let ids: Vec<_> = videos.iter().map(|(id, at)| (id.as_str(), *at)).collect();
assert_eq!(
ids,
[("dQw4w9WgXcQ", Some(1_709_294_400)), ("9bZkp7q19f0", None)]
);
let old = "Playlist Id,Channel Id,Time Created,Time Updated,Title,Description,Visibility\n\
PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf,UC7EVSn5inapL20oPSwAwEUg,2020-01-01,2020-01-02,Road trip,,Private\n\
\n\
Video Id,Time Added\n\
dQw4w9WgXcQ,2020-01-01 10:00:00 UTC\n";
let (name, videos) = playlist(old.as_bytes()).unwrap();
assert_eq!(name.as_deref(), Some("Road trip"));
assert_eq!(videos.len(), 1);
}
#[test]
fn a_playlist_is_named_after_its_file_and_watch_later_known_in_other_languages() {
assert_eq!(
playlist_name(Path::new("/t/Road trip-videos.csv")),
"Road trip"
);
assert_eq!(playlist_name(Path::new("/t/Lo-fi-Videos.csv")), "Lo-fi");
assert_eq!(playlist_name(Path::new("/t/plain.csv")), "plain");
let dir = temp("names");
let path = dir.join("Später ansehen-Videos.csv");
std::fs::write(&path, "dQw4w9WgXcQ,2024-03-01T12:00:00+00:00\n").unwrap();
let Takeout::Playlist { watch_later, .. } = read(&path).unwrap() else {
panic!("a playlist");
};
assert!(watch_later);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn watch_history_keeps_videos_not_ads_or_music_each_once() {
let json = r#"[
{"header":"YouTube","title":"Watched Rust in 100 Seconds","titleUrl":"https://www.youtube.com/watch?v=5C_HPTJg5ek",
"subtitles":[{"name":"Fireship","url":"https://www.youtube.com/channel/UCsBjURrPoezykLs9EqgamOA"}],
"time":"2024-05-02T10:00:00.123Z","products":["YouTube"],"activityControls":["YouTube watch history"]},
{"header":"YouTube","title":"Watched An ad","titleUrl":"https://www.youtube.com/watch?v=aaaaaaaaaaa",
"details":[{"name":"From Google Ads"}],"time":"2024-05-02T09:00:00Z"},
{"header":"YouTube Music","title":"Watched A song","titleUrl":"https://music.youtube.com/watch?v=bbbbbbbbbbb","time":"2024-05-02T08:00:00Z"},
{"header":"YouTube","title":"Hai guardato Un video","titleUrl":"https://www.youtube.com/watch?v=ccccccccccc","time":"2024-05-01T08:00:00Z"},
{"header":"YouTube","title":"Watched https://www.youtube.com/watch?v=ddddddddddd","titleUrl":"https://www.youtube.com/watch?v=ddddddddddd","time":"2024-04-01T08:00:00Z"},
{"header":"YouTube","title":"Watched Rust in 100 Seconds","titleUrl":"https://www.youtube.com/watch?v=5C_HPTJg5ek","time":"2024-01-01T08:00:00Z"},
{"header":"YouTube","title":"Watched a video that has been removed","time":"2024-01-01T08:00:00Z"},
"not an entry",
{"header":"YouTube","title":"Searched for rust","titleUrl":"https://www.youtube.com/results?search_query=rust","time":"2024-01-01T08:00:00Z"}
]"#;
let watched = history(json.as_bytes());
let shown: Vec<_> = watched
.iter()
.map(|(v, at)| (v.id.as_str(), v.title.as_str(), v.needs_details(), *at))
.collect();
assert_eq!(
shown,
[
("5C_HPTJg5ek", "Rust in 100 Seconds", false, 1_714_644_000),
("ccccccccccc", "youtu.be/ccccccccccc", true, 1_714_550_400),
("ddddddddddd", "youtu.be/ddddddddddd", true, 1_711_958_400),
]
);
let (first, _) = &watched[0];
assert_eq!(first.channel, "Fireship");
assert_eq!(
first.channel_id.as_ref().map(ChannelId::as_str),
Some("UCsBjURrPoezykLs9EqgamOA")
);
}
fn temp(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("tuitube-takeout-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn a_takeout_folder_is_searched_for_what_it_holds() {
let dir = temp("folder");
let youtube = dir.join("Takeout").join("YouTube and YouTube Music");
for sub in ["subscriptions", "playlists", "history", "channels"] {
std::fs::create_dir_all(youtube.join(sub)).unwrap();
}
std::fs::write(
youtube.join("subscriptions").join("subscriptions.csv"),
"Channel Id,Channel Url,Channel Title\n\
UC7EVSn5inapL20oPSwAwEUg,http://www.youtube.com/channel/UC7EVSn5inapL20oPSwAwEUg,BekBrace\n",
)
.unwrap();
std::fs::write(
youtube.join("playlists").join("Watch later-videos.csv"),
"Video ID,Playlist Video Creation Timestamp\ndQw4w9WgXcQ,2024-03-01T12:00:00+00:00\n",
)
.unwrap();
std::fs::write(
youtube.join("playlists").join("playlists.csv"),
"Playlist ID,Add new videos to top,Playlist Title (Original)\n\
PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf,False,Road trip\n",
)
.unwrap();
std::fs::write(
youtube.join("history").join("watch-history.html"),
"<html>Watched <a href=\"https://www.youtube.com/watch?v=dQw4w9WgXcQ\">A video</a>",
)
.unwrap();
std::fs::write(dir.join("Takeout").join("archive_browser.html"), "<html>").unwrap();
std::fs::write(
youtube.join("channels").join("channel.csv"),
"Channel ID,Channel Title (Original),Channel Visibility,Country\n\
UC7EVSn5inapL20oPSwAwEUg,Me,Public,FR\n",
)
.unwrap();
let found: Vec<Takeout> = read_folder(&dir)
.unwrap()
.into_iter()
.map(|(_, t)| t)
.collect();
assert_eq!(found.len(), 3, "{found:?}");
assert!(found.contains(&Takeout::HistoryPage));
assert!(
found
.iter()
.any(|t| matches!(t, Takeout::Subscriptions(c) if c.len() == 1))
);
assert!(found
.iter()
.any(|t| matches!(t, Takeout::Playlist { watch_later: true, videos, .. } if videos.len() == 1)));
let mut store = Store::in_memory();
let imported = save(&mut store, found, true).unwrap();
assert_eq!(
imported.summary(),
"Imported 1 subscription (1 new) and 1 video to Watch later. For your history, \
export it again as JSON (History: “Multiple formats”)"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[cfg(unix)]
#[test]
fn devices_and_pipes_are_refused_without_reading_them() {
let dir = temp("dev");
let link = dir.join("subscriptions.csv");
std::os::unix::fs::symlink("/dev/zero", &link).unwrap();
let error = read(&link).unwrap_err().to_string();
assert!(error.contains("isn't a file"), "{error}");
assert!(read_folder(&dir).unwrap().is_empty());
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_file_without_any_of_it_is_an_error() {
let dir = temp("none");
let path = dir.join("subscriptions.csv");
std::fs::write(&path, "just,some,text\n").unwrap();
assert!(read(&path).is_err());
std::fs::remove_dir_all(&dir).unwrap();
}
}