From b0e52fc498125c448237ef07f03e3b104a942a0e Mon Sep 17 00:00:00 2001 From: Jim Myhrberg Date: Wed, 7 Sep 2022 08:37:01 +0100 Subject: [PATCH] wip: initial commit Extremely unfinished and work in progress. --- .gitignore | 3 + .golangci.yml | 94 +++++++++++++++++++ commands/midjourney.go | 23 +++++ commands/midjourney_recent_jobs.go | 90 ++++++++++++++++++ commands/mj2n.go | 134 +++++++++++++++++++++++++++ commands/render.go | 52 +++++++++++ go.mod | 17 ++++ go.sum | 31 +++++++ main.go | 36 ++++++++ midjourney/client.go | 144 +++++++++++++++++++++++++++++ midjourney/job.go | 57 ++++++++++++ midjourney/midjourney.go | 4 + midjourney/recent_jobs.go | 124 +++++++++++++++++++++++++ 13 files changed, 809 insertions(+) create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 commands/midjourney.go create mode 100644 commands/midjourney_recent_jobs.go create mode 100644 commands/mj2n.go create mode 100644 commands/render.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 midjourney/client.go create mode 100644 midjourney/job.go create mode 100644 midjourney/midjourney.go create mode 100644 midjourney/recent_jobs.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fa21c28 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env* +bin/* +mj2n diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..abca8cf --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,94 @@ +linters-settings: + funlen: + lines: 100 + statements: 150 + gocyclo: + min-complexity: 20 + golint: + min-confidence: 0 + govet: + check-shadowing: true + enable-all: true + disable: + - fieldalignment + lll: + line-length: 80 + tab-width: 4 + maligned: + suggest-new: true + misspell: + locale: US + +linters: + disable-all: true + enable: + - asciicheck + - bodyclose + - depguard + - durationcheck + - errcheck + - errorlint + - exhaustive + - exportloopref + - funlen + - gochecknoinits + - goconst + - gocritic + - gocyclo + - godot + - gofumpt + - goimports + - goprintffuncname + - gosec + - gosimple + - govet + - importas + - ineffassign + - lll + - misspell + - nakedret + - nilerr + - nlreturn + - noctx + - nolintlint + - prealloc + - predeclared + - revive + - rowserrcheck + - sqlclosecheck + - staticcheck + - structcheck + - tparallel + - typecheck + - unconvert + - unparam + - unused + - wastedassign + - whitespace + +issues: + exclude: + - Using the variable on range scope `tt` in function literal + - Using the variable on range scope `tc` in function literal + exclude-rules: + - path: "_test\\.go" + linters: + - funlen + - dupl + - source: "^//go:generate " + linters: + - lll + - source: "`json:" + linters: + - lll + - source: "`xml:" + linters: + - lll + - source: "`yaml:" + linters: + - lll + +run: + timeout: 2m + allow-parallel-runners: true + modules-download-mode: readonly diff --git a/commands/midjourney.go b/commands/midjourney.go new file mode 100644 index 0000000..89fc2cc --- /dev/null +++ b/commands/midjourney.go @@ -0,0 +1,23 @@ +package commands + +import ( + "github.com/jimeh/mj2n/midjourney" + "github.com/spf13/cobra" +) + +func NewMidjourney(mc *midjourney.Client) (*cobra.Command, error) { + cmd := &cobra.Command{ + Use: "midjourney", + Aliases: []string{"mj"}, + Short: "MidJourney specific commands", + } + + recentJobsCmd, err := NewMidjourneyRecentJobs(mc) + if err != nil { + return nil, err + } + + cmd.AddCommand(recentJobsCmd) + + return cmd, nil +} diff --git a/commands/midjourney_recent_jobs.go b/commands/midjourney_recent_jobs.go new file mode 100644 index 0000000..be5bee8 --- /dev/null +++ b/commands/midjourney_recent_jobs.go @@ -0,0 +1,90 @@ +package commands + +import ( + "github.com/jimeh/mj2n/midjourney" + "github.com/spf13/cobra" +) + +func NewMidjourneyRecentJobs(mc *midjourney.Client) (*cobra.Command, error) { + cmd := &cobra.Command{ + Use: "recent-jobs", + Aliases: []string{"jobs", "recent", "rj", "j", "r"}, + Short: "List recent jobs", + RunE: midjourneyRecentJobsRunE(mc), + } + + cmd.Flags().StringP("format", "f", "", "output format (yaml or json)") + cmd.Flags().IntP("amount", "a", 50, "amount of jobs to list") + cmd.Flags().StringP("type", "t", "", "type of jobs to list") + cmd.Flags().StringP("order", "o", "new", "either \"new\" or \"oldest\"") + cmd.Flags().StringP("user-id", "u", "", "user ID to list jobs for") + cmd.Flags().StringP("page", "p", "", "page to fetch") + cmd.Flags().Bool("dedupe", true, "dedupe results") + + return cmd, nil +} + +func midjourneyRecentJobsRunE(mc *midjourney.Client) runEFunc { + return func(cmd *cobra.Command, _ []string) error { + fs := cmd.Flags() + q := &midjourney.RecentJobsQuery{} + + if v, err := fs.GetInt("amount"); err == nil && v > 0 { + q.Amount = v + } + if v, err := fs.GetString("type"); err == nil && v != "" { + q.JobType = midjourney.JobType(v) + } + if v, err := fs.GetString("order"); err == nil && v != "" { + q.OrderBy = midjourney.Order(v) + } + if v, err := fs.GetString("user-id"); err == nil && v != "" { + q.UserID = v + } + if v, err := fs.GetInt("page"); err == nil && v != 0 { + q.Page = v + } + if v, err := fs.GetBool("dedupe"); err == nil { + q.Dedupe = v + } + + rj, err := mc.RecentJobs(cmd.Context(), q) + if err != nil { + return err + } + + r := []*MidjourneyJob{} + for _, j := range rj.Jobs { + r = append(r, &MidjourneyJob{ + ID: j.ID, + Status: string(j.CurrentStatus), + Type: string(j.Type), + EnqueueTime: j.EnqueueTime, + Prompt: j.Prompt, + ImagePaths: j.ImagePaths, + IsPublished: j.IsPublished, + UserID: j.UserID, + Username: j.Username, + FullCommand: j.FullCommand, + ReferenceJobID: j.ReferenceJobID, + }) + } + format := flagString(cmd, "format") + + return render(cmd.OutOrStdout(), format, r) + } +} + +type MidjourneyJob struct { + ID string `json:"id,omitempty" yaml:"id,omitempty"` + Status string `json:"current_status,omitempty" yaml:"current_status,omitempty"` + Type string `json:"type,omitempty" yaml:"type,omitempty"` + EnqueueTime string `json:"enqueue_time,omitempty" yaml:"enqueue_time,omitempty"` + Prompt string `json:"prompt,omitempty" yaml:"prompt,omitempty"` + ImagePaths []string `json:"image_paths,omitempty" yaml:"image_paths,omitempty"` + IsPublished bool `json:"is_published,omitempty" yaml:"is_published,omitempty"` + UserID string `json:"user_id,omitempty" yaml:"user_id,omitempty"` + Username string `json:"username,omitempty" yaml:"username,omitempty"` + FullCommand string `json:"full_command,omitempty" yaml:"full_command,omitempty"` + ReferenceJobID string `json:"reference_job_id,omitempty" yaml:"reference_job_id,omitempty"` +} diff --git a/commands/mj2n.go b/commands/mj2n.go new file mode 100644 index 0000000..b49a132 --- /dev/null +++ b/commands/mj2n.go @@ -0,0 +1,134 @@ +package commands + +import ( + "io" + "os" + + "github.com/jimeh/mj2n/midjourney" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +type runEFunc func(cmd *cobra.Command, _ []string) error + +func NewMJ2N() (*cobra.Command, error) { + mc, err := midjourney.New(midjourney.WithUserAgent("mj2n/0.0.1-dev")) + if err != nil { + return nil, err + } + + cmd := &cobra.Command{ + Use: "mj2n", + Short: "MidJourney to Notion importer", + PersistentPreRunE: persistentPreRunE(mc), + } + + cmd.PersistentFlags().StringP( + "log-level", "l", "info", + "one of: trace, debug, info, warn, error, fatal, panic", + ) + cmd.PersistentFlags().StringP( + "mj-token", "m", "", "MidJourney API token", + ) + cmd.PersistentFlags().String( + "mj-api-url", midjourney.DefaultAPIURL.String(), "MidJourney API URL", + ) + + midjourneyCmd, err := NewMidjourney(mc) + if err != nil { + return nil, err + } + + cmd.AddCommand(midjourneyCmd) + + return cmd, nil +} + +func persistentPreRunE(mc *midjourney.Client) runEFunc { + return func(cmd *cobra.Command, _ []string) error { + err := setupZerolog(cmd) + if err != nil { + return err + } + + err = setupMidJourney(cmd, mc) + if err != nil { + return err + } + + return nil + } +} + +func setupMidJourney(cmd *cobra.Command, mc *midjourney.Client) error { + opts := []midjourney.Option{ + midjourney.WithLogger(log.Logger), + } + + if f := cmd.Flag("mj-token"); f.Changed { + opts = append(opts, midjourney.WithAuthToken(f.Value.String())) + } else if v := os.Getenv("MIDJOURNEY_TOKEN"); v != "" { + opts = append(opts, midjourney.WithAuthToken(v)) + } + + apiURL := flagString(cmd, "mj-api-url") + if apiURL == "" { + apiURL = os.Getenv("MIDJOURNEY_API_URL") + } + if apiURL != "" { + opts = append(opts, midjourney.WithAPIURL(apiURL)) + } + + return mc.Set(opts...) +} + +func setupZerolog(cmd *cobra.Command) error { + var levelStr string + if v := os.Getenv("MJ2N_DEBUG"); v != "" { + levelStr = "debug" + } else if v := os.Getenv("MJ2N_LOG_LEVEL"); v != "" { + levelStr = v + } + + var out io.Writer = os.Stderr + + if cmd != nil { + out = cmd.OutOrStderr() + fl := cmd.Flag("log-level") + if fl != nil && (fl.Changed || levelStr == "") { + levelStr = fl.Value.String() + } + } + + if levelStr == "" { + levelStr = "info" + } + + level, err := zerolog.ParseLevel(levelStr) + if err != nil { + return err + } + + zerolog.SetGlobalLevel(level) + zerolog.TimeFieldFormat = "" + + output := zerolog.ConsoleWriter{Out: out} + output.FormatTimestamp = func(i interface{}) string { + return "" + } + + log.Logger = zerolog.New(output).With().Timestamp().Logger() + + return nil +} + +func flagString(cmd *cobra.Command, name string) string { + var r string + + if f := cmd.Flag(name); f != nil { + r = f.Value.String() + } + + return r +} diff --git a/commands/render.go b/commands/render.go new file mode 100644 index 0000000..896a5be --- /dev/null +++ b/commands/render.go @@ -0,0 +1,52 @@ +package commands + +import ( + "encoding" + "encoding/json" + "io" + + "gopkg.in/yaml.v3" +) + +func render(w io.Writer, format string, v interface{}) error { + if format == "yaml" || format == "yml" { + return renderYAML(w, v) + } + + if format == "json" { + return renderJSON(w, v) + } + + if wt, ok := v.(io.WriterTo); ok { + _, err := wt.WriteTo(w) + + return err + } + + if tm, ok := v.(encoding.TextMarshaler); ok { + b, err := tm.MarshalText() + if err != nil { + return err + } + + _, err = w.Write(b) + + return err + } + + return renderYAML(w, v) +} + +func renderYAML(w io.Writer, v interface{}) error { + enc := yaml.NewEncoder(w) + enc.SetIndent(2) + + return enc.Encode(v) +} + +func renderJSON(w io.Writer, v interface{}) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + + return enc.Encode(v) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4079ab3 --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module github.com/jimeh/mj2n + +go 1.19 + +require ( + github.com/rs/zerolog v1.28.0 + github.com/spf13/cobra v1.5.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/inconshreveable/mousetrap v1.0.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.16 // indirect + github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/sys v0.0.0-20220906165534-d0df966e6959 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ca60b37 --- /dev/null +++ b/go.sum @@ -0,0 +1,31 @@ +github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= +github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.28.0 h1:MirSo27VyNi7RJYP3078AA1+Cyzd2GB66qy3aUHvsWY= +github.com/rs/zerolog v1.28.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU= +github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220906165534-d0df966e6959 h1:qSa+Hg9oBe6UJXrznE+yYvW51V9UbyIj/nj/KpDigo8= +golang.org/x/sys v0.0.0-20220906165534-d0df966e6959/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..1c1db7d --- /dev/null +++ b/main.go @@ -0,0 +1,36 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/jimeh/mj2n/commands" +) + +func main() { + cmd, err := commands.NewMJ2N() + if err != nil { + fatal(err) + } + + ctx, cancel := signal.NotifyContext( + context.Background(), + syscall.SIGINT, syscall.SIGTERM, + ) + defer cancel() + + err = cmd.ExecuteContext(ctx) + if err != nil { + defer os.Exit(1) + + return + } +} + +func fatal(err error) { + fmt.Fprintf(os.Stderr, "ERROR: %s\n", err) + os.Exit(1) +} diff --git a/midjourney/client.go b/midjourney/client.go new file mode 100644 index 0000000..ecf6bd3 --- /dev/null +++ b/midjourney/client.go @@ -0,0 +1,144 @@ +package midjourney + +import ( + "errors" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/rs/zerolog" +) + +var ( + Err = errors.New("midjourney") + ErrNoAuthToken = fmt.Errorf("%w: no auth token", Err) + ErrInvalidAPIURL = fmt.Errorf("%w: invalid API URL", Err) + ErrInvalidHTTPClient = fmt.Errorf("%w: invalid HTTP client", Err) + ErrResponseStatus = fmt.Errorf("%w: response status", Err) + + DefaultAPIURL = url.URL{ + Scheme: "https", + Host: "www.midjourney.com", + Path: "/api/", + } + DefaultUserAgent = "go-midjourney/0.0.1-dev" +) + +type Order string + +const ( + OrderNew Order = "new" + OrderOldest Order = "oldest" +) + +type Option interface { + apply(*Client) error +} + +type optionFunc func(*Client) error + +func (fn optionFunc) apply(o *Client) error { + return fn(o) +} + +func WithAuthToken(authToken string) Option { + return optionFunc(func(c *Client) error { + c.AuthToken = authToken + + return nil + }) +} + +func WithAPIURL(baseURL string) Option { + return optionFunc(func(c *Client) error { + if !strings.HasSuffix(baseURL, "/") { + baseURL += "/" + } + + u, err := url.Parse(baseURL) + if err != nil { + return err + } + + c.APIURL = u + + return nil + }) +} + +func WithHTTPClient(httpClient *http.Client) Option { + return optionFunc(func(c *Client) error { + c.HTTPClient = httpClient + + return nil + }) +} + +func WithUserAgent(userAgent string) Option { + return optionFunc(func(c *Client) error { + c.UserAgent = userAgent + + return nil + }) +} + +func WithLogger(logger zerolog.Logger) Option { + return optionFunc(func(c *Client) error { + c.Logger = logger + + return nil + }) +} + +type HTTPClient interface { + Do(req *http.Request) (*http.Response, error) +} + +type Client struct { + HTTPClient HTTPClient + APIURL *url.URL + AuthToken string + UserAgent string + Logger zerolog.Logger +} + +func New(options ...Option) (*Client, error) { + c := &Client{ + HTTPClient: http.DefaultClient, + APIURL: &DefaultAPIURL, + UserAgent: DefaultUserAgent, + Logger: zerolog.Nop(), + } + err := c.Set(options...) + + return c, err +} + +func (c *Client) Set(options ...Option) error { + for _, opt := range options { + err := opt.apply(c) + if err != nil { + return err + } + } + + return nil +} + +func (c *Client) Do(req *http.Request) (*http.Response, error) { + req.URL = c.APIURL.ResolveReference(req.URL) + c.Logger.Debug().Str("url", req.URL.String()).Msg("request") + + req.Header.Set("Accept", "application/json") + if c.AuthToken != "" { + req.Header.Set( + "Cookie", "__Secure-next-auth.session-token="+c.AuthToken, + ) + } + if c.UserAgent != "" { + req.Header.Set("User-Agent", c.UserAgent) + } + + return c.HTTPClient.Do(req) +} diff --git a/midjourney/job.go b/midjourney/job.go new file mode 100644 index 0000000..e75a8ca --- /dev/null +++ b/midjourney/job.go @@ -0,0 +1,57 @@ +package midjourney + +type JobType string + +const ( + JobTypeGrid JobType = "grid" + JobTypeUpscale JobType = "upscale" +) + +type JobStatus string + +const ( + JobStatusRunning JobStatus = "running" + JobStatusCompleted JobStatus = "completed" +) + +type Event struct { + Height int `json:"height,omitempty"` + TextPrompt []string `json:"textPrompt,omitempty"` + ImagePrompts []string `json:"imagePrompts,omitempty"` + Width int `json:"width,omitempty"` + BatchSize int `json:"batchSize,omitempty"` + SeedImageURL string `json:"seedImageURL,omitempty"` +} + +type Job struct { + CurrentStatus JobStatus `json:"current_status,omitempty"` + EnqueueTime string `json:"enqueue_time,omitempty"` + Event *Event `json:"event,omitempty"` + Flagged bool `json:"flagged,omitempty"` + FollowedByUser bool `json:"followed_by_user,omitempty"` + GridID string `json:"grid_id,omitempty"` + GridNum string `json:"grid_num,omitempty"` + GuildID string `json:"guild_id,omitempty"` + Hidden bool `json:"hidden,omitempty"` + ID string `json:"id,omitempty"` + ImagePaths []string `json:"image_paths,omitempty"` + IsPublished bool `json:"is_published,omitempty"` + LikedByUser bool `json:"liked_by_user,omitempty"` + LowPriority bool `json:"low_priority,omitempty"` + Metered bool `json:"metered,omitempty"` + ModHidden bool `json:"mod_hidden,omitempty"` + Platform string `json:"platform,omitempty"` + PlatformChannel string `json:"platform_channel,omitempty"` + PlatformChannelID string `json:"platform_channel_id,omitempty"` + PlatformMessageID string `json:"platform_message_id,omitempty"` + PlatformThreadID string `json:"platform_thread_id,omitempty"` + Prompt string `json:"prompt,omitempty"` + RankedByUser bool `json:"ranked_by_user,omitempty"` + RankingByUser int `json:"ranking_by_user,omitempty"` + Type JobType `json:"type,omitempty"` + UserID string `json:"user_id,omitempty"` + Username string `json:"username,omitempty"` + FullCommand string `json:"full_command,omitempty"` + ReferenceJobID string `json:"reference_job_id,omitempty"` + ReferenceImageNum string `json:"reference_image_num,omitempty"` +} diff --git a/midjourney/midjourney.go b/midjourney/midjourney.go new file mode 100644 index 0000000..28503b7 --- /dev/null +++ b/midjourney/midjourney.go @@ -0,0 +1,4 @@ +// Package midjourney provides a basic read-only API client for MidJourney. As +// there is no official API, it uses the same API as the MidJourney website +// uses. +package midjourney diff --git a/midjourney/recent_jobs.go b/midjourney/recent_jobs.go new file mode 100644 index 0000000..1e5df5f --- /dev/null +++ b/midjourney/recent_jobs.go @@ -0,0 +1,124 @@ +package midjourney + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "time" +) + +const FromDateFormat = "2006-01-02 15:04:05.999999" + +type RecentJobsQuery struct { + Amount int + JobType JobType + OrderBy Order + JobStatus JobStatus + UserID string + FromDate time.Time + Page int + Dedupe bool + RefreshAPI int +} + +func (rjq *RecentJobsQuery) Values() url.Values { + v := url.Values{} + if rjq.Amount != 0 { + v.Set("amount", strconv.Itoa(rjq.Amount)) + } + if rjq.JobType != "" { + v.Set("jobType", string(rjq.JobType)) + } + if rjq.OrderBy != "" { + v.Set("orderBy", string(rjq.OrderBy)) + } + if rjq.JobStatus != "" { + v.Set("jobStatus", string(rjq.JobStatus)) + } + if rjq.UserID != "" { + v.Set("userId", rjq.UserID) + } + if !rjq.FromDate.IsZero() { + v.Set("fromDate", rjq.FromDate.Format(FromDateFormat)) + } + if rjq.Page != 0 { + v.Set("page", strconv.Itoa(rjq.Page)) + } + if rjq.Dedupe { + v.Set("dedupe", "true") + } + if rjq.RefreshAPI != 0 { + v.Set("refreshApi", strconv.Itoa(rjq.RefreshAPI)) + } + + return v +} + +func (rjq *RecentJobsQuery) NextPage() *RecentJobsQuery { + q := *rjq + if q.OrderBy == OrderNew && q.FromDate.IsZero() { + q.FromDate = time.Now().UTC() + } + if q.Page == 0 { + q.Page = 1 + } + q.Page = rjq.Page + 1 + + return &q +} + +type RecentJobs struct { + Query RecentJobsQuery + Jobs []*Job + Page int +} + +func (c *Client) RecentJobs( + ctx context.Context, + q *RecentJobsQuery, +) (*RecentJobs, error) { + u := &url.URL{ + Path: "app/recent-jobs", + RawQuery: q.Values().Encode(), + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, err + } + + fromDate := q.FromDate + if fromDate.IsZero() { + fromDate = time.Now().UTC() + } + + resp, err := c.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w: %s", ErrResponseStatus, resp.Status) + } + + rj := &RecentJobs{ + Query: *q, + Jobs: []*Job{}, + Page: q.Page, + } + + err = json.NewDecoder(resp.Body).Decode(&rj.Jobs) + if err != nil { + return nil, err + } + + if rj.Query.OrderBy == OrderNew && rj.Query.FromDate.IsZero() { + rj.Query.FromDate = fromDate + } + + return rj, nil +}