From 8ec2807ece746b001e3d64a1db990ce239a98801 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sun, 19 Jul 2026 10:46:22 +0300 Subject: cv0: open YouTube links in an alternative browser The 'o' open key now routes youtube.com/youtu.be video links to an optional alternative browser (e.g. chromium) via the new --youtube-browser-cmd flag, while every other URL keeps using --browser-cmd. When the flag is unset, YouTube links fall back to the default browser as before. - add youtubeBrowserCmd field and SetYouTubeBrowserCmd setter - add youtubeHostRegex plus isYouTubeURL and browserForURL helpers - wire --youtube-browser-cmd flag in main.go - unit-test URL detection and browser selection - document the flag in README Co-Authored-By: Claude Opus 4.8 --- README.md | 1 + cmd/tasksamurai/main.go | 5 +++ internal/ui/keyactions.go | 27 +++++++++++++++- internal/ui/table.go | 28 +++++++++++++--- internal/ui/youtube_test.go | 79 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 134 insertions(+), 6 deletions(-) create mode 100644 internal/ui/youtube_test.go diff --git a/README.md b/README.md index 926bfa6..35f5992 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ tasksamurai -- -excludetag +includetag ### Flags - `--browser-cmd `: command used to open URLs (default: firefox on Linux, open on macOS) +- `--youtube-browser-cmd `: command used to open `youtube.com` / `youtu.be` links with the `o` key (default: unset, so YouTube links use `--browser-cmd`). Set it to, e.g., `chromium` to play YouTube videos in a different browser than your default. - `--agent-hotkey `: hotkey used to toggle the `+agent` / `-agent` filter (default: `3`) - `--debug-log `: path to debug log file for Taskwarrior commands - `--debug-dir `: directory for runtime debug output (goroutine dumps, profiles) diff --git a/cmd/tasksamurai/main.go b/cmd/tasksamurai/main.go index 2b90dc4..8d4924c 100644 --- a/cmd/tasksamurai/main.go +++ b/cmd/tasksamurai/main.go @@ -24,6 +24,10 @@ func main() { debugLog := flag.String("debug-log", "", "path to debug log file") debugDir := flag.String("debug-dir", "", "directory for runtime debug output (goroutine dumps, profiles)") browserCmd := flag.String("browser-cmd", browserCmdDefault, "command used to open URLs") + // Empty by default: YouTube links then open with --browser-cmd like any + // other URL. Set this to route youtube.com/youtu.be links to an + // alternative browser (e.g. chromium) that plays them better. + youtubeBrowserCmd := flag.String("youtube-browser-cmd", "", "command used to open youtube.com/youtu.be links (defaults to --browser-cmd)") agentHotkey := flag.String("agent-hotkey", "3", "key used to toggle the +agent/-agent filter") disco := flag.Bool("disco", false, "enable disco mode") ultra := flag.Bool("ultra", false, "start directly in ultra mode") @@ -48,6 +52,7 @@ func main() { fmt.Fprintln(os.Stderr, "invalid --agent-hotkey:", err) fmt.Fprintln(os.Stderr, "using default hotkey 3") } + m.SetYouTubeBrowserCmd(*youtubeBrowserCmd) m.SetDisco(*disco) m.SetUltra(*ultra) diff --git a/internal/ui/keyactions.go b/internal/ui/keyactions.go index 70d113f..a2902a5 100644 --- a/internal/ui/keyactions.go +++ b/internal/ui/keyactions.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "math/rand" + "net/url" "os" "os/exec" "path/filepath" @@ -108,7 +109,7 @@ func (m *Model) handleOpenURL() (tea.Model, tea.Cmd) { } if url := findTaskURL(task); url != "" { - return m, openURLCmd(m.browserCmd, url, task.ID) + return m, openURLCmd(m.browserForURL(url), url, task.ID) } if path := findTaskFileRef(task); path != "" { @@ -118,6 +119,30 @@ func (m *Model) handleOpenURL() (tea.Model, tea.Cmd) { return m, nil } +// browserForURL picks the command used to open rawURL. YouTube video links are +// routed to the configured alternative browser (youtubeBrowserCmd) when one is +// set, so videos can play in a browser better suited for them; every other URL +// (and YouTube links when no alternative is configured) uses the default +// browserCmd. +func (m *Model) browserForURL(rawURL string) string { + if m.youtubeBrowserCmd != "" && isYouTubeURL(rawURL) { + return m.youtubeBrowserCmd + } + return m.browserCmd +} + +// isYouTubeURL reports whether rawURL points at a YouTube video link. The host +// is parsed with net/url and matched against youtubeHostRegex so that only the +// real host is considered — a path or query string that merely mentions +// "youtube.com" (e.g. https://example.com/?u=youtube.com) does not match. +func isYouTubeURL(rawURL string) bool { + u, err := url.Parse(rawURL) + if err != nil { + return false + } + return youtubeHostRegex.MatchString(u.Hostname()) +} + // findTaskURL returns the first http(s) URL found in the task description, or // failing that in any annotation. func findTaskURL(t *task.Task) string { diff --git a/internal/ui/table.go b/internal/ui/table.go index e168ba7..5efb41e 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -35,7 +35,12 @@ var ( // fileRefRegex matches an @-prefixed file reference such as // "@path/to/file.txt". The leading (^|\s) anchor keeps it from matching // the "@host" part of an email address; capture group 2 is the path. - fileRefRegex = regexp.MustCompile(`(^|\s)@(\S+)`) + fileRefRegex = regexp.MustCompile(`(^|\s)@(\S+)`) + // youtubeHostRegex matches the host of a YouTube video link so the "o" + // key can route it to an alternative browser. It covers youtube.com (with + // optional www./m. subdomains) and the youtu.be short-link domain. Case is + // ignored because hostnames are case-insensitive. + youtubeHostRegex = regexp.MustCompile(`(?i)^(www\.|m\.)?(youtube\.com|youtu\.be)$`) searchRegexCache = make(map[string]*regexp.Regexp, 16) searchRegexMu sync.RWMutex ) @@ -221,10 +226,15 @@ type Model struct { inProgress int due int - filters []string - tasks []task.Task - undoStack []undoAction - browserCmd string + filters []string + tasks []task.Task + undoStack []undoAction + browserCmd string + // youtubeBrowserCmd, when non-empty, overrides browserCmd for YouTube + // links opened with the "o" key. This lets the user route videos to a + // browser better suited for them (e.g. chromium) while keeping the + // default browser (e.g. firefox) for everything else. + youtubeBrowserCmd string agentFilterHotkey string taskwarrior task.Taskwarrior @@ -1436,6 +1446,14 @@ func (m *Model) SetAgentFilterHotkey(key string) error { return nil } +// SetYouTubeBrowserCmd configures the browser command used specifically for +// YouTube links opened with the "o" key. An empty value (the default) means +// YouTube links are opened with the regular browser command like any other +// URL. Surrounding whitespace is trimmed so a blank flag value counts as unset. +func (m *Model) SetYouTubeBrowserCmd(cmd string) { + m.youtubeBrowserCmd = strings.TrimSpace(cmd) +} + func (m *Model) agentFilterHotkeyLabel() string { if strings.TrimSpace(m.agentFilterHotkey) == "" { return "3" diff --git a/internal/ui/youtube_test.go b/internal/ui/youtube_test.go new file mode 100644 index 0000000..86fa324 --- /dev/null +++ b/internal/ui/youtube_test.go @@ -0,0 +1,79 @@ +package ui + +import "testing" + +// TestIsYouTubeURL verifies that only real YouTube video hosts are recognised, +// covering the canonical domains and short link, subdomains, and cases where +// "youtube.com" merely appears in a path or query string of another host. +func TestIsYouTubeURL(t *testing.T) { + cases := []struct { + name string + url string + want bool + }{ + {"youtube.com watch", "https://youtube.com/watch?v=abc123", true}, + {"www.youtube.com", "https://www.youtube.com/watch?v=abc123", true}, + {"m.youtube.com", "https://m.youtube.com/watch?v=abc123", true}, + {"youtu.be short link", "https://youtu.be/abc123", true}, + {"uppercase host", "https://WWW.YOUTUBE.COM/watch?v=abc", true}, + {"plain http", "http://youtube.com/", true}, + {"not youtube", "https://example.com/watch?v=abc123", false}, + {"youtube in path only", "https://example.com/youtube.com/x", false}, + {"youtube in query only", "https://example.com/?u=youtube.com", false}, + {"lookalike host", "https://notyoutube.com/watch", false}, + {"empty", "", false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := isYouTubeURL(tc.url); got != tc.want { + t.Fatalf("isYouTubeURL(%q) = %v, want %v", tc.url, got, tc.want) + } + }) + } +} + +// TestBrowserForURL verifies the browser-selection logic: YouTube links use the +// configured alternative browser only when one is set, and every other URL (as +// well as YouTube links with no override) falls back to the default browser. +func TestBrowserForURL(t *testing.T) { + cases := []struct { + name string + browser string + youtube string + url string + wantSelected string + }{ + {"youtube with override", "firefox", "chromium", "https://youtu.be/x", "chromium"}, + {"youtube without override", "firefox", "", "https://youtu.be/x", "firefox"}, + {"non-youtube with override set", "firefox", "chromium", "https://example.com", "firefox"}, + {"non-youtube no override", "firefox", "", "https://example.com", "firefox"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := &Model{browserCmd: tc.browser, youtubeBrowserCmd: tc.youtube} + if got := m.browserForURL(tc.url); got != tc.wantSelected { + t.Fatalf("browserForURL(%q) = %q, want %q", tc.url, got, tc.wantSelected) + } + }) + } +} + +// TestSetYouTubeBrowserCmdTrimsWhitespace verifies that a blank flag value is +// treated as unset so YouTube links keep using the default browser. +func TestSetYouTubeBrowserCmdTrimsWhitespace(t *testing.T) { + m := &Model{browserCmd: "firefox"} + m.SetYouTubeBrowserCmd(" ") + if m.youtubeBrowserCmd != "" { + t.Fatalf("blank value should be empty, got %q", m.youtubeBrowserCmd) + } + if got := m.browserForURL("https://youtu.be/x"); got != "firefox" { + t.Fatalf("browserForURL = %q, want firefox", got) + } + + m.SetYouTubeBrowserCmd(" chromium ") + if m.youtubeBrowserCmd != "chromium" { + t.Fatalf("value should be trimmed to chromium, got %q", m.youtubeBrowserCmd) + } +} -- cgit v1.2.3