From d55f184dafa060d52a27d088965cbdee509d4c8c Mon Sep 17 00:00:00 2001 From: Capharno Date: Sat, 20 Jun 2026 10:17:19 -0400 Subject: [PATCH] feat(youtube): videos list requests all non-owner parts (+--parts flag) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend 'yt videos list' so it fetches every videos.list part readable for arbitrary (non-owned) videos — snippet, contentDetails, statistics, status, topicDetails, recordingDetails, liveStreamingDetails, player, localizations — instead of only the previous 3 (snippet,contentDetails,statistics). A new --parts flag narrows the set when desired; the default is the full non-owner list. Owner-only parts (fileDetails/processingDetails/suggestions) are deliberately excluded — the API returns them only for the account's own uploads. The Google SDK omits parts with no data for a given video (e.g. liveStreamingDetails on a non-live video), so the broad request tolerates per-video partial responses without erroring. Tests assert the full part set is requested by default, --parts overrides it, non-core part fields (status.privacyStatus, topicDetails, all thumbnail sizes, liveStreamingDetails) survive in --json output, and a video missing optional parts still serializes cleanly. --- internal/cmd/youtube.go | 34 +++++- internal/cmd/youtube_test.go | 204 +++++++++++++++++++++++++++++++++++ 2 files changed, 237 insertions(+), 1 deletion(-) diff --git a/internal/cmd/youtube.go b/internal/cmd/youtube.go index ce2688187..6d384ec99 100644 --- a/internal/cmd/youtube.go +++ b/internal/cmd/youtube.go @@ -16,6 +16,26 @@ import ( const youtubeForceSSLOAuthScope = "https://www.googleapis.com/auth/youtube.force-ssl" +// youtubeVideoAllParts is every videos.list part that is readable for an +// arbitrary (non-owned) video. The owner-only parts fileDetails, +// processingDetails and suggestions are deliberately excluded — the API returns +// them only for videos the authenticated account itself uploaded, so requesting +// them for other people's liked/playlist videos errors. The Google SDK simply +// omits parts that have no data for a given video (e.g. liveStreamingDetails on +// a non-live video), so requesting the full set is safe and tolerant of +// per-video partial responses. +var youtubeVideoAllParts = []string{ + "snippet", + "contentDetails", + "statistics", + "status", + "topicDetails", + "recordingDetails", + "liveStreamingDetails", + "player", + "localizations", +} + type YouTubeCmd struct { Activities YouTubeActivitiesCmd `cmd:"" name:"activities" aliases:"activity" help:"List channel activities"` Videos YouTubeVideosCmd `cmd:"" name:"videos" aliases:"video" help:"List or get videos"` @@ -72,10 +92,22 @@ type YouTubeVideosListCmd struct { Chart string `name:"chart" help:"Chart: mostPopular (regionCode required)"` Region string `name:"region" help:"Region code (e.g. US) for chart"` MyRating string `name:"my-rating" help:"Your rated videos: like (liked videos) or dislike (requires -a account)"` + Parts string `name:"parts" help:"Comma-separated videos.list parts (default: every part readable for non-owned videos)"` Max int64 `name:"max" aliases:"limit" help:"Max results" default:"25"` Page string `name:"page" help:"Page token"` } +// resolveParts returns the requested videos.list parts. An empty/blank --parts +// flag (the default) yields the full non-owner part set so callers get complete +// metadata without having to enumerate parts. An explicit --parts narrows it. +func (c *YouTubeVideosListCmd) resolveParts() []string { + parts := splitCSV(c.Parts) + if len(parts) == 0 { + return append([]string(nil), youtubeVideoAllParts...) + } + return parts +} + func (c *YouTubeVideosListCmd) Run(ctx context.Context, flags *RootFlags) error { if err := validateYouTubeMax(c.Max); err != nil { return err @@ -127,7 +159,7 @@ func (c *YouTubeVideosListCmd) Run(ctx context.Context, flags *RootFlags) error return err } - call := svc.Videos.List([]string{"snippet", "contentDetails", "statistics"}). + call := svc.Videos.List(c.resolveParts()). MaxResults(c.Max). PageToken(c.Page) switch { diff --git a/internal/cmd/youtube_test.go b/internal/cmd/youtube_test.go index a81ac8840..e167f54d0 100644 --- a/internal/cmd/youtube_test.go +++ b/internal/cmd/youtube_test.go @@ -758,6 +758,210 @@ func TestYouTubeVideosListMyRatingUsesOAuthService(t *testing.T) { } } +// youtubePartValues collects the videos.list "part" selector from a request. +// The Google SDK may send part either comma-joined or as repeated query params, +// so normalize both into a flat slice. +func youtubePartValues(r *http.Request) []string { + var out []string + for _, raw := range r.URL.Query()["part"] { + out = append(out, strings.Split(raw, ",")...) + } + return out +} + +func TestYouTubeVideosListRequestsAllNonOwnerParts(t *testing.T) { + var gotParts []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/youtube/v3/videos" { + t.Fatalf("path = %s", r.URL.Path) + } + gotParts = youtubePartValues(r) + _ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{}}) + })) + defer srv.Close() + + svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", youtube.NewService) + ctx := withYouTubeTestServices(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), youtubeTestServices{ + Account: fixedYouTubeTestService(svc), + APIKey: unexpectedYouTubeTestService(t, "API key service should not be used when account is configured"), + }) + err := runKong(t, &YouTubeVideosListCmd{}, []string{"--id", "vid1", "--max", "1"}, ctx, &RootFlags{Account: "me@example.com"}) + if err != nil { + t.Fatalf("runKong: %v", err) + } + + wantParts := []string{ + "snippet", "contentDetails", "statistics", "status", "topicDetails", + "recordingDetails", "liveStreamingDetails", "player", "localizations", + } + if len(gotParts) != len(wantParts) { + t.Fatalf("parts = %v (%d), want %d parts %v", gotParts, len(gotParts), len(wantParts), wantParts) + } + gotSet := make(map[string]bool, len(gotParts)) + for _, p := range gotParts { + gotSet[p] = true + } + for _, p := range wantParts { + if !gotSet[p] { + t.Fatalf("part list %v is missing %q", gotParts, p) + } + } + // Owner-only parts must never be requested for arbitrary (non-owned) videos. + for _, owner := range []string{"fileDetails", "processingDetails", "suggestions"} { + if gotSet[owner] { + t.Fatalf("part list %v must not request owner-only part %q", gotParts, owner) + } + } +} + +func TestYouTubeVideosListPartsOverride(t *testing.T) { + var gotParts []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/youtube/v3/videos" { + t.Fatalf("path = %s", r.URL.Path) + } + gotParts = youtubePartValues(r) + _ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{}}) + })) + defer srv.Close() + + svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", youtube.NewService) + ctx := withYouTubeTestServices(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), youtubeTestServices{ + Account: fixedYouTubeTestService(svc), + }) + err := runKong(t, &YouTubeVideosListCmd{}, []string{"--id", "vid1", "--parts", "snippet, statistics", "--max", "1"}, ctx, &RootFlags{Account: "me@example.com"}) + if err != nil { + t.Fatalf("runKong: %v", err) + } + if len(gotParts) != 2 || gotParts[0] != "snippet" || gotParts[1] != "statistics" { + t.Fatalf("parts = %v, want [snippet statistics]", gotParts) + } +} + +func TestYouTubeVideosListJSONSerializesNonCoreParts(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/youtube/v3/videos" { + t.Fatalf("path = %s", r.URL.Path) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + { + "id": "vidRich", + "snippet": map[string]any{ + "title": "Rich Video", + "publishedAt": "2026-01-02T03:04:05Z", + "thumbnails": map[string]any{ + "default": map[string]any{"url": "https://img/d.jpg", "width": 120, "height": 90}, + "high": map[string]any{"url": "https://img/h.jpg", "width": 480, "height": 360}, + "maxres": map[string]any{"url": "https://img/m.jpg", "width": 1280, "height": 720}, + }, + }, + "status": map[string]any{ + "privacyStatus": "public", + "uploadStatus": "processed", + "madeForKids": false, + }, + "topicDetails": map[string]any{ + "topicCategories": []string{"https://en.wikipedia.org/wiki/Music"}, + }, + "liveStreamingDetails": map[string]any{ + "actualStartTime": "2026-01-01T00:00:00Z", + }, + }, + }, + }) + })) + defer srv.Close() + + svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", youtube.NewService) + var stdout bytes.Buffer + ctx := withYouTubeTestServices(newCmdRuntimeJSONOutputContext(t, &stdout, io.Discard), youtubeTestServices{ + Account: fixedYouTubeTestService(svc), + }) + err := runKong(t, &YouTubeVideosListCmd{}, []string{"--id", "vidRich", "--max", "1"}, ctx, &RootFlags{Account: "me@example.com", JSON: true}) + if err != nil { + t.Fatalf("runKong: %v", err) + } + + var got struct { + Items []struct { + ID string `json:"id"` + Status struct { + PrivacyStatus string `json:"privacyStatus"` + UploadStatus string `json:"uploadStatus"` + } `json:"status"` + TopicDetails struct { + TopicCategories []string `json:"topicCategories"` + } `json:"topicDetails"` + Snippet struct { + Thumbnails map[string]struct { + URL string `json:"url"` + } `json:"thumbnails"` + } `json:"snippet"` + LiveStreamingDetails struct { + ActualStartTime string `json:"actualStartTime"` + } `json:"liveStreamingDetails"` + } `json:"items"` + } + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("json output %q: %v", stdout.String(), err) + } + if len(got.Items) != 1 { + t.Fatalf("items len = %d: %s", len(got.Items), stdout.String()) + } + item := got.Items[0] + if item.Status.PrivacyStatus != "public" { + t.Fatalf("status.privacyStatus = %q (non-core status part dropped): %s", item.Status.PrivacyStatus, stdout.String()) + } + if len(item.TopicDetails.TopicCategories) != 1 { + t.Fatalf("topicDetails.topicCategories = %v (non-core topicDetails part dropped): %s", item.TopicDetails.TopicCategories, stdout.String()) + } + for _, size := range []string{"default", "high", "maxres"} { + if item.Snippet.Thumbnails[size].URL == "" { + t.Fatalf("thumbnail size %q missing from JSON (compacted): %s", size, stdout.String()) + } + } + if item.LiveStreamingDetails.ActualStartTime == "" { + t.Fatalf("liveStreamingDetails.actualStartTime dropped: %s", stdout.String()) + } +} + +// A video with no liveStreamingDetails (a normal non-live video) must still +// serialize cleanly — the SDK omits parts with no data, never errors. +func TestYouTubeVideosListToleratesPartialParts(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + { + "id": "vidPlain", + "snippet": map[string]any{"title": "Plain Video"}, + "status": map[string]any{"privacyStatus": "unlisted"}, + }, + }, + }) + })) + defer srv.Close() + + svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", youtube.NewService) + var stdout bytes.Buffer + ctx := withYouTubeTestServices(newCmdRuntimeJSONOutputContext(t, &stdout, io.Discard), youtubeTestServices{ + Account: fixedYouTubeTestService(svc), + }) + err := runKong(t, &YouTubeVideosListCmd{}, []string{"--id", "vidPlain", "--max", "1"}, ctx, &RootFlags{Account: "me@example.com", JSON: true}) + if err != nil { + t.Fatalf("runKong: %v", err) + } + var got struct { + Items []json.RawMessage `json:"items"` + } + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("json output %q: %v", stdout.String(), err) + } + if len(got.Items) != 1 { + t.Fatalf("items len = %d: %s", len(got.Items), stdout.String()) + } +} + func TestYouTubeVideosListMyRatingValidation(t *testing.T) { ctx := withYouTubeTestServices(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), youtubeTestServices{ Account: unexpectedYouTubeTestService(t, "should not reach service with invalid my-rating"),