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..53385f2 --- /dev/null +++ b/.github/workflows/integration.yml @@ -0,0 +1,470 @@ +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 }} + # 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: ${{ github.ref_name }} + - name: run goreleaser + uses: goreleaser/goreleaser-action@v3 + with: + args: release --rm-dist + 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 }}:${{ github.ref_name }} + ${{ 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 }}:${{ github.ref_name }} + ${{ github.repository }}:latest + build-args: | + GOLANG_VERSION=${{ env.GOLANG_VERSION }} + VERSION=${{ github.ref_name }} + 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=${{ github.ref_name }} + 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..0e23d20 --- /dev/null +++ b/.gitignore @@ -0,0 +1,53 @@ +*.env +*.env.local +bin/ + +# 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..d5194a1 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,41 @@ +before: + hooks: + - go mod download +builds: +- id: handler + main: cmd/handler/main.go + goos: + - linux + - windows + - darwin + goarch: + - 386 + - amd64 + - arm + - arm64 + goarm: + - 6 + - 7 +archives: +- replacements: + darwin: Darwin + linux: Linux + windows: Windows + 386: i386 + amd64: x86_64 +checksum: + name_template: 'checksums.txt' +snapshot: + name_template: "{{ .Tag }}-edge" +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..cab6677 --- /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.base.name="golang:${GOLANG_VERSION}-alpine" +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 " + +ARG VERSION +LABEL org.opencontainers.image.version="${VERSION}" + +COPY --from=build /src/bin/handler /usr/local/bin/handler + +USER 20000:20000 + +CMD ["/usr/local/bin/handler"] + +EXPOSE 8080 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..5f8011d --- /dev/null +++ b/Makefile @@ -0,0 +1,57 @@ +-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: 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.0.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} \ + . + +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..469b6e7 --- /dev/null +++ b/internal/drivers/caches/bolt.go @@ -0,0 +1,80 @@ +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)) + })) +} + +func (c *boltCache) Close() error { + return c.Close() +} diff --git a/internal/drivers/caches/redis.go b/internal/drivers/caches/redis.go new file mode 100644 index 0000000..58d73c2 --- /dev/null +++ b/internal/drivers/caches/redis.go @@ -0,0 +1,60 @@ +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()) +} + +func (c *redisCache) Close() error { + return c.Close() +} 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 { + + } + +}