feat: MVP's out 🚀

This commit is contained in:
William Artero 2023-08-02 16:51:01 +02:00
commit 75394b92a0
Signed by: wwmoraes
GPG key ID: 4180618C988F24A3
76 changed files with 15214 additions and 0 deletions

44
.air.toml Normal file
View file

@ -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

24
.dockerignore Normal file
View file

@ -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

28
.editorconfig Normal file
View file

@ -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

33
.github/ISSUE_TEMPLATE/BUG.md vendored Normal file
View file

@ -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.

View file

@ -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.

View file

@ -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}}

14
.github/dependabot.yml vendored Normal file
View file

@ -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

30
.github/workflows/codeql.yml vendored Normal file
View file

@ -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

440
.github/workflows/integration.yml vendored Normal file
View file

@ -0,0 +1,440 @@
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
permissions: read-all
jobs:
metadata:
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: calculate version
uses: paulhatch/semantic-version@v5.0.3
id: version
with:
branch: ${{ github.ref_name }}
bump_each_commit: false
change_path: >-
cmd/handler
internal
go.mod
go.sum
major_pattern: /^BREAKING CHANGE:|^[^()!:]+(?:\([^()!:]+\))?!:/
minor_pattern: /^feat(?:\([^()!:]+\))?:/
search_commit_body: true
user_format_type: csv
version_format: ${major}.${minor}.${patch}-rc.${increment}
- name: generate container meta
id: meta
uses: docker/metadata-action@v4
with:
context: workflow
images: ${{ github.repository }}
flavor: |
latest=true
# yamllint disable rule:line-length
labels: |
org.opencontainers.image.documentation=https://github.com/${{ github.repository }}/blob/master/README.md
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.url=https://hub.docker.com/r/${{ github.repository }}
org.opencontainers.image.version=${{ steps.version.outputs.version }}
# yamllint enable rule:line-length
tags: |
type=ref,event=branch
type=ref,event=pr
type=raw,value=${{ env.BRANCH }}
type=semver,pattern={{version}}
github-token: ${{ github.token }}
outputs:
major: ${{ steps.version.outputs.major }}
minor: ${{ steps.version.outputs.minor }}
patch: ${{ steps.version.outputs.patch }}
increment: ${{ steps.version.outputs.increment }}
version_type: ${{ steps.version.outputs.version_type }}
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.version_tag }}
revision: ${{ steps.version.outputs.current_commit }}
authors: ${{ steps.version.outputs.authors }}
container-labels: ${{ steps.meta.outputs.labels }}
container-tags: ${{ steps.meta.outputs.tags }}
build:
runs-on: ubuntu-latest
needs: metadata
steps:
- name: checkout
uses: actions/checkout@v2
- name: setup golang
uses: actions/setup-go@v3
with:
go-version: ${{ env.GOLANG_VERSION }}
- name: set golang environment variables
uses: wwmoraes/actions/golang/env@master
- name: cache modules
uses: pat-s/always-upload-cache@v2.1.5
with:
path: ${GOMODCACHE}
key: ${{ runner.os }}-modules-${{ hashFiles('go.sum') }}
restore-keys: |
${{ runner.os }}-modules-${{ hashFiles('go.sum') }}
${{ runner.os }}-modules-
- name: download modules
run: go mod download
- name: cache build
uses: pat-s/always-upload-cache@v2.1.5
with:
path: ${GOCACHE}
key: ${{ runner.os }}-build-${{ hashFiles('**/*.go') }}
restore-keys: |
${{ runner.os }}-build-${{ hashFiles('**/*.go') }}
${{ runner.os }}-build-
- name: generate
run: go generate ./...
env:
VERSION: ${{ needs.metadata.outputs.version }}+${{ github.sha }}
- name: build
uses: wwmoraes/actions/golang/build@master
lint:
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v2
- name: setup golang
uses: actions/setup-go@v3
with:
go-version: ${{ env.GOLANG_VERSION }}
- name: set golang environment variables
uses: wwmoraes/actions/golang/env@master
- name: cache modules
uses: pat-s/always-upload-cache@v2.1.5
with:
path: ${GOMODCACHE}
key: ${{ runner.os }}-modules-${{ hashFiles('go.sum') }}
restore-keys: |
${{ runner.os }}-modules-${{ hashFiles('go.sum') }}
${{ runner.os }}-modules-
- name: download modules
run: go mod download
- name: cache lint
uses: pat-s/always-upload-cache@v2.1.5
with:
path: ${HOME}/.cache/golangci-lint
key: ${{ runner.os }}-lint-${{ hashFiles('.golangci.yaml') }}
restore-keys: |
${{ runner.os }}-lint-${{ hashFiles('.golangci.yaml') }}
${{ runner.os }}-lint-
- name: lint
uses: wwmoraes/actions/golang/lint@master
id: lint
with:
work-dir: ${{ env.WORK_DIR }}
version: v1.46-alpine
- name: upload lint report
uses: actions/upload-artifact@v2
if: always()
with:
name: lint-report
path: ${{ steps.lint.outputs.report-file }}
test:
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v2
- name: setup golang
uses: actions/setup-go@v3
with:
go-version: ${{ env.GOLANG_VERSION }}
- name: set golang environment variables
uses: wwmoraes/actions/golang/env@master
- name: cache modules
uses: pat-s/always-upload-cache@v2.1.5
with:
path: ${GOMODCACHE}
key: ${{ runner.os }}-modules-${{ hashFiles('go.sum') }}
restore-keys: |
${{ runner.os }}-modules-${{ hashFiles('go.sum') }}
${{ runner.os }}-modules-
- name: download modules
run: go mod download
- name: cache test
uses: pat-s/always-upload-cache@v2.1.5
with:
path: ${GOCACHE}
key: ${{ runner.os }}-test-${{ hashFiles('**/*.go') }}
restore-keys: |
${{ runner.os }}-test-${{ hashFiles('**/*.go') }}
${{ runner.os }}-test-
- name: test
uses: wwmoraes/actions/golang/test@master
id: test
- name: security scan
uses: anchore/scan-action@v3
with:
path: .
fail-build: true
- name: upload coverage report
uses: actions/upload-artifact@v2
if: always()
with:
name: coverage-report
path: ${{ steps.test.outputs.cover-profile }}
- name: upload test report
uses: actions/upload-artifact@v2
if: always()
with:
name: test-report
path: ${{ steps.test.outputs.report-file }}
report:
runs-on: ubuntu-latest
needs: [lint, test]
if: always()
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]
permissions:
contents: write
if: >-
github.event_name == 'push'
&& startsWith(github.ref, 'refs/tags/')
steps:
- name: checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: setup golang
uses: actions/setup-go@v3
with:
go-version: ${{ env.GOLANG_VERSION }}
- name: set golang environment variables
uses: wwmoraes/actions/golang/env@master
- name: cache modules
uses: pat-s/always-upload-cache@v2.1.5
with:
path: ${GOMODCACHE}
key: ${{ runner.os }}-modules-${{ hashFiles('go.sum') }}
restore-keys: |
${{ runner.os }}-modules-${{ hashFiles('go.sum') }}
${{ runner.os }}-modules-
- name: cache build
uses: pat-s/always-upload-cache@v2.1.5
with:
path: ${GOCACHE}
key: ${{ runner.os }}-build-${{ hashFiles('**/*.go') }}
restore-keys: |
${{ runner.os }}-build-${{ hashFiles('**/*.go') }}
${{ runner.os }}-build-
- name: generate
run: go generate ./...
env:
VERSION: ${{ needs.metadata.outputs.version }}
- name: run goreleaser
uses: goreleaser/goreleaser-action@v4
with:
args: release --clean --skip-validate
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
docker:
runs-on: ubuntu-latest
needs: metadata
env:
# runner context is not available here...
GRYPE_DB_CACHE_TEMP_PATH: .cache/grype/db/
steps:
- name: checkout
uses: actions/checkout@v2
- name: set up QEMU
uses: docker/setup-qemu-action@v2
- name: set up docker buildx
uses: docker/setup-buildx-action@v2
- name: cache buildx
uses: pat-s/always-upload-cache@v2.1.5
with:
path: ${{ runner.temp }}/.buildx-cache
# yamllint disable-line rule:line-length
key: ${{ runner.os }}-buildx-${{ hashFiles('Dockerfile', '.dockerignore') }}
# yamllint disable rule:line-length
restore-keys: |
${{ runner.os }}-buildx-${{ hashFiles('Dockerfile', '.dockerignore') }}
${{ runner.os }}-buildx-
# yamllint enable rule:line-length
- name: build single-arch test image
uses: docker/build-push-action@v3
env:
DOCKER_BUILDKIT: 0
BUILDKIT_INLINE_CACHE: 1
with:
push: false
load: true
labels: ${{ needs.metadata.outputs.container-labels }}
cache-to: |
type=local,mode=max,dest=${{ runner.temp }}/.buildx-cache-new
cache-from: |
type=local,src=${{ runner.temp }}/.buildx-cache
${{ needs.metadata.outputs.container-tags }}
${{ github.repository }}:test
tags: ${{ github.repository }}:test
build-args: |
GOLANG_VERSION=${{ env.GOLANG_VERSION }}
VERSION=${{ needs.metadata.outputs.version }}
# fix to prevent ever-growing caches
# https://github.com/docker/build-push-action/issues/252
# https://github.com/moby/buildkit/issues/1896
- name: Move cache
run: |
rm -rf ${{ runner.temp }}/.buildx-cache
mv ${{ runner.temp }}/.buildx-cache-new ${{ runner.temp }}/.buildx-cache
- name: test structure
uses: brpaz/structure-tests-action@v1.1.2
with:
image: ${{ github.repository }}:test
configFile: container-structure-test.yaml
- name: cache grype
uses: pat-s/always-upload-cache@v2.1.5
with:
path: ${{ runner.temp }}/${{ env.GRYPE_DB_CACHE_TEMP_PATH }}
key: ${{ runner.os }}-grype-${{ hashFiles('.grype.yaml') }}
restore-keys: |
${{ runner.os }}-grype-${{ hashFiles('.grype.yaml') }}
${{ runner.os }}-grype-
- name: grype scan
uses: anchore/scan-action@v3
with:
image: ${{ github.repository }}:test
fail-build: true
publish-image:
runs-on: ubuntu-latest
needs: [metadata, docker, lint, test]
if: >-
github.event_name == 'push'
&& startsWith(github.ref, 'refs/tags/')
steps:
- name: checkout
uses: actions/checkout@v2
- name: set up QEMU
uses: docker/setup-qemu-action@v2
- name: set up docker buildx
uses: docker/setup-buildx-action@v2
- name: cache buildx
uses: pat-s/always-upload-cache@v2.1.5
with:
path: ${{ runner.temp }}/.buildx-cache
# yamllint disable-line rule:line-length
key: ${{ runner.os }}-buildx-${{ hashFiles('Dockerfile', '.dockerignore') }}
# yamllint disable rule:line-length
restore-keys: |
${{ runner.os }}-buildx-${{ hashFiles('Dockerfile', '.dockerignore') }}
${{ runner.os }}-buildx-
# yamllint enable rule:line-length
# fix to prevent ever-growing caches
# https://github.com/docker/build-push-action/issues/252
# https://github.com/moby/buildkit/issues/1896
- name: login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: push multi-arch image
uses: docker/build-push-action@v3
env:
DOCKER_BUILDKIT: 1
BUILDKIT_INLINE_CACHE: 1
with:
push: true
platforms: linux/amd64,linux/arm/v7,linux/arm64
labels: ${{ needs.metadata.outputs.container-labels }}
cache-to: |
type=local,mode=max,dest=${{ runner.temp }}/.buildx-cache-new
cache-from: |
type=local,src=${{ runner.temp }}/.buildx-cache
${{ needs.metadata.outputs.container-tags }}
${{ github.repository }}:master
${{ github.repository }}:latest
build-args: |
GOLANG_VERSION=${{ env.GOLANG_VERSION }}
VERSION=${{ needs.metadata.outputs.version }}
tags: ${{ needs.metadata.outputs.container-tags }}
# fix to prevent ever-growing caches
# https://github.com/docker/build-push-action/issues/252
# https://github.com/moby/buildkit/issues/1896
- name: Move cache
run: |
rm -rf ${{ runner.temp }}/.buildx-cache
mv ${{ runner.temp }}/.buildx-cache-new ${{ runner.temp }}/.buildx-cache
- name: update DockerHub description
uses: meeDamian/sync-readme@v1.0.6
with:
pass: ${{ secrets.DOCKER_PASSWORD }}
description: true

