Compare commits

..

No commits in common. "310ecb0b39b1e60c267a0e487e79e4ffadd55530" and "1361482dd4eeb6658c861d751208329fabad861b" have entirely different histories.

13 changed files with 139 additions and 489 deletions

64
cmd/enbas/instance.go Normal file
View file

@ -0,0 +1,64 @@
package main
import (
"flag"
"fmt"
"codeflow.dananglin.me.uk/apollo/enbas/internal/client"
)
var instanceDetailsFormat = `INSTANCE:
%s - %s
DOMAIN:
%s
VERSION:
Running GoToSocial %s
CONTACT:
name: %s
username: %s
email: %s
`
type instanceCommand struct {
*flag.FlagSet
summary string
}
func newInstanceCommand(name, summary string) *instanceCommand {
command := instanceCommand{
FlagSet: flag.NewFlagSet(name, flag.ExitOnError),
summary: summary,
}
command.Usage = commandUsageFunc(command.Name(), command.summary, command.FlagSet)
return &command
}
func (c *instanceCommand) Execute() error {
gtsClient, err := client.NewClientFromConfig()
if err != nil {
return fmt.Errorf("unable to create the GoToSocial client; %w", err)
}
instance, err := gtsClient.GetInstance()
if err != nil {
return fmt.Errorf("unable to retrieve the instance details; %w", err)
}
fmt.Printf(
instanceDetailsFormat,
instance.Title,
instance.Description,
instance.Domain,
instance.Version,
instance.Contact.Account.DisplayName,
instance.Contact.Account.Username,
instance.Contact.Email,
)
return nil
}

View file

