commit 3f46417c3b62f87cbfbb9a67d78d54dd919ff77f Author: Jim Myhrberg Date: Thu Sep 15 23:06:08 2022 +0100 feat: initial commit 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 +}