Skip to content

Commit d55f184

Browse files
committed
feat(youtube): videos list requests all non-owner parts (+--parts flag)
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.
1 parent fc13b41 commit d55f184

2 files changed

Lines changed: 237 additions & 1 deletion

File tree

internal/cmd/youtube.go

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,26 @@ import (
1616

1717
const youtubeForceSSLOAuthScope = "https://www.googleapis.com/auth/youtube.force-ssl"
1818

19+
// youtubeVideoAllParts is every videos.list part that is readable for an
20+
// arbitrary (non-owned) video. The owner-only parts fileDetails,
21+
// processingDetails and suggestions are deliberately excluded — the API returns
22+
// them only for videos the authenticated account itself uploaded, so requesting
23+
// them for other people's liked/playlist videos errors. The Google SDK simply
24+
// omits parts that have no data for a given video (e.g. liveStreamingDetails on
25+
// a non-live video), so requesting the full set is safe and tolerant of
26+
// per-video partial responses.
27+
var youtubeVideoAllParts = []string{
28+
"snippet",
29+
"contentDetails",
30+
"statistics",
31+
"status",
32+
"topicDetails",
33+
"recordingDetails",
34+
"liveStreamingDetails",
35+
"player",
36+
"localizations",
37+
}
38+
1939
type YouTubeCmd struct {
2040
Activities YouTubeActivitiesCmd `cmd:"" name:"activities" aliases:"activity" help:"List channel activities"`
2141
Videos YouTubeVideosCmd `cmd:"" name:"videos" aliases:"video" help:"List or get videos"`
@@ -72,10 +92,22 @@ type YouTubeVideosListCmd struct {
7292
Chart string `name:"chart" help:"Chart: mostPopular (regionCode required)"`
7393
Region string `name:"region" help:"Region code (e.g. US) for chart"`
7494
MyRating string `name:"my-rating" help:"Your rated videos: like (liked videos) or dislike (requires -a account)"`
95+
Parts string `name:"parts" help:"Comma-separated videos.list parts (default: every part readable for non-owned videos)"`
7596
Max int64 `name:"max" aliases:"limit" help:"Max results" default:"25"`
7697
Page string `name:"page" help:"Page token"`
7798
}
7899

100+
// resolveParts returns the requested videos.list parts. An empty/blank --parts
101+
// flag (the default) yields the full non-owner part set so callers get complete
102+
// metadata without having to enumerate parts. An explicit --parts narrows it.
103+
func (c *YouTubeVideosListCmd) resolveParts() []string {
104+
parts := splitCSV(c.Parts)
105+
if len(parts) == 0 {
106+
return append([]string(nil), youtubeVideoAllParts...)
107+
}
108+
return parts
109+
}
110+
79111
func (c *YouTubeVideosListCmd) Run(ctx context.Context, flags *RootFlags) error {
80112
if err := validateYouTubeMax(c.Max); err != nil {
81113
return err
@@ -127,7 +159,7 @@ func (c *YouTubeVideosListCmd) Run(ctx context.Context, flags *RootFlags) error
127159
return err
128160
}
129161

130-
call := svc.Videos.List([]string{"snippet", "contentDetails", "statistics"}).
162+
call := svc.Videos.List(c.resolveParts()).
131163
MaxResults(c.Max).
132164
PageToken(c.Page)
133165
switch {

internal/cmd/youtube_test.go

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -758,6 +758,210 @@ func TestYouTubeVideosListMyRatingUsesOAuthService(t *testing.T) {
758758
}
759759
}
760760

761+
// youtubePartValues collects the videos.list "part" selector from a request.
762+
// The Google SDK may send part either comma-joined or as repeated query params,
763+
// so normalize both into a flat slice.
764+
func youtubePartValues(r *http.Request) []string {
765+
var out []string
766+
for _, raw := range r.URL.Query()["part"] {
767+
out = append(out, strings.Split(raw, ",")...)
768+
}
769+
return out
770+
}
771+
772+
func TestYouTubeVideosListRequestsAllNonOwnerParts(t *testing.T) {
773+
var gotParts []string
774+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
775+
if r.URL.Path != "/youtube/v3/videos" {
776+
t.Fatalf("path = %s", r.URL.Path)
777+
}
778+
gotParts = youtubePartValues(r)
779+
_ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{}})
780+
}))
781+
defer srv.Close()
782+
783+
svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", youtube.NewService)
784+
ctx := withYouTubeTestServices(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), youtubeTestServices{
785+
Account: fixedYouTubeTestService(svc),
786+
APIKey: unexpectedYouTubeTestService(t, "API key service should not be used when account is configured"),
787+
})
788+
err := runKong(t, &YouTubeVideosListCmd{}, []string{"--id", "vid1", "--max", "1"}, ctx, &RootFlags{Account: "[email protected]"})
789+
if err != nil {
790+
t.Fatalf("runKong: %v", err)
791+
}
792+
793+
wantParts := []string{
794+
"snippet", "contentDetails", "statistics", "status", "topicDetails",
795+
"recordingDetails", "liveStreamingDetails", "player", "localizations",
796+
}
797+
if len(gotParts) != len(wantParts) {
798+
t.Fatalf("parts = %v (%d), want %d parts %v", gotParts, len(gotParts), len(wantParts), wantParts)
799+
}
800+
gotSet := make(map[string]bool, len(gotParts))
801+
for _, p := range gotParts {
802+
gotSet[p] = true
803+
}
804+
for _, p := range wantParts {
805+
if !gotSet[p] {
806+
t.Fatalf("part list %v is missing %q", gotParts, p)
807+
}
808+
}
809+
// Owner-only parts must never be requested for arbitrary (non-owned) videos.
810+
for _, owner := range []string{"fileDetails", "processingDetails", "suggestions"} {
811+
if gotSet[owner] {
812+
t.Fatalf("part list %v must not request owner-only part %q", gotParts, owner)
813+
}
814+
}
815+
}
816+
817+
func TestYouTubeVideosListPartsOverride(t *testing.T) {
818+
var gotParts []string
819+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
820+
if r.URL.Path != "/youtube/v3/videos" {
821+
t.Fatalf("path = %s", r.URL.Path)
822+
}
823+
gotParts = youtubePartValues(r)
824+
_ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{}})
825+
}))
826+
defer srv.Close()
827+
828+
svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", youtube.NewService)
829+
ctx := withYouTubeTestServices(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), youtubeTestServices{
830+
Account: fixedYouTubeTestService(svc),
831+
})
832+
err := runKong(t, &YouTubeVideosListCmd{}, []string{"--id", "vid1", "--parts", "snippet, statistics", "--max", "1"}, ctx, &RootFlags{Account: "[email protected]"})
833+
if err != nil {
834+
t.Fatalf("runKong: %v", err)
835+
}
836+
if len(gotParts) != 2 || gotParts[0] != "snippet" || gotParts[1] != "statistics" {
837+
t.Fatalf("parts = %v, want [snippet statistics]", gotParts)
838+
}
839+
}
840+
841+
func TestYouTubeVideosListJSONSerializesNonCoreParts(t *testing.T) {
842+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
843+
if r.URL.Path != "/youtube/v3/videos" {
844+
t.Fatalf("path = %s", r.URL.Path)
845+
}
846+
_ = json.NewEncoder(w).Encode(map[string]any{
847+
"items": []map[string]any{
848+
{
849+
"id": "vidRich",
850+
"snippet": map[string]any{
851+
"title": "Rich Video",
852+
"publishedAt": "2026-01-02T03:04:05Z",
853+
"thumbnails": map[string]any{
854+
"default": map[string]any{"url": "https://img/d.jpg", "width": 120, "height": 90},
855+
"high": map[string]any{"url": "https://img/h.jpg", "width": 480, "height": 360},
856+
"maxres": map[string]any{"url": "https://img/m.jpg", "width": 1280, "height": 720},
857+
},
858+
},
859+
"status": map[string]any{
860+
"privacyStatus": "public",
861+
"uploadStatus": "processed",
862+
"madeForKids": false,
863+
},
864+
"topicDetails": map[string]any{
865+
"topicCategories": []string{"https://en.wikipedia.org/wiki/Music"},
866+
},
867+
"liveStreamingDetails": map[string]any{
868+
"actualStartTime": "2026-01-01T00:00:00Z",
869+
},
870+
},
871+
},
872+
})
873+
}))
874+
defer srv.Close()
875+
876+
svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", youtube.NewService)
877+
var stdout bytes.Buffer
878+
ctx := withYouTubeTestServices(newCmdRuntimeJSONOutputContext(t, &stdout, io.Discard), youtubeTestServices{
879+
Account: fixedYouTubeTestService(svc),
880+
})
881+
err := runKong(t, &YouTubeVideosListCmd{}, []string{"--id", "vidRich", "--max", "1"}, ctx, &RootFlags{Account: "[email protected]", JSON: true})
882+
if err != nil {
883+
t.Fatalf("runKong: %v", err)
884+
}
885+
886+
var got struct {
887+
Items []struct {
888+
ID string `json:"id"`
889+
Status struct {
890+
PrivacyStatus string `json:"privacyStatus"`
891+
UploadStatus string `json:"uploadStatus"`
892+
} `json:"status"`
893+
TopicDetails struct {
894+
TopicCategories []string `json:"topicCategories"`
895+
} `json:"topicDetails"`
896+
Snippet struct {
897+
Thumbnails map[string]struct {
898+
URL string `json:"url"`
899+
} `json:"thumbnails"`
900+
} `json:"snippet"`
901+
LiveStreamingDetails struct {
902+
ActualStartTime string `json:"actualStartTime"`
903+
} `json:"liveStreamingDetails"`
904+
} `json:"items"`
905+
}
906+
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
907+
t.Fatalf("json output %q: %v", stdout.String(), err)
908+
}
909+
if len(got.Items) != 1 {
910+
t.Fatalf("items len = %d: %s", len(got.Items), stdout.String())
911+
}
912+
item := got.Items[0]
913+
if item.Status.PrivacyStatus != "public" {
914+
t.Fatalf("status.privacyStatus = %q (non-core status part dropped): %s", item.Status.PrivacyStatus, stdout.String())
915+
}
916+
if len(item.TopicDetails.TopicCategories) != 1 {
917+
t.Fatalf("topicDetails.topicCategories = %v (non-core topicDetails part dropped): %s", item.TopicDetails.TopicCategories, stdout.String())
918+
}
919+
for _, size := range []string{"default", "high", "maxres"} {
920+
if item.Snippet.Thumbnails[size].URL == "" {
921+
t.Fatalf("thumbnail size %q missing from JSON (compacted): %s", size, stdout.String())
922+
}
923+
}
924+
if item.LiveStreamingDetails.ActualStartTime == "" {
925+
t.Fatalf("liveStreamingDetails.actualStartTime dropped: %s", stdout.String())
926+
}
927+
}
928+
929+
// A video with no liveStreamingDetails (a normal non-live video) must still
930+
// serialize cleanly — the SDK omits parts with no data, never errors.
931+
func TestYouTubeVideosListToleratesPartialParts(t *testing.T) {
932+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
933+
_ = json.NewEncoder(w).Encode(map[string]any{
934+
"items": []map[string]any{
935+
{
936+
"id": "vidPlain",
937+
"snippet": map[string]any{"title": "Plain Video"},
938+
"status": map[string]any{"privacyStatus": "unlisted"},
939+
},
940+
},
941+
})
942+
}))
943+
defer srv.Close()
944+
945+
svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", youtube.NewService)
946+
var stdout bytes.Buffer
947+
ctx := withYouTubeTestServices(newCmdRuntimeJSONOutputContext(t, &stdout, io.Discard), youtubeTestServices{
948+
Account: fixedYouTubeTestService(svc),
949+
})
950+
err := runKong(t, &YouTubeVideosListCmd{}, []string{"--id", "vidPlain", "--max", "1"}, ctx, &RootFlags{Account: "[email protected]", JSON: true})
951+
if err != nil {
952+
t.Fatalf("runKong: %v", err)
953+
}
954+
var got struct {
955+
Items []json.RawMessage `json:"items"`
956+
}
957+
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
958+
t.Fatalf("json output %q: %v", stdout.String(), err)
959+
}
960+
if len(got.Items) != 1 {
961+
t.Fatalf("items len = %d: %s", len(got.Items), stdout.String())
962+
}
963+
}
964+
761965
func TestYouTubeVideosListMyRatingValidation(t *testing.T) {
762966
ctx := withYouTubeTestServices(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), youtubeTestServices{
763967
Account: unexpectedYouTubeTestService(t, "should not reach service with invalid my-rating"),

0 commit comments

Comments
 (0)