@ -17,13 +17,12 @@ import (
type loginCommand struct { type loginCommand struct {
*flag.FlagSet *flag.FlagSet
summary string
instance string instance string
} }
var ( var errEmptyAccessToken = errors.New("received an empty access token")
errEmptyAccessToken = errors.New("received an empty access token") var errInstanceNotSet = errors.New("the instance flag is not set")
errInstanceNotSet = errors.New("the instance flag is not set")
)
var consentMessageFormat = ` var consentMessageFormat = `
You'll need to sign into your GoToSocial's consent page in order to generate the out-of-band token to continue with You'll need to sign into your GoToSocial's consent page in order to generate the out-of-band token to continue with
@ -38,13 +37,13 @@ Once you have the code please copy and paste it below.
func newLoginCommand(name, summary string) *loginCommand { func newLoginCommand(name, summary string) *loginCommand {
command := loginCommand{ command := loginCommand{
FlagSet: flag.NewFlagSet(name, flag.ExitOnError), FlagSet: flag.NewFlagSet(name, flag.ExitOnError),
instance: "", summary: summary,
} }
command.StringVar(&command.instance, "instance", "", "specify the instance that you want to login to.") command.StringVar(&command.instance, "instance", "", "specify the instance that you want to login to.")
command.Usage = commandUsageFunc(name, summary, command.FlagSet) command.Usage = commandUsageFunc(command.Name(), command.summary, command.FlagSet)
return &command return &command
} }

View file

@ -8,32 +8,23 @@ import (
"strings" "strings"
) )
const (
login string = "login"
version string = "version"
instance string = "instance"
)
type Executor interface { type Executor interface {
Name() string
Parse([]string) error Parse([]string) error
Name() string
Execute() error Execute() error
} }
func main() { func main() {
if err := run(); err != nil {
fmt.Printf("ERROR: %v.\n", err)
os.Exit(1)
}
}
func run() error {
const (
login string = "login"
version string = "version"
show string = "show"
switchAccount string = "switch"
)
summaries := map[string]string{ summaries := map[string]string{
login: "login to an account on GoToSocial", login: "login to an account on GoToSocial",
version: "print the application's version and build information", version: "print the application's version and build information",
show: "print details about a specified resource", instance: "print the instance information",
switchAccount: "switch to an account",
} }
flag.Usage = enbasUsageFunc(summaries) flag.Usage = enbasUsageFunc(summaries)
@ -42,8 +33,7 @@ func run() error {
if flag.NArg() < 1 { if flag.NArg() < 1 {
flag.Usage() flag.Usage()
os.Exit(0)
return nil
} }
subcommand := flag.Arg(0) subcommand := flag.Arg(0)
@ -56,46 +46,33 @@ func run() error {
executor = newLoginCommand(login, summaries[login]) executor = newLoginCommand(login, summaries[login])
case version: case version:
executor = newVersionCommand(version, summaries[version]) executor = newVersionCommand(version, summaries[version])
case show: case instance:
executor = newShowCommand(show, summaries[show]) executor = newInstanceCommand(instance, summaries[instance])
case switchAccount:
executor = newSwitchCommand(switchAccount, summaries[switchAccount])
default: default:
fmt.Printf("ERROR: Unknown subcommand: %s\n", subcommand)
flag.Usage() flag.Usage()
return fmt.Errorf("unknown subcommand %q", subcommand) os.Exit(1)
} }
if err := executor.Parse(args); err != nil { if err := executor.Parse(args); err != nil {
return fmt.Errorf("unable to parse the command line flags; %w", err) fmt.Printf("ERROR: Unable to parse the command line flags; %v.\n", err)
os.Exit(1)
} }
if err := executor.Execute(); err != nil { if err := executor.Execute(); err != nil {
return fmt.Errorf("received an error after executing %q; %w", executor.Name(), err) fmt.Printf("ERROR: Unable to run %q; %v.\n", executor.Name(), err)
os.Exit(1)
} }
return nil
} }
func commandUsageFunc(name, summary string, flagset *flag.FlagSet) func() { func commandUsageFunc(name, summary string, flagset *flag.FlagSet) func() {
return func() { return func() {
var builder strings.Builder var builder strings.Builder
fmt.Fprintf( fmt.Fprintf(&builder, "SUMMARY:\n %s - %s\n\nUSAGE:\n enbas %s [flags]\n\nFLAGS:", name, summary, name)
&builder,
"SUMMARY:\n %s - %s\n\nUSAGE:\n enbas %s [flags]\n\nFLAGS:",
name,
summary,
name,
)
flagset.VisitAll(func(f *flag.Flag) { flagset.VisitAll(func(f *flag.Flag) {
fmt.Fprintf( fmt.Fprintf(&builder, "\n -%s, --%s\n %s", f.Name, f.Name, f.Usage)
&builder,
"\n -%s, --%s\n %s",
f.Name,
f.Name,
f.Usage,
)
}) })
builder.WriteString("\n") builder.WriteString("\n")
@ -120,21 +97,21 @@ func enbasUsageFunc(summaries map[string]string) func() {
return func() { return func() {
var builder strings.Builder var builder strings.Builder
builder.WriteString("SUMMARY:\n enbas - A GoToSocial client for the terminal.\n\n") builder.WriteString("SUMMARY:\n enbas - A GoToSocial client for the terminal.\n\n")
if binaryVersion != "" { //if binaryVersion != "" {
builder.WriteString("VERSION:\n " + binaryVersion + "\n\n") // builder.WriteString("VERSION:\n " + binaryVersion + "\n\n")
} //}
builder.WriteString("USAGE:\n enbas [flags]\n enbas [command]\n\nCOMMANDS:") builder.WriteString("USAGE:\n enbas [flags]\n enbas [command]\n\nCOMMANDS:")
for _, cmd := range cmds { for _, cmd := range cmds {
fmt.Fprintf(&builder, "\n %s\t%s", cmd, summaries[cmd]) fmt.Fprintf(&builder, "\n %s\t%s", cmd, summaries[cmd])
} }
builder.WriteString("\n\nFLAGS:\n -help, --help\n print the help message\n") builder.WriteString("\n\nFLAGS:\n -help, --help\n print the help message\n")
flag.VisitAll(func(f *flag.Flag) { flag.VisitAll(func(f *flag.Flag) {
fmt.Fprintf(&builder, "\n -%s, --%s\n %s\n", f.Name, f.Name, f.Usage) fmt.Fprintf(&builder, "\n -%s, --%s\n %s\n", f.Name, f.Name, f.Usage)
}) })
builder.WriteString("\nUse \"enbas [command] --help\" for more information about a command.\n") builder.WriteString("\nUse \"enbas [command] --help\" for more information about a command.\n")

View file

@ -1,155 +0,0 @@
package main
import (
"errors"
"flag"
"fmt"
"codeflow.dananglin.me.uk/apollo/enbas/internal/client"
"codeflow.dananglin.me.uk/apollo/enbas/internal/config"
)
var instanceDetailsFormat = `INSTANCE:
%s - %s
DOMAIN:
%s
VERSION:
Running GoToSocial %s
CONTACT:
name: %s
username: %s
email: %s
`
var accountDetailsFormat = `
%s (@%s)
ACCOUNT ID:
%s
CREATED AT:
%s
STATS:
Followers: %d
Following: %d
Statuses: %d
BIOGRAPHY:
%s
METADATA: %s
ACCOUNT URL:
%s
`
type showCommand struct {
*flag.FlagSet
targetType string
account string
myAccount bool
}
func newShowCommand(name, summary string) *showCommand {
command := showCommand{
FlagSet: flag.NewFlagSet(name, flag.ExitOnError),
targetType: "",
myAccount: false,
}
command.StringVar(&command.targetType, "type", "", "specify the type of resource to display")
command.StringVar(&command.account, "account", "", "specify the account URI to lookup")
command.BoolVar(&command.myAccount, "my-account", false, "set to true to lookup your account")
command.Usage = commandUsageFunc(name, summary, command.FlagSet)
return &command
}
func (c *showCommand) Execute() error {
gtsClient, err := client.NewClientFromConfig()
if err != nil {
return fmt.Errorf("unable to create the GoToSocial client; %w", err)
}
funcMap := map[string]func(*client.Client) error{
"instance": c.showInstance,
"account": c.showAccount,
}
doFunc, ok := funcMap[c.targetType]
if !ok {
return fmt.Errorf("unsupported type %q", c.targetType)
}
return doFunc(gtsClient)
}
func (c *showCommand) showInstance(gts *client.Client) error {
instance, err := gts.GetInstance()
if err != nil {
return fmt.Errorf("unable to retrieve the instance details; %w", err)
}
fmt.Printf(
instanceDetailsFormat,
instance.Title,
instance.Description,
instance.Domain,
instance.Version,
instance.Contact.Account.DisplayName,
instance.Contact.Account.Username,
instance.Contact.Email,
)
return nil
}
func (c *showCommand) showAccount(gts *client.Client) error {
var accountURI string
if c.myAccount {
authConfig, err := config.NewAuthenticationConfigFromFile()
if err != nil {
return fmt.Errorf("unable to retrieve the authentication configuration; %w", err)
}
accountURI = authConfig.CurrentAccount
} else {
if c.account == "" {
return errors.New("the account flag is not set")
}
accountURI = c.account
}
account, err := gts.GetAccount(accountURI)
if err != nil {
return fmt.Errorf("unable to retrieve the account details; %w", err)
}
metadata := ""
for _, field := range account.Fields {
metadata += fmt.Sprintf("\n %s: %s", field.Name, field.Value)
}
fmt.Printf(
accountDetailsFormat,
account.DisplayName,
account.Username,
account.ID,
account.CreatedAt,
account.FollowersCount,
account.FollowingCount,
account.StatusCount,
account.Note,
metadata,
account.URL,
)
return nil
}

View file

@ -1,35 +0,0 @@
package main
import (
"flag"
"fmt"
"codeflow.dananglin.me.uk/apollo/enbas/internal/config"
)
type switchCommand struct {
*flag.FlagSet
toAccount string
}
func newSwitchCommand(name, summary string) *switchCommand {
command := switchCommand{
FlagSet: flag.NewFlagSet(name, flag.ExitOnError),
toAccount: "",
}
command.StringVar(&command.toAccount, "to-account", "", "the account to switch to")
command.Usage = commandUsageFunc(name, summary, command.FlagSet)
return &command
}
func (c *switchCommand) Execute() error {
if err := config.UpdateCurrentAccount(c.toAccount); err != nil {
return fmt.Errorf("unable to switch accounts; %w", err)
}
fmt.Printf("The current account is now set to %q.\n", c.toAccount)
return nil
}

View file

@ -16,6 +16,7 @@ var (
type versionCommand struct { type versionCommand struct {
*flag.FlagSet *flag.FlagSet
summary string
showFullVersion bool showFullVersion bool
binaryVersion string binaryVersion string
buildTime string buildTime string
@ -30,12 +31,13 @@ func newVersionCommand(name, summary string) *versionCommand {
buildTime: buildTime, buildTime: buildTime,
goVersion: goVersion, goVersion: goVersion,
gitCommit: gitCommit, gitCommit: gitCommit,
summary: summary,
showFullVersion: false, showFullVersion: false,
} }
command.BoolVar(&command.showFullVersion, "full", false, "prints the full build information") command.BoolVar(&command.showFullVersion, "full", false, "prints the full build information")
command.Usage = commandUsageFunc(name, summary, command.FlagSet) command.Usage = commandUsageFunc(command.Name(), command.summary, command.FlagSet)
return &command return &command
} }

View file

@ -85,27 +85,6 @@ func (g *Client) GetInstance() (model.InstanceV2, error) {
return instance, nil return instance, nil
} }
func (g *Client) GetAccount(accountURI string) (model.Account, error) {
path := "/api/v1/accounts/lookup"
url := g.Authentication.Instance + path + "?acct=" + accountURI
ctx, cancel := context.WithTimeout(context.Background(), g.Timeout)
defer cancel()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return model.Account{}, fmt.Errorf("unable to create the HTTP request, %w", err)
}
var account model.Account
if err := g.sendRequest(request, &account); err != nil {
return model.Account{}, fmt.Errorf("received an error after sending the request to get the account information; %w", err)
}
return account, nil
}
func (g *Client) sendRequest(request *http.Request, object any) error { func (g *Client) sendRequest(request *http.Request, object any) error {
request.Header.Set("Content-Type", "application/json; charset=utf-8") request.Header.Set("Content-Type", "application/json; charset=utf-8")
request.Header.Set("Accept", "application/json; charset=utf-8") request.Header.Set("Accept", "application/json; charset=utf-8")

View file

@ -8,7 +8,6 @@ import (
"net/http" "net/http"
"codeflow.dananglin.me.uk/apollo/enbas/internal" "codeflow.dananglin.me.uk/apollo/enbas/internal"
"codeflow.dananglin.me.uk/apollo/enbas/internal/model"
) )
type RegisterRequest struct { type RegisterRequest struct {
@ -18,6 +17,16 @@ type RegisterRequest struct {
Website string `json:"website"` Website string `json:"website"`
} }
type RegisterResponse struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
ID string `json:"id"`
Name string `json:"name"`
RedirectUri string `json:"redirect_uri"`
VapidKey string `json:"vapid_key"`
Website string `json:"website"`
}
func (g *Client) Register() error { func (g *Client) Register() error {
params := RegisterRequest{ params := RegisterRequest{
ClientName: internal.ApplicationName, ClientName: internal.ApplicationName,
@ -44,14 +53,14 @@ func (g *Client) Register() error {
return fmt.Errorf("unable to create the HTTP request; %w", err) return fmt.Errorf("unable to create the HTTP request; %w", err)
} }
var app model.Application var registerResponse RegisterResponse
if err := g.sendRequest(request, &app); err != nil { if err := g.sendRequest(request, &registerResponse); err != nil {
return fmt.Errorf("received an error after sending the registration request; %w", err) return fmt.Errorf("received an error after sending the registration request; %w", err)
} }
g.Authentication.ClientID = app.ClientID g.Authentication.ClientID = registerResponse.ClientID
g.Authentication.ClientSecret = app.ClientSecret g.Authentication.ClientSecret = registerResponse.ClientSecret
return nil return nil
} }

View file

@ -28,7 +28,7 @@ func SaveAuthentication(username string, authentication Authentication) (string,
return "", fmt.Errorf("unable to ensure the configuration directory; %w", err) return "", fmt.Errorf("unable to ensure the configuration directory; %w", err)
} }
var authConfig AuthenticationConfig var config AuthenticationConfig
filepath := authenticationConfigFile() filepath := authenticationConfigFile()
@ -37,9 +37,9 @@ func SaveAuthentication(username string, authentication Authentication) (string,
return "", fmt.Errorf("unknown error received when running stat on %s; %w", filepath, err) return "", fmt.Errorf("unknown error received when running stat on %s; %w", filepath, err)
} }
authConfig.Authentications = make(map[string]Authentication) config.Authentications = make(map[string]Authentication)
} else { } else {
authConfig, err = NewAuthenticationConfigFromFile() config, err = NewAuthenticationConfigFromFile()
if err != nil { if err != nil {
return "", fmt.Errorf("unable to retrieve the existing authentication configuration; %w", err) return "", fmt.Errorf("unable to retrieve the existing authentication configuration; %w", err)
} }
@ -55,12 +55,17 @@ func SaveAuthentication(username string, authentication Authentication) (string,
authenticationName := username + "@" + instance authenticationName := username + "@" + instance
authConfig.CurrentAccount = authenticationName config.CurrentAccount = authenticationName
authConfig.Authentications[authenticationName] = authentication config.Authentications[authenticationName] = authentication
if err := saveAuthenticationFile(authConfig); err != nil { file, err := os.Create(authenticationConfigFile())
return "", fmt.Errorf("unable to save the authentication configuration to file; %w", err) if err != nil {
return "", fmt.Errorf("unable to open the config file; %w", err)
}
if err := json.NewEncoder(file).Encode(config); err != nil {
return "", fmt.Errorf("unable to save the JSON data to the authentication config file; %w", err)
} }
return authenticationName, nil return authenticationName, nil
@ -75,32 +80,13 @@ func NewAuthenticationConfigFromFile() (AuthenticationConfig, error) {
} }
defer file.Close() defer file.Close()
var authConfig AuthenticationConfig var config AuthenticationConfig
if err := json.NewDecoder(file).Decode(&authConfig); err != nil { if err := json.NewDecoder(file).Decode(&config); err != nil {
return AuthenticationConfig{}, fmt.Errorf("unable to decode the JSON data; %w", err) return AuthenticationConfig{}, fmt.Errorf("unable to decode the JSON data; %w", err)
} }
return authConfig, nil return config, nil
}
func UpdateCurrentAccount(account string) error {
authConfig, err := NewAuthenticationConfigFromFile()
if err != nil {
return fmt.Errorf("unable to retrieve the existing authentication configuration; %w", err)
}
if _, ok := authConfig.Authentications[account]; !ok {
return fmt.Errorf("account %s is not found", account)
}
authConfig.CurrentAccount = account
if err := saveAuthenticationFile(authConfig); err != nil {
return fmt.Errorf("unable to save the authentication configuration to file; %w", err)
}
return nil
} }
func authenticationConfigFile() string { func authenticationConfigFile() string {
@ -131,21 +117,3 @@ func ensureConfigDir() error {
return nil return nil
} }
func saveAuthenticationFile(authConfig AuthenticationConfig) error {
file, err := os.Create(authenticationConfigFile())
if err != nil {
return fmt.Errorf("unable to open the config file; %w", err)
}
defer file.Close()
encoder := json.NewEncoder(file)
encoder.SetIndent("", " ")
if err := encoder.Encode(authConfig); err != nil {
return fmt.Errorf("unable to save the JSON data to the authentication config file; %w", err)
}
return nil
}

View file

@ -29,6 +29,14 @@ type Account struct {
Username string `json:"username"` Username string `json:"username"`
} }
type Emoji struct {
Category string `json:"category"`
Shortcode string `json:"shortcode"`
StaticURL string `json:"static_url"`
URL string `json:"url"`
VisibleInPicker bool `json:"visible_in_picker"`
}
type AccountRole struct { type AccountRole struct {
Name string `json:"name"` Name string `json:"name"`
} }

View file

@ -1,11 +0,0 @@
package model
type Application struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
ID string `json:"id"`
Name string `json:"name"`
RedirectUri string `json:"redirect_uri"`
VapidKey string `json:"vapid_key"`
Website string `json:"website"`
}

View file

@ -1,9 +0,0 @@
package model
type Emoji struct {
Category string `json:"category"`
Shortcode string `json:"shortcode"`
StaticURL string `json:"static_url"`
URL string `json:"url"`
VisibleInPicker bool `json:"visible_in_picker"`
}

View file

@ -1,146 +0,0 @@
package model
type Status struct {
Account Account `json:"account"`
Application Application `json:"application"`
Bookmarked bool `json:"bookmarked"`
Card Card `json:"card"`
Content string `json:"content"`
CreatedAt string `json:"created_at"`
Emojis []Emoji `json:"emojis"`
Favourited bool `json:"favourited"`
FavouritesCount int `json:"favourites_count"`
ID string `json:"id"`
InReplyToAccountID string `json:"in_reply_to_account_id"`
InReplyToID string `json:"in_reply_to_id"`
Language string `json:"language"`
MediaAttachments []Attachment `json:"media_attachments"`
Mentions []Mention `json:"mentions"`
Muted bool `json:"muted"`
Pinned bool `json:"pinned"`
Poll Poll `json:"poll"`
Reblog StatusReblogged `json:"reblog"`
Reblogged bool `json:"reblogged"`
RebloggsCount int `json:"reblogs_count"`
RepliesCount int `json:"replies_count"`
Sensitive bool `json:"sensitive"`
SpolierText string `json:"spoiler_text"`
Tags []Tag `json:"tags"`
Text string `json:"text"`
URI string `json:"uri"`
URL string `json:"url"`
Visibility string `json:"visibility"`
}
type Card struct {
AuthorName string `json:"author_name"`
AuthorURL string `json:"author_url"`
Blurhash string `json:"blurhash"`
Description string `json:"description"`
EmbedURL string `json:"embed_url"`
HTML string `json:"html"`
Image string `json:"image"`
ProviderName string `json:"provider_name"`
ProviderURL string `json:"provider_url"`
Title string `json:"title"`
Type string `json:"type"`
URL string `json:"url"`
Height int `json:"height"`
Width int `json:"width"`
}
type Mention struct {
Acct string `json:"acct"`
ID string `json:"id"`
URL string `json:"url"`
Username string `json:"username"`
}
type Poll struct {
Emojis []Emoji `json:"emojis"`
Expired bool `json:"expired"`
Voted bool `json:"voted"`
Multiple bool `json:"multiple"`
ExpiredAt string `json:"expires_at"`
ID string `json:"id"`
OwnVotes []int `json:"own_votes"`
VotersCount int `json:"voters_count"`
VotesCount int `json:"votes_count"`
Options []PollOption `json:"options"`
}
type PollOption struct {
Title string `json:"title"`
VotesCount string `json:"votes_count"`
}
type StatusReblogged struct {
Account Account `json:"account"`
Application Application `json:"application"`
Bookmarked bool `json:"bookmarked"`
Card Card `json:"card"`
Content string `json:"content"`
CreatedAt string `json:"created_at"`
Emojis []Emoji `json:"emojis"`
Favourited bool `json:"favourited"`
FavouritesCount int `json:"favourites_count"`
ID string `json:"id"`
InReplyToAccountID string `json:"in_reply_to_account_id"`
InReplyToID string `json:"in_reply_to_id"`
Language string `json:"language"`
MediaAttachments []Attachment `json:"media_attachments"`
Mentions []Mention `json:"mentions"`
Muted bool `json:"muted"`
Pinned bool `json:"pinned"`
Poll Poll `json:"poll"`
Reblogged bool `json:"reblogged"`
RebloggsCount int `json:"reblogs_count"`
RepliesCount int `json:"replies_count"`
Sensitive bool `json:"sensitive"`
SpolierText string `json:"spoiler_text"`
Tags []Tag `json:"tags"`
Text string `json:"text"`
URI string `json:"uri"`
URL string `json:"url"`
Visibility string `json:"visibility"`
}
type Tag struct {
History []any `json:"history"`
Name string `json:"name"`
URL string `json:"url"`
}
type Attachment struct {
Meta MediaMeta `json:"meta"`
Blurhash string `json:"blurhash"`
Description string `json:"description"`
ID string `json:"id"`
PreviewRemoteURL string `json:"preview_remote_url"`
PreviewURL string `json:"preview_url"`
RemoteURL string `json:"remote_url"`
TextURL string `json:"text_url"`
Type string `json:"type"`
URL string `json:"url"`
}
type MediaMeta struct {
Focus MediaFocus `json:"focus"`
Original MediaDimensions `json:"original"`
Small MediaDimensions `json:"small"`
}
type MediaFocus struct {
X float64 `json:"x"`
Y float64 `json:"y"`
}
type MediaDimensions struct {
Aspect float64 `json:"aspect"`
Bitrate int `json:"bitrate"`
Duration float64 `json:"duration"`
FrameRate string `json:"frame_rate"`
Size string `json:"size"`
Height int `json:"height"`
Width int `json:"width"`
}