From bddd75d92e0cb2e3cee5c6e78a5350544c7a8894 Mon Sep 17 00:00:00 2001 From: Mathias Date: Tue, 9 Jun 2026 21:35:05 +0200 Subject: [PATCH] feat(web): parse YouTube video id from pasted URL forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure parser for watch?v=, youtu.be/, shorts/, embed/, and bare ids; rejects non-YouTube hosts and malformed input. Foundation for paste-a-URL summarize (Feature 2). No fetch, no gate interaction — parsing only. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/web/paste.go | 62 ++++++++++++++++++++++++++++++++++++++ internal/web/paste_test.go | 57 +++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 internal/web/paste.go create mode 100644 internal/web/paste_test.go diff --git a/internal/web/paste.go b/internal/web/paste.go new file mode 100644 index 0000000..d1d60e0 --- /dev/null +++ b/internal/web/paste.go @@ -0,0 +1,62 @@ +package web + +import ( + "fmt" + "net/url" + "regexp" + "strings" +) + +// youtubeVideoID matches a canonical YouTube video id: exactly 11 URL-safe chars. +var youtubeVideoID = regexp.MustCompile(`^[A-Za-z0-9_-]{11}$`) + +// parseYouTubeVideoID extracts the 11-character video id from a pasted YouTube +// URL (watch?v=, youtu.be/, shorts/, embed/) or a bare id. It rejects non-YouTube +// hosts and anything that doesn't yield a valid id, so the paste flow never tries +// to fetch a video that can't exist (Feature 2). +func parseYouTubeVideoID(raw string) (string, error) { + s := strings.TrimSpace(raw) + if s == "" { + return "", fmt.Errorf("empty input") + } + + // Bare id (no URL) — accept directly. + if youtubeVideoID.MatchString(s) { + return s, nil + } + + // Accept scheme-less URLs (youtube.com/watch?v=...) by giving url.Parse a host. + if !strings.Contains(s, "://") { + s = "https://" + s + } + u, err := url.Parse(s) + if err != nil { + return "", fmt.Errorf("not a URL: %w", err) + } + + host := strings.ToLower(u.Hostname()) + isYouTube := host == "youtu.be" || host == "youtube.com" || strings.HasSuffix(host, ".youtube.com") + if !isYouTube { + return "", fmt.Errorf("not a YouTube URL: %q", host) + } + + var id string + switch { + case host == "youtu.be": + // youtu.be/ + id = strings.Trim(u.Path, "/") + case u.Path == "/watch": + id = u.Query().Get("v") + default: + // /shorts/, /embed/ + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(parts) == 2 && (parts[0] == "shorts" || parts[0] == "embed") { + id = parts[1] + } + } + + if !youtubeVideoID.MatchString(id) { + return "", fmt.Errorf("no YouTube video id in %q", raw) + } + return id, nil +} diff --git a/internal/web/paste_test.go b/internal/web/paste_test.go new file mode 100644 index 0000000..68ed05d --- /dev/null +++ b/internal/web/paste_test.go @@ -0,0 +1,57 @@ +package web + +import "testing" + +func TestParseYouTubeVideoID(t *testing.T) { + const id = "dQw4w9WgXcQ" + ok := []struct { + name, in string + }{ + {"watch", "https://www.youtube.com/watch?v=" + id}, + {"watch no www", "https://youtube.com/watch?v=" + id}, + {"watch m", "https://m.youtube.com/watch?v=" + id}, + {"watch extra params", "https://www.youtube.com/watch?v=" + id + "&t=42s&list=PLxyz"}, + {"watch param after", "https://www.youtube.com/watch?list=PLxyz&v=" + id}, + {"short link", "https://youtu.be/" + id}, + {"short link param", "https://youtu.be/" + id + "?si=abcd&t=1"}, + {"shorts", "https://www.youtube.com/shorts/" + id}, + {"embed", "https://www.youtube.com/embed/" + id}, + {"bare id", id}, + {"http scheme", "http://youtube.com/watch?v=" + id}, + {"no scheme", "youtube.com/watch?v=" + id}, + {"trailing space", " https://youtu.be/" + id + " "}, + } + for _, c := range ok { + t.Run(c.name, func(t *testing.T) { + got, err := parseYouTubeVideoID(c.in) + if err != nil { + t.Fatalf("parseYouTubeVideoID(%q) error: %v", c.in, err) + } + if got != id { + t.Fatalf("parseYouTubeVideoID(%q) = %q, want %q", c.in, got, id) + } + }) + } + + bad := []struct { + name, in string + }{ + {"empty", ""}, + {"blank", " "}, + {"vimeo", "https://vimeo.com/123456789"}, + {"other host", "https://example.com/watch?v=" + id}, + {"watch no id", "https://www.youtube.com/watch?v="}, + {"short id", "https://youtu.be/abc"}, + {"long id", "https://youtu.be/" + id + "extra"}, + {"bad chars", "https://youtu.be/dQw4w9Wg!cQ"}, + {"not a url", "just some text"}, + {"channel url", "https://www.youtube.com/@somechannel"}, + } + for _, c := range bad { + t.Run("reject "+c.name, func(t *testing.T) { + if got, err := parseYouTubeVideoID(c.in); err == nil { + t.Fatalf("parseYouTubeVideoID(%q) = %q, want error", c.in, got) + } + }) + } +}