From 79cd752ff8a01d494b28969cb5a48b47b046966c Mon Sep 17 00:00:00 2001
From: Duc-Tam Nguyen <tamnd87@gmail.com>
Date: Mon, 15 Jun 2026 13:41:59 +0700
Subject: [PATCH 1/2] Add discover, a breadth-first walk of the YouTube graph

The reads each answer one question about one object: a video's metadata,
a channel's uploads, a playlist's items. discover chains them. From a
seed video, channel, or playlist it follows that object's links outward,
hop by hop, streaming one node per row as it is reached.

The walker lives in the library behind a small grapher interface, the
exact subset of Client it needs, so the BFS is tested hermetically over a
fake in-memory graph with no network: the bounds, ordering, dedup, and
degradation are unit tests, not integration tests.

Nine edges across five node kinds: a video to its channel and related
videos and comments, a channel to its uploads, playlists, and community
posts, a playlist to its items and owner, a comment to the channel that
wrote it. Presets bundle them by intent (content, feed, comments, all),
with content the default since it spans every seed kind, so plain
discover does the obvious thing with no flags.

Unlike X there are no scrape tiers to gate, so nothing is dropped up
front. The only walled edges are the two that touch comments, refused by
YouTube's per-IP Restricted Mode on some networks. The walk attempts
them and degrades to a one-line note on failure, continuing on the rest
of the graph, rather than failing the whole walk. A seed that cannot be
fetched is still fatal, matching a single read; deeper failures are
notes.

Depth, fanout, and a total node budget bound the walk so it always
terminates, even with an uncapped fanout. Edges are recorded eagerly so
the stored graph stays complete while nodes stay de-duplicated by an
alias-collapsing key.

discover --store tees the walk into the typed crawl store and records
each traversed link into a new edges table, so a live walk doubles as a
crawl you can query with db query afterwards. The existing seed, crawl,
queue, and jobs worklist crawler is untouched; discover is the
complement that finds the worklist by walking instead of draining one.

Docs get a graph-discovery guide, a persist-a-walk section in the store
guide, and a discover entry in the CLI reference.
---
 cli/commands.go                        |   1 +
 cli/discover.go                        | 145 ++++++
 cli/rows.go                            |  60 +++
 docs/content/guides/graph-discovery.md | 125 +++++
 docs/content/guides/the-store.md       |  16 +
 docs/content/reference/cli.md          |  14 +
 youtube/discover.go                    | 684 +++++++++++++++++++++++++
 youtube/discover_test.go               | 407 +++++++++++++++
 youtube/store.go                       |  56 +-
 9 files changed, 1506 insertions(+), 2 deletions(-)
 create mode 100644 cli/discover.go
 create mode 100644 docs/content/guides/graph-discovery.md
 create mode 100644 youtube/discover.go
 create mode 100644 youtube/discover_test.go