54
.gitignore vendored Normal file
View file

@ -0,0 +1,54 @@
*.env
*.env.local
bin/
dist/
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk

19
.golangci.yaml Normal file
View file

@ -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

38
.goreleaser.yml Normal file
View file

@ -0,0 +1,38 @@
before:
hooks:
- go mod download
builds:
- id: handler
main: ./cmd/handler
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
goarch:
- amd64
- arm
- arm64
goarm:
- 7
ignore:
- goos: darwin
goarch: arm
- goos: darwin
goarch: 386
checksum:
name_template: 'checksums.txt'
snapshot:
name_template: "{{ .Tag }}-rc"
changelog:
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'
- '^ci:'
release:
github:
owner: wwmoraes
name: anilistarr
prerelease: auto

68
.pre-commit-config.yaml Normal file
View file

@ -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

55
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,55 @@
{
"cSpell.words": [
"Anidb",
"anilist",
"anilistarr",
"Anisearch",
"bbolt",
"fribbs",
"genqlient",
"HTTPURL",
"Imdb",
"jmoiron",
"Kitsu",
"Livechart",
"logr",
"modernc",
"nolint",
"nosniff",
"opentelemetry",
"otel",
"otelhttp",
"otlp",
"otlpmetric",
"otlpmetricgrpc",
"otlpr",
"otlptrace",
"otlptracegrpc",
"OWASP",
"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"
}

77
CODE_OF_CONDUCT.md Normal file
View file

@ -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
<https://www.contributor-covenant.org/version/1/4/code-of-conduct.html>
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see
<https://www.contributor-covenant.org/faq>

146
CONTRIBUTING.md Normal file
View file

@ -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 <https://www.contributor-covenant.org/version/2/0/code_of_conduct.html>.
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
<https://www.contributor-covenant.org/faq>. Translations are available at
<https://www.contributor-covenant.org/translations>.

32
Dockerfile Normal file
View file

@ -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 <docker@artero.dev>"
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 <docker@artero.dev>"
CMD ["/usr/local/bin/handler"]
EXPOSE 8080
ARG GOLANG_VERSION
LABEL org.opencontainers.image.base.name="golang:${GOLANG_VERSION}-alpine"
ARG VERSION
LABEL org.opencontainers.image.version="${VERSION}"
COPY --from=build /src/bin/handler /usr/local/bin/handler
USER 20000:20000

21
LICENSE Normal file
View file

@ -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.

62
Makefile Normal file
View file

@ -0,0 +1,62 @@
-include .env
-include .env.local
export
CMD_SOURCE_FILES := $(shell find cmd -type f -name '*.go')
INTERNAL_SOURCE_FILES := $(shell find internal -type f -name '*.go')
SOURCE_FILES := $(CMD_SOURCE_FILES) $(INTERNAL_SOURCE_FILES)
.PHONY: build
build: generate
go build -o ./bin/ ./...
.PHONY: release
release:
goreleaser release --clean --skip-publish --skip-announce --snapshot
.PHONY: generate
generate:
go generate ./...
.PHONY: test
test:
go test -race -v ./...
.PHONY: coverage
coverage: coverage.out
@go tool cover -func=$<
%.out: $(SOURCE_FILES)
@go test -race -cover -coverprofile=$@ -v ./...
IMAGE ?= wwmoraes/anilistarr
# needs go install github.com/Khan/genqlient@latest
anilist:
@cd internal/drivers/anilist && genqlient
image: CREATED=$(shell date -u +"%Y-%m-%dT%TZ")
image: REVISION=$(shell git log -n 1 --format="%H")
image: VERSION=$(patsubst v%,%,$(shell git describe --tags 2> /dev/null || echo "0.1.0-rc.0"))
image:
docker build --load $(if ${TARGET},--target ${TARGET}) \
-t ${IMAGE} \
--build-arg VERSION=${VERSION} \
--label org.opencontainers.image.created=${CREATED} \
--label org.opencontainers.image.revision=${REVISION} \
--label org.opencontainers.image.documentation=https://github.com/${IMAGE}/blob/master/README.md \
--label org.opencontainers.image.source=https://github.com/${IMAGE} \
--label org.opencontainers.image.url=https://hub.docker.com/r/${IMAGE} \
.
@container-structure-test test -c container-structure-test.yaml -i wwmoraes/anilistarr
run:
@go run ./cmd/handler/...
redis-cli:
@redis-cli -p 16379
redis-proxy:
@flyctl redis proxy
get-user:
@curl -v "http://127.0.0.1:8080/user?name=wwmoraes"

83
README.md Normal file
View file

@ -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 <https://anilist.gitbook.io/anilist-apiv2-docs/>
- The community for their efforts to map IDs between services
- <https://github.com/Fribb/anime-lists>
- <https://github.com/Anime-Lists/anime-lists/>

47
SECURITY.md Normal file
View file

@ -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

28
TODO.md Normal file
View file

@ -0,0 +1,28 @@
# to-dos
## security
- [ ] OWASP ZAP scan <https://github.com/marketplace?type=actions&query=publisher%3Azaproxy+>
## code
### Telemetry
- <https://github.com/riandyrn/otelchi>
- <https://github.com/MrAlias/flow>
### store
- <https://github.com/objectbox/objectbox-go>
### providers
- <https://github.com/Anime-Lists/anime-lists>
## environment
### production
- <https://fly.io/docs/postgres/>
- <https://fly.io/docs/app-guides/custom-domains-with-fly/>
- <https://github.com/superfly/litefs-example/blob/main/fly-io-config/etc/litefs.yml>

142
anilistarr.puml Normal file
View file

@ -0,0 +1,142 @@
@startuml components
package entities {
struct Media {
AnilistID uint64
TvdbID uint64
Type string
}
struct SonarrCustomEntry {
TvdbID uint64
}
struct SonarrCustomList <<alias>> {
[]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 <<Frame>> {
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 <<Frame>> {
interface Cache {
GetString(ctx, key) string
SetString(ctx, key, value)
}
class CachedTracker
}
}
package drivers {
package providers <<Frame>> {
struct FribbsEntry
entity FribbsSource
entity LocalJSON
}
package stores <<Frame>> {
class Sql
}
package caches <<Frame>> {
class Redis
class Bolt
}
package trackers <<Frame>> {
class Anilist
}
}
package cmd {
package api {
class RestAPI <<net.http>>
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

37
cmd/handler/limiter.go Normal file
View file

@ -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)
})
}
}

