feat: save readable channel handles with stable URL mappings
This commit is contained in:
+151
-18
@@ -35,15 +35,40 @@ fn resolve_path(xdg: Option<OsString>, home: Option<OsString>) -> io::Result<Pat
|
|||||||
|
|
||||||
pub fn load(path: &Path) -> io::Result<Config> {
|
pub fn load(path: &Path) -> io::Result<Config> {
|
||||||
let (_, document) = read_document(path)?;
|
let (_, document) = read_document(path)?;
|
||||||
parse_categories(&document)
|
parse_categories(&document, &parse_channels(&document)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_channel(path: &Path, category: &str, channel: &str) -> io::Result<Config> {
|
pub fn add_channel(path: &Path, category: &str, handle: &str, channel: &str) -> io::Result<Config> {
|
||||||
let category = category_name(category)?;
|
let category = category_name(category)?;
|
||||||
|
let handle = normalize_handle(handle)?;
|
||||||
let channel = normalize_channel(channel)?;
|
let channel = normalize_channel(channel)?;
|
||||||
|
if !channel.starts_with("https://www.youtube.com/channel/") {
|
||||||
|
return Err(invalid(
|
||||||
|
"Channel mappings require a canonical UC channel ID",
|
||||||
|
));
|
||||||
|
}
|
||||||
let (original, mut document) = read_document(path)?;
|
let (original, mut document) = read_document(path)?;
|
||||||
// Validate every category before touching the document or creating directories.
|
let mut mappings = parse_channels(&document)?;
|
||||||
let mut config = parse_categories(&document)?;
|
// Validate the whole file before changing it or creating directories.
|
||||||
|
parse_categories(&document, &mappings)?;
|
||||||
|
match mappings.get(&handle) {
|
||||||
|
Some(existing) if existing != &channel => {
|
||||||
|
return Err(invalid(format!(
|
||||||
|
"Handle {handle:?} already maps to another channel"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Some(_) => {}
|
||||||
|
None => {
|
||||||
|
if !document.contains_key("channels") {
|
||||||
|
document.insert("channels", Item::Table(Table::new()));
|
||||||
|
}
|
||||||
|
document["channels"]
|
||||||
|
.as_table_like_mut()
|
||||||
|
.expect("channel mappings were validated")
|
||||||
|
.insert(&handle, value(&channel));
|
||||||
|
mappings.insert(handle.clone(), channel.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
if !document.contains_key("categories") {
|
if !document.contains_key("categories") {
|
||||||
document.insert("categories", Item::Table(Table::new()));
|
document.insert("categories", Item::Table(Table::new()));
|
||||||
}
|
}
|
||||||
@@ -63,12 +88,22 @@ pub fn add_channel(path: &Path, category: &str, channel: &str) -> io::Result<Con
|
|||||||
.get_mut(&key)
|
.get_mut(&key)
|
||||||
.and_then(Item::as_array_mut)
|
.and_then(Item::as_array_mut)
|
||||||
.expect("category was validated");
|
.expect("category was validated");
|
||||||
let saved_channels = config.categories.entry(category.to_owned()).or_default();
|
let mut found = false;
|
||||||
// Preserve manually written entries and their comments; do not append an equivalent URL.
|
for entry in channels.iter_mut() {
|
||||||
if !saved_channels.contains(&channel) {
|
if resolve_channel(entry.as_str().expect("channel was validated"), &mappings)? == channel {
|
||||||
channels.push(channel.clone());
|
// Replace old URLs/aliases with the current handle, preserving entry comments.
|
||||||
saved_channels.push(channel);
|
if entry.as_str() != Some(&handle) {
|
||||||
|
let decor = entry.decor().clone();
|
||||||
|
*entry = handle.as_str().into();
|
||||||
|
*entry.decor_mut() = decor;
|
||||||
|
}
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if !found {
|
||||||
|
channels.push(handle);
|
||||||
|
}
|
||||||
|
let config = parse_categories(&document, &mappings)?;
|
||||||
save(path, original.as_deref(), document.to_string().as_bytes())?;
|
save(path, original.as_deref(), document.to_string().as_bytes())?;
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
@@ -130,6 +165,54 @@ fn normalize_channel(input: &str) -> io::Result<String> {
|
|||||||
Ok(format!("https://www.youtube.com/channel/{id}"))
|
Ok(format!("https://www.youtube.com/channel/{id}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_handle(input: &str) -> io::Result<String> {
|
||||||
|
let mut address = normalize_channel(input)?;
|
||||||
|
let prefix = "https://www.youtube.com/";
|
||||||
|
if !address[prefix.len()..].starts_with('@') {
|
||||||
|
return Err(invalid("Expected a YouTube @handle"));
|
||||||
|
}
|
||||||
|
address.drain(..prefix.len());
|
||||||
|
Ok(address)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_channels(document: &DocumentMut) -> io::Result<BTreeMap<String, String>> {
|
||||||
|
let mut mappings = BTreeMap::new();
|
||||||
|
let Some(item) = document.get("channels") else {
|
||||||
|
return Ok(mappings);
|
||||||
|
};
|
||||||
|
let channels = item
|
||||||
|
.as_table_like()
|
||||||
|
.ok_or_else(|| invalid("channels must be a TOML table"))?;
|
||||||
|
for (handle, address) in channels.iter() {
|
||||||
|
let handle = normalize_handle(handle)?;
|
||||||
|
let address = normalize_channel(
|
||||||
|
address
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| invalid("Channel mappings must be strings"))?,
|
||||||
|
)?;
|
||||||
|
if !address.starts_with("https://www.youtube.com/channel/") {
|
||||||
|
return Err(invalid(
|
||||||
|
"Channel mappings require a canonical UC channel ID",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if mappings.insert(handle, address).is_some() {
|
||||||
|
return Err(invalid("Duplicate channel handle after normalization"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(mappings)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_channel(input: &str, mappings: &BTreeMap<String, String>) -> io::Result<String> {
|
||||||
|
let address = normalize_channel(input)?;
|
||||||
|
match address
|
||||||
|
.strip_prefix("https://www.youtube.com/")
|
||||||
|
.and_then(|handle| mappings.get(handle))
|
||||||
|
{
|
||||||
|
Some(mapped) => Ok(mapped.clone()),
|
||||||
|
None => Ok(address),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn read_bytes(path: &Path) -> io::Result<Option<Vec<u8>>> {
|
fn read_bytes(path: &Path) -> io::Result<Option<Vec<u8>>> {
|
||||||
match fs::read(path) {
|
match fs::read(path) {
|
||||||
Ok(bytes) => Ok(Some(bytes)),
|
Ok(bytes) => Ok(Some(bytes)),
|
||||||
@@ -150,7 +233,10 @@ fn read_document(path: &Path) -> io::Result<(Option<Vec<u8>>, DocumentMut)> {
|
|||||||
Ok((bytes, document))
|
Ok((bytes, document))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_categories(document: &DocumentMut) -> io::Result<Config> {
|
fn parse_categories(
|
||||||
|
document: &DocumentMut,
|
||||||
|
mappings: &BTreeMap<String, String>,
|
||||||
|
) -> io::Result<Config> {
|
||||||
let mut config = Config::default();
|
let mut config = Config::default();
|
||||||
let Some(item) = document.get("categories") else {
|
let Some(item) = document.get("categories") else {
|
||||||
return Ok(config);
|
return Ok(config);
|
||||||
@@ -165,10 +251,11 @@ fn parse_categories(document: &DocumentMut) -> io::Result<Config> {
|
|||||||
.ok_or_else(|| invalid(format!("Category {name:?} must be an array of channels")))?;
|
.ok_or_else(|| invalid(format!("Category {name:?} must be an array of channels")))?;
|
||||||
let mut normalized = Vec::with_capacity(channels.len());
|
let mut normalized = Vec::with_capacity(channels.len());
|
||||||
for channel in channels.iter() {
|
for channel in channels.iter() {
|
||||||
let channel = normalize_channel(
|
let channel = resolve_channel(
|
||||||
channel
|
channel
|
||||||
.as_str()
|
.as_str()
|
||||||
.ok_or_else(|| invalid("Channels must be strings"))?,
|
.ok_or_else(|| invalid("Channels must be strings"))?,
|
||||||
|
mappings,
|
||||||
)?;
|
)?;
|
||||||
if !normalized.contains(&channel) {
|
if !normalized.contains(&channel) {
|
||||||
normalized.push(channel);
|
normalized.push(channel);
|
||||||
@@ -278,22 +365,22 @@ mod tests {
|
|||||||
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();
|
||||||
let path = directory.0.join("nested/config.toml");
|
let path = directory.0.join("nested/config.toml");
|
||||||
|
let id = "UC4QobU6STFB0P71PMvOGN5A";
|
||||||
assert!(load(&path).unwrap().categories.is_empty());
|
assert!(load(&path).unwrap().categories.is_empty());
|
||||||
assert!(!path.parent().unwrap().exists());
|
assert!(!path.parent().unwrap().exists());
|
||||||
let config = add_channel(&path, " Music ", "@example").unwrap();
|
let config = add_channel(&path, " Music ", "@example", id).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
config.categories["Music"],
|
config.categories["Music"],
|
||||||
["https://www.youtube.com/@example"]
|
[format!("https://www.youtube.com/channel/{id}")]
|
||||||
);
|
);
|
||||||
|
|
||||||
let manual = "# manual preferences\nplayer = 'mpv' # keep me\n[other]\nvalue = 42\n[categories]\n# favorites\n' Music ' = ['@example', 'https://www.youtube.com/@example'] # channel note\nEmpty = []\n";
|
let manual = "# manual preferences\nplayer = 'mpv' # keep me\n[other]\nvalue = 42\n[categories]\n# favorites\n' Music ' = ['@example', 'https://www.youtube.com/@example'] # channel note\nEmpty = []\n";
|
||||||
fs::write(&path, manual).unwrap();
|
fs::write(&path, manual).unwrap();
|
||||||
let config = add_channel(&path, "Music", "https://youtube.com/@example").unwrap();
|
let config = add_channel(&path, "Music", "https://youtube.com/@example", id).unwrap();
|
||||||
assert_eq!(config.categories["Music"].len(), 1);
|
assert_eq!(config.categories["Music"].len(), 1);
|
||||||
assert!(config.categories["Empty"].is_empty());
|
assert!(config.categories["Empty"].is_empty());
|
||||||
assert_eq!(fs::read_to_string(&path).unwrap(), manual);
|
|
||||||
let id = "UCabcdefghijklmnopqrstuv";
|
let id = "UCabcdefghijklmnopqrstuv";
|
||||||
let config = add_channel(&path, "Music", id).unwrap();
|
let config = add_channel(&path, "Music", "@another", id).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
config.categories["Music"][1],
|
config.categories["Music"][1],
|
||||||
format!("https://www.youtube.com/channel/{id}")
|
format!("https://www.youtube.com/channel/{id}")
|
||||||
@@ -318,20 +405,66 @@ mod tests {
|
|||||||
"[categories]\nMusic = [3]\n",
|
"[categories]\nMusic = [3]\n",
|
||||||
"[categories]\nMusic = ['https://example.com/@example']\n",
|
"[categories]\nMusic = ['https://example.com/@example']\n",
|
||||||
"[categories]\nMusic = []\n' Music ' = []\n",
|
"[categories]\nMusic = []\n' Music ' = []\n",
|
||||||
|
"channels = 3\n",
|
||||||
|
"[channels]\n'@example' = 3\n",
|
||||||
|
"[channels]\n'@example' = 'https://example.com/channel/UCabcdefghijklmnopqrstuv'\n",
|
||||||
|
"[channels]\n'@example' = '@another'\n",
|
||||||
|
"[channels]\n'not-a-handle' = 'UCabcdefghijklmnopqrstuv'\n",
|
||||||
] {
|
] {
|
||||||
fs::write(&path, malformed).unwrap();
|
fs::write(&path, malformed).unwrap();
|
||||||
assert!(load(&path).is_err());
|
assert!(load(&path).is_err());
|
||||||
assert!(add_channel(&path, "Music", "@example").is_err());
|
assert!(add_channel(&path, "Music", "@example", id).is_err());
|
||||||
assert_eq!(fs::read_to_string(&path).unwrap(), malformed);
|
assert_eq!(fs::read_to_string(&path).unwrap(), malformed);
|
||||||
}
|
}
|
||||||
fs::write(&path, manual).unwrap();
|
fs::write(&path, manual).unwrap();
|
||||||
assert!(add_channel(&path, "Music", "https://www.youtube.com/@example/videos").is_err());
|
assert!(
|
||||||
|
add_channel(
|
||||||
|
&path,
|
||||||
|
"Music",
|
||||||
|
"https://www.youtube.com/@example/videos",
|
||||||
|
id
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
assert_eq!(fs::read_to_string(&path).unwrap(), manual);
|
assert_eq!(fs::read_to_string(&path).unwrap(), manual);
|
||||||
assert!(save(&path, Some(b"previous contents"), b"replacement").is_err());
|
assert!(save(&path, Some(b"previous contents"), b"replacement").is_err());
|
||||||
assert_eq!(fs::read_to_string(&path).unwrap(), manual);
|
assert_eq!(fs::read_to_string(&path).unwrap(), manual);
|
||||||
assert_eq!(fs::read_dir(path.parent().unwrap()).unwrap().count(), 1);
|
assert_eq!(fs::read_dir(path.parent().unwrap()).unwrap().count(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handles_resolve_to_stable_ids_and_resaving_replaces_opaque_entries() {
|
||||||
|
let directory = TestDirectory::new();
|
||||||
|
let path = directory.0.join("config.toml");
|
||||||
|
let id = "UC4QobU6STFB0P71PMvOGN5A";
|
||||||
|
let url = format!("https://www.youtube.com/channel/{id}");
|
||||||
|
let manual = format!(
|
||||||
|
"[channels]\n'@old' = '{url}'\n[categories]\nJapanese = [\n '{url}', # keep this entry comment\n '@old',\n]\nOther = ['@old']\nDirect = ['@RustVideos']\n"
|
||||||
|
);
|
||||||
|
fs::write(&path, manual).unwrap();
|
||||||
|
let config = load(&path).unwrap();
|
||||||
|
assert_eq!(config.categories["Japanese"], std::slice::from_ref(&url));
|
||||||
|
assert_eq!(
|
||||||
|
config.categories["Direct"],
|
||||||
|
["https://www.youtube.com/@RustVideos"]
|
||||||
|
);
|
||||||
|
let config = add_channel(&path, "Japanese", "@jawed", id).unwrap();
|
||||||
|
assert_eq!(config.categories["Japanese"], std::slice::from_ref(&url));
|
||||||
|
assert_eq!(config.categories["Other"], std::slice::from_ref(&url));
|
||||||
|
let saved = fs::read_to_string(&path).unwrap();
|
||||||
|
let document = saved.parse::<DocumentMut>().unwrap();
|
||||||
|
let entries = document["categories"]["Japanese"].as_array().unwrap();
|
||||||
|
assert!(entries.iter().all(|entry| entry.as_str() == Some("@jawed")));
|
||||||
|
assert_eq!(document["channels"]["@jawed"].as_str(), Some(url.as_str()));
|
||||||
|
assert_eq!(document["channels"]["@old"].as_str(), Some(url.as_str()));
|
||||||
|
assert!(saved.contains("# keep this entry comment"));
|
||||||
|
add_channel(&path, "Japanese", "@jawed", id).unwrap();
|
||||||
|
assert_eq!(fs::read_to_string(&path).unwrap(), saved);
|
||||||
|
assert!(add_channel(&path, "Japanese", "@jawed", "UCabcdefghijklmnopqrstuv").is_err());
|
||||||
|
assert_eq!(fs::read_to_string(&path).unwrap(), saved);
|
||||||
|
assert_eq!(load(&path).unwrap().categories["Japanese"], [url]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn paths_and_channel_addresses_are_strict() {
|
fn paths_and_channel_addresses_are_strict() {
|
||||||
let home = Some(OsString::from("/home/test"));
|
let home = Some(OsString::from("/home/test"));
|
||||||
|
|||||||
+32
-5
@@ -13,6 +13,10 @@
|
|||||||
//! curl fetches JPEGs lazily into memory; decoding and image resizing/encoding run off-thread.
|
//! curl fetches JPEGs lazily into memory; decoding and image resizing/encoding run off-thread.
|
||||||
//! Channel categories live in `$XDG_CONFIG_HOME/tubular/config.toml`, or `~/.config/tubular/config.toml`.
|
//! Channel categories live in `$XDG_CONFIG_HOME/tubular/config.toml`, or `~/.config/tubular/config.toml`.
|
||||||
//! Under `[categories]`, write `Rust = ["@RustVideos"]`; channel URLs and UC IDs also work.
|
//! Under `[categories]`, write `Rust = ["@RustVideos"]`; channel URLs and UC IDs also work.
|
||||||
|
//! Saving puts the creator's @handle in the category and its canonical URL in `[channels]`.
|
||||||
|
//! Mapped handles resolve to stable channel IDs; unmapped handles still work directly.
|
||||||
|
//! Re-saving an existing channel replaces its opaque URL in that category and preserves comments.
|
||||||
|
//! Missing handle/ID metadata or a conflicting mapping prevents saving rather than guessing.
|
||||||
//! Normal-mode c opens the category picker; n there prompts for a new name, Enter saves, Esc cancels.
|
//! Normal-mode c opens the category picker; n there prompts for a new name, Enter saves, Esc cancels.
|
||||||
//! Tab/Shift-Tab switch the independent Search/category views; r reloads config and refreshes.
|
//! Tab/Shift-Tab switch the independent Search/category views; r reloads config and refreshes.
|
||||||
//! Feeds merge up to 20 videos per channel's /videos tab, ordered by approximate upload dates.
|
//! Feeds merge up to 20 videos per channel's /videos tab, ordered by approximate upload dates.
|
||||||
@@ -41,7 +45,7 @@ use youtube::{Job, Playback, Video};
|
|||||||
#[command(
|
#[command(
|
||||||
version,
|
version,
|
||||||
about = "Search YouTube and stream videos to your chosen player",
|
about = "Search YouTube and stream videos to your chosen player",
|
||||||
after_help = "Requires yt-dlp, FFmpeg, and a player on PATH; thumbnails also use curl.\nDefaults to IINA on macOS and mpv elsewhere; --player overrides the default.\nIINA receives '--stdin'; other players receive '-' to read audio/video from standard input.\nWithout a CLI query, start in INSERT; a CLI query is searched automatically in NORMAL.\nINSERT: type to edit; Enter submits if changed and returns to NORMAL; Esc returns without submitting.\nNORMAL: j/k or arrows select; Enter streams; i edits the query; d toggles description; t toggles thumbnail; c saves a channel; q quits.\nTab/Shift-Tab switch Search/category views; r reloads config and refreshes the current view.\nSubscriptions: $XDG_CONFIG_HOME/tubular/config.toml, or ~/.config/tubular/config.toml when XDG_CONFIG_HOME is unset/empty/relative.\nExample TOML:\n[categories]\nRust = [\"@RustVideos\"]\nChannel entries accept @handles, HTTPS YouTube channel URLs, or canonical UC channel IDs.\nFeeds combine up to 20 videos per channel's /videos tab, newest-first using approximate upload dates.\nCATEGORY PICKER: j/k or arrows choose; Enter saves; n prompts for a new category; Esc cancels.\nNEW CATEGORY: type a name; Enter creates it and saves the channel; Esc returns to the picker; Ctrl-U clears.\nOpening the description fetches full video metadata, preserving paragraphs and caching it for these results.\nPreview windows use up to 70% width and 60% height, capped at 96 by 20 cells.\nIn descriptions, Up/Down scroll lines and PgUp/PgDn scroll pages; j/k still selects videos.\nEsc closes the description; i closes it to edit the query. Scrolling stays within the text.\nTHUMBNAIL: t or Esc closes; j/k or arrows load the selected video's image; d switches to description.\nThumbnails are fetched lazily into memory and fit proportionally inside the popup on resize.\nTerminal graphics are detected at startup, with colored half-block rendering when image protocols are unavailable.\nDownloading, JPEG decoding, and image resizing/encoding do not block navigation.\ni, c, view changes, and refresh dismiss the thumbnail; typing t in INSERT or a category name edits text.\nCtrl-U clears in INSERT; Ctrl-C quits in any mode or popup.\nSelecting another video replaces playback. Quitting stops playback.\nExample: tubular --player mpv --player-arg=--volume=50 \"rust programming\""
|
after_help = "Requires yt-dlp, FFmpeg, and a player on PATH; thumbnails also use curl.\nDefaults to IINA on macOS and mpv elsewhere; --player overrides the default.\nIINA receives '--stdin'; other players receive '-' to read audio/video from standard input.\nWithout a CLI query, start in INSERT; a CLI query is searched automatically in NORMAL.\nINSERT: type to edit; Enter submits if changed and returns to NORMAL; Esc returns without submitting.\nNORMAL: j/k or arrows select; Enter streams; i edits the query; d toggles description; t toggles thumbnail; c saves a channel; q quits.\nTab/Shift-Tab switch Search/category views; r reloads config and refreshes the current view.\nSubscriptions: $XDG_CONFIG_HOME/tubular/config.toml, or ~/.config/tubular/config.toml when XDG_CONFIG_HOME is unset/empty/relative.\nExample TOML:\n[categories]\nCreators = [\"@jawed\"]\n[channels]\n\"@jawed\" = \"https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A\"\nCategory entries accept @handles, HTTPS YouTube channel URLs, or canonical UC channel IDs.\n[channels] optionally maps handles to canonical channel URLs/IDs; feeds use these stable identities.\nSaving writes a handle plus its mapping; re-saving replaces matching URLs/aliases in that category, preserving comments.\nUnmapped handles work directly. Existing URL entries still load; they are not changed until re-saved.\nSaving requires a handle and canonical ID; unavailable metadata or conflicting mappings are reported without changing the file.\nFeeds combine up to 20 videos per channel's /videos tab, newest-first using approximate upload dates.\nCATEGORY PICKER: j/k or arrows choose; Enter saves; n prompts for a new category; Esc cancels.\nNEW CATEGORY: type a name; Enter creates it and saves the channel; Esc returns to the picker; Ctrl-U clears.\nOpening the description fetches full video metadata, preserving paragraphs and caching it for these results.\nPreview windows use up to 70% width and 60% height, capped at 96 by 20 cells.\nIn descriptions, Up/Down scroll lines and PgUp/PgDn scroll pages; j/k still selects videos.\nEsc closes the description; i closes it to edit the query. Scrolling stays within the text.\nTHUMBNAIL: t or Esc closes; j/k or arrows load the selected video's image; d switches to description.\nThumbnails are fetched lazily into memory and fit proportionally inside the popup on resize.\nTerminal graphics are detected at startup, with colored half-block rendering when image protocols are unavailable.\nDownloading, JPEG decoding, and image resizing/encoding do not block navigation.\ni, c, view changes, and refresh dismiss the thumbnail; typing t in INSERT or a category name edits text.\nCtrl-U clears in INSERT; Ctrl-C quits in any mode or popup.\nSelecting another video replaces playback. Quitting stops playback.\nExample: tubular --player mpv --player-arg=--volume=50 \"rust programming\""
|
||||||
)]
|
)]
|
||||||
struct Options {
|
struct Options {
|
||||||
/// Player executable (IINA or a player supporting '-' for standard input).
|
/// Player executable (IINA or a player supporting '-' for standard input).
|
||||||
@@ -84,6 +88,7 @@ impl View {
|
|||||||
|
|
||||||
struct CategoryDialog {
|
struct CategoryDialog {
|
||||||
channel: String,
|
channel: String,
|
||||||
|
handle: String,
|
||||||
label: String,
|
label: String,
|
||||||
selection: ListState,
|
selection: ListState,
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
@@ -277,8 +282,18 @@ impl App {
|
|||||||
self.status = "This video has no available channel identity.".into();
|
self.status = "This video has no available channel identity.".into();
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
let Some(handle) = video
|
||||||
|
.uploader_id
|
||||||
|
.as_deref()
|
||||||
|
.filter(|handle| handle.starts_with('@'))
|
||||||
|
else {
|
||||||
|
self.status =
|
||||||
|
"This video's channel handle is unavailable; no channel was saved.".into();
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let handle = handle.to_owned();
|
||||||
let channel = format!("https://www.youtube.com/channel/{id}");
|
let channel = format!("https://www.youtube.com/channel/{id}");
|
||||||
let label = format!(" {} ", video.channel.as_deref().unwrap_or(id));
|
let label = format!(" {} ({handle}) ", video.channel.as_deref().unwrap_or(id));
|
||||||
match config::load(&self.config_path) {
|
match config::load(&self.config_path) {
|
||||||
Ok(config) => self.apply_config(config),
|
Ok(config) => self.apply_config(config),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -292,6 +307,7 @@ impl App {
|
|||||||
self.description_job = None;
|
self.description_job = None;
|
||||||
self.category_dialog = Some(CategoryDialog {
|
self.category_dialog = Some(CategoryDialog {
|
||||||
channel,
|
channel,
|
||||||
|
handle,
|
||||||
label,
|
label,
|
||||||
selection,
|
selection,
|
||||||
name: None,
|
name: None,
|
||||||
@@ -366,7 +382,12 @@ impl App {
|
|||||||
.cloned()
|
.cloned()
|
||||||
});
|
});
|
||||||
if let Some(category) = category {
|
if let Some(category) = category {
|
||||||
match config::add_channel(&self.config_path, &category, &dialog.channel) {
|
match config::add_channel(
|
||||||
|
&self.config_path,
|
||||||
|
&category,
|
||||||
|
&dialog.handle,
|
||||||
|
&dialog.channel,
|
||||||
|
) {
|
||||||
Ok(config) => {
|
Ok(config) => {
|
||||||
self.apply_config(config);
|
self.apply_config(config);
|
||||||
self.switch_view(self.view_index);
|
self.switch_view(self.view_index);
|
||||||
@@ -929,6 +950,7 @@ mod tests {
|
|||||||
title: "Me at the zoo".into(),
|
title: "Me at the zoo".into(),
|
||||||
channel: None,
|
channel: None,
|
||||||
channel_id: None,
|
channel_id: None,
|
||||||
|
uploader_id: None,
|
||||||
timestamp: None,
|
timestamp: None,
|
||||||
description: None,
|
description: None,
|
||||||
},
|
},
|
||||||
@@ -937,6 +959,7 @@ mod tests {
|
|||||||
title: "Rust course".into(),
|
title: "Rust course".into(),
|
||||||
channel: None,
|
channel: None,
|
||||||
channel_id: None,
|
channel_id: None,
|
||||||
|
uploader_id: None,
|
||||||
timestamp: None,
|
timestamp: None,
|
||||||
description: None,
|
description: None,
|
||||||
},
|
},
|
||||||
@@ -973,7 +996,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn category_dialog_is_modal_and_views_keep_independent_selections() {
|
fn category_dialog_is_modal_and_views_keep_independent_selections() {
|
||||||
let mut app = app(&["tubular", "rust"]);
|
let mut app = app(&["tubular", "rust"]);
|
||||||
let videos = r#"{"entries":[{"id":"l-GaaivVJCE","title":"Rust video","channel":"Rust","channel_id":"UCaYhcUwRBNscFNUKTjgPFiA"},{"id":"rQ_J9WH6CGk","title":"Another video"}]}"#;
|
let videos = r#"{"entries":[{"id":"l-GaaivVJCE","title":"Rust video","channel":"Rust","channel_id":"UCaYhcUwRBNscFNUKTjgPFiA","uploader_id":"@RustVideos"},{"id":"rQ_J9WH6CGk","title":"Another video"}]}"#;
|
||||||
app.views[0].videos = youtube::search_results(videos).unwrap();
|
app.views[0].videos = youtube::search_results(videos).unwrap();
|
||||||
app.views[0].loaded = true;
|
app.views[0].loaded = true;
|
||||||
app.views[0].selection.select(Some(1));
|
app.views[0].selection.select(Some(1));
|
||||||
@@ -981,6 +1004,10 @@ mod tests {
|
|||||||
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
|
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
|
||||||
assert!(app.category_dialog.is_none());
|
assert!(app.category_dialog.is_none());
|
||||||
app.views[0].selection.select(Some(0));
|
app.views[0].selection.select(Some(0));
|
||||||
|
let handle = app.views[0].videos[0].uploader_id.take();
|
||||||
|
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
|
||||||
|
assert!(app.category_dialog.is_none());
|
||||||
|
app.views[0].videos[0].uploader_id = handle;
|
||||||
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
|
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
|
||||||
app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
|
app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
|
||||||
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||||
@@ -1050,7 +1077,7 @@ mod tests {
|
|||||||
assert!(!app.thumbnail_open);
|
assert!(!app.thumbnail_open);
|
||||||
|
|
||||||
app.views[0].videos = youtube::search_results(
|
app.views[0].videos = youtube::search_results(
|
||||||
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Me at the zoo","channel_id":"UC4QobU6STFB0P71PMvOGN5A"}]}"#,
|
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Me at the zoo","channel_id":"UC4QobU6STFB0P71PMvOGN5A","uploader_id":"@jawed"}]}"#,
|
||||||
).unwrap();
|
).unwrap();
|
||||||
app.views[0].videos[0].description = Some("A cached full description.".into());
|
app.views[0].videos[0].description = Some("A cached full description.".into());
|
||||||
app.views[0].selection.select(Some(0));
|
app.views[0].selection.select(Some(0));
|
||||||
|
|||||||
+24
-3
@@ -14,6 +14,7 @@ pub struct Video {
|
|||||||
pub title: String,
|
pub title: String,
|
||||||
pub channel: Option<String>,
|
pub channel: Option<String>,
|
||||||
pub channel_id: Option<String>,
|
pub channel_id: Option<String>,
|
||||||
|
pub uploader_id: Option<String>,
|
||||||
pub timestamp: Option<i64>,
|
pub timestamp: Option<i64>,
|
||||||
// Search descriptions are truncated; only full video metadata populates this cache.
|
// Search descriptions are truncated; only full video metadata populates this cache.
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
@@ -25,6 +26,7 @@ struct SearchResults {
|
|||||||
entries: Vec<Option<Video>>,
|
entries: Vec<Option<Video>>,
|
||||||
channel: Option<String>,
|
channel: Option<String>,
|
||||||
channel_id: Option<String>,
|
channel_id: Option<String>,
|
||||||
|
uploader_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn search_results(output: &str) -> Result<Vec<Video>, String> {
|
pub fn search_results(output: &str) -> Result<Vec<Video>, String> {
|
||||||
@@ -63,6 +65,13 @@ fn validate_video(video: &mut Video) -> Result<(), String> {
|
|||||||
{
|
{
|
||||||
return Err("an invalid YouTube channel ID".into());
|
return Err("an invalid YouTube channel ID".into());
|
||||||
}
|
}
|
||||||
|
if video
|
||||||
|
.uploader_id
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|handle| handle.chars().any(char::is_control))
|
||||||
|
{
|
||||||
|
return Err("an invalid YouTube channel handle".into());
|
||||||
|
}
|
||||||
video.title.retain(|c| !c.is_control());
|
video.title.retain(|c| !c.is_control());
|
||||||
if let Some(channel) = &mut video.channel {
|
if let Some(channel) = &mut video.channel {
|
||||||
channel.retain(|c| !c.is_control());
|
channel.retain(|c| !c.is_control());
|
||||||
@@ -88,6 +97,9 @@ pub fn feed_results(output: &str) -> Result<Vec<Video>, String> {
|
|||||||
if video.channel_id.is_none() {
|
if video.channel_id.is_none() {
|
||||||
video.channel_id = playlist.channel_id.clone();
|
video.channel_id = playlist.channel_id.clone();
|
||||||
}
|
}
|
||||||
|
if video.uploader_id.is_none() {
|
||||||
|
video.uploader_id = playlist.uploader_id.clone();
|
||||||
|
}
|
||||||
if video.channel.is_none() {
|
if video.channel.is_none() {
|
||||||
video.channel = playlist.channel.clone();
|
video.channel = playlist.channel.clone();
|
||||||
}
|
}
|
||||||
@@ -485,26 +497,30 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn search_handles_unavailable_entries_and_rejects_invalid_ids() {
|
fn search_handles_unavailable_entries_and_rejects_invalid_ids() {
|
||||||
let videos = search_results(
|
let videos = search_results(
|
||||||
r#"{"entries":[null,{"id":"rQ_J9WH6CGk","title":"Rust\u001b","channel":null}]}"#,
|
r#"{"entries":[null,{"id":"rQ_J9WH6CGk","title":"Rust\u001b","channel":null,"uploader_id":"@RustVideos"}]}"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(videos[0].title, "Rust");
|
assert_eq!(videos[0].title, "Rust");
|
||||||
assert_eq!(videos[0].channel, None);
|
assert_eq!(videos[0].channel, None);
|
||||||
assert_eq!(videos[0].channel_id, None);
|
assert_eq!(videos[0].channel_id, None);
|
||||||
|
assert_eq!(videos[0].uploader_id.as_deref(), Some("@RustVideos"));
|
||||||
assert!(
|
assert!(
|
||||||
search_results(
|
search_results(
|
||||||
r#"{"entries":[{"id":"rQ_J9WH6CGk","title":"Bad","channel_id":"not-a-channel"}]}"#
|
r#"{"entries":[{"id":"rQ_J9WH6CGk","title":"Bad","channel_id":"not-a-channel"}]}"#
|
||||||
)
|
)
|
||||||
.is_err()
|
.is_err()
|
||||||
);
|
);
|
||||||
|
assert!(search_results(
|
||||||
|
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Unsafe","uploader_id":"@jawed\u001b"}]}"#
|
||||||
|
).is_err());
|
||||||
assert!(search_results(r#"{"entries":[{"id":"--bad","title":"Bad"}]}"#).is_err());
|
assert!(search_results(r#"{"entries":[{"id":"--bad","title":"Bad"}]}"#).is_err());
|
||||||
assert!(search_results("not JSON").is_err());
|
assert!(search_results("not JSON").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn feeds_merge_newest_first_deduplicate_and_inherit_channel_identity() {
|
fn feeds_merge_newest_first_deduplicate_and_inherit_channel_identity() {
|
||||||
let output = r#"{"channel_id":"UCaYhcUwRBNscFNUKTjgPFiA","channel":"Rust\u001b","entries":[null,{"id":"l-GaaivVJCE","title":"Older","timestamp":100},{"id":"jNQXAC9IVRw","title":"Unknown","timestamp":null}]}
|
let output = r#"{"channel_id":"UCaYhcUwRBNscFNUKTjgPFiA","uploader_id":"@RustVideos","channel":"Rust\u001b","entries":[null,{"id":"l-GaaivVJCE","title":"Older","timestamp":100},{"id":"jNQXAC9IVRw","title":"Unknown","timestamp":null}]}
|
||||||
{"channel_id":"UCaYhcUwRBNscFNUKTjgPFiA","channel":"Rust","entries":[{"id":"SLV9AJxnb1U","title":"Newest","timestamp":300},{"id":"l-GaaivVJCE","title":"Newer","timestamp":200},{"id":"rQ_J9WH6CGk","title":"No metadata","channel_id":null}]}"#;
|
{"channel_id":"UCaYhcUwRBNscFNUKTjgPFiA","uploader_id":"@RustVideos","channel":"Rust","entries":[{"id":"SLV9AJxnb1U","title":"Newest","timestamp":300},{"id":"l-GaaivVJCE","title":"Newer","timestamp":200},{"id":"rQ_J9WH6CGk","title":"No metadata","channel_id":null}]}"#;
|
||||||
let videos = feed_results(output).unwrap();
|
let videos = feed_results(output).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
videos
|
videos
|
||||||
@@ -522,6 +538,11 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.all(|video| video.channel_id.as_deref() == Some("UCaYhcUwRBNscFNUKTjgPFiA"))
|
.all(|video| video.channel_id.as_deref() == Some("UCaYhcUwRBNscFNUKTjgPFiA"))
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
videos
|
||||||
|
.iter()
|
||||||
|
.all(|video| video.uploader_id.as_deref() == Some("@RustVideos"))
|
||||||
|
);
|
||||||
let unavailable = feed_results(
|
let unavailable = feed_results(
|
||||||
r#"{"entries":[null,{"id":"jNQXAC9IVRw","title":"Unknown","channel_id":null}]}"#,
|
r#"{"entries":[null,{"id":"jNQXAC9IVRw","title":"Unknown","channel_id":null}]}"#,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user