diff --git a/cli/commands.go b/cli/commands.go
index 6cf9013..8c7a818 100644
--- a/cli/commands.go
+++ b/cli/commands.go
@@ -16,6 +16,7 @@ func registerEscapeHatches(app *kit.App) {
 	app.AddCommand(newDownloadCmd())
 	app.AddCommand(newExtractCmd())
 	app.AddCommand(newMusicCmd())
+	app.AddCommand(newDiscoverCmd())
 	app.AddCommand(newSeedCmd())
 	app.AddCommand(newCrawlCmd())
 	app.AddCommand(newQueueCmd())
diff --git a/cli/discover.go b/cli/discover.go
new file mode 100644
index 0000000..5e1beba
--- /dev/null
+++ b/cli/discover.go
@@ -0,0 +1,145 @@
+package cli
+
+import (
+	"context"
+
+	"github.com/tamnd/any-cli/kit"
+	"github.com/tamnd/ytb-cli/youtube"
+)
+
+// defaultDiscoverBudget caps a streaming walk when the user did not pass -n, so
+// `ytb discover <video>` always terminates instead of spidering YouTube forever.
+const defaultDiscoverBudget = 500
+
+// newDiscoverCmd is the breadth-first graph walk. Where the record reads each
+// answer one question about one object, discover chains them: from a seed video,
+// channel, or playlist it follows the object's links and from each neighbor it
+// follows theirs, hop by hop, emitting one row per node as it is reached.
+//
+// It shares the read group with the per-object commands because it is a read; it
+// only touches the store when --store is set, where it persists each node and
+// records every traversed edge into the edges table.
+func newDiscoverCmd() kit.Command {
+	var (
+		depth  int
+		fanout int
+		follow string
+		store  bool
+	)
+	return kit.Command{
+		Use:     "discover <seed>...",
+		Aliases: []string{"walk", "graph"},
+		Group:   "read",
+		Short:   "Breadth-first walk of the graph linked from a video, channel, or playlist",
+		Long: `Walk the graph of linked YouTube objects, breadth first, starting from one or
+more seeds. A seed is anything ytb can resolve: a video id or watch URL, a
+channel id, @handle, or URL, or a playlist id or URL.
+
+--follow chooses which links to traverse. It takes a preset or a comma-separated
+edge list:
+
+  content   a video's channel and related videos, a channel's uploads, a
+            playlist's items and owner (the default; the obvious neighbors)
+  feed      a channel's uploads, playlists, and community posts
+  comments  a video's comments and the channels that wrote them
+  all       every edge
+
+Edges: channel, related, comments, uploads, playlists, community, items, owner,
+commenter. Name an edge directly to follow just that link, e.g. --follow related
+to chase the recommendation graph.
+
+--depth is how many hops to follow (default 1; 0 emits only the seeds). --fanout
+caps neighbors per edge (default 25). The walk streams nodes and stops after -n
+nodes (default 500). Comments are served only when YouTube is not applying its
+per-IP Restricted Mode to this network; when it is, the comment edges are noted
+and skipped and the rest of the walk continues.
+
+Add --store to persist every node into its typed table and record each traversed
+edge into the edges table, so a walk doubles as a crawl. Query it afterwards with
+ytb db query.`,
+		Args: kit.MinimumNArgs(1),
+		Flags: func(f *kit.FlagSet) {
+			f.IntVar(&depth, "depth", 1, "hops to follow from each seed (0 = seeds only)")
+			f.IntVar(&fanout, "fanout", 25, "max neighbors to follow per edge (0 = unlimited)")
+			f.StringVar(&follow, "follow", "content", "edges to follow ("+youtube.EdgeHelp()+")")
+			f.BoolVar(&store, "store", false, "persist nodes and edges into the local store")
+		},
+		Run: func(ctx context.Context, args []string) error {
+			app := appFromCtx(ctx)
+
+			edges, err := youtube.ParseEdges(follow)
+			if err != nil {
+				return usageErr(err.Error())
+			}
+			seeds, err := parseSeeds(args)
+			if err != nil {
+				return err
+			}
+
+			var st *youtube.Store
+			if store {
+				st, err = app.RequireStore()
+				if err != nil {
+					return err
+				}
+			}
+
+			budget := app.Limit
+			if budget <= 0 {
+				budget = defaultDiscoverBudget
+			}
+
+			opts := youtube.WalkOptions{
+				Depth:  depth,
+				Max:    budget,
+				Fanout: fanout,
+				Edges:  edges,
+				Note:   func(s string) { app.logf("note: %s", s) },
+			}
+			if st != nil {
+				opts.OnEdge = func(src, dst string, e youtube.Edge) {
+					_ = st.UpsertEdge(src, dst, string(e))
+				}
+			}
+
+			n := 0
+			err = app.Client.Walk(ctx, seeds, opts, func(nd *youtube.Node) error {
+				if st != nil {
+					_ = st.UpsertNode(nd)
+				}
+				if e := app.Out.Emit(nodeRow(nd)); e != nil {
+					return e
+				}
+				n++
+				return nil
+			})
+			if flushErr := app.Out.Flush(); flushErr != nil && err == nil {
+				err = flushErr
+			}
+			if err != nil {
+				// The only fatal walk error is a seed that could not be fetched;
+				// it surfaces like a failed single read. Deeper failures (a gated
+				// comment edge, a missing community tab) are notes, not errors.
+				return err
+			}
+			if n == 0 {
+				return noResults("nothing discovered")
+			}
+			return nil
+		},
+	}
+}
+
+// parseSeeds turns the positional arguments into walk seeds, reporting an
+// unrecognized reference as a usage error rather than a plain failure.
+func parseSeeds(args []string) ([]youtube.Seed, error) {
+	seeds := make([]youtube.Seed, 0, len(args))
+	for _, a := range args {
+		s, err := youtube.ParseSeed(a)
+		if err != nil {
+			return nil, usageErr(err.Error())
+		}
+		seeds = append(seeds, s)
+	}
+	return seeds, nil
+}
diff --git a/cli/rows.go b/cli/rows.go
index 78ea39e..ef2e3c1 100644
--- a/cli/rows.go
+++ b/cli/rows.go
@@ -2,6 +2,7 @@ package cli
 
 import (
 	"strconv"
+	"strings"
 
 	"github.com/tamnd/ytb-cli/youtube"
 )
@@ -10,6 +11,65 @@ func itoa(n int) string   { return strconv.Itoa(n) }
 func i64a(n int64) string { return strconv.FormatInt(n, 10) }
 func boola(b bool) string { return strconv.FormatBool(b) }
 
+// oneline flattens a value to a single short line for a curated column, so a
+// multi-line title or comment body never breaks the list and table layouts.
+func oneline(s string) string {
+	s = strings.Join(strings.Fields(s), " ")
+	if len(s) > 80 {
+		return s[:79] + "..."
+	}
+	return s
+}
+
+// nodeRow renders a graph node discovered by `ytb discover`. The curated columns
+// read the walk at a glance (how deep, by which edge, and a one-line who/what)
+// while the full typed node (with the nested video, channel, playlist, comment,
+// or post) rides in Value for json, jsonl, and templates.
+func nodeRow(n *youtube.Node) Row {
+	var id, who, summary, url string
+	switch n.Kind {
+	case youtube.KindVideo:
+		v := n.Video
+		id = v.VideoID
+		who = v.ChannelName
+		summary = oneline(v.Title)
+		url = v.URL
+		if url == "" {
+			url = "https://www.youtube.com/watch?v=" + v.VideoID
+		}
+	case youtube.KindChannel:
+		c := n.Channel
+		id = c.ChannelID
+		who = c.Handle
+		if who == "" {
+			who = c.Title
+		}
+		summary = oneline(c.Title)
+		url = c.URL
+	case youtube.KindPlaylist:
+		p := n.Playlist
+		id = p.PlaylistID
+		who = p.ChannelName
+		summary = oneline(p.Title)
+		url = p.URL
+	case youtube.KindComment:
+		c := n.Comment
+		id = c.ID
+		who = c.AuthorDisplayName
+		summary = oneline(c.TextDisplay)
+	case youtube.KindPost:
+		p := n.Post
+		id = p.PostID
+		who = p.AuthorName
+		summary = oneline(p.ContentText)
+	}
+	return Row{
+		Cols:  []string{"depth", "via", "kind", "id", "who", "summary", "url"},
+		Vals:  []string{itoa(n.Depth), string(n.Via), string(n.Kind), id, who, summary, url},
+		Value: n,
+	}
+}
+
 func videoRow(v youtube.Video) Row {
 	return Row{
 		Cols: []string{"id", "title", "channel", "duration", "views", "published", "url"},
diff --git a/docs/content/guides/graph-discovery.md b/docs/content/guides/graph-discovery.md
new file mode 100644
index 0000000..3311a9e
--- /dev/null
+++ b/docs/content/guides/graph-discovery.md
@@ -0,0 +1,125 @@
+---
+title: "Graph discovery"
+description: "Walk the graph linked from a video, channel, or playlist breadth-first with ytb discover: follow uploaders, related videos, uploads, playlists, items, owners, community posts, comments, and the channels behind them."
+weight: 55
+---
+
+Every other read answers one question about one object: a video's metadata, a
+channel's uploads, a playlist's items. `ytb discover` chains them. It starts at a
+video, channel, or playlist and follows that object's links outward, hop by hop,
+streaming every node it reaches. It is a breadth-first walk of the YouTube graph.
+
+```sh
+ytb discover dQw4w9WgXcQ      # what is this video linked to?
+ytb discover @MrBeast         # what is this channel linked to?
+```
+
+A *seed* is any reference ytb already resolves: a video id or watch URL, a
+channel id, `@handle`, or URL, or a playlist id or URL. Pass more than one to
+start the walk from several places at once.
+
+## What gets followed
+
+The default follows an object's **content**: a video yields its uploader and
+related videos, a channel yields its uploads, a playlist yields its items and
+owner. It spans every seed kind, so `ytb discover <anything>` does the obvious
+thing with no flags, and it stays on the open surface, so it needs nothing
+special.
+
+Choose what to follow with `--follow`. It takes a preset:
+
+```sh
+ytb discover <ref> --follow content    # uploader, related, uploads, items, owner (default)
+ytb discover <channel> --follow feed   # uploads, playlists, community posts
+ytb discover <video> --follow comments # comments and the channels that wrote them
+ytb discover <ref> --follow all        # every edge
+```
+
+or a comma-separated list of individual edges:
+
+```sh
+ytb discover <video> --follow channel,related
+ytb discover <channel> --follow uploads,playlists
+```
+
+The full edge vocabulary:
+
+| Edge | From → to | Gated | What it follows |
+|---|---|---|---|
+| `channel` | video → channel | no | the channel that uploaded the video |
+| `related` | video → video | no | a related / recommended video |
+| `comments` | video → comment | yes | a comment on the video |
+| `uploads` | channel → video | no | a video the channel uploaded |
+| `playlists` | channel → playlist | no | a playlist the channel owns |
+| `community` | channel → post | no | a community-tab post |
+| `items` | playlist → video | no | a video in the playlist |
+| `owner` | playlist → channel | no | the channel that owns the playlist |
+| `commenter` | comment → channel | yes | the channel that wrote the comment |
+
+Name an edge directly to chase just that link: `--follow related` walks the
+recommendation graph, `--follow uploads` sweeps a channel.
+
+YouTube serves the open surface to an anonymous request, so there are no tiers to
+add. The only gated edges are the two that touch comments: YouTube applies a
+per-IP Restricted Mode to some networks, and when it refuses the comments,
+`ytb discover` notes it on stderr and keeps going on the rest of the graph rather
+than failing the walk. So `--follow all` from a flagged network still returns the
+channel, related videos, playlists, and everything else, with one advisory about
+the comments it could not read.
+
+## How far and how wide
+
+```sh
+ytb discover <ref> --depth 2          # follow two hops from the seed (default 1)
+ytb discover <ref> --fanout 50        # up to 50 neighbors per edge (default 25)
+ytb discover <ref> --fanout 0         # no per-edge cap
+ytb discover <ref> -n 1000            # stop after 1000 nodes total (default 500)
+```
+
+`--depth` is how many hops to follow; `0` emits only the seeds. `--fanout` caps
+how many neighbors each edge contributes per node, so one hop never pages a whole
+upload history unless you raise it. `-n/--limit` is the total node budget, the
+hard stop on a deep or wide walk; even `--fanout 0` stays bounded by it, so a
+walk always terminates.
+
+## Reading the output
+
+`ytb discover` streams one row per node, tagged with how it was reached:
+
+```text
+depth  via      kind     id           who        summary                url
+0               video    dQw4w9WgXcQ  RickAstley  Never Gonna Give You U https://youtu.be/dQw4w9WgXcQ
+1      channel  channel  UCuAXFkgsw1L  RickAstley  Rick Astley           https://youtube.com/channel/UCuAXFkgsw1L
+1      related  video    J---aiyznGQ  ...         Keyboard Cat           https://youtu.be/J---aiyznGQ
+```
+
+Because it streams through the same formatter as every read, it shapes and pipes
+the same way. The JSON forms carry the full typed node, with the nested video,
+channel, playlist, comment, or post:
+
+```sh
+ytb discover <ref> -o json | jq -r '.via + " -> " + (.video.id // .channel.id)'
+ytb discover @MrBeast --follow feed -o jsonl | jq -r '.video.id' | sort -u
+ytb discover <ref> --fields depth,via,who,url -o table
+```
+
+## Persisting a walk
+
+Add `--store` to write every node and edge into the local store as the walk
+streams, so you keep the graph as well as see it:
+
+```sh
+ytb discover @MrBeast --follow all --depth 2 --store
+ytb db query "select kind, count(*) from edges group by kind order by 2 desc"
+```
+
+Each node lands in its own typed table (`videos`, `channels`, `playlists`,
+`comments`, `community_posts`) and each traversed link lands in an `edges` table
+(`src`, `dst`, `kind`), so the graph is queryable afterwards. Re-walking is
+idempotent.
+
+When you want a dataset built from an explicit worklist rather than a live walk,
+reach for [the crawl queue](/guides/the-store/), which drains a queue of URLs you
+load yourself; `discover` is the complement that finds the worklist by walking.
+See [the local store](/guides/the-store/) for inspecting and exporting what you
+collect.
diff --git a/docs/content/guides/the-store.md b/docs/content/guides/the-store.md
index 18fb169..e8dc1e0 100644
--- a/docs/content/guides/the-store.md
+++ b/docs/content/guides/the-store.md
@@ -19,6 +19,22 @@ The store is pure Go (modernc.org/sqlite), so nothing links libsqlite and the
 binary stays static. Without `--db`, no database is ever created: the flag is the
 only thing that turns persistence on.
 
+## Persist a walk
+
+`ytb discover --store` tees a breadth-first walk into the store as a side effect,
+so a live walk doubles as a crawl. Every node lands in its typed table and every
+traversed link lands in an `edges` table, so the graph is queryable afterwards:
+
+```sh
+ytb discover @MrBeast --follow all --depth 2 --store
+ytb db query "select kind, count(*) from edges group by kind order by 2 desc"
+```
+
+See [graph discovery](/guides/graph-discovery/) for the full edge and preset
+vocabulary. The difference from the crawl queue below is direction: `discover`
+finds the worklist by walking the graph, while `seed`/`crawl` drain a worklist
+you load yourself.
+
 ## Building a crawl queue
 
 For larger collection runs, `seed`, `crawl`, `queue`, and `jobs` turn the store
diff --git a/docs/content/reference/cli.md b/docs/content/reference/cli.md
index 3a75e09..a5700e4 100644
--- a/docs/content/reference/cli.md
+++ b/docs/content/reference/cli.md
@@ -51,6 +51,7 @@ Persistent flags accepted by every command.
 | `community` | Community / posts tab |
 | `hashtag` | A hashtag feed |
 | `related` | The related-videos graph |
+| `discover` | Breadth-first walk of the graph linked from a video, channel, or playlist |
 | `suggest` | Search autocomplete suggestions |
 | `transcript` | Captions as text |
 | `formats` | Streaming formats (metadata only) |
@@ -155,6 +156,19 @@ Persistent flags accepted by every command.
 
 `ytb related <video-id|url> [--flags]`. The related-videos graph. No notable flags beyond the globals.
 
+## discover
+
+`ytb discover <seed>... [--flags]` (aliases `walk`, `graph`). Walk the graph of linked objects breadth-first from one or more seeds (a video, channel, or playlist reference), streaming one row per node reached. See [graph discovery](/guides/graph-discovery/).
+
+| Flag | Meaning |
+| --- | --- |
+| `--follow` | Edges to follow: a preset (`content`, `feed`, `comments`, `all`) or a comma-separated edge list (`channel`, `related`, `comments`, `uploads`, `playlists`, `community`, `items`, `owner`, `commenter`). Default `content` |
+| `--depth` | Hops to follow from each seed (default `1`; `0` = seeds only) |
+| `--fanout` | Max neighbors to follow per edge (default `25`; `0` = unlimited) |
+| `--store` | Persist every node into its typed table and each traversed edge into the `edges` table |
+
+The comment edges (`comments`, `commenter`) are served only when YouTube is not applying its per-IP Restricted Mode to this network; when it is, they are noted on stderr and skipped and the rest of the walk continues. `-n/--limit` is the total node budget (default `500`).
+
 ## suggest
 
 `ytb suggest <query> [--flags]`. Search autocomplete suggestions. No notable flags beyond the globals.
diff --git a/youtube/discover.go b/youtube/discover.go
new file mode 100644
index 0000000..3c14273
--- /dev/null
+++ b/youtube/discover.go
@@ -0,0 +1,684 @@
+package youtube
+
+import (
+	"context"
+	"fmt"
+	"strings"
+)
+
+// discover.go is the breadth-first graph walker. Every read in this package
+// answers one question about one object (a video's metadata, a channel's
+// uploads, a playlist's items); the walker chains them. From a seed video,
+// channel, or playlist it follows the object's links (a video's uploader and
+// related videos, a channel's uploads, playlists, and community posts, a
+// playlist's items and owner, a comment's author) and from each neighbor it
+// follows theirs, hop by hop, until it runs out of depth or budget.
+//
+// It is engine-agnostic on purpose: Walk talks to the small grapher interface,
+// not to *Client directly, so the traversal is hermetically testable with a
+// fake graph and *Client is just the production grapher.
+
+// NodeKind is the type of a node the walk visits.
+type NodeKind string
+
+const (
+	KindVideo    NodeKind = "video"
+	KindChannel  NodeKind = "channel"
+	KindPlaylist NodeKind = "playlist"
+	KindComment  NodeKind = "comment"
+	KindPost     NodeKind = "post"
+)
+
+// Edge names a link the walk can follow. The string is the public vocabulary:
+// it is what the user types in --follow, what lands in the store's edges.kind
+// column, and what a discovered node reports as the edge it arrived by.
+type Edge string
+
+const (
+	EdgeChannel   Edge = "channel"   // video -> the channel that uploaded it
+	EdgeRelated   Edge = "related"   // video -> a related/recommended video
+	EdgeComments  Edge = "comments"  // video -> a comment on it
+	EdgeUploads   Edge = "uploads"   // channel -> a video it uploaded
+	EdgePlaylists Edge = "playlists" // channel -> a playlist it owns
+	EdgeCommunity Edge = "community" // channel -> a community post
+	EdgeItems     Edge = "items"     // playlist -> a video in it
+	EdgeOwner     Edge = "owner"     // playlist -> the channel that owns it
+	EdgeCommenter Edge = "commenter" // comment -> the channel that wrote it
+)
+
+// allEdges is the full vocabulary, in a stable display order.
+var allEdges = []Edge{
+	EdgeChannel, EdgeRelated, EdgeComments,
+	EdgeUploads, EdgePlaylists, EdgeCommunity,
+	EdgeItems, EdgeOwner, EdgeCommenter,
+}
+
+// knownEdges indexes allEdges for validation.
+var knownEdges = func() map[Edge]bool {
+	m := make(map[Edge]bool, len(allEdges))
+	for _, e := range allEdges {
+		m[e] = true
+	}
+	return m
+}()
+
+// Target reports the kind of node an edge leads to.
+func (e Edge) Target() NodeKind {
+	switch e {
+	case EdgeChannel, EdgeOwner, EdgeCommenter:
+		return KindChannel
+	case EdgePlaylists:
+		return KindPlaylist
+	case EdgeComments:
+		return KindComment
+	case EdgeCommunity:
+		return KindPost
+	default:
+		return KindVideo
+	}
+}
+
+// gated reports whether an edge is the one YouTube hides behind its per-IP
+// anti-bot wall. Comments (and a commenter reached through them) are served only
+// when the request is not in Restricted Mode; every other edge is on the open
+// surface. The walker does not pre-drop a gated edge: it tries it and turns a
+// refusal into a note, so a walk always produces what it can.
+func (e Edge) gated() bool {
+	switch e {
+	case EdgeComments, EdgeCommenter:
+		return true
+	default:
+		return false
+	}
+}
+
+// EdgeSet is a chosen set of edges to follow.
+type EdgeSet map[Edge]bool
+
+// Has reports whether the set contains e (a nil set contains nothing).
+func (s EdgeSet) Has(e Edge) bool { return s[e] }
+
+// List returns the set's edges in stable display order.
+func (s EdgeSet) List() []Edge {
+	var out []Edge
+	for _, e := range allEdges {
+		if s[e] {
+			out = append(out, e)
+		}
+	}
+	return out
+}
+
+// String renders the set as a comma-separated, ordered list.
+func (s EdgeSet) String() string { return joinEdges(s.List()) }
+
+// edgePresets are the named bundles --follow accepts in place of listing edges.
+// They are the everyday intents: read the obvious neighbors of whatever you gave
+// (content), map what a channel publishes (feed), study who engaged (comments),
+// or take it all. Preset names are kept disjoint from edge names so no token is
+// ambiguous: to chase recommendations alone, name the edge (`--follow related`).
+//
+// content is the default and deliberately spans every seed kind: a video yields
+// its uploader and related videos, a channel yields its uploads, a playlist
+// yields its items and owner, so `ytb discover <anything>` does the obvious
+// thing with no flags. It stays entirely on the open surface (no comments).
+var edgePresets = map[string]EdgeSet{
+	"content":  newEdgeSet(EdgeChannel, EdgeRelated, EdgeUploads, EdgeItems, EdgeOwner),
+	"feed":     newEdgeSet(EdgeUploads, EdgePlaylists, EdgeCommunity),
+	"comments": newEdgeSet(EdgeComments, EdgeCommenter),
+	"all":      newEdgeSet(allEdges...),
+}
+
+// presetNames lists the presets in a friendly order for help text.
+var presetNames = []string{"content", "feed", "comments", "all"}
+
+func newEdgeSet(edges ...Edge) EdgeSet {
+	s := make(EdgeSet, len(edges))
+	for _, e := range edges {
+		s[e] = true
+	}
+	return s
+}
+
+// DefaultEdges is what a walk follows when --follow is unset: the obvious
+// neighbors of the seed, all on the open surface, so `ytb discover <video>`
+// works with no tokens and no anti-bot exposure.
+func DefaultEdges() EdgeSet { return edgePresets["content"].clone() }
+
+func (s EdgeSet) clone() EdgeSet {
+	out := make(EdgeSet, len(s))
+	for e := range s {
+		out[e] = true
+	}
+	return out
+}
+
+// EdgeHelp is the one-line catalogue of presets and edges for flag help and
+// usage errors, so the names a user can type live in exactly one place.
+func EdgeHelp() string {
+	return "presets: " + strings.Join(presetNames, ",") + "; edges: " + joinEdges(allEdges)
+}
+
+// ParseEdges turns a --follow spec into an EdgeSet. The spec is a comma list of
+// preset names and/or edge names ("content", "channel,related", "uploads,items").
+// An empty spec yields DefaultEdges. An unknown token is a usage error naming the
+// catalogue, so a typo points the user at the real vocabulary.
+func ParseEdges(spec string) (EdgeSet, error) {
+	spec = strings.TrimSpace(spec)
+	if spec == "" {
+		return DefaultEdges(), nil
+	}
+	set := EdgeSet{}
+	for _, part := range strings.Split(spec, ",") {
+		p := strings.ToLower(strings.TrimSpace(part))
+		if p == "" {
+			continue
+		}
+		if preset, ok := edgePresets[p]; ok {
+			for e := range preset {
+				set[e] = true
+			}
+			continue
+		}
+		e := Edge(p)
+		if !knownEdges[e] {
+			return nil, fmt.Errorf("unknown edge or preset %q (%s)", p, EdgeHelp())
+		}
+		set[e] = true
+	}
+	if len(set) == 0 {
+		return nil, fmt.Errorf("no edges selected (%s)", EdgeHelp())
+	}
+	return set, nil
+}
+
+func joinEdges(edges []Edge) string {
+	ss := make([]string, len(edges))
+	for i, e := range edges {
+		ss[i] = string(e)
+	}
+	return strings.Join(ss, ",")
+}
+
+// Node is one object the walk reached, tagged with how it got there: the BFS
+// depth, the edge it arrived by, and the endpoint of the node it came from.
+// Exactly one of the entity pointers is set, matching Kind. Node is what Walk
+// hands to its callback and what the CLI renders.
+type Node struct {
+	Kind     NodeKind       `json:"kind"`
+	Depth    int            `json:"depth"`
+	Via      Edge           `json:"via,omitempty"`
+	Parent   string         `json:"parent,omitempty"`
+	Video    *Video         `json:"video,omitempty"`
+	Channel  *Channel       `json:"channel,omitempty"`
+	Playlist *Playlist      `json:"playlist,omitempty"`
+	Comment  *Comment       `json:"comment,omitempty"`
+	Post     *CommunityPost `json:"post,omitempty"`
+}
+
+// Endpoint is the node's stable identifier inside a walk: a video/channel/
+// playlist/comment/post id. It is what edges record as src/dst.
+func (n *Node) Endpoint() string {
+	switch n.Kind {
+	case KindVideo:
+		if n.Video != nil {
+			return n.Video.VideoID
+		}
+	case KindChannel:
+		if n.Channel != nil {
+			return n.Channel.ChannelID
+		}
+	case KindPlaylist:
+		if n.Playlist != nil {
+			return n.Playlist.PlaylistID
+		}
+	case KindComment:
+		if n.Comment != nil {
+			return n.Comment.ID
+		}
+	case KindPost:
+		if n.Post != nil {
+			return n.Post.PostID
+		}
+	}
+	return ""
+}
+
+// key is the dedup key for a hydrated node. Channels key on a lowercased id so
+// the same channel reached as an uploader and as a commenter collapses to one
+// node; the rest key on their id with a per-kind prefix so a video and a
+// playlist that happened to share an id never collide.
+func (n *Node) key() string {
+	switch n.Kind {
+	case KindVideo:
+		return "v:" + nodeID(n.Video != nil, func() string { return n.Video.VideoID })
+	case KindChannel:
+		return "c:" + strings.ToLower(nodeID(n.Channel != nil, func() string { return n.Channel.ChannelID }))
+	case KindPlaylist:
+		return "p:" + nodeID(n.Playlist != nil, func() string { return n.Playlist.PlaylistID })
+	case KindComment:
+		return "m:" + nodeID(n.Comment != nil, func() string { return n.Comment.ID })
+	case KindPost:
+		return "o:" + nodeID(n.Post != nil, func() string { return n.Post.PostID })
+	}
+	return ""
+}
+
+func nodeID(ok bool, get func() string) string {
+	if ok {
+		return get()
+	}
+	return ""
+}
+
+// Seed is a parsed starting point for a walk.
+type Seed struct {
+	Kind NodeKind
+	Ref  string // canonical id / @handle to fetch
+}
+
+// ParseSeed classifies a raw reference into a Seed, reusing the domain's own
+// Classify so a seed is read exactly like any other youtube reference: a watch
+// link or bare video id is a video, a playlist link or PL-style id is a
+// playlist, a @handle or UC id or channel URL is a channel.
+func ParseSeed(ref string) (Seed, error) {
+	t, id, err := Domain{}.Classify(ref)
+	if err != nil {
+		return Seed{}, err
+	}
+	switch t {
+	case "video":
+		return Seed{Kind: KindVideo, Ref: id}, nil
+	case "channel":
+		return Seed{Kind: KindChannel, Ref: id}, nil
+	case "playlist":
+		return Seed{Kind: KindPlaylist, Ref: id}, nil
+	default:
+		return Seed{}, fmt.Errorf("cannot start a walk from a %s reference: %q", t, ref)
+	}
+}
+
+// WalkOptions tunes a traversal.
+type WalkOptions struct {
+	Depth  int     // hops to follow from each seed (0 = seeds only)
+	Max    int     // stop after emitting this many nodes (0 = unlimited)
+	Fanout int     // per-edge neighbor cap (0 = unlimited)
+	Edges  EdgeSet // edges to follow (nil = DefaultEdges)
+
+	// OnEdge, if set, is called for every edge the walk traverses, before the
+	// neighbor is visited, with the two endpoints and the edge. The store sink
+	// uses it to record the graph; it fires even for an already-visited neighbor
+	// so the edge list stays complete.
+	OnEdge func(src, dst string, edge Edge)
+
+	// Note, if set, surfaces a one-line advisory (a comment edge refused by the
+	// anti-bot wall, a neighbor that could not be fetched). It never carries a
+	// fatal error.
+	Note func(string)
+}
+
+// grapher is the slice of the client the walker needs. *Client satisfies it; a
+// test supplies a fake. Every method matches *Client exactly.
+type grapher interface {
+	FetchVideo(ctx context.Context, ref string, opt VideoOptions) (*VideoResult, error)
+	FetchChannel(ctx context.Context, ref string) (*Channel, error)
+	FetchPlaylist(ctx context.Context, ref string) (*Playlist, error)
+	StreamChannelTab(ctx context.Context, ref, tab string, opt PageOptions, emit func(Video) error) error
+	StreamChannelPlaylists(ctx context.Context, ref string, opt PageOptions, emit func(Playlist) error) error
+	StreamPlaylistItems(ctx context.Context, ref string, opt PageOptions, emit func(PlaylistVideo, Video) error) error
+	StreamComments(ctx context.Context, ref string, opt CommentOptions, emit func(Comment) error) error
+	StreamCommunity(ctx context.Context, ref string, opt PageOptions, emit func(CommunityPost) error) error
+}
+
+var _ grapher = (*Client)(nil)
+
+// Walker performs the breadth-first traversal over a grapher.
+type Walker struct{ g grapher }
+
+// NewWalker builds a Walker over any grapher (the client in production, a fake
+// in tests).
+func NewWalker(g grapher) *Walker { return &Walker{g: g} }
+
+// Walk runs the client's traversal. It is the production entry point: it builds
+// a Walker over the client and walks the seeds. See Walker.Walk.
+func (c *Client) Walk(ctx context.Context, seeds []Seed, opts WalkOptions, emit func(*Node) error) error {
+	return NewWalker(c).Walk(ctx, seeds, opts, emit)
+}
+
+// frontier is a queued, possibly-not-yet-hydrated node. Stream reads (a
+// channel's uploads, a playlist's items) hand back fully built entities, so
+// those rides carry the entity already and skip the per-pop fetch; a reference
+// reached as an id (an uploader channel, a related video) carries only the id
+// and is fetched when it is popped.
+type frontier struct {
+	kind     NodeKind
+	ref      string
+	depth    int
+	via      Edge
+	parent   string
+	video    *Video
+	channel  *Channel
+	playlist *Playlist
+	comment  *Comment
+	post     *CommunityPost
+}
+
+func (f frontier) key() string {
+	switch f.kind {
+	case KindVideo:
+		id := f.ref
+		if f.video != nil {
+			id = f.video.VideoID
+		}
+		return "v:" + id
+	case KindChannel:
+		id := f.ref
+		if f.channel != nil {
+			id = f.channel.ChannelID
+		}
+		return "c:" + strings.ToLower(id)
+	case KindPlaylist:
+		id := f.ref
+		if f.playlist != nil {
+			id = f.playlist.PlaylistID
+		}
+		return "p:" + id
+	case KindComment:
+		if f.comment != nil {
+			return "m:" + f.comment.ID
+		}
+		return "m:" + f.ref
+	case KindPost:
+		if f.post != nil {
+			return "o:" + f.post.PostID
+		}
+		return "o:" + f.ref
+	}
+	return ""
+}
+
+// Walk visits the seeds and their links in breadth-first order, calling emit for
+// each node as it is reached. It returns when the queue drains, the node budget
+// (opts.Max) is hit, emit returns an error, or a seed cannot be fetched. A
+// gated edge (comments, and the channel reached through one) is attempted, not
+// pre-dropped: when YouTube refuses it the failure becomes a Note and the walk
+// keeps going on the rest of the graph.
+func (w *Walker) Walk(ctx context.Context, seeds []Seed, opts WalkOptions, emit func(*Node) error) error {
+	edges := opts.Edges
+	if edges == nil {
+		edges = DefaultEdges()
+	}
+
+	visited := map[string]bool{}
+	queue := make([]frontier, 0, len(seeds))
+	for _, s := range seeds {
+		queue = append(queue, frontier{kind: s.Kind, ref: s.Ref})
+	}
+
+	emitted := 0
+	for len(queue) > 0 {
+		if err := ctx.Err(); err != nil {
+			return err
+		}
+		f := queue[0]
+		queue = queue[1:]
+		if visited[f.key()] {
+			continue
+		}
+		visited[f.key()] = true
+
+		node, vres, err := w.hydrate(ctx, f, f.depth < opts.Depth, edges)
+		if err != nil {
+			if f.depth == 0 {
+				return err // a seed we cannot fetch is fatal, like a single read
+			}
+			if opts.Note != nil {
+				opts.Note(fmt.Sprintf("skip %s %s: %v", f.kind, f.ref, err))
+			}
+			continue
+		}
+		if node == nil {
+			continue
+		}
+		visited[node.key()] = true // collapse handle/id aliases of the same node
+
+		if err := emit(node); err != nil {
+			return err
+		}
+		emitted++
+		if opts.Max > 0 && emitted >= opts.Max {
+			return nil
+		}
+		if f.depth >= opts.Depth {
+			continue
+		}
+		for _, nb := range w.neighbors(ctx, node, vres, edges, opts) {
+			if !visited[nb.key()] {
+				queue = append(queue, nb)
+			}
+		}
+	}
+	return nil
+}
+
+// hydrate turns a frontier item into a Node, fetching the object when the item
+// did not already carry it. It returns the VideoResult alongside a video node so
+// neighbors can read the related list and comment availability without a second
+// fetch. expand says whether this node will be expanded (its depth is below the
+// limit); a video carried in from a stream is only refetched (for its related
+// list) when it will actually be expanded along the related edge.
+func (w *Walker) hydrate(ctx context.Context, f frontier, expand bool, edges EdgeSet) (*Node, *VideoResult, error) {
+	n := &Node{Kind: f.kind, Depth: f.depth, Via: f.via, Parent: f.parent}
+	switch f.kind {
+	case KindVideo:
+		wantRelated := expand && edges.Has(EdgeRelated)
+		if f.video == nil {
+			res, err := w.g.FetchVideo(ctx, f.ref, VideoOptions{Next: wantRelated})
+			if err != nil {
+				return nil, nil, err
+			}
+			if res == nil {
+				return nil, nil, fmt.Errorf("video not found: %s", f.ref)
+			}
+			n.Video = &res.Video
+			return n, res, nil
+		}
+		n.Video = f.video
+		if wantRelated {
+			// The streamed video has no related list; fetch it once so the related
+			// edge has something to expand. A failure here is not fatal: keep the
+			// node we already have and expand whatever else applies.
+			if res, err := w.g.FetchVideo(ctx, f.video.VideoID, VideoOptions{Next: true}); err == nil && res != nil {
+				n.Video = &res.Video
+				return n, res, nil
+			}
+		}
+		return n, nil, nil
+	case KindChannel:
+		if f.channel == nil {
+			ch, err := w.g.FetchChannel(ctx, f.ref)
+			if err != nil {
+				return nil, nil, err
+			}
+			if ch == nil {
+				return nil, nil, fmt.Errorf("channel not found: %s", f.ref)
+			}
+			n.Channel = ch
+		} else {
+			n.Channel = f.channel
+		}
+		return n, nil, nil
+	case KindPlaylist:
+		if f.playlist == nil {
+			pl, err := w.g.FetchPlaylist(ctx, f.ref)
+			if err != nil {
+				return nil, nil, err
+			}
+			if pl == nil {
+				return nil, nil, fmt.Errorf("playlist not found: %s", f.ref)
+			}
+			n.Playlist = pl
+		} else {
+			n.Playlist = f.playlist
+		}
+		return n, nil, nil
+	case KindComment:
+		n.Comment = f.comment
+		return n, nil, nil
+	case KindPost:
+		n.Post = f.post
+		return n, nil, nil
+	}
+	return nil, nil, nil
+}
+
+// neighbors expands a node into its outbound frontier under the chosen edges,
+// recording each edge via opts.OnEdge. The per-edge fanout caps every stream
+// read and the inline related loop, so one hop can never page a channel's whole
+// upload history unless the caller asked for it (Fanout 0).
+func (w *Walker) neighbors(ctx context.Context, n *Node, vres *VideoResult, edges EdgeSet, opts WalkOptions) []frontier {
+	var out []frontier
+	src := n.Endpoint()
+	streamMax := opts.Fanout
+	if streamMax <= 0 {
+		streamMax = opts.Max // bound an uncapped stream by the node budget
+	}
+
+	addEdge := func(dst string, via Edge) {
+		if opts.OnEdge != nil {
+			opts.OnEdge(src, dst, via)
+		}
+	}
+
+	switch n.Kind {
+	case KindVideo:
+		v := n.Video
+		if edges.Has(EdgeChannel) && v.ChannelID != "" {
+			addEdge(v.ChannelID, EdgeChannel)
+			out = append(out, frontier{kind: KindChannel, ref: v.ChannelID, depth: n.Depth + 1, via: EdgeChannel, parent: src})
+		}
+		if edges.Has(EdgeRelated) && vres != nil {
+			i := 0
+			for _, r := range vres.Related {
+				if r.RelatedVideoID == "" {
+					continue
+				}
+				if streamMax > 0 && i >= streamMax {
+					break
+				}
+				addEdge(r.RelatedVideoID, EdgeRelated)
+				out = append(out, frontier{kind: KindVideo, ref: r.RelatedVideoID, depth: n.Depth + 1, via: EdgeRelated, parent: src})
+				i++
+			}
+		}
+		if edges.Has(EdgeComments) {
+			i := 0
+			err := w.g.StreamComments(ctx, v.VideoID, CommentOptions{Max: streamMax}, func(cm Comment) error {
+				c := cm
+				addEdge(c.ID, EdgeComments)
+				out = append(out, frontier{kind: KindComment, depth: n.Depth + 1, via: EdgeComments, parent: src, comment: &c})
+				i++
+				if streamMax > 0 && i >= streamMax {
+					return ErrStop
+				}
+				return nil
+			})
+			w.note(opts, err)
+		}
+	case KindChannel:
+		ch := n.Channel
+		ref := ch.ChannelID
+		if edges.Has(EdgeUploads) {
+			i := 0
+			err := w.g.StreamChannelTab(ctx, ref, "videos", PageOptions{Max: streamMax}, func(v Video) error {
+				vv := v
+				addEdge(vv.VideoID, EdgeUploads)
+				out = append(out, frontier{kind: KindVideo, depth: n.Depth + 1, via: EdgeUploads, parent: src, video: &vv})
+				i++
+				if streamMax > 0 && i >= streamMax {
+					return ErrStop
+				}
+				return nil
+			})
+			w.note(opts, err)
+		}
+		if edges.Has(EdgePlaylists) {
+			i := 0
+			err := w.g.StreamChannelPlaylists(ctx, ref, PageOptions{Max: streamMax}, func(p Playlist) error {
+				pp := p
+				addEdge(pp.PlaylistID, EdgePlaylists)
+				out = append(out, frontier{kind: KindPlaylist, depth: n.Depth + 1, via: EdgePlaylists, parent: src, playlist: &pp})
+				i++
+				if streamMax > 0 && i >= streamMax {
+					return ErrStop
+				}
+				return nil
+			})
+			w.note(opts, err)
+		}
+		if edges.Has(EdgeCommunity) {
+			i := 0
+			err := w.g.StreamCommunity(ctx, ref, PageOptions{Max: streamMax}, func(p CommunityPost) error {
+				pp := p
+				addEdge(pp.PostID, EdgeCommunity)
+				out = append(out, frontier{kind: KindPost, depth: n.Depth + 1, via: EdgeCommunity, parent: src, post: &pp})
+				i++
+				if streamMax > 0 && i >= streamMax {
+					return ErrStop
+				}
+				return nil
+			})
+			w.note(opts, err)
+		}
+	case KindPlaylist:
+		pl := n.Playlist
+		ref := pl.PlaylistID
+		if edges.Has(EdgeItems) {
+			i := 0
+			err := w.g.StreamPlaylistItems(ctx, ref, PageOptions{Max: streamMax}, func(pv PlaylistVideo, v Video) error {
+				vv := v
+				if vv.VideoID == "" {
+					vv.VideoID = pv.VideoID
+				}
+				if vv.URL == "" {
+					vv.URL = "https://www.youtube.com/watch?v=" + vv.VideoID
+				}
+				addEdge(vv.VideoID, EdgeItems)
+				out = append(out, frontier{kind: KindVideo, depth: n.Depth + 1, via: EdgeItems, parent: src, video: &vv})
+				i++
+				if streamMax > 0 && i >= streamMax {
+					return ErrStop
+				}
+				return nil
+			})
+			w.note(opts, err)
+		}
+		if edges.Has(EdgeOwner) && pl.ChannelID != "" {
+			addEdge(pl.ChannelID, EdgeOwner)
+			out = append(out, frontier{kind: KindChannel, ref: pl.ChannelID, depth: n.Depth + 1, via: EdgeOwner, parent: src})
+		}
+	case KindComment:
+		cm := n.Comment
+		if edges.Has(EdgeCommenter) && cm.AuthorChannelID != "" {
+			addEdge(cm.AuthorChannelID, EdgeCommenter)
+			out = append(out, frontier{kind: KindChannel, ref: cm.AuthorChannelID, depth: n.Depth + 1, via: EdgeCommenter, parent: src})
+		}
+	case KindPost:
+		// A community post is a leaf in this graph: its author is the channel we
+		// arrived from, so it has no outbound edge of its own.
+	}
+	return out
+}
+
+// note surfaces a non-fatal stream failure (a comment edge refused by the
+// anti-bot wall, a channel with no community tab, a transient rate limit) as an
+// advisory and keeps the walk going on the rest of the graph. The clean stop
+// sentinel ErrStop is not a failure.
+func (w *Walker) note(opts WalkOptions, err error) {
+	if err == nil || err == ErrStop {
+		return
+	}
+	if opts.Note != nil {
+		opts.Note(err.Error())
+	}
+}
diff --git a/youtube/discover_test.go b/youtube/discover_test.go
new file mode 100644
index 0000000..fd84505
--- /dev/null
+++ b/youtube/discover_test.go
@@ -0,0 +1,407 @@
+package youtube
+
+import (
+	"context"
+	"errors"
+	"sort"
+	"testing"
+)
+
+// fakeGraph is an in-memory grapher: a tiny YouTube whose edges are fixed maps,
+// so the walker is tested without a network. It implements every grapher method
+// the walker calls.
+type fakeGraph struct {
+	videos    map[string]*VideoResult    // video id -> result (with Related)
+	channels  map[string]*Channel        // ref (id/handle) -> channel
+	playlists map[string]*Playlist       // playlist id -> playlist
+	uploads   map[string][]Video         // channel id -> uploaded videos
+	plLists   map[string][]Playlist      // channel id -> playlists
+	community map[string][]CommunityPost // channel id -> posts
+	items     map[string][]Video         // playlist id -> videos
+	comments  map[string][]Comment       // video id -> comments
+	commErr   map[string]error           // video id -> error from StreamComments
+}
+
+func (g *fakeGraph) FetchVideo(_ context.Context, ref string, _ VideoOptions) (*VideoResult, error) {
+	if v, ok := g.videos[ref]; ok {
+		return v, nil
+	}
+	return nil, errors.New("video not found: " + ref)
+}
+
+func (g *fakeGraph) FetchChannel(_ context.Context, ref string) (*Channel, error) {
+	if c, ok := g.channels[ref]; ok {
+		return c, nil
+	}
+	return nil, errors.New("channel not found: " + ref)
+}
+
+func (g *fakeGraph) FetchPlaylist(_ context.Context, ref string) (*Playlist, error) {
+	if p, ok := g.playlists[ref]; ok {
+		return p, nil
+	}
+	return nil, errors.New("playlist not found: " + ref)
+}
+
+func streamSlice[T any](items []T, max int, emit func(T) error) error {
+	n := 0
+	for _, it := range items {
+		if max > 0 && n >= max {
+			return nil
+		}
+		if err := emit(it); err != nil {
+			if err == ErrStop {
+				return nil
+			}
+			return err
+		}
+		n++
+	}
+	return nil
+}
+
+func (g *fakeGraph) StreamChannelTab(_ context.Context, ref, _ string, opt PageOptions, emit func(Video) error) error {
+	return streamSlice(g.uploads[ref], opt.Max, emit)
+}
+
+func (g *fakeGraph) StreamChannelPlaylists(_ context.Context, ref string, opt PageOptions, emit func(Playlist) error) error {
+	return streamSlice(g.plLists[ref], opt.Max, emit)
+}
+
+func (g *fakeGraph) StreamPlaylistItems(_ context.Context, ref string, opt PageOptions, emit func(PlaylistVideo, Video) error) error {
+	vids := g.items[ref]
+	n := 0
+	for i, v := range vids {
+		if opt.Max > 0 && n >= opt.Max {
+			return nil
+		}
+		if err := emit(PlaylistVideo{PlaylistID: ref, VideoID: v.VideoID, Position: i}, v); err != nil {
+			if err == ErrStop {
+				return nil
+			}
+			return err
+		}
+		n++
+	}
+	return nil
+}
+
+func (g *fakeGraph) StreamComments(_ context.Context, ref string, opt CommentOptions, emit func(Comment) error) error {
+	if err, ok := g.commErr[ref]; ok {
+		return err
+	}
+	return streamSlice(g.comments[ref], opt.Max, emit)
+}
+
+func (g *fakeGraph) StreamCommunity(_ context.Context, ref string, opt PageOptions, emit func(CommunityPost) error) error {
+	return streamSlice(g.community[ref], opt.Max, emit)
+}
+
+// newFakeGraph builds a small connected corpus:
+//
+//	video vid1 (channel UCa, related [vid2])
+//	channel UCa (uploads [vid1, vid3], playlists [PLaaaaaaaaaa], community [post1])
+//	playlist PLaaaaaaaaaa (items [vid1, vid2], owner UCa)
+//	comments on vid1: cmt1 by UCb
+//	channel UCb (the commenter)
+func newFakeGraph() *fakeGraph {
+	vid1 := &VideoResult{
+		Video:   Video{VideoID: "vid1", Title: "First", ChannelID: "UCa", ChannelName: "Alpha"},
+		Related: []RelatedVideo{{VideoID: "vid1", RelatedVideoID: "vid2", Position: 0}},
+	}
+	vid2 := &VideoResult{Video: Video{VideoID: "vid2", Title: "Second", ChannelID: "UCa", ChannelName: "Alpha"}}
+	vid3 := &VideoResult{Video: Video{VideoID: "vid3", Title: "Third", ChannelID: "UCa", ChannelName: "Alpha"}}
+	return &fakeGraph{
+		videos: map[string]*VideoResult{"vid1": vid1, "vid2": vid2, "vid3": vid3},
+		channels: map[string]*Channel{
+			"UCa": {ChannelID: "UCa", Handle: "@alpha", Title: "Alpha"},
+			"UCb": {ChannelID: "UCb", Handle: "@beta", Title: "Beta"},
+		},
+		playlists: map[string]*Playlist{
+			"PLaaaaaaaaaa": {PlaylistID: "PLaaaaaaaaaa", Title: "Mix", ChannelID: "UCa", ChannelName: "Alpha"},
+		},
+		uploads: map[string][]Video{
+			"UCa": {{VideoID: "vid1", Title: "First", ChannelID: "UCa"}, {VideoID: "vid3", Title: "Third", ChannelID: "UCa"}},
+		},
+		plLists: map[string][]Playlist{
+			"UCa": {{PlaylistID: "PLaaaaaaaaaa", Title: "Mix", ChannelID: "UCa"}},
+		},
+		community: map[string][]CommunityPost{
+			"UCa": {{PostID: "post1", ChannelID: "UCa", AuthorName: "Alpha", ContentText: "hello"}},
+		},
+		items: map[string][]Video{
+			"PLaaaaaaaaaa": {{VideoID: "vid1"}, {VideoID: "vid2"}},
+		},
+		comments: map[string][]Comment{
+			"vid1": {{ID: "cmt1", VideoID: "vid1", AuthorChannelID: "UCb", AuthorDisplayName: "Beta", TextDisplay: "nice"}},
+		},
+	}
+}
+
+// collect walks and returns the emitted nodes plus the recorded edges.
+func collect(t *testing.T, g grapher, seeds []Seed, opts WalkOptions) ([]*Node, []string) {
+	t.Helper()
+	var edgesLog []string
+	opts.OnEdge = func(src, dst string, e Edge) {
+		edgesLog = append(edgesLog, string(e)+":"+src+"->"+dst)
+	}
+	var nodes []*Node
+	err := NewWalker(g).Walk(context.Background(), seeds, opts, func(n *Node) error {
+		nodes = append(nodes, n)
+		return nil
+	})
+	if err != nil {
+		t.Fatalf("Walk: %v", err)
+	}
+	return nodes, edgesLog
+}
+
+func TestParseEdges(t *testing.T) {
+	t.Run("empty is the content default", func(t *testing.T) {
+		set, err := ParseEdges("")
+		if err != nil {
+			t.Fatal(err)
+		}
+		want := edgePresets["content"]
+		if set.String() != want.String() {
+			t.Fatalf("got %q, want %q", set, want)
+		}
+	})
+	t.Run("preset expands", func(t *testing.T) {
+		set, err := ParseEdges("comments")
+		if err != nil {
+			t.Fatal(err)
+		}
+		if !set.Has(EdgeComments) || !set.Has(EdgeCommenter) {
+			t.Fatalf("comments preset missing edges: %q", set)
+		}
+	})
+	t.Run("list of edges and presets", func(t *testing.T) {
+		set, err := ParseEdges("related,channel,uploads")
+		if err != nil {
+			t.Fatal(err)
+		}
+		for _, e := range []Edge{EdgeRelated, EdgeChannel, EdgeUploads} {
+			if !set.Has(e) {
+				t.Fatalf("missing %s in %q", e, set)
+			}
+		}
+	})
+	t.Run("unknown token errors", func(t *testing.T) {
+		if _, err := ParseEdges("nope"); err == nil {
+			t.Fatal("expected error for unknown token")
+		}
+	})
+}
+
+func TestEdgeTargetAndGated(t *testing.T) {
+	cases := map[Edge]NodeKind{
+		EdgeChannel: KindChannel, EdgeOwner: KindChannel, EdgeCommenter: KindChannel,
+		EdgePlaylists: KindPlaylist, EdgeComments: KindComment, EdgeCommunity: KindPost,
+		EdgeRelated: KindVideo, EdgeUploads: KindVideo, EdgeItems: KindVideo,
+	}
+	for e, want := range cases {
+		if got := e.Target(); got != want {
+			t.Errorf("%s.Target() = %s, want %s", e, got, want)
+		}
+	}
+	for _, e := range []Edge{EdgeComments, EdgeCommenter} {
+		if !e.gated() {
+			t.Errorf("%s should be gated", e)
+		}
+	}
+	for _, e := range []Edge{EdgeChannel, EdgeRelated, EdgeUploads, EdgeItems, EdgeOwner, EdgePlaylists, EdgeCommunity} {
+		if e.gated() {
+			t.Errorf("%s should not be gated", e)
+		}
+	}
+}
+
+func TestParseSeed(t *testing.T) {
+	cases := []struct {
+		in   string
+		kind NodeKind
+		ref  string
+	}{
+		{"https://www.youtube.com/watch?v=dQw4w9WgXcQ", KindVideo, "dQw4w9WgXcQ"},
+		{"dQw4w9WgXcQ", KindVideo, "dQw4w9WgXcQ"},
+		{"UCabcdefghijklmnopqrst12", KindChannel, "UCabcdefghijklmnopqrst12"},
+		{"@mkbhd", KindChannel, "@mkbhd"},
+		{"PLaaaaaaaaaaaa", KindPlaylist, "PLaaaaaaaaaaaa"},
+		{"https://www.youtube.com/playlist?list=PLxyz", KindPlaylist, "PLxyz"},
+	}
+	for _, c := range cases {
+		s, err := ParseSeed(c.in)
+		if err != nil {
+			t.Errorf("ParseSeed(%q): %v", c.in, err)
+			continue
+		}
+		if s.Kind != c.kind || s.Ref != c.ref {
+			t.Errorf("ParseSeed(%q) = %s/%s, want %s/%s", c.in, s.Kind, s.Ref, c.kind, c.ref)
+		}
+	}
+	if _, err := ParseSeed(""); err == nil {
+		t.Error("expected error for empty seed")
+	}
+}
+
+func TestWalkContentFromVideo(t *testing.T) {
+	g := newFakeGraph()
+	nodes, edges := collect(t, g, []Seed{{Kind: KindVideo, Ref: "vid1"}}, WalkOptions{Depth: 1, Edges: DefaultEdges()})
+
+	// Breadth-first: the seed first, then its channel and related video.
+	if len(nodes) != 3 {
+		t.Fatalf("got %d nodes, want 3: %+v", len(nodes), endpoints(nodes))
+	}
+	if nodes[0].Kind != KindVideo || nodes[0].Depth != 0 {
+		t.Fatalf("seed node = %s depth %d", nodes[0].Kind, nodes[0].Depth)
+	}
+	got := endpoints(nodes[1:])
+	sort.Strings(got)
+	want := []string{"UCa", "vid2"}
+	if !equal(got, want) {
+		t.Fatalf("neighbors = %v, want %v", got, want)
+	}
+	if !contains(edges, "channel:vid1->UCa") || !contains(edges, "related:vid1->vid2") {
+		t.Fatalf("edges = %v", edges)
+	}
+}
+
+func TestWalkChannelPreset(t *testing.T) {
+	g := newFakeGraph()
+	nodes, _ := collect(t, g, []Seed{{Kind: KindChannel, Ref: "UCa"}}, WalkOptions{Depth: 1, Edges: edgePresets["feed"]})
+	got := endpoints(nodes)
+	sort.Strings(got)
+	// seed UCa, uploads vid1+vid3, playlist PLaaaaaaaaaa, post1.
+	want := []string{"PLaaaaaaaaaa", "UCa", "post1", "vid1", "vid3"}
+	if !equal(got, want) {
+		t.Fatalf("channel walk = %v, want %v", got, want)
+	}
+}
+
+func TestWalkCommentsThenCommenter(t *testing.T) {
+	g := newFakeGraph()
+	// Depth 2 so the comment (depth 1) expands to its author channel (depth 2).
+	nodes, edges := collect(t, g, []Seed{{Kind: KindVideo, Ref: "vid1"}},
+		WalkOptions{Depth: 2, Edges: edgePresets["comments"]})
+	kinds := map[NodeKind]int{}
+	for _, n := range nodes {
+		kinds[n.Kind]++
+	}
+	if kinds[KindComment] != 1 {
+		t.Fatalf("want 1 comment node, got %d (%v)", kinds[KindComment], endpoints(nodes))
+	}
+	if kinds[KindChannel] != 1 {
+		t.Fatalf("want 1 commenter channel, got %d (%v)", kinds[KindChannel], endpoints(nodes))
+	}
+	if !contains(edges, "comments:vid1->cmt1") || !contains(edges, "commenter:cmt1->UCb") {
+		t.Fatalf("edges = %v", edges)
+	}
+}
+
+func TestWalkCommentsRestrictedDegrades(t *testing.T) {
+	g := newFakeGraph()
+	g.commErr = map[string]error{"vid1": ErrCommentsRestricted}
+	var notes []string
+	opts := WalkOptions{Depth: 1, Edges: edgePresets["comments"], Note: func(s string) { notes = append(notes, s) }}
+	var nodes []*Node
+	err := NewWalker(g).Walk(context.Background(), []Seed{{Kind: KindVideo, Ref: "vid1"}}, opts, func(n *Node) error {
+		nodes = append(nodes, n)
+		return nil
+	})
+	if err != nil {
+		t.Fatalf("a gated comment edge must not be fatal: %v", err)
+	}
+	if len(nodes) != 1 || nodes[0].Kind != KindVideo {
+		t.Fatalf("want just the seed, got %v", endpoints(nodes))
+	}
+	if len(notes) == 0 {
+		t.Fatal("expected a note about the refused comment edge")
+	}
+}
+
+func TestWalkBudgetStops(t *testing.T) {
+	g := newFakeGraph()
+	nodes, _ := collect(t, g, []Seed{{Kind: KindVideo, Ref: "vid1"}}, WalkOptions{Depth: 1, Max: 2, Edges: DefaultEdges()})
+	if len(nodes) != 2 {
+		t.Fatalf("budget Max=2 should stop at 2, got %d", len(nodes))
+	}
+}
+
+func TestWalkFanoutCaps(t *testing.T) {
+	g := newFakeGraph()
+	// Channel uploads has 2 videos; fanout 1 keeps only one of them.
+	nodes, _ := collect(t, g, []Seed{{Kind: KindChannel, Ref: "UCa"}},
+		WalkOptions{Depth: 1, Fanout: 1, Edges: newEdgeSet(EdgeUploads)})
+	if len(nodes) != 2 { // seed + 1 upload
+		t.Fatalf("fanout 1 should yield seed + 1 upload, got %d (%v)", len(nodes), endpoints(nodes))
+	}
+}
+
+func TestWalkDedup(t *testing.T) {
+	g := newFakeGraph()
+	// From the playlist: items vid1, vid2 and owner UCa. UCa's uploads bring vid1
+	// and vid3 back; vid1 must not be emitted twice.
+	nodes, _ := collect(t, g, []Seed{{Kind: KindPlaylist, Ref: "PLaaaaaaaaaa"}},
+		WalkOptions{Depth: 2, Edges: newEdgeSet(EdgeItems, EdgeOwner, EdgeUploads)})
+	seen := map[string]int{}
+	for _, n := range nodes {
+		seen[n.Endpoint()]++
+	}
+	for id, c := range seen {
+		if c != 1 {
+			t.Fatalf("node %s emitted %d times, want 1 (%v)", id, c, endpoints(nodes))
+		}
+	}
+}
+
+func TestWalkSeedNotFoundFatal(t *testing.T) {
+	g := newFakeGraph()
+	err := NewWalker(g).Walk(context.Background(), []Seed{{Kind: KindVideo, Ref: "missing"}},
+		WalkOptions{Depth: 1, Edges: DefaultEdges()}, func(*Node) error { return nil })
+	if err == nil {
+		t.Fatal("an unfetchable seed must be fatal")
+	}
+}
+
+func TestWalkDepthZeroSeedsOnly(t *testing.T) {
+	g := newFakeGraph()
+	nodes, edges := collect(t, g, []Seed{{Kind: KindVideo, Ref: "vid1"}}, WalkOptions{Depth: 0, Edges: DefaultEdges()})
+	if len(nodes) != 1 {
+		t.Fatalf("depth 0 should emit only the seed, got %d", len(nodes))
+	}
+	if len(edges) != 0 {
+		t.Fatalf("depth 0 should record no edges, got %v", edges)
+	}
+}
+
+// --- small test helpers ---
+
+func endpoints(nodes []*Node) []string {
+	out := make([]string, len(nodes))
+	for i, n := range nodes {
+		out[i] = n.Endpoint()
+	}
+	return out
+}
+
+func contains(ss []string, want string) bool {
+	for _, s := range ss {
+		if s == want {
+			return true
+		}
+	}
+	return false
+}
+
+func equal(a, b []string) bool {
+	if len(a) != len(b) {
+		return false
+	}
+	for i := range a {
+		if a[i] != b[i] {
+			return false
+		}
+	}
+	return true
+}
diff --git a/youtube/store.go b/youtube/store.go
index cc7c38e..c2bc753 100644
--- a/youtube/store.go
+++ b/youtube/store.go
@@ -201,6 +201,14 @@ func (s *Store) initSchema() error {
 			started_at   TEXT,
 			completed_at TEXT
 		)`,
+		`CREATE TABLE IF NOT EXISTS edges (
+			src        TEXT NOT NULL,
+			dst        TEXT NOT NULL,
+			kind       TEXT NOT NULL,
+			created_at TEXT,
+			PRIMARY KEY (src, dst, kind)
+		)`,
+		`CREATE INDEX IF NOT EXISTS idx_edges_kind ON edges(kind)`,
 	}
 	for _, stmt := range stmts {
 		if _, err := s.db.Exec(stmt); err != nil {
@@ -352,6 +360,50 @@ func (s *Store) UpsertCommunityPost(p CommunityPost) error {
 	return err
 }
 
+// --- Graph edges and nodes ---
+
+// UpsertEdge records one traversed link of the discovery graph, keyed by the
+// (src, dst, kind) triple so re-walking is idempotent. src and dst are entity
+// ids; kind is the Edge string ("channel", "uploads", ...). It is the sink
+// `ytb discover --store` feeds from WalkOptions.OnEdge.
+func (s *Store) UpsertEdge(src, dst, kind string) error {
+	_, err := s.db.Exec(
+		`INSERT OR IGNORE INTO edges (src, dst, kind, created_at) VALUES (?,?,?,?)`,
+		src, dst, kind, storeTime(time.Now()),
+	)
+	return err
+}
+
+// UpsertNode persists a discovered node into its typed table, dispatching on the
+// node kind. It is what `ytb discover --store` calls for every emitted node, so
+// a walk fills videos/channels/playlists/comments/community_posts exactly as the
+// per-object reads do, with the edges table joining them.
+func (s *Store) UpsertNode(n *Node) error {
+	switch n.Kind {
+	case KindVideo:
+		if n.Video != nil {
+			return s.UpsertVideo(*n.Video)
+		}
+	case KindChannel:
+		if n.Channel != nil {
+			return s.UpsertChannel(*n.Channel)
+		}
+	case KindPlaylist:
+		if n.Playlist != nil {
+			return s.UpsertPlaylist(*n.Playlist)
+		}
+	case KindComment:
+		if n.Comment != nil {
+			return s.UpsertComment(*n.Comment)
+		}
+	case KindPost:
+		if n.Post != nil {
+			return s.UpsertCommunityPost(*n.Post)
+		}
+	}
+	return nil
+}
+
 // --- VideoFormat ---
 
 func (s *Store) UpsertVideoFormat(f VideoFormat) error {
@@ -498,7 +550,7 @@ func (s *Store) Stats() (map[string]int64, error) {
 	tables := []string{
 		"videos", "channels", "playlists", "playlist_videos", "related_videos",
 		"caption_tracks", "comments", "chapters", "community_posts", "video_formats",
-		"queue", "jobs",
+		"queue", "jobs", "edges",
 	}
 	out := make(map[string]int64, len(tables))
 	for _, t := range tables {
@@ -618,7 +670,7 @@ func (s *Store) Reset() error {
 	tables := []string{
 		"videos", "channels", "playlists", "playlist_videos", "related_videos",
 		"caption_tracks", "comments", "chapters", "community_posts", "video_formats",
-		"queue", "jobs",
+		"queue", "jobs", "edges",
 	}
 	for _, t := range tables {
 		if _, err := s.db.Exec(`DROP TABLE IF EXISTS ` + t); err != nil {

From acc61f843a2ac82f230915af3e3ba45bb5ddd7d2 Mon Sep 17 00:00:00 2001
From: Duc-Tam Nguyen <tamnd87@gmail.com>
Date: Mon, 15 Jun 2026 19:50:14 +0700
Subject: [PATCH 2/2] Add v0.4.0 release notes for discover

---
 docs/content/release-notes/v0-4-0.md | 66 ++++++++++++++++++++++++++++
 1 file changed, 66 insertions(+)
 create mode 100644 docs/content/release-notes/v0-4-0.md

diff --git a/docs/content/release-notes/v0-4-0.md b/docs/content/release-notes/v0-4-0.md
new file mode 100644
index 0000000..9521599
--- /dev/null
+++ b/docs/content/release-notes/v0-4-0.md
@@ -0,0 +1,66 @@
+---
+title: "v0.4.0"
+linkTitle: "v0.4.0"
+description: "A new discover command walks the YouTube graph from a video, channel, or playlist breadth first, following uploaders, related videos, uploads, playlists, items, owners, community posts, and comments."
+weight: 9
+---
+
+Every other read answers one question about one object: a video's metadata, a
+channel's uploads, a playlist's items. This release adds `discover`, which chains
+them into a single breadth-first walk.
+
+## discover: walk the graph
+
+```sh
+ytb discover dQw4w9WgXcQ      # what is this video linked to?
+ytb discover @MrBeast         # what is this channel linked to?
+```
+
+A seed is any reference `ytb` already resolves: a video id or watch URL, a
+channel id, `@handle`, or URL, or a playlist id or URL. Pass several to start the
+walk from more than one place. The first row is the seed at depth 0; each later
+row is tagged with the edge it arrived by.
+
+## What gets followed
+
+The default `content` preset spans every seed kind: a video yields its uploader
+and related videos, a channel yields its uploads, a playlist yields its items and
+owner. The full edge vocabulary is `channel`, `related`, `comments`, `uploads`,
+`playlists`, `community`, `items`, `owner`, and `commenter`.
+
+```sh
+ytb discover <channel> --follow feed       # uploads, playlists, community posts
+ytb discover <video> --follow comments     # comments and the channels behind them
+ytb discover <ref> --follow all
+ytb discover <channel> --follow uploads,playlists
+```
+
+The presets are `content`, `feed`, `comments`, and `all`. YouTube serves the open
+surface to an anonymous request, so most edges need nothing. The two that touch
+comments are gated by the per-IP Restricted Mode some networks see; when YouTube
+refuses them, `discover` notes it on stderr and keeps going on the rest of the
+graph rather than failing the walk.
+
+## Bounds, output, and the store
+
+`--depth` (default 1; 0 emits only the seeds), `--fanout` (default 25; 0 for
+unlimited), and `-n` (default 500) keep a walk finite. `discover` streams one row
+per node through the same formatter as every read, and `--store` writes every
+node and edge into the local store as the walk streams, so the graph is queryable
+afterwards. See the [Graph discovery](/guides/graph-discovery/) guide for the
+full tour.
+
+## Install
+
+```sh
+go install github.com/tamnd/ytb-cli/cmd/ytb@latest
+```
+
+Prebuilt archives for Linux, macOS, and Windows, plus Linux packages (deb, rpm,
+apk) and checksums, are on the
+[release page](https://github.com/tamnd/ytb-cli/releases/tag/v0.4.0). The
+container image is on GHCR:
+
+```sh
+docker run --rm ghcr.io/tamnd/ytb:0.4.0 discover dQw4w9WgXcQ -n 10
+```
