From b7da9a3df7d2828976ba846886f54dbfc401a19c Mon Sep 17 00:00:00 2001 From: William Artero Date: Wed, 2 Aug 2023 16:51:01 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20MVP's=20out=20=F0=9F=9A=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .air.toml | 44 + .dockerignore | 24 + .editorconfig | 28 + .github/ISSUE_TEMPLATE/BUG.md | 33 + .github/ISSUE_TEMPLATE/FEATURE_REQUEST.md | 22 + .github/PULL_REQUEST_TEMPLATE/SEMANTIC.md | 21 + .github/dependabot.yml | 14 + .github/workflows/codeql.yml | 30 + .github/workflows/integration.yml | 469 + .gitignore | 54 + .golangci.yaml | 19 + .goreleaser.yml | 38 + .pre-commit-config.yaml | 68 + .vscode/settings.json | 53 + CODE_OF_CONDUCT.md | 77 + CONTRIBUTING.md | 146 + Dockerfile | 32 + LICENSE | 21 + Makefile | 62 + README.md | 83 + SECURITY.md | 47 + TODO.md | 24 + anilistarr.puml | 142 + cmd/handler/limiter.go | 37 + cmd/handler/main.go | 116 + cmd/handler/restapi.go | 135 + cmd/handler/wire.go | 41 + cmd/version/main.go | 172 + container-structure-test.yaml | 51 + fly.toml | 19 + gen.go | 9 + go.mod | 66 + go.sum | 541 + internal/adapters/anilistmapper.go | 90 + internal/adapters/cache.go | 13 + internal/adapters/cachedtracker.go | 64 + internal/adapters/jsonlocalpath.go | 25 + internal/adapters/jsonsourceurl.go | 36 + internal/adapters/jsonsourceurl_test.go | 86 + internal/adapters/metadata.go | 26 + internal/adapters/metasource.go | 14 + internal/adapters/store.go | 22 + internal/drivers/anilist/anilist.go | 101 + internal/drivers/anilist/generated.go | 200 + internal/drivers/anilist/genqlient.yaml | 6 + internal/drivers/anilist/httpclient.go | 91 + internal/drivers/anilist/queries.graphql | 19 + internal/drivers/anilist/schema.graphql | 10025 +++++++++++++++++ internal/drivers/caches/bolt.go | 76 + internal/drivers/caches/redis.go | 56 + internal/drivers/providers/fribbs.go | 38 + internal/drivers/stores/models/bulk.go | 92 + internal/drivers/stores/models/converter.go | 28 + internal/drivers/stores/models/db.xo.go | 241 + internal/drivers/stores/models/mapping.xo.go | 165 + internal/drivers/stores/sql.go | 104 + internal/entities/media.go | 22 + internal/entities/sonarr.go | 7 + internal/telemetry/atributable.go | 11 + internal/telemetry/constants.go | 11 + internal/telemetry/http.go | 59 + internal/telemetry/httpspan.go | 41 + internal/telemetry/instrument.go | 180 + internal/telemetry/logger.go | 64 + internal/telemetry/meter.go | 17 + internal/telemetry/span.go | 55 + internal/telemetry/stdlogsink.go | 56 + internal/telemetry/teesink.go | 57 + internal/telemetry/telemetry.go | 55 + internal/telemetry/tracer.go | 92 + internal/usecases/mapper.go | 14 + internal/usecases/mediabridge.go | 83 + internal/usecases/tracker.go | 13 + media.db.sql | 8 + sonar-project.properties | 17 + workspace.dsl | 13 + 76 files changed, 15231 insertions(+) create mode 100644 .air.toml create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .github/ISSUE_TEMPLATE/BUG.md create mode 100644 .github/ISSUE_TEMPLATE/FEATURE_REQUEST.md create mode 100644 .github/PULL_REQUEST_TEMPLATE/SEMANTIC.md create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/integration.yml create mode 100644 .gitignore create mode 100644 .golangci.yaml create mode 100644 .goreleaser.yml create mode 100644 .pre-commit-config.yaml create mode 100644 .vscode/settings.json create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 TODO.md create mode 100644 anilistarr.puml create mode 100644 cmd/handler/limiter.go create mode 100644 cmd/handler/main.go create mode 100644 cmd/handler/restapi.go create mode 100644 cmd/handler/wire.go create mode 100644 cmd/version/main.go create mode 100644 container-structure-test.yaml create mode 100644 fly.toml create mode 100644 gen.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/adapters/anilistmapper.go create mode 100644 internal/adapters/cache.go create mode 100644 internal/adapters/cachedtracker.go create mode 100644 internal/adapters/jsonlocalpath.go create mode 100644 internal/adapters/jsonsourceurl.go create mode 100644 internal/adapters/jsonsourceurl_test.go create mode 100644 internal/adapters/metadata.go create mode 100644 internal/adapters/metasource.go create mode 100644 internal/adapters/store.go create mode 100644 internal/drivers/anilist/anilist.go create mode 100644 internal/drivers/anilist/generated.go create mode 100644 internal/drivers/anilist/genqlient.yaml create mode 100644 internal/drivers/anilist/httpclient.go create mode 100644 internal/drivers/anilist/queries.graphql create mode 100644 internal/drivers/anilist/schema.graphql create mode 100644 internal/drivers/caches/bolt.go create mode 100644 internal/drivers/caches/redis.go create mode 100644 internal/drivers/providers/fribbs.go create mode 100644 internal/drivers/stores/models/bulk.go create mode 100644 internal/drivers/stores/models/converter.go create mode 100644 internal/drivers/stores/models/db.xo.go create mode 100644 internal/drivers/stores/models/mapping.xo.go create mode 100644 internal/drivers/stores/sql.go create mode 100644 internal/entities/media.go create mode 100644 internal/entities/sonarr.go create mode 100644 internal/telemetry/atributable.go create mode 100644 internal/telemetry/constants.go create mode 100644 internal/telemetry/http.go create mode 100644 internal/telemetry/httpspan.go create mode 100644 internal/telemetry/instrument.go create mode 100644 internal/telemetry/logger.go create mode 100644 internal/telemetry/meter.go create mode 100644 internal/telemetry/span.go create mode 100644 internal/telemetry/stdlogsink.go create mode 100644 internal/telemetry/teesink.go create mode 100644 internal/telemetry/telemetry.go create mode 100644 internal/telemetry/tracer.go create mode 100644 internal/usecases/mapper.go create mode 100644 internal/usecases/mediabridge.go create mode 100644 internal/usecases/tracker.go create mode 100644 media.db.sql create mode 100644 sonar-project.properties create mode 100644 workspace.dsl diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..6397fab --- /dev/null +++ b/.air.toml @@ -0,0 +1,44 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./tmp/main" + cmd = "go build -o ./tmp/main ./cmd/handler/..." + delay = 0 + exclude_dir = ["assets", "tmp", "vendor", "testdata"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + time = false + +[misc] + clean_on_exit = false + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4dc63c1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,24 @@ +.dockerignore +.editorconfig +.git +.github +.gitignore +.vscode +*.dsl +*.env +*.env.local +*.json +*.md +*.puml +*.sql +*.toml +*.yaml +*.yml +bin +Dockerfile +internal/**/*.graphql +internal/**/genqlient.yaml +LICENSE +Makefile +src +tmp diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d230f11 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,28 @@ +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[.vscode/*.json] +insert_final_newline = false + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab + +[*.mk] +indent_style = tab + +[*.go] +indent_style = tab + +[go.mod] +indent_style = tab + +[.git*] +indent_style = tab diff --git a/.github/ISSUE_TEMPLATE/BUG.md b/.github/ISSUE_TEMPLATE/BUG.md new file mode 100644 index 0000000..e09374d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/BUG.md @@ -0,0 +1,33 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: + +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Device information:** + +- OS: [e.g. iOS] +- Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md b/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md new file mode 100644 index 0000000..d5a8d55 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md @@ -0,0 +1,22 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always +frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've +considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/PULL_REQUEST_TEMPLATE/SEMANTIC.md b/.github/PULL_REQUEST_TEMPLATE/SEMANTIC.md new file mode 100644 index 0000000..90b3846 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/SEMANTIC.md @@ -0,0 +1,21 @@ +# Changes + +## Non-breaking + +- [ ] `feat` - adds functionality for the user +- [ ] `fix` - fixes an issue for the user +- [ ] `docs` - changes to the documentation +- [ ] `style` - formatting, missing semi colons, etc +- [ ] `refactor` - refactoring production code, eg. renaming a variable +- [ ] `test` - adding missing tests, refactoring tests +- [ ] `chore` - configuration of lint, IDE, issue template, etc + +## Breaking + +- [ ] `feat!` - adds functionality for the user, but changes existing functionality +- [ ] `fix!` - fixes an issue for the user, but changes existing functionality +- [ ] `chore!` - updating yarn/lerna scripts, etc + +## Changelog + +{{changelog}} diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..3329f90 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: +- package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + labels: + - dependencies +- package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + labels: + - dependencies diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..761e9ec --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,30 @@ +name: "CodeQL" + +# yamllint disable-line rule:truthy +on: + schedule: + - cron: '0 9 * * 1' + +env: + GOLANG_VERSION: 1.16 + +jobs: + analyze: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v2 + - name: setup golang + uses: actions/setup-go@v3 + with: + go-version: ${{ env.GOLANG_VERSION }} + - name: initialize CodeQL + uses: github/codeql-action/init@v1 + with: + languages: go + - name: vendor + run: go mod vendor + - name: build + uses: wwmoraes/actions/golang/build@master + - name: CodeQL analysis + uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml new file mode 100644 index 0000000..0bb9175 --- /dev/null +++ b/.github/workflows/integration.yml @@ -0,0 +1,469 @@ +name: CI + +# yamllint disable-line rule:truthy +on: + release: + types: [published] + push: + branches: + - master + tags: + - '*' + paths: + - .github/workflows/integration.yml + - .golangci.yaml + - .goreleaser.yaml + - container-structure-test.yaml + - Dockerfile + - .dockerignore + - go.mod + - go.sum + - '**.go' + pull_request: + branches: + - master + paths: + - .github/workflows/integration.yml + - .golangci.yaml + - .goreleaser.yaml + - container-structure-test.yaml + - Dockerfile + - .dockerignore + - go.mod + - go.sum + - '**.go' + +env: + GOLANG_VERSION: "1.20" + GOLANG_FLAGS: -race -mod=readonly + WORK_DIR: /usr/src + +jobs: + metadata: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v2 + with: + fetch-depth: 0 + - name: calculate version + uses: paulhatch/semantic-version@v5.0.3 + id: version + with: + branch: ${{ github.ref_name }} + bump_each_commit: false + change_path: >- + cmd/handler + internal + go.mod + go.sum + major_pattern: /^BREAKING CHANGE:|^[^()!:]+(?:\([^()!:]+\))?!:/ + minor_pattern: /^feat(?:\([^()!:]+\))?:/ + search_commit_body: true + user_format_type: csv + version_format: ${major}.${minor}.${patch}-rc.${increment} + - name: generate container meta + id: meta + uses: docker/metadata-action@v4 + with: + context: workflow + images: ${{ github.repository }} + flavor: | + latest=true + # yamllint disable rule:line-length + labels: | + org.opencontainers.image.documentation=https://github.com/${{ github.repository }}/blob/master/README.md + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.url=https://hub.docker.com/r/${{ github.repository }} + org.opencontainers.image.version=${{ steps.version.outputs.version }} + # yamllint enable rule:line-length + tags: | + type=ref,event=branch + type=ref,event=pr + type=raw,value=${{ env.BRANCH }} + type=semver,pattern={{version}} + github-token: ${{ github.token }} + outputs: + major: ${{ steps.version.outputs.major }} + minor: ${{ steps.version.outputs.minor }} + patch: ${{ steps.version.outputs.patch }} + increment: ${{ steps.version.outputs.increment }} + version_type: ${{ steps.version.outputs.version_type }} + version: ${{ steps.version.outputs.version }} + tag: ${{ steps.version.outputs.version_tag }} + revision: ${{ steps.version.outputs.current_commit }} + authors: ${{ steps.version.outputs.authors }} + container-labels: ${{ steps.meta.outputs.labels }} + container-tags: ${{ steps.meta.outputs.tags }} + build: + runs-on: ubuntu-latest + needs: metadata + steps: + - name: checkout + uses: actions/checkout@v2 + - name: setup golang + uses: actions/setup-go@v3 + with: + go-version: ${{ env.GOLANG_VERSION }} + - name: set golang environment variables + uses: wwmoraes/actions/golang/env@master + - name: cache modules + uses: pat-s/always-upload-cache@v2.1.5 + with: + path: ${GOMODCACHE} + key: ${{ runner.os }}-modules-${{ hashFiles('go.sum') }} + restore-keys: | + ${{ runner.os }}-modules-${{ hashFiles('go.sum') }} + ${{ runner.os }}-modules- + - name: download modules + run: go mod download + - name: cache build + uses: pat-s/always-upload-cache@v2.1.5 + with: + path: ${GOCACHE} + key: ${{ runner.os }}-build-${{ hashFiles('**/*.go') }} + restore-keys: | + ${{ runner.os }}-build-${{ hashFiles('**/*.go') }} + ${{ runner.os }}-build- + - name: generate + run: go generate ./... + env: + VERSION: ${{ needs.metadata.outputs.version }}+${{ github.sha }} + - name: build + uses: wwmoraes/actions/golang/build@master + lint: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v2 + - name: setup golang + uses: actions/setup-go@v3 + with: + go-version: ${{ env.GOLANG_VERSION }} + - name: set golang environment variables + uses: wwmoraes/actions/golang/env@master + - name: cache modules + uses: pat-s/always-upload-cache@v2.1.5 + with: + path: ${GOMODCACHE} + key: ${{ runner.os }}-modules-${{ hashFiles('go.sum') }} + restore-keys: | + ${{ runner.os }}-modules-${{ hashFiles('go.sum') }} + ${{ runner.os }}-modules- + - name: download modules + run: go mod download + - name: cache lint + uses: pat-s/always-upload-cache@v2.1.5 + with: + path: ${HOME}/.cache/golangci-lint + key: ${{ runner.os }}-lint-${{ hashFiles('.golangci.yaml') }} + restore-keys: | + ${{ runner.os }}-lint-${{ hashFiles('.golangci.yaml') }} + ${{ runner.os }}-lint- + - name: lint + uses: wwmoraes/actions/golang/lint@master + id: lint + with: + work-dir: ${{ env.WORK_DIR }} + version: v1.46-alpine + - name: upload lint report + uses: actions/upload-artifact@v2 + if: always() + with: + name: lint-report + path: ${{ steps.lint.outputs.report-file }} + test: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v2 + - name: setup golang + uses: actions/setup-go@v3 + with: + go-version: ${{ env.GOLANG_VERSION }} + - name: set golang environment variables + uses: wwmoraes/actions/golang/env@master + - name: cache modules + uses: pat-s/always-upload-cache@v2.1.5 + with: + path: ${GOMODCACHE} + key: ${{ runner.os }}-modules-${{ hashFiles('go.sum') }} + restore-keys: | + ${{ runner.os }}-modules-${{ hashFiles('go.sum') }} + ${{ runner.os }}-modules- + - name: download modules + run: go mod download + - name: cache test + uses: pat-s/always-upload-cache@v2.1.5 + with: + path: ${GOCACHE} + key: ${{ runner.os }}-test-${{ hashFiles('**/*.go') }} + restore-keys: | + ${{ runner.os }}-test-${{ hashFiles('**/*.go') }} + ${{ runner.os }}-test- + - name: test + uses: wwmoraes/actions/golang/test@master + id: test + - name: security scan + uses: anchore/scan-action@v3 + with: + path: . + fail-build: true + - name: upload coverage report + uses: actions/upload-artifact@v2 + if: always() + with: + name: coverage-report + path: ${{ steps.test.outputs.cover-profile }} + - name: upload test report + uses: actions/upload-artifact@v2 + if: always() + with: + name: test-report + path: ${{ steps.test.outputs.report-file }} + report: + runs-on: ubuntu-latest + needs: [lint, test] + steps: + - name: checkout + uses: actions/checkout@v2 + with: + fetch-depth: 0 + - name: cache sonar scanner + uses: pat-s/always-upload-cache@v2.1.5 + with: + path: ${{ runner.temp }}/sonar-scanner/cache + # yamllint disable-line rule:line-length + key: ${{ runner.os }}-sonar-scanner-cache-${{ hashFiles('**/sonar-project.properties') }} + # yamllint disable rule:line-length + restore-keys: | + ${{ runner.os }}-sonar-scanner-cache-${{ hashFiles('**/sonar-project.properties') }} + ${{ runner.os }}-sonar-scanner-cache- + # yamllint enable rule:line-length + - name: download lint report + uses: actions/download-artifact@v2 + with: + name: lint-report + - name: download test report + uses: actions/download-artifact@v2 + with: + name: test-report + - name: download coverage report + uses: actions/download-artifact@v2 + with: + name: coverage-report + - name: run sonar scanner + uses: wwmoraes/actions/sonar-scanner@master + with: + token: ${{ secrets.SONAR_TOKEN }} + work-dir: ${{ env.WORK_DIR }} + home: ${{ runner.temp }}/sonar-scanner + release-binary: + runs-on: ubuntu-latest + needs: [metadata, build, lint, test] + if: >- + github.event_name == 'push' + && startsWith(github.ref, 'refs/tags/') + steps: + - name: checkout + uses: actions/checkout@v2 + with: + fetch-depth: 0 + - name: setup golang + uses: actions/setup-go@v3 + with: + go-version: ${{ env.GOLANG_VERSION }} + - name: set golang environment variables + uses: wwmoraes/actions/golang/env@master + - name: cache modules + uses: pat-s/always-upload-cache@v2.1.5 + with: + path: ${GOMODCACHE} + key: ${{ runner.os }}-modules-${{ hashFiles('go.sum') }} + restore-keys: | + ${{ runner.os }}-modules-${{ hashFiles('go.sum') }} + ${{ runner.os }}-modules- + - name: cache build + uses: pat-s/always-upload-cache@v2.1.5 + with: + path: ${GOCACHE} + key: ${{ runner.os }}-build-${{ hashFiles('**/*.go') }} + restore-keys: | + ${{ runner.os }}-build-${{ hashFiles('**/*.go') }} + ${{ runner.os }}-build- + - name: generate + run: go generate ./... + env: + VERSION: ${{ needs.metadata.outputs.version }} + - name: run goreleaser + uses: goreleaser/goreleaser-action@v3 + with: + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + docker: + runs-on: ubuntu-latest + needs: metadata + env: + # runner context is not available here... + GRYPE_DB_CACHE_TEMP_PATH: .cache/grype/db/ + steps: + - name: checkout + uses: actions/checkout@v2 + - name: set up QEMU + uses: docker/setup-qemu-action@v2 + - name: set up docker buildx + uses: docker/setup-buildx-action@v2 + - name: cache buildx + uses: pat-s/always-upload-cache@v2.1.5 + with: + path: ${{ runner.temp }}/.buildx-cache + # yamllint disable-line rule:line-length + key: ${{ runner.os }}-buildx-${{ hashFiles('Dockerfile', '.dockerignore') }} + # yamllint disable rule:line-length + restore-keys: | + ${{ runner.os }}-buildx-${{ hashFiles('Dockerfile', '.dockerignore') }} + ${{ runner.os }}-buildx- + # yamllint enable rule:line-length + - name: build single-arch test image + uses: docker/build-push-action@v3 + env: + DOCKER_BUILDKIT: 0 + BUILDKIT_INLINE_CACHE: 1 + with: + push: false + load: true + labels: ${{ needs.metadata.outputs.container-labels }} + cache-to: | + type=local,mode=max,dest=${{ runner.temp }}/.buildx-cache-new + cache-from: | + type=local,src=${{ runner.temp }}/.buildx-cache + ${{ needs.metadata.outputs.container-tags }} + ${{ github.repository }}:test + tags: ${{ github.repository }}:test + build-args: | + GOLANG_VERSION=${{ env.GOLANG_VERSION }} + VERSION=${{ needs.metadata.outputs.version }} + # fix to prevent ever-growing caches + # https://github.com/docker/build-push-action/issues/252 + # https://github.com/moby/buildkit/issues/1896 + - name: Move cache + run: | + rm -rf ${{ runner.temp }}/.buildx-cache + mv ${{ runner.temp }}/.buildx-cache-new ${{ runner.temp }}/.buildx-cache + - name: test structure + uses: brpaz/structure-tests-action@v1.1.2 + with: + image: ${{ github.repository }}:test + configFile: container-structure-test.yaml + - name: cache grype + uses: pat-s/always-upload-cache@v2.1.5 + with: + path: ${{ runner.temp }}/${{ env.GRYPE_DB_CACHE_TEMP_PATH }} + key: ${{ runner.os }}-grype-${{ hashFiles('.grype.yaml') }} + restore-keys: | + ${{ runner.os }}-grype-${{ hashFiles('.grype.yaml') }} + ${{ runner.os }}-grype- + - name: grype scan + uses: anchore/scan-action@v3 + with: + image: ${{ github.repository }}:test + fail-build: true + publish-image: + runs-on: ubuntu-latest + needs: [metadata, docker, lint, test] + if: >- + github.event_name == 'push' + && startsWith(github.ref, 'refs/tags/') + steps: + - name: checkout + uses: actions/checkout@v2 + - name: set up QEMU + uses: docker/setup-qemu-action@v2 + - name: set up docker buildx + uses: docker/setup-buildx-action@v2 + - name: cache buildx + uses: pat-s/always-upload-cache@v2.1.5 + with: + path: ${{ runner.temp }}/.buildx-cache + # yamllint disable-line rule:line-length + key: ${{ runner.os }}-buildx-${{ hashFiles('Dockerfile', '.dockerignore') }} + # yamllint disable rule:line-length + restore-keys: | + ${{ runner.os }}-buildx-${{ hashFiles('Dockerfile', '.dockerignore') }} + ${{ runner.os }}-buildx- + # yamllint enable rule:line-length + - name: login to DockerHub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + - name: build multi-arch image + uses: docker/build-push-action@v3 + env: + DOCKER_BUILDKIT: 1 + BUILDKIT_INLINE_CACHE: 1 + with: + push: false + load: true + platforms: linux/amd64,linux/arm/v7,linux/arm64 + labels: ${{ needs.metadata.outputs.container-labels }} + cache-to: | + type=local,mode=max,dest=${{ runner.temp }}/.buildx-cache-new + cache-from: | + type=local,src=${{ runner.temp }}/.buildx-cache + ${{ needs.metadata.outputs.container-tags }} + ${{ github.repository }}:latest + build-args: | + GOLANG_VERSION=${{ env.GOLANG_VERSION }} + VERSION=${{ needs.metadata.outputs.version }} + tags: ${{ needs.metadata.outputs.container-tags }} + # fix to prevent ever-growing caches + # https://github.com/docker/build-push-action/issues/252 + # https://github.com/moby/buildkit/issues/1896 + - name: Move cache + run: | + rm -rf ${{ runner.temp }}/.buildx-cache + mv ${{ runner.temp }}/.buildx-cache-new ${{ runner.temp }}/.buildx-cache + - name: test release + uses: brpaz/structure-tests-action@v1.1.2 + with: + image: ${{ github.repository }}:test + configFile: container-structure-test.yaml + - name: security scan + uses: anchore/scan-action@v3 + with: + image: ${{ github.repository }}:test + fail-build: true + - name: push multi-arch image + uses: docker/build-push-action@v3 + env: + DOCKER_BUILDKIT: 1 + BUILDKIT_INLINE_CACHE: 1 + with: + push: true + platforms: linux/amd64,linux/arm/v7,linux/arm64 + labels: ${{ needs.metadata.outputs.container-labels }} + cache-to: | + type=local,mode=max,dest=${{ runner.temp }}/.buildx-cache-new + cache-from: | + type=local,src=${{ runner.temp }}/.buildx-cache + ${{ needs.metadata.outputs.container-tags }} + ${{ github.repository }}:master + ${{ github.repository }}:latest + build-args: | + GOLANG_VERSION=${{ env.GOLANG_VERSION }} + VERSION=${{ needs.metadata.outputs.version }} + tags: ${{ needs.metadata.outputs.container-tags }} + # fix to prevent ever-growing caches + # https://github.com/docker/build-push-action/issues/252 + # https://github.com/moby/buildkit/issues/1896 + - name: Move cache + run: | + rm -rf ${{ runner.temp }}/.buildx-cache + mv ${{ runner.temp }}/.buildx-cache-new ${{ runner.temp }}/.buildx-cache + - name: update DockerHub description + uses: meeDamian/sync-readme@v1.0.6 + with: + pass: ${{ secrets.DOCKER_PASSWORD }} + description: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9d8e7c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,54 @@ +*.env +*.env.local +bin/ +dist/ + +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..e0b6ab1 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,19 @@ +run: + modules-download-mode: readonly + issues-exit-code: 0 + timeout: 2m +output: + format: colored-line-number +linters-settings: + enable: + - stylecheck + - unconvert + - gosec + - errorlint + - maligned + - interfacer + - unparam + - whitespace + - wrapcheck + - prealloc + - nolintlint diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..28a760d --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,38 @@ +before: + hooks: + - go mod download +builds: +- id: handler + main: ./cmd/handler + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + goarch: + - amd64 + - arm + - arm64 + goarm: + - 7 + ignore: + - goos: darwin + goarch: arm + - goos: darwin + goarch: 386 +checksum: + name_template: 'checksums.txt' +snapshot: + name_template: "{{ .Tag }}-rc" +changelog: + sort: asc + filters: + exclude: + - '^docs:' + - '^test:' + - '^ci:' +release: + github: + owner: wwmoraes + name: anilistarr + prerelease: auto diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..208d139 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,68 @@ +## See https://pre-commit.com for more information +## See https://pre-commit.com/hooks.html for more hooks +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.3.0 + hooks: + - id: check-added-large-files + - id: check-case-conflict + - id: check-merge-conflict + args: + - --assume-in-merge + - id: check-vcs-permalinks + - id: check-yaml + args: [--allow-multiple-documents] + - id: detect-private-key + # - id: end-of-file-fixer + - id: fix-byte-order-marker + - id: mixed-line-ending + # - id: no-commit-to-branch + - id: trailing-whitespace +- repo: https://gitlab.com/bmares/check-json5 + rev: v1.0.0 + hooks: + - id: check-json5 +- repo: https://github.com/editorconfig-checker/editorconfig-checker.python + rev: 2.4.0 + hooks: + - id: editorconfig-checker + exclude: vscode/.config/Code/User/globalStorage/.* +- repo: https://github.com/igorshubovych/markdownlint-cli + rev: v0.32.1 + hooks: + - id: markdownlint +- repo: https://github.com/hadolint/hadolint + rev: v2.10.0 + hooks: + - id: hadolint +- repo: https://github.com/tekwizely/pre-commit-golang + rev: v1.0.0-beta.5 + hooks: + - id: golangci-lint-repo-mod + name: golangci-lint + - id: go-mod-tidy + name: go mod tidy + - id: go-build-repo-mod + name: go build + - id: go-test-repo-mod + name: go test + ## + ## Invoking Custom Go Tools + ## - Configured *entirely* through the `args` attribute, ie: + ## args: [ go, test, ./... ] + ## - Use the `name` attribute to provide better messaging when the hook runs + ## - Use the `alias` attribute to be able invoke your hook via `pre-commit run` + ## + # - id: my-cmd + # - id: my-cmd-mod + # - id: my-cmd-pkg + # - id: my-cmd-repo + # - id: my-cmd-repo-mod + # - id: my-cmd-repo-pkg +ci: + skip: + - hadolint + - golangci-lint-repo-mod + - go-mod-tidy + - go-build-repo-mod + - go-test-repo-mod diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..377a949 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,53 @@ +{ + "cSpell.words": [ + "Anidb", + "anilist", + "anilistarr", + "Anisearch", + "bbolt", + "fribbs", + "genqlient", + "HTTPURL", + "Imdb", + "jmoiron", + "Kitsu", + "Livechart", + "logr", + "modernc", + "nolint", + "opentelemetry", + "otel", + "otelhttp", + "otlp", + "otlpmetric", + "otlpmetricgrpc", + "otlpr", + "otlptrace", + "otlptracegrpc", + "promhttp", + "redisotel", + "romaji", + "sdkmetric", + "sdktrace", + "semconv", + "Sonarr", + "sqlx", + "themoviedb", + "thetvdb", + "Tmdb", + "tvdb", + "Upsert", + "usecases" + ], + "go.coverageDecorator": { + "type": "highlight", + "coveredHighlightColor": "rgba(64,128,128,0.5)", + "uncoveredHighlightColor": "rgba(128,64,64,0.25)", + "coveredBorderColor": "rgba(64,128,128,0.5)", + "uncoveredBorderColor": "rgba(128,64,64,0.25)", + "coveredGutterStyle": "blockblue", + "uncoveredGutterStyle": "slashyellow" + }, + "go.coverageOptions": "showBothCoveredAndUncoveredCode", + "go.coverMode": "atomic" +} \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..587aa6b --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,77 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or +advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic +address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a +professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at github@artero.dev. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 1.4, available at + + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7a112b7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,146 @@ +# Contributing + +When contributing to this repository, please first discuss the change you wish +to make via issue, email, or any other method with the owners of this repository +before making a change. + +Please note we have a code of conduct, please follow it in all your interactions +with the project. + +## Pull Request Process + +1. Ensure any install or build dependencies are removed before the end of the +layer when doing a build. +2. Update the README.md with details of changes to the interface, this includes +new environment variables, exposed ports, useful file locations and container +parameters. +3. Increase the version numbers in any examples files and the README.md to the +new version that this Pull Request would represent. The versioning scheme we use +is [SemVer](http://semver.org/). +4. You may merge the Pull Request in once you have the sign-off of two other +developers, or if you do not have permission to do that, you may request the +second reviewer to merge it for you. + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, +and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall +community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement. All complaints +will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +project community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at . + +Community Impact Guidelines were inspired by Mozilla's +[code of conduct enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +. Translations are available at +. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6fd2dee --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +ARG GOLANG_VERSION=1.20 +FROM golang:${GOLANG_VERSION}-alpine AS build + +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download -x + +COPY . . +ARG VERSION +RUN go generate ./... && go build -o ./bin/handler ./cmd/handler/... + + +FROM scratch + +LABEL org.opencontainers.image.authors="William Artero " +LABEL org.opencontainers.image.description="anilist custom list provider for sonarr/radarr" +LABEL org.opencontainers.image.licenses="MIT" +LABEL org.opencontainers.image.title="Anilistarr" +LABEL org.opencontainers.image.vendor="William Artero " + +CMD ["/usr/local/bin/handler"] +EXPOSE 8080 + +ARG GOLANG_VERSION +LABEL org.opencontainers.image.base.name="golang:${GOLANG_VERSION}-alpine" +ARG VERSION +LABEL org.opencontainers.image.version="${VERSION}" + +COPY --from=build /src/bin/handler /usr/local/bin/handler + +USER 20000:20000 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7cb9525 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 William Artero + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fbed192 --- /dev/null +++ b/Makefile @@ -0,0 +1,62 @@ +-include .env +-include .env.local +export + +CMD_SOURCE_FILES := $(shell find cmd -type f -name '*.go') +INTERNAL_SOURCE_FILES := $(shell find internal -type f -name '*.go') +SOURCE_FILES := $(CMD_SOURCE_FILES) $(INTERNAL_SOURCE_FILES) + +.PHONY: build +build: generate + go build -o ./bin/ ./... + +.PHONY: release +release: + goreleaser release --clean --skip-publish --skip-announce --snapshot + +.PHONY: generate +generate: + go generate ./... + +.PHONY: test +test: + go test -race -v ./... + +.PHONY: coverage +coverage: coverage.out + @go tool cover -func=$< + +%.out: $(SOURCE_FILES) + @go test -race -cover -coverprofile=$@ -v ./... + +IMAGE ?= wwmoraes/anilistarr +# needs go install github.com/Khan/genqlient@latest +anilist: + @cd internal/drivers/anilist && genqlient + +image: CREATED=$(shell date -u +"%Y-%m-%dT%TZ") +image: REVISION=$(shell git log -n 1 --format="%H") +image: VERSION=$(patsubst v%,%,$(shell git describe --tags 2> /dev/null || echo "0.1.0-rc.0")) +image: + docker build --load $(if ${TARGET},--target ${TARGET}) \ + -t ${IMAGE} \ + --build-arg VERSION=${VERSION} \ + --label org.opencontainers.image.created=${CREATED} \ + --label org.opencontainers.image.revision=${REVISION} \ + --label org.opencontainers.image.documentation=https://github.com/${IMAGE}/blob/master/README.md \ + --label org.opencontainers.image.source=https://github.com/${IMAGE} \ + --label org.opencontainers.image.url=https://hub.docker.com/r/${IMAGE} \ + . + @container-structure-test test -c container-structure-test.yaml -i wwmoraes/anilistarr + +run: + @go run ./cmd/handler/... + +redis-cli: + @redis-cli -p 16379 + +redis-proxy: + @flyctl redis proxy + +get-user: + @curl -v "http://127.0.0.1:8080/user?name=wwmoraes" diff --git a/README.md b/README.md new file mode 100644 index 0000000..88b93c3 --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# anilistarr + +> anilist custom list provider for sonarr/radarr + +![Status](https://img.shields.io/badge/status-active-success.svg) +[![GitHub Issues](https://img.shields.io/github/issues/wwmoraes/anilistarr.svg)](https://github.com/wwmoraes/anilistarr/issues) +[![GitHub Pull Requests](https://img.shields.io/github/issues-pr/wwmoraes/anilistarr.svg)](https://github.com/wwmoraes/anilistarr/pulls) + +[![pre-commit.ci status](https://results.pre-commit.ci/badge/github/wwmoraes/anilistarr/master.svg)](https://results.pre-commit.ci/latest/github/wwmoraes/anilistarr/master) +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=wwmoraes_anilistarr&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=wwmoraes_anilistarr) +[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=wwmoraes_anilistarr&metric=coverage)](https://sonarcloud.io/summary/new_code?id=wwmoraes_anilistarr) +[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=wwmoraes_anilistarr&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=wwmoraes_anilistarr) +[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=wwmoraes_anilistarr&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=wwmoraes_anilistarr) + +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](/LICENSE) +[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fwwmoraes%2Fanilistarr.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2Fwwmoraes%2Fanilistarr?ref=badge_shield) +[![CII Best Practices](https://bestpractices.coreinfrastructure.org/projects/7718/badge)](https://bestpractices.coreinfrastructure.org/projects/7718) + +[![Docker Image Size (latest semver)](https://img.shields.io/docker/image-size/wwmoraes/anilistarr)](https://hub.docker.com/r/wwmoraes/anilistarr) +[![Docker Image Version (latest semver)](https://img.shields.io/docker/v/wwmoraes/anilistarr?label=image%20version)](https://hub.docker.com/r/wwmoraes/anilistarr) +[![Docker Pulls](https://img.shields.io/docker/pulls/wwmoraes/anilistarr)](https://hub.docker.com/r/wwmoraes/anilistarr) + +--- + +## 📝 Table of Contents + +- [About](#-about) +- [Getting Started](#-getting-started) +- [Deployment](#-deployment) +- [Usage](#-usage) +- [Built Using](#-built-using) +- [TODO](./TODO.md) +- [Contributing](./CONTRIBUTING.md) +- [Authors](#-authors) +- [Acknowledgments](#-acknowledgements) + +## 🧐 About + +Converts an Anilist user watching list to a custom list format which *arr apps support. + +It works by fetching the user info directly from Anilist thanks to its API, and +converts the IDs using community-provided mappings. + +## 🏁 Getting Started + +Clone the repository and use `go run ./cmd/handler/...` to get the REST API up. + +## 🔧 Running the tests + +Explain how to run the automated tests for this system. + +## 🎈 Usage + +Configuration in general is a WIP. The code supports distinct storage and cache +options and even has built-in support for Redis and Bolt as caches already. +The handler needs flags/configuration file support to allow switching at +runtime. + +## 🚀 Deployment + +The `handler` binary is statically compiled and serves both the REST API and the +telemetry to an OTLP endpoint. Extra requirements depend on which storage and +cache technologies you've chosen; e.g. using SQLite/Bolt requires a database +file. The Docker image provided contains the handler alone, for instance. + +## 🔧 Built Using + +- [Golang](https://go.dev) - Base language +- [Chi](https://go-chi.io) - net/HTTP-compatible router that doesn't suck +- [genqlient](https://github.com/Khan/genqlient) - type-safe GraphQL client generator +- [xo](https://github.com/xo/xo) - SQL client code generator +- [Open Telemetry](https://opentelemetry.io) - Observability + +## 🧑‍💻 Authors + +- [@wwmoraes](https://github.com/wwmoraes) - Idea & Initial work + +## 🎉 Acknowledgements + +- Anilist for their great service and API +- The community for their efforts to map IDs between services + - + - diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..aca1f3d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,47 @@ +# Security Guidelines + +## How security is managed on this project + +Contributors take security seriously and wants to ensure that we maintain a +secure environment for our customers and that we also provide secure solutions +for the open source community. To help us achieve these goals, please note the +following before using this software: + +- Review the software license to understand the contributor's obligations in +terms of warranties and suitability for purpose +- For any questions or concerns about security, you can +[create an issue][new-issue] or [report a vulnerability][new-sec-issue] +- We request that you work with our security team and opt for +[responsible disclosure][disclosure] using the guidelines below +- All security related issues and pull requests you make should be tagged with +"security" for easy identification +- Please monitor this repository and update your environment in a timely manner +as we release patches and updates + +## Responsibly Disclosing Security Bugs + +If you find a security bug in this repository, please work with contributors +following responsible disclosure principles and these guidelines: + +- Do not submit a normal issue or pull request in our public repository, instead +[report it directly][new-sec-issue]. +- We will review your submission and may follow up for additional details +- If you have a patch, we will review it and approve it privately; once approved +for release you can submit it as a pull request publicly in the repository (we +give credit where credit is due) +- We will keep you informed during our investigation, feel free to check in for +a status update +- We will release the fix and publicly disclose the issue as soon as possible, +but want to ensure we due properly due diligence before releasing +- Please do not publicly blog or post about the security issue until after we +have updated the public repo so that other downstream users have an opportunity +to patch + +## Contact / Misc + +If you have any questions, please reach out directly by +[creating an issue][new-issue]. + +[new-issue]: https://github.com/wwmoraes/anilistarr/issues/new/choose +[new-sec-issue]: https://github.com/wwmoraes/anilistarr/security/advisories/new +[disclosure]: https://corporate.walmart.com/article/responsible-disclosure-policy diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..44e24b4 --- /dev/null +++ b/TODO.md @@ -0,0 +1,24 @@ +# to-dos + +## code + +### Telemetry + +- +- + +### store + +- + +### providers + +- + +## environment + +### production + +- +- +- diff --git a/anilistarr.puml b/anilistarr.puml new file mode 100644 index 0000000..321c4cb --- /dev/null +++ b/anilistarr.puml @@ -0,0 +1,142 @@ +@startuml components + +package entities { + struct Media { + AnilistID uint64 + TvdbID uint64 + Type string + } + + struct SonarrCustomEntry { + TvdbID uint64 + } + + struct SonarrCustomList <> { + []SonarrCustomEntry + } +} + +package usecases { + interface Mapper { + +MapIDs([]string) []string + +MapID(string) string + +Refresh() + } + + interface Tracker { + +GetUserID(name string) string + +GetMediaList(userId string) []Media + } + + class MediaLinker { + +GenerateCustomList(name string) SonarrCustomList + +GetUserID(name string) string + } +} + +package adapters { + package mapper <> { + interface MetadataSource { + Fetch() + } + + interface Metadata { + GetAnilistID() string + GetTvdbID() string + } + + interface Store { + PutMedia(Context, Media) + } + + interface AnilistStore { + MappingByAnilistID(Context, string) Media + } + + class JSONFile + class JSONURL + class AnilistMapper + } + + package cache <> { + interface Cache { + GetString(ctx, key) string + SetString(ctx, key, value) + } + + class CachedTracker + } +} + +package drivers { + package providers <> { + struct FribbsEntry + entity FribbsSource + entity LocalJSON + } + + package stores <> { + class Sql + } + + package caches <> { + class Redis + class Bolt + } + + package trackers <> { + class Anilist + } +} + +package cmd { + package api { + class RestAPI <> + entity "main" as apiMain + + RestAPI o-- apiMain + } + + package cli { + entity "main" as cliMain + } +} + +'' visual hack to force both outer-level packages on the same rank +drivers -[hidden] cmd + +'' entities +SonarrCustomEntry --* SonarrCustomList +'' use-cases +Media <-- MediaLinker +SonarrCustomList <-- MediaLinker +MediaLinker o--> Mapper +MediaLinker o--> Tracker +'' adapters/mapper +Mapper <|-[dashed]- AnilistMapper +AnilistMapper o--> Metadata +AnilistMapper o--> MetadataSource +AnilistMapper o--> AnilistStore +Store <|-[dashed]- AnilistStore +MetadataSource <|-[dashed]- JSONFile +MetadataSource <|-[dashed]- JSONURL +'' adapters/cache +Tracker <|-[dashed]- CachedTracker +CachedTracker o--> Cache +CachedTracker o--> Tracker +'' drivers/providers +FribbsEntry -* FribbsSource +JSONURL <|-- FribbsSource +JSONFile <|-- LocalJSON +Metadata <|-[dashed]- FribbsEntry +'' drivers/stores +AnilistStore <|-[dashed]- Sql +'' drivers/caches +Cache <|-[dashed]- Bolt +Cache <|-[dashed]- Redis +'' drivers/trackers +Tracker <|-[dashed]- Anilist +'' cmd +MediaLinker <--o RestAPI + +@enduml diff --git a/cmd/handler/limiter.go b/cmd/handler/limiter.go new file mode 100644 index 0000000..0817f50 --- /dev/null +++ b/cmd/handler/limiter.go @@ -0,0 +1,37 @@ +package main + +import ( + "fmt" + "math" + "net/http" + "strconv" + + "github.com/wwmoraes/anilistarr/internal/telemetry" + "golang.org/x/time/rate" +) + +func Limiter(limiter *rate.Limiter) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + span := telemetry.SpanFromContext(r.Context()) + + re := limiter.Reserve() + if !re.OK() { + err := fmt.Errorf("misconfigured rate limiter on the API, cannot act") + http.Error(w, err.Error(), http.StatusInternalServerError) + span.RecordError(err) + return + } + + if re.Delay() > 0 { + re.Cancel() + w.Header().Add("Retry-After", strconv.FormatFloat(math.Ceil(re.Delay().Seconds()), 'f', 0, 64)) + w.WriteHeader(http.StatusTooManyRequests) + http.Error(w, http.StatusText(http.StatusTooManyRequests), http.StatusTooManyRequests) + return + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/cmd/handler/main.go b/cmd/handler/main.go new file mode 100644 index 0000000..6575568 --- /dev/null +++ b/cmd/handler/main.go @@ -0,0 +1,116 @@ +package main + +import ( + "context" + "net/http" + "os" + "os/signal" + "time" + + "github.com/go-chi/chi/v5" + "github.com/wwmoraes/anilistarr/internal/drivers/caches" + "github.com/wwmoraes/anilistarr/internal/telemetry" + "github.com/wwmoraes/anilistarr/internal/usecases" + "golang.org/x/time/rate" +) + +type ServerContext string + +const ListenerAddressKey ServerContext = "listener-address" + +func main() { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + + log := telemetry.DefaultLogger() + log.Info("staring up", "name", telemetry.NAME, "version", telemetry.VERSION) + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + addr := "127.0.0.1:" + port + + mapper, err := NewAnilistBridge( + os.Getenv("ANILIST_GRAPHQL_ENDPOINT"), + &caches.RedisOptions{ + Addr: os.Getenv("REDIS_ADDRESS"), + Username: os.Getenv("REDIS_USERNAME"), + Password: os.Getenv("REDIS_PASSWORD"), + ClientName: "anilistarr-handler", + }, + ) + assert(err) + + api, err := NewRestAPI(mapper) + assert(err) + + shutdown, err := telemetry.InstrumentAll(ctx, os.Getenv("OTLP_ENDPOINT")) + assert(err) + defer shutdown(context.Background()) + + ctx = telemetry.ContextWithLogger(ctx) + + r := chi.NewRouter() + r.Use(telemetry.NewHandlerMiddleware) + r.Use(Limiter(rate.NewLimiter(rate.Every(time.Minute), 10))) + r.Get("/list", api.GetList) + r.Get("/map", api.GetMap) + r.Get("/user", api.GetUser) + + server := http.Server{ + Addr: addr, + Handler: r, + } + + // update mapping every week + go scheduledRefresh(ctx, mapper, time.Hour*24*7) + go server.ListenAndServe() //nolint:errcheck + log.Info("server listening", "address", addr) + + <-ctx.Done() + cancel() + + gracefulShutdown(&server) +} + +func assert(err error) { + if err == nil { + return + } + + log := telemetry.DefaultLogger() + + log.Error(err, "assertion failed") + os.Exit(1) +} + +func scheduledRefresh(ctx context.Context, linker *usecases.MediaBridge, interval time.Duration) { + log := telemetry.LoggerFromContext(ctx) + + for { + log.Info("refreshing linker metadata") + err := linker.Refresh(ctx) + assert(err) + log.Info("linker metadata refreshed") + + select { + case <-ctx.Done(): + log.Info("scheduled refresh stopped") + return + case <-time.After(interval): + continue + } + } +} + +func gracefulShutdown(server *http.Server) { + log := telemetry.DefaultLogger() + + log.Info("shutting down, press Ctrl+C again to force") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + assert(server.Shutdown(ctx)) +} diff --git a/cmd/handler/restapi.go b/cmd/handler/restapi.go new file mode 100644 index 0000000..3a338bd --- /dev/null +++ b/cmd/handler/restapi.go @@ -0,0 +1,135 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/wwmoraes/anilistarr/internal/entities" + "github.com/wwmoraes/anilistarr/internal/telemetry" + "github.com/wwmoraes/anilistarr/internal/usecases" +) + +type RestAPI interface { + GetList(http.ResponseWriter, *http.Request) + GetMap(http.ResponseWriter, *http.Request) + GetUser(http.ResponseWriter, *http.Request) +} + +type restAPI struct { + mapper *usecases.MediaBridge +} + +func NewRestAPI(mapper *usecases.MediaBridge) (RestAPI, error) { + return &restAPI{ + mapper: mapper, + }, nil +} + +func (face *restAPI) GetList(w http.ResponseWriter, r *http.Request) { + span := telemetry.SpanFromContext(r.Context()) + + username := r.URL.Query().Get("username") + if username == "" { + w.WriteHeader(http.StatusBadRequest) + return + } + + customList, err := face.mapper.GenerateCustomList(r.Context(), username) + if err != nil { + w.WriteHeader(http.StatusBadGateway) + fmt.Fprintln(w, err.Error()) + span.RecordError(err) + return + } + + data, err := json.Marshal(customList) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintln(w, err.Error()) + span.RecordError(err) + return + } + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err = w.Write(data) + span.RecordError(err) +} + +func (face *restAPI) GetMap(w http.ResponseWriter, r *http.Request) { + span := telemetry.SpanFromContext(r.Context()) + + resp, err := http.Get("https://github.com/Fribb/anime-lists/raw/master/anime-list-full.json") + if err != nil { + w.WriteHeader(http.StatusBadGateway) + fmt.Fprintln(w, err.Error()) + span.RecordError(err) + return + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintln(w, err.Error()) + span.RecordError(err) + return + } + + var entries []entities.Media + err = json.Unmarshal(data, &entries) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintln(w, err.Error()) + span.RecordError(err) + return + } + + telemetry.Int(span, "entries", len(entries)) + + records := make(map[string]string, len(entries)) + for _, entry := range entries { + if entry.AnilistID == "" || entry.TvdbID == "" { + continue + } + + records[entry.AnilistID] = entry.TvdbID + } + + newData, err := json.MarshalIndent(records, "", " ") + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintln(w, err.Error()) + span.RecordError(err) + return + } + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, string(newData)) +} + +func (face *restAPI) GetUser(w http.ResponseWriter, r *http.Request) { + span := telemetry.SpanFromContext(r.Context()) + + name := r.URL.Query().Get("name") + if name == "" { + w.WriteHeader(http.StatusBadRequest) + return + } + + userId, err := face.mapper.GetUserID(r.Context(), name) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintln(w, err.Error()) + span.RecordError(err) + return + } + + w.Header().Add("X-Anilist-User-Name", name) + w.Header().Add("X-Anilist-User-Id", userId) + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, userId) +} diff --git a/cmd/handler/wire.go b/cmd/handler/wire.go new file mode 100644 index 0000000..ec65584 --- /dev/null +++ b/cmd/handler/wire.go @@ -0,0 +1,41 @@ +package main + +import ( + "fmt" + + "github.com/wwmoraes/anilistarr/internal/adapters" + "github.com/wwmoraes/anilistarr/internal/drivers/anilist" + "github.com/wwmoraes/anilistarr/internal/drivers/caches" + "github.com/wwmoraes/anilistarr/internal/drivers/providers" + "github.com/wwmoraes/anilistarr/internal/drivers/stores" + "github.com/wwmoraes/anilistarr/internal/usecases" +) + +func NewAnilistBridge(anilistEndpoint string, cacheOptions *caches.RedisOptions) (*usecases.MediaBridge, error) { + tracker := anilist.New(anilistEndpoint, 50) + if cacheOptions != nil { + // cache, err := caches.NewRedis(cacheOptions) + cache, err := caches.NewBolt("tmp/cache.db", nil) + if err != nil { + return nil, fmt.Errorf("bolt cache initialization failed: %w", err) + } + + tracker, err = adapters.NewCachedTracker(tracker, cache) + if err != nil { + return nil, fmt.Errorf("cached adapter initialization failed: %w", err) + } + } + + store, err := stores.NewSQL("sqlite", "tmp/media.db?loc=auto") + if err != nil { + return nil, fmt.Errorf("sql store initialization failed: %w", err) + } + + return &usecases.MediaBridge{ + Tracker: tracker, + Mapper: &adapters.AnilistMapper{ + Source: providers.FribbsSource, + Store: store, + }, + }, nil +} diff --git a/cmd/version/main.go b/cmd/version/main.go new file mode 100644 index 0000000..22cab2f --- /dev/null +++ b/cmd/version/main.go @@ -0,0 +1,172 @@ +package main + +import ( + "bytes" + "flag" + "fmt" + "go/format" + "os" + "text/template" + + "golang.org/x/mod/modfile" +) + +const templateString = `// Code generated by go generate. DO NOT EDIT. + +package {{ .Package }} + +const ( + {{ .Constants.Environment }} = "{{ .Environment }}" + {{ .Constants.Module }} = "{{ .Module }}" + {{ .Constants.Version }} = "{{ .Version }}" + {{ .Constants.Name }} = "{{ .Name }}" + {{ .Constants.Namespace }} = "{{ .Namespace }}" +)` + +type templateData struct { + Environment string + Module string + Name string + Namespace string + Package string + Version string + Constants constantNames +} + +type constantNames struct { + Environment string + Module string + Name string + Namespace string + Version string +} + +func main() { + constEnvironment := flag.String( + "const-environment", + "ENVIRONMENT", + "environment constant name", + ) + constModule := flag.String( + "const-module", + "MODULE", + "module constant name", + ) + constName := flag.String( + "const-name", + "NAME", + "application/service name constant name", + ) + constNamespace := flag.String( + "const-namespace", + "NAMESPACE", + "namespace constant name", + ) + constVersion := flag.String( + "const-version", + "VERSION", + "version constant name", + ) + + environment := flag.String( + "environment", + "development", + "target environment of the build", + ) + modFile := flag.String( + "mod", + "go.mod", + "Go mod file path", + ) + name := flag.String( + "name", + "", + "application/service name", + ) + namespace := flag.String( + "namespace", + "", + "namespace of the application (as per Open Telemetry definitions)", + ) + packageName := flag.String( + "package", + "main", + "name of the target package", + ) + version := flag.String( + "version", + "", + "version of the application", + ) + + output := flag.String( + "output", + "", + "output file to generate", + ) + flag.Parse() + + assert(checkFlag("name")) + assert(checkFlag("namespace")) + assert(checkFlag("version")) + assert(checkFlag("output")) + + constantsTemplate, err := template.New("").Parse(templateString) + assert(err) + + modData, err := os.ReadFile(*modFile) + assert(err) + + mod, err := modfile.Parse(*modFile, modData, nil) + assert(err) + + var buf bytes.Buffer + err = constantsTemplate.Execute(&buf, templateData{ + Environment: *environment, + Module: mod.Module.Mod.Path, + Name: *name, + Namespace: *namespace, + Package: *packageName, + Version: *version, + Constants: constantNames{ + Environment: *constEnvironment, + Module: *constModule, + Name: *constName, + Namespace: *constNamespace, + Version: *constVersion, + }, + }) + assert(err) + + data, err := format.Source(buf.Bytes()) + assert(err) + + fd, err := os.Create(*output) + assert(err) + defer fd.Close() + + _, err = fd.Write(data) + assert(err) +} + +func assert(err error) { + if err == nil { + return + } + + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(1) +} + +func checkFlag(name string) error { + f := flag.Lookup(name) + if f == nil { + return fmt.Errorf("flag %s not defined", name) + } + + if len(f.Value.String()) == 0 { + return fmt.Errorf("flag %s must be set", name) + } + + return nil +} diff --git a/container-structure-test.yaml b/container-structure-test.yaml new file mode 100644 index 0000000..112f755 --- /dev/null +++ b/container-structure-test.yaml @@ -0,0 +1,51 @@ +schemaVersion: "2.0.0" +fileExistenceTests: + - name: handler binary + path: /usr/local/bin/handler + shouldExist: true + gid: 0 + uid: 0 + permissions: -rwxr-xr-x + isExecutableBy: any + +metadataTest: + cmd: [/usr/local/bin/handler] + workdir: / + entrypoint: [] + exposedPorts: + - "8080" + labels: + - key: org.opencontainers.image.authors + value: ^([^<>]+? <[^@>]+@[^>]+>(, )?)+$ + isRegex: true + - key: org.opencontainers.image.description + value: .+ + isRegex: true + - key: org.opencontainers.image.documentation + value: https?://.+ + isRegex: true + - key: org.opencontainers.image.licenses + value: .+ + isRegex: true + - key: org.opencontainers.image.source + value: https?://.+ + isRegex: true + - key: org.opencontainers.image.title + value: .+ + isRegex: true + - key: org.opencontainers.image.url + value: https?://.+ + isRegex: true + - key: org.opencontainers.image.vendor + value: ^([^<>]+? <[^@>]+@[^>]+>(, )?)+$ + isRegex: true + - key: org.opencontainers.image.version + # yamllint disable-line rule:line-length + value: '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$' + isRegex: true + - key: org.opencontainers.image.created + value: '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?Z$' + isRegex: true + - key: org.opencontainers.image.revision + value: "^[0-9a-fA-F]{40}$" + isRegex: true diff --git a/fly.toml b/fly.toml new file mode 100644 index 0000000..d6a421e --- /dev/null +++ b/fly.toml @@ -0,0 +1,19 @@ +# fly.toml app configuration file generated for anilistarr on 2023-07-27T00:01:05+02:00 +# +# See https://fly.io/docs/reference/configuration/ for information about how to use this file. +# + +app = "anilistarr" +primary_region = "ams" + +[http_service] +internal_port = 8080 +force_https = true +auto_stop_machines = true +auto_start_machines = true +min_machines_running = 0 +processes = ["app"] + +[metrics] +port = 9091 # default for most prometheus clients +path = "/metrics" # default for most prometheus clients diff --git a/gen.go b/gen.go new file mode 100644 index 0000000..0874333 --- /dev/null +++ b/gen.go @@ -0,0 +1,9 @@ +package anilistarr + +//go:generate go run ./cmd/version/... -package telemetry -name handler -namespace media -version "$VERSION" -output internal/telemetry/constants.go + +//// DISABLED: this generator needs a db to derive the code from +// go:generate xo schema "file:tmp/media.db?loc=auto" -o internal/drivers/stores/models + +//// TODO: genqlient generator +//// cd internal/drivers/anilist && genqlient diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..aaf0f85 --- /dev/null +++ b/go.mod @@ -0,0 +1,66 @@ +module github.com/wwmoraes/anilistarr + +go 1.19 + +require ( + github.com/Khan/genqlient v0.6.0 + github.com/MrAlias/otlpr v0.2.0 + github.com/XSAM/otelsql v0.23.0 + github.com/go-chi/chi/v5 v5.0.10 + github.com/go-logr/logr v1.2.4 + github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 + github.com/redis/go-redis/v9 v9.0.5 + go.etcd.io/bbolt v1.3.7 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.42.0 + go.opentelemetry.io/contrib/instrumentation/runtime v0.42.0 + go.opentelemetry.io/otel v1.16.0 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.39.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.16.0 + go.opentelemetry.io/otel/metric v1.16.0 + go.opentelemetry.io/otel/sdk v1.16.0 + go.opentelemetry.io/otel/sdk/metric v0.39.0 + go.opentelemetry.io/otel/trace v1.16.0 + golang.org/x/mod v0.10.0 + golang.org/x/time v0.0.0-20191024005414-555d28b269f0 + google.golang.org/grpc v1.57.0 + modernc.org/sqlite v1.24.0 +) + +require ( + github.com/cenkalti/backoff/v4 v4.2.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/felixge/httpsnoop v1.0.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0 // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/vektah/gqlparser/v2 v2.5.1 // indirect + go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.16.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric v0.39.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.16.0 // indirect + go.opentelemetry.io/proto/otlp v0.19.0 // indirect + golang.org/x/net v0.10.0 // indirect + golang.org/x/sync v0.2.0 // indirect + golang.org/x/sys v0.10.0 // indirect + golang.org/x/text v0.9.0 // indirect + golang.org/x/tools v0.8.0 // indirect + google.golang.org/genproto v0.0.0-20230526161137-0005af68ea54 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20230525234035-dd9d682886f9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20230525234030-28d5490b6b19 // indirect + google.golang.org/protobuf v1.30.0 // indirect + lukechampine.com/uint128 v1.2.0 // indirect + modernc.org/cc/v3 v3.40.0 // indirect + modernc.org/ccgo/v3 v3.16.13 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/opt v0.1.3 // indirect + modernc.org/strutil v1.1.3 // indirect + modernc.org/token v1.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a7dd963 --- /dev/null +++ b/go.sum @@ -0,0 +1,541 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= +cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= +cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= +cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= +cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= +cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= +cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= +cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= +cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= +cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= +cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= +cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/Khan/genqlient v0.6.0 h1:Bwb1170ekuNIVIwTJEqvO8y7RxBxXu639VJOkKSrwAk= +github.com/Khan/genqlient v0.6.0/go.mod h1:rvChwWVTqXhiapdhLDV4bp9tz/Xvtewwkon4DpWWCRM= +github.com/MrAlias/otlpr v0.2.0 h1:dhf6kuadIhtzanrjluGnDH//po7S92FniNgyobgi6Mc= +github.com/MrAlias/otlpr v0.2.0/go.mod h1:H+SQlbqgaFsTlKSNAxE+pGhHEZ/5SqY7uUW+Lt7H3RA= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/XSAM/otelsql v0.23.0 h1:NsJQS9YhI1+RDsFqE9mW5XIQmPmdF/qa8qQOLZN8XEA= +github.com/XSAM/otelsql v0.23.0/go.mod h1:oX4LXMsb+9lAZhvHjUS61oQP/hbcJRadWHnBKNL+LuM= +github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/bsm/ginkgo/v2 v2.7.0 h1:ItPMPH90RbmZJt5GtkcNvIRuGEdwlBItdNVoyzaNQao= +github.com/bsm/ginkgo/v2 v2.7.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w= +github.com/bsm/gomega v1.26.0 h1:LhQm+AFcgV2M0WyKroMASzAzCAJVpAxQXv4SaI9a69Y= +github.com/bsm/gomega v1.26.0/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= +github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= +github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= +github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= +github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk= +github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= +github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4= +github.com/golang/glog v1.1.0 h1:/d3pCKDPWNnvIWe0vVUpNP32qc8U3PDVxySP/y360qE= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0 h1:BZHcxBETFHIdVyhyEfOvn/RdU/QGdLI4y34qQGjGWO0= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0/go.mod h1:hgWBS7lorOAVIJEQMi4ZsPv9hVvWI6+ch50m39Pf2Ks= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= +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/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 h1:EaDatTxkdHG+U3Bk4EUr+DZ7fOGwTfezUiUJMaIcaho= +github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5/go.mod h1:fyalQWdtzDBECAQFBJuQe5bzQ02jGd5Qcbgb97Flm7U= +github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 h1:EfpWLLCyXw8PSM2/XNJLjI3Pb27yVE+gIAfeqp8LUCc= +github.com/redis/go-redis/extra/redisotel/v9 v9.0.5/go.mod h1:WZjPDy7VNzn77AAfnAfVjZNvfJTYfPetfZk5yoSTLaQ= +github.com/redis/go-redis/v9 v9.0.5 h1:CuQcn5HIEeK7BgElubPP8CGtE0KakrnbBSTLjathl5o= +github.com/redis/go-redis/v9 v9.0.5/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= +github.com/vektah/gqlparser/v2 v2.5.1 h1:ZGu+bquAY23jsxDRcYpWjttRZrUz07LbiY77gUOHcr4= +github.com/vektah/gqlparser/v2 v2.5.1/go.mod h1:mPgqFBu/woKTVYWyNk8cO3kh4S/f4aRFZrvOnp3hmCs= +github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.etcd.io/bbolt v1.3.7 h1:j+zJOnnEjF/kyHlDDgGnVL/AIqIJPq8UoB2GSNfkUfQ= +go.etcd.io/bbolt v1.3.7/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.42.0 h1:pginetY7+onl4qN1vl0xW/V/v6OBZ0vVdH+esuJgvmM= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.42.0/go.mod h1:XiYsayHc36K3EByOO6nbAXnAWbrUxdjUROCEeeROOH8= +go.opentelemetry.io/contrib/instrumentation/runtime v0.42.0 h1:EbmAUG9hEAMXyfWEasIt2kmh/WmXUznUksChApTgBGc= +go.opentelemetry.io/contrib/instrumentation/runtime v0.42.0/go.mod h1:rD9feqRYP24P14t5kmhNMqsqm1jvKmpx2H2rKVw52V8= +go.opentelemetry.io/otel v1.16.0 h1:Z7GVAX/UkAXPKsy94IU+i6thsQS4nb7LviLpnaNeW8s= +go.opentelemetry.io/otel v1.16.0/go.mod h1:vl0h9NUa1D5s1nv3A5vZOYWn8av4K8Ml6JDeHrT/bx4= +go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.16.0 h1:t4ZwRPU+emrcvM2e9DHd0Fsf0JTPVcbfa/BhTDF03d0= +go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.16.0/go.mod h1:vLarbg68dH2Wa77g71zmKQqlQ8+8Rq3GRG31uc0WcWI= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric v0.39.0 h1:f6BwB2OACc3FCbYVznctQ9V6KK7Vq6CjmYXJ7DeSs4E= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric v0.39.0/go.mod h1:UqL5mZ3qs6XYhDnZaW1Ps4upD+PX6LipH40AoeuIlwU= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.39.0 h1:rm+Fizi7lTM2UefJ1TO347fSRcwmIsUAaZmYmIGBRAo= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.39.0/go.mod h1:sWFbI3jJ+6JdjOVepA5blpv/TJ20Hw+26561iMbWcwU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.16.0 h1:cbsD4cUcviQGXdw8+bo5x2wazq10SKz8hEbtCRPcU78= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.16.0/go.mod h1:JgXSGah17croqhJfhByOLVY719k1emAXC8MVhCIJlRs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.16.0 h1:TVQp/bboR4mhZSav+MdgXB8FaRho1RC8UwVn3T0vjVc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.16.0/go.mod h1:I33vtIe0sR96wfrUcilIzLoA3mLHhRmz9S9Te0S3gDo= +go.opentelemetry.io/otel/metric v1.16.0 h1:RbrpwVG1Hfv85LgnZ7+txXioPDoh6EdbZHo26Q3hqOo= +go.opentelemetry.io/otel/metric v1.16.0/go.mod h1:QE47cpOmkwipPiefDwo2wDzwJrlfxxNYodqc4xnGCo4= +go.opentelemetry.io/otel/sdk v1.16.0 h1:Z1Ok1YsijYL0CSJpHt4cS3wDDh7p572grzNrBMiMWgE= +go.opentelemetry.io/otel/sdk v1.16.0/go.mod h1:tMsIuKXuuIWPBAOrH+eHtvhTL+SntFtXF9QD68aP6p4= +go.opentelemetry.io/otel/sdk/metric v0.39.0 h1:Kun8i1eYf48kHH83RucG93ffz0zGV1sh46FAScOTuDI= +go.opentelemetry.io/otel/sdk/metric v0.39.0/go.mod h1:piDIRgjcK7u0HCL5pCA4e74qpK/jk3NiUoAHATVAmiI= +go.opentelemetry.io/otel/trace v1.16.0 h1:8JRpaObFoW0pxuVPapkgH8UhHQj+bJW8jJsCZEu5MQs= +go.opentelemetry.io/otel/trace v1.16.0/go.mod h1:Yt9vYq1SdNz3xdjZZK7wcXv1qv2pwLkqr2QVwea0ef0= +go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= +go.opentelemetry.io/proto/otlp v0.19.0 h1:IVN6GR+mhC4s5yfcTbmzHYODqvWAp3ZedA2SJPI1Nnw= +go.opentelemetry.io/proto/otlp v0.19.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U= +go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk= +golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI= +golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0 h1:/5xXl8Y5W96D+TtHSlonuFqGHIWVuyCkGJLwGh9JJFs= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.8.0 h1:vSDcovVPld282ceKgDimkRSC8kpaH1dgyc9UMzlt84Y= +golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= +google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= +google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= +google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20230526161137-0005af68ea54 h1:9NWlQfY2ePejTmfwUH1OWwmznFa+0kKcHGPDvcPza9M= +google.golang.org/genproto v0.0.0-20230526161137-0005af68ea54/go.mod h1:zqTuNwFlFRsw5zIts5VnzLQxSRqh+CGOTVMlYbY0Eyk= +google.golang.org/genproto/googleapis/api v0.0.0-20230525234035-dd9d682886f9 h1:m8v1xLLLzMe1m5P+gCTF8nJB9epwZQUBERm20Oy1poQ= +google.golang.org/genproto/googleapis/api v0.0.0-20230525234035-dd9d682886f9/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230525234030-28d5490b6b19 h1:0nDDozoAU19Qb2HwhXadU8OcsiO/09cnTqhUtq2MEOM= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230525234030-28d5490b6b19/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= +google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= +google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= +google.golang.org/grpc v1.57.0 h1:kfzNeI/klCGD2YPMUlaGNT3pxvYfga7smW3Vth8Zsiw= +google.golang.org/grpc v1.57.0/go.mod h1:Sd+9RMTACXwmub0zcNY2c4arhtrbBYD1AUHI/dt16Mo= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= +google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +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= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI= +lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= +modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw= +modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0= +modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw= +modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY= +modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk= +modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sqlite v1.24.0 h1:EsClRIWHGhLTCX44p+Ri/JLD+vFGo0QGjasg2/F9TlI= +modernc.org/sqlite v1.24.0/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= +modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY= +modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= +modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY= +modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg= +modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/internal/adapters/anilistmapper.go b/internal/adapters/anilistmapper.go new file mode 100644 index 0000000..9984c53 --- /dev/null +++ b/internal/adapters/anilistmapper.go @@ -0,0 +1,90 @@ +package adapters + +import ( + "context" + "fmt" + + "github.com/wwmoraes/anilistarr/internal/entities" + "github.com/wwmoraes/anilistarr/internal/telemetry" +) + +type AnilistMapper struct { + Source MetadataSource[Metadata] + Store AnilistStore +} + +func (mapper *AnilistMapper) Close() error { + return mapper.Store.Close() +} + +func (mapper *AnilistMapper) MapID(ctx context.Context, anilistId string) (string, error) { + ctx, span := telemetry.StartFunction(ctx) + defer span.End() + + media, err := mapper.Store.MappingByAnilistID(ctx, anilistId) + + if err != nil { + return "", span.Assert(fmt.Errorf("failed to map ID %s: %w", anilistId, err)) + } + + if media == nil { + return "", span.Assert(nil) + } + + return media.TvdbID, span.Assert(nil) +} + +func (mapper *AnilistMapper) MapIDs(ctx context.Context, anilistIds []string) ([]string, error) { + ctx, span := telemetry.StartFunction(ctx) + defer span.End() + + records, err := mapper.Store.MappingByAnilistIDBulk(ctx, anilistIds) + if err != nil { + return nil, span.Assert(fmt.Errorf("failed to map IDs: %w", err)) + } + + ids := make([]string, len(records)) + for index, record := range records { + ids[index] = record.TvdbID + } + + // for _, sourceId := range anilistIds { + // targetId, err := mapper.MapID(ctx, sourceId) + // if err != nil { + // return nil, span.Assert(fmt.Errorf("failed to map IDs: %w", err)) + // } + + // ids = append(ids, targetId) + // } + + return ids, span.Assert(nil) +} + +func (mapper *AnilistMapper) Refresh(ctx context.Context) error { + ctx, span := telemetry.StartFunction(ctx) + defer span.End() + + data, err := mapper.Source.Fetch(ctx, nil) + if err != nil { + return span.Assert(fmt.Errorf("failed to refresh anilist mapper: %w", err)) + } + + medias := make([]*entities.Media, 0, len(data)) + for _, entry := range data { + if entry.GetTvdbID() == "0" || entry.GetAnilistID() == "0" { + continue + } + + medias = append(medias, &entities.Media{ + AnilistID: entry.GetAnilistID(), + TvdbID: entry.GetTvdbID(), + }) + } + + err = mapper.Store.PutMediaBulk(ctx, medias) + if err != nil { + return span.Assert(fmt.Errorf("failed to store media during refresh: %w", err)) + } + + return span.Assert(nil) +} diff --git a/internal/adapters/cache.go b/internal/adapters/cache.go new file mode 100644 index 0000000..04a9608 --- /dev/null +++ b/internal/adapters/cache.go @@ -0,0 +1,13 @@ +package adapters + +import ( + "context" + "io" +) + +type Cache interface { + io.Closer + + GetString(ctx context.Context, key string) (string, error) + SetString(ctx context.Context, key, value string) error +} diff --git a/internal/adapters/cachedtracker.go b/internal/adapters/cachedtracker.go new file mode 100644 index 0000000..decc6bb --- /dev/null +++ b/internal/adapters/cachedtracker.go @@ -0,0 +1,64 @@ +package adapters + +import ( + "context" + "fmt" + + "github.com/wwmoraes/anilistarr/internal/telemetry" + "github.com/wwmoraes/anilistarr/internal/usecases" +) + +const ( + cacheKeyUserID string = "anilist:user:%s:id" +) + +type cachedTracker struct { + cache Cache + tracker usecases.Tracker +} + +func NewCachedTracker(tracker usecases.Tracker, cache Cache) (usecases.Tracker, error) { + return &cachedTracker{ + cache: cache, + tracker: tracker, + }, nil +} + +func (wrapper *cachedTracker) GetUserID(ctx context.Context, name string) (string, error) { + ctx, span := telemetry.StartFunction(ctx) + defer span.End() + + key := fmt.Sprintf(cacheKeyUserID, name) + + span.AddEvent("try cache") + userId, err := wrapper.cache.GetString(ctx, key) + if err != nil { + return "", span.Assert(fmt.Errorf("failed to get user ID: %w", err)) + } + + if userId != "" { + span.AddEvent("cache hit") + return userId, span.Assert(err) + } + + span.AddEvent("cache miss") + userId, err = wrapper.tracker.GetUserID(ctx, name) + if err != nil { + return "", span.Assert(fmt.Errorf("failed to get user ID: %w", err)) + } + + return userId, span.Assert(wrapper.cache.SetString(ctx, key, userId)) +} + +func (wrapper *cachedTracker) GetMediaListIDs(ctx context.Context, userId string) ([]string, error) { + ctx, span := telemetry.StartFunction(ctx) + defer span.End() + + ids, err := wrapper.tracker.GetMediaListIDs(ctx, userId) + + return ids, span.Assert(err) +} + +func (wrapper *cachedTracker) Close() error { + return wrapper.cache.Close() +} diff --git a/internal/adapters/jsonlocalpath.go b/internal/adapters/jsonlocalpath.go new file mode 100644 index 0000000..a349a76 --- /dev/null +++ b/internal/adapters/jsonlocalpath.go @@ -0,0 +1,25 @@ +package adapters + +import ( + "context" + "fmt" + "os" + + "github.com/wwmoraes/anilistarr/internal/telemetry" +) + +type JSONLocalPath[F Metadata] string + +func (source JSONLocalPath[F]) Fetch(ctx context.Context) ([]Metadata, error) { + _, span := telemetry.StartFunction(ctx) + defer span.End() + + data, err := os.ReadFile(string(source)) + if err != nil { + return nil, span.Assert(fmt.Errorf("failed to read local JSON: %w", err)) + } + + metadata, err := unmarshalJSON[F](data) + + return metadata, span.Assert(err) +} diff --git a/internal/adapters/jsonsourceurl.go b/internal/adapters/jsonsourceurl.go new file mode 100644 index 0000000..5d4dfff --- /dev/null +++ b/internal/adapters/jsonsourceurl.go @@ -0,0 +1,36 @@ +package adapters + +import ( + "context" + "fmt" + "io" + "net/http" + + "github.com/wwmoraes/anilistarr/internal/telemetry" +) + +type JSONSourceURL[F Metadata] string + +func (source JSONSourceURL[F]) Fetch(ctx context.Context, client Getter) ([]Metadata, error) { + _, span := telemetry.StartFunction(ctx) + defer span.End() + + if client == nil { + client = http.DefaultClient + } + + res, err := client.Get(string(source)) + if err != nil { + return nil, span.Assert(fmt.Errorf("failed to fetch remote JSON: %w", err)) + } + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, span.Assert(fmt.Errorf("failed to read fetched JSON response body: %w", err)) + } + + metadata, err := unmarshalJSON[F](data) + + return metadata, span.Assert(err) +} diff --git a/internal/adapters/jsonsourceurl_test.go b/internal/adapters/jsonsourceurl_test.go new file mode 100644 index 0000000..70e1690 --- /dev/null +++ b/internal/adapters/jsonsourceurl_test.go @@ -0,0 +1,86 @@ +package adapters_test + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/wwmoraes/anilistarr/internal/adapters" +) + +type mockClient struct { + data map[string]string +} + +func (client *mockClient) Get(url string) (*http.Response, error) { + data, ok := client.data[url] + if !ok { + return &http.Response{ + Status: http.StatusText(http.StatusNotFound), + StatusCode: http.StatusNotFound, + }, nil + } + + return &http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(data)), + }, nil +} + +type mockData struct { + AnilistID string `json:"anilist_id,omitempty"` + TvdbID string `json:"tvdb_id,omitempty"` +} + +func (data mockData) GetAnilistID() string { + return data.AnilistID +} + +func (data mockData) GetTvdbID() string { + return data.TvdbID +} + +func TestJSONSourceURL_Fetch(t *testing.T) { + expectedData := []adapters.Metadata{ + mockData{ + AnilistID: "123", + TvdbID: "456", + }, + } + + bytesData, err := json.Marshal(expectedData) + if err != nil { + t.Error(err) + } + + ctx := context.Background() + + provider := adapters.JSONSourceURL[mockData]("/anime-lists.json") + + metadata, err := provider.Fetch(ctx, &mockClient{ + data: map[string]string{ + "/anime-lists.json": string(bytesData), + }, + }) + if err != nil { + t.Error(err) + } + + if len(metadata) != len(expectedData) { + t.Errorf("metadata length mismatch: got %d, wanted %d", len(metadata), len(expectedData)) + } + + for index, entry := range metadata { + if entry.GetAnilistID() != expectedData[index].GetAnilistID() { + t.Errorf("metadata anilist ID mismatch: got %s, expected %s", entry.GetAnilistID(), expectedData[index].GetAnilistID()) + } + + if entry.GetTvdbID() != expectedData[index].GetTvdbID() { + t.Errorf("metadata tvdb ID mismatch: got %s, expected %s", entry.GetTvdbID(), expectedData[index].GetTvdbID()) + } + } +} diff --git a/internal/adapters/metadata.go b/internal/adapters/metadata.go new file mode 100644 index 0000000..3997cb1 --- /dev/null +++ b/internal/adapters/metadata.go @@ -0,0 +1,26 @@ +package adapters + +import ( + "encoding/json" + "fmt" +) + +type Metadata interface { + GetAnilistID() string + GetTvdbID() string +} + +func unmarshalJSON[F Metadata](data []byte) ([]Metadata, error) { + var dataEntries []F + err := json.Unmarshal(data, &dataEntries) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal JSON: %w", err) + } + + entries := make([]Metadata, len(dataEntries)) + for index, entry := range dataEntries { + entries[index] = entry + } + + return entries, nil +} diff --git a/internal/adapters/metasource.go b/internal/adapters/metasource.go new file mode 100644 index 0000000..762f234 --- /dev/null +++ b/internal/adapters/metasource.go @@ -0,0 +1,14 @@ +package adapters + +import ( + "context" + "net/http" +) + +type Getter interface { + Get(string) (*http.Response, error) +} + +type MetadataSource[T Metadata] interface { + Fetch(ctx context.Context, client Getter) ([]T, error) +} diff --git a/internal/adapters/store.go b/internal/adapters/store.go new file mode 100644 index 0000000..d5c6356 --- /dev/null +++ b/internal/adapters/store.go @@ -0,0 +1,22 @@ +package adapters + +import ( + "context" + "io" + + "github.com/wwmoraes/anilistarr/internal/entities" +) + +type Store interface { + io.Closer + + PutMedia(ctx context.Context, media *entities.Media) error + PutMediaBulk(ctx context.Context, medias []*entities.Media) error +} + +type AnilistStore interface { + Store + + MappingByAnilistID(ctx context.Context, anilistId string) (*entities.Media, error) + MappingByAnilistIDBulk(ctx context.Context, anilistIds []string) ([]*entities.Media, error) +} diff --git a/internal/drivers/anilist/anilist.go b/internal/drivers/anilist/anilist.go new file mode 100644 index 0000000..7cfd280 --- /dev/null +++ b/internal/drivers/anilist/anilist.go @@ -0,0 +1,101 @@ +package anilist + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/Khan/genqlient/graphql" + "github.com/wwmoraes/anilistarr/internal/telemetry" + "github.com/wwmoraes/anilistarr/internal/usecases" +) + +const ( + // interval reflects the current Anilist API rate limits + // https://anilist.gitbook.io/anilist-apiv2-docs/overview/rate-limiting + interval time.Duration = time.Minute + // requests reflects the current Anilist API rate limits + // https://anilist.gitbook.io/anilist-apiv2-docs/overview/rate-limiting + requests int = 90 +) + +type Tracker struct { + Client graphql.Client + PageSize int +} + +func New(anilistEndpoint string, pageSize int) usecases.Tracker { + return &Tracker{ + Client: NewGraphQLClient(anilistEndpoint), + PageSize: pageSize, + } +} + +func NewGraphQLClient(anilistEndpoint string) graphql.Client { + return graphql.NewClient(anilistEndpoint, NewRatedClient(interval, requests, nil)) +} + +func (tracker *Tracker) GetUserID(ctx context.Context, name string) (string, error) { + ctx, span := telemetry.StartFunction(ctx) + defer span.End() + + res, err := GetUserByName(ctx, tracker.Client, name) + if err != nil { + return "", span.Assert(fmt.Errorf("failed to get user by name: %w", err)) + } + + return strconv.Itoa(res.User.Id), span.Assert(nil) +} + +func (tracker *Tracker) GetMediaListIDs(ctx context.Context, userId string) ([]string, error) { + log := telemetry.LoggerFromContext(ctx) + ctx, span := telemetry.StartFunction(ctx) + defer span.End() + + userIdInt, err := strconv.Atoi(userId) + if err != nil { + return nil, span.Assert(fmt.Errorf("failed to convert user ID to integer: %w", err)) + } + + page := 1 + anilistIds := make([]string, 0, tracker.PageSize) + telemetry.Int(span, "page.size", tracker.PageSize) + for { + if ctx.Err() != nil { + break + } + + log.Info("requesting media list", "page", page) + extCtx, extSpan := telemetry.Start( + ctx, + "anilist.GetWatching", + telemetry.WithSpanKindClient(), + telemetry.WithInt("page", page), + ) + res, err := GetWatching(extCtx, tracker.Client, userIdInt, page, tracker.PageSize) + err = extSpan.Assert(err) + extSpan.End() + if err != nil { + return nil, span.Assert(fmt.Errorf("failed to fetch media list: %w", err)) + } + + if len(res.Page.MediaList) == 0 { + break + } + + // far from optimal, I know, yet it works fine unless the use has thousands + // of entries... + for _, entry := range res.Page.MediaList { + anilistIds = append(anilistIds, strconv.Itoa(entry.Media.Id)) + } + + page++ + } + + return anilistIds, span.Assert(nil) +} + +func (tracker *Tracker) Close() error { + return nil +} diff --git a/internal/drivers/anilist/generated.go b/internal/drivers/anilist/generated.go new file mode 100644 index 0000000..502fd34 --- /dev/null +++ b/internal/drivers/anilist/generated.go @@ -0,0 +1,200 @@ +// Code generated by github.com/Khan/genqlient, DO NOT EDIT. + +package anilist + +import ( + "context" + + "github.com/Khan/genqlient/graphql" +) + +// GetUserByNameResponse is returned by GetUserByName on success. +type GetUserByNameResponse struct { + // User query + User GetUserByNameUser `json:"User"` +} + +// GetUser returns GetUserByNameResponse.User, and is useful for accessing the field via an interface. +func (v *GetUserByNameResponse) GetUser() GetUserByNameUser { return v.User } + +// GetUserByNameUser includes the requested fields of the GraphQL type User. +// The GraphQL type's documentation follows. +// +// A user +type GetUserByNameUser struct { + // The id of the user + Id int `json:"id"` +} + +// GetId returns GetUserByNameUser.Id, and is useful for accessing the field via an interface. +func (v *GetUserByNameUser) GetId() int { return v.Id } + +// GetWatchingPage includes the requested fields of the GraphQL type Page. +// The GraphQL type's documentation follows. +// +// Page of data +type GetWatchingPage struct { + MediaList []GetWatchingPageMediaList `json:"mediaList"` +} + +// GetMediaList returns GetWatchingPage.MediaList, and is useful for accessing the field via an interface. +func (v *GetWatchingPage) GetMediaList() []GetWatchingPageMediaList { return v.MediaList } + +// GetWatchingPageMediaList includes the requested fields of the GraphQL type MediaList. +// The GraphQL type's documentation follows. +// +// List of anime or manga +type GetWatchingPageMediaList struct { + Media GetWatchingPageMediaListMedia `json:"media"` +} + +// GetMedia returns GetWatchingPageMediaList.Media, and is useful for accessing the field via an interface. +func (v *GetWatchingPageMediaList) GetMedia() GetWatchingPageMediaListMedia { return v.Media } + +// GetWatchingPageMediaListMedia includes the requested fields of the GraphQL type Media. +// The GraphQL type's documentation follows. +// +// Anime or Manga +type GetWatchingPageMediaListMedia struct { + // The id of the media + Id int `json:"id"` + // The mal id of the media + IdMal int `json:"idMal"` + // The official titles of the media in various languages + Title GetWatchingPageMediaListMediaTitle `json:"title"` +} + +// GetId returns GetWatchingPageMediaListMedia.Id, and is useful for accessing the field via an interface. +func (v *GetWatchingPageMediaListMedia) GetId() int { return v.Id } + +// GetIdMal returns GetWatchingPageMediaListMedia.IdMal, and is useful for accessing the field via an interface. +func (v *GetWatchingPageMediaListMedia) GetIdMal() int { return v.IdMal } + +// GetTitle returns GetWatchingPageMediaListMedia.Title, and is useful for accessing the field via an interface. +func (v *GetWatchingPageMediaListMedia) GetTitle() GetWatchingPageMediaListMediaTitle { return v.Title } + +// GetWatchingPageMediaListMediaTitle includes the requested fields of the GraphQL type MediaTitle. +// The GraphQL type's documentation follows. +// +// The official titles of the media in various languages +type GetWatchingPageMediaListMediaTitle struct { + // The romanization of the native language title + Romaji string `json:"romaji"` +} + +// GetRomaji returns GetWatchingPageMediaListMediaTitle.Romaji, and is useful for accessing the field via an interface. +func (v *GetWatchingPageMediaListMediaTitle) GetRomaji() string { return v.Romaji } + +// GetWatchingResponse is returned by GetWatching on success. +type GetWatchingResponse struct { + Page GetWatchingPage `json:"Page"` +} + +// GetPage returns GetWatchingResponse.Page, and is useful for accessing the field via an interface. +func (v *GetWatchingResponse) GetPage() GetWatchingPage { return v.Page } + +// __GetUserByNameInput is used internally by genqlient +type __GetUserByNameInput struct { + Name string `json:"name"` +} + +// GetName returns __GetUserByNameInput.Name, and is useful for accessing the field via an interface. +func (v *__GetUserByNameInput) GetName() string { return v.Name } + +// __GetWatchingInput is used internally by genqlient +type __GetWatchingInput struct { + UserId int `json:"userId"` + Page int `json:"page"` + PerPage int `json:"perPage"` +} + +// GetUserId returns __GetWatchingInput.UserId, and is useful for accessing the field via an interface. +func (v *__GetWatchingInput) GetUserId() int { return v.UserId } + +// GetPage returns __GetWatchingInput.Page, and is useful for accessing the field via an interface. +func (v *__GetWatchingInput) GetPage() int { return v.Page } + +// GetPerPage returns __GetWatchingInput.PerPage, and is useful for accessing the field via an interface. +func (v *__GetWatchingInput) GetPerPage() int { return v.PerPage } + +// The query or mutation executed by GetUserByName. +const GetUserByName_Operation = ` +query GetUserByName ($name: String!) { + User(name: $name) { + id + } +} +` + +func GetUserByName( + ctx context.Context, + client graphql.Client, + name string, +) (*GetUserByNameResponse, error) { + req := &graphql.Request{ + OpName: "GetUserByName", + Query: GetUserByName_Operation, + Variables: &__GetUserByNameInput{ + Name: name, + }, + } + var err error + + var data GetUserByNameResponse + resp := &graphql.Response{Data: &data} + + err = client.MakeRequest( + ctx, + req, + resp, + ) + + return &data, err +} + +// The query or mutation executed by GetWatching. +const GetWatching_Operation = ` +query GetWatching ($userId: Int!, $page: Int!, $perPage: Int!) { + Page(page: $page, perPage: $perPage) { + mediaList(userId: $userId, type: ANIME, status: CURRENT) { + media { + id + idMal + title { + romaji + } + } + } + } +} +` + +func GetWatching( + ctx context.Context, + client graphql.Client, + userId int, + page int, + perPage int, +) (*GetWatchingResponse, error) { + req := &graphql.Request{ + OpName: "GetWatching", + Query: GetWatching_Operation, + Variables: &__GetWatchingInput{ + UserId: userId, + Page: page, + PerPage: perPage, + }, + } + var err error + + var data GetWatchingResponse + resp := &graphql.Response{Data: &data} + + err = client.MakeRequest( + ctx, + req, + resp, + ) + + return &data, err +} diff --git a/internal/drivers/anilist/genqlient.yaml b/internal/drivers/anilist/genqlient.yaml new file mode 100644 index 0000000..e2e5feb --- /dev/null +++ b/internal/drivers/anilist/genqlient.yaml @@ -0,0 +1,6 @@ +# Default genqlient config; for full documentation see: +# https://github.com/Khan/genqlient/blob/main/docs/genqlient.yaml +schema: schema.graphql +operations: +- queries.graphql +generated: generated.go diff --git a/internal/drivers/anilist/httpclient.go b/internal/drivers/anilist/httpclient.go new file mode 100644 index 0000000..98af763 --- /dev/null +++ b/internal/drivers/anilist/httpclient.go @@ -0,0 +1,91 @@ +package anilist + +import ( + "fmt" + "net/http" + "strconv" + "time" + + "github.com/Khan/genqlient/graphql" + "github.com/wwmoraes/anilistarr/internal/telemetry" + "go.opentelemetry.io/otel/semconv/v1.20.0/httpconv" + "golang.org/x/time/rate" +) + +type ratedClient struct { + client *http.Client + rater *rate.Limiter +} + +func NewRatedClient(interval time.Duration, requests int, base *http.Client) graphql.Doer { + if base == nil { + base = http.DefaultClient + } + + return &ratedClient{ + client: base, + rater: rate.NewLimiter(rate.Every(interval), requests), + } +} + +// TODO check if we can update the rate limiter safely (without resetting the current count) +func (c *ratedClient) Do(req *http.Request) (*http.Response, error) { + span := telemetry.SpanFromContext(req.Context()) + err := c.rater.Wait(req.Context()) + if err != nil { + return nil, err + } + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + + span.SetAttributes(httpconv.ResponseHeader(telemetry.WantedRequestHeaders( + resp.Header, + "X-RateLimit-Remaining", + "X-RateLimit-Limit", + "Retry-After", + ))...) + + if resp.StatusCode != http.StatusTooManyRequests { + return resp, nil + } + + //// exceptional case: we wait and retry + //// first we make sure the rate limiter has the right burst + remaining, err := strconv.Atoi(resp.Header.Get("X-RateLimit-Remaining")) + if err != nil { + return nil, err + } + c.rater.SetBurst(remaining) + + //// this should never happen if the rate limiter is properly set and the API + //// lives by its documentation + if remaining != 0 { + return nil, fmt.Errorf("WARNING inconsistent upstream API - try increasing the rate interval") + } + + //// update future bursts + burst, err := strconv.Atoi(resp.Header.Get("X-RateLimit-Limit")) + if err != nil { + return nil, err + } + + reset, err := strconv.ParseInt(resp.Header.Get("X-RateLimit-Reset"), 10, 0) + if err != nil { + return nil, err + } + + c.rater.SetBurstAt(time.Unix(reset, 0), burst) + + //// respect the wait time proposed by the API + after, err := strconv.ParseInt(resp.Header.Get("Retry-After"), 10, 0) + if err != nil { + return nil, err + } + + time.Sleep(time.Duration(after) * time.Second) + + return c.Do(req) +} diff --git a/internal/drivers/anilist/queries.graphql b/internal/drivers/anilist/queries.graphql new file mode 100644 index 0000000..ed03efa --- /dev/null +++ b/internal/drivers/anilist/queries.graphql @@ -0,0 +1,19 @@ +query GetUserByName($name:String!){ + User(name: $name) { + id + } +} + +query GetWatching($userId: Int!, $page: Int!, $perPage:Int!) { + Page(page:$page, perPage: $perPage) { + mediaList(userId: $userId, type: ANIME, status: CURRENT) { + media { + id + idMal + title { + romaji + } + } + } + } +} diff --git a/internal/drivers/anilist/schema.graphql b/internal/drivers/anilist/schema.graphql new file mode 100644 index 0000000..4706542 --- /dev/null +++ b/internal/drivers/anilist/schema.graphql @@ -0,0 +1,10025 @@ +""" +Notification for when a activity is liked +""" +type ActivityLikeNotification { + """ + The id of the Notification + """ + id: Int! + + """ + The id of the user who liked to the activity + """ + userId: Int! + + """ + The type of notification + """ + type: NotificationType + + """ + The id of the activity which was liked + """ + activityId: Int! + + """ + The notification context text + """ + context: String + + """ + The time the notification was created at + """ + createdAt: Int + + """ + The liked activity + """ + activity: ActivityUnion + + """ + The user who liked the activity + """ + user: User +} + +""" +Notification for when authenticated user is @ mentioned in activity or reply +""" +type ActivityMentionNotification { + """ + The id of the Notification + """ + id: Int! + + """ + The id of the user who mentioned the authenticated user + """ + userId: Int! + + """ + The type of notification + """ + type: NotificationType + + """ + The id of the activity where mentioned + """ + activityId: Int! + + """ + The notification context text + """ + context: String + + """ + The time the notification was created at + """ + createdAt: Int + + """ + The liked activity + """ + activity: ActivityUnion + + """ + The user who mentioned the authenticated user + """ + user: User +} + +""" +Notification for when a user is send an activity message +""" +type ActivityMessageNotification { + """ + The id of the Notification + """ + id: Int! + + """ + The if of the user who send the message + """ + userId: Int! + + """ + The type of notification + """ + type: NotificationType + + """ + The id of the activity message + """ + activityId: Int! + + """ + The notification context text + """ + context: String + + """ + The time the notification was created at + """ + createdAt: Int + + """ + The message activity + """ + message: MessageActivity + + """ + The user who sent the message + """ + user: User +} + +""" +Replay to an activity item +""" +type ActivityReply { + """ + The id of the reply + """ + id: Int! + + """ + The id of the replies creator + """ + userId: Int + + """ + The id of the parent activity + """ + activityId: Int + + """ + The reply text + """ + text( + """ + Return the string in pre-parsed html instead of markdown + """ + asHtml: Boolean + ): String + + """ + The amount of likes the reply has + """ + likeCount: Int! + + """ + If the currently authenticated user liked the reply + """ + isLiked: Boolean + + """ + The time the reply was created at + """ + createdAt: Int! + + """ + The user who created reply + """ + user: User + + """ + The users who liked the reply + """ + likes: [User] +} + +""" +Notification for when a activity reply is liked +""" +type ActivityReplyLikeNotification { + """ + The id of the Notification + """ + id: Int! + + """ + The id of the user who liked to the activity reply + """ + userId: Int! + + """ + The type of notification + """ + type: NotificationType + + """ + The id of the activity where the reply which was liked + """ + activityId: Int! + + """ + The notification context text + """ + context: String + + """ + The time the notification was created at + """ + createdAt: Int + + """ + The liked activity + """ + activity: ActivityUnion + + """ + The user who liked the activity reply + """ + user: User +} + +""" +Notification for when a user replies to the authenticated users activity +""" +type ActivityReplyNotification { + """ + The id of the Notification + """ + id: Int! + + """ + The id of the user who replied to the activity + """ + userId: Int! + + """ + The type of notification + """ + type: NotificationType + + """ + The id of the activity which was replied too + """ + activityId: Int! + + """ + The notification context text + """ + context: String + + """ + The time the notification was created at + """ + createdAt: Int + + """ + The liked activity + """ + activity: ActivityUnion + + """ + The user who replied to the activity + """ + user: User +} + +""" +Notification for when a user replies to activity the authenticated user has replied to +""" +type ActivityReplySubscribedNotification { + """ + The id of the Notification + """ + id: Int! + + """ + The id of the user who replied to the activity + """ + userId: Int! + + """ + The type of notification + """ + type: NotificationType + + """ + The id of the activity which was replied too + """ + activityId: Int! + + """ + The notification context text + """ + context: String + + """ + The time the notification was created at + """ + createdAt: Int + + """ + The liked activity + """ + activity: ActivityUnion + + """ + The user who replied to the activity + """ + user: User +} + +""" +Activity sort enums +""" +enum ActivitySort { + ID + ID_DESC + PINNED +} + +""" +Activity type enum. +""" +enum ActivityType { + """ + A text activity + """ + TEXT + + """ + A anime list update activity + """ + ANIME_LIST + + """ + A manga list update activity + """ + MANGA_LIST + + """ + A text message activity sent to another user + """ + MESSAGE + + """ + Anime & Manga list update, only used in query arguments + """ + MEDIA_LIST +} + +""" +Activity union type +""" +union ActivityUnion = TextActivity | ListActivity | MessageActivity + +""" +Notification for when an episode of anime airs +""" +type AiringNotification { + """ + The id of the Notification + """ + id: Int! + + """ + The type of notification + """ + type: NotificationType + + """ + The id of the aired anime + """ + animeId: Int! + + """ + The episode number that just aired + """ + episode: Int! + + """ + The notification context text + """ + contexts: [String] + + """ + The time the notification was created at + """ + createdAt: Int + + """ + The associated media of the airing schedule + """ + media: Media +} + +""" +Score & Watcher stats for airing anime by episode and mid-week +""" +type AiringProgression { + """ + The episode the stats were recorded at. .5 is the mid point between 2 episodes airing dates. + """ + episode: Float + + """ + The average score for the media + """ + score: Float + + """ + The amount of users watching the anime + """ + watching: Int +} + +""" +Media Airing Schedule. NOTE: We only aim to guarantee that FUTURE airing data is present and accurate. +""" +type AiringSchedule { + """ + The id of the airing schedule item + """ + id: Int! + + """ + The time the episode airs at + """ + airingAt: Int! + + """ + Seconds until episode starts airing + """ + timeUntilAiring: Int! + + """ + The airing episode number + """ + episode: Int! + + """ + The associate media id of the airing episode + """ + mediaId: Int! + + """ + The associate media of the airing episode + """ + media: Media +} + +type AiringScheduleConnection { + edges: [AiringScheduleEdge] + nodes: [AiringSchedule] + + """ + The pagination information + """ + pageInfo: PageInfo +} + +""" +AiringSchedule connection edge +""" +type AiringScheduleEdge { + node: AiringSchedule + + """ + The id of the connection + """ + id: Int +} + +input AiringScheduleInput { + airingAt: Int + episode: Int + timeUntilAiring: Int +} + +""" +Airing schedule sort enums +""" +enum AiringSort { + ID + ID_DESC + MEDIA_ID + MEDIA_ID_DESC + TIME + TIME_DESC + EPISODE + EPISODE_DESC +} + +input AniChartHighlightInput { + mediaId: Int + highlight: String +} + +type AniChartUser { + user: User + settings: Json + highlights: Json +} + +""" +A character that features in an anime or manga +""" +type Character { + """ + The id of the character + """ + id: Int! + + """ + The names of the character + """ + name: CharacterName + + """ + Character images + """ + image: CharacterImage + + """ + A general description of the character + """ + description( + """ + Return the string in pre-parsed html instead of markdown + """ + asHtml: Boolean + ): String + + """ + The character's gender. Usually Male, Female, or Non-binary but can be any string. + """ + gender: String + + """ + The character's birth date + """ + dateOfBirth: FuzzyDate + + """ + The character's age. Note this is a string, not an int, it may contain further text and additional ages. + """ + age: String + + """ + The characters blood type + """ + bloodType: String + + """ + If the character is marked as favourite by the currently authenticated user + """ + isFavourite: Boolean! + + """ + If the character is blocked from being added to favourites + """ + isFavouriteBlocked: Boolean! + + """ + The url for the character page on the AniList website + """ + siteUrl: String + + """ + Media that includes the character + """ + media( + sort: [MediaSort] + type: MediaType + onList: Boolean + + """ + The page + """ + page: Int + + """ + The amount of entries per page, max 25 + """ + perPage: Int + ): MediaConnection + updatedAt: Int @deprecated(reason: "No data available") + + """ + The amount of user's who have favourited the character + """ + favourites: Int + + """ + Notes for site moderators + """ + modNotes: String +} + +type CharacterConnection { + edges: [CharacterEdge] + nodes: [Character] + + """ + The pagination information + """ + pageInfo: PageInfo +} + +""" +Character connection edge +""" +type CharacterEdge { + node: Character + + """ + The id of the connection + """ + id: Int + + """ + The characters role in the media + """ + role: CharacterRole + + """ + Media specific character name + """ + name: String + + """ + The voice actors of the character + """ + voiceActors(language: StaffLanguage, sort: [StaffSort]): [Staff] + + """ + The voice actors of the character with role date + """ + voiceActorRoles(language: StaffLanguage, sort: [StaffSort]): [StaffRoleType] + + """ + The media the character is in + """ + media: [Media] + + """ + The order the character should be displayed from the users favourites + """ + favouriteOrder: Int +} + +type CharacterImage { + """ + The character's image of media at its largest size + """ + large: String + + """ + The character's image of media at medium size + """ + medium: String +} + +""" +The names of the character +""" +type CharacterName { + """ + The character's given name + """ + first: String + + """ + The character's middle name + """ + middle: String + + """ + The character's surname + """ + last: String + + """ + The character's first and last name + """ + full: String + + """ + The character's full name in their native language + """ + native: String + + """ + Other names the character might be referred to as + """ + alternative: [String] + + """ + Other names the character might be referred to as but are spoilers + """ + alternativeSpoiler: [String] + + """ + The currently authenticated users preferred name language. Default romaji for non-authenticated + """ + userPreferred: String +} + +""" +The names of the character +""" +input CharacterNameInput { + """ + The character's given name + """ + first: String + + """ + The character's middle name + """ + middle: String + + """ + The character's surname + """ + last: String + + """ + The character's full name in their native language + """ + native: String + + """ + Other names the character might be referred by + """ + alternative: [String] + + """ + Other names the character might be referred to as but are spoilers + """ + alternativeSpoiler: [String] +} + +""" +The role the character plays in the media +""" +enum CharacterRole { + """ + A primary character role in the media + """ + MAIN + + """ + A supporting character role in the media + """ + SUPPORTING + + """ + A background character in the media + """ + BACKGROUND +} + +""" +Character sort enums +""" +enum CharacterSort { + ID + ID_DESC + ROLE + ROLE_DESC + SEARCH_MATCH + FAVOURITES + FAVOURITES_DESC + + """ + Order manually decided by moderators + """ + RELEVANCE +} + +""" +A submission for a character that features in an anime or manga +""" +type CharacterSubmission { + """ + The id of the submission + """ + id: Int! + + """ + Character that the submission is referencing + """ + character: Character + + """ + The character submission changes + """ + submission: Character + + """ + Submitter for the submission + """ + submitter: User + + """ + Data Mod assigned to handle the submission + """ + assignee: User + + """ + Status of the submission + """ + status: SubmissionStatus + + """ + Inner details of submission status + """ + notes: String + source: String + + """ + Whether the submission is locked + """ + locked: Boolean + createdAt: Int +} + +type CharacterSubmissionConnection { + edges: [CharacterSubmissionEdge] + nodes: [CharacterSubmission] + + """ + The pagination information + """ + pageInfo: PageInfo +} + +""" +CharacterSubmission connection edge +""" +type CharacterSubmissionEdge { + node: CharacterSubmission + + """ + The characters role in the media + """ + role: CharacterRole + + """ + The voice actors of the character + """ + voiceActors: [Staff] + + """ + The submitted voice actors of the character + """ + submittedVoiceActors: [StaffSubmission] +} + +""" +ISO 3166-1 alpha-2 country code +""" +scalar CountryCode + +""" +Deleted data type +""" +type Deleted { + """ + If an item has been successfully deleted + """ + deleted: Boolean +} + +enum ExternalLinkMediaType { + ANIME + MANGA + STAFF +} + +enum ExternalLinkType { + INFO + STREAMING + SOCIAL +} + +""" +User's favourite anime, manga, characters, staff & studios +""" +type Favourites { + """ + Favourite anime + """ + anime( + """ + The page number + """ + page: Int + + """ + The amount of entries per page, max 25 + """ + perPage: Int + ): MediaConnection + + """ + Favourite manga + """ + manga( + """ + The page number + """ + page: Int + + """ + The amount of entries per page, max 25 + """ + perPage: Int + ): MediaConnection + + """ + Favourite characters + """ + characters( + """ + The page number + """ + page: Int + + """ + The amount of entries per page, max 25 + """ + perPage: Int + ): CharacterConnection + + """ + Favourite staff + """ + staff( + """ + The page number + """ + page: Int + + """ + The amount of entries per page, max 25 + """ + perPage: Int + ): StaffConnection + + """ + Favourite studios + """ + studios( + """ + The page number + """ + page: Int + + """ + The amount of entries per page, max 25 + """ + perPage: Int + ): StudioConnection +} + +""" +Notification for when the authenticated user is followed by another user +""" +type FollowingNotification { + """ + The id of the Notification + """ + id: Int! + + """ + The id of the user who followed the authenticated user + """ + userId: Int! + + """ + The type of notification + """ + type: NotificationType + + """ + The notification context text + """ + context: String + + """ + The time the notification was created at + """ + createdAt: Int + + """ + The liked activity + """ + user: User +} + +""" +User's format statistics +""" +type FormatStats { + format: MediaFormat + amount: Int +} + +""" +Date object that allows for incomplete date values (fuzzy) +""" +type FuzzyDate { + """ + Numeric Year (2017) + """ + year: Int + + """ + Numeric Month (3) + """ + month: Int + + """ + Numeric Day (24) + """ + day: Int +} + +""" +Date object that allows for incomplete date values (fuzzy) +""" +input FuzzyDateInput { + """ + Numeric Year (2017) + """ + year: Int + + """ + Numeric Month (3) + """ + month: Int + + """ + Numeric Day (24) + """ + day: Int +} + +""" +8 digit long date integer (YYYYMMDD). Unknown dates represented by 0. E.g. 2016: 20160000, May 1976: 19760500 +""" +scalar FuzzyDateInt + +""" +User's genre statistics +""" +type GenreStats { + genre: String + amount: Int + meanScore: Int + + """ + The amount of time in minutes the genre has been watched by the user + """ + timeWatched: Int +} + +""" +Page of data (Used for internal use only) +""" +type InternalPage { + mediaSubmissions( + mediaId: Int + submissionId: Int + userId: Int + assigneeId: Int + status: SubmissionStatus + + """ + Filter by the media's type + """ + type: MediaType + + """ + The order the results will be returned in + """ + sort: [SubmissionSort] + ): [MediaSubmission] + characterSubmissions( + characterId: Int + + """ + Filter by the submitter of the submission + """ + userId: Int + assigneeId: Int + + """ + Filter by the status of the submission + """ + status: SubmissionStatus + + """ + The order the results will be returned in + """ + sort: [SubmissionSort] + ): [CharacterSubmission] + staffSubmissions( + staffId: Int + + """ + Filter by the submitter of the submission + """ + userId: Int + assigneeId: Int + + """ + Filter by the status of the submission + """ + status: SubmissionStatus + + """ + The order the results will be returned in + """ + sort: [SubmissionSort] + ): [StaffSubmission] + revisionHistory( + """ + Filter by the user id + """ + userId: Int + + """ + Filter by the media id + """ + mediaId: Int + + """ + Filter by the character id + """ + characterId: Int + + """ + Filter by the staff id + """ + staffId: Int + + """ + Filter by the studio id + """ + studioId: Int + ): [RevisionHistory] + reports(reporterId: Int, reportedId: Int): [Report] + modActions(userId: Int, modId: Int): [ModAction] + userBlockSearch( + """ + Filter by search query + """ + search: String + ): [User] + + """ + The pagination information + """ + pageInfo: PageInfo + users( + """ + Filter by the user id + """ + id: Int + + """ + Filter by the name of the user + """ + name: String + + """ + Filter to moderators only if true + """ + isModerator: Boolean + + """ + Filter by search query + """ + search: String + + """ + The order the results will be returned in + """ + sort: [UserSort] + ): [User] + media( + """ + Filter by the media id + """ + id: Int + + """ + Filter by the media's MyAnimeList id + """ + idMal: Int + + """ + Filter by the start date of the media + """ + startDate: FuzzyDateInt + + """ + Filter by the end date of the media + """ + endDate: FuzzyDateInt + + """ + Filter by the season the media was released in + """ + season: MediaSeason + + """ + The year of the season (Winter 2017 would also include December 2016 releases). Requires season argument + """ + seasonYear: Int + + """ + Filter by the media's type + """ + type: MediaType + + """ + Filter by the media's format + """ + format: MediaFormat + + """ + Filter by the media's current release status + """ + status: MediaStatus + + """ + Filter by amount of episodes the media has + """ + episodes: Int + + """ + Filter by the media's episode length + """ + duration: Int + + """ + Filter by the media's chapter count + """ + chapters: Int + + """ + Filter by the media's volume count + """ + volumes: Int + + """ + Filter by if the media's intended for 18+ adult audiences + """ + isAdult: Boolean + + """ + Filter by the media's genres + """ + genre: String + + """ + Filter by the media's tags + """ + tag: String + + """ + Only apply the tags filter argument to tags above this rank. Default: 18 + """ + minimumTagRank: Int + + """ + Filter by the media's tags with in a tag category + """ + tagCategory: String + + """ + Filter by the media on the authenticated user's lists + """ + onList: Boolean + + """ + Filter media by sites name with a online streaming or reading license + """ + licensedBy: String + + """ + Filter media by sites id with a online streaming or reading license + """ + licensedById: Int + + """ + Filter by the media's average score + """ + averageScore: Int + + """ + Filter by the number of users with this media on their list + """ + popularity: Int + + """ + Filter by the source type of the media + """ + source: MediaSource + + """ + Filter by the media's country of origin + """ + countryOfOrigin: CountryCode + + """ + If the media is officially licensed or a self-published doujin release + """ + isLicensed: Boolean + + """ + Filter by search query + """ + search: String + + """ + Filter by the media id + """ + id_not: Int + + """ + Filter by the media id + """ + id_in: [Int] + + """ + Filter by the media id + """ + id_not_in: [Int] + + """ + Filter by the media's MyAnimeList id + """ + idMal_not: Int + + """ + Filter by the media's MyAnimeList id + """ + idMal_in: [Int] + + """ + Filter by the media's MyAnimeList id + """ + idMal_not_in: [Int] + + """ + Filter by the start date of the media + """ + startDate_greater: FuzzyDateInt + + """ + Filter by the start date of the media + """ + startDate_lesser: FuzzyDateInt + + """ + Filter by the start date of the media + """ + startDate_like: String + + """ + Filter by the end date of the media + """ + endDate_greater: FuzzyDateInt + + """ + Filter by the end date of the media + """ + endDate_lesser: FuzzyDateInt + + """ + Filter by the end date of the media + """ + endDate_like: String + + """ + Filter by the media's format + """ + format_in: [MediaFormat] + + """ + Filter by the media's format + """ + format_not: MediaFormat + + """ + Filter by the media's format + """ + format_not_in: [MediaFormat] + + """ + Filter by the media's current release status + """ + status_in: [MediaStatus] + + """ + Filter by the media's current release status + """ + status_not: MediaStatus + + """ + Filter by the media's current release status + """ + status_not_in: [MediaStatus] + + """ + Filter by amount of episodes the media has + """ + episodes_greater: Int + + """ + Filter by amount of episodes the media has + """ + episodes_lesser: Int + + """ + Filter by the media's episode length + """ + duration_greater: Int + + """ + Filter by the media's episode length + """ + duration_lesser: Int + + """ + Filter by the media's chapter count + """ + chapters_greater: Int + + """ + Filter by the media's chapter count + """ + chapters_lesser: Int + + """ + Filter by the media's volume count + """ + volumes_greater: Int + + """ + Filter by the media's volume count + """ + volumes_lesser: Int + + """ + Filter by the media's genres + """ + genre_in: [String] + + """ + Filter by the media's genres + """ + genre_not_in: [String] + + """ + Filter by the media's tags + """ + tag_in: [String] + + """ + Filter by the media's tags + """ + tag_not_in: [String] + + """ + Filter by the media's tags with in a tag category + """ + tagCategory_in: [String] + + """ + Filter by the media's tags with in a tag category + """ + tagCategory_not_in: [String] + + """ + Filter media by sites name with a online streaming or reading license + """ + licensedBy_in: [String] + + """ + Filter media by sites id with a online streaming or reading license + """ + licensedById_in: [Int] + + """ + Filter by the media's average score + """ + averageScore_not: Int + + """ + Filter by the media's average score + """ + averageScore_greater: Int + + """ + Filter by the media's average score + """ + averageScore_lesser: Int + + """ + Filter by the number of users with this media on their list + """ + popularity_not: Int + + """ + Filter by the number of users with this media on their list + """ + popularity_greater: Int + + """ + Filter by the number of users with this media on their list + """ + popularity_lesser: Int + + """ + Filter by the source type of the media + """ + source_in: [MediaSource] + + """ + The order the results will be returned in + """ + sort: [MediaSort] + ): [Media] + characters( + """ + Filter by character id + """ + id: Int + + """ + Filter by character by if its their birthday today + """ + isBirthday: Boolean + + """ + Filter by search query + """ + search: String + + """ + Filter by character id + """ + id_not: Int + + """ + Filter by character id + """ + id_in: [Int] + + """ + Filter by character id + """ + id_not_in: [Int] + + """ + The order the results will be returned in + """ + sort: [CharacterSort] + ): [Character] + staff( + """ + Filter by the staff id + """ + id: Int + + """ + Filter by staff by if its their birthday today + """ + isBirthday: Boolean + + """ + Filter by search query + """ + search: String + + """ + Filter by the staff id + """ + id_not: Int + + """ + Filter by the staff id + """ + id_in: [Int] + + """ + Filter by the staff id + """ + id_not_in: [Int] + + """ + The order the results will be returned in + """ + sort: [StaffSort] + ): [Staff] + studios( + """ + Filter by the studio id + """ + id: Int + + """ + Filter by search query + """ + search: String + + """ + Filter by the studio id + """ + id_not: Int + + """ + Filter by the studio id + """ + id_in: [Int] + + """ + Filter by the studio id + """ + id_not_in: [Int] + + """ + The order the results will be returned in + """ + sort: [StudioSort] + ): [Studio] + mediaList( + """ + Filter by a list entry's id + """ + id: Int + + """ + Filter by a user's id + """ + userId: Int + + """ + Filter by a user's name + """ + userName: String + + """ + Filter by the list entries media type + """ + type: MediaType + + """ + Filter by the watching/reading status + """ + status: MediaListStatus + + """ + Filter by the media id of the list entry + """ + mediaId: Int + + """ + Filter list entries to users who are being followed by the authenticated user + """ + isFollowing: Boolean + + """ + Filter by note words and #tags + """ + notes: String + + """ + Filter by the date the user started the media + """ + startedAt: FuzzyDateInt + + """ + Filter by the date the user completed the media + """ + completedAt: FuzzyDateInt + + """ + Limit to only entries also on the auth user's list. Requires user id or name arguments. + """ + compareWithAuthList: Boolean + + """ + Filter by a user's id + """ + userId_in: [Int] + + """ + Filter by the watching/reading status + """ + status_in: [MediaListStatus] + + """ + Filter by the watching/reading status + """ + status_not_in: [MediaListStatus] + + """ + Filter by the watching/reading status + """ + status_not: MediaListStatus + + """ + Filter by the media id of the list entry + """ + mediaId_in: [Int] + + """ + Filter by the media id of the list entry + """ + mediaId_not_in: [Int] + + """ + Filter by note words and #tags + """ + notes_like: String + + """ + Filter by the date the user started the media + """ + startedAt_greater: FuzzyDateInt + + """ + Filter by the date the user started the media + """ + startedAt_lesser: FuzzyDateInt + + """ + Filter by the date the user started the media + """ + startedAt_like: String + + """ + Filter by the date the user completed the media + """ + completedAt_greater: FuzzyDateInt + + """ + Filter by the date the user completed the media + """ + completedAt_lesser: FuzzyDateInt + + """ + Filter by the date the user completed the media + """ + completedAt_like: String + + """ + The order the results will be returned in + """ + sort: [MediaListSort] + ): [MediaList] + airingSchedules( + """ + Filter by the id of the airing schedule item + """ + id: Int + + """ + Filter by the id of associated media + """ + mediaId: Int + + """ + Filter by the airing episode number + """ + episode: Int + + """ + Filter by the time of airing + """ + airingAt: Int + + """ + Filter to episodes that haven't yet aired + """ + notYetAired: Boolean + + """ + Filter by the id of the airing schedule item + """ + id_not: Int + + """ + Filter by the id of the airing schedule item + """ + id_in: [Int] + + """ + Filter by the id of the airing schedule item + """ + id_not_in: [Int] + + """ + Filter by the id of associated media + """ + mediaId_not: Int + + """ + Filter by the id of associated media + """ + mediaId_in: [Int] + + """ + Filter by the id of associated media + """ + mediaId_not_in: [Int] + + """ + Filter by the airing episode number + """ + episode_not: Int + + """ + Filter by the airing episode number + """ + episode_in: [Int] + + """ + Filter by the airing episode number + """ + episode_not_in: [Int] + + """ + Filter by the airing episode number + """ + episode_greater: Int + + """ + Filter by the airing episode number + """ + episode_lesser: Int + + """ + Filter by the time of airing + """ + airingAt_greater: Int + + """ + Filter by the time of airing + """ + airingAt_lesser: Int + + """ + The order the results will be returned in + """ + sort: [AiringSort] + ): [AiringSchedule] + mediaTrends( + """ + Filter by the media id + """ + mediaId: Int + + """ + Filter by date + """ + date: Int + + """ + Filter by trending amount + """ + trending: Int + + """ + Filter by score + """ + averageScore: Int + + """ + Filter by popularity + """ + popularity: Int + + """ + Filter by episode number + """ + episode: Int + + """ + Filter to stats recorded while the media was releasing + """ + releasing: Boolean + + """ + Filter by the media id + """ + mediaId_not: Int + + """ + Filter by the media id + """ + mediaId_in: [Int] + + """ + Filter by the media id + """ + mediaId_not_in: [Int] + + """ + Filter by date + """ + date_greater: Int + + """ + Filter by date + """ + date_lesser: Int + + """ + Filter by trending amount + """ + trending_greater: Int + + """ + Filter by trending amount + """ + trending_lesser: Int + + """ + Filter by trending amount + """ + trending_not: Int + + """ + Filter by score + """ + averageScore_greater: Int + + """ + Filter by score + """ + averageScore_lesser: Int + + """ + Filter by score + """ + averageScore_not: Int + + """ + Filter by popularity + """ + popularity_greater: Int + + """ + Filter by popularity + """ + popularity_lesser: Int + + """ + Filter by popularity + """ + popularity_not: Int + + """ + Filter by episode number + """ + episode_greater: Int + + """ + Filter by episode number + """ + episode_lesser: Int + + """ + Filter by episode number + """ + episode_not: Int + + """ + The order the results will be returned in + """ + sort: [MediaTrendSort] + ): [MediaTrend] + notifications( + """ + Filter by the type of notifications + """ + type: NotificationType + + """ + Reset the unread notification count to 0 on load + """ + resetNotificationCount: Boolean + + """ + Filter by the type of notifications + """ + type_in: [NotificationType] + ): [NotificationUnion] + followers( + """ + User id of the follower/followed + """ + userId: Int! + + """ + The order the results will be returned in + """ + sort: [UserSort] + ): [User] + following( + """ + User id of the follower/followed + """ + userId: Int! + + """ + The order the results will be returned in + """ + sort: [UserSort] + ): [User] + activities( + """ + Filter by the activity id + """ + id: Int + + """ + Filter by the owner user id + """ + userId: Int + + """ + Filter by the id of the user who sent a message + """ + messengerId: Int + + """ + Filter by the associated media id of the activity + """ + mediaId: Int + + """ + Filter by the type of activity + """ + type: ActivityType + + """ + Filter activity to users who are being followed by the authenticated user + """ + isFollowing: Boolean + + """ + Filter activity to only activity with replies + """ + hasReplies: Boolean + + """ + Filter activity to only activity with replies or is of type text + """ + hasRepliesOrTypeText: Boolean + + """ + Filter by the time the activity was created + """ + createdAt: Int + + """ + Filter by the activity id + """ + id_not: Int + + """ + Filter by the activity id + """ + id_in: [Int] + + """ + Filter by the activity id + """ + id_not_in: [Int] + + """ + Filter by the owner user id + """ + userId_not: Int + + """ + Filter by the owner user id + """ + userId_in: [Int] + + """ + Filter by the owner user id + """ + userId_not_in: [Int] + + """ + Filter by the id of the user who sent a message + """ + messengerId_not: Int + + """ + Filter by the id of the user who sent a message + """ + messengerId_in: [Int] + + """ + Filter by the id of the user who sent a message + """ + messengerId_not_in: [Int] + + """ + Filter by the associated media id of the activity + """ + mediaId_not: Int + + """ + Filter by the associated media id of the activity + """ + mediaId_in: [Int] + + """ + Filter by the associated media id of the activity + """ + mediaId_not_in: [Int] + + """ + Filter by the type of activity + """ + type_not: ActivityType + + """ + Filter by the type of activity + """ + type_in: [ActivityType] + + """ + Filter by the type of activity + """ + type_not_in: [ActivityType] + + """ + Filter by the time the activity was created + """ + createdAt_greater: Int + + """ + Filter by the time the activity was created + """ + createdAt_lesser: Int + + """ + The order the results will be returned in + """ + sort: [ActivitySort] + ): [ActivityUnion] + activityReplies( + """ + Filter by the reply id + """ + id: Int + + """ + Filter by the parent id + """ + activityId: Int + ): [ActivityReply] + threads( + """ + Filter by the thread id + """ + id: Int + + """ + Filter by the user id of the thread's creator + """ + userId: Int + + """ + Filter by the user id of the last user to comment on the thread + """ + replyUserId: Int + + """ + Filter by if the currently authenticated user's subscribed threads + """ + subscribed: Boolean + + """ + Filter by thread category id + """ + categoryId: Int + + """ + Filter by thread media id category + """ + mediaCategoryId: Int + + """ + Filter by search query + """ + search: String + + """ + Filter by the thread id + """ + id_in: [Int] + + """ + The order the results will be returned in + """ + sort: [ThreadSort] + ): [Thread] + threadComments( + """ + Filter by the comment id + """ + id: Int + + """ + Filter by the thread id + """ + threadId: Int + + """ + Filter by the user id of the comment's creator + """ + userId: Int + + """ + The order the results will be returned in + """ + sort: [ThreadCommentSort] + ): [ThreadComment] + reviews( + """ + Filter by Review id + """ + id: Int + + """ + Filter by media id + """ + mediaId: Int + + """ + Filter by user id + """ + userId: Int + + """ + Filter by media type + """ + mediaType: MediaType + + """ + The order the results will be returned in + """ + sort: [ReviewSort] + ): [Review] + recommendations( + """ + Filter by recommendation id + """ + id: Int + + """ + Filter by media id + """ + mediaId: Int + + """ + Filter by media recommendation id + """ + mediaRecommendationId: Int + + """ + Filter by user who created the recommendation + """ + userId: Int + + """ + Filter by total rating of the recommendation + """ + rating: Int + + """ + Filter by the media on the authenticated user's lists + """ + onList: Boolean + + """ + Filter by total rating of the recommendation + """ + rating_greater: Int + + """ + Filter by total rating of the recommendation + """ + rating_lesser: Int + + """ + The order the results will be returned in + """ + sort: [RecommendationSort] + ): [Recommendation] + likes( + """ + The id of the likeable type + """ + likeableId: Int + + """ + The type of model the id applies to + """ + type: LikeableType + ): [User] +} + +scalar Json + +""" +Types that can be liked +""" +enum LikeableType { + THREAD + THREAD_COMMENT + ACTIVITY + ACTIVITY_REPLY +} + +""" +Likeable union type +""" +union LikeableUnion = ListActivity | TextActivity | MessageActivity | ActivityReply | Thread | ThreadComment + +""" +User list activity (anime & manga updates) +""" +type ListActivity { + """ + The id of the activity + """ + id: Int! + + """ + The user id of the activity's creator + """ + userId: Int + + """ + The type of activity + """ + type: ActivityType + + """ + The number of activity replies + """ + replyCount: Int! + + """ + The list item's textual status + """ + status: String + + """ + The list progress made + """ + progress: String + + """ + If the activity is locked and can receive replies + """ + isLocked: Boolean + + """ + If the currently authenticated user is subscribed to the activity + """ + isSubscribed: Boolean + + """ + The amount of likes the activity has + """ + likeCount: Int! + + """ + If the currently authenticated user liked the activity + """ + isLiked: Boolean + + """ + If the activity is pinned to the top of the users activity feed + """ + isPinned: Boolean + + """ + The url for the activity page on the AniList website + """ + siteUrl: String + + """ + The time the activity was created at + """ + createdAt: Int! + + """ + The owner of the activity + """ + user: User + + """ + The associated media to the activity update + """ + media: Media + + """ + The written replies to the activity + """ + replies: [ActivityReply] + + """ + The users who liked the activity + """ + likes: [User] +} + +type ListActivityOption { + disabled: Boolean + type: MediaListStatus +} + +input ListActivityOptionInput { + disabled: Boolean + type: MediaListStatus +} + +""" +User's list score statistics +""" +type ListScoreStats { + meanScore: Int + standardDeviation: Int +} + +""" +Anime or Manga +""" +type Media { + """ + The id of the media + """ + id: Int! + + """ + The mal id of the media + """ + idMal: Int + + """ + The official titles of the media in various languages + """ + title: MediaTitle + + """ + The type of the media; anime or manga + """ + type: MediaType + + """ + The format the media was released in + """ + format: MediaFormat + + """ + The current releasing status of the media + """ + status( + """ + Provide 2 to use new version 2 of sources enum + """ + version: Int + ): MediaStatus + + """ + Short description of the media's story and characters + """ + description( + """ + Return the string in pre-parsed html instead of markdown + """ + asHtml: Boolean + ): String + + """ + The first official release date of the media + """ + startDate: FuzzyDate + + """ + The last official release date of the media + """ + endDate: FuzzyDate + + """ + The season the media was initially released in + """ + season: MediaSeason + + """ + The season year the media was initially released in + """ + seasonYear: Int + + """ + The year & season the media was initially released in + """ + seasonInt: Int + + """ + The amount of episodes the anime has when complete + """ + episodes: Int + + """ + The general length of each anime episode in minutes + """ + duration: Int + + """ + The amount of chapters the manga has when complete + """ + chapters: Int + + """ + The amount of volumes the manga has when complete + """ + volumes: Int + + """ + Where the media was created. (ISO 3166-1 alpha-2) + """ + countryOfOrigin: CountryCode + + """ + If the media is officially licensed or a self-published doujin release + """ + isLicensed: Boolean + + """ + Source type the media was adapted from. + """ + source( + """ + Provide 2 or 3 to use new version 2 or 3 of sources enum + """ + version: Int + ): MediaSource + + """ + Official Twitter hashtags for the media + """ + hashtag: String + + """ + Media trailer or advertisement + """ + trailer: MediaTrailer + + """ + When the media's data was last updated + """ + updatedAt: Int + + """ + The cover images of the media + """ + coverImage: MediaCoverImage + + """ + The banner image of the media + """ + bannerImage: String + + """ + The genres of the media + """ + genres: [String] + + """ + Alternative titles of the media + """ + synonyms: [String] + + """ + A weighted average score of all the user's scores of the media + """ + averageScore: Int + + """ + Mean score of all the user's scores of the media + """ + meanScore: Int + + """ + The number of users with the media on their list + """ + popularity: Int + + """ + Locked media may not be added to lists our favorited. This may be due to the entry pending for deletion or other reasons. + """ + isLocked: Boolean + + """ + The amount of related activity in the past hour + """ + trending: Int + + """ + The amount of user's who have favourited the media + """ + favourites: Int + + """ + List of tags that describes elements and themes of the media + """ + tags: [MediaTag] + + """ + Other media in the same or connecting franchise + """ + relations: MediaConnection + + """ + The characters in the media + """ + characters( + sort: [CharacterSort] + role: CharacterRole + + """ + The page + """ + page: Int + + """ + The amount of entries per page, max 25 + """ + perPage: Int + ): CharacterConnection + + """ + The staff who produced the media + """ + staff( + sort: [StaffSort] + + """ + The page + """ + page: Int + + """ + The amount of entries per page, max 25 + """ + perPage: Int + ): StaffConnection + + """ + The companies who produced the media + """ + studios(sort: [StudioSort], isMain: Boolean): StudioConnection + + """ + If the media is marked as favourite by the current authenticated user + """ + isFavourite: Boolean! + + """ + If the media is blocked from being added to favourites + """ + isFavouriteBlocked: Boolean! + + """ + If the media is intended only for 18+ adult audiences + """ + isAdult: Boolean + + """ + The media's next episode airing schedule + """ + nextAiringEpisode: AiringSchedule + + """ + The media's entire airing schedule + """ + airingSchedule( + """ + Filter to episodes that have not yet aired + """ + notYetAired: Boolean + + """ + The page + """ + page: Int + + """ + The amount of entries per page, max 25 + """ + perPage: Int + ): AiringScheduleConnection + + """ + The media's daily trend stats + """ + trends( + sort: [MediaTrendSort] + + """ + Filter to stats recorded while the media was releasing + """ + releasing: Boolean + + """ + The page + """ + page: Int + + """ + The amount of entries per page, max 25 + """ + perPage: Int + ): MediaTrendConnection + + """ + External links to another site related to the media + """ + externalLinks: [MediaExternalLink] + + """ + Data and links to legal streaming episodes on external sites + """ + streamingEpisodes: [MediaStreamingEpisode] + + """ + The ranking of the media in a particular time span and format compared to other media + """ + rankings: [MediaRank] + + """ + The authenticated user's media list entry for the media + """ + mediaListEntry: MediaList + + """ + User reviews of the media + """ + reviews( + limit: Int + sort: [ReviewSort] + + """ + The page + """ + page: Int + + """ + The amount of entries per page, max 25 + """ + perPage: Int + ): ReviewConnection + + """ + User recommendations for similar media + """ + recommendations( + sort: [RecommendationSort] + + """ + The page + """ + page: Int + + """ + The amount of entries per page, max 25 + """ + perPage: Int + ): RecommendationConnection + stats: MediaStats + + """ + The url for the media page on the AniList website + """ + siteUrl: String + + """ + If the media should have forum thread automatically created for it on airing episode release + """ + autoCreateForumThread: Boolean + + """ + If the media is blocked from being recommended to/from + """ + isRecommendationBlocked: Boolean + + """ + If the media is blocked from being reviewed + """ + isReviewBlocked: Boolean + + """ + Notes for site moderators + """ + modNotes: String +} + +""" +Internal - Media characters separated +""" +type MediaCharacter { + """ + The id of the connection + """ + id: Int + + """ + The characters role in the media + """ + role: CharacterRole + roleNotes: String + dubGroup: String + + """ + Media specific character name + """ + characterName: String + + """ + The characters in the media voiced by the parent actor + """ + character: Character + + """ + The voice actor of the character + """ + voiceActor: Staff +} + +type MediaConnection { + edges: [MediaEdge] + nodes: [Media] + + """ + The pagination information + """ + pageInfo: PageInfo +} + +type MediaCoverImage { + """ + The cover image url of the media at its largest size. If this size isn't available, large will be provided instead. + """ + extraLarge: String + + """ + The cover image url of the media at a large size + """ + large: String + + """ + The cover image url of the media at medium size + """ + medium: String + + """ + Average #hex color of cover image + """ + color: String +} + +""" +Notification for when a media entry's data was changed in a significant way impacting users' list tracking +""" +type MediaDataChangeNotification { + """ + The id of the Notification + """ + id: Int! + + """ + The type of notification + """ + type: NotificationType + + """ + The id of the media that received data changes + """ + mediaId: Int! + + """ + The reason for the media data change + """ + context: String + + """ + The reason for the media data change + """ + reason: String + + """ + The time the notification was created at + """ + createdAt: Int + + """ + The media that received data changes + """ + media: Media +} + +""" +Notification for when a media tracked in a user's list is deleted from the site +""" +type MediaDeletionNotification { + """ + The id of the Notification + """ + id: Int! + + """ + The type of notification + """ + type: NotificationType + + """ + The title of the deleted media + """ + deletedMediaTitle: String + + """ + The reason for the media deletion + """ + context: String + + """ + The reason for the media deletion + """ + reason: String + + """ + The time the notification was created at + """ + createdAt: Int +} + +""" +Media connection edge +""" +type MediaEdge { + node: Media + + """ + The id of the connection + """ + id: Int + + """ + The type of relation to the parent model + """ + relationType( + """ + Provide 2 to use new version 2 of relation enum + """ + version: Int + ): MediaRelation + + """ + If the studio is the main animation studio of the media (For Studio->MediaConnection field only) + """ + isMainStudio: Boolean! + + """ + The characters in the media voiced by the parent actor + """ + characters: [Character] + + """ + The characters role in the media + """ + characterRole: CharacterRole + + """ + Media specific character name + """ + characterName: String + + """ + Notes regarding the VA's role for the character + """ + roleNotes: String + + """ + Used for grouping roles where multiple dubs exist for the same language. Either dubbing company name or language variant. + """ + dubGroup: String + + """ + The role of the staff member in the production of the media + """ + staffRole: String + + """ + The voice actors of the character + """ + voiceActors(language: StaffLanguage, sort: [StaffSort]): [Staff] + + """ + The voice actors of the character with role date + """ + voiceActorRoles(language: StaffLanguage, sort: [StaffSort]): [StaffRoleType] + + """ + The order the media should be displayed from the users favourites + """ + favouriteOrder: Int +} + +""" +An external link to another site related to the media or staff member +""" +type MediaExternalLink { + """ + The id of the external link + """ + id: Int! + + """ + The url of the external link or base url of link source + """ + url: String + + """ + The links website site name + """ + site: String! + + """ + The links website site id + """ + siteId: Int + type: ExternalLinkType + + """ + Language the site content is in. See Staff language field for values. + """ + language: String + color: String + + """ + The icon image url of the site. Not available for all links. Transparent PNG 64x64 + """ + icon: String + notes: String + isDisabled: Boolean +} + +""" +An external link to another site related to the media +""" +input MediaExternalLinkInput { + """ + The id of the external link + """ + id: Int! + + """ + The url of the external link + """ + url: String! + + """ + The site location of the external link + """ + site: String! +} + +""" +The format the media was released in +""" +enum MediaFormat { + """ + Anime broadcast on television + """ + TV + + """ + Anime which are under 15 minutes in length and broadcast on television + """ + TV_SHORT + + """ + Anime movies with a theatrical release + """ + MOVIE + + """ + Special episodes that have been included in DVD/Blu-ray releases, picture dramas, pilots, etc + """ + SPECIAL + + """ + (Original Video Animation) Anime that have been released directly on + DVD/Blu-ray without originally going through a theatrical release or + television broadcast + """ + OVA + + """ + (Original Net Animation) Anime that have been originally released online or are only available through streaming services. + """ + ONA + + """ + Short anime released as a music video + """ + MUSIC + + """ + Professionally published manga with more than one chapter + """ + MANGA + + """ + Written books released as a series of light novels + """ + NOVEL + + """ + Manga with just one chapter + """ + ONE_SHOT +} + +""" +List of anime or manga +""" +type MediaList { + """ + The id of the list entry + """ + id: Int! + + """ + The id of the user owner of the list entry + """ + userId: Int! + + """ + The id of the media + """ + mediaId: Int! + + """ + The watching/reading status + """ + status: MediaListStatus + + """ + The score of the entry + """ + score( + """ + Force the score to be returned in the provided format type. + """ + format: ScoreFormat + ): Float + + """ + The amount of episodes/chapters consumed by the user + """ + progress: Int + + """ + The amount of volumes read by the user + """ + progressVolumes: Int + + """ + The amount of times the user has rewatched/read the media + """ + repeat: Int + + """ + Priority of planning + """ + priority: Int + + """ + If the entry should only be visible to authenticated user + """ + private: Boolean + + """ + Text notes + """ + notes: String + + """ + If the entry shown be hidden from non-custom lists + """ + hiddenFromStatusLists: Boolean + + """ + Map of booleans for which custom lists the entry are in + """ + customLists( + """ + Change return structure to an array of objects + """ + asArray: Boolean + ): Json + + """ + Map of advanced scores with name keys + """ + advancedScores: Json + + """ + When the entry was started by the user + """ + startedAt: FuzzyDate + + """ + When the entry was completed by the user + """ + completedAt: FuzzyDate + + """ + When the entry data was last updated + """ + updatedAt: Int + + """ + When the entry data was created + """ + createdAt: Int + media: Media + user: User +} + +""" +List of anime or manga +""" +type MediaListCollection { + """ + Grouped media list entries + """ + lists: [MediaListGroup] + + """ + The owner of the list + """ + user: User + + """ + If there is another chunk + """ + hasNextChunk: Boolean + + """ + A map of media list entry arrays grouped by status + """ + statusLists(asArray: Boolean): [[MediaList]] @deprecated(reason: "Not GraphQL spec compliant, use lists field instead.") + + """ + A map of media list entry arrays grouped by custom lists + """ + customLists(asArray: Boolean): [[MediaList]] @deprecated(reason: "Not GraphQL spec compliant, use lists field instead.") +} + +""" +List group of anime or manga entries +""" +type MediaListGroup { + """ + Media list entries + """ + entries: [MediaList] + name: String + isCustomList: Boolean + isSplitCompletedList: Boolean + status: MediaListStatus +} + +""" +A user's list options +""" +type MediaListOptions { + """ + The score format the user is using for media lists + """ + scoreFormat: ScoreFormat + + """ + The default order list rows should be displayed in + """ + rowOrder: String + useLegacyLists: Boolean @deprecated(reason: "No longer used") + + """ + The user's anime list options + """ + animeList: MediaListTypeOptions + + """ + The user's manga list options + """ + mangaList: MediaListTypeOptions + + """ + The list theme options for both lists + """ + sharedTheme: Json @deprecated(reason: "No longer used") + + """ + If the shared theme should be used instead of the individual list themes + """ + sharedThemeEnabled: Boolean @deprecated(reason: "No longer used") +} + +""" +A user's list options for anime or manga lists +""" +input MediaListOptionsInput { + """ + The order each list should be displayed in + """ + sectionOrder: [String] + + """ + If the completed sections of the list should be separated by format + """ + splitCompletedSectionByFormat: Boolean + + """ + The names of the user's custom lists + """ + customLists: [String] + + """ + The names of the user's advanced scoring sections + """ + advancedScoring: [String] + + """ + If advanced scoring is enabled + """ + advancedScoringEnabled: Boolean + + """ + list theme + """ + theme: String +} + +""" +Media list sort enums +""" +enum MediaListSort { + MEDIA_ID + MEDIA_ID_DESC + SCORE + SCORE_DESC + STATUS + STATUS_DESC + PROGRESS + PROGRESS_DESC + PROGRESS_VOLUMES + PROGRESS_VOLUMES_DESC + REPEAT + REPEAT_DESC + PRIORITY + PRIORITY_DESC + STARTED_ON + STARTED_ON_DESC + FINISHED_ON + FINISHED_ON_DESC + ADDED_TIME + ADDED_TIME_DESC + UPDATED_TIME + UPDATED_TIME_DESC + MEDIA_TITLE_ROMAJI + MEDIA_TITLE_ROMAJI_DESC + MEDIA_TITLE_ENGLISH + MEDIA_TITLE_ENGLISH_DESC + MEDIA_TITLE_NATIVE + MEDIA_TITLE_NATIVE_DESC + MEDIA_POPULARITY + MEDIA_POPULARITY_DESC +} + +""" +Media list watching/reading status enum. +""" +enum MediaListStatus { + """ + Currently watching/reading + """ + CURRENT + + """ + Planning to watch/read + """ + PLANNING + + """ + Finished watching/reading + """ + COMPLETED + + """ + Stopped watching/reading before completing + """ + DROPPED + + """ + Paused watching/reading + """ + PAUSED + + """ + Re-watching/reading + """ + REPEATING +} + +""" +A user's list options for anime or manga lists +""" +type MediaListTypeOptions { + """ + The order each list should be displayed in + """ + sectionOrder: [String] + + """ + If the completed sections of the list should be separated by format + """ + splitCompletedSectionByFormat: Boolean + + """ + The list theme options + """ + theme: Json @deprecated(reason: "This field has not yet been fully implemented and may change without warning") + + """ + The names of the user's custom lists + """ + customLists: [String] + + """ + The names of the user's advanced scoring sections + """ + advancedScoring: [String] + + """ + If advanced scoring is enabled + """ + advancedScoringEnabled: Boolean +} + +""" +Notification for when a media entry is merged into another for a user who had it on their list +""" +type MediaMergeNotification { + """ + The id of the Notification + """ + id: Int! + + """ + The type of notification + """ + type: NotificationType + + """ + The id of the media that was merged into + """ + mediaId: Int! + + """ + The title of the deleted media + """ + deletedMediaTitles: [String] + + """ + The reason for the media data change + """ + context: String + + """ + The reason for the media merge + """ + reason: String + + """ + The time the notification was created at + """ + createdAt: Int + + """ + The media that was merged into + """ + media: Media +} + +""" +The ranking of a media in a particular time span and format compared to other media +""" +type MediaRank { + """ + The id of the rank + """ + id: Int! + + """ + The numerical rank of the media + """ + rank: Int! + + """ + The type of ranking + """ + type: MediaRankType! + + """ + The format the media is ranked within + """ + format: MediaFormat! + + """ + The year the media is ranked within + """ + year: Int + + """ + The season the media is ranked within + """ + season: MediaSeason + + """ + If the ranking is based on all time instead of a season/year + """ + allTime: Boolean + + """ + String that gives context to the ranking type and time span + """ + context: String! +} + +""" +The type of ranking +""" +enum MediaRankType { + """ + Ranking is based on the media's ratings/score + """ + RATED + + """ + Ranking is based on the media's popularity + """ + POPULAR +} + +""" +Type of relation media has to its parent. +""" +enum MediaRelation { + """ + An adaption of this media into a different format + """ + ADAPTATION + + """ + Released before the relation + """ + PREQUEL + + """ + Released after the relation + """ + SEQUEL + + """ + The media a side story is from + """ + PARENT + + """ + A side story of the parent media + """ + SIDE_STORY + + """ + Shares at least 1 character + """ + CHARACTER + + """ + A shortened and summarized version + """ + SUMMARY + + """ + An alternative version of the same media + """ + ALTERNATIVE + + """ + An alternative version of the media with a different primary focus + """ + SPIN_OFF + + """ + Other + """ + OTHER + + """ + Version 2 only. The source material the media was adapted from + """ + SOURCE + + """ + Version 2 only. + """ + COMPILATION + + """ + Version 2 only. + """ + CONTAINS +} + +enum MediaSeason { + """ + Months December to February + """ + WINTER + + """ + Months March to May + """ + SPRING + + """ + Months June to August + """ + SUMMER + + """ + Months September to November + """ + FALL +} + +""" +Media sort enums +""" +enum MediaSort { + ID + ID_DESC + TITLE_ROMAJI + TITLE_ROMAJI_DESC + TITLE_ENGLISH + TITLE_ENGLISH_DESC + TITLE_NATIVE + TITLE_NATIVE_DESC + TYPE + TYPE_DESC + FORMAT + FORMAT_DESC + START_DATE + START_DATE_DESC + END_DATE + END_DATE_DESC + SCORE + SCORE_DESC + POPULARITY + POPULARITY_DESC + TRENDING + TRENDING_DESC + EPISODES + EPISODES_DESC + DURATION + DURATION_DESC + STATUS + STATUS_DESC + CHAPTERS + CHAPTERS_DESC + VOLUMES + VOLUMES_DESC + UPDATED_AT + UPDATED_AT_DESC + SEARCH_MATCH + FAVOURITES + FAVOURITES_DESC +} + +""" +Source type the media was adapted from +""" +enum MediaSource { + """ + An original production not based of another work + """ + ORIGINAL + + """ + Asian comic book + """ + MANGA + + """ + Written work published in volumes + """ + LIGHT_NOVEL + + """ + Video game driven primary by text and narrative + """ + VISUAL_NOVEL + + """ + Video game + """ + VIDEO_GAME + + """ + Other + """ + OTHER + + """ + Version 2+ only. Written works not published in volumes + """ + NOVEL + + """ + Version 2+ only. Self-published works + """ + DOUJINSHI + + """ + Version 2+ only. Japanese Anime + """ + ANIME + + """ + Version 3 only. Written works published online + """ + WEB_NOVEL + + """ + Version 3 only. Live action media such as movies or TV show + """ + LIVE_ACTION + + """ + Version 3 only. Games excluding video games + """ + GAME + + """ + Version 3 only. Comics excluding manga + """ + COMIC + + """ + Version 3 only. Multimedia project + """ + MULTIMEDIA_PROJECT + + """ + Version 3 only. Picture book + """ + PICTURE_BOOK +} + +""" +A media's statistics +""" +type MediaStats { + scoreDistribution: [ScoreDistribution] + statusDistribution: [StatusDistribution] + airingProgression: [AiringProgression] @deprecated(reason: "Replaced by MediaTrends") +} + +""" +The current releasing status of the media +""" +enum MediaStatus { + """ + Has completed and is no longer being released + """ + FINISHED + + """ + Currently releasing + """ + RELEASING + + """ + To be released at a later date + """ + NOT_YET_RELEASED + + """ + Ended before the work could be finished + """ + CANCELLED + + """ + Version 2 only. Is currently paused from releasing and will resume at a later date + """ + HIATUS +} + +""" +Data and links to legal streaming episodes on external sites +""" +type MediaStreamingEpisode { + """ + Title of the episode + """ + title: String + + """ + Url of episode image thumbnail + """ + thumbnail: String + + """ + The url of the episode + """ + url: String + + """ + The site location of the streaming episodes + """ + site: String +} + +""" +Media submission +""" +type MediaSubmission { + """ + The id of the submission + """ + id: Int! + + """ + User submitter of the submission + """ + submitter: User + + """ + Data Mod assigned to handle the submission + """ + assignee: User + + """ + Status of the submission + """ + status: SubmissionStatus + submitterStats: Json + notes: String + source: String + changes: [String] + + """ + Whether the submission is locked + """ + locked: Boolean + media: Media + submission: Media + characters: [MediaSubmissionComparison] + staff: [MediaSubmissionComparison] + studios: [MediaSubmissionComparison] + relations: [MediaEdge] + externalLinks: [MediaSubmissionComparison] + createdAt: Int +} + +""" +Media submission with comparison to current data +""" +type MediaSubmissionComparison { + submission: MediaSubmissionEdge + character: MediaCharacter + staff: StaffEdge + studio: StudioEdge + externalLink: MediaExternalLink +} + +type MediaSubmissionEdge { + """ + The id of the direct submission + """ + id: Int + characterRole: CharacterRole + staffRole: String + roleNotes: String + dubGroup: String + characterName: String + isMain: Boolean + character: Character + characterSubmission: Character + voiceActor: Staff + voiceActorSubmission: Staff + staff: Staff + staffSubmission: Staff + studio: Studio + externalLink: MediaExternalLink + media: Media +} + +""" +A tag that describes a theme or element of the media +""" +type MediaTag { + """ + The id of the tag + """ + id: Int! + + """ + The name of the tag + """ + name: String! + + """ + A general description of the tag + """ + description: String + + """ + The categories of tags this tag belongs to + """ + category: String + + """ + The relevance ranking of the tag out of the 100 for this media + """ + rank: Int + + """ + If the tag could be a spoiler for any media + """ + isGeneralSpoiler: Boolean + + """ + If the tag is a spoiler for this media + """ + isMediaSpoiler: Boolean + + """ + If the tag is only for adult 18+ media + """ + isAdult: Boolean + + """ + The user who submitted the tag + """ + userId: Int +} + +""" +The official titles of the media in various languages +""" +type MediaTitle { + """ + The romanization of the native language title + """ + romaji(stylised: Boolean): String + + """ + The official english title + """ + english(stylised: Boolean): String + + """ + Official title in it's native language + """ + native(stylised: Boolean): String + + """ + The currently authenticated users preferred title language. Default romaji for non-authenticated + """ + userPreferred: String +} + +""" +The official titles of the media in various languages +""" +input MediaTitleInput { + """ + The romanization of the native language title + """ + romaji: String + + """ + The official english title + """ + english: String + + """ + Official title in it's native language + """ + native: String +} + +""" +Media trailer or advertisement +""" +type MediaTrailer { + """ + The trailer video id + """ + id: String + + """ + The site the video is hosted by (Currently either youtube or dailymotion) + """ + site: String + + """ + The url for the thumbnail image of the video + """ + thumbnail: String +} + +""" +Daily media statistics +""" +type MediaTrend { + """ + The id of the tag + """ + mediaId: Int! + + """ + The day the data was recorded (timestamp) + """ + date: Int! + + """ + The amount of media activity on the day + """ + trending: Int! + + """ + A weighted average score of all the user's scores of the media + """ + averageScore: Int + + """ + The number of users with the media on their list + """ + popularity: Int + + """ + The number of users with watching/reading the media + """ + inProgress: Int + + """ + If the media was being released at this time + """ + releasing: Boolean! + + """ + The episode number of the anime released on this day + """ + episode: Int + + """ + The related media + """ + media: Media +} + +type MediaTrendConnection { + edges: [MediaTrendEdge] + nodes: [MediaTrend] + + """ + The pagination information + """ + pageInfo: PageInfo +} + +""" +Media trend connection edge +""" +type MediaTrendEdge { + node: MediaTrend +} + +""" +Media trend sort enums +""" +enum MediaTrendSort { + ID + ID_DESC + MEDIA_ID + MEDIA_ID_DESC + DATE + DATE_DESC + SCORE + SCORE_DESC + POPULARITY + POPULARITY_DESC + TRENDING + TRENDING_DESC + EPISODE + EPISODE_DESC +} + +""" +Media type enum, anime or manga. +""" +enum MediaType { + """ + Japanese Anime + """ + ANIME + + """ + Asian comic + """ + MANGA +} + +""" +User message activity +""" +type MessageActivity { + """ + The id of the activity + """ + id: Int! + + """ + The user id of the activity's recipient + """ + recipientId: Int + + """ + The user id of the activity's sender + """ + messengerId: Int + + """ + The type of the activity + """ + type: ActivityType + + """ + The number of activity replies + """ + replyCount: Int! + + """ + The message text (Markdown) + """ + message( + """ + Return the string in pre-parsed html instead of markdown + """ + asHtml: Boolean + ): String + + """ + If the activity is locked and can receive replies + """ + isLocked: Boolean + + """ + If the currently authenticated user is subscribed to the activity + """ + isSubscribed: Boolean + + """ + The amount of likes the activity has + """ + likeCount: Int! + + """ + If the currently authenticated user liked the activity + """ + isLiked: Boolean + + """ + If the message is private and only viewable to the sender and recipients + """ + isPrivate: Boolean + + """ + The url for the activity page on the AniList website + """ + siteUrl: String + + """ + The time the activity was created at + """ + createdAt: Int! + + """ + The user who the activity message was sent to + """ + recipient: User + + """ + The user who sent the activity message + """ + messenger: User + + """ + The written replies to the activity + """ + replies: [ActivityReply] + + """ + The users who liked the activity + """ + likes: [User] +} + +type ModAction { + """ + The id of the action + """ + id: Int! + user: User + mod: User + type: ModActionType + objectId: Int + objectType: String + data: String + createdAt: Int! +} + +enum ModActionType { + NOTE + BAN + DELETE + EDIT + EXPIRE + REPORT + RESET + ANON +} + +""" +Mod role enums +""" +enum ModRole { + """ + An AniList administrator + """ + ADMIN + + """ + A head developer of AniList + """ + LEAD_DEVELOPER + + """ + An AniList developer + """ + DEVELOPER + + """ + A lead community moderator + """ + LEAD_COMMUNITY + + """ + A community moderator + """ + COMMUNITY + + """ + A discord community moderator + """ + DISCORD_COMMUNITY + + """ + A lead anime data moderator + """ + LEAD_ANIME_DATA + + """ + An anime data moderator + """ + ANIME_DATA + + """ + A lead manga data moderator + """ + LEAD_MANGA_DATA + + """ + A manga data moderator + """ + MANGA_DATA + + """ + A lead social media moderator + """ + LEAD_SOCIAL_MEDIA + + """ + A social media moderator + """ + SOCIAL_MEDIA + + """ + A retired moderator + """ + RETIRED +} + +type Mutation { + UpdateUser( + """ + User's about/bio text + """ + about: String + + """ + User's title language + """ + titleLanguage: UserTitleLanguage + + """ + If the user should see media marked as adult-only + """ + displayAdultContent: Boolean + + """ + If the user should get notifications when a show they are watching aires + """ + airingNotifications: Boolean + + """ + The user's list scoring system + """ + scoreFormat: ScoreFormat + + """ + The user's default list order + """ + rowOrder: String + + """ + Profile highlight color + """ + profileColor: String + + """ + Profile highlight color + """ + donatorBadge: String + + """ + Notification options + """ + notificationOptions: [NotificationOptionInput] + + """ + Timezone offset format: -?HH:MM + """ + timezone: String + + """ + Minutes between activity for them to be merged together. 0 is Never, Above 2 weeks (20160 mins) is Always. + """ + activityMergeTime: Int + + """ + The user's anime list options + """ + animeListOptions: MediaListOptionsInput + + """ + The user's anime list options + """ + mangaListOptions: MediaListOptionsInput + + """ + The language the user wants to see staff and character names in + """ + staffNameLanguage: UserStaffNameLanguage + + """ + Only allow messages from other users the user follows + """ + restrictMessagesToFollowing: Boolean + disabledListActivity: [ListActivityOptionInput] + ): User + + """ + Create or update a media list entry + """ + SaveMediaListEntry( + """ + The list entry id, required for updating + """ + id: Int + + """ + The id of the media the entry is of + """ + mediaId: Int + + """ + The watching/reading status + """ + status: MediaListStatus + + """ + The score of the media in the user's chosen scoring method + """ + score: Float + + """ + The score of the media in 100 point + """ + scoreRaw: Int + + """ + The amount of episodes/chapters consumed by the user + """ + progress: Int + + """ + The amount of volumes read by the user + """ + progressVolumes: Int + + """ + The amount of times the user has rewatched/read the media + """ + repeat: Int + + """ + Priority of planning + """ + priority: Int + + """ + If the entry should only be visible to authenticated user + """ + private: Boolean + + """ + Text notes + """ + notes: String + + """ + If the entry shown be hidden from non-custom lists + """ + hiddenFromStatusLists: Boolean + + """ + Array of custom list names which should be enabled for this entry + """ + customLists: [String] + + """ + Array of advanced scores + """ + advancedScores: [Float] + + """ + When the entry was started by the user + """ + startedAt: FuzzyDateInput + + """ + When the entry was completed by the user + """ + completedAt: FuzzyDateInput + ): MediaList + + """ + Update multiple media list entries to the same values + """ + UpdateMediaListEntries( + """ + The watching/reading status + """ + status: MediaListStatus + + """ + The score of the media in the user's chosen scoring method + """ + score: Float + + """ + The score of the media in 100 point + """ + scoreRaw: Int + + """ + The amount of episodes/chapters consumed by the user + """ + progress: Int + + """ + The amount of volumes read by the user + """ + progressVolumes: Int + + """ + The amount of times the user has rewatched/read the media + """ + repeat: Int + + """ + Priority of planning + """ + priority: Int + + """ + If the entry should only be visible to authenticated user + """ + private: Boolean + + """ + Text notes + """ + notes: String + + """ + If the entry shown be hidden from non-custom lists + """ + hiddenFromStatusLists: Boolean + + """ + Array of advanced scores + """ + advancedScores: [Float] + + """ + When the entry was started by the user + """ + startedAt: FuzzyDateInput + + """ + When the entry was completed by the user + """ + completedAt: FuzzyDateInput + + """ + The list entries ids to update + """ + ids: [Int] + ): [MediaList] + + """ + Delete a media list entry + """ + DeleteMediaListEntry( + """ + The id of the media list entry to delete + """ + id: Int + ): Deleted + + """ + Delete a custom list and remove the list entries from it + """ + DeleteCustomList( + """ + The name of the custom list to delete + """ + customList: String + + """ + The media list type of the custom list + """ + type: MediaType + ): Deleted + + """ + Create or update text activity for the currently authenticated user + """ + SaveTextActivity( + """ + The activity's id, required for updating + """ + id: Int + + """ + The activity text + """ + text: String + + """ + If the activity should be locked. (Mod Only) + """ + locked: Boolean + ): TextActivity + + """ + Create or update message activity for the currently authenticated user + """ + SaveMessageActivity( + """ + The activity id, required for updating + """ + id: Int + + """ + The activity message text + """ + message: String + + """ + The id of the user the message is being sent to + """ + recipientId: Int + + """ + If the activity should be private + """ + private: Boolean + + """ + If the activity should be locked. (Mod Only) + """ + locked: Boolean + + """ + If the message should be sent from the Moderator account (Mod Only) + """ + asMod: Boolean + ): MessageActivity + + """ + Update list activity (Mod Only) + """ + SaveListActivity( + """ + The activity's id, required for updating + """ + id: Int + + """ + If the activity should be locked. (Mod Only) + """ + locked: Boolean + ): ListActivity + + """ + Delete an activity item of the authenticated users + """ + DeleteActivity( + """ + The id of the activity to delete + """ + id: Int + ): Deleted + + """ + Toggle activity to be pinned to the top of the user's activity feed + """ + ToggleActivityPin( + """ + Toggle activity id to be pinned + """ + id: Int + + """ + If the activity should be pinned or unpinned + """ + pinned: Boolean + ): ActivityUnion + + """ + Toggle the subscription of an activity item + """ + ToggleActivitySubscription( + """ + The id of the activity to un/subscribe + """ + activityId: Int + + """ + Whether to subscribe or unsubscribe from the activity + """ + subscribe: Boolean + ): ActivityUnion + + """ + Create or update an activity reply + """ + SaveActivityReply( + """ + The activity reply id, required for updating + """ + id: Int + + """ + The id of the parent activity being replied to + """ + activityId: Int + + """ + The reply text + """ + text: String + + """ + If the reply should be sent from the Moderator account (Mod Only) + """ + asMod: Boolean + ): ActivityReply + + """ + Delete an activity reply of the authenticated users + """ + DeleteActivityReply( + """ + The id of the reply to delete + """ + id: Int + ): Deleted + + """ + Add or remove a like from a likeable type. + Returns all the users who liked the same model + """ + ToggleLike( + """ + The id of the likeable type + """ + id: Int + + """ + The type of model to be un/liked + """ + type: LikeableType + ): [User] + + """ + Add or remove a like from a likeable type. + """ + ToggleLikeV2( + """ + The id of the likeable type + """ + id: Int + + """ + The type of model to be un/liked + """ + type: LikeableType + ): LikeableUnion + + """ + Toggle the un/following of a user + """ + ToggleFollow( + """ + The id of the user to un/follow + """ + userId: Int + ): User + + """ + Favourite or unfavourite an anime, manga, character, staff member, or studio + """ + ToggleFavourite( + """ + The id of the anime to un/favourite + """ + animeId: Int + + """ + The id of the manga to un/favourite + """ + mangaId: Int + + """ + The id of the character to un/favourite + """ + characterId: Int + + """ + The id of the staff to un/favourite + """ + staffId: Int + + """ + The id of the studio to un/favourite + """ + studioId: Int + ): Favourites + + """ + Update the order favourites are displayed in + """ + UpdateFavouriteOrder( + """ + The id of the anime to un/favourite + """ + animeIds: [Int] + + """ + The id of the manga to un/favourite + """ + mangaIds: [Int] + + """ + The id of the character to un/favourite + """ + characterIds: [Int] + + """ + The id of the staff to un/favourite + """ + staffIds: [Int] + + """ + The id of the studio to un/favourite + """ + studioIds: [Int] + + """ + List of integers which the anime should be ordered by (Asc) + """ + animeOrder: [Int] + + """ + List of integers which the manga should be ordered by (Asc) + """ + mangaOrder: [Int] + + """ + List of integers which the character should be ordered by (Asc) + """ + characterOrder: [Int] + + """ + List of integers which the staff should be ordered by (Asc) + """ + staffOrder: [Int] + + """ + List of integers which the studio should be ordered by (Asc) + """ + studioOrder: [Int] + ): Favourites + + """ + Create or update a review + """ + SaveReview( + """ + The review id, required for updating + """ + id: Int + + """ + The id of the media the review is of + """ + mediaId: Int + + """ + The main review text. Min:2200 characters + """ + body: String + + """ + A short summary/preview of the review. Min:20, Max:120 characters + """ + summary: String + + """ + A short summary/preview of the review. Min:20, Max:120 characters + """ + score: Int + + """ + If the review should only be visible to its creator + """ + private: Boolean + ): Review + + """ + Delete a review + """ + DeleteReview( + """ + The id of the review to delete + """ + id: Int + ): Deleted + + """ + Rate a review + """ + RateReview( + """ + The id of the review to rate + """ + reviewId: Int + + """ + The rating to apply to the review + """ + rating: ReviewRating + ): Review + + """ + Recommendation a media + """ + SaveRecommendation( + """ + The id of the base media + """ + mediaId: Int + + """ + The id of the media to recommend + """ + mediaRecommendationId: Int + + """ + The rating to give the recommendation + """ + rating: RecommendationRating + ): Recommendation + + """ + Create or update a forum thread + """ + SaveThread( + """ + The thread id, required for updating + """ + id: Int + + """ + The title of the thread + """ + title: String + + """ + The main text body of the thread + """ + body: String + + """ + Forum categories the thread should be within + """ + categories: [Int] + + """ + Media related to the contents of the thread + """ + mediaCategories: [Int] + + """ + If the thread should be stickied. (Mod Only) + """ + sticky: Boolean + + """ + If the thread should be locked. (Mod Only) + """ + locked: Boolean + ): Thread + + """ + Delete a thread + """ + DeleteThread( + """ + The id of the thread to delete + """ + id: Int + ): Deleted + + """ + Toggle the subscription of a forum thread + """ + ToggleThreadSubscription( + """ + The id of the forum thread to un/subscribe + """ + threadId: Int + + """ + Whether to subscribe or unsubscribe from the forum thread + """ + subscribe: Boolean + ): Thread + + """ + Create or update a thread comment + """ + SaveThreadComment( + """ + The comment id, required for updating + """ + id: Int + + """ + The id of thread the comment belongs to + """ + threadId: Int + + """ + The id of thread comment to reply to + """ + parentCommentId: Int + + """ + The comment markdown text + """ + comment: String + + """ + If the comment tree should be locked. (Mod Only) + """ + locked: Boolean + ): ThreadComment + + """ + Delete a thread comment + """ + DeleteThreadComment( + """ + The id of the thread comment to delete + """ + id: Int + ): Deleted + UpdateAniChartSettings(titleLanguage: String, outgoingLinkProvider: String, theme: String, sort: String): Json + UpdateAniChartHighlights(highlights: [AniChartHighlightInput]): Json +} + +""" +Notification option +""" +type NotificationOption { + """ + The type of notification + """ + type: NotificationType + + """ + Whether this type of notification is enabled + """ + enabled: Boolean +} + +""" +Notification option input +""" +input NotificationOptionInput { + """ + The type of notification + """ + type: NotificationType + + """ + Whether this type of notification is enabled + """ + enabled: Boolean +} + +""" +Notification type enum +""" +enum NotificationType { + """ + A user has sent you message + """ + ACTIVITY_MESSAGE + + """ + A user has replied to your activity + """ + ACTIVITY_REPLY + + """ + A user has followed you + """ + FOLLOWING + + """ + A user has mentioned you in their activity + """ + ACTIVITY_MENTION + + """ + A user has mentioned you in a forum comment + """ + THREAD_COMMENT_MENTION + + """ + A user has commented in one of your subscribed forum threads + """ + THREAD_SUBSCRIBED + + """ + A user has replied to your forum comment + """ + THREAD_COMMENT_REPLY + + """ + An anime you are currently watching has aired + """ + AIRING + + """ + A user has liked your activity + """ + ACTIVITY_LIKE + + """ + A user has liked your activity reply + """ + ACTIVITY_REPLY_LIKE + + """ + A user has liked your forum thread + """ + THREAD_LIKE + + """ + A user has liked your forum comment + """ + THREAD_COMMENT_LIKE + + """ + A user has replied to activity you have also replied to + """ + ACTIVITY_REPLY_SUBSCRIBED + + """ + A new anime or manga has been added to the site where its related media is on the user's list + """ + RELATED_MEDIA_ADDITION + + """ + An anime or manga has had a data change that affects how a user may track it in their lists + """ + MEDIA_DATA_CHANGE + + """ + Anime or manga entries on the user's list have been merged into a single entry + """ + MEDIA_MERGE + + """ + An anime or manga on the user's list has been deleted from the site + """ + MEDIA_DELETION +} + +""" +Notification union type +""" +union NotificationUnion = + AiringNotification + | FollowingNotification + | ActivityMessageNotification + | ActivityMentionNotification + | ActivityReplyNotification + | ActivityReplySubscribedNotification + | ActivityLikeNotification + | ActivityReplyLikeNotification + | ThreadCommentMentionNotification + | ThreadCommentReplyNotification + | ThreadCommentSubscribedNotification + | ThreadCommentLikeNotification + | ThreadLikeNotification + | RelatedMediaAdditionNotification + | MediaDataChangeNotification + | MediaMergeNotification + | MediaDeletionNotification + +""" +Page of data +""" +type Page { + """ + The pagination information + """ + pageInfo: PageInfo + users( + """ + Filter by the user id + """ + id: Int + + """ + Filter by the name of the user + """ + name: String + + """ + Filter to moderators only if true + """ + isModerator: Boolean + + """ + Filter by search query + """ + search: String + + """ + The order the results will be returned in + """ + sort: [UserSort] + ): [User] + media( + """ + Filter by the media id + """ + id: Int + + """ + Filter by the media's MyAnimeList id + """ + idMal: Int + + """ + Filter by the start date of the media + """ + startDate: FuzzyDateInt + + """ + Filter by the end date of the media + """ + endDate: FuzzyDateInt + + """ + Filter by the season the media was released in + """ + season: MediaSeason + + """ + The year of the season (Winter 2017 would also include December 2016 releases). Requires season argument + """ + seasonYear: Int + + """ + Filter by the media's type + """ + type: MediaType + + """ + Filter by the media's format + """ + format: MediaFormat + + """ + Filter by the media's current release status + """ + status: MediaStatus + + """ + Filter by amount of episodes the media has + """ + episodes: Int + + """ + Filter by the media's episode length + """ + duration: Int + + """ + Filter by the media's chapter count + """ + chapters: Int + + """ + Filter by the media's volume count + """ + volumes: Int + + """ + Filter by if the media's intended for 18+ adult audiences + """ + isAdult: Boolean + + """ + Filter by the media's genres + """ + genre: String + + """ + Filter by the media's tags + """ + tag: String + + """ + Only apply the tags filter argument to tags above this rank. Default: 18 + """ + minimumTagRank: Int + + """ + Filter by the media's tags with in a tag category + """ + tagCategory: String + + """ + Filter by the media on the authenticated user's lists + """ + onList: Boolean + + """ + Filter media by sites name with a online streaming or reading license + """ + licensedBy: String + + """ + Filter media by sites id with a online streaming or reading license + """ + licensedById: Int + + """ + Filter by the media's average score + """ + averageScore: Int + + """ + Filter by the number of users with this media on their list + """ + popularity: Int + + """ + Filter by the source type of the media + """ + source: MediaSource + + """ + Filter by the media's country of origin + """ + countryOfOrigin: CountryCode + + """ + If the media is officially licensed or a self-published doujin release + """ + isLicensed: Boolean + + """ + Filter by search query + """ + search: String + + """ + Filter by the media id + """ + id_not: Int + + """ + Filter by the media id + """ + id_in: [Int] + + """ + Filter by the media id + """ + id_not_in: [Int] + + """ + Filter by the media's MyAnimeList id + """ + idMal_not: Int + + """ + Filter by the media's MyAnimeList id + """ + idMal_in: [Int] + + """ + Filter by the media's MyAnimeList id + """ + idMal_not_in: [Int] + + """ + Filter by the start date of the media + """ + startDate_greater: FuzzyDateInt + + """ + Filter by the start date of the media + """ + startDate_lesser: FuzzyDateInt + + """ + Filter by the start date of the media + """ + startDate_like: String + + """ + Filter by the end date of the media + """ + endDate_greater: FuzzyDateInt + + """ + Filter by the end date of the media + """ + endDate_lesser: FuzzyDateInt + + """ + Filter by the end date of the media + """ + endDate_like: String + + """ + Filter by the media's format + """ + format_in: [MediaFormat] + + """ + Filter by the media's format + """ + format_not: MediaFormat + + """ + Filter by the media's format + """ + format_not_in: [MediaFormat] + + """ + Filter by the media's current release status + """ + status_in: [MediaStatus] + + """ + Filter by the media's current release status + """ + status_not: MediaStatus + + """ + Filter by the media's current release status + """ + status_not_in: [MediaStatus] + + """ + Filter by amount of episodes the media has + """ + episodes_greater: Int + + """ + Filter by amount of episodes the media has + """ + episodes_lesser: Int + + """ + Filter by the media's episode length + """ + duration_greater: Int + + """ + Filter by the media's episode length + """ + duration_lesser: Int + + """ + Filter by the media's chapter count + """ + chapters_greater: Int + + """ + Filter by the media's chapter count + """ + chapters_lesser: Int + + """ + Filter by the media's volume count + """ + volumes_greater: Int + + """ + Filter by the media's volume count + """ + volumes_lesser: Int + + """ + Filter by the media's genres + """ + genre_in: [String] + + """ + Filter by the media's genres + """ + genre_not_in: [String] + + """ + Filter by the media's tags + """ + tag_in: [String] + + """ + Filter by the media's tags + """ + tag_not_in: [String] + + """ + Filter by the media's tags with in a tag category + """ + tagCategory_in: [String] + + """ + Filter by the media's tags with in a tag category + """ + tagCategory_not_in: [String] + + """ + Filter media by sites name with a online streaming or reading license + """ + licensedBy_in: [String] + + """ + Filter media by sites id with a online streaming or reading license + """ + licensedById_in: [Int] + + """ + Filter by the media's average score + """ + averageScore_not: Int + + """ + Filter by the media's average score + """ + averageScore_greater: Int + + """ + Filter by the media's average score + """ + averageScore_lesser: Int + + """ + Filter by the number of users with this media on their list + """ + popularity_not: Int + + """ + Filter by the number of users with this media on their list + """ + popularity_greater: Int + + """ + Filter by the number of users with this media on their list + """ + popularity_lesser: Int + + """ + Filter by the source type of the media + """ + source_in: [MediaSource] + + """ + The order the results will be returned in + """ + sort: [MediaSort] + ): [Media] + characters( + """ + Filter by character id + """ + id: Int + + """ + Filter by character by if its their birthday today + """ + isBirthday: Boolean + + """ + Filter by search query + """ + search: String + + """ + Filter by character id + """ + id_not: Int + + """ + Filter by character id + """ + id_in: [Int] + + """ + Filter by character id + """ + id_not_in: [Int] + + """ + The order the results will be returned in + """ + sort: [CharacterSort] + ): [Character] + staff( + """ + Filter by the staff id + """ + id: Int + + """ + Filter by staff by if its their birthday today + """ + isBirthday: Boolean + + """ + Filter by search query + """ + search: String + + """ + Filter by the staff id + """ + id_not: Int + + """ + Filter by the staff id + """ + id_in: [Int] + + """ + Filter by the staff id + """ + id_not_in: [Int] + + """ + The order the results will be returned in + """ + sort: [StaffSort] + ): [Staff] + studios( + """ + Filter by the studio id + """ + id: Int + + """ + Filter by search query + """ + search: String + + """ + Filter by the studio id + """ + id_not: Int + + """ + Filter by the studio id + """ + id_in: [Int] + + """ + Filter by the studio id + """ + id_not_in: [Int] + + """ + The order the results will be returned in + """ + sort: [StudioSort] + ): [Studio] + mediaList( + """ + Filter by a list entry's id + """ + id: Int + + """ + Filter by a user's id + """ + userId: Int + + """ + Filter by a user's name + """ + userName: String + + """ + Filter by the list entries media type + """ + type: MediaType + + """ + Filter by the watching/reading status + """ + status: MediaListStatus + + """ + Filter by the media id of the list entry + """ + mediaId: Int + + """ + Filter list entries to users who are being followed by the authenticated user + """ + isFollowing: Boolean + + """ + Filter by note words and #tags + """ + notes: String + + """ + Filter by the date the user started the media + """ + startedAt: FuzzyDateInt + + """ + Filter by the date the user completed the media + """ + completedAt: FuzzyDateInt + + """ + Limit to only entries also on the auth user's list. Requires user id or name arguments. + """ + compareWithAuthList: Boolean + + """ + Filter by a user's id + """ + userId_in: [Int] + + """ + Filter by the watching/reading status + """ + status_in: [MediaListStatus] + + """ + Filter by the watching/reading status + """ + status_not_in: [MediaListStatus] + + """ + Filter by the watching/reading status + """ + status_not: MediaListStatus + + """ + Filter by the media id of the list entry + """ + mediaId_in: [Int] + + """ + Filter by the media id of the list entry + """ + mediaId_not_in: [Int] + + """ + Filter by note words and #tags + """ + notes_like: String + + """ + Filter by the date the user started the media + """ + startedAt_greater: FuzzyDateInt + + """ + Filter by the date the user started the media + """ + startedAt_lesser: FuzzyDateInt + + """ + Filter by the date the user started the media + """ + startedAt_like: String + + """ + Filter by the date the user completed the media + """ + completedAt_greater: FuzzyDateInt + + """ + Filter by the date the user completed the media + """ + completedAt_lesser: FuzzyDateInt + + """ + Filter by the date the user completed the media + """ + completedAt_like: String + + """ + The order the results will be returned in + """ + sort: [MediaListSort] + ): [MediaList] + airingSchedules( + """ + Filter by the id of the airing schedule item + """ + id: Int + + """ + Filter by the id of associated media + """ + mediaId: Int + + """ + Filter by the airing episode number + """ + episode: Int + + """ + Filter by the time of airing + """ + airingAt: Int + + """ + Filter to episodes that haven't yet aired + """ + notYetAired: Boolean + + """ + Filter by the id of the airing schedule item + """ + id_not: Int + + """ + Filter by the id of the airing schedule item + """ + id_in: [Int] + + """ + Filter by the id of the airing schedule item + """ + id_not_in: [Int] + + """ + Filter by the id of associated media + """ + mediaId_not: Int + + """ + Filter by the id of associated media + """ + mediaId_in: [Int] + + """ + Filter by the id of associated media + """ + mediaId_not_in: [Int] + + """ + Filter by the airing episode number + """ + episode_not: Int + + """ + Filter by the airing episode number + """ + episode_in: [Int] + + """ + Filter by the airing episode number + """ + episode_not_in: [Int] + + """ + Filter by the airing episode number + """ + episode_greater: Int + + """ + Filter by the airing episode number + """ + episode_lesser: Int + + """ + Filter by the time of airing + """ + airingAt_greater: Int + + """ + Filter by the time of airing + """ + airingAt_lesser: Int + + """ + The order the results will be returned in + """ + sort: [AiringSort] + ): [AiringSchedule] + mediaTrends( + """ + Filter by the media id + """ + mediaId: Int + + """ + Filter by date + """ + date: Int + + """ + Filter by trending amount + """ + trending: Int + + """ + Filter by score + """ + averageScore: Int + + """ + Filter by popularity + """ + popularity: Int + + """ + Filter by episode number + """ + episode: Int + + """ + Filter to stats recorded while the media was releasing + """ + releasing: Boolean + + """ + Filter by the media id + """ + mediaId_not: Int + + """ + Filter by the media id + """ + mediaId_in: [Int] + + """ + Filter by the media id + """ + mediaId_not_in: [Int] + + """ + Filter by date + """ + date_greater: Int + + """ + Filter by date + """ + date_lesser: Int + + """ + Filter by trending amount + """ + trending_greater: Int + + """ + Filter by trending amount + """ + trending_lesser: Int + + """ + Filter by trending amount + """ + trending_not: Int + + """ + Filter by score + """ + averageScore_greater: Int + + """ + Filter by score + """ + averageScore_lesser: Int + + """ + Filter by score + """ + averageScore_not: Int + + """ + Filter by popularity + """ + popularity_greater: Int + + """ + Filter by popularity + """ + popularity_lesser: Int + + """ + Filter by popularity + """ + popularity_not: Int + + """ + Filter by episode number + """ + episode_greater: Int + + """ + Filter by episode number + """ + episode_lesser: Int + + """ + Filter by episode number + """ + episode_not: Int + + """ + The order the results will be returned in + """ + sort: [MediaTrendSort] + ): [MediaTrend] + notifications( + """ + Filter by the type of notifications + """ + type: NotificationType + + """ + Reset the unread notification count to 0 on load + """ + resetNotificationCount: Boolean + + """ + Filter by the type of notifications + """ + type_in: [NotificationType] + ): [NotificationUnion] + followers( + """ + User id of the follower/followed + """ + userId: Int! + + """ + The order the results will be returned in + """ + sort: [UserSort] + ): [User] + following( + """ + User id of the follower/followed + """ + userId: Int! + + """ + The order the results will be returned in + """ + sort: [UserSort] + ): [User] + activities( + """ + Filter by the activity id + """ + id: Int + + """ + Filter by the owner user id + """ + userId: Int + + """ + Filter by the id of the user who sent a message + """ + messengerId: Int + + """ + Filter by the associated media id of the activity + """ + mediaId: Int + + """ + Filter by the type of activity + """ + type: ActivityType + + """ + Filter activity to users who are being followed by the authenticated user + """ + isFollowing: Boolean + + """ + Filter activity to only activity with replies + """ + hasReplies: Boolean + + """ + Filter activity to only activity with replies or is of type text + """ + hasRepliesOrTypeText: Boolean + + """ + Filter by the time the activity was created + """ + createdAt: Int + + """ + Filter by the activity id + """ + id_not: Int + + """ + Filter by the activity id + """ + id_in: [Int] + + """ + Filter by the activity id + """ + id_not_in: [Int] + + """ + Filter by the owner user id + """ + userId_not: Int + + """ + Filter by the owner user id + """ + userId_in: [Int] + + """ + Filter by the owner user id + """ + userId_not_in: [Int] + + """ + Filter by the id of the user who sent a message + """ + messengerId_not: Int + + """ + Filter by the id of the user who sent a message + """ + messengerId_in: [Int] + + """ + Filter by the id of the user who sent a message + """ + messengerId_not_in: [Int] + + """ + Filter by the associated media id of the activity + """ + mediaId_not: Int + + """ + Filter by the associated media id of the activity + """ + mediaId_in: [Int] + + """ + Filter by the associated media id of the activity + """ + mediaId_not_in: [Int] + + """ + Filter by the type of activity + """ + type_not: ActivityType + + """ + Filter by the type of activity + """ + type_in: [ActivityType] + + """ + Filter by the type of activity + """ + type_not_in: [ActivityType] + + """ + Filter by the time the activity was created + """ + createdAt_greater: Int + + """ + Filter by the time the activity was created + """ + createdAt_lesser: Int + + """ + The order the results will be returned in + """ + sort: [ActivitySort] + ): [ActivityUnion] + activityReplies( + """ + Filter by the reply id + """ + id: Int + + """ + Filter by the parent id + """ + activityId: Int + ): [ActivityReply] + threads( + """ + Filter by the thread id + """ + id: Int + + """ + Filter by the user id of the thread's creator + """ + userId: Int + + """ + Filter by the user id of the last user to comment on the thread + """ + replyUserId: Int + + """ + Filter by if the currently authenticated user's subscribed threads + """ + subscribed: Boolean + + """ + Filter by thread category id + """ + categoryId: Int + + """ + Filter by thread media id category + """ + mediaCategoryId: Int + + """ + Filter by search query + """ + search: String + + """ + Filter by the thread id + """ + id_in: [Int] + + """ + The order the results will be returned in + """ + sort: [ThreadSort] + ): [Thread] + threadComments( + """ + Filter by the comment id + """ + id: Int + + """ + Filter by the thread id + """ + threadId: Int + + """ + Filter by the user id of the comment's creator + """ + userId: Int + + """ + The order the results will be returned in + """ + sort: [ThreadCommentSort] + ): [ThreadComment] + reviews( + """ + Filter by Review id + """ + id: Int + + """ + Filter by media id + """ + mediaId: Int + + """ + Filter by user id + """ + userId: Int + + """ + Filter by media type + """ + mediaType: MediaType + + """ + The order the results will be returned in + """ + sort: [ReviewSort] + ): [Review] + recommendations( + """ + Filter by recommendation id + """ + id: Int + + """ + Filter by media id + """ + mediaId: Int + + """ + Filter by media recommendation id + """ + mediaRecommendationId: Int + + """ + Filter by user who created the recommendation + """ + userId: Int + + """ + Filter by total rating of the recommendation + """ + rating: Int + + """ + Filter by the media on the authenticated user's lists + """ + onList: Boolean + + """ + Filter by total rating of the recommendation + """ + rating_greater: Int + + """ + Filter by total rating of the recommendation + """ + rating_lesser: Int + + """ + The order the results will be returned in + """ + sort: [RecommendationSort] + ): [Recommendation] + likes( + """ + The id of the likeable type + """ + likeableId: Int + + """ + The type of model the id applies to + """ + type: LikeableType + ): [User] +} + +type PageInfo { + """ + The total number of items. Note: This value is not guaranteed to be accurate, do not rely on this for logic + """ + total: Int + + """ + The count on a page + """ + perPage: Int + + """ + The current page + """ + currentPage: Int + + """ + The last page + """ + lastPage: Int + + """ + If there is another page + """ + hasNextPage: Boolean +} + +""" +Provides the parsed markdown as html +""" +type ParsedMarkdown { + """ + The parsed markdown as html + """ + html: String +} + +type Query { + Page( + """ + The page number + """ + page: Int + + """ + The amount of entries per page, max 50 + """ + perPage: Int + ): Page + + """ + Media query + """ + Media( + """ + Filter by the media id + """ + id: Int + + """ + Filter by the media's MyAnimeList id + """ + idMal: Int + + """ + Filter by the start date of the media + """ + startDate: FuzzyDateInt + + """ + Filter by the end date of the media + """ + endDate: FuzzyDateInt + + """ + Filter by the season the media was released in + """ + season: MediaSeason + + """ + The year of the season (Winter 2017 would also include December 2016 releases). Requires season argument + """ + seasonYear: Int + + """ + Filter by the media's type + """ + type: MediaType + + """ + Filter by the media's format + """ + format: MediaFormat + + """ + Filter by the media's current release status + """ + status: MediaStatus + + """ + Filter by amount of episodes the media has + """ + episodes: Int + + """ + Filter by the media's episode length + """ + duration: Int + + """ + Filter by the media's chapter count + """ + chapters: Int + + """ + Filter by the media's volume count + """ + volumes: Int + + """ + Filter by if the media's intended for 18+ adult audiences + """ + isAdult: Boolean + + """ + Filter by the media's genres + """ + genre: String + + """ + Filter by the media's tags + """ + tag: String + + """ + Only apply the tags filter argument to tags above this rank. Default: 18 + """ + minimumTagRank: Int + + """ + Filter by the media's tags with in a tag category + """ + tagCategory: String + + """ + Filter by the media on the authenticated user's lists + """ + onList: Boolean + + """ + Filter media by sites name with a online streaming or reading license + """ + licensedBy: String + + """ + Filter media by sites id with a online streaming or reading license + """ + licensedById: Int + + """ + Filter by the media's average score + """ + averageScore: Int + + """ + Filter by the number of users with this media on their list + """ + popularity: Int + + """ + Filter by the source type of the media + """ + source: MediaSource + + """ + Filter by the media's country of origin + """ + countryOfOrigin: CountryCode + + """ + If the media is officially licensed or a self-published doujin release + """ + isLicensed: Boolean + + """ + Filter by search query + """ + search: String + + """ + Filter by the media id + """ + id_not: Int + + """ + Filter by the media id + """ + id_in: [Int] + + """ + Filter by the media id + """ + id_not_in: [Int] + + """ + Filter by the media's MyAnimeList id + """ + idMal_not: Int + + """ + Filter by the media's MyAnimeList id + """ + idMal_in: [Int] + + """ + Filter by the media's MyAnimeList id + """ + idMal_not_in: [Int] + + """ + Filter by the start date of the media + """ + startDate_greater: FuzzyDateInt + + """ + Filter by the start date of the media + """ + startDate_lesser: FuzzyDateInt + + """ + Filter by the start date of the media + """ + startDate_like: String + + """ + Filter by the end date of the media + """ + endDate_greater: FuzzyDateInt + + """ + Filter by the end date of the media + """ + endDate_lesser: FuzzyDateInt + + """ + Filter by the end date of the media + """ + endDate_like: String + + """ + Filter by the media's format + """ + format_in: [MediaFormat] + + """ + Filter by the media's format + """ + format_not: MediaFormat + + """ + Filter by the media's format + """ + format_not_in: [MediaFormat] + + """ + Filter by the media's current release status + """ + status_in: [MediaStatus] + + """ + Filter by the media's current release status + """ + status_not: MediaStatus + + """ + Filter by the media's current release status + """ + status_not_in: [MediaStatus] + + """ + Filter by amount of episodes the media has + """ + episodes_greater: Int + + """ + Filter by amount of episodes the media has + """ + episodes_lesser: Int + + """ + Filter by the media's episode length + """ + duration_greater: Int + + """ + Filter by the media's episode length + """ + duration_lesser: Int + + """ + Filter by the media's chapter count + """ + chapters_greater: Int + + """ + Filter by the media's chapter count + """ + chapters_lesser: Int + + """ + Filter by the media's volume count + """ + volumes_greater: Int + + """ + Filter by the media's volume count + """ + volumes_lesser: Int + + """ + Filter by the media's genres + """ + genre_in: [String] + + """ + Filter by the media's genres + """ + genre_not_in: [String] + + """ + Filter by the media's tags + """ + tag_in: [String] + + """ + Filter by the media's tags + """ + tag_not_in: [String] + + """ + Filter by the media's tags with in a tag category + """ + tagCategory_in: [String] + + """ + Filter by the media's tags with in a tag category + """ + tagCategory_not_in: [String] + + """ + Filter media by sites name with a online streaming or reading license + """ + licensedBy_in: [String] + + """ + Filter media by sites id with a online streaming or reading license + """ + licensedById_in: [Int] + + """ + Filter by the media's average score + """ + averageScore_not: Int + + """ + Filter by the media's average score + """ + averageScore_greater: Int + + """ + Filter by the media's average score + """ + averageScore_lesser: Int + + """ + Filter by the number of users with this media on their list + """ + popularity_not: Int + + """ + Filter by the number of users with this media on their list + """ + popularity_greater: Int + + """ + Filter by the number of users with this media on their list + """ + popularity_lesser: Int + + """ + Filter by the source type of the media + """ + source_in: [MediaSource] + + """ + The order the results will be returned in + """ + sort: [MediaSort] + ): Media + + """ + Media Trend query + """ + MediaTrend( + """ + Filter by the media id + """ + mediaId: Int + + """ + Filter by date + """ + date: Int + + """ + Filter by trending amount + """ + trending: Int + + """ + Filter by score + """ + averageScore: Int + + """ + Filter by popularity + """ + popularity: Int + + """ + Filter by episode number + """ + episode: Int + + """ + Filter to stats recorded while the media was releasing + """ + releasing: Boolean + + """ + Filter by the media id + """ + mediaId_not: Int + + """ + Filter by the media id + """ + mediaId_in: [Int] + + """ + Filter by the media id + """ + mediaId_not_in: [Int] + + """ + Filter by date + """ + date_greater: Int + + """ + Filter by date + """ + date_lesser: Int + + """ + Filter by trending amount + """ + trending_greater: Int + + """ + Filter by trending amount + """ + trending_lesser: Int + + """ + Filter by trending amount + """ + trending_not: Int + + """ + Filter by score + """ + averageScore_greater: Int + + """ + Filter by score + """ + averageScore_lesser: Int + + """ + Filter by score + """ + averageScore_not: Int + + """ + Filter by popularity + """ + popularity_greater: Int + + """ + Filter by popularity + """ + popularity_lesser: Int + + """ + Filter by popularity + """ + popularity_not: Int + + """ + Filter by episode number + """ + episode_greater: Int + + """ + Filter by episode number + """ + episode_lesser: Int + + """ + Filter by episode number + """ + episode_not: Int + + """ + The order the results will be returned in + """ + sort: [MediaTrendSort] + ): MediaTrend + + """ + Airing schedule query + """ + AiringSchedule( + """ + Filter by the id of the airing schedule item + """ + id: Int + + """ + Filter by the id of associated media + """ + mediaId: Int + + """ + Filter by the airing episode number + """ + episode: Int + + """ + Filter by the time of airing + """ + airingAt: Int + + """ + Filter to episodes that haven't yet aired + """ + notYetAired: Boolean + + """ + Filter by the id of the airing schedule item + """ + id_not: Int + + """ + Filter by the id of the airing schedule item + """ + id_in: [Int] + + """ + Filter by the id of the airing schedule item + """ + id_not_in: [Int] + + """ + Filter by the id of associated media + """ + mediaId_not: Int + + """ + Filter by the id of associated media + """ + mediaId_in: [Int] + + """ + Filter by the id of associated media + """ + mediaId_not_in: [Int] + + """ + Filter by the airing episode number + """ + episode_not: Int + + """ + Filter by the airing episode number + """ + episode_in: [Int] + + """ + Filter by the airing episode number + """ + episode_not_in: [Int] + + """ + Filter by the airing episode number + """ + episode_greater: Int + + """ + Filter by the airing episode number + """ + episode_lesser: Int + + """ + Filter by the time of airing + """ + airingAt_greater: Int + + """ + Filter by the time of airing + """ + airingAt_lesser: Int + + """ + The order the results will be returned in + """ + sort: [AiringSort] + ): AiringSchedule + + """ + Character query + """ + Character( + """ + Filter by character id + """ + id: Int + + """ + Filter by character by if its their birthday today + """ + isBirthday: Boolean + + """ + Filter by search query + """ + search: String + + """ + Filter by character id + """ + id_not: Int + + """ + Filter by character id + """ + id_in: [Int] + + """ + Filter by character id + """ + id_not_in: [Int] + + """ + The order the results will be returned in + """ + sort: [CharacterSort] + ): Character + + """ + Staff query + """ + Staff( + """ + Filter by the staff id + """ + id: Int + + """ + Filter by staff by if its their birthday today + """ + isBirthday: Boolean + + """ + Filter by search query + """ + search: String + + """ + Filter by the staff id + """ + id_not: Int + + """ + Filter by the staff id + """ + id_in: [Int] + + """ + Filter by the staff id + """ + id_not_in: [Int] + + """ + The order the results will be returned in + """ + sort: [StaffSort] + ): Staff + + """ + Media list query + """ + MediaList( + """ + Filter by a list entry's id + """ + id: Int + + """ + Filter by a user's id + """ + userId: Int + + """ + Filter by a user's name + """ + userName: String + + """ + Filter by the list entries media type + """ + type: MediaType + + """ + Filter by the watching/reading status + """ + status: MediaListStatus + + """ + Filter by the media id of the list entry + """ + mediaId: Int + + """ + Filter list entries to users who are being followed by the authenticated user + """ + isFollowing: Boolean + + """ + Filter by note words and #tags + """ + notes: String + + """ + Filter by the date the user started the media + """ + startedAt: FuzzyDateInt + + """ + Filter by the date the user completed the media + """ + completedAt: FuzzyDateInt + + """ + Limit to only entries also on the auth user's list. Requires user id or name arguments. + """ + compareWithAuthList: Boolean + + """ + Filter by a user's id + """ + userId_in: [Int] + + """ + Filter by the watching/reading status + """ + status_in: [MediaListStatus] + + """ + Filter by the watching/reading status + """ + status_not_in: [MediaListStatus] + + """ + Filter by the watching/reading status + """ + status_not: MediaListStatus + + """ + Filter by the media id of the list entry + """ + mediaId_in: [Int] + + """ + Filter by the media id of the list entry + """ + mediaId_not_in: [Int] + + """ + Filter by note words and #tags + """ + notes_like: String + + """ + Filter by the date the user started the media + """ + startedAt_greater: FuzzyDateInt + + """ + Filter by the date the user started the media + """ + startedAt_lesser: FuzzyDateInt + + """ + Filter by the date the user started the media + """ + startedAt_like: String + + """ + Filter by the date the user completed the media + """ + completedAt_greater: FuzzyDateInt + + """ + Filter by the date the user completed the media + """ + completedAt_lesser: FuzzyDateInt + + """ + Filter by the date the user completed the media + """ + completedAt_like: String + + """ + The order the results will be returned in + """ + sort: [MediaListSort] + ): MediaList + + """ + Media list collection query, provides list pre-grouped by status & custom + lists. User ID and Media Type arguments required. + """ + MediaListCollection( + """ + Filter by a user's id + """ + userId: Int + + """ + Filter by a user's name + """ + userName: String + + """ + Filter by the list entries media type + """ + type: MediaType + + """ + Filter by the watching/reading status + """ + status: MediaListStatus + + """ + Filter by note words and #tags + """ + notes: String + + """ + Filter by the date the user started the media + """ + startedAt: FuzzyDateInt + + """ + Filter by the date the user completed the media + """ + completedAt: FuzzyDateInt + + """ + Always return completed list entries in one group, overriding the user's split completed option. + """ + forceSingleCompletedList: Boolean + + """ + Which chunk of list entries to load + """ + chunk: Int + + """ + The amount of entries per chunk, max 500 + """ + perChunk: Int + + """ + Filter by the watching/reading status + """ + status_in: [MediaListStatus] + + """ + Filter by the watching/reading status + """ + status_not_in: [MediaListStatus] + + """ + Filter by the watching/reading status + """ + status_not: MediaListStatus + + """ + Filter by note words and #tags + """ + notes_like: String + + """ + Filter by the date the user started the media + """ + startedAt_greater: FuzzyDateInt + + """ + Filter by the date the user started the media + """ + startedAt_lesser: FuzzyDateInt + + """ + Filter by the date the user started the media + """ + startedAt_like: String + + """ + Filter by the date the user completed the media + """ + completedAt_greater: FuzzyDateInt + + """ + Filter by the date the user completed the media + """ + completedAt_lesser: FuzzyDateInt + + """ + Filter by the date the user completed the media + """ + completedAt_like: String + + """ + The order the results will be returned in + """ + sort: [MediaListSort] + ): MediaListCollection + + """ + Collection of all the possible media genres + """ + GenreCollection: [String] + + """ + Collection of all the possible media tags + """ + MediaTagCollection( + """ + Mod Only + """ + status: Int + ): [MediaTag] + + """ + User query + """ + User( + """ + Filter by the user id + """ + id: Int + + """ + Filter by the name of the user + """ + name: String + + """ + Filter to moderators only if true + """ + isModerator: Boolean + + """ + Filter by search query + """ + search: String + + """ + The order the results will be returned in + """ + sort: [UserSort] + ): User + + """ + Get the currently authenticated user + """ + Viewer: User + + """ + Notification query + """ + Notification( + """ + Filter by the type of notifications + """ + type: NotificationType + + """ + Reset the unread notification count to 0 on load + """ + resetNotificationCount: Boolean + + """ + Filter by the type of notifications + """ + type_in: [NotificationType] + ): NotificationUnion + + """ + Studio query + """ + Studio( + """ + Filter by the studio id + """ + id: Int + + """ + Filter by search query + """ + search: String + + """ + Filter by the studio id + """ + id_not: Int + + """ + Filter by the studio id + """ + id_in: [Int] + + """ + Filter by the studio id + """ + id_not_in: [Int] + + """ + The order the results will be returned in + """ + sort: [StudioSort] + ): Studio + + """ + Review query + """ + Review( + """ + Filter by Review id + """ + id: Int + + """ + Filter by media id + """ + mediaId: Int + + """ + Filter by user id + """ + userId: Int + + """ + Filter by media type + """ + mediaType: MediaType + + """ + The order the results will be returned in + """ + sort: [ReviewSort] + ): Review + + """ + Activity query + """ + Activity( + """ + Filter by the activity id + """ + id: Int + + """ + Filter by the owner user id + """ + userId: Int + + """ + Filter by the id of the user who sent a message + """ + messengerId: Int + + """ + Filter by the associated media id of the activity + """ + mediaId: Int + + """ + Filter by the type of activity + """ + type: ActivityType + + """ + Filter activity to users who are being followed by the authenticated user + """ + isFollowing: Boolean + + """ + Filter activity to only activity with replies + """ + hasReplies: Boolean + + """ + Filter activity to only activity with replies or is of type text + """ + hasRepliesOrTypeText: Boolean + + """ + Filter by the time the activity was created + """ + createdAt: Int + + """ + Filter by the activity id + """ + id_not: Int + + """ + Filter by the activity id + """ + id_in: [Int] + + """ + Filter by the activity id + """ + id_not_in: [Int] + + """ + Filter by the owner user id + """ + userId_not: Int + + """ + Filter by the owner user id + """ + userId_in: [Int] + + """ + Filter by the owner user id + """ + userId_not_in: [Int] + + """ + Filter by the id of the user who sent a message + """ + messengerId_not: Int + + """ + Filter by the id of the user who sent a message + """ + messengerId_in: [Int] + + """ + Filter by the id of the user who sent a message + """ + messengerId_not_in: [Int] + + """ + Filter by the associated media id of the activity + """ + mediaId_not: Int + + """ + Filter by the associated media id of the activity + """ + mediaId_in: [Int] + + """ + Filter by the associated media id of the activity + """ + mediaId_not_in: [Int] + + """ + Filter by the type of activity + """ + type_not: ActivityType + + """ + Filter by the type of activity + """ + type_in: [ActivityType] + + """ + Filter by the type of activity + """ + type_not_in: [ActivityType] + + """ + Filter by the time the activity was created + """ + createdAt_greater: Int + + """ + Filter by the time the activity was created + """ + createdAt_lesser: Int + + """ + The order the results will be returned in + """ + sort: [ActivitySort] + ): ActivityUnion + + """ + Activity reply query + """ + ActivityReply( + """ + Filter by the reply id + """ + id: Int + + """ + Filter by the parent id + """ + activityId: Int + ): ActivityReply + + """ + Follow query + """ + Following( + """ + User id of the follower/followed + """ + userId: Int! + + """ + The order the results will be returned in + """ + sort: [UserSort] + ): User + + """ + Follow query + """ + Follower( + """ + User id of the follower/followed + """ + userId: Int! + + """ + The order the results will be returned in + """ + sort: [UserSort] + ): User + + """ + Thread query + """ + Thread( + """ + Filter by the thread id + """ + id: Int + + """ + Filter by the user id of the thread's creator + """ + userId: Int + + """ + Filter by the user id of the last user to comment on the thread + """ + replyUserId: Int + + """ + Filter by if the currently authenticated user's subscribed threads + """ + subscribed: Boolean + + """ + Filter by thread category id + """ + categoryId: Int + + """ + Filter by thread media id category + """ + mediaCategoryId: Int + + """ + Filter by search query + """ + search: String + + """ + Filter by the thread id + """ + id_in: [Int] + + """ + The order the results will be returned in + """ + sort: [ThreadSort] + ): Thread + + """ + Comment query + """ + ThreadComment( + """ + Filter by the comment id + """ + id: Int + + """ + Filter by the thread id + """ + threadId: Int + + """ + Filter by the user id of the comment's creator + """ + userId: Int + + """ + The order the results will be returned in + """ + sort: [ThreadCommentSort] + ): [ThreadComment] + + """ + Recommendation query + """ + Recommendation( + """ + Filter by recommendation id + """ + id: Int + + """ + Filter by media id + """ + mediaId: Int + + """ + Filter by media recommendation id + """ + mediaRecommendationId: Int + + """ + Filter by user who created the recommendation + """ + userId: Int + + """ + Filter by total rating of the recommendation + """ + rating: Int + + """ + Filter by the media on the authenticated user's lists + """ + onList: Boolean + + """ + Filter by total rating of the recommendation + """ + rating_greater: Int + + """ + Filter by total rating of the recommendation + """ + rating_lesser: Int + + """ + The order the results will be returned in + """ + sort: [RecommendationSort] + ): Recommendation + + """ + Like query + """ + Like( + """ + The id of the likeable type + """ + likeableId: Int + + """ + The type of model the id applies to + """ + type: LikeableType + ): User + + """ + Provide AniList markdown to be converted to html (Requires auth) + """ + Markdown( + """ + The markdown to be parsed to html + """ + markdown: String! + ): ParsedMarkdown + AniChartUser: AniChartUser + + """ + Site statistics query + """ + SiteStatistics: SiteStatistics + + """ + ExternalLinkSource collection query + """ + ExternalLinkSourceCollection( + """ + Filter by the link id + """ + id: Int + type: ExternalLinkType + mediaType: ExternalLinkMediaType + ): [MediaExternalLink] +} + +""" +Media recommendation +""" +type Recommendation { + """ + The id of the recommendation + """ + id: Int! + + """ + Users rating of the recommendation + """ + rating: Int + + """ + The rating of the recommendation by currently authenticated user + """ + userRating: RecommendationRating + + """ + The media the recommendation is from + """ + media: Media + + """ + The recommended media + """ + mediaRecommendation: Media + + """ + The user that first created the recommendation + """ + user: User +} + +type RecommendationConnection { + edges: [RecommendationEdge] + nodes: [Recommendation] + + """ + The pagination information + """ + pageInfo: PageInfo +} + +""" +Recommendation connection edge +""" +type RecommendationEdge { + node: Recommendation +} + +""" +Recommendation rating enums +""" +enum RecommendationRating { + NO_RATING + RATE_UP + RATE_DOWN +} + +""" +Recommendation sort enums +""" +enum RecommendationSort { + ID + ID_DESC + RATING + RATING_DESC +} + +""" +Notification for when new media is added to the site +""" +type RelatedMediaAdditionNotification { + """ + The id of the Notification + """ + id: Int! + + """ + The type of notification + """ + type: NotificationType + + """ + The id of the new media + """ + mediaId: Int! + + """ + The notification context text + """ + context: String + + """ + The time the notification was created at + """ + createdAt: Int + + """ + The associated media of the airing schedule + """ + media: Media +} + +type Report { + id: Int! + reporter: User + reported: User + reason: String + + """ + When the entry data was created + """ + createdAt: Int + cleared: Boolean +} + +""" +A Review that features in an anime or manga +""" +type Review { + """ + The id of the review + """ + id: Int! + + """ + The id of the review's creator + """ + userId: Int! + + """ + The id of the review's media + """ + mediaId: Int! + + """ + For which type of media the review is for + """ + mediaType: MediaType + + """ + A short summary of the review + """ + summary: String + + """ + The main review body text + """ + body( + """ + Return the string in pre-parsed html instead of markdown + """ + asHtml: Boolean + ): String + + """ + The total user rating of the review + """ + rating: Int + + """ + The amount of user ratings of the review + """ + ratingAmount: Int + + """ + The rating of the review by currently authenticated user + """ + userRating: ReviewRating + + """ + The review score of the media + """ + score: Int + + """ + If the review is not yet publicly published and is only viewable by creator + """ + private: Boolean + + """ + The url for the review page on the AniList website + """ + siteUrl: String + + """ + The time of the thread creation + """ + createdAt: Int! + + """ + The time of the thread last update + """ + updatedAt: Int! + + """ + The creator of the review + """ + user: User + + """ + The media the review is of + """ + media: Media +} + +type ReviewConnection { + edges: [ReviewEdge] + nodes: [Review] + + """ + The pagination information + """ + pageInfo: PageInfo +} + +""" +Review connection edge +""" +type ReviewEdge { + node: Review +} + +""" +Review rating enums +""" +enum ReviewRating { + NO_VOTE + UP_VOTE + DOWN_VOTE +} + +""" +Review sort enums +""" +enum ReviewSort { + ID + ID_DESC + SCORE + SCORE_DESC + RATING + RATING_DESC + CREATED_AT + CREATED_AT_DESC + UPDATED_AT + UPDATED_AT_DESC +} + +""" +Feed of mod edit activity +""" +type RevisionHistory { + """ + The id of the media + """ + id: Int! + + """ + The action taken on the objects + """ + action: RevisionHistoryAction + + """ + A JSON object of the fields that changed + """ + changes: Json + + """ + The user who made the edit to the object + """ + user: User + + """ + The media the mod feed entry references + """ + media: Media + + """ + The character the mod feed entry references + """ + character: Character + + """ + The staff member the mod feed entry references + """ + staff: Staff + + """ + The studio the mod feed entry references + """ + studio: Studio + + """ + The external link source the mod feed entry references + """ + externalLink: MediaExternalLink + + """ + When the mod feed entry was created + """ + createdAt: Int +} + +""" +Revision history actions +""" +enum RevisionHistoryAction { + CREATE + EDIT +} + +""" +A user's list score distribution. +""" +type ScoreDistribution { + score: Int + + """ + The amount of list entries with this score + """ + amount: Int +} + +""" +Media list scoring type +""" +enum ScoreFormat { + """ + An integer from 0-100 + """ + POINT_100 + + """ + A float from 0-10 with 1 decimal place + """ + POINT_10_DECIMAL + + """ + An integer from 0-10 + """ + POINT_10 + + """ + An integer from 0-5. Should be represented in Stars + """ + POINT_5 + + """ + An integer from 0-3. Should be represented in Smileys. 0 => No Score, 1 => :(, 2 => :|, 3 => :) + """ + POINT_3 +} + +type SiteStatistics { + users( + sort: [SiteTrendSort] + + """ + The page + """ + page: Int + + """ + The amount of entries per page, max 25 + """ + perPage: Int + ): SiteTrendConnection + anime( + sort: [SiteTrendSort] + + """ + The page + """ + page: Int + + """ + The amount of entries per page, max 25 + """ + perPage: Int + ): SiteTrendConnection + manga( + sort: [SiteTrendSort] + + """ + The page + """ + page: Int + + """ + The amount of entries per page, max 25 + """ + perPage: Int + ): SiteTrendConnection + characters( + sort: [SiteTrendSort] + + """ + The page + """ + page: Int + + """ + The amount of entries per page, max 25 + """ + perPage: Int + ): SiteTrendConnection + staff( + sort: [SiteTrendSort] + + """ + The page + """ + page: Int + + """ + The amount of entries per page, max 25 + """ + perPage: Int + ): SiteTrendConnection + studios( + sort: [SiteTrendSort] + + """ + The page + """ + page: Int + + """ + The amount of entries per page, max 25 + """ + perPage: Int + ): SiteTrendConnection + reviews( + sort: [SiteTrendSort] + + """ + The page + """ + page: Int + + """ + The amount of entries per page, max 25 + """ + perPage: Int + ): SiteTrendConnection +} + +""" +Daily site statistics +""" +type SiteTrend { + """ + The day the data was recorded (timestamp) + """ + date: Int! + count: Int! + + """ + The change from yesterday + """ + change: Int! +} + +type SiteTrendConnection { + edges: [SiteTrendEdge] + nodes: [SiteTrend] + + """ + The pagination information + """ + pageInfo: PageInfo +} + +""" +Site trend connection edge +""" +type SiteTrendEdge { + node: SiteTrend +} + +""" +Site trend sort enums +""" +enum SiteTrendSort { + DATE + DATE_DESC + COUNT + COUNT_DESC + CHANGE + CHANGE_DESC +} + +""" +Voice actors or production staff +""" +type Staff { + """ + The id of the staff member + """ + id: Int! + + """ + The names of the staff member + """ + name: StaffName + + """ + The primary language the staff member dub's in + """ + language: StaffLanguage @deprecated(reason: "Replaced with languageV2") + + """ + The primary language of the staff member. Current values: Japanese, English, + Korean, Italian, Spanish, Portuguese, French, German, Hebrew, Hungarian, + Chinese, Arabic, Filipino, Catalan, Finnish, Turkish, Dutch, Swedish, Thai, + Tagalog, Malaysian, Indonesian, Vietnamese, Nepali, Hindi, Urdu + """ + languageV2: String + + """ + The staff images + """ + image: StaffImage + + """ + A general description of the staff member + """ + description( + """ + Return the string in pre-parsed html instead of markdown + """ + asHtml: Boolean + ): String + + """ + The person's primary occupations + """ + primaryOccupations: [String] + + """ + The staff's gender. Usually Male, Female, or Non-binary but can be any string. + """ + gender: String + dateOfBirth: FuzzyDate + dateOfDeath: FuzzyDate + + """ + The person's age in years + """ + age: Int + + """ + [startYear, endYear] (If the 2nd value is not present staff is still active) + """ + yearsActive: [Int] + + """ + The persons birthplace or hometown + """ + homeTown: String + + """ + The persons blood type + """ + bloodType: String + + """ + If the staff member is marked as favourite by the currently authenticated user + """ + isFavourite: Boolean! + + """ + If the staff member is blocked from being added to favourites + """ + isFavouriteBlocked: Boolean! + + """ + The url for the staff page on the AniList website + """ + siteUrl: String + + """ + Media where the staff member has a production role + """ + staffMedia( + sort: [MediaSort] + type: MediaType + onList: Boolean + + """ + The page + """ + page: Int + + """ + The amount of entries per page, max 25 + """ + perPage: Int + ): MediaConnection + + """ + Characters voiced by the actor + """ + characters( + sort: [CharacterSort] + + """ + The page + """ + page: Int + + """ + The amount of entries per page, max 25 + """ + perPage: Int + ): CharacterConnection + + """ + Media the actor voiced characters in. (Same data as characters with media as node instead of characters) + """ + characterMedia( + sort: [MediaSort] + onList: Boolean + + """ + The page + """ + page: Int + + """ + The amount of entries per page, max 25 + """ + perPage: Int + ): MediaConnection + updatedAt: Int @deprecated(reason: "No data available") + + """ + Staff member that the submission is referencing + """ + staff: Staff + + """ + Submitter for the submission + """ + submitter: User + + """ + Status of the submission + """ + submissionStatus: Int + + """ + Inner details of submission status + """ + submissionNotes: String + + """ + The amount of user's who have favourited the staff member + """ + favourites: Int + + """ + Notes for site moderators + """ + modNotes: String +} + +type StaffConnection { + edges: [StaffEdge] + nodes: [Staff] + + """ + The pagination information + """ + pageInfo: PageInfo +} + +""" +Staff connection edge +""" +type StaffEdge { + node: Staff + + """ + The id of the connection + """ + id: Int + + """ + The role of the staff member in the production of the media + """ + role: String + + """ + The order the staff should be displayed from the users favourites + """ + favouriteOrder: Int +} + +type StaffImage { + """ + The person's image of media at its largest size + """ + large: String + + """ + The person's image of media at medium size + """ + medium: String +} + +""" +The primary language of the voice actor +""" +enum StaffLanguage { + """ + Japanese + """ + JAPANESE + + """ + English + """ + ENGLISH + + """ + Korean + """ + KOREAN + + """ + Italian + """ + ITALIAN + + """ + Spanish + """ + SPANISH + + """ + Portuguese + """ + PORTUGUESE + + """ + French + """ + FRENCH + + """ + German + """ + GERMAN + + """ + Hebrew + """ + HEBREW + + """ + Hungarian + """ + HUNGARIAN +} + +""" +The names of the staff member +""" +type StaffName { + """ + The person's given name + """ + first: String + + """ + The person's middle name + """ + middle: String + + """ + The person's surname + """ + last: String + + """ + The person's first and last name + """ + full: String + + """ + The person's full name in their native language + """ + native: String + + """ + Other names the staff member might be referred to as (pen names) + """ + alternative: [String] + + """ + The currently authenticated users preferred name language. Default romaji for non-authenticated + """ + userPreferred: String +} + +""" +The names of the staff member +""" +input StaffNameInput { + """ + The person's given name + """ + first: String + + """ + The person's middle name + """ + middle: String + + """ + The person's surname + """ + last: String + + """ + The person's full name in their native language + """ + native: String + + """ + Other names the character might be referred by + """ + alternative: [String] +} + +""" +Voice actor role for a character +""" +type StaffRoleType { + """ + The voice actors of the character + """ + voiceActor: Staff + + """ + Notes regarding the VA's role for the character + """ + roleNotes: String + + """ + Used for grouping roles where multiple dubs exist for the same language. Either dubbing company name or language variant. + """ + dubGroup: String +} + +""" +Staff sort enums +""" +enum StaffSort { + ID + ID_DESC + ROLE + ROLE_DESC + LANGUAGE + LANGUAGE_DESC + SEARCH_MATCH + FAVOURITES + FAVOURITES_DESC + + """ + Order manually decided by moderators + """ + RELEVANCE +} + +""" +User's staff statistics +""" +type StaffStats { + staff: Staff + amount: Int + meanScore: Int + + """ + The amount of time in minutes the staff member has been watched by the user + """ + timeWatched: Int +} + +""" +A submission for a staff that features in an anime or manga +""" +type StaffSubmission { + """ + The id of the submission + """ + id: Int! + + """ + Staff that the submission is referencing + """ + staff: Staff + + """ + The staff submission changes + """ + submission: Staff + + """ + Submitter for the submission + """ + submitter: User + + """ + Data Mod assigned to handle the submission + """ + assignee: User + + """ + Status of the submission + """ + status: SubmissionStatus + + """ + Inner details of submission status + """ + notes: String + source: String + + """ + Whether the submission is locked + """ + locked: Boolean + createdAt: Int +} + +""" +The distribution of the watching/reading status of media or a user's list +""" +type StatusDistribution { + """ + The day the activity took place (Unix timestamp) + """ + status: MediaListStatus + + """ + The amount of entries with this status + """ + amount: Int +} + +""" +Animation or production company +""" +type Studio { + """ + The id of the studio + """ + id: Int! + + """ + The name of the studio + """ + name: String! + + """ + If the studio is an animation studio or a different kind of company + """ + isAnimationStudio: Boolean! + + """ + The media the studio has worked on + """ + media( + """ + The order the results will be returned in + """ + sort: [MediaSort] + + """ + If the studio was the primary animation studio of the media + """ + isMain: Boolean + onList: Boolean + + """ + The page + """ + page: Int + + """ + The amount of entries per page, max 25 + """ + perPage: Int + ): MediaConnection + + """ + The url for the studio page on the AniList website + """ + siteUrl: String + + """ + If the studio is marked as favourite by the currently authenticated user + """ + isFavourite: Boolean! + + """ + The amount of user's who have favourited the studio + """ + favourites: Int +} + +type StudioConnection { + edges: [StudioEdge] + nodes: [Studio] + + """ + The pagination information + """ + pageInfo: PageInfo +} + +""" +Studio connection edge +""" +type StudioEdge { + node: Studio + + """ + The id of the connection + """ + id: Int + + """ + If the studio is the main animation studio of the anime + """ + isMain: Boolean! + + """ + The order the character should be displayed from the users favourites + """ + favouriteOrder: Int +} + +""" +Studio sort enums +""" +enum StudioSort { + ID + ID_DESC + NAME + NAME_DESC + SEARCH_MATCH + FAVOURITES + FAVOURITES_DESC +} + +""" +User's studio statistics +""" +type StudioStats { + studio: Studio + amount: Int + meanScore: Int + + """ + The amount of time in minutes the studio's works have been watched by the user + """ + timeWatched: Int +} + +""" +Submission sort enums +""" +enum SubmissionSort { + ID + ID_DESC +} + +""" +Submission status +""" +enum SubmissionStatus { + PENDING + REJECTED + PARTIALLY_ACCEPTED + ACCEPTED +} + +""" +User's tag statistics +""" +type TagStats { + tag: MediaTag + amount: Int + meanScore: Int + + """ + The amount of time in minutes the tag has been watched by the user + """ + timeWatched: Int +} + +""" +User text activity +""" +type TextActivity { + """ + The id of the activity + """ + id: Int! + + """ + The user id of the activity's creator + """ + userId: Int + + """ + The type of activity + """ + type: ActivityType + + """ + The number of activity replies + """ + replyCount: Int! + + """ + The status text (Markdown) + """ + text( + """ + Return the string in pre-parsed html instead of markdown + """ + asHtml: Boolean + ): String + + """ + The url for the activity page on the AniList website + """ + siteUrl: String + + """ + If the activity is locked and can receive replies + """ + isLocked: Boolean + + """ + If the currently authenticated user is subscribed to the activity + """ + isSubscribed: Boolean + + """ + The amount of likes the activity has + """ + likeCount: Int! + + """ + If the currently authenticated user liked the activity + """ + isLiked: Boolean + + """ + If the activity is pinned to the top of the users activity feed + """ + isPinned: Boolean + + """ + The time the activity was created at + """ + createdAt: Int! + + """ + The user who created the activity + """ + user: User + + """ + The written replies to the activity + """ + replies: [ActivityReply] + + """ + The users who liked the activity + """ + likes: [User] +} + +""" +Forum Thread +""" +type Thread { + """ + The id of the thread + """ + id: Int! + + """ + The title of the thread + """ + title: String + + """ + The text body of the thread (Markdown) + """ + body( + """ + Return the string in pre-parsed html instead of markdown + """ + asHtml: Boolean + ): String + + """ + The id of the thread owner user + """ + userId: Int! + + """ + The id of the user who most recently commented on the thread + """ + replyUserId: Int + + """ + The id of the most recent comment on the thread + """ + replyCommentId: Int + + """ + The number of comments on the thread + """ + replyCount: Int + + """ + The number of times users have viewed the thread + """ + viewCount: Int + + """ + If the thread is locked and can receive comments + """ + isLocked: Boolean + + """ + If the thread is stickied and should be displayed at the top of the page + """ + isSticky: Boolean + + """ + If the currently authenticated user is subscribed to the thread + """ + isSubscribed: Boolean + + """ + The amount of likes the thread has + """ + likeCount: Int! + + """ + If the currently authenticated user liked the thread + """ + isLiked: Boolean + + """ + The time of the last reply + """ + repliedAt: Int + + """ + The time of the thread creation + """ + createdAt: Int! + + """ + The time of the thread last update + """ + updatedAt: Int! + + """ + The owner of the thread + """ + user: User + + """ + The user to last reply to the thread + """ + replyUser: User + + """ + The users who liked the thread + """ + likes: [User] + + """ + The url for the thread page on the AniList website + """ + siteUrl: String + + """ + The categories of the thread + """ + categories: [ThreadCategory] + + """ + The media categories of the thread + """ + mediaCategories: [Media] +} + +""" +A forum thread category +""" +type ThreadCategory { + """ + The id of the category + """ + id: Int! + + """ + The name of the category + """ + name: String! +} + +""" +Forum Thread Comment +""" +type ThreadComment { + """ + The id of the comment + """ + id: Int! + + """ + The user id of the comment's owner + """ + userId: Int + + """ + The id of thread the comment belongs to + """ + threadId: Int + + """ + The text content of the comment (Markdown) + """ + comment( + """ + Return the string in pre-parsed html instead of markdown + """ + asHtml: Boolean + ): String + + """ + The amount of likes the comment has + """ + likeCount: Int! + + """ + If the currently authenticated user liked the comment + """ + isLiked: Boolean + + """ + The url for the comment page on the AniList website + """ + siteUrl: String + + """ + The time of the comments creation + """ + createdAt: Int! + + """ + The time of the comments last update + """ + updatedAt: Int! + + """ + The thread the comment belongs to + """ + thread: Thread + + """ + The user who created the comment + """ + user: User + + """ + The users who liked the comment + """ + likes: [User] + + """ + The comment's child reply comments + """ + childComments: Json + + """ + If the comment tree is locked and may not receive replies or edits + """ + isLocked: Boolean +} + +""" +Notification for when a thread comment is liked +""" +type ThreadCommentLikeNotification { + """ + The id of the Notification + """ + id: Int! + + """ + The id of the user who liked to the activity + """ + userId: Int! + + """ + The type of notification + """ + type: NotificationType + + """ + The id of the activity which was liked + """ + commentId: Int! + + """ + The notification context text + """ + context: String + + """ + The time the notification was created at + """ + createdAt: Int + + """ + The thread that the relevant comment belongs to + """ + thread: Thread + + """ + The thread comment that was liked + """ + comment: ThreadComment + + """ + The user who liked the activity + """ + user: User +} + +""" +Notification for when authenticated user is @ mentioned in a forum thread comment +""" +type ThreadCommentMentionNotification { + """ + The id of the Notification + """ + id: Int! + + """ + The id of the user who mentioned the authenticated user + """ + userId: Int! + + """ + The type of notification + """ + type: NotificationType + + """ + The id of the comment where mentioned + """ + commentId: Int! + + """ + The notification context text + """ + context: String + + """ + The time the notification was created at + """ + createdAt: Int + + """ + The thread that the relevant comment belongs to + """ + thread: Thread + + """ + The thread comment that included the @ mention + """ + comment: ThreadComment + + """ + The user who mentioned the authenticated user + """ + user: User +} + +""" +Notification for when a user replies to your forum thread comment +""" +type ThreadCommentReplyNotification { + """ + The id of the Notification + """ + id: Int! + + """ + The id of the user who create the comment reply + """ + userId: Int! + + """ + The type of notification + """ + type: NotificationType + + """ + The id of the reply comment + """ + commentId: Int! + + """ + The notification context text + """ + context: String + + """ + The time the notification was created at + """ + createdAt: Int + + """ + The thread that the relevant comment belongs to + """ + thread: Thread + + """ + The reply thread comment + """ + comment: ThreadComment + + """ + The user who replied to the activity + """ + user: User +} + +""" +Thread comments sort enums +""" +enum ThreadCommentSort { + ID + ID_DESC +} + +""" +Notification for when a user replies to a subscribed forum thread +""" +type ThreadCommentSubscribedNotification { + """ + The id of the Notification + """ + id: Int! + + """ + The id of the user who commented on the thread + """ + userId: Int! + + """ + The type of notification + """ + type: NotificationType + + """ + The id of the new comment in the subscribed thread + """ + commentId: Int! + + """ + The notification context text + """ + context: String + + """ + The time the notification was created at + """ + createdAt: Int + + """ + The thread that the relevant comment belongs to + """ + thread: Thread + + """ + The reply thread comment + """ + comment: ThreadComment + + """ + The user who replied to the subscribed thread + """ + user: User +} + +""" +Notification for when a thread is liked +""" +type ThreadLikeNotification { + """ + The id of the Notification + """ + id: Int! + + """ + The id of the user who liked to the activity + """ + userId: Int! + + """ + The type of notification + """ + type: NotificationType + + """ + The id of the thread which was liked + """ + threadId: Int! + + """ + The notification context text + """ + context: String + + """ + The time the notification was created at + """ + createdAt: Int + + """ + The thread that the relevant comment belongs to + """ + thread: Thread + + """ + The liked thread comment + """ + comment: ThreadComment + + """ + The user who liked the activity + """ + user: User +} + +""" +Thread sort enums +""" +enum ThreadSort { + ID + ID_DESC + TITLE + TITLE_DESC + CREATED_AT + CREATED_AT_DESC + UPDATED_AT + UPDATED_AT_DESC + REPLIED_AT + REPLIED_AT_DESC + REPLY_COUNT + REPLY_COUNT_DESC + VIEW_COUNT + VIEW_COUNT_DESC + IS_STICKY + SEARCH_MATCH +} + +""" +A user +""" +type User { + """ + The id of the user + """ + id: Int! + + """ + The name of the user + """ + name: String! + + """ + The bio written by user (Markdown) + """ + about( + """ + Return the string in pre-parsed html instead of markdown + """ + asHtml: Boolean + ): String + + """ + The user's avatar images + """ + avatar: UserAvatar + + """ + The user's banner images + """ + bannerImage: String + + """ + If the authenticated user if following this user + """ + isFollowing: Boolean + + """ + If this user if following the authenticated user + """ + isFollower: Boolean + + """ + If the user is blocked by the authenticated user + """ + isBlocked: Boolean + bans: Json + + """ + The user's general options + """ + options: UserOptions + + """ + The user's media list options + """ + mediaListOptions: MediaListOptions + + """ + The users favourites + """ + favourites( + """ + Deprecated. Use page arguments on each favourite field instead. + """ + page: Int + ): Favourites + + """ + The users anime & manga list statistics + """ + statistics: UserStatisticTypes + + """ + The number of unread notifications the user has + """ + unreadNotificationCount: Int + + """ + The url for the user page on the AniList website + """ + siteUrl: String + + """ + The donation tier of the user + """ + donatorTier: Int + + """ + Custom donation badge text + """ + donatorBadge: String + + """ + The user's moderator roles if they are a site moderator + """ + moderatorRoles: [ModRole] + + """ + When the user's account was created. (Does not exist for accounts created before 2020) + """ + createdAt: Int + + """ + When the user's data was last updated + """ + updatedAt: Int + + """ + The user's statistics + """ + stats: UserStats @deprecated(reason: "Deprecated. Replaced with statistics field.") + + """ + If the user is a moderator or data moderator + """ + moderatorStatus: String @deprecated(reason: "Deprecated. Replaced with moderatorRoles field.") + + """ + The user's previously used names. + """ + previousNames: [UserPreviousName] +} + +""" +A user's activity history stats. +""" +type UserActivityHistory { + """ + The day the activity took place (Unix timestamp) + """ + date: Int + + """ + The amount of activity on the day + """ + amount: Int + + """ + The level of activity represented on a 1-10 scale + """ + level: Int +} + +""" +A user's avatars +""" +type UserAvatar { + """ + The avatar of user at its largest size + """ + large: String + + """ + The avatar of user at medium size + """ + medium: String +} + +type UserCountryStatistic { + count: Int! + meanScore: Float! + minutesWatched: Int! + chaptersRead: Int! + mediaIds: [Int]! + country: CountryCode +} + +type UserFormatStatistic { + count: Int! + meanScore: Float! + minutesWatched: Int! + chaptersRead: Int! + mediaIds: [Int]! + format: MediaFormat +} + +type UserGenreStatistic { + count: Int! + meanScore: Float! + minutesWatched: Int! + chaptersRead: Int! + mediaIds: [Int]! + genre: String +} + +type UserLengthStatistic { + count: Int! + meanScore: Float! + minutesWatched: Int! + chaptersRead: Int! + mediaIds: [Int]! + length: String +} + +""" +User data for moderators +""" +type UserModData { + alts: [User] + bans: Json + ip: Json + counts: Json + privacy: Int + email: String +} + +""" +A user's general options +""" +type UserOptions { + """ + The language the user wants to see media titles in + """ + titleLanguage: UserTitleLanguage + + """ + Whether the user has enabled viewing of 18+ content + """ + displayAdultContent: Boolean + + """ + Whether the user receives notifications when a show they are watching aires + """ + airingNotifications: Boolean + + """ + Profile highlight color (blue, purple, pink, orange, red, green, gray) + """ + profileColor: String + + """ + Notification options + """ + notificationOptions: [NotificationOption] + + """ + The user's timezone offset (Auth user only) + """ + timezone: String + + """ + Minutes between activity for them to be merged together. 0 is Never, Above 2 weeks (20160 mins) is Always. + """ + activityMergeTime: Int + + """ + The language the user wants to see staff and character names in + """ + staffNameLanguage: UserStaffNameLanguage + + """ + Whether the user only allow messages from users they follow + """ + restrictMessagesToFollowing: Boolean + + """ + The list activity types the user has disabled from being created from list updates + """ + disabledListActivity: [ListActivityOption] +} + +""" +A user's previous name +""" +type UserPreviousName { + """ + A previous name of the user. + """ + name: String + + """ + When the user first changed from this name. + """ + createdAt: Int + + """ + When the user most recently changed from this name. + """ + updatedAt: Int +} + +type UserReleaseYearStatistic { + count: Int! + meanScore: Float! + minutesWatched: Int! + chaptersRead: Int! + mediaIds: [Int]! + releaseYear: Int +} + +type UserScoreStatistic { + count: Int! + meanScore: Float! + minutesWatched: Int! + chaptersRead: Int! + mediaIds: [Int]! + score: Int +} + +""" +User sort enums +""" +enum UserSort { + ID + ID_DESC + USERNAME + USERNAME_DESC + WATCHED_TIME + WATCHED_TIME_DESC + CHAPTERS_READ + CHAPTERS_READ_DESC + SEARCH_MATCH +} + +""" +The language the user wants to see staff and character names in +""" +enum UserStaffNameLanguage { + """ + The romanization of the staff or character's native name, with western name ordering + """ + ROMAJI_WESTERN + + """ + The romanization of the staff or character's native name + """ + ROMAJI + + """ + The staff or character's name in their native language + """ + NATIVE +} + +type UserStaffStatistic { + count: Int! + meanScore: Float! + minutesWatched: Int! + chaptersRead: Int! + mediaIds: [Int]! + staff: Staff +} + +type UserStartYearStatistic { + count: Int! + meanScore: Float! + minutesWatched: Int! + chaptersRead: Int! + mediaIds: [Int]! + startYear: Int +} + +type UserStatistics { + count: Int! + meanScore: Float! + standardDeviation: Float! + minutesWatched: Int! + episodesWatched: Int! + chaptersRead: Int! + volumesRead: Int! + formats(limit: Int, sort: [UserStatisticsSort]): [UserFormatStatistic] + statuses(limit: Int, sort: [UserStatisticsSort]): [UserStatusStatistic] + scores(limit: Int, sort: [UserStatisticsSort]): [UserScoreStatistic] + lengths(limit: Int, sort: [UserStatisticsSort]): [UserLengthStatistic] + releaseYears(limit: Int, sort: [UserStatisticsSort]): [UserReleaseYearStatistic] + startYears(limit: Int, sort: [UserStatisticsSort]): [UserStartYearStatistic] + genres(limit: Int, sort: [UserStatisticsSort]): [UserGenreStatistic] + tags(limit: Int, sort: [UserStatisticsSort]): [UserTagStatistic] + countries(limit: Int, sort: [UserStatisticsSort]): [UserCountryStatistic] + voiceActors(limit: Int, sort: [UserStatisticsSort]): [UserVoiceActorStatistic] + staff(limit: Int, sort: [UserStatisticsSort]): [UserStaffStatistic] + studios(limit: Int, sort: [UserStatisticsSort]): [UserStudioStatistic] +} + +""" +User statistics sort enum +""" +enum UserStatisticsSort { + ID + ID_DESC + COUNT + COUNT_DESC + PROGRESS + PROGRESS_DESC + MEAN_SCORE + MEAN_SCORE_DESC +} + +type UserStatisticTypes { + anime: UserStatistics + manga: UserStatistics +} + +""" +A user's statistics +""" +type UserStats { + """ + The amount of anime the user has watched in minutes + """ + watchedTime: Int + + """ + The amount of manga chapters the user has read + """ + chaptersRead: Int + activityHistory: [UserActivityHistory] + animeStatusDistribution: [StatusDistribution] + mangaStatusDistribution: [StatusDistribution] + animeScoreDistribution: [ScoreDistribution] + mangaScoreDistribution: [ScoreDistribution] + animeListScores: ListScoreStats + mangaListScores: ListScoreStats + favouredGenresOverview: [GenreStats] + favouredGenres: [GenreStats] + favouredTags: [TagStats] + favouredActors: [StaffStats] + favouredStaff: [StaffStats] + favouredStudios: [StudioStats] + favouredYears: [YearStats] + favouredFormats: [FormatStats] +} + +type UserStatusStatistic { + count: Int! + meanScore: Float! + minutesWatched: Int! + chaptersRead: Int! + mediaIds: [Int]! + status: MediaListStatus +} + +type UserStudioStatistic { + count: Int! + meanScore: Float! + minutesWatched: Int! + chaptersRead: Int! + mediaIds: [Int]! + studio: Studio +} + +type UserTagStatistic { + count: Int! + meanScore: Float! + minutesWatched: Int! + chaptersRead: Int! + mediaIds: [Int]! + tag: MediaTag +} + +""" +The language the user wants to see media titles in +""" +enum UserTitleLanguage { + """ + The romanization of the native language title + """ + ROMAJI + + """ + The official english title + """ + ENGLISH + + """ + Official title in it's native language + """ + NATIVE + + """ + The romanization of the native language title, stylised by media creator + """ + ROMAJI_STYLISED + + """ + The official english title, stylised by media creator + """ + ENGLISH_STYLISED + + """ + Official title in it's native language, stylised by media creator + """ + NATIVE_STYLISED +} + +type UserVoiceActorStatistic { + count: Int! + meanScore: Float! + minutesWatched: Int! + chaptersRead: Int! + mediaIds: [Int]! + voiceActor: Staff + characterIds: [Int]! +} + +""" +User's year statistics +""" +type YearStats { + year: Int + amount: Int + meanScore: Int +} diff --git a/internal/drivers/caches/bolt.go b/internal/drivers/caches/bolt.go new file mode 100644 index 0000000..1e9e453 --- /dev/null +++ b/internal/drivers/caches/bolt.go @@ -0,0 +1,76 @@ +package caches + +import ( + "context" + "fmt" + + "github.com/wwmoraes/anilistarr/internal/adapters" + "github.com/wwmoraes/anilistarr/internal/telemetry" + "go.etcd.io/bbolt" +) + +const bucketName = "anilistarr" + +type BoltOptions = bbolt.Options + +type boltCache struct { + *bbolt.DB +} + +func NewBolt(path string, options *BoltOptions) (adapters.Cache, error) { + db, err := bbolt.Open(path, 0640, options) + if err != nil { + return nil, fmt.Errorf("failed to open bolt database: %w", err) + } + + err = db.Update(func(tx *bbolt.Tx) error { + _, err := tx.CreateBucketIfNotExists([]byte(bucketName)) + if err != nil { + return fmt.Errorf("failed to create/get bucket: %w", err) + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("failed to initialize bolt: %w", err) + } + + return &boltCache{db}, nil +} + +func (c *boltCache) GetString(ctx context.Context, key string) (string, error) { + _, span := telemetry.StartFunction(ctx) + defer span.End() + + var value string + + err := c.View(func(tx *bbolt.Tx) error { + bucket := tx.Bucket([]byte(bucketName)) + if bucket == nil { + return fmt.Errorf("bucket %s does not exist", bucketName) + } + + data := bucket.Get([]byte(key)) + value = string(data) + + return nil + }) + if err != nil { + return "", span.Assert(fmt.Errorf("failed to get string: %w", err)) + } + + return value, span.Assert(nil) +} + +func (c *boltCache) SetString(ctx context.Context, key, value string) error { + _, span := telemetry.StartFunction(ctx) + defer span.End() + + return span.Assert(c.Update(func(tx *bbolt.Tx) error { + bucket := tx.Bucket([]byte(bucketName)) + if bucket == nil { + return fmt.Errorf("bucket %s does not exist", bucketName) + } + + return bucket.Put([]byte(key), []byte(value)) + })) +} diff --git a/internal/drivers/caches/redis.go b/internal/drivers/caches/redis.go new file mode 100644 index 0000000..d4401cf --- /dev/null +++ b/internal/drivers/caches/redis.go @@ -0,0 +1,56 @@ +package caches + +import ( + "context" + "fmt" + + "github.com/redis/go-redis/extra/redisotel/v9" + "github.com/redis/go-redis/v9" + "github.com/wwmoraes/anilistarr/internal/adapters" + "github.com/wwmoraes/anilistarr/internal/telemetry" +) + +type RedisOptions = redis.Options + +func NewRedis(options *RedisOptions) (adapters.Cache, error) { + rdb := redis.NewClient(options) + + err := redisotel.InstrumentTracing(rdb) + if err != nil { + return nil, fmt.Errorf("failed to instrument tracing for Redis: %w", err) + } + + err = redisotel.InstrumentMetrics(rdb) + if err != nil { + return nil, fmt.Errorf("failed to instrument metrics for Redis: %w", err) + } + + return &redisCache{rdb}, nil +} + +type redisCache struct { + *redis.Client +} + +func (c *redisCache) GetString(ctx context.Context, key string) (string, error) { + ctx, span := telemetry.StartFunction(ctx) + defer span.End() + + res, err := c.Get(ctx, key).Result() + if err == redis.Nil { + return "", span.Assert(nil) + } + + if err != nil { + return "", span.Assert(fmt.Errorf("failed to get string: %w", err)) + } + + return res, span.Assert(nil) +} + +func (c *redisCache) SetString(ctx context.Context, key, value string) error { + ctx, span := telemetry.StartFunction(ctx) + defer span.End() + + return span.Assert(c.Set(ctx, key, value, 0).Err()) +} diff --git a/internal/drivers/providers/fribbs.go b/internal/drivers/providers/fribbs.go new file mode 100644 index 0000000..a223e92 --- /dev/null +++ b/internal/drivers/providers/fribbs.go @@ -0,0 +1,38 @@ +package providers + +import ( + "strconv" + + "github.com/wwmoraes/anilistarr/internal/adapters" +) + +const FribbsSource adapters.JSONSourceURL[FribbsEntry] = "https://github.com/Fribb/anime-lists/raw/master/anime-list-full.json" + +type FribbsEntry struct { + AnilistID uint64 `json:"anilist_id,omitempty"` + TvdbID uint64 `json:"thetvdb_id,omitempty"` + + //// useless + // Type string `json:"type,omitempty"` + + //// commented out as we don't need these + // AnidbID uint `json:"anidb_id,omitempty"` + // AnisearchID uint `json:"anisearch_id,omitempty"` + // ImdbID string `json:"imdb_id,omitempty"` + // KitsuID uint `json:"kitsu_id,omitempty"` + // LivechartID uint `json:"livechart_id,omitempty"` + // MalID uint `json:"mal_id,omitempty"` + // NotifyMoeID string `json:"notify.moe_id,omitempty"` + + //// those are even worse as they mix strings and numbers + // AnimePlanetID string `json:"anime-planet_id,omitempty"` + // TmdbID uint `json:"themoviedb_id,omitempty"` +} + +func (entry FribbsEntry) GetTvdbID() string { + return strconv.FormatUint(entry.TvdbID, 10) +} + +func (entry FribbsEntry) GetAnilistID() string { + return strconv.FormatUint(entry.AnilistID, 10) +} diff --git a/internal/drivers/stores/models/bulk.go b/internal/drivers/stores/models/bulk.go new file mode 100644 index 0000000..936c7a0 --- /dev/null +++ b/internal/drivers/stores/models/bulk.go @@ -0,0 +1,92 @@ +package models + +import ( + "context" + "database/sql" + "fmt" + "strings" +) + +type MappingList []*Mapping + +func (m MappingList) Upsert(ctx context.Context, db DB) error { + rows := make([]string, len(m)) + for index, entry := range m { + if entry._deleted { + return logerror(&ErrUpsertFailed{ErrMarkedForDeletion}) + } + + rows[index] = fmt.Sprintf("(%s, %s)", entry.TvdbID, nullableString(entry.AnilistID)) + } + + const baseSqlstr = `INSERT INTO mapping (` + + `tvdb_id, anilist_id` + + `) VALUES %s` + + ` ON CONFLICT (tvdb_id) DO ` + + `UPDATE SET ` + + `anilist_id = EXCLUDED.anilist_id ` + + sqlstr := fmt.Sprintf(baseSqlstr, strings.Join(rows, ",")) + + logf(sqlstr) + if _, err := db.ExecContext(ctx, sqlstr); err != nil { + return logerror(err) + } + + for _, entry := range m { + entry._exists = true + } + + return nil +} + +func MappingByAnilistIDBulk(ctx context.Context, db DB, anilistIDs []sql.NullString) ([]*Mapping, error) { + ids := make([]string, 0, len(anilistIDs)) + for _, id := range anilistIDs { + if !id.Valid { + continue + } + + ids = append(ids, id.String) + } + + // query + const baseSqlstr = `SELECT ` + + `tvdb_id, anilist_id ` + + `FROM mapping ` + + `WHERE anilist_id IN (%s)` + sqlstr := fmt.Sprintf(baseSqlstr, strings.Join(ids, ",")) + + // run + logf(sqlstr) + rows, err := db.QueryContext(ctx, sqlstr) + if err != nil { + return nil, logerror(err) + } + defer rows.Close() + + results := make([]*Mapping, 0, len(ids)) + var m *Mapping + for rows.Next() { + m = &Mapping{ + _exists: true, + } + + err = rows.Scan(&m.TvdbID, &m.AnilistID) + if err != nil { + return nil, logerror(err) + } + + results = append(results, m) + } + + return results, nil +} + +func nullableString(v sql.NullString) string { + if !v.Valid { + return "NULL" + } + + return v.String +} diff --git a/internal/drivers/stores/models/converter.go b/internal/drivers/stores/models/converter.go new file mode 100644 index 0000000..3cb79b0 --- /dev/null +++ b/internal/drivers/stores/models/converter.go @@ -0,0 +1,28 @@ +package models + +import ( + "database/sql" + + "github.com/wwmoraes/anilistarr/internal/entities" +) + +func (mapping *Mapping) ToMedia() *entities.Media { + return &entities.Media{ + AnilistID: mapping.AnilistID.String, + TvdbID: mapping.TvdbID, + } +} + +func MappingFromMedia(media *entities.Media) *Mapping { + if media == nil { + return nil + } + + return &Mapping{ + AnilistID: sql.NullString{ + String: media.AnilistID, + Valid: len(media.AnilistID) > 0, + }, + TvdbID: media.TvdbID, + } +} diff --git a/internal/drivers/stores/models/db.xo.go b/internal/drivers/stores/models/db.xo.go new file mode 100644 index 0000000..fdd6a6d --- /dev/null +++ b/internal/drivers/stores/models/db.xo.go @@ -0,0 +1,241 @@ +// Package models contains generated code for schema 'media.db'. +package models + +// Code generated by xo. DO NOT EDIT. + +import ( + "context" + "database/sql" + "database/sql/driver" + "fmt" + "io" + "time" +) + +var ( + // logf is used by generated code to log SQL queries. + logf = func(string, ...interface{}) {} + // errf is used by generated code to log SQL errors. + errf = func(string, ...interface{}) {} +) + +// logerror logs the error and returns it. +func logerror(err error) error { + errf("ERROR: %v", err) + return err +} + +// Logf logs a message using the package logger. +func Logf(s string, v ...interface{}) { + logf(s, v...) +} + +// SetLogger sets the package logger. Valid logger types: +// +// io.Writer +// func(string, ...interface{}) (int, error) // fmt.Printf +// func(string, ...interface{}) // log.Printf +func SetLogger(logger interface{}) { + logf = convLogger(logger) +} + +// Errorf logs an error message using the package error logger. +func Errorf(s string, v ...interface{}) { + errf(s, v...) +} + +// SetErrorLogger sets the package error logger. Valid logger types: +// +// io.Writer +// func(string, ...interface{}) (int, error) // fmt.Printf +// func(string, ...interface{}) // log.Printf +func SetErrorLogger(logger interface{}) { + errf = convLogger(logger) +} + +// convLogger converts logger to the standard logger interface. +func convLogger(logger interface{}) func(string, ...interface{}) { + switch z := logger.(type) { + case io.Writer: + return func(s string, v ...interface{}) { + fmt.Fprintf(z, s, v...) + } + case func(string, ...interface{}) (int, error): // fmt.Printf + return func(s string, v ...interface{}) { + _, _ = z(s, v...) + } + case func(string, ...interface{}): // log.Printf + return z + } + panic(fmt.Sprintf("unsupported logger type %T", logger)) +} + +// DB is the common interface for database operations that can be used with +// types from schema 'media.db'. +// +// This works with both database/sql.DB and database/sql.Tx. +type DB interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +// Error is an error. +type Error string + +// Error satisfies the error interface. +func (err Error) Error() string { + return string(err) +} + +// Error values. +const ( + // ErrAlreadyExists is the already exists error. + ErrAlreadyExists Error = "already exists" + // ErrDoesNotExist is the does not exist error. + ErrDoesNotExist Error = "does not exist" + // ErrMarkedForDeletion is the marked for deletion error. + ErrMarkedForDeletion Error = "marked for deletion" +) + +// ErrInsertFailed is the insert failed error. +type ErrInsertFailed struct { + Err error +} + +// Error satisfies the error interface. +func (err *ErrInsertFailed) Error() string { + return fmt.Sprintf("insert failed: %v", err.Err) +} + +// Unwrap satisfies the unwrap interface. +func (err *ErrInsertFailed) Unwrap() error { + return err.Err +} + +// ErrUpdateFailed is the update failed error. +type ErrUpdateFailed struct { + Err error +} + +// Error satisfies the error interface. +func (err *ErrUpdateFailed) Error() string { + return fmt.Sprintf("update failed: %v", err.Err) +} + +// Unwrap satisfies the unwrap interface. +func (err *ErrUpdateFailed) Unwrap() error { + return err.Err +} + +// ErrUpsertFailed is the upsert failed error. +type ErrUpsertFailed struct { + Err error +} + +// Error satisfies the error interface. +func (err *ErrUpsertFailed) Error() string { + return fmt.Sprintf("upsert failed: %v", err.Err) +} + +// Unwrap satisfies the unwrap interface. +func (err *ErrUpsertFailed) Unwrap() error { + return err.Err +} + +// ErrInvalidTime is the invalid Time error. +type ErrInvalidTime string + +// Error satisfies the error interface. +func (err ErrInvalidTime) Error() string { + return fmt.Sprintf("invalid Time (%s)", string(err)) +} + +// Time is a SQLite3 Time that scans for the various timestamps values used by +// SQLite3 database drivers to store time.Time values. +type Time struct { + time time.Time +} + +// NewTime creates a time. +func NewTime(t time.Time) Time { + return Time{time: t} +} + +// String satisfies the fmt.Stringer interface. +func (t Time) String() string { + return t.time.String() +} + +// Format formats the time. +func (t Time) Format(layout string) string { + return t.time.Format(layout) +} + +// Time returns a time.Time. +func (t Time) Time() time.Time { + return t.time +} + +// Value satisfies the sql/driver.Valuer interface. +func (t Time) Value() (driver.Value, error) { + return t.time, nil +} + +// Scan satisfies the sql.Scanner interface. +func (t *Time) Scan(v interface{}) error { + switch x := v.(type) { + case time.Time: + t.time = x + return nil + case []byte: + return t.Parse(string(x)) + case string: + return t.Parse(x) + } + return ErrInvalidTime(fmt.Sprintf("%T", v)) +} + +// Parse attempts to Parse string s to t. +func (t *Time) Parse(s string) error { + if s == "" { + return nil + } + for _, f := range TimestampFormats { + if z, err := time.Parse(f, s); err == nil { + t.time = z + return nil + } + } + return ErrInvalidTime(s) +} + +// MarshalJSON satisfies the json.Marshaler interface. +func (t Time) MarshalJSON() ([]byte, error) { + return t.time.MarshalJSON() +} + +// UnmarshalJSON satisfies the json.Unmarshaler interface. +func (t *Time) UnmarshalJSON(data []byte) error { + return t.time.UnmarshalJSON(data) +} + +// TimestampFormats are the timestamp formats used by SQLite3 database drivers +// to store a time.Time in SQLite3. +// +// The first format in the slice will be used when saving time values into the +// database. When parsing a string from a timestamp or datetime column, the +// formats are tried in order. +var TimestampFormats = []string{ + // By default, use timestamps with the timezone they have. When parsed, + // they will be returned with the same timezone. + "2006-01-02 15:04:05.999999999-07:00", + "2006-01-02T15:04:05.999999999-07:00", + "2006-01-02 15:04:05.999999999", + "2006-01-02T15:04:05.999999999", + "2006-01-02 15:04:05", + "2006-01-02T15:04:05", + "2006-01-02 15:04", + "2006-01-02T15:04", + "2006-01-02", +} diff --git a/internal/drivers/stores/models/mapping.xo.go b/internal/drivers/stores/models/mapping.xo.go new file mode 100644 index 0000000..276ea2b --- /dev/null +++ b/internal/drivers/stores/models/mapping.xo.go @@ -0,0 +1,165 @@ +package models + +// Code generated by xo. DO NOT EDIT. + +import ( + "context" + "database/sql" +) + +// Mapping represents a row from 'mapping'. +type Mapping struct { + TvdbID string `json:"tvdb_id"` // tvdb_id + AnilistID sql.NullString `json:"anilist_id"` // anilist_id + // xo fields + _exists, _deleted bool +} + +// Exists returns true when the Mapping exists in the database. +func (m *Mapping) Exists() bool { + return m._exists +} + +// Deleted returns true when the Mapping has been marked for deletion from +// the database. +func (m *Mapping) Deleted() bool { + return m._deleted +} + +// Insert inserts the Mapping to the database. +func (m *Mapping) Insert(ctx context.Context, db DB) error { + switch { + case m._exists: // already exists + return logerror(&ErrInsertFailed{ErrAlreadyExists}) + case m._deleted: // deleted + return logerror(&ErrInsertFailed{ErrMarkedForDeletion}) + } + // insert (manual) + const sqlstr = `INSERT INTO mapping (` + + `tvdb_id, anilist_id` + + `) VALUES (` + + `$1, $2` + + `)` + // run + logf(sqlstr, m.TvdbID, m.AnilistID) + if _, err := db.ExecContext(ctx, sqlstr, m.TvdbID, m.AnilistID); err != nil { + return logerror(err) + } + // set exists + m._exists = true + return nil +} + +// Update updates a Mapping in the database. +func (m *Mapping) Update(ctx context.Context, db DB) error { + switch { + case !m._exists: // doesn't exist + return logerror(&ErrUpdateFailed{ErrDoesNotExist}) + case m._deleted: // deleted + return logerror(&ErrUpdateFailed{ErrMarkedForDeletion}) + } + // update with primary key + const sqlstr = `UPDATE mapping SET ` + + `anilist_id = $1 ` + + `WHERE tvdb_id = $2` + // run + logf(sqlstr, m.AnilistID, m.TvdbID) + if _, err := db.ExecContext(ctx, sqlstr, m.AnilistID, m.TvdbID); err != nil { + return logerror(err) + } + return nil +} + +// Save saves the Mapping to the database. +func (m *Mapping) Save(ctx context.Context, db DB) error { + if m.Exists() { + return m.Update(ctx, db) + } + return m.Insert(ctx, db) +} + +// Upsert performs an upsert for Mapping. +func (m *Mapping) Upsert(ctx context.Context, db DB) error { + switch { + case m._deleted: // deleted + return logerror(&ErrUpsertFailed{ErrMarkedForDeletion}) + } + // upsert + const sqlstr = `INSERT INTO mapping (` + + `tvdb_id, anilist_id` + + `) VALUES (` + + `$1, $2` + + `)` + + ` ON CONFLICT (tvdb_id) DO ` + + `UPDATE SET ` + + `anilist_id = EXCLUDED.anilist_id ` + // run + logf(sqlstr, m.TvdbID, m.AnilistID) + if _, err := db.ExecContext(ctx, sqlstr, m.TvdbID, m.AnilistID); err != nil { + return logerror(err) + } + // set exists + m._exists = true + return nil +} + +// Delete deletes the Mapping from the database. +func (m *Mapping) Delete(ctx context.Context, db DB) error { + switch { + case !m._exists: // doesn't exist + return nil + case m._deleted: // deleted + return nil + } + // delete with single primary key + const sqlstr = `DELETE FROM mapping ` + + `WHERE tvdb_id = $1` + // run + logf(sqlstr, m.TvdbID) + if _, err := db.ExecContext(ctx, sqlstr, m.TvdbID); err != nil { + return logerror(err) + } + // set deleted + m._deleted = true + return nil +} + +// MappingByTvdbID retrieves a row from 'mapping' as a Mapping. +// +// Generated from index 'sqlite_autoindex_mapping_1'. +func MappingByTvdbID(ctx context.Context, db DB, tvdbID string) (*Mapping, error) { + // query + const sqlstr = `SELECT ` + + `tvdb_id, anilist_id ` + + `FROM mapping ` + + `WHERE tvdb_id = $1` + // run + logf(sqlstr, tvdbID) + m := Mapping{ + _exists: true, + } + if err := db.QueryRowContext(ctx, sqlstr, tvdbID).Scan(&m.TvdbID, &m.AnilistID); err != nil { + return nil, logerror(err) + } + return &m, nil +} + +// MappingByAnilistID retrieves a row from 'mapping' as a Mapping. +// +// Generated from index 'sqlite_autoindex_mapping_2'. +func MappingByAnilistID(ctx context.Context, db DB, anilistID sql.NullString) (*Mapping, error) { + // query + const sqlstr = `SELECT ` + + `tvdb_id, anilist_id ` + + `FROM mapping ` + + `WHERE anilist_id = $1` + // run + logf(sqlstr, anilistID) + m := Mapping{ + _exists: true, + } + if err := db.QueryRowContext(ctx, sqlstr, anilistID).Scan(&m.TvdbID, &m.AnilistID); err != nil { + return nil, logerror(err) + } + return &m, nil +} diff --git a/internal/drivers/stores/sql.go b/internal/drivers/stores/sql.go new file mode 100644 index 0000000..f35be2e --- /dev/null +++ b/internal/drivers/stores/sql.go @@ -0,0 +1,104 @@ +package stores + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/wwmoraes/anilistarr/internal/drivers/stores/models" + "github.com/wwmoraes/anilistarr/internal/entities" + "github.com/wwmoraes/anilistarr/internal/telemetry" + + _ "modernc.org/sqlite" +) + +type Sql struct { + db *sql.DB +} + +func NewSQL(driverName, dataSourceName string) (*Sql, error) { + db, err := telemetry.OpenSQL(driverName, dataSourceName) + if err != nil { + return nil, fmt.Errorf("failed to open SQL database: %w", err) + } + + return &Sql{ + db: db, + }, nil +} + +func (s *Sql) PutMedia(ctx context.Context, media *entities.Media) error { + ctx, span := telemetry.StartFunction(ctx) + defer span.End() + + record := models.MappingFromMedia(media) + + return span.Assert(record.Upsert(ctx, s.db)) +} + +func (s *Sql) PutMediaBulk(ctx context.Context, medias []*entities.Media) error { + ctx, span := telemetry.StartFunction(ctx) + defer span.End() + + records := make(models.MappingList, len(medias)) + for index, media := range medias { + records[index] = &models.Mapping{ + TvdbID: media.TvdbID, + AnilistID: sql.NullString{ + String: media.AnilistID, + Valid: len(media.AnilistID) > 0, + }, + } + } + + return span.Assert(records.Upsert(ctx, s.db)) +} + +func (s *Sql) MappingByAnilistID(ctx context.Context, anilistId string) (*entities.Media, error) { + ctx, span := telemetry.StartFunction(ctx) + defer span.End() + + record, err := models.MappingByAnilistID(ctx, s.db, sql.NullString{ + String: anilistId, + Valid: len(anilistId) > 0, + }) + if errors.Is(err, sql.ErrNoRows) { + return nil, span.Assert(nil) + } else if err != nil { + return nil, span.Assert(fmt.Errorf("failed to get mapping by anilist ID: %w", err)) + } + + return record.ToMedia(), span.Assert(nil) +} + +func (s *Sql) MappingByAnilistIDBulk(ctx context.Context, anilistIds []string) ([]*entities.Media, error) { + ctx, span := telemetry.StartFunction(ctx) + defer span.End() + + ids := make([]sql.NullString, len(anilistIds)) + for index, id := range anilistIds { + ids[index] = sql.NullString{ + String: id, + Valid: len(id) > 0, + } + } + + records, err := models.MappingByAnilistIDBulk(ctx, s.db, ids) + if errors.Is(err, sql.ErrNoRows) { + return nil, span.Assert(nil) + } else if err != nil { + return nil, span.Assert(fmt.Errorf("failed to get mapping by anilist ID: %w", err)) + } + + results := make([]*entities.Media, len(records)) + for index, entry := range records { + results[index] = entry.ToMedia() + } + + return results, span.Assert(nil) +} + +func (s *Sql) Close() error { + return s.db.Close() +} diff --git a/internal/entities/media.go b/internal/entities/media.go new file mode 100644 index 0000000..4aeef78 --- /dev/null +++ b/internal/entities/media.go @@ -0,0 +1,22 @@ +package entities + +type Media struct { + AnilistID string `json:"anilist_id,omitempty" db:"anilist_id"` + TvdbID string `json:"thetvdb_id,omitempty" db:"thetvdb_id"` + + //// useless + // Type string `json:"type,omitempty"` + + //// commented out as we don't need these + // AnidbID uint `json:"anidb_id,omitempty"` + // AnisearchID uint `json:"anisearch_id,omitempty"` + // ImdbID string `json:"imdb_id,omitempty"` + // KitsuID uint `json:"kitsu_id,omitempty"` + // LivechartID uint `json:"livechart_id,omitempty"` + // MalID uint `json:"mal_id,omitempty"` + // NotifyMoeID string `json:"notify.moe_id,omitempty"` + + //// those are even worse as they mix strings and numbers + // AnimePlanetID string `json:"anime-planet_id,omitempty"` + // TmdbID uint `json:"themoviedb_id,omitempty"` +} diff --git a/internal/entities/sonarr.go b/internal/entities/sonarr.go new file mode 100644 index 0000000..8faf07b --- /dev/null +++ b/internal/entities/sonarr.go @@ -0,0 +1,7 @@ +package entities + +type SonarrCustomList []SonarrCustomEntry + +type SonarrCustomEntry struct { + TvdbID uint64 +} diff --git a/internal/telemetry/atributable.go b/internal/telemetry/atributable.go new file mode 100644 index 0000000..c2d1ac9 --- /dev/null +++ b/internal/telemetry/atributable.go @@ -0,0 +1,11 @@ +package telemetry + +import "go.opentelemetry.io/otel/attribute" + +type Attributable interface { + SetAttributes(kv ...attribute.KeyValue) +} + +func Int(element Attributable, k string, v int) { + element.SetAttributes(attribute.Int(k, v)) +} diff --git a/internal/telemetry/constants.go b/internal/telemetry/constants.go new file mode 100644 index 0000000..b1feebb --- /dev/null +++ b/internal/telemetry/constants.go @@ -0,0 +1,11 @@ +// Code generated by go generate. DO NOT EDIT. + +package telemetry + +const ( + ENVIRONMENT = "development" + MODULE = "github.com/wwmoraes/anilistarr" + VERSION = "0.1.0-rc.1" + NAME = "handler" + NAMESPACE = "media" +) diff --git a/internal/telemetry/http.go b/internal/telemetry/http.go new file mode 100644 index 0000000..ff152b5 --- /dev/null +++ b/internal/telemetry/http.go @@ -0,0 +1,59 @@ +package telemetry + +import ( + "net/http" + + "github.com/go-chi/chi/v5/middleware" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "go.opentelemetry.io/otel/trace" +) + +func NewHandler(handler http.Handler, operation string) http.Handler { + return otelhttp.NewHandler(handler, operation) +} + +func NewHandlerMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx, span := globalTracer.StartHTTPResponse(r) + ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + + next.ServeHTTP(ww, r.WithContext(ctx)) + span.EndWithStatus(ww.Status()) + }) +} + +func NewHandleFunc(fn http.HandlerFunc, operation string) http.Handler { + return NewHandler(fn, operation) +} + +type responseWriterSnooper struct { + w http.ResponseWriter + statusCode int +} + +func (ws *responseWriterSnooper) WriteHeader(statusCode int) { + ws.statusCode = statusCode + ws.w.WriteHeader(statusCode) +} + +func (ws *responseWriterSnooper) Header() http.Header { + return ws.w.Header() +} +func (ws *responseWriterSnooper) Write(data []byte) (int, error) { + return ws.w.Write(data) +} + +func HandlerFunc(fn http.HandlerFunc, startOptions []trace.SpanStartOption, endOptions []trace.SpanEndOption) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := globalTracer.StartHTTPResponse(r, startOptions...) + + res := responseWriterSnooper{ + w: w, + statusCode: http.StatusOK, + } + + fn(&res, r.WithContext(ctx)) + + span.EndWithStatus(res.statusCode, endOptions...) + } +} diff --git a/internal/telemetry/httpspan.go b/internal/telemetry/httpspan.go new file mode 100644 index 0000000..082c8cd --- /dev/null +++ b/internal/telemetry/httpspan.go @@ -0,0 +1,41 @@ +package telemetry + +import ( + "net/http" + + "go.opentelemetry.io/otel/codes" + semconv "go.opentelemetry.io/otel/semconv/v1.20.0" + "go.opentelemetry.io/otel/trace" +) + +type HTTPSpan interface { + Span + + HTTPStatus(status int) + EndWithStatus(status int, options ...trace.SpanEndOption) +} + +type httpSpan struct { + span +} + +func (s *httpSpan) HTTPStatus(status int) { + code := codes.Ok + if status >= 400 { + code = codes.Error + } + + s.SetAttributes(semconv.HTTPStatusCode(status)) + s.SetStatus(code, http.StatusText(status)) +} + +func (s *httpSpan) EndWithStatus(status int, options ...trace.SpanEndOption) { + code := codes.Ok + if status >= 400 { + code = codes.Error + } + + s.SetAttributes(semconv.HTTPStatusCode(status)) + s.SetStatus(code, http.StatusText(status)) + s.End(options...) +} diff --git a/internal/telemetry/instrument.go b/internal/telemetry/instrument.go new file mode 100644 index 0000000..927e9ab --- /dev/null +++ b/internal/telemetry/instrument.go @@ -0,0 +1,180 @@ +package telemetry + +import ( + "context" + "fmt" + "log" + "os" + "sync" + "time" + + "github.com/MrAlias/otlpr" + "github.com/go-logr/logr" + otelruntime "go.opentelemetry.io/contrib/instrumentation/runtime" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/propagation" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.17.0" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +var ( + otlpConnHandler sync.Once + otlpConn *grpc.ClientConn + otlpConnErr error + + otlpResource *resource.Resource + + globalTracer Tracer + globalMeter Meter + globalLogger Logger +) + +func init() { + otel.SetTextMapPropagator( + propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + ), + ) + + var err error + otlpResource, err = resource.Merge(resource.Empty(), resource.NewWithAttributes( + semconv.SchemaURL, + semconv.ServiceNamespace(NAMESPACE), + semconv.ServiceName(NAME), + semconv.ServiceVersion(VERSION), + semconv.CodeNamespace(MODULE), + semconv.DeploymentEnvironment(ENVIRONMENT), + )) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to create OTLP resource: %s", err.Error()) + } + + globalTracer = newTracer() + globalMeter = newMeter() + globalLogger = logr.New(NewStdLogSink()) +} + +func getOTLPConnGRPC(ctx context.Context, otlpEndpoint string) (*grpc.ClientConn, error) { + otlpConnHandler.Do(func() { + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + otlpConn, otlpConnErr = grpc.DialContext(ctx, otlpEndpoint, + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithBlock(), + ) + if otlpConnErr != nil { + otlpConnErr = fmt.Errorf("failed to connect to the OTLP endpoint: %w", otlpConnErr) + } + }) + + return otlpConn, otlpConnErr +} + +func providerShutdown(shutdown func(context.Context) error) func(context.Context) { + return func(ctx context.Context) { + if err := shutdown(ctx); err != nil { + log.Fatal(err) + } + } +} + +func InstrumentTracing(ctx context.Context, otlpEndpoint string) (func(context.Context), error) { + conn, err := getOTLPConnGRPC(ctx, otlpEndpoint) + if err != nil { + return nil, err + } + + traceExporter, err := otlptracegrpc.New(ctx, otlptracegrpc.WithGRPCConn(conn)) + if err != nil { + return nil, fmt.Errorf("failed to create an OTLP exporter: %w", err) + } + + bsp := sdktrace.NewBatchSpanProcessor(traceExporter) + traceProvider := sdktrace.NewTracerProvider( + + sdktrace.WithSampler(sdktrace.AlwaysSample()), + sdktrace.WithResource(otlpResource), + sdktrace.WithSpanProcessor(bsp), + // flow.WithSpanProcessor(bsp), + ) + + otel.SetTracerProvider(traceProvider) + + return providerShutdown(traceProvider.Shutdown), nil +} + +func InstrumentMetrics(ctx context.Context, otlpEndpoint string) (func(context.Context), error) { + conn, err := getOTLPConnGRPC(ctx, otlpEndpoint) + if err != nil { + return nil, err + } + + meterExporter, err := otlpmetricgrpc.New(ctx, otlpmetricgrpc.WithGRPCConn(conn)) + if err != nil { + return nil, fmt.Errorf("failed to create an OTLP exporter: %w", err) + } + + meterProvider := sdkmetric.NewMeterProvider( + sdkmetric.WithResource(otlpResource), + sdkmetric.WithReader(sdkmetric.NewPeriodicReader(meterExporter)), + ) + + err = otelruntime.Start( + otelruntime.WithMeterProvider(meterProvider), + otelruntime.WithMinimumReadMemStatsInterval(time.Second), + ) + if err != nil { + providerShutdown(meterProvider.Shutdown)(ctx) + return nil, err + } + + otel.SetMeterProvider(meterProvider) + + return providerShutdown(meterProvider.Shutdown), nil +} + +func InstrumentLogging(ctx context.Context, otlpEndpoint string) error { + conn, err := getOTLPConnGRPC(ctx, otlpEndpoint) + if err != nil { + return err + } + + logger := otlpr.WithResource(otlpr.New(conn), otlpResource) + otlpSink := logger.GetSink() + + globalLogger = logger.WithSink(TeeSink(globalLogger.GetSink(), otlpSink)) + + otel.SetLogger(globalLogger) + + return nil +} + +func InstrumentAll(ctx context.Context, otlpEndpoint string) (func(context.Context), error) { + tracingShutdown, err := InstrumentTracing(ctx, otlpEndpoint) + if err != nil { + return nil, err + } + + metricsShutdown, err := InstrumentMetrics(ctx, otlpEndpoint) + if err != nil { + return nil, err + } + + err = InstrumentLogging(ctx, otlpEndpoint) + if err != nil { + return nil, err + } + + return func(ctx context.Context) { + tracingShutdown(ctx) + metricsShutdown(ctx) + }, nil +} diff --git a/internal/telemetry/logger.go b/internal/telemetry/logger.go new file mode 100644 index 0000000..966460e --- /dev/null +++ b/internal/telemetry/logger.go @@ -0,0 +1,64 @@ +package telemetry + +import ( + "context" + "fmt" + "strings" + + "github.com/go-logr/logr" +) + +const ( + keyValueSeparator = ": " + entrySeparator = " | " +) + +type Logger = logr.Logger + +func DefaultLogger() logr.Logger { + return globalLogger +} + +func ContextWithLogger(ctx context.Context) context.Context { + return logr.NewContext(ctx, globalLogger) +} + +func LoggerFromContext(ctx context.Context) logr.Logger { + return logr.FromContextOrDiscard(ctx) +} + +func kv2Map(keysAndValues ...interface{}) map[interface{}]interface{} { + values := make(map[interface{}]interface{}, len(keysAndValues)/2) + + for i := 0; i+1 < len(keysAndValues); i = i + 2 { + values[keysAndValues[i]] = keysAndValues[i+1] + } + + return values +} + +func mergeMaps(maps ...map[interface{}]interface{}) map[interface{}]interface{} { + values := make(map[interface{}]interface{}) + + for _, entry := range maps { + for k, v := range entry { + values[k] = v + } + } + + return values +} + +func mapString(m map[interface{}]interface{}) string { + entries := make([]string, 0, len(m)) + + for k, v := range m { + if k == nil || v == nil { + continue + } + + entries = append(entries, fmt.Sprintf("%v%s%v", k, keyValueSeparator, v)) + } + + return strings.Join(entries, entrySeparator) +} diff --git a/internal/telemetry/meter.go b/internal/telemetry/meter.go new file mode 100644 index 0000000..6d82ec2 --- /dev/null +++ b/internal/telemetry/meter.go @@ -0,0 +1,17 @@ +package telemetry + +import ( + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/metric" +) + +type Meter = metric.Meter +type MeterOption = metric.MeterOption + +func newMeter(opts ...MeterOption) Meter { + return otel.Meter(NAME, opts...) +} + +func DefaultMeter() Meter { + return globalMeter +} diff --git a/internal/telemetry/span.go b/internal/telemetry/span.go new file mode 100644 index 0000000..fae2159 --- /dev/null +++ b/internal/telemetry/span.go @@ -0,0 +1,55 @@ +package telemetry + +import ( + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +type Span interface { + trace.Span + + EndWith(err error, options ...trace.SpanEndOption) + Assert(error) error + Int(k string, v int) +} + +type span struct { + trace.Span +} + +func (s *span) Assert(err error) error { + if err == nil { + s.SetStatus(codes.Ok, "") + } else { + s.SetStatus(codes.Error, err.Error()) + s.RecordError(err) + } + + return err +} + +func (s *span) EndWith(err error, options ...trace.SpanEndOption) { + if err == nil { + s.SetStatus(codes.Ok, "") + } else { + s.SetStatus(codes.Error, err.Error()) + s.RecordError(err) + } + + s.End(options...) +} + +func (s *span) Int(k string, v int) { + s.SetAttributes(attribute.Int(k, v)) +} + +func WithInt(k string, v int) trace.SpanStartEventOption { + return trace.WithAttributes( + attribute.Int(k, v), + ) +} + +func WithSpanKindClient() trace.SpanStartOption { + return trace.WithSpanKind(trace.SpanKindClient) +} diff --git a/internal/telemetry/stdlogsink.go b/internal/telemetry/stdlogsink.go new file mode 100644 index 0000000..19d0775 --- /dev/null +++ b/internal/telemetry/stdlogsink.go @@ -0,0 +1,56 @@ +package telemetry + +import ( + "log" + "os" + "time" + + "github.com/go-logr/logr" +) + +type stdLogSink struct { + stdout *log.Logger + stderr *log.Logger + values map[interface{}]interface{} +} + +func NewStdLogSink() *stdLogSink { + return &stdLogSink{ + stdout: log.New(os.Stdout, "", 0), + stderr: log.New(os.Stderr, "", 0), + } +} + +func (sink *stdLogSink) Enabled(level int) bool { + return true +} + +func (sink *stdLogSink) Error(err error, msg string, keysAndValues ...interface{}) { + values := mergeMaps(sink.values, kv2Map(keysAndValues...)) + + sink.stderr.Printf("%s: %s [%s]", msg, err.Error(), mapString(values)) +} + +func (sink *stdLogSink) Info(level int, msg string, keysAndValues ...interface{}) { + values := mergeMaps(sink.values, kv2Map(keysAndValues...)) + + sink.stdout.Printf("%s [%s] %s", time.Now().Format(time.Stamp), mapString(values), msg) +} + +func (sink *stdLogSink) Init(info logr.RuntimeInfo) {} + +func (sink *stdLogSink) WithValues(keysAndValues ...interface{}) logr.LogSink { + return &stdLogSink{ + stdout: log.New(sink.stdout.Writer(), sink.stdout.Prefix(), sink.stdout.Flags()), + stderr: log.New(sink.stderr.Writer(), sink.stderr.Prefix(), sink.stderr.Flags()), + values: mergeMaps(sink.values, kv2Map(keysAndValues...)), + } +} + +func (sink *stdLogSink) WithName(name string) logr.LogSink { + return &stdLogSink{ + stdout: log.New(sink.stdout.Writer(), sink.stdout.Prefix()+name, sink.stderr.Flags()), + stderr: log.New(sink.stderr.Writer(), sink.stderr.Prefix()+name, sink.stderr.Flags()), + values: sink.values, + } +} diff --git a/internal/telemetry/teesink.go b/internal/telemetry/teesink.go new file mode 100644 index 0000000..5989dea --- /dev/null +++ b/internal/telemetry/teesink.go @@ -0,0 +1,57 @@ +package telemetry + +import "github.com/go-logr/logr" + +type teeSink []logr.LogSink + +func TeeSink(sinks ...logr.LogSink) logr.LogSink { + return teeSink(sinks) +} + +func (sinks teeSink) Enabled(level int) bool { + for _, sink := range sinks { + if !sink.Enabled(level) { + return false + } + } + + return true +} + +func (sinks teeSink) Error(err error, msg string, keysAndValues ...interface{}) { + for _, sink := range sinks { + sink.Error(err, msg, keysAndValues...) + } +} + +func (sinks teeSink) Info(level int, msg string, keysAndValues ...interface{}) { + for _, sink := range sinks { + sink.Info(level, msg, keysAndValues...) + } +} + +func (sinks teeSink) Init(info logr.RuntimeInfo) { + for _, sink := range sinks { + sink.Init(info) + } +} + +func (sinks teeSink) WithValues(keysAndValues ...interface{}) logr.LogSink { + newSinks := make(teeSink, len(sinks)) + + for index, sink := range sinks { + newSinks[index] = sink.WithValues(keysAndValues...) + } + + return newSinks +} + +func (sinks teeSink) WithName(name string) logr.LogSink { + newSinks := make(teeSink, len(sinks)) + + for index, sink := range sinks { + newSinks[index] = sink.WithName(name) + } + + return newSinks +} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go new file mode 100644 index 0000000..72c40b8 --- /dev/null +++ b/internal/telemetry/telemetry.go @@ -0,0 +1,55 @@ +package telemetry + +import ( + "context" + "database/sql" + "net/http" + + "github.com/XSAM/otelsql" + "go.opentelemetry.io/otel/attribute" + semconv "go.opentelemetry.io/otel/semconv/v1.20.0" + "go.opentelemetry.io/otel/trace" +) + +func StartFunction(ctx context.Context, opts ...trace.SpanStartOption) (context.Context, Span) { + name, opt := functionInfo(2) + opts = append(opts, opt) + + return globalTracer.Start(ctx, name, opts...) +} + +func Start(ctx context.Context, spanName string, opts ...SpanStartOption) (context.Context, Span) { + return globalTracer.Start(ctx, spanName, opts...) +} + +func SpanFromContext(ctx context.Context) trace.Span { + return trace.SpanFromContext(ctx) +} + +func WantedRequestHeaders(h http.Header, keys ...string) http.Header { + target := http.Header{} + + for _, key := range keys { + target[key] = h.Values(key) + } + + return target +} + +func OpenSQL(driverName, dataSourceName string) (*sql.DB, error) { + attributes := otelsql.WithAttributes( + attribute.String(string(semconv.DBSystemKey), driverName), + ) + + db, err := otelsql.Open(driverName, dataSourceName, attributes) + if err != nil { + return nil, err + } + + err = otelsql.RegisterDBStatsMetrics(db, attributes) + if err != nil { + return nil, err + } + + return db, nil +} diff --git a/internal/telemetry/tracer.go b/internal/telemetry/tracer.go new file mode 100644 index 0000000..a4feffb --- /dev/null +++ b/internal/telemetry/tracer.go @@ -0,0 +1,92 @@ +package telemetry + +import ( + "context" + "fmt" + "net/http" + "regexp" + "runtime" + + "go.opentelemetry.io/otel" + semconv "go.opentelemetry.io/otel/semconv/v1.20.0" + "go.opentelemetry.io/otel/trace" +) + +var ( + fnNameRE = regexp.MustCompile(`.*?(?:\(\*)?([^\./\(\)\[\]]+)(?:[\[\]\.\)]*)?\.([^\./\(\)]+)$`) +) + +type TracerOption = trace.TracerOption +type SpanStartOption = trace.SpanStartOption + +type Tracer interface { + StartFunction(ctx context.Context, opts ...SpanStartOption) (context.Context, Span) + StartHTTPResponse(req *http.Request, opts ...SpanStartOption) (context.Context, HTTPSpan) + + // custom span + // from trace.Tracer + Start(ctx context.Context, spanName string, opts ...SpanStartOption) (context.Context, Span) +} + +type tracer struct { + upstream trace.Tracer +} + +func newTracer(opts ...TracerOption) Tracer { + return &tracer{ + upstream: otel.Tracer(NAME, opts...), + } +} + +func DefaultTracer() Tracer { + return globalTracer +} + +func (t *tracer) Start(ctx context.Context, spanName string, opts ...SpanStartOption) (context.Context, Span) { + ctx, upstreamSpan := t.upstream.Start(ctx, spanName, opts...) + return ctx, &span{upstreamSpan} +} + +func (t *tracer) StartFunction(ctx context.Context, opts ...SpanStartOption) (context.Context, Span) { + name, opt := functionInfo(1) + opts = append(opts, opt) + + return t.Start(ctx, name, opts...) +} + +func (t *tracer) StartHTTPResponse(req *http.Request, opts ...SpanStartOption) (context.Context, HTTPSpan) { + opts = append(opts, trace.WithAttributes( + semconv.HTTPMethod(req.Method), + semconv.HTTPURL(req.URL.String()), + semconv.HTTPRoute(req.URL.Path), + semconv.HTTPScheme(req.URL.Scheme), + semconv.HTTPClientIP(req.RemoteAddr), + semconv.HTTPRequestContentLength(int(req.ContentLength)), + )) + + upstreamCtx, upstreamSpan := t.upstream.Start(req.Context(), fmt.Sprintf("%s %s", req.Method, req.URL.Path), opts...) + + return upstreamCtx, &httpSpan{span{upstreamSpan}} +} + +func functionInfo(skip int) (string, trace.SpanStartOption) { + name, fullName := "", "" + pc, file, line, ok := runtime.Caller(skip) + if ok { + details := runtime.FuncForPC(pc) + if details != nil { + fullName = details.Name() + name = fnNameRE.ReplaceAllString(details.Name(), "$1.$2") + file, line = details.FileLine(pc) + } + } else { + name = file + fullName = file + } + + return name, trace.WithAttributes( + semconv.CodeFunction(fullName), + semconv.CodeFilepath(file), + semconv.CodeLineNumber(line), + ) +} diff --git a/internal/usecases/mapper.go b/internal/usecases/mapper.go new file mode 100644 index 0000000..864817e --- /dev/null +++ b/internal/usecases/mapper.go @@ -0,0 +1,14 @@ +package usecases + +import ( + "context" + "io" +) + +type Mapper interface { + io.Closer + + MapIDs(context.Context, []string) ([]string, error) + MapID(context.Context, string) (string, error) + Refresh(context.Context) error +} diff --git a/internal/usecases/mediabridge.go b/internal/usecases/mediabridge.go new file mode 100644 index 0000000..d02dc0f --- /dev/null +++ b/internal/usecases/mediabridge.go @@ -0,0 +1,83 @@ +package usecases + +import ( + "context" + "fmt" + "strconv" + + "github.com/wwmoraes/anilistarr/internal/entities" + "github.com/wwmoraes/anilistarr/internal/telemetry" +) + +type MediaBridge struct { + Tracker Tracker + Mapper Mapper +} + +func (linker *MediaBridge) GenerateCustomList(ctx context.Context, name string) (entities.SonarrCustomList, error) { + log := telemetry.LoggerFromContext(ctx).WithValues("username", name) + ctx, span := telemetry.StartFunction(ctx) + defer span.End() + + log.Info("retrieving user ID") + userId, err := linker.GetUserID(ctx, name) + if err != nil { + return nil, span.Assert(fmt.Errorf("failed to get user ID: %w", err)) + } + + log.Info("retrieving media list IDs", "userID", userId) + sourceIds, err := linker.Tracker.GetMediaListIDs(ctx, userId) + if err != nil { + return nil, span.Assert(fmt.Errorf("failed to get media list IDs: %w", err)) + } + + targetIds, err := linker.Mapper.MapIDs(ctx, sourceIds) + if err != nil { + return nil, span.Assert(fmt.Errorf("failed to get mapped IDs: %w", err)) + } + + customList := make(entities.SonarrCustomList, 0, len(targetIds)) + for index, entry := range targetIds { + if entry == "" { + log.Info("no TVDB ID registered for source ID", "sourceID", sourceIds[index]) + continue + } + + tvdbID, err := strconv.ParseUint(entry, 10, 0) + if err != nil { + return nil, span.Assert(fmt.Errorf("failed to parse TVDB ID: %w", err)) + } + + customList = append(customList, entities.SonarrCustomEntry{ + TvdbID: tvdbID, + }) + } + + return customList, span.Assert(nil) +} + +func (linker *MediaBridge) GetUserID(ctx context.Context, name string) (string, error) { + ctx, span := telemetry.StartFunction(ctx) + defer span.End() + + res, err := linker.Tracker.GetUserID(ctx, name) + return res, span.Assert(err) +} + +func (linker *MediaBridge) Close() error { + errT := linker.Tracker.Close() + errR := linker.Mapper.Close() + + if errT != nil || errR != nil { + return fmt.Errorf("failed to close mapper dependencies: %v", []error{errT, errR}) + } + + return nil +} + +func (linker *MediaBridge) Refresh(ctx context.Context) error { + ctx, span := telemetry.StartFunction(ctx) + defer span.End() + + return span.Assert(linker.Mapper.Refresh(ctx)) +} diff --git a/internal/usecases/tracker.go b/internal/usecases/tracker.go new file mode 100644 index 0000000..8c525a2 --- /dev/null +++ b/internal/usecases/tracker.go @@ -0,0 +1,13 @@ +package usecases + +import ( + "context" + "io" +) + +type Tracker interface { + io.Closer + + GetUserID(ctx context.Context, name string) (string, error) + GetMediaListIDs(ctx context.Context, userId string) ([]string, error) +} diff --git a/media.db.sql b/media.db.sql new file mode 100644 index 0000000..fea5d29 --- /dev/null +++ b/media.db.sql @@ -0,0 +1,8 @@ +CREATE TABLE mapping ( + tvdb_id TEXT UNIQUE + PRIMARY KEY + NOT NULL, + anilist_id TEXT UNIQUE +) +WITHOUT ROWID, +STRICT; diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..710bf1f --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,17 @@ +sonar.host.url=https://sonarcloud.io +sonar.organization=wwmoraes +sonar.projectKey=wwmoraes_anilistarr + +sonar.sourceEncoding=UTF-8 + +sonar.sources=. +sonar.exclusions=**/*_test.go,**/vendor/** +sonar.tests=. +sonar.language=go + +sonar.test.inclusions=**/*_test.go +sonar.test.exclusions=**/vendor/** + +sonar.go.golangci-lint.reportPaths=golangci-lint-report.xml +sonar.go.tests.reportPaths=test-report.json +sonar.go.coverage.reportPaths=coverage.out diff --git a/workspace.dsl b/workspace.dsl new file mode 100644 index 0000000..bbf125e --- /dev/null +++ b/workspace.dsl @@ -0,0 +1,13 @@ +workspace anilistarr "list provider for *arr applications" { + + model { + anilistarr = softwareSystem anilistarr "converts anime sources" { + + } + } + + views { + + } + +}