Skip to content

Commit 621757c

Browse files
feat(youtube): add opt-in full video metadata (#871)
Preserve the historical videos-list default while adding --parts all, exact explicit selection, validation, docs, and regression coverage. Co-authored-by: Capharno <[email protected]>
1 parent d405310 commit 621757c

5 files changed

Lines changed: 311 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## 0.31.2 - Unreleased
44

5+
- YouTube: add opt-in `videos list --parts all` full metadata while preserving the existing compact default and explicit owner-only part requests. (#871) — thanks @coeur-de-loup.
56
- CLI: add read-only `update status` / `update check` release metadata, platform asset, checksum, and install-method reporting. (#882) — thanks @titus7490.
67
- Docs: add `docs format --spacing-mode` for setting paragraph spacing collapse behavior alongside `--space-above` and `--space-below`. (#885) — thanks @odyssey4me.
78

docs/commands/gog-youtube-videos-list.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ gog youtube (yt) videos (video) list (ls) [flags]
3737
| `--my-rating` | `string` | | Your rated videos: like (liked videos) or dislike (requires -a account) |
3838
| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
3939
| `--page` | `string` | | Page token |
40+
| `--parts` | `string` | | Comma-separated videos.list parts or 'all' (default: snippet,contentDetails,statistics) |
4041
| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) |
4142
| `--readonly` | `bool` | false | Block mutating API requests at runtime; auth add also requests read-only OAuth scopes |
4243
| `--region` | `string` | | Region code (e.g. US) for chart |

docs/youtube.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,22 @@ gog yt videos list --my-rating dislike --account [email protected] --json
6767
Both reads work with the default `youtube.readonly` scope; no extra scope is
6868
required.
6969

70+
## Choose video fields
71+
72+
`videos list` keeps its compact historical default: `snippet`,
73+
`contentDetails`, and `statistics`. Request every field available for arbitrary
74+
videos with `--parts all`, or pass an exact comma-separated list:
75+
76+
```bash
77+
gog yt videos list --id VIDEO_ID --parts all --json
78+
gog yt videos list --id YOUR_VIDEO_ID --parts snippet,fileDetails --account [email protected] --json
79+
```
80+
81+
`all` excludes the owner-only `fileDetails`, `processingDetails`, and
82+
`suggestions` parts. Explicit lists are passed through unchanged so authenticated
83+
owners can request those fields for their own uploads. Do not combine `all` with
84+
other part names.
85+
7086
## Manage subscriptions
7187

7288
List one page or fetch every page:

internal/cmd/youtube.go

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,29 @@ import (
1616

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

19+
var youtubeVideoDefaultParts = []string{
20+
"snippet",
21+
"contentDetails",
22+
"statistics",
23+
}
24+
25+
// youtubeVideoAllNonOwnerParts matches every videos.list part documented as
26+
// readable for arbitrary videos. Owner-only parts remain available through an
27+
// explicit --parts list for callers querying their own uploads.
28+
var youtubeVideoAllNonOwnerParts = []string{
29+
"contentDetails",
30+
"id",
31+
"liveStreamingDetails",
32+
"localizations",
33+
"paidProductPlacementDetails",
34+
"player",
35+
"recordingDetails",
36+
"snippet",
37+
"statistics",
38+
"status",
39+
"topicDetails",
40+
}
41+
1942
type YouTubeCmd struct {
2043
Activities YouTubeActivitiesCmd `cmd:"" name:"activities" aliases:"activity" help:"List channel activities"`
2144
Videos YouTubeVideosCmd `cmd:"" name:"videos" aliases:"video" help:"List or get videos"`
@@ -72,10 +95,32 @@ type YouTubeVideosListCmd struct {
7295
Chart string `name:"chart" help:"Chart: mostPopular (regionCode required)"`
7396
Region string `name:"region" help:"Region code (e.g. US) for chart"`
7497
MyRating string `name:"my-rating" help:"Your rated videos: like (liked videos) or dislike (requires -a account)"`
98+
Parts string `name:"parts" help:"Comma-separated videos.list parts or 'all' (default: snippet,contentDetails,statistics)"`
7599
Max int64 `name:"max" aliases:"limit" help:"Max results" default:"25"`
76100
Page string `name:"page" help:"Page token"`
77101
}
78102

103+
func (c *YouTubeVideosListCmd) resolveParts() ([]string, error) {
104+
parts := splitCSV(c.Parts)
105+
if len(parts) == 0 {
106+
return append([]string(nil), youtubeVideoDefaultParts...), nil
107+
}
108+
all := false
109+
for _, part := range parts {
110+
if part == literalAll {
111+
all = true
112+
break
113+
}
114+
}
115+
if !all {
116+
return parts, nil
117+
}
118+
if len(parts) != 1 {
119+
return nil, usage("--parts all cannot be combined with explicit parts")
120+
}
121+
return append([]string(nil), youtubeVideoAllNonOwnerParts...), nil
122+
}
123+
79124
func (c *YouTubeVideosListCmd) Run(ctx context.Context, flags *RootFlags) error {
80125
if err := validateYouTubeMax(c.Max); err != nil {
81126
return err
@@ -110,9 +155,12 @@ func (c *YouTubeVideosListCmd) Run(ctx context.Context, flags *RootFlags) error
110155
if myRating != "" && myRating != "like" && myRating != "dislike" {
111156
return usage("--my-rating must be like or dislike")
112157
}
158+
parts, err := c.resolveParts()
159+
if err != nil {
160+
return err
161+
}
113162

114163
var svc *youtube.Service
115-
var err error
116164
if myRating != "" {
117165
// myRating reads are per-user and require OAuth, not an API key.
118166
account, accErr := requireAccount(flags)
@@ -127,7 +175,7 @@ func (c *YouTubeVideosListCmd) Run(ctx context.Context, flags *RootFlags) error
127175
return err
128176
}
129177

130-
call := svc.Videos.List([]string{"snippet", "contentDetails", "statistics"}).
178+
call := svc.Videos.List(parts).
131179
MaxResults(c.Max).
132180
PageToken(c.Page)
133181
switch {

internal/cmd/youtube_test.go

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -758,6 +758,249 @@ 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+
values := r.URL.Query()["part"]
766+
out := make([]string, 0, len(values))
767+
for _, raw := range values {
768+
out = append(out, strings.Split(raw, ",")...)
769+
}
770+
return out
771+
}
772+
773+
func TestYouTubeVideosListPreservesDefaultParts(t *testing.T) {
774+
var gotParts []string
775+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
776+
gotParts = youtubePartValues(r)
777+
_ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{}})
778+
}))
779+
defer srv.Close()
780+
781+
svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", youtube.NewService)
782+
ctx := withYouTubeTestServices(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), youtubeTestServices{
783+
Account: fixedYouTubeTestService(svc),
784+
})
785+
err := runKong(t, &YouTubeVideosListCmd{}, []string{"--id", "vid1", "--max", "1"}, ctx, &RootFlags{Account: "[email protected]"})
786+
if err != nil {
787+
t.Fatalf("runKong: %v", err)
788+
}
789+
790+
want := []string{"snippet", "contentDetails", "statistics"}
791+
if strings.Join(gotParts, ",") != strings.Join(want, ",") {
792+
t.Fatalf("parts = %v, want %v", gotParts, want)
793+
}
794+
}
795+
796+
func TestYouTubeVideosListRequestsAllNonOwnerParts(t *testing.T) {
797+
var gotParts []string
798+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
799+
if r.URL.Path != "/youtube/v3/videos" {
800+
t.Fatalf("path = %s", r.URL.Path)
801+
}
802+
gotParts = youtubePartValues(r)
803+
_ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{}})
804+
}))
805+
defer srv.Close()
806+
807+
svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", youtube.NewService)
808+
ctx := withYouTubeTestServices(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), youtubeTestServices{
809+
Account: fixedYouTubeTestService(svc),
810+
APIKey: unexpectedYouTubeTestService(t, "API key service should not be used when account is configured"),
811+
})
812+
err := runKong(t, &YouTubeVideosListCmd{}, []string{"--id", "vid1", "--parts", "all", "--max", "1"}, ctx, &RootFlags{Account: "[email protected]"})
813+
if err != nil {
814+
t.Fatalf("runKong: %v", err)
815+
}
816+
817+
wantParts := []string{
818+
"contentDetails", "id", "liveStreamingDetails", "localizations",
819+
"paidProductPlacementDetails", "player", "recordingDetails", "snippet",
820+
"statistics", "status", "topicDetails",
821+
}
822+
if strings.Join(gotParts, ",") != strings.Join(wantParts, ",") {
823+
t.Fatalf("parts = %v, want %v", gotParts, wantParts)
824+
}
825+
gotSet := make(map[string]bool, len(gotParts))
826+
for _, part := range gotParts {
827+
gotSet[part] = true
828+
}
829+
for _, owner := range []string{"fileDetails", "processingDetails", "suggestions"} {
830+
if gotSet[owner] {
831+
t.Fatalf("part list %v must not request owner-only part %q", gotParts, owner)
832+
}
833+
}
834+
}
835+
836+
func TestYouTubeVideosListPartsOverride(t *testing.T) {
837+
var gotParts []string
838+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
839+
if r.URL.Path != "/youtube/v3/videos" {
840+
t.Fatalf("path = %s", r.URL.Path)
841+
}
842+
gotParts = youtubePartValues(r)
843+
_ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{}})
844+
}))
845+
defer srv.Close()
846+
847+
svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", youtube.NewService)
848+
ctx := withYouTubeTestServices(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), youtubeTestServices{
849+
Account: fixedYouTubeTestService(svc),
850+
})
851+
err := runKong(t, &YouTubeVideosListCmd{}, []string{"--id", "vid1", "--parts", "snippet, fileDetails", "--max", "1"}, ctx, &RootFlags{Account: "[email protected]"})
852+
if err != nil {
853+
t.Fatalf("runKong: %v", err)
854+
}
855+
if len(gotParts) != 2 || gotParts[0] != "snippet" || gotParts[1] != "fileDetails" {
856+
t.Fatalf("parts = %v, want [snippet fileDetails]", gotParts)
857+
}
858+
}
859+
860+
func TestYouTubeVideosListRejectsMixedAllParts(t *testing.T) {
861+
ctx := withYouTubeTestServices(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), youtubeTestServices{
862+
Account: unexpectedYouTubeTestService(t, "invalid --parts must not create a service"),
863+
APIKey: unexpectedYouTubeTestService(t, "invalid --parts must not create a service"),
864+
})
865+
err := runKong(t, &YouTubeVideosListCmd{}, []string{"--id", "vid1", "--parts", "all,status", "--max", "1"}, ctx, &RootFlags{Account: "[email protected]"})
866+
if err == nil || ExitCode(err) != 2 || !strings.Contains(err.Error(), "--parts all cannot be combined with explicit parts") {
867+
t.Fatalf("expected mixed all usage error, got %v", err)
868+
}
869+
}
870+
871+
func TestYouTubeVideosListJSONSerializesNonCoreParts(t *testing.T) {
872+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
873+
if r.URL.Path != "/youtube/v3/videos" {
874+
t.Fatalf("path = %s", r.URL.Path)
875+
}
876+
_ = json.NewEncoder(w).Encode(map[string]any{
877+
"items": []map[string]any{
878+
{
879+
"id": "vidRich",
880+
"snippet": map[string]any{
881+
"title": "Rich Video",
882+
"publishedAt": "2026-01-02T03:04:05Z",
883+
"thumbnails": map[string]any{
884+
"default": map[string]any{"url": "https://img/d.jpg", "width": 120, "height": 90},
885+
"high": map[string]any{"url": "https://img/h.jpg", "width": 480, "height": 360},
886+
"maxres": map[string]any{"url": "https://img/m.jpg", "width": 1280, "height": 720},
887+
},
888+
},
889+
"status": map[string]any{
890+
"privacyStatus": "public",
891+
"uploadStatus": "processed",
892+
"madeForKids": false,
893+
},
894+
"topicDetails": map[string]any{
895+
"topicCategories": []string{"https://en.wikipedia.org/wiki/Music"},
896+
},
897+
"liveStreamingDetails": map[string]any{
898+
"actualStartTime": "2026-01-01T00:00:00Z",
899+
},
900+
"paidProductPlacementDetails": map[string]any{
901+
"hasPaidProductPlacement": true,
902+
},
903+
},
904+
},
905+
})
906+
}))
907+
defer srv.Close()
908+
909+
svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", youtube.NewService)
910+
var stdout bytes.Buffer
911+
ctx := withYouTubeTestServices(newCmdRuntimeJSONOutputContext(t, &stdout, io.Discard), youtubeTestServices{
912+
Account: fixedYouTubeTestService(svc),
913+
})
914+
err := runKong(t, &YouTubeVideosListCmd{}, []string{"--id", "vidRich", "--parts", "all", "--max", "1"}, ctx, &RootFlags{Account: "[email protected]", JSON: true})
915+
if err != nil {
916+
t.Fatalf("runKong: %v", err)
917+
}
918+
919+
var got struct {
920+
Items []struct {
921+
ID string `json:"id"`
922+
Status struct {
923+
PrivacyStatus string `json:"privacyStatus"`
924+
UploadStatus string `json:"uploadStatus"`
925+
} `json:"status"`
926+
TopicDetails struct {
927+
TopicCategories []string `json:"topicCategories"`
928+
} `json:"topicDetails"`
929+
Snippet struct {
930+
Thumbnails map[string]struct {
931+
URL string `json:"url"`
932+
} `json:"thumbnails"`
933+
} `json:"snippet"`
934+
LiveStreamingDetails struct {
935+
ActualStartTime string `json:"actualStartTime"`
936+
} `json:"liveStreamingDetails"`
937+
PaidProductPlacementDetails struct {
938+
HasPaidProductPlacement bool `json:"hasPaidProductPlacement"`
939+
} `json:"paidProductPlacementDetails"`
940+
} `json:"items"`
941+
}
942+
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
943+
t.Fatalf("json output %q: %v", stdout.String(), err)
944+
}
945+
if len(got.Items) != 1 {
946+
t.Fatalf("items len = %d: %s", len(got.Items), stdout.String())
947+
}
948+
item := got.Items[0]
949+
if item.Status.PrivacyStatus != "public" {
950+
t.Fatalf("status.privacyStatus = %q (non-core status part dropped): %s", item.Status.PrivacyStatus, stdout.String())
951+
}
952+
if len(item.TopicDetails.TopicCategories) != 1 {
953+
t.Fatalf("topicDetails.topicCategories = %v (non-core topicDetails part dropped): %s", item.TopicDetails.TopicCategories, stdout.String())
954+
}
955+
for _, size := range []string{"default", "high", "maxres"} {
956+
if item.Snippet.Thumbnails[size].URL == "" {
957+
t.Fatalf("thumbnail size %q missing from JSON (compacted): %s", size, stdout.String())
958+
}
959+
}
960+
if item.LiveStreamingDetails.ActualStartTime == "" {
961+
t.Fatalf("liveStreamingDetails.actualStartTime dropped: %s", stdout.String())
962+
}
963+
if !item.PaidProductPlacementDetails.HasPaidProductPlacement {
964+
t.Fatalf("paidProductPlacementDetails.hasPaidProductPlacement dropped: %s", stdout.String())
965+
}
966+
}
967+
968+
// A video with no liveStreamingDetails (a normal non-live video) must still
969+
// serialize cleanly — the SDK omits parts with no data, never errors.
970+
func TestYouTubeVideosListToleratesPartialParts(t *testing.T) {
971+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
972+
_ = json.NewEncoder(w).Encode(map[string]any{
973+
"items": []map[string]any{
974+
{
975+
"id": "vidPlain",
976+
"snippet": map[string]any{"title": "Plain Video"},
977+
"status": map[string]any{"privacyStatus": "unlisted"},
978+
},
979+
},
980+
})
981+
}))
982+
defer srv.Close()
983+
984+
svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", youtube.NewService)
985+
var stdout bytes.Buffer
986+
ctx := withYouTubeTestServices(newCmdRuntimeJSONOutputContext(t, &stdout, io.Discard), youtubeTestServices{
987+
Account: fixedYouTubeTestService(svc),
988+
})
989+
err := runKong(t, &YouTubeVideosListCmd{}, []string{"--id", "vidPlain", "--parts", "all", "--max", "1"}, ctx, &RootFlags{Account: "[email protected]", JSON: true})
990+
if err != nil {
991+
t.Fatalf("runKong: %v", err)
992+
}
993+
var got struct {
994+
Items []json.RawMessage `json:"items"`
995+
}
996+
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
997+
t.Fatalf("json output %q: %v", stdout.String(), err)
998+
}
999+
if len(got.Items) != 1 {
1000+
t.Fatalf("items len = %d: %s", len(got.Items), stdout.String())
1001+
}
1002+
}
1003+
7611004
func TestYouTubeVideosListMyRatingValidation(t *testing.T) {
7621005
ctx := withYouTubeTestServices(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), youtubeTestServices{
7631006
Account: unexpectedYouTubeTestService(t, "should not reach service with invalid my-rating"),

0 commit comments

Comments
 (0)