From 3f46417c3b62f87cbfbb9a67d78d54dd919ff77f Mon Sep 17 00:00:00 2001 From: Jim Myhrberg Date: Thu, 15 Sep 2022 23:06:08 +0100 Subject: [PATCH] feat: initial commit --- .gitignore | 3 + .golangci.yml | 93 ++++++++++++++++++++++ Makefile | 194 ++++++++++++++++++++++++++++++++++++++++++++++ client.go | 137 +++++++++++++++++++++++++++++++++ go.mod | 17 +++++ go.sum | 28 +++++++ job.go | 77 +++++++++++++++++++ job_test.go | 124 ++++++++++++++++++++++++++++++ midjourney.go | 5 ++ recent_jobs.go | 203 +++++++++++++++++++++++++++++++++++++++++++++++++ time.go | 34 +++++++++ 11 files changed, 915 insertions(+) create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 Makefile create mode 100644 client.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 job.go create mode 100644 job_test.go create mode 100644 midjourney.go create mode 100644 recent_jobs.go create mode 100644 time.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..144711e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env* +bin/* +mje diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..8f67aa6 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,93 @@ +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 + - 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/Makefile b/Makefile new file mode 100644 index 0000000..b4f93a5 --- /dev/null +++ b/Makefile @@ -0,0 +1,194 @@ +GOMODNAME := $(shell grep 'module' go.mod | sed -e 's/^module //') +SOURCES := $(shell find . -name "*.go" -or -name "go.mod" -or -name "go.sum" \ + -or -name "Makefile") + +# Verbose output +ifdef VERBOSE +V = -v +endif + +# +# Environment +# + +BINDIR := bin +TOOLDIR := $(BINDIR)/tools + +# Global environment variables for all targets +SHELL ?= /bin/bash +SHELL := env \ + GO111MODULE=on \ + GOBIN=$(CURDIR)/$(TOOLDIR) \ + CGO_ENABLED=1 \ + PATH='$(CURDIR)/$(BINDIR):$(CURDIR)/$(TOOLDIR):$(PATH)' \ + $(SHELL) + +# +# Defaults +# + +# Default target +.DEFAULT_GOAL := test + +# +# Tools +# + +# external tool +define tool # 1: binary-name, 2: go-import-path +TOOLS += $(TOOLDIR)/$(1) + +$(TOOLDIR)/$(1): Makefile + GOBIN="$(CURDIR)/$(TOOLDIR)" go install "$(2)" +endef + +$(eval $(call tool,godoc,golang.org/x/tools/cmd/godoc@latest)) +$(eval $(call tool,gofumpt,mvdan.cc/gofumpt@latest)) +$(eval $(call tool,goimports,golang.org/x/tools/cmd/goimports@latest)) +$(eval $(call tool,golangci-lint,github.com/golangci/golangci-lint/cmd/golangci-lint@v1.49)) +$(eval $(call tool,gomod,github.com/Helcaraxan/gomod@latest)) +$(eval $(call tool,mockgen,github.com/golang/mock/mockgen@v1.6.0)) + +.PHONY: tools +tools: $(TOOLS) + +# +# Development +# + +BENCH ?= . +TESTARGS ?= + +.PHONY: clean +clean: + rm -f $(TOOLS) + rm -f ./coverage.out ./go.mod.tidy-check ./go.sum.tidy-check + +.PHONY: test +test: + go test $(V) -count=1 -race $(TESTARGS) ./... + +.PHONY: test-integration +test-integration: + env USE_ZFS=1 go test $(V) -count=1 $(TESTARGS) -run=^TestIntegration . + +.PHONY: test-deps +test-deps: + go test all + +.PHONY: lint +lint: $(TOOLDIR)/golangci-lint + golangci-lint $(V) run + +.PHONY: format +format: $(TOOLDIR)/goimports $(TOOLDIR)/gofumpt + goimports -w . && gofumpt -w . + +.SILENT: bench +.PHONY: bench +bench: + go test $(V) -count=1 -bench=$(BENCH) $(TESTARGS) ./... + +# +# Code Generation +# + +.PHONY: generate +generate: $(TOOLDIR)/mockgen + go generate ./... + +.PHONY: check-generate +check-generate: $(TOOLDIR)/mockgen + $(eval CHKDIR := $(shell mktemp -d)) + cp -av . "$(CHKDIR)" + make -C "$(CHKDIR)/" generate + ( diff -rN . "$(CHKDIR)" && rm -rf "$(CHKDIR)" ) || \ + ( rm -rf "$(CHKDIR)" && exit 1 ) + +# +# Coverage +# + +.PHONY: cov +cov: coverage.out + +.PHONY: cov-html +cov-html: coverage.out + go tool cover -html=./coverage.out + +.PHONY: cov-func +cov-func: coverage.out + go tool cover -func=./coverage.out + +coverage.out: $(SOURCES) + go test $(V) -covermode=count -coverprofile=./coverage.out ./... + +# +# Dependencies +# + +.PHONY: deps +deps: + go mod download + +.PHONY: deps-update +deps-update: + go get -u -t ./... + +.PHONY: deps-analyze +deps-analyze: $(TOOLDIR)/gomod + gomod analyze + +.PHONY: tidy +tidy: + go mod tidy $(V) + +.PHONY: verify +verify: + go mod verify + +.SILENT: check-tidy +.PHONY: check-tidy +check-tidy: + cp go.mod go.mod.tidy-check + cp go.sum go.sum.tidy-check + go mod tidy + ( \ + diff go.mod go.mod.tidy-check && \ + diff go.sum go.sum.tidy-check && \ + rm -f go.mod go.sum && \ + mv go.mod.tidy-check go.mod && \ + mv go.sum.tidy-check go.sum \ + ) || ( \ + rm -f go.mod go.sum && \ + mv go.mod.tidy-check go.mod && \ + mv go.sum.tidy-check go.sum; \ + exit 1 \ + ) + +# +# Documentation +# + +# Serve docs +.PHONY: docs +docs: $(TOOLDIR)/godoc + $(info serviing docs on http://127.0.0.1:6060/pkg/$(GOMODNAME)/) + @godoc -http=127.0.0.1:6060 + +# +# Release +# + +.PHONY: new-version +new-version: check-npx + npx standard-version + +.PHONY: next-version +next-version: check-npx + npx standard-version --dry-run + +.PHONY: check-npx +check-npx: + $(if $(shell which npx),,\ + $(error No npx found in PATH, please install NodeJS)) diff --git a/client.go b/client.go new file mode 100644 index 0000000..afc4f4c --- /dev/null +++ b/client.go @@ -0,0 +1,137 @@ +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.0-dev" +) + +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/go.mod b/go.mod new file mode 100644 index 0000000..557efe0 --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module github.com/jimeh/go-midjourney + +go 1.19 + +require ( + github.com/rs/zerolog v1.28.0 + github.com/stretchr/testify v1.8.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/mattn/go-colorable v0.1.12 // indirect + github.com/mattn/go-isatty v0.0.14 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..de0a4a6 --- /dev/null +++ b/go.sum @@ -0,0 +1,28 @@ +github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6 h1:foEbQz/B0Oz6YIqu/69kfXPYeFQAuuMYFkjaqXzl5Wo= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/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.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +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/job.go b/job.go new file mode 100644 index 0000000..557101d --- /dev/null +++ b/job.go @@ -0,0 +1,77 @@ +package midjourney + +import "fmt" + +type JobType string + +const ( + JobTypeNull JobType = "null" + 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 Time `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"` +} + +func (j *Job) DiscordURL() string { + if j.Platform != "discord" || j.GuildID == "" || + j.PlatformChannelID == "" || j.PlatformMessageID == "" { + return "" + } + + return fmt.Sprintf("https://discord.com/channels/%s/%s/%s", + j.GuildID, + j.PlatformChannelID, + j.PlatformMessageID, + ) +} + +func (j *Job) MainImageURL() string { + return fmt.Sprintf("https://mj-gallery.com/%s/grid_0.png", j.ID) +} diff --git a/job_test.go b/job_test.go new file mode 100644 index 0000000..6ff760b --- /dev/null +++ b/job_test.go @@ -0,0 +1,124 @@ +package midjourney + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestJob_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + json string + want *Job + }{ + { + name: "full", + //nolint: lll + json: `{ + "current_status": "completed", + "enqueue_time": "2022-09-07 06:58:02.200753", + "event": { + "height": 512, + "textPrompt": [ + "earth, landscape, picturesque, photo, photorealistic" + ], + "imagePrompts": [], + "width": 768, + "batchSize": 1, + "seedImageURL": null + }, + "flagged": false, + "followed_by_user": false, + "grid_id": null, + "grid_num": null, + "guild_id": null, + "hidden": false, + "id": "a3052616-372b-42a1-a72b-eb86fa0be633", + "image_paths": [ + "https://storage.googleapis.com/dream-machines-output/a3052616-372b-42a1-a72b-eb86fa0be633/0_0.png" + ], + "is_published": true, + "liked_by_user": false, + "low_priority": true, + "metered": false, + "mod_hidden": false, + "platform": "discord", + "platform_channel": "DM", + "platform_channel_id": "991150132894638170", + "platform_message_id": "1016966680586506291", + "platform_thread_id": null, + "prompt": "earth, landscape, picturesque, photo, photorealistic", + "ranked_by_user": false, + "ranking_by_user": null, + "type": "grid", + "user_id": "146914681683050496", + "username": "jimeh", + "full_command": "earth, landscape, picturesque, photo, photorealistic --testp --ar 16:10 --video", + "reference_job_id": null, + "reference_image_num": null + }`, + want: &Job{ + CurrentStatus: "completed", + EnqueueTime: Time{ + time.Date(2022, 9, 7, 6, 58, 2, 200753000, time.UTC), + }, + Event: &Event{ + Height: 512, + TextPrompt: []string{ + "earth, landscape, picturesque, photo, photorealistic", + }, + ImagePrompts: []string{}, + Width: 768, + BatchSize: 1, + SeedImageURL: "", + }, + Flagged: false, + FollowedByUser: false, + GridID: "", + GridNum: "", + GuildID: "", + Hidden: false, + ID: "a3052616-372b-42a1-a72b-eb86fa0be633", + ImagePaths: []string{ + "https://storage.googleapis.com/dream-machines-output/" + + "a3052616-372b-42a1-a72b-eb86fa0be633/0_0.png", + }, + IsPublished: true, + LikedByUser: false, + LowPriority: true, + Metered: false, + ModHidden: false, + Platform: "discord", + PlatformChannel: "DM", + PlatformChannelID: "991150132894638170", + PlatformMessageID: "1016966680586506291", + PlatformThreadID: "", + Prompt: "earth, landscape, picturesque, photo, " + + "photorealistic", + RankedByUser: false, + RankingByUser: 0, + Type: "grid", + UserID: "146914681683050496", + Username: "jimeh", + FullCommand: "earth, landscape, picturesque, photo, " + + "photorealistic --testp --ar 16:10 --video", + ReferenceJobID: "", + ReferenceImageNum: "", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := &Job{} + + err := json.Unmarshal([]byte(tt.json), got) + require.NoError(t, err) + + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/midjourney.go b/midjourney.go new file mode 100644 index 0000000..3c32433 --- /dev/null +++ b/midjourney.go @@ -0,0 +1,5 @@ +// 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, meaning that it is subject to change at any time, breaking this +// package. +package midjourney diff --git a/recent_jobs.go b/recent_jobs.go new file mode 100644 index 0000000..ff865ad --- /dev/null +++ b/recent_jobs.go @@ -0,0 +1,203 @@ +package midjourney + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "time" +) + +const FromDateFormat = "2006-01-02 15:04:05.999999" + +var ErrUserIDRequired = fmt.Errorf("%w: user id required", Err) + +type Order string + +const ( + OrderHot Order = "hot" + OrderNew Order = "new" + OrderOldest Order = "oldest" + OrderTopToday Order = "top-today" + OrderTopWeekly Order = "top-weekly" + OrderTopMonth Order = "top-month" + OrderTopAll Order = "top-all" + OrderLikedTime Order = "liked_timestamp" +) + +type RecentJobsQuery struct { + Amount int + JobType JobType + OrderBy Order + JobStatus JobStatus + UserID string + UserIDLiked string + FromDate time.Time + Page int + Prompt string + Personal bool + 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.UserIDLiked != "" { + v.Set("userIdLiked", rjq.UserIDLiked) + } + if !rjq.FromDate.IsZero() { + v.Set("fromDate", rjq.FromDate.Format(FromDateFormat)) + } + if rjq.Page != 0 { + v.Set("page", strconv.Itoa(rjq.Page)) + } + if rjq.Prompt != "" { + v.Set("prompt", rjq.Prompt) + } + if rjq.Personal { + v.Set("personal", "true") + } + if rjq.Dedupe { + v.Set("dedupe", "true") + } + 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 + } + + now := 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 = now + } + + return rj, nil +} + +func (c *Client) Home( + ctx context.Context, + userID string, +) (*RecentJobs, error) { + if userID == "" { + return nil, ErrUserIDRequired + } + + return c.RecentJobs(ctx, &RecentJobsQuery{ + Amount: 50, + JobType: JobTypeNull, + OrderBy: OrderNew, + JobStatus: JobStatusCompleted, + UserID: userID, + Dedupe: true, + }) +} + +func (c *Client) CommunityFeed(ctx context.Context) (*RecentJobs, error) { + return c.RecentJobs(ctx, &RecentJobsQuery{ + Amount: 50, + JobType: JobTypeUpscale, + OrderBy: OrderHot, + JobStatus: JobStatusCompleted, + Dedupe: true, + }) +} + +func (c *Client) PersonalFeed(ctx context.Context) (*RecentJobs, error) { + return c.RecentJobs(ctx, &RecentJobsQuery{ + Amount: 50, + JobType: JobTypeUpscale, + OrderBy: OrderNew, + JobStatus: JobStatusCompleted, + Personal: true, + Dedupe: true, + }) +} + +func (c *Client) Bookmarks( + ctx context.Context, + userID string, +) (*RecentJobs, error) { + if userID == "" { + return nil, ErrUserIDRequired + } + + return c.RecentJobs(ctx, &RecentJobsQuery{ + Amount: 50, + JobType: JobTypeNull, + OrderBy: OrderLikedTime, + JobStatus: JobStatusCompleted, + UserIDLiked: userID, + Dedupe: true, + }) +} diff --git a/time.go b/time.go new file mode 100644 index 0000000..a633525 --- /dev/null +++ b/time.go @@ -0,0 +1,34 @@ +package midjourney + +import ( + "fmt" + "strings" + "time" +) + +const TimeFormat = "2006-01-02 15:04:05.999999" + +type Time struct { + time.Time +} + +func (ct *Time) UnmarshalJSON(b []byte) (err error) { + s := strings.Trim(string(b), "\"") + if s == "null" { + ct.Time = time.Time{} + + return + } + + ct.Time, err = time.Parse(TimeFormat, s) + + return +} + +func (ct *Time) MarshalJSON() ([]byte, error) { + if ct.Time.IsZero() { + return []byte("null"), nil + } + + return []byte(fmt.Sprintf("\"%s\"", ct.Time.Format(TimeFormat))), nil +}