From 71b663cfeedd67e7fd1355b89a10a9b1d9db90d8 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 20 Sep 2026 03:21:16 +0200 Subject: [PATCH] feat(accounts): add the per-network extras commands pinterest, youtube, bluesky, tiktok, instagram and linkedin subcommands under accounts, covering boards, playlists and captions, post languages, creator limits plus music, place and video lookup, Reel audio, the publishing limit and live stories, and mention lookup. go.mod points at the fopost-go branch commit that carries them; it needs bumping to the tag once that repo releases. --- README.md | 7 + go.mod | 2 +- go.sum | 2 + internal/cmd/accounts.go | 6 + internal/cmd/accounts_extras.go | 569 ++++++++++++++++++++++++++++++++ internal/cmd/cli_test.go | 72 +++- 6 files changed, 656 insertions(+), 2 deletions(-) create mode 100644 internal/cmd/accounts_extras.go diff --git a/README.md b/README.md index 9a95ecb..da6306a 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,13 @@ fopost accounts list · get · rename · move · health · validate · refr gbp delete-place-action gbp verification · start-verification · complete-verification gbp performance · keywords · assign + pinterest boards · create-board + youtube playlists · create-playlist · set-default-playlist + youtube captions · transcript + bluesky languages · set-languages + tiktok creator-info · music · locations · video + instagram audio · publishing-limit · stories + linkedin mentions fopost account-groups list · get · create · rename · set-members · delete fopost posts list · get · create · publish · cancel · delete duplicate · preflight · deliveries diff --git a/go.mod b/go.mod index 92ec44f..cedb9aa 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/fopost/fopost-cli go 1.22 require ( - github.com/fopost/fopost-go v0.2.1-0.20260920161845-0d662e46400a + github.com/fopost/fopost-go v0.2.1-0.20260920181919-3c2f1c56810a github.com/spf13/cobra v1.10.2 golang.org/x/term v0.27.0 ) diff --git a/go.sum b/go.sum index afc4d4d..0d462e3 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,8 @@ github.com/fopost/fopost-go v0.2.1-0.20260920154339-1957686eb2db h1:ZRTF4nBDm+K4 github.com/fopost/fopost-go v0.2.1-0.20260920154339-1957686eb2db/go.mod h1:Qz+7UCYTBnDEeZqB3VMrePxmYLVR9rFvx3+QWcKND8Q= github.com/fopost/fopost-go v0.2.1-0.20260920161845-0d662e46400a h1:RG/LXPHro7c2J/e7e+sTxNV8C963xZ8vXBDo+4AvL/c= github.com/fopost/fopost-go v0.2.1-0.20260920161845-0d662e46400a/go.mod h1:Qz+7UCYTBnDEeZqB3VMrePxmYLVR9rFvx3+QWcKND8Q= +github.com/fopost/fopost-go v0.2.1-0.20260920181919-3c2f1c56810a h1:fnSVISo/7C+RU4yYk/9OaTqY1qvstY+0KV4rSBzUJ/w= +github.com/fopost/fopost-go v0.2.1-0.20260920181919-3c2f1c56810a/go.mod h1:Qz+7UCYTBnDEeZqB3VMrePxmYLVR9rFvx3+QWcKND8Q= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= diff --git a/internal/cmd/accounts.go b/internal/cmd/accounts.go index 45d96f0..9c7455a 100644 --- a/internal/cmd/accounts.go +++ b/internal/cmd/accounts.go @@ -31,6 +31,12 @@ func newAccountsCmd(state *State) *cobra.Command { newAccountsWebhookCmd(state), newAccountsDiscordCmd(state), newAccountsGBPCmd(state), + newAccountsPinterestCmd(state), + newAccountsYouTubeCmd(state), + newAccountsBlueskyCmd(state), + newAccountsTikTokCmd(state), + newAccountsInstagramCmd(state), + newAccountsLinkedInCmd(state), ) return cmd } diff --git a/internal/cmd/accounts_extras.go b/internal/cmd/accounts_extras.go new file mode 100644 index 0000000..6e25446 --- /dev/null +++ b/internal/cmd/accounts_extras.go @@ -0,0 +1,569 @@ +package cmd + +import ( + "errors" + "strconv" + + fopost "github.com/fopost/fopost-go" + "github.com/spf13/cobra" + + "github.com/fopost/fopost-cli/internal/output" +) + +// errMissingPlaylist keeps `set-default-playlist` from silently clearing the +// default when the id is simply forgotten; --clear is the explicit way. +var errMissingPlaylist = errors.New("a playlist id is required, or pass --clear to clear the default") + +// intLabel prints an optional count, or a dash when the API did not report one. +func intLabel(value *int) string { + if value == nil { + return "-" + } + return strconv.Itoa(*value) +} + +// Per-network extras on an account: the boards, playlists, captions, languages +// and searches each network offers beyond publishing. + +func newAccountsPinterestCmd(state *State) *cobra.Command { + cmd := &cobra.Command{ + Use: "pinterest", + Short: "List and create the boards a Pinterest account can pin to", + } + cmd.AddCommand(newPinterestBoardsCmd(state), newPinterestCreateBoardCmd(state)) + return cmd +} + +func newPinterestBoardsCmd(state *State) *cobra.Command { + return &cobra.Command{ + Use: "boards ", + Short: "List the boards this connection can pin to", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := state.Client() + if err != nil { + return err + } + boards, err := client.Accounts.ListPinterestBoards(cmd.Context(), args[0]) + if err != nil { + return err + } + printer := state.Printer() + return printer.Value(boards, func() { + rows := make([][]string, 0, len(boards)) + for _, b := range boards { + rows = append(rows, []string{b.ID, b.Name, output.Dash(deref(b.Privacy))}) + } + printer.Table([]string{"id", "name", "privacy"}, rows) + }) + }, + } +} + +func newPinterestCreateBoardCmd(state *State) *cobra.Command { + var description, privacy string + cmd := &cobra.Command{ + Use: "create-board ", + Short: "Create a board on the connected Pinterest account", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := state.Client() + if err != nil { + return err + } + board, err := client.Accounts.CreatePinterestBoard(cmd.Context(), args[0], + &fopost.CreatePinterestBoardRequest{ + Name: args[1], Description: description, Privacy: privacy, + }) + if err != nil { + return err + } + printer := state.Printer() + return printer.Value(board, func() { + printer.Table([]string{"id", "name", "privacy"}, + [][]string{{board.ID, board.Name, output.Dash(deref(board.Privacy))}}) + }) + }, + } + cmd.Flags().StringVar(&description, "description", "", "Board description") + cmd.Flags().StringVar(&privacy, "privacy", "", "PUBLIC, PROTECTED or SECRET") + return cmd +} + +func newAccountsYouTubeCmd(state *State) *cobra.Command { + cmd := &cobra.Command{ + Use: "youtube", + Short: "Manage a YouTube channel's playlists and caption tracks", + } + cmd.AddCommand( + newYouTubePlaylistsCmd(state), + newYouTubeCreatePlaylistCmd(state), + newYouTubeSetDefaultPlaylistCmd(state), + newYouTubeCaptionsCmd(state), + newYouTubeTranscriptCmd(state), + ) + return cmd +} + +func newYouTubePlaylistsCmd(state *State) *cobra.Command { + return &cobra.Command{ + Use: "playlists ", + Short: "List the channel's playlists, with the stored default marked", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := state.Client() + if err != nil { + return err + } + playlists, err := client.Accounts.ListYouTubePlaylists(cmd.Context(), args[0]) + if err != nil { + return err + } + printer := state.Printer() + return printer.Value(playlists, func() { + rows := make([][]string, 0, len(playlists)) + for _, p := range playlists { + rows = append(rows, []string{p.ID, p.Title, output.Dash(deref(p.Privacy)), boolLabel(p.IsDefault, "yes", "no")}) + } + printer.Table([]string{"id", "title", "privacy", "default"}, rows) + }) + }, + } +} + +func newYouTubeCreatePlaylistCmd(state *State) *cobra.Command { + var description, privacy string + cmd := &cobra.Command{ + Use: "create-playlist ", + Short: "Create a playlist on the connected channel", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := state.Client() + if err != nil { + return err + } + playlist, err := client.Accounts.CreateYouTubePlaylist(cmd.Context(), args[0], + &fopost.CreateYouTubePlaylistRequest{ + Title: args[1], Description: description, Privacy: privacy, + }) + if err != nil { + return err + } + printer := state.Printer() + return printer.Value(playlist, func() { + printer.Table([]string{"id", "title"}, [][]string{{playlist.ID, playlist.Title}}) + }) + }, + } + cmd.Flags().StringVar(&description, "description", "", "Playlist description") + cmd.Flags().StringVar(&privacy, "privacy", "", "public, unlisted or private") + return cmd +} + +func newYouTubeSetDefaultPlaylistCmd(state *State) *cobra.Command { + var clear bool + cmd := &cobra.Command{ + Use: "set-default-playlist <account-id> [playlist-id]", + Short: "Set the playlist a new video joins when a post does not pick one", + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := state.Client() + if err != nil { + return err + } + playlistID := "" + if !clear { + if len(args) < 2 { + return errMissingPlaylist + } + playlistID = args[1] + } + result, err := client.Accounts.SetDefaultYouTubePlaylist(cmd.Context(), args[0], playlistID) + if err != nil { + return err + } + printer := state.Printer() + return printer.Value(map[string]string{"playlist_id": result}, func() { + printer.Table([]string{"playlist id"}, [][]string{{output.Dash(result)}}) + }) + }, + } + cmd.Flags().BoolVar(&clear, "clear", false, "Clear the stored default instead of setting one") + return cmd +} + +func newYouTubeCaptionsCmd(state *State) *cobra.Command { + return &cobra.Command{ + Use: "captions <account-id> <video-id>", + Short: "List the caption tracks on one of the channel's videos", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := state.Client() + if err != nil { + return err + } + tracks, err := client.Accounts.ListYouTubeCaptions(cmd.Context(), args[0], args[1]) + if err != nil { + return err + } + printer := state.Printer() + return printer.Value(tracks, func() { + rows := make([][]string, 0, len(tracks)) + for _, t := range tracks { + rows = append(rows, []string{t.ID, t.Language, t.Name, boolLabel(t.IsDraft, "yes", "no")}) + } + printer.Table([]string{"id", "language", "name", "draft"}, rows) + }) + }, + } +} + +func newYouTubeTranscriptCmd(state *State) *cobra.Command { + return &cobra.Command{ + Use: "transcript <account-id> <caption-id>", + Short: "Print one caption track as text", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := state.Client() + if err != nil { + return err + } + transcript, err := client.Accounts.ReadYouTubeTranscript(cmd.Context(), args[0], args[1]) + if err != nil { + return err + } + printer := state.Printer() + return printer.Value(transcript, func() { + printer.Line("%s", transcript.Transcript) + }) + }, + } +} + +func newAccountsBlueskyCmd(state *State) *cobra.Command { + cmd := &cobra.Command{ + Use: "bluesky", + Short: "Read and set a Bluesky connection's default post languages", + } + cmd.AddCommand(newBlueskyLanguagesCmd(state), newBlueskySetLanguagesCmd(state)) + return cmd +} + +func newBlueskyLanguagesCmd(state *State) *cobra.Command { + return &cobra.Command{ + Use: "languages <account-id>", + Short: "Show the default post languages", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := state.Client() + if err != nil { + return err + } + result, err := client.Accounts.GetBlueskyLanguages(cmd.Context(), args[0]) + if err != nil { + return err + } + return printLanguages(state, result) + }, + } +} + +func newBlueskySetLanguagesCmd(state *State) *cobra.Command { + return &cobra.Command{ + Use: "set-languages <account-id> [tag...]", + Short: "Set the default post languages; no tags clears them", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := state.Client() + if err != nil { + return err + } + result, err := client.Accounts.SetBlueskyLanguages(cmd.Context(), args[0], args[1:]) + if err != nil { + return err + } + return printLanguages(state, result) + }, + } +} + +func printLanguages(state *State, result *fopost.BlueskyLanguages) error { + printer := state.Printer() + return printer.Value(result, func() { + rows := make([][]string, 0, len(result.Languages)) + for _, tag := range result.Languages { + rows = append(rows, []string{tag}) + } + printer.Table([]string{"language"}, rows) + }) +} + +func newAccountsTikTokCmd(state *State) *cobra.Command { + cmd := &cobra.Command{ + Use: "tiktok", + Short: "Read TikTok creator limits and search its music, places and videos", + } + cmd.AddCommand( + newTikTokCreatorInfoCmd(state), + newTikTokMusicCmd(state), + newTikTokLocationsCmd(state), + newTikTokVideoCmd(state), + ) + return cmd +} + +func newTikTokCreatorInfoCmd(state *State) *cobra.Command { + return &cobra.Command{ + Use: "creator-info <account-id>", + Short: "Show the switches TikTok enforces at publish time", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := state.Client() + if err != nil { + return err + } + info, err := client.Accounts.GetTikTokCreatorInfo(cmd.Context(), args[0]) + if err != nil { + return err + } + printer := state.Printer() + return printer.Value(info, func() { + duration := "-" + if info.MaxVideoPostDurationSec != nil { + duration = strconv.Itoa(*info.MaxVideoPostDurationSec) + "s" + } + printer.Table( + []string{"comments", "duet", "stitch", "max video"}, + [][]string{{ + boolLabel(info.CommentDisabled, "off", "on"), + boolLabel(info.DuetDisabled, "off", "on"), + boolLabel(info.StitchDisabled, "off", "on"), + duration, + }}, + ) + }) + }, + } +} + +func newTikTokMusicCmd(state *State) *cobra.Command { + var limit int + cmd := &cobra.Command{ + Use: "music <account-id> <query>", + Short: "Search TikTok's Commercial Music Library", + Long: "Searches TikTok's Commercial Music Library. Needs the Marketing API product on\nthe TikTok app; without it the API answers 403 naming what to enable.", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := state.Client() + if err != nil { + return err + } + tracks, err := client.Accounts.SearchTikTokMusic(cmd.Context(), args[0], + fopost.TikTokSearchOptions{Query: args[1], Limit: limit}) + if err != nil { + return err + } + printer := state.Printer() + return printer.Value(tracks, func() { + rows := make([][]string, 0, len(tracks)) + for _, t := range tracks { + rows = append(rows, []string{t.ID, t.Title, output.Dash(deref(t.Author))}) + } + printer.Table([]string{"id", "title", "author"}, rows) + }) + }, + } + cmd.Flags().IntVar(&limit, "limit", 0, "How many to return, 1 to 50") + return cmd +} + +func newTikTokLocationsCmd(state *State) *cobra.Command { + var limit int + cmd := &cobra.Command{ + Use: "locations <account-id> <query>", + Short: "Search the places a TikTok post can be tagged with", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := state.Client() + if err != nil { + return err + } + places, err := client.Accounts.SearchTikTokLocations(cmd.Context(), args[0], + fopost.TikTokSearchOptions{Query: args[1], Limit: limit}) + if err != nil { + return err + } + printer := state.Printer() + return printer.Value(places, func() { + rows := make([][]string, 0, len(places)) + for _, p := range places { + rows = append(rows, []string{p.ID, p.Name, output.Dash(deref(p.City))}) + } + printer.Table([]string{"id", "name", "city"}, rows) + }) + }, + } + cmd.Flags().IntVar(&limit, "limit", 0, "How many to return, 1 to 50") + return cmd +} + +func newTikTokVideoCmd(state *State) *cobra.Command { + return &cobra.Command{ + Use: "video <account-id> <share-url>", + Short: "Resolve a share link to one of this account's own videos", + Long: "Resolves a share link for repurposing. TikTok serves no raw media file, so the\ndownload url is the share address, which is what a repurpose run reads.", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := state.Client() + if err != nil { + return err + } + video, err := client.Accounts.LookupTikTokVideo(cmd.Context(), args[0], args[1]) + if err != nil { + return err + } + printer := state.Printer() + return printer.Value(video, func() { + printer.Table([]string{"video id", "title", "download url"}, + [][]string{{video.VideoID, output.Dash(deref(video.Title)), output.Dash(deref(video.DownloadURL))}}) + }) + }, + } +} + +func newAccountsInstagramCmd(state *State) *cobra.Command { + cmd := &cobra.Command{ + Use: "instagram", + Short: "Search Reel audio and read the publishing limit and live stories", + } + cmd.AddCommand( + newInstagramAudioCmd(state), + newInstagramPublishingLimitCmd(state), + newInstagramStoriesCmd(state), + ) + return cmd +} + +func newInstagramAudioCmd(state *State) *cobra.Command { + var audioType string + cmd := &cobra.Command{ + Use: "audio <account-id> [query]", + Short: "Search the tracks a Reel can carry", + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := state.Client() + if err != nil { + return err + } + opts := &fopost.InstagramAudioSearchOptions{AudioType: audioType} + if len(args) > 1 { + opts.Query = args[1] + } + tracks, err := client.Accounts.SearchInstagramAudio(cmd.Context(), args[0], opts) + if err != nil { + return err + } + printer := state.Printer() + return printer.Value(tracks, func() { + rows := make([][]string, 0, len(tracks)) + for _, t := range tracks { + rows = append(rows, []string{t.ID, output.Dash(deref(t.Title)), output.Dash(deref(t.Artist))}) + } + printer.Table([]string{"id", "title", "artist"}, rows) + }) + }, + } + cmd.Flags().StringVar(&audioType, "audio-type", "", "music or original_sound") + return cmd +} + +func newInstagramPublishingLimitCmd(state *State) *cobra.Command { + return &cobra.Command{ + Use: "publishing-limit <account-id>", + Short: "Show how many posts are left before Instagram refuses the next one", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := state.Client() + if err != nil { + return err + } + limit, err := client.Accounts.GetInstagramPublishingLimit(cmd.Context(), args[0]) + if err != nil { + return err + } + printer := state.Printer() + return printer.Value(limit, func() { + printer.Table([]string{"used", "total", "remaining"}, [][]string{{ + strconv.Itoa(limit.QuotaUsage), + intLabel(limit.QuotaTotal), + intLabel(limit.Remaining), + }}) + }) + }, + } +} + +func newInstagramStoriesCmd(state *State) *cobra.Command { + var insights bool + cmd := &cobra.Command{ + Use: "stories <account-id>", + Short: "List the stories still inside their 24 hours", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := state.Client() + if err != nil { + return err + } + stories, err := client.Accounts.ListInstagramStories(cmd.Context(), args[0], insights) + if err != nil { + return err + } + printer := state.Printer() + return printer.Value(stories, func() { + rows := make([][]string, 0, len(stories)) + for _, s := range stories { + rows = append(rows, []string{s.ID, output.Dash(deref(s.MediaType)), output.Dash(deref(s.Permalink))}) + } + printer.Table([]string{"id", "type", "permalink"}, rows) + }) + }, + } + cmd.Flags().BoolVar(&insights, "insights", false, "Fetch each story's insights, at one extra call per story") + return cmd +} + +func newAccountsLinkedInCmd(state *State) *cobra.Command { + cmd := &cobra.Command{ + Use: "linkedin", + Short: "Find the organizations a LinkedIn post can mention", + } + cmd.AddCommand(newLinkedInMentionsCmd(state)) + return cmd +} + +func newLinkedInMentionsCmd(state *State) *cobra.Command { + return &cobra.Command{ + Use: "mentions <account-id> <query>", + Short: "Search organizations and print the annotation to paste", + Long: "People are not searchable: LinkedIn has no public person search, so a member\nmention needs a URN you already hold.", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := state.Client() + if err != nil { + return err + } + mentions, err := client.Accounts.SearchLinkedInMentions(cmd.Context(), args[0], args[1]) + if err != nil { + return err + } + printer := state.Printer() + return printer.Value(mentions, func() { + rows := make([][]string, 0, len(mentions)) + for _, m := range mentions { + rows = append(rows, []string{m.Name, m.URN, m.Annotation}) + } + printer.Table([]string{"name", "urn", "annotation"}, rows) + }) + }, + } +} diff --git a/internal/cmd/cli_test.go b/internal/cmd/cli_test.go index b64b486..094dc86 100644 --- a/internal/cmd/cli_test.go +++ b/internal/cmd/cli_test.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "os" "path/filepath" "strings" @@ -46,6 +47,7 @@ type fakeAPI struct { type recordedRequest struct { Method string Path string + Query url.Values Body map[string]any } @@ -61,7 +63,9 @@ func newFakeAPI(t *testing.T, handler func(w http.ResponseWriter, r *http.Reques } <-api.mu api.Keys = append(api.Keys, r.Header.Get("X-API-Key")) - api.Requests = append(api.Requests, recordedRequest{Method: r.Method, Path: r.URL.Path, Body: body}) + api.Requests = append(api.Requests, recordedRequest{ + Method: r.Method, Path: r.URL.Path, Query: r.URL.Query(), Body: body, + }) api.mu <- struct{}{} w.Header().Set("Content-Type", "application/json") @@ -961,3 +965,69 @@ func TestAccountsDiscordAssignRoleSendsAPut(t *testing.T) { } api.find(t, http.MethodPut, "/accounts/acc_1/discord/roles/r1/members/u7") } + +func TestAccountsPinterestCreateBoardSendsOnlyWhatWasGiven(t *testing.T) { + isolate(t) + api := newFakeAPI(t, func(w http.ResponseWriter, _ *http.Request) { + json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"id": "b1", "name": "Recipes"}}) + }) + if err := config.Save(&config.File{APIKey: "fp_k", BaseURL: api.URL}); err != nil { + t.Fatal(err) + } + + _, stderr, code := run(t, "", "accounts", "pinterest", "create-board", "acc_1", "Recipes", "--quiet") + if code != ExitOK { + t.Fatalf("exit = %d: %s", code, stderr) + } + sent := api.find(t, http.MethodPost, "/accounts/acc_1/pinterest/boards") + if sent.Body["name"] != "Recipes" { + t.Fatalf("body = %v", sent.Body) + } + if _, ok := sent.Body["privacy"]; ok { + t.Fatalf("privacy should be absent: %v", sent.Body) + } +} + +func TestAccountsTikTokMusicPassesTheQueryThrough(t *testing.T) { + isolate(t) + api := newFakeAPI(t, func(w http.ResponseWriter, _ *http.Request) { + json.NewEncoder(w).Encode(map[string]any{"data": []any{map[string]any{"id": "m1", "title": "Sunrise", "author": "Kite"}}}) + }) + if err := config.Save(&config.File{APIKey: "fp_k", BaseURL: api.URL}); err != nil { + t.Fatal(err) + } + + stdout, stderr, code := run(t, "", "accounts", "tiktok", "music", "acc_1", "sunrise", "--limit", "5", "--json") + if code != ExitOK { + t.Fatalf("exit = %d: %s", code, stderr) + } + sent := api.find(t, http.MethodGet, "/accounts/acc_1/tiktok/music") + if sent.Query.Get("q") != "sunrise" || sent.Query.Get("limit") != "5" { + t.Fatalf("query = %v", sent.Query) + } + if !strings.Contains(stdout, `"id": "m1"`) { + t.Fatalf("stdout = %q", stdout) + } +} + +func TestAccountsYouTubeSetDefaultPlaylistRefusesAMissingID(t *testing.T) { + isolate(t) + api := newFakeAPI(t, func(w http.ResponseWriter, _ *http.Request) { + json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"playlist_id": nil}}) + }) + if err := config.Save(&config.File{APIKey: "fp_k", BaseURL: api.URL}); err != nil { + t.Fatal(err) + } + + if _, _, code := run(t, "", "accounts", "youtube", "set-default-playlist", "acc_1"); code == ExitOK { + t.Fatal("a missing playlist id should not clear the default") + } + + if _, stderr, code := run(t, "", "accounts", "youtube", "set-default-playlist", "acc_1", "--clear", "--quiet"); code != ExitOK { + t.Fatalf("exit = %d: %s", code, stderr) + } + sent := api.find(t, http.MethodPut, "/accounts/acc_1/youtube/playlists/default") + if sent.Body["playlist_id"] != nil { + t.Fatalf("body = %v", sent.Body) + } +}