Compare commits
10 Commits
16c57ea101
...
49c4b4da17
| Author | SHA1 | Date | |
|---|---|---|---|
| 49c4b4da17 | |||
| c6ba925e2a | |||
| 866437736c | |||
| b0c8fa18c1 | |||
| 924d84b46b | |||
| ae5ee57028 | |||
| b05d4bcb47 | |||
| 7b7b65ca3f | |||
| 8ee9aa8d6c | |||
| e2fffa3afa |
@@ -1 +1,8 @@
|
|||||||
- [ ] bulk category editing: when on a certain category view, add bulk category editing, such that all channels saved to that category are updated with the new categories set.
|
- [x] bulk category editing: when on a certain category view, add bulk category editing, such that all channels saved to that category are updated with the new categories set.
|
||||||
|
- [x] ui improvement: display the pinned status on category tab titles for pinned categories
|
||||||
|
- [x] start the terebi player as a new instance explicitly:
|
||||||
|
```sh
|
||||||
|
terebi --new-instance
|
||||||
|
terebi --new-instance "/path/to/video.mp4"
|
||||||
|
terebi --new-instance --url 'https://youtu.be/jNQXAC9IVRw'
|
||||||
|
```
|
||||||
|
|||||||
+403
-50
@@ -1,5 +1,5 @@
|
|||||||
use std::{
|
use std::{
|
||||||
collections::BTreeMap,
|
collections::{BTreeMap, BTreeSet},
|
||||||
ffi::OsString,
|
ffi::OsString,
|
||||||
fs::{self, OpenOptions},
|
fs::{self, OpenOptions},
|
||||||
io::{self, Write},
|
io::{self, Write},
|
||||||
@@ -66,15 +66,7 @@ pub fn set_channel_tags(
|
|||||||
handle: &str,
|
handle: &str,
|
||||||
channel: &str,
|
channel: &str,
|
||||||
) -> io::Result<Config> {
|
) -> io::Result<Config> {
|
||||||
let mut normalized_tags = BTreeMap::new();
|
let normalized_tags = normalize_tags(tags)?;
|
||||||
for (name, checked) in tags {
|
|
||||||
let name = category_name(name)?;
|
|
||||||
if normalized_tags.insert(name, *checked).is_some() {
|
|
||||||
return Err(invalid(format!(
|
|
||||||
"Duplicate category name after trimming: {name:?}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let handle = normalize_handle(handle)?;
|
let handle = normalize_handle(handle)?;
|
||||||
let channel = normalize_channel(channel)?;
|
let channel = normalize_channel(channel)?;
|
||||||
if !channel.starts_with("https://www.youtube.com/channel/") {
|
if !channel.starts_with("https://www.youtube.com/channel/") {
|
||||||
@@ -144,46 +136,7 @@ pub fn set_channel_tags(
|
|||||||
channels.push(handle.as_str());
|
channels.push(handle.as_str());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for index in (0..channels.len()).rev() {
|
remove_category_channels(channels, &mappings, |identity| identity == channel)?;
|
||||||
if resolve_channel(
|
|
||||||
channels
|
|
||||||
.get(index)
|
|
||||||
.unwrap()
|
|
||||||
.as_str()
|
|
||||||
.expect("channel was validated"),
|
|
||||||
&mappings,
|
|
||||||
)? != channel
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let removed = channels.remove(index);
|
|
||||||
// Array comments can be attached to the next entry, not their own line.
|
|
||||||
let prefix = removed
|
|
||||||
.decor()
|
|
||||||
.prefix()
|
|
||||||
.and_then(|raw| raw.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
let suffix = removed
|
|
||||||
.decor()
|
|
||||||
.suffix()
|
|
||||||
.and_then(|raw| raw.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
if prefix.contains('#') || suffix.contains('#') {
|
|
||||||
if let Some(next) = channels.get_mut(index) {
|
|
||||||
let next_prefix = next
|
|
||||||
.decor()
|
|
||||||
.prefix()
|
|
||||||
.and_then(|raw| raw.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
let preserved = format!("{prefix}{suffix}{next_prefix}");
|
|
||||||
next.decor_mut().set_prefix(preserved);
|
|
||||||
} else {
|
|
||||||
let trailing = channels.trailing().as_str().unwrap_or("");
|
|
||||||
let preserved = format!("{prefix}{suffix}{trailing}");
|
|
||||||
channels.set_trailing(preserved);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let categories = parse_categories(&document, &mappings)?;
|
let categories = parse_categories(&document, &mappings)?;
|
||||||
@@ -195,6 +148,176 @@ pub fn set_channel_tags(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_category_tags(
|
||||||
|
path: &Path,
|
||||||
|
source: &str,
|
||||||
|
expected_channels: &[String],
|
||||||
|
tags: &BTreeMap<String, bool>,
|
||||||
|
) -> io::Result<Config> {
|
||||||
|
let source = category_name(source)?;
|
||||||
|
let tags = normalize_tags(tags)?;
|
||||||
|
let (original, mut document) = read_document(path)?;
|
||||||
|
let mappings = parse_channels(&document)?;
|
||||||
|
let parsed_categories = parse_categories(&document, &mappings)?;
|
||||||
|
let pinned_categories = parse_pins(&document)?;
|
||||||
|
let targets = parsed_categories
|
||||||
|
.get(source)
|
||||||
|
.ok_or_else(|| invalid(format!("Unknown source category: {source:?}")))?;
|
||||||
|
if targets.is_empty() {
|
||||||
|
return Err(invalid("Cannot edit tags for an empty source category"));
|
||||||
|
}
|
||||||
|
let expected: BTreeSet<_> = expected_channels.iter().map(String::as_str).collect();
|
||||||
|
if targets.len() != expected.len()
|
||||||
|
|| targets
|
||||||
|
.iter()
|
||||||
|
.any(|channel| !expected.contains(channel.as_str()))
|
||||||
|
{
|
||||||
|
return Err(invalid(
|
||||||
|
"Source category changed since opening the editor; please reopen it",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !tags.values().any(|checked| *checked)
|
||||||
|
&& targets
|
||||||
|
.iter()
|
||||||
|
.any(|channel| !mappings.values().any(|mapped| mapped == channel))
|
||||||
|
{
|
||||||
|
return Err(invalid(
|
||||||
|
"Cannot clear every category: a source channel is not registered in [channels]",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let categories = document["categories"]
|
||||||
|
.as_table_like_mut()
|
||||||
|
.expect("source category was validated");
|
||||||
|
let source_entries = categories
|
||||||
|
.iter()
|
||||||
|
.find(|(name, _)| name.trim() == source)
|
||||||
|
.and_then(|(_, item)| item.as_array())
|
||||||
|
.expect("source category was validated");
|
||||||
|
let mut additions = BTreeMap::new();
|
||||||
|
for entry in source_entries.iter() {
|
||||||
|
let raw = entry.as_str().expect("channel was validated").trim();
|
||||||
|
let identity = resolve_channel(raw, &mappings)?;
|
||||||
|
let readable = raw
|
||||||
|
.strip_prefix("https://www.youtube.com/")
|
||||||
|
.or_else(|| raw.strip_prefix("https://youtube.com/"))
|
||||||
|
.filter(|address| address.starts_with('@'))
|
||||||
|
.unwrap_or(raw);
|
||||||
|
let addition = additions
|
||||||
|
.entry(identity)
|
||||||
|
.or_insert_with(|| readable.to_owned());
|
||||||
|
if readable.starts_with('@') && !addition.starts_with('@') {
|
||||||
|
addition.clear();
|
||||||
|
addition.push_str(readable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (handle, identity) in &mappings {
|
||||||
|
if let Some(addition) = additions.get_mut(identity)
|
||||||
|
&& !addition.starts_with('@')
|
||||||
|
{
|
||||||
|
addition.clear();
|
||||||
|
addition.push_str(handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The checked set is exact, including categories added by another editor.
|
||||||
|
for (name, item) in categories.iter_mut() {
|
||||||
|
let entries = item.as_array_mut().expect("category was validated");
|
||||||
|
if tags.get(name.trim()).copied().unwrap_or(false) {
|
||||||
|
let present: BTreeSet<_> = entries
|
||||||
|
.iter()
|
||||||
|
.map(|entry| {
|
||||||
|
resolve_channel(entry.as_str().expect("channel was validated"), &mappings)
|
||||||
|
})
|
||||||
|
.collect::<io::Result<_>>()?;
|
||||||
|
for (identity, readable) in &additions {
|
||||||
|
if !present.contains(identity) {
|
||||||
|
entries.push(readable.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
remove_category_channels(entries, &mappings, |identity| {
|
||||||
|
additions.contains_key(identity)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (name, checked) in tags {
|
||||||
|
if checked && !parsed_categories.contains_key(name) {
|
||||||
|
let mut entries = Array::new();
|
||||||
|
for readable in additions.values() {
|
||||||
|
entries.push(readable.as_str());
|
||||||
|
}
|
||||||
|
categories.insert(name, value(entries));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let categories = parse_categories(&document, &mappings)?;
|
||||||
|
save(path, original.as_deref(), document.to_string().as_bytes())?;
|
||||||
|
Ok(Config {
|
||||||
|
channels: mappings,
|
||||||
|
categories,
|
||||||
|
pinned_categories,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_tags(tags: &BTreeMap<String, bool>) -> io::Result<BTreeMap<&str, bool>> {
|
||||||
|
let mut normalized = BTreeMap::new();
|
||||||
|
for (name, checked) in tags {
|
||||||
|
let name = category_name(name)?;
|
||||||
|
if normalized.insert(name, *checked).is_some() {
|
||||||
|
return Err(invalid(format!(
|
||||||
|
"Duplicate category name after trimming: {name:?}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_category_channels(
|
||||||
|
channels: &mut Array,
|
||||||
|
mappings: &BTreeMap<String, String>,
|
||||||
|
remove: impl Fn(&str) -> bool,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
for index in (0..channels.len()).rev() {
|
||||||
|
if !remove(&resolve_channel(
|
||||||
|
channels
|
||||||
|
.get(index)
|
||||||
|
.unwrap()
|
||||||
|
.as_str()
|
||||||
|
.expect("channel was validated"),
|
||||||
|
mappings,
|
||||||
|
)?) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let removed = channels.remove(index);
|
||||||
|
// Array comments can be attached to the next entry, not their own line.
|
||||||
|
let prefix = removed
|
||||||
|
.decor()
|
||||||
|
.prefix()
|
||||||
|
.and_then(|raw| raw.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let suffix = removed
|
||||||
|
.decor()
|
||||||
|
.suffix()
|
||||||
|
.and_then(|raw| raw.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
if prefix.contains('#') || suffix.contains('#') {
|
||||||
|
if let Some(next) = channels.get_mut(index) {
|
||||||
|
let next_prefix = next
|
||||||
|
.decor()
|
||||||
|
.prefix()
|
||||||
|
.and_then(|raw| raw.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let preserved = format!("{prefix}{suffix}{next_prefix}");
|
||||||
|
next.decor_mut().set_prefix(preserved);
|
||||||
|
} else {
|
||||||
|
let trailing = channels.trailing().as_str().unwrap_or("");
|
||||||
|
let preserved = format!("{prefix}{suffix}{trailing}");
|
||||||
|
channels.set_trailing(preserved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_category_pinned(path: &Path, name: &str, pinned: bool) -> io::Result<Config> {
|
pub fn set_category_pinned(path: &Path, name: &str, pinned: bool) -> io::Result<Config> {
|
||||||
let name = category_name(name)?;
|
let name = category_name(name)?;
|
||||||
let (original, mut document) = read_document(path)?;
|
let (original, mut document) = read_document(path)?;
|
||||||
@@ -536,6 +659,236 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn category_tags_apply_exact_set_and_preserve_unrelated_entries_and_preferences() {
|
||||||
|
let directory = TestDirectory::new();
|
||||||
|
let path = directory.0.join("config.toml");
|
||||||
|
let first = "UC4QobU6STFB0P71PMvOGN5A";
|
||||||
|
let second = "UCabcdefghijklmnopqrstuv";
|
||||||
|
let direct = "UC0123456789012345678901";
|
||||||
|
let first_url = format!("https://www.youtube.com/channel/{first}");
|
||||||
|
let second_url = format!("https://www.youtube.com/channel/{second}");
|
||||||
|
let direct_url = format!("https://www.youtube.com/channel/{direct}");
|
||||||
|
let manual_url = "https://www.youtube.com/@manual".to_owned();
|
||||||
|
let original = format!(
|
||||||
|
"# manual preferences\npinned_categories = ['Source'] # pin note\nplayer = 'mpv'\n[channels]\n'@alias' = '{first}' # alias mapping\n'@second' = '{second_url}'\n[other]\nvalue = 42\n[categories]\n' Source ' = [\n '@alias', # source channel\n '{first_url}',\n '{second}',\n 'https://youtube.com/@manual',\n '@manual',\n '{direct}', # direct channel\n] # source note\nKeep = ['@alias', '@unrelated'] # keep note\nDrop = [\n '@manual', # removed channel note\n 'https://youtube.com/@alias',\n '@unrelated', # unrelated note\n]\nUntouched = ['@unrelated']\n"
|
||||||
|
);
|
||||||
|
fs::write(&path, &original).unwrap();
|
||||||
|
let opened = load(&path).unwrap();
|
||||||
|
let before = original.parse::<DocumentMut>().unwrap();
|
||||||
|
// This category was not present in the dialog's tags.
|
||||||
|
fs::write(
|
||||||
|
&path,
|
||||||
|
format!("{original}Late = ['@alias', '@unrelated'] # late note\n"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let tags = BTreeMap::from([
|
||||||
|
("Source".to_owned(), false),
|
||||||
|
("Keep".to_owned(), true),
|
||||||
|
("Drop".to_owned(), false),
|
||||||
|
(" New ".to_owned(), true),
|
||||||
|
]);
|
||||||
|
let saved =
|
||||||
|
set_category_tags(&path, " Source ", &opened.categories["Source"], &tags).unwrap();
|
||||||
|
assert!(saved.categories["Source"].is_empty());
|
||||||
|
for name in ["Drop", "Late", "Untouched"] {
|
||||||
|
assert_eq!(
|
||||||
|
saved.categories[name],
|
||||||
|
["https://www.youtube.com/@unrelated"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
saved.categories["Keep"],
|
||||||
|
[
|
||||||
|
first_url.clone(),
|
||||||
|
"https://www.youtube.com/@unrelated".to_owned(),
|
||||||
|
manual_url.clone(),
|
||||||
|
direct_url.clone(),
|
||||||
|
second_url.clone(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
saved.categories["New"],
|
||||||
|
[manual_url, direct_url, first_url, second_url]
|
||||||
|
);
|
||||||
|
assert_eq!(saved.channels, opened.channels);
|
||||||
|
assert_eq!(saved.pinned_categories, ["Source"]);
|
||||||
|
let content = fs::read_to_string(&path).unwrap();
|
||||||
|
let document = content.parse::<DocumentMut>().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
document["channels"].to_string(),
|
||||||
|
before["channels"].to_string()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
document["pinned_categories"].to_string(),
|
||||||
|
before["pinned_categories"].to_string()
|
||||||
|
);
|
||||||
|
assert_eq!(document["other"]["value"].as_integer(), Some(42));
|
||||||
|
assert_eq!(document["player"].as_str(), Some("mpv"));
|
||||||
|
let new = document["categories"]["New"].as_array().unwrap();
|
||||||
|
let raw: Vec<_> = new.iter().map(|entry| entry.as_str().unwrap()).collect();
|
||||||
|
assert_eq!(raw, ["@manual", direct, "@alias", "@second"]);
|
||||||
|
let keep = document["categories"]["Keep"].as_array().unwrap();
|
||||||
|
assert_eq!(keep.get(0).unwrap().as_str(), Some("@alias"));
|
||||||
|
assert_eq!(keep.get(1).unwrap().as_str(), Some("@unrelated"));
|
||||||
|
for comment in [
|
||||||
|
"# manual preferences",
|
||||||
|
"# pin note",
|
||||||
|
"# alias mapping",
|
||||||
|
"# source channel",
|
||||||
|
"# direct channel",
|
||||||
|
"# source note",
|
||||||
|
"# keep note",
|
||||||
|
"# removed channel note",
|
||||||
|
"# unrelated note",
|
||||||
|
"# late note",
|
||||||
|
] {
|
||||||
|
assert!(content.contains(comment), "lost {comment}");
|
||||||
|
}
|
||||||
|
let reloaded = load(&path).unwrap();
|
||||||
|
assert_eq!(reloaded.categories, saved.categories);
|
||||||
|
assert_eq!(reloaded.subscriptions(), saved.subscriptions());
|
||||||
|
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn category_tags_compare_identity_sets_not_source_order_or_alias_spelling() {
|
||||||
|
let directory = TestDirectory::new();
|
||||||
|
let path = directory.0.join("config.toml");
|
||||||
|
let id = "UC4QobU6STFB0P71PMvOGN5A";
|
||||||
|
let url = format!("https://www.youtube.com/channel/{id}");
|
||||||
|
let registry = format!("[channels]\n'@old' = '{id}'\n'@alias' = '{url}'\n");
|
||||||
|
fs::write(
|
||||||
|
&path,
|
||||||
|
format!("{registry}[categories]\nSource = ['@old', '@manual']\n"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let expected = load(&path).unwrap().categories.remove("Source").unwrap();
|
||||||
|
fs::write(
|
||||||
|
&path,
|
||||||
|
format!(
|
||||||
|
"{registry}[categories]\nSource = ['https://youtube.com/@manual', '{url}', '@alias'] # alias note\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let tags = BTreeMap::from([("Source".to_owned(), true), ("New".to_owned(), true)]);
|
||||||
|
let saved = set_category_tags(&path, "Source", &expected, &tags).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
saved.categories["Source"],
|
||||||
|
["https://www.youtube.com/@manual".to_owned(), url.clone()]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
saved.categories["New"],
|
||||||
|
["https://www.youtube.com/@manual".to_owned(), url]
|
||||||
|
);
|
||||||
|
let content = fs::read_to_string(&path).unwrap();
|
||||||
|
let document = content.parse::<DocumentMut>().unwrap();
|
||||||
|
let source = document["categories"]["Source"].as_array().unwrap();
|
||||||
|
assert_eq!(source.len(), 3);
|
||||||
|
assert_eq!(source.get(2).unwrap().as_str(), Some("@alias"));
|
||||||
|
let new = document["categories"]["New"].as_array().unwrap();
|
||||||
|
assert_eq!(new.get(1).unwrap().as_str(), Some("@alias"));
|
||||||
|
assert!(content.contains("# alias note"));
|
||||||
|
set_category_tags(&path, "Source", &expected, &tags).unwrap();
|
||||||
|
assert_eq!(fs::read_to_string(&path).unwrap(), content);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn category_tags_reject_stale_sources_invalid_files_and_tags_without_writing() {
|
||||||
|
let directory = TestDirectory::new();
|
||||||
|
let path = directory.0.join("config.toml");
|
||||||
|
let tags = BTreeMap::from([("New".to_owned(), true)]);
|
||||||
|
let expected = vec!["https://www.youtube.com/@manual".to_owned()];
|
||||||
|
for contents in [
|
||||||
|
"[categories]\nOther = ['@manual']\n",
|
||||||
|
"[categories]\nSource = []\n",
|
||||||
|
"[categories]\nSource = ['@different']\n",
|
||||||
|
"[categories]\nSource = ['@manual', '@different']\n",
|
||||||
|
"[channels]\n'@manual' = 'UC4QobU6STFB0P71PMvOGN5A'\n[categories]\nSource = ['@manual']\n",
|
||||||
|
"[categories\n",
|
||||||
|
"categories = true\n",
|
||||||
|
"[categories]\nSource = ['@manual']\nInvalid = [7]\n",
|
||||||
|
"[categories]\nSource = ['@manual']\n' Source ' = []\n",
|
||||||
|
"channels = true\n[categories]\nSource = ['@manual']\n",
|
||||||
|
"[channels]\n'@manual' = '@different'\n[categories]\nSource = ['@manual']\n",
|
||||||
|
"pinned_categories = [7]\n[categories]\nSource = ['@manual']\n",
|
||||||
|
] {
|
||||||
|
fs::write(&path, contents).unwrap();
|
||||||
|
assert!(set_category_tags(&path, "Source", &expected, &tags).is_err());
|
||||||
|
assert_eq!(fs::read_to_string(&path).unwrap(), contents);
|
||||||
|
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1);
|
||||||
|
}
|
||||||
|
fs::write(&path, [0xff]).unwrap();
|
||||||
|
assert!(set_category_tags(&path, "Source", &expected, &tags).is_err());
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), [0xff]);
|
||||||
|
let valid = "[categories]\nSource = ['@manual']\n";
|
||||||
|
fs::write(&path, valid).unwrap();
|
||||||
|
for invalid_tags in [
|
||||||
|
BTreeMap::from([(" ".to_owned(), true)]),
|
||||||
|
BTreeMap::from([("bad\nname".to_owned(), true)]),
|
||||||
|
BTreeMap::from([("New".to_owned(), true), (" New ".to_owned(), false)]),
|
||||||
|
] {
|
||||||
|
assert!(set_category_tags(&path, "Source", &expected, &invalid_tags).is_err());
|
||||||
|
assert_eq!(fs::read_to_string(&path).unwrap(), valid);
|
||||||
|
}
|
||||||
|
assert!(set_category_tags(&path, "bad\nname", &expected, &tags).is_err());
|
||||||
|
assert_eq!(fs::read_to_string(&path).unwrap(), valid);
|
||||||
|
let missing = directory.0.join("missing/config.toml");
|
||||||
|
assert!(set_category_tags(&missing, "Source", &expected, &tags).is_err());
|
||||||
|
assert!(!missing.parent().unwrap().exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn category_tags_only_clear_all_tags_when_every_target_remains_registered() {
|
||||||
|
let directory = TestDirectory::new();
|
||||||
|
let path = directory.0.join("config.toml");
|
||||||
|
let id = "UC4QobU6STFB0P71PMvOGN5A";
|
||||||
|
let registry = format!(
|
||||||
|
"pinned_categories = ['Source']\n[channels]\n'@mapped' = '{id}'\n[categories]\n"
|
||||||
|
);
|
||||||
|
for source in ["'@mapped', '@manual'", "'UCabcdefghijklmnopqrstuv'"] {
|
||||||
|
let original = format!("{registry}Source = [{source}]\n");
|
||||||
|
fs::write(&path, &original).unwrap();
|
||||||
|
let opened = load(&path).unwrap();
|
||||||
|
for tags in [
|
||||||
|
BTreeMap::new(),
|
||||||
|
BTreeMap::from([("Source".to_owned(), false)]),
|
||||||
|
] {
|
||||||
|
let error = set_category_tags(&path, "Source", &opened.categories["Source"], &tags)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(error.to_string().contains("not registered in [channels]"));
|
||||||
|
assert_eq!(fs::read_to_string(&path).unwrap(), original);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let original = format!(
|
||||||
|
"{registry}Source = ['@mapped', '{id}'] # source note\nOther = ['https://youtube.com/@mapped', '@unrelated']\n"
|
||||||
|
);
|
||||||
|
fs::write(&path, &original).unwrap();
|
||||||
|
let opened = load(&path).unwrap();
|
||||||
|
let saved = set_category_tags(
|
||||||
|
&path,
|
||||||
|
"Source",
|
||||||
|
&opened.categories["Source"],
|
||||||
|
&BTreeMap::new(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(saved.categories["Source"].is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
saved.categories["Other"],
|
||||||
|
["https://www.youtube.com/@unrelated"]
|
||||||
|
);
|
||||||
|
assert_eq!(saved.channels, opened.channels);
|
||||||
|
assert_eq!(saved.pinned_categories, ["Source"]);
|
||||||
|
assert_eq!(
|
||||||
|
saved.subscriptions(),
|
||||||
|
[
|
||||||
|
"https://www.youtube.com/@unrelated",
|
||||||
|
"https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert!(fs::read_to_string(&path).unwrap().contains("# source note"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn edits_preserve_manual_config_and_refuse_invalid_or_changed_files() {
|
fn edits_preserve_manual_config_and_refuse_invalid_or_changed_files() {
|
||||||
let directory = TestDirectory::new();
|
let directory = TestDirectory::new();
|
||||||
|
|||||||
+584
-307
File diff suppressed because one or more lines are too long
+125
-13
@@ -11,7 +11,7 @@ use ratatui::{
|
|||||||
widgets::{Paragraph, Wrap},
|
widgets::{Paragraph, Wrap},
|
||||||
};
|
};
|
||||||
use ratatui_image::{
|
use ratatui_image::{
|
||||||
FilterType, Resize, StatefulImage,
|
FilterType, FontSize, Resize, StatefulImage,
|
||||||
picker::Picker,
|
picker::Picker,
|
||||||
thread::{ResizeRequest, ResizeResponse, ThreadProtocol},
|
thread::{ResizeRequest, ResizeResponse, ThreadProtocol},
|
||||||
};
|
};
|
||||||
@@ -112,25 +112,26 @@ impl Thumbnail {
|
|||||||
.is_some_and(|image| image.complete && image.protocol.protocol_type().is_some())
|
.is_some_and(|image| image.complete && image.protocol.protocol_type().is_some())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draw the image left-aligned and return its occupied width for adjacent content.
|
/// Draw the image left-aligned and reserve its fitted width for adjacent content.
|
||||||
pub fn draw(&mut self, frame: &mut Frame, mut area: Rect) -> u16 {
|
pub fn draw(&mut self, frame: &mut Frame, area: Rect) -> u16 {
|
||||||
self.poll();
|
self.poll();
|
||||||
if area.width == 0 || area.height == 0 || self.selected.is_none() {
|
if area.width == 0 || area.height == 0 || self.selected.is_none() {
|
||||||
return area.width;
|
return area.width;
|
||||||
}
|
}
|
||||||
|
let slot = left_aligned(area, thumbnail_size(area, self.picker.font_size()));
|
||||||
|
|
||||||
let mut message = self.error.as_deref().unwrap_or("Loading thumbnail...");
|
let mut message = self.error.as_deref().unwrap_or("Loading thumbnail...");
|
||||||
if let Some(image) = self.image.as_mut() {
|
if let Some(image) = self.image.as_mut() {
|
||||||
let resize = Resize::Scale(Some(FilterType::Triangle));
|
let resize = Resize::Scale(Some(FilterType::Triangle));
|
||||||
if let Some(size) = image.protocol.size_for(resize.clone(), area.into()) {
|
if let Some(size) = image.protocol.size_for(resize.clone(), area.into()) {
|
||||||
area = left_aligned(area, size);
|
let image_area = left_aligned(area, size);
|
||||||
frame.render_stateful_widget(
|
frame.render_stateful_widget(
|
||||||
StatefulImage::new().resize(resize),
|
StatefulImage::new().resize(resize),
|
||||||
area,
|
image_area,
|
||||||
&mut image.protocol,
|
&mut image.protocol,
|
||||||
);
|
);
|
||||||
if image.protocol.protocol_type().is_some() {
|
if image.protocol.protocol_type().is_some() {
|
||||||
return area.width;
|
return slot.width;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
message = "Preparing thumbnail...";
|
message = "Preparing thumbnail...";
|
||||||
@@ -142,13 +143,13 @@ impl Thumbnail {
|
|||||||
.alignment(Alignment::Center)
|
.alignment(Alignment::Center)
|
||||||
.wrap(Wrap { trim: true }),
|
.wrap(Wrap { trim: true }),
|
||||||
Rect::new(
|
Rect::new(
|
||||||
area.x,
|
slot.x,
|
||||||
area.y + area.height / 2,
|
slot.y + slot.height / 2,
|
||||||
area.width,
|
slot.width,
|
||||||
area.height - area.height / 2,
|
slot.height - slot.height / 2,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
area.width
|
slot.width
|
||||||
}
|
}
|
||||||
|
|
||||||
fn poll(&mut self) {
|
fn poll(&mut self) {
|
||||||
@@ -253,6 +254,29 @@ fn decode(bytes: Vec<u8>) -> Result<image::DynamicImage, String> {
|
|||||||
.map_err(|error| format!("Could not decode thumbnail JPEG: {error}"))
|
.map_err(|error| format!("Could not decode thumbnail JPEG: {error}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn thumbnail_size(area: Rect, font: FontSize) -> Size {
|
||||||
|
if area.width == 0 || area.height == 0 || font.width == 0 || font.height == 0 {
|
||||||
|
return Size::default();
|
||||||
|
}
|
||||||
|
// HQ thumbnails are 480x360. Match Scale's nearest-pixel rounding, then
|
||||||
|
// round up to cells without allocating a placeholder image.
|
||||||
|
let width = u64::from(area.width) * u64::from(font.width);
|
||||||
|
let height = u64::from(area.height) * u64::from(font.height);
|
||||||
|
let (width, height) = if width * 3 <= height * 4 {
|
||||||
|
(width, ((width * 3 + 2) / 4).max(1))
|
||||||
|
} else {
|
||||||
|
(((height * 4 + 1) / 3).max(1), height)
|
||||||
|
};
|
||||||
|
Size::new(
|
||||||
|
width
|
||||||
|
.div_ceil(u64::from(font.width))
|
||||||
|
.min(u64::from(area.width)) as u16,
|
||||||
|
height
|
||||||
|
.div_ceil(u64::from(font.height))
|
||||||
|
.min(u64::from(area.height)) as u16,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn left_aligned(area: Rect, size: Size) -> Rect {
|
fn left_aligned(area: Rect, size: Size) -> Rect {
|
||||||
let width = size.width.min(area.width);
|
let width = size.width.min(area.width);
|
||||||
let height = size.height.min(area.height);
|
let height = size.height.min(area.height);
|
||||||
@@ -263,6 +287,81 @@ fn left_aligned(area: Rect, size: Size) -> Rect {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loading_thumbnail_keeps_the_same_text_column_as_the_ready_image() {
|
||||||
|
use ratatui::{Terminal, backend::TestBackend};
|
||||||
|
use std::time::Duration;
|
||||||
|
let mut thumbnail = Thumbnail::new();
|
||||||
|
thumbnail.selected = Some("fixture".into());
|
||||||
|
let area = Rect::new(2, 2, 80, 8);
|
||||||
|
let mut terminal = Terminal::new(TestBackend::new(100, 20)).unwrap();
|
||||||
|
let mut loading_width = 0;
|
||||||
|
terminal
|
||||||
|
.draw(|frame| loading_width = thumbnail.draw(frame, area))
|
||||||
|
.unwrap();
|
||||||
|
let (_decode_tx, decode_rx) = mpsc::channel();
|
||||||
|
thumbnail.decoder = Some(decode_rx);
|
||||||
|
let mut decoding_width = 0;
|
||||||
|
terminal
|
||||||
|
.draw(|frame| decoding_width = thumbnail.draw(frame, area))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(loading_width, decoding_width);
|
||||||
|
thumbnail.decoder = None;
|
||||||
|
let (requests_tx, requests_rx) = mpsc::channel::<ResizeRequest>();
|
||||||
|
let (replies_tx, replies_rx) = mpsc::channel();
|
||||||
|
thumbnail.image = Some(ThumbnailImage {
|
||||||
|
id: "fixture".into(),
|
||||||
|
protocol: ThreadProtocol::new(
|
||||||
|
requests_tx,
|
||||||
|
Some(
|
||||||
|
thumbnail
|
||||||
|
.picker
|
||||||
|
.new_resize_protocol(image::DynamicImage::new_rgb8(48, 36)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
replies: replies_rx,
|
||||||
|
complete: false,
|
||||||
|
});
|
||||||
|
let mut preparing_width = 0;
|
||||||
|
terminal
|
||||||
|
.draw(|frame| preparing_width = thumbnail.draw(frame, area))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(loading_width, preparing_width);
|
||||||
|
terminal
|
||||||
|
.draw(|frame| preparing_width = thumbnail.draw(frame, area))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(loading_width, preparing_width);
|
||||||
|
replies_tx
|
||||||
|
.send(Ok(requests_rx
|
||||||
|
.recv_timeout(Duration::from_secs(2))
|
||||||
|
.unwrap()
|
||||||
|
.resize_encode()
|
||||||
|
.unwrap()))
|
||||||
|
.unwrap();
|
||||||
|
let mut ready_width = 0;
|
||||||
|
terminal
|
||||||
|
.draw(|frame| ready_width = thumbnail.draw(frame, area))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
loading_width, ready_width,
|
||||||
|
"right-hand content must not move when the image finishes loading"
|
||||||
|
);
|
||||||
|
thumbnail.cancel();
|
||||||
|
thumbnail.select("fixture");
|
||||||
|
terminal
|
||||||
|
.draw(|frame| ready_width = thumbnail.draw(frame, area))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(loading_width, ready_width);
|
||||||
|
replies_tx
|
||||||
|
.send(Err("fixture renderer failure".into()))
|
||||||
|
.unwrap();
|
||||||
|
let mut error_width = 0;
|
||||||
|
terminal
|
||||||
|
.draw(|frame| error_width = thumbnail.draw(frame, area))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(loading_width, error_width);
|
||||||
|
assert!(thumbnail.error.is_some());
|
||||||
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_corrupt_and_oversized_jpeg_input() {
|
fn rejects_corrupt_and_oversized_jpeg_input() {
|
||||||
assert!(decode(b"not a JPEG".to_vec()).is_err());
|
assert!(decode(b"not a JPEG".to_vec()).is_err());
|
||||||
@@ -377,13 +476,25 @@ mod tests {
|
|||||||
complete: false,
|
complete: false,
|
||||||
});
|
});
|
||||||
let mut terminal = Terminal::new(TestBackend::new(100, 40)).unwrap();
|
let mut terminal = Terminal::new(TestBackend::new(100, 40)).unwrap();
|
||||||
for area in [Rect::new(3, 2, 80, 18), Rect::new(7, 4, 30, 8)] {
|
for area in [
|
||||||
|
Rect::new(3, 2, 80, 18),
|
||||||
|
Rect::new(7, 4, 30, 8),
|
||||||
|
Rect::new(3, 2, 9, 18),
|
||||||
|
Rect::new(7, 4, 30, 8),
|
||||||
|
] {
|
||||||
|
let mut pending_width = 0;
|
||||||
terminal
|
terminal
|
||||||
.draw(|frame| {
|
.draw(|frame| {
|
||||||
thumbnail.draw(frame, area);
|
pending_width = thumbnail.draw(frame, area);
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(thumbnail.is_loading());
|
assert!(thumbnail.is_loading());
|
||||||
|
assert!(
|
||||||
|
!terminal.backend().buffer().content.iter().any(|cell| {
|
||||||
|
matches!(cell.fg, Color::Rgb(..)) || matches!(cell.bg, Color::Rgb(..))
|
||||||
|
}),
|
||||||
|
"a resize must not render stale pixels"
|
||||||
|
);
|
||||||
let response = requests_rx
|
let response = requests_rx
|
||||||
.recv_timeout(Duration::from_secs(2))
|
.recv_timeout(Duration::from_secs(2))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -397,6 +508,7 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(!thumbnail.is_loading());
|
assert!(!thumbnail.is_loading());
|
||||||
|
assert_eq!(pending_width, occupied_width);
|
||||||
let buffer = terminal.backend().buffer();
|
let buffer = terminal.backend().buffer();
|
||||||
let pixels: Vec<_> = (0..40)
|
let pixels: Vec<_> = (0..40)
|
||||||
.flat_map(|y| (0..100).map(move |x| (x, y)))
|
.flat_map(|y| (0..100).map(move |x| (x, y)))
|
||||||
|
|||||||
+5
-1
@@ -460,7 +460,11 @@ impl PlayerLaunch {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let mut player = isolated_command(player)
|
let mut player = isolated_command(player)
|
||||||
.args(["--url", &format!("https://www.youtube.com/watch?v={id}")])
|
.args([
|
||||||
|
"--new-instance",
|
||||||
|
"--url",
|
||||||
|
&format!("https://www.youtube.com/watch?v={id}"),
|
||||||
|
])
|
||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
.stderr(Stdio::piped())
|
.stderr(Stdio::piped())
|
||||||
|
|||||||
Reference in New Issue
Block a user