116
cmd/handler/main.go Normal file
View file

@ -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), 1000)))
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))
}

141
cmd/handler/restapi.go Normal file
View file

@ -0,0 +1,141 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"github.com/wwmoraes/anilistarr/internal/entities"
"github.com/wwmoraes/anilistarr/internal/telemetry"
"github.com/wwmoraes/anilistarr/internal/usecases"
)
var (
usernameRegex = regexp.MustCompile("^[[:word:]]+$")
)
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 !usernameRegex.MatchString(username) {
err := fmt.Errorf("invalid username")
http.Error(w, err.Error(), http.StatusBadRequest)
span.RecordError(err)
return
}
customList, err := face.mapper.GenerateCustomList(r.Context(), username)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
span.RecordError(err)
return
}
data, err := json.Marshal(customList)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
span.RecordError(err)
return
}
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("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 {
http.Error(w, err.Error(), http.StatusBadGateway)
span.RecordError(err)
return
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
span.RecordError(err)
return
}
var entries []entities.Media
err = json.Unmarshal(data, &entries)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
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 {
http.Error(w, err.Error(), http.StatusInternalServerError)
span.RecordError(err)
return
}
w.Header().Set("X-Content-Type-Options", "nosniff")
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 !usernameRegex.MatchString(name) {
err := fmt.Errorf("invalid username")
http.Error(w, err.Error(), http.StatusBadRequest)
span.RecordError(err)
return
}
userId, err := face.mapper.GetUserID(r.Context(), name)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
span.RecordError(err)
return
}
w.Header().Add("X-Anilist-User-Name", name)
w.Header().Add("X-Anilist-User-Id", userId)
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, userId)
}

41
cmd/handler/wire.go Normal file
View file

@ -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
}

172
cmd/version/main.go Normal file
View file

@ -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
}

View file

@ -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

19
fly.toml Normal file
View file

@ -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

9
gen.go Normal file
View file

@ -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

66
go.mod Normal file
View file

@ -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
)

541
go.sum Normal file
View file

@ -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=

View file

@ -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)
}

View file

@ -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
}

View file

@ -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()
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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())
}
}
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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

View file

@ -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)
}

View file

@ -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
}
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,76 @@
package caches
import (
"context"
"fmt"
"github.com/wwmoraes/anilistarr/internal/adapters"
"github.com/wwmoraes/anilistarr/internal/telemetry"
"go.etcd.io/bbolt"
)
const bucketName = "anilistarr"
type BoltOptions = bbolt.Options
type boltCache struct {
*bbolt.DB
}
func NewBolt(path string, options *BoltOptions) (adapters.Cache, error) {
db, err := bbolt.Open(path, 0640, options)
if err != nil {
return nil, fmt.Errorf("failed to open bolt database: %w", err)
}
err = db.Update(func(tx *bbolt.Tx) error {
_, err := tx.CreateBucketIfNotExists([]byte(bucketName))
if err != nil {
return fmt.Errorf("failed to create/get bucket: %w", err)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to initialize bolt: %w", err)
}
return &boltCache{db}, nil
}
func (c *boltCache) GetString(ctx context.Context, key string) (string, error) {
_, span := telemetry.StartFunction(ctx)
defer span.End()
var value string
err := c.View(func(tx *bbolt.Tx) error {
bucket := tx.Bucket([]byte(bucketName))
if bucket == nil {
return fmt.Errorf("bucket %s does not exist", bucketName)
}
data := bucket.Get([]byte(key))
value = string(data)
return nil
})
if err != nil {
return "", span.Assert(fmt.Errorf("failed to get string: %w", err))
}
return value, span.Assert(nil)
}
func (c *boltCache) SetString(ctx context.Context, key, value string) error {
_, span := telemetry.StartFunction(ctx)
defer span.End()
return span.Assert(c.Update(func(tx *bbolt.Tx) error {
bucket := tx.Bucket([]byte(bucketName))
if bucket == nil {
return fmt.Errorf("bucket %s does not exist", bucketName)
}
return bucket.Put([]byte(key), []byte(value))
}))
}

View file

@ -0,0 +1,56 @@
package caches
import (
"context"
"fmt"
"github.com/redis/go-redis/extra/redisotel/v9"
"github.com/redis/go-redis/v9"
"github.com/wwmoraes/anilistarr/internal/adapters"
"github.com/wwmoraes/anilistarr/internal/telemetry"
)
type RedisOptions = redis.Options
func NewRedis(options *RedisOptions) (adapters.Cache, error) {
rdb := redis.NewClient(options)
err := redisotel.InstrumentTracing(rdb)
if err != nil {
return nil, fmt.Errorf("failed to instrument tracing for Redis: %w", err)
}
err = redisotel.InstrumentMetrics(rdb)
if err != nil {
return nil, fmt.Errorf("failed to instrument metrics for Redis: %w", err)
}
return &redisCache{rdb}, nil
}
type redisCache struct {
*redis.Client
}
func (c *redisCache) GetString(ctx context.Context, key string) (string, error) {
ctx, span := telemetry.StartFunction(ctx)
defer span.End()
res, err := c.Get(ctx, key).Result()
if err == redis.Nil {
return "", span.Assert(nil)
}
if err != nil {
return "", span.Assert(fmt.Errorf("failed to get string: %w", err))
}
return res, span.Assert(nil)
}
func (c *redisCache) SetString(ctx context.Context, key, value string) error {
ctx, span := telemetry.StartFunction(ctx)
defer span.End()
return span.Assert(c.Set(ctx, key, value, 0).Err())
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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,
}
}

View file

@ -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",
}

View file

@ -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
}

View file

@ -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()
}

View file

@ -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"`
}

View file

@ -0,0 +1,7 @@
package entities
type SonarrCustomList []SonarrCustomEntry
type SonarrCustomEntry struct {
TvdbID uint64
}

View file

@ -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))
}

View file

@ -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"
)

View file

@ -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...)
}
}

View file

@ -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...)
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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,
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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),
)
}

View file

@ -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
}

View file

@ -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))
}

View file

@ -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)
}

8
media.db.sql Normal file
View file

@ -0,0 +1,8 @@
CREATE TABLE mapping (
tvdb_id TEXT UNIQUE
PRIMARY KEY
NOT NULL,
anilist_id TEXT UNIQUE
)
WITHOUT ROWID,
STRICT;

17
sonar-project.properties Normal file
View file

@ -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

13
workspace.dsl Normal file
View file

@ -0,0 +1,13 @@
workspace anilistarr "list provider for *arr applications" {
model {
anilistarr = softwareSystem anilistarr "converts anime sources" {
}
}
views {
}
}