Compare commits

..

2 Commits

Author SHA1 Message Date
hl-valdemar 1a13fa8f47 feat: open selected video channel with U 2026-10-11 16:41:11 +02:00
hl-valdemar b38ca30735 feat: browse individual saved channels 2026-10-11 16:41:11 +02:00
3 changed files with 865 additions and 186 deletions
+15 -3
View File
@@ -61,6 +61,8 @@ Each selection opens an independent player instance without replacing existing p
| `d` | Open video info; press again for the full description, again to return to info |
| Esc | Close the preview |
| `c` | Browse saved categories |
| `u` | Browse an individual saved channel |
| `U` (Shift-U) | Open the selected video's channel |
| `s` | Edit the selected channel's tags |
| `S` | Edit tags for all saved channels in the current category |
| Tab / Shift-Tab | Switch between visible views |
@@ -73,11 +75,11 @@ The full-description view scrolls with ↓ / ↑, Ctrl-J / Ctrl-K, or PageDown /
### Text input
These shortcuts work in search, category-filter, and new-tag fields:
These shortcuts work in search, category/channel-filter, and new-tag fields:
| Key | Action |
| --- | --- |
| Enter | Submit the search, browse the selected category, or stage the new tag |
| Enter | Submit the search, browse the selected category/channel, or stage the new tag |
| Esc | Leave insert mode or abandon the new-tag name |
| Backspace | Delete one character |
| Option/Alt-Backspace or Ctrl-W | Delete the previous whitespace-delimited word |
@@ -91,6 +93,16 @@ If the terminal intercepts modified Backspace rather than transmitting it, use C
- **Insert:** type a case-insensitive substring to filter; arrow keys or Ctrl-J / Ctrl-K navigate matches; Enter browses; Esc returns to normal mode without clearing the filter.
- Reopening the browser clears its filter. No matches makes browsing and pinning a no-op.
### Saved channels (`u`)
- **Normal:** `j` / `k` or arrow keys navigate; `i` enters filter mode; Enter opens the selected channel's videos; Esc closes.
- **Insert:** type a case-insensitive substring to filter; arrow keys or Ctrl-J / Ctrl-K navigate matches; Enter browses; Esc returns to normal mode without clearing the filter.
- The list includes registered channels, including untagged ones, and channels saved only in categories. Aliases of the same channel appear once, using a mapped handle when available; otherwise the saved channel identity is shown.
- Choosing a channel opens one temporary channel tab. Choosing another replaces it without replacing the temporary category tab. Revisiting a channel restores its cached videos, selection, and pagination.
- Channel selection is single-choice and does not pin channels or change subscriptions. `s` still edits the selected video's channel tags; bulk `S` remains category-only.
Press **`U` (Shift-U)** on a video to browse its creator directly, including channels you have not saved. This uses the same temporary channel tab and caches without adding a subscription. Channel IDs are preferred; a valid handle is used when an ID is unavailable. Missing channel metadata leaves the current view unchanged. Unsaved channel views survive configuration changes for the session; explicitly removing a saved subscription still removes its channel view.
### Channel tags (`s`, or `S` for bulk editing)
- `j` / `k` or arrow keys navigate; Space toggles a tag.
@@ -134,7 +146,7 @@ Bulk editing refuses to clear all tags when a target has no `[channels]` registr
## Loading and previews
Search loads 20 results per page; Feed and category views request 20 uploads per channel per page. Moving near the loaded end in normal mode fetches more asynchronously, preserving selection and existing rows. Insert mode and open popups pause automatic prefetch. Deep channel archives can get slower because yt-dlp offsets traverse earlier pages again.
Search loads 20 results per page; Feed and category views request 20 uploads per channel per page. Individual channel views request only that channel's uploads, using the same pagination. Moving near the loaded end in normal mode fetches more asynchronously, preserving selection and existing rows. Insert mode and open popups pause automatic prefetch. Deep channel archives can get slower because yt-dlp offsets traverse earlier pages again.
Feed views merge newest-first using available upload dates. Older uploads wait while newer channel pages catch up; undated uploads appear when the feed is exhausted. Publication labels use `YYYY-MM-DD`; `~` marks an estimated UTC date, and `Unknown` means unavailable metadata.
+84 -1
View File
@@ -27,6 +27,23 @@ impl Config {
channels.dedup();
channels.into_iter().cloned().collect()
}
pub fn saved_channels(&self) -> Vec<(String, String)> {
let mut labels = BTreeMap::new();
// Mappings are sorted by handle, so aliases use the first readable label.
for (handle, identity) in &self.channels {
labels.entry(identity).or_insert(handle);
}
for identity in self.categories.values().flatten() {
labels.entry(identity).or_insert(identity);
}
let mut channels: Vec<_> = labels
.into_iter()
.map(|(identity, label)| (label.clone(), identity.clone()))
.collect();
channels.sort_unstable();
channels
}
}
pub fn config_path() -> io::Result<PathBuf> {
@@ -383,7 +400,7 @@ fn category_name(name: &str) -> io::Result<&str> {
Ok(name.trim())
}
fn normalize_channel(input: &str) -> io::Result<String> {
pub(crate) fn normalize_channel(input: &str) -> io::Result<String> {
if input.chars().any(char::is_control) {
return Err(invalid(
"Channel addresses must contain no control characters",
@@ -1097,6 +1114,72 @@ mod tests {
);
}
#[test]
fn saved_channels_use_readable_aliases_and_include_every_subscription_once() {
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let mapped_id = "UC4QobU6STFB0P71PMvOGN5A";
let untagged_id = "UCabcdefghijklmnopqrstuv";
let legacy_id = "UCzyxwvutsrqponmlkjihgfe";
let mapped = format!("https://www.youtube.com/channel/{mapped_id}");
let untagged = format!("https://www.youtube.com/channel/{untagged_id}");
let legacy = format!("https://www.youtube.com/channel/{legacy_id}");
fs::write(
&path,
format!(
"[channels]\n'@zAlias' = '{mapped_id}'\n'https://youtube.com/@aAlias' = '{mapped}'\n'@untagged' = '{untagged_id}'\n[categories]\nFirst = ['@zAlias', '{mapped_id}', '@manual', '{legacy_id}']\nSecond = ['@aAlias', 'https://youtube.com/@manual', '{legacy}']\n"
),
)
.unwrap();
let config = load(&path).unwrap();
let expected = vec![
("@aAlias".to_owned(), mapped.clone()),
("@untagged".to_owned(), untagged.clone()),
(
"https://www.youtube.com/@manual".to_owned(),
"https://www.youtube.com/@manual".to_owned(),
),
(legacy.clone(), legacy.clone()),
];
assert_eq!(config.saved_channels(), expected);
assert_eq!(load(&path).unwrap().saved_channels(), expected);
let mut identities: Vec<_> = config
.saved_channels()
.into_iter()
.map(|(_, identity)| identity)
.collect();
identities.sort_unstable();
assert_eq!(identities, config.subscriptions());
assert!(
config
.categories
.values()
.flatten()
.all(|id| id != &untagged)
);
assert_eq!(config.channels["@zAlias"], mapped);
}
#[test]
fn saved_channels_support_empty_and_registry_only_configs() {
assert!(Config::default().saved_channels().is_empty());
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let identity = "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A";
fs::write(
&path,
format!("[channels]\n'@zAlias' = '{identity}'\n'@aAlias' = '{identity}'\n"),
)
.unwrap();
let config = load(&path).unwrap();
assert!(config.categories.is_empty());
assert_eq!(
config.saved_channels(),
vec![("@aAlias".to_owned(), identity.to_owned())]
);
assert_eq!(config.subscriptions(), [identity]);
}
#[test]
fn subscriptions_include_untagged_registry_and_manual_channels_after_tag_removal() {
let directory = TestDirectory::new();
+757 -173
View File
File diff suppressed because one or more lines are too long