├── .dockerignore ├── .github ├── FUNDING.yml ├── images │ └── logo.png └── workflows │ └── release.yml ├── .gitignore ├── .goreleaser.yaml ├── Dockerfile ├── LICENSE.md ├── Makefile ├── README.md ├── ci.Dockerfile ├── cmd └── shinkro │ └── main.go ├── config.toml.template ├── docker-compose.yml ├── entrypoint.sh ├── go.mod ├── go.sum ├── internal ├── config │ └── config.go ├── database │ ├── database.go │ ├── media.go │ ├── media_test.go │ └── shinkrodb.go ├── domain │ ├── config.go │ ├── helpers.go │ ├── mapping.go │ ├── mapping_test.go │ ├── notification.go │ ├── update.go │ └── update_test.go ├── logger │ └── logger.go ├── malauth │ └── malauth.go ├── notification │ ├── discord.go │ └── notification.go ├── server │ ├── handlers.go │ ├── handlers_test.go │ ├── helpers.go │ ├── middleware.go │ ├── routes.go │ ├── server.go │ ├── templates.go │ └── testdata │ │ ├── mal-credentials.json.example │ │ └── token.json.example └── tautulli │ └── tautulli.go └── pkg └── plex ├── client.go ├── plex.go └── webhook.go /.dockerignore: -------------------------------------------------------------------------------- 1 | .gitignore 2 | .github 3 | .goreleaser.yaml 4 | Dockerfile 5 | ci.Dockerfile 6 | docker-compose.yml 7 | README.md 8 | bin -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [varoOP] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.github/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/varoOP/shinkro/bf40fd0d7f9d652392d0964fdd05e8e57486737d/.github/images/logo.png -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | - "develop" 8 | tags: 9 | - "v*" 10 | pull_request: 11 | 12 | env: 13 | REGISTRY: ghcr.io 14 | REGISTRY_IMAGE: ghcr.io/${{ github.repository }} 15 | GO_VERSION: "1.23" 16 | 17 | permissions: 18 | contents: write 19 | packages: write 20 | 21 | jobs: 22 | test: 23 | name: Test 24 | runs-on: ubuntu-latest 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v4 28 | with: 29 | fetch-depth: 0 30 | 31 | - name: Set up Go 32 | uses: actions/setup-go@v5 33 | with: 34 | go-version: ${{ env.GO_VERSION }} 35 | cache: true 36 | 37 | - name: Test 38 | run: go test -v ./... 39 | 40 | goreleaserbuild: 41 | name: Build Go binaries 42 | if: github.event_name == 'pull_request' 43 | runs-on: ubuntu-latest 44 | needs: test 45 | steps: 46 | - name: Checkout 47 | uses: actions/checkout@v4 48 | with: 49 | fetch-depth: 0 50 | 51 | - name: Set up Go 52 | uses: actions/setup-go@v5 53 | with: 54 | go-version: ${{ env.GO_VERSION }} 55 | cache: true 56 | 57 | - name: Run GoReleaser build 58 | uses: goreleaser/goreleaser-action@v6 59 | with: 60 | distribution: goreleaser 61 | version: latest 62 | args: release --clean --skip=validate --skip=publish --parallelism 5 63 | env: 64 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 65 | 66 | - name: Upload assets 67 | uses: actions/upload-artifact@v4 68 | with: 69 | name: shinkro 70 | path: | 71 | dist/*.tar.gz 72 | dist/*.zip 73 | 74 | goreleaser: 75 | name: Build and publish binaries 76 | if: startsWith(github.ref, 'refs/tags/') 77 | runs-on: ubuntu-latest 78 | needs: test 79 | steps: 80 | - name: Checkout 81 | uses: actions/checkout@v4 82 | with: 83 | fetch-depth: 0 84 | 85 | - name: Set up Go 86 | uses: actions/setup-go@v5 87 | with: 88 | go-version: ${{ env.GO_VERSION }} 89 | cache: true 90 | 91 | - name: Run GoReleaser build and publish tags 92 | uses: goreleaser/goreleaser-action@v6 93 | with: 94 | distribution: goreleaser 95 | version: latest 96 | args: release --clean 97 | env: 98 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 99 | 100 | - name: Upload assets 101 | uses: actions/upload-artifact@v4 102 | with: 103 | name: shinkro 104 | path: | 105 | dist/*.tar.gz 106 | dist/*.zip 107 | 108 | docker: 109 | name: Build and publish Docker images 110 | runs-on: ubuntu-latest 111 | needs: [test] 112 | steps: 113 | - name: Checkout 114 | uses: actions/checkout@v4 115 | with: 116 | fetch-depth: 0 117 | 118 | - name: Login to GitHub Container Registry 119 | uses: docker/login-action@v3 120 | with: 121 | registry: ghcr.io 122 | username: ${{ github.repository_owner }} 123 | password: ${{ secrets.GITHUB_TOKEN }} 124 | 125 | - name: Extract metadata 126 | id: meta 127 | uses: docker/metadata-action@v5 128 | with: 129 | images: ghcr.io/varoOP/shinkro 130 | tags: | 131 | type=semver,pattern={{raw}} 132 | type=ref,event=branch 133 | type=ref,event=pr 134 | 135 | - name: Set up QEMU 136 | uses: docker/setup-qemu-action@v3 137 | 138 | - name: Set up Docker Buildx 139 | uses: docker/setup-buildx-action@v3 140 | 141 | - name: Build and publish image 142 | id: docker_build 143 | uses: docker/build-push-action@v5 144 | with: 145 | context: . 146 | file: ./ci.Dockerfile 147 | platforms: linux/amd64,linux/arm/v7,linux/arm64/v8 148 | push: ${{ github.event.pull_request.head.repo.full_name == 'varoOP/shinkro' || github.event_name != 'pull_request' }} 149 | tags: ${{ steps.meta.outputs.tags }} 150 | labels: ${{ steps.meta.outputs.labels }} 151 | build-args: | 152 | BUILDTIME=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }} 153 | VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }} 154 | REVISION=${{ github.event.pull_request.head.sha }} 155 | 156 | - name: Image digest 157 | run: echo ${{ steps.docker_build.outputs.digest }} 158 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.db* 2 | *.json 3 | dist/ 4 | bin/ 5 | test/ -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | project_name: shinkro 2 | before: 3 | hooks: 4 | - go mod tidy 5 | 6 | builds: 7 | - main: ./cmd/shinkro/ 8 | binary: shinkro 9 | env: 10 | - CGO_ENABLED=0 11 | goos: 12 | - linux 13 | - windows 14 | - darwin 15 | - freebsd 16 | goarch: 17 | - amd64 18 | - arm 19 | - arm64 20 | goarm: 21 | - "6" 22 | ignore: 23 | - goos: windows 24 | goarch: arm 25 | - goos: windows 26 | goarch: arm64 27 | - goos: darwin 28 | goarch: arm 29 | - goos: freebsd 30 | goarch: arm 31 | - goos: freebsd 32 | goarch: arm64 33 | 34 | archives: 35 | - name_template: "{{ .ProjectName }}_v{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}" 36 | format_overrides: 37 | - goos: windows 38 | format: zip 39 | 40 | checksum: 41 | name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt" 42 | 43 | snapshot: 44 | name_template: "{{ incpatch .Version }}-next" 45 | 46 | changelog: 47 | sort: asc 48 | use: github 49 | filters: 50 | exclude: 51 | - Merge pull request 52 | - Merge remote-tracking branch 53 | - Merge branch 54 | groups: 55 | - title: "New Features" 56 | regexp: "^.*feat[(\\w)]*:+.*$" 57 | order: 0 58 | - title: "Bug fixes" 59 | regexp: "^.*fix[(\\w)]*:+.*$" 60 | order: 10 61 | - title: Other work 62 | order: 999 63 | 64 | release: 65 | prerelease: auto 66 | footer: | 67 | **Full Changelog**: https://github.com/varoOP/shinkro/compare/{{ .PreviousTag }}...{{ .Tag }} 68 | 69 | ## Docker images 70 | 71 | - `docker pull ghcr.io/varoOP/shinkro:{{ .Tag }}` 72 | 73 | ## What to do next? 74 | 75 | - Read the [documentation](https://docs.shinkro.com) 76 | - Join our [Discord server](https://discord.gg/ZkYdfNgbAT) 77 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build app 2 | FROM golang:1.23-alpine3.20 AS app-builder 3 | 4 | ARG VERSION=dev 5 | ARG REVISION=dev 6 | ARG BUILDTIME 7 | 8 | RUN apk add --no-cache git build-base tzdata 9 | 10 | ENV SERVICE=shinkro 11 | 12 | WORKDIR /src 13 | 14 | COPY go.mod go.sum ./ 15 | RUN go mod download 16 | 17 | COPY . ./ 18 | # ENV GOOS=linux 19 | # ENV CGO_ENABLED=0 20 | 21 | RUN go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${REVISION} -X main.date=${BUILDTIME}" -o bin/shinkro cmd/shinkro/main.go 22 | 23 | # Build runner 24 | FROM alpine:latest 25 | 26 | LABEL org.opencontainers.image.source="https://github.com/varoOP/shinkro" 27 | 28 | ENV HOME="/config" \ 29 | XDG_CONFIG_HOME="/config" \ 30 | XDG_DATA_HOME="/config" \ 31 | GOSU_VERSION=1.17 32 | 33 | # Install necessary utilities and dynamically fetch the correct gosu version 34 | RUN set -eux; \ 35 | apk --no-cache add ca-certificates curl tzdata jq gettext dpkg gnupg; \ 36 | \ 37 | # Dynamically detect architecture and download gosu 38 | dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \ 39 | curl -o /usr/local/bin/gosu -SL "https://github.com/tianon/gosu/releases/download/${GOSU_VERSION}/gosu-${dpkgArch}"; \ 40 | curl -o /usr/local/bin/gosu.asc -SL "https://github.com/tianon/gosu/releases/download/${GOSU_VERSION}/gosu-${dpkgArch}.asc"; \ 41 | \ 42 | # Verify gosu binary signature 43 | export GNUPGHOME="$(mktemp -d)"; \ 44 | gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \ 45 | gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \ 46 | gpgconf --kill all; \ 47 | rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \ 48 | \ 49 | # Final setup for gosu 50 | chmod +x /usr/local/bin/gosu; \ 51 | gosu --version; \ 52 | gosu nobody true 53 | 54 | WORKDIR /app 55 | 56 | VOLUME /config 57 | 58 | COPY --from=app-builder /src/bin/shinkro /usr/local/bin/ 59 | COPY --from=app-builder /src/config.toml.template /app/ 60 | COPY --from=app-builder /src/entrypoint.sh /app/ 61 | RUN chmod +x /app/entrypoint.sh 62 | 63 | EXPOSE 7011 64 | 65 | ENTRYPOINT ["/app/entrypoint.sh"] 66 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Rohit Vardam 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test 2 | .POSIX: 3 | .SUFFIXES: 4 | 5 | GIT_COMMIT := $(shell git rev-parse HEAD 2> /dev/null) 6 | GIT_TAG := $(shell git describe --abbrev=0 --tags) 7 | 8 | SERVICE = shinkro 9 | GO = go 10 | RM = rm 11 | GOFLAGS = "-X main.commit=$(GIT_COMMIT) -X main.version=$(GIT_TAG)" 12 | PREFIX = /usr/local 13 | BINDIR = bin 14 | 15 | all: clean build 16 | 17 | deps: 18 | go mod download 19 | 20 | test: 21 | go test ./... 22 | 23 | build: deps build/app 24 | 25 | build/app: 26 | go build -ldflags $(GOFLAGS) -o bin/$(SERVICE) cmd/$(SERVICE)/main.go 27 | 28 | build/docker: 29 | docker build -t shinkro:dev -f Dockerfile . --build-arg GIT_TAG=$(GIT_TAG) --build-arg GIT_COMMIT=$(GIT_COMMIT) 30 | 31 | clean: 32 | $(RM) -rf bin 33 | 34 | install: all 35 | echo $(DESTDIR)$(PREFIX)/$(BINDIR) 36 | mkdir -p $(DESTDIR)$(PREFIX)/$(BINDIR) 37 | cp -f bin/$(SERVICE) $(DESTDIR)$(PREFIX)/$(BINDIR) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | shinkro logo
3 | shinkro 4 |

5 | 6 |

An application to sync Plex watch status to myanimelist.

7 | 8 |

GitHub release (latest by date) GitHub all releases GitHub Workflow Status

9 | 10 | ## Documentation 11 | 12 | Installation guide and documentation can be found at https://docs.shinkro.com 13 | 14 | ## Key features 15 | 16 | - Support for multiple metadata agents, including default Plex agent, HAMA, and MyAnimeList.bundle. 17 | - Live updates to myanimelist as soon as you watch or rate in Plex. 18 | - Powerful anime-id mapping support, make custom maps or use the community mapping. 19 | - Built on Go making shinkro lightweight and perfect for supporting multiple platforms (Linux, FreeBSD, 20 | Windows, macOS) on different architectures. (e.g. x86, ARM) 21 | - Discord Notifications. 22 | - Base path / Subfolder (and subdomain) support for convenient reverse-proxy support. 23 | 24 | Available methods to use shinkro 25 | 26 | - Official Plex Webhook (Requires Plex Pass) (recommended) 27 | - Tautulli (Limitation: cannot sync ratings) 28 | 29 | ## What is shinkro? 30 | 31 | If you use both Plex and Myanimelist to watch and track your anime, you know how mundande and boring it is to have to update your myanimelist manually after watching an anime on your Plex server. 32 | 33 | shinkro enables you to sync your Plex ratings and watch status for anime to myanimelist.net. 34 | 35 | ## Installation 36 | 37 | Full installation guide and documentation can be found at https://docs.shinkro.com/installation 38 | 39 | ### Quickstart via Docker: 40 | 41 | Before your first launch, ensure you've set these environment variables: 42 | - SHINKRO_USERNAME 43 | - SHINKRO_PASSWORD 44 | - PLEX_USERNAME 45 | - ANIME_LIBRARIES 46 | 47 | After shinkro is initialized, configurations are primarily managed through the `config.toml` file. The environment variables above won't override the settings in this config file. 48 | 49 | ``` 50 | docker run \ 51 | --name shinkro \ 52 | -v /path/to/shinkro/config:/config \ 53 | -e TZ=US/Pacific \ 54 | -e SHINKRO_USERNAME=shinkro \ 55 | -e SHINKRO_PASSWORD=shinkro \ 56 | -e PLEX_USERNAME=shinkro \ 57 | -e ANIME_LIBRARIES=Library1,Library2,Library3 \ 58 | -p 7011:7011 \ 59 | --restart unless-stopped \ 60 | ghcr.io/varoop/shinkro:latest 61 | ``` 62 | 63 | ## Custom Mapping 64 | 65 | While shinkro maps most malids to tvdbids in it's database it only works well for season 1 of anime. Multiseason anime mapping is too complicated to automate at this point in time. For malid to tmdbids, a lot of movies are properly mapped in shinkro's database but not all of them. The ones which aren't are listed in [shinkro-mapping](https://github.com/varoOP/shinkro-mapping) ready for manual mapping. 66 | 67 | By default, shinkro will use the community mapping hosted in the [shinkro-mapping](https://github.com/varoOP/shinkro-mapping) repository. It is encouraged for the user base to use the community mapping - if it does not contain a mapping you need, consider contributing or creating an issue. 68 | 69 | Of course, you do have the option to specify your own custom maps. Simply place `tvdb-mal.yaml` for MAL-TVDB mappings or `tmdb-mal.yaml` for MAL-TMDB mappings in shinkro's configuration directory (where config.toml and shinkro.db files are located). shinkro will automatically detect the change and start using your custom mapping(s). The structure of both yaml files can be viewed at the [shinkro-mapping](https://github.com/varoOP/shinkro-mapping) repository. 70 | 71 | ## Community 72 | 73 | Come join us on [Discord](https://discord.gg/ZkYdfNgbAT)! 74 | 75 | ## License 76 | 77 | * [MIT](https://mit-license.org/) 78 | * Copyright 2022-2023 -------------------------------------------------------------------------------- /ci.Dockerfile: -------------------------------------------------------------------------------- 1 | # Build app 2 | FROM --platform=$BUILDPLATFORM golang:1.23-alpine3.20 AS app-builder 3 | 4 | RUN apk add --no-cache git tzdata 5 | 6 | ENV SERVICE=shinkro 7 | 8 | WORKDIR /src 9 | COPY . ./ 10 | 11 | RUN --mount=target=. \ 12 | go mod download 13 | 14 | ARG VERSION=dev 15 | ARG REVISION=dev 16 | ARG BUILDTIME 17 | ARG TARGETOS TARGETARCH 18 | 19 | RUN --mount=target=. \ 20 | GOOS="$TARGETOS" GOARCH="$TARGETARCH" go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${REVISION} -X main.date=${BUILDTIME}" -o /out/bin/shinkro cmd/shinkro/main.go 21 | 22 | # Build runner 23 | FROM alpine:latest 24 | 25 | LABEL org.opencontainers.image.source="https://github.com/varoOP/shinkro" 26 | 27 | ENV HOME="/config" \ 28 | XDG_CONFIG_HOME="/config" \ 29 | XDG_DATA_HOME="/config" \ 30 | GOSU_VERSION=1.17 31 | 32 | # Install necessary utilities and dynamically fetch the correct gosu version 33 | RUN set -eux; \ 34 | apk --no-cache add ca-certificates curl tzdata jq gettext dpkg gnupg; \ 35 | \ 36 | # Dynamically detect architecture and download gosu 37 | dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \ 38 | curl -o /usr/local/bin/gosu -SL "https://github.com/tianon/gosu/releases/download/${GOSU_VERSION}/gosu-${dpkgArch}"; \ 39 | curl -o /usr/local/bin/gosu.asc -SL "https://github.com/tianon/gosu/releases/download/${GOSU_VERSION}/gosu-${dpkgArch}.asc"; \ 40 | \ 41 | # Verify gosu binary signature 42 | export GNUPGHOME="$(mktemp -d)"; \ 43 | gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \ 44 | gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \ 45 | gpgconf --kill all; \ 46 | rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \ 47 | \ 48 | # Final setup for gosu 49 | chmod +x /usr/local/bin/gosu; \ 50 | gosu --version; \ 51 | gosu nobody true 52 | 53 | WORKDIR /app 54 | VOLUME /config 55 | 56 | COPY --from=app-builder /out/bin/shinkro /usr/local/bin/ 57 | COPY --from=app-builder /src/config.toml.template /app/ 58 | COPY --from=app-builder /src/entrypoint.sh /app/ 59 | RUN chmod +x /app/entrypoint.sh 60 | 61 | EXPOSE 7011 62 | ENTRYPOINT ["/app/entrypoint.sh"] 63 | -------------------------------------------------------------------------------- /cmd/shinkro/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "os" 8 | "os/signal" 9 | "path/filepath" 10 | "syscall" 11 | "time" 12 | 13 | "github.com/mitchellh/go-homedir" 14 | "github.com/robfig/cron/v3" 15 | "github.com/spf13/pflag" 16 | 17 | "github.com/varoOP/shinkro/internal/config" 18 | "github.com/varoOP/shinkro/internal/database" 19 | "github.com/varoOP/shinkro/internal/domain" 20 | "github.com/varoOP/shinkro/internal/logger" 21 | "github.com/varoOP/shinkro/internal/malauth" 22 | "github.com/varoOP/shinkro/internal/notification" 23 | "github.com/varoOP/shinkro/internal/server" 24 | ) 25 | 26 | const usage = `shinkro 27 | Sync your Anime watch status in Plex to myanimelist.net! 28 | Usage: 29 | shinkro --config Run shinkro 30 | shinkro genkey Generate an API key 31 | shinkro version Print version info 32 | shinkro help Show this help message 33 | ` 34 | 35 | func init() { 36 | pflag.Usage = func() { 37 | fmt.Fprint(flag.CommandLine.Output(), usage) 38 | } 39 | } 40 | 41 | var ( 42 | version = "dev" 43 | commit = "" 44 | date = "" 45 | ) 46 | 47 | func main() { 48 | var configPath string 49 | 50 | d, err := homedir.Dir() 51 | if err != nil { 52 | fmt.Fprint(flag.CommandLine.Output(), "FATAL: Unable to get home directory") 53 | os.Exit(1) 54 | } 55 | 56 | d = filepath.Join(d, ".config", "shinkro") 57 | pflag.StringVar(&configPath, "config", d, "path to configuration") 58 | pflag.Parse() 59 | configPath, err = homedir.Expand(configPath) 60 | if err != nil { 61 | fmt.Fprint(flag.CommandLine.Output(), "FATAL: Unable to expand configuration path") 62 | os.Exit(1) 63 | } 64 | 65 | switch cmd := pflag.Arg(0); cmd { 66 | case "": 67 | cfg := config.NewConfig(configPath).Config 68 | log := logger.NewLogger(configPath, cfg) 69 | db := database.NewDB(configPath, log) 70 | 71 | log.Info().Msg("Starting shinkro") 72 | log.Info().Msgf("Version: %s", version) 73 | log.Info().Msgf("Commit: %s", commit) 74 | log.Info().Msgf("Build date: %s", date) 75 | log.Info().Msgf("Base URL: %s", cfg.BaseUrl) 76 | log.Info().Msgf("Log-level: %s", cfg.LogLevel) 77 | 78 | err, mapLoaded := domain.ChecklocalMaps(cfg) 79 | if err != nil { 80 | log.Fatal().Err(err).Msg("Unable to load local custom mapping") 81 | } 82 | 83 | if mapLoaded { 84 | log.Info().Msg("Loaded local custom mapping") 85 | } 86 | 87 | db.CreateDB() 88 | db.MigrateDB() 89 | db.UpdateAnime() 90 | 91 | c := cron.New(cron.WithLocation(time.UTC)) 92 | c.AddFunc("0 1 * * MON", func() { 93 | db.UpdateAnime() 94 | malauth.NewOauth2Client(context.Background(), db) 95 | }) 96 | c.Start() 97 | 98 | n := notification.NewAppNotification(cfg.DiscordWebHookURL, log) 99 | go n.ListenforNotification() 100 | s := server.NewServer(cfg, n.Notification, db, log) 101 | go s.Start() 102 | 103 | sigchnl := make(chan os.Signal, 1) 104 | signal.Notify(sigchnl, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM) 105 | sig := <-sigchnl 106 | 107 | db.Close() 108 | log.Fatal().Msgf("Caught signal %v, Shutting Down", sig) 109 | 110 | case "genkey": 111 | fmt.Fprintln(os.Stdout, config.GenApikey()) 112 | 113 | case "version": 114 | fmt.Fprintln(flag.CommandLine.Output(), "Version:", version) 115 | fmt.Fprintln(flag.CommandLine.Output(), "Commit:", commit) 116 | fmt.Fprintln(flag.CommandLine.Output(), "Build Date:", date) 117 | 118 | default: 119 | pflag.Usage() 120 | if cmd != "help" { 121 | os.Exit(0) 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /config.toml.template: -------------------------------------------------------------------------------- 1 | ###Example config.toml for shinkro 2 | ###[shinkro] 3 | ### Username and Password is required for basic authentication. 4 | ###Discord webhook, and BaseUrl are optional. 5 | ###LogLevel can be set to any one of the following: "INFO", "ERROR", "DEBUG", "TRACE" 6 | ###LogxMaxSize is in MB. 7 | ###[plex] 8 | ###PlexUser and AnimeLibraries must be set to the correct values. 9 | ###AnimeLibraries is a list of your plex library names that contain anime - the ones shinkro will use to update your MAL account. 10 | ###Example: AnimeLibraries = ["Anime", "Anime Movies"] 11 | ###Url and Token are optional - only required if you have anime libraries that use the plex agents. 12 | 13 | [shinkro] 14 | Username = "${SHINKRO_USERNAME}" 15 | Password = "${SHINKRO_PASSWORD}" 16 | Host = "0.0.0.0" 17 | Port = 7011 18 | ApiKey = "${SHINKRO_APIKEY}" 19 | #BaseUrl = "/shinkro" 20 | #DiscordWebhookUrl = "" 21 | LogLevel = "INFO" 22 | LogMaxSize = 50 23 | LogMaxBackups = 3 24 | 25 | [plex] 26 | PlexUsername = "${PLEX_USERNAME}" 27 | AnimeLibraries = [${ANIME_LIBRARIES}] 28 | #Url = "http://127.0.0.1:32400" 29 | #Token = "" -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: "1.0" 3 | services: 4 | shinkro: 5 | image: shinkro:dev 6 | container_name: shinkro 7 | volumes: 8 | - ./config:/config 9 | ports: 10 | - "7011:7011" 11 | restart: unless-stopped 12 | environment: 13 | #Required for first start 14 | - SHINKRO_USERNAME=shinkro 15 | - SHINKRO_PASSWORD=shinkro 16 | - PLEX_USERNAME=shinkro 17 | - ANIME_LIBRARIES=Library1,Library2,Library3 18 | #Optional 19 | #- PUID=1000 20 | #- PGID=1000 21 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Function to check if a group with the given GID exists and return its name 4 | get_group_name_by_gid() { 5 | getent group "$1" | cut -d: -f1 6 | } 7 | 8 | # Function to check if a user with the given UID exists and return its name 9 | get_user_name_by_uid() { 10 | getent passwd "$1" | cut -d: -f1 11 | } 12 | 13 | # Attempt to create the group if PGID is provided and does not already exist 14 | if [ -n "$PGID" ]; then 15 | GROUP_NAME=$(get_group_name_by_gid "$PGID") 16 | if [ -z "$GROUP_NAME" ]; then 17 | GROUP_NAME="shinkrogrp" 18 | addgroup -g "$PGID" "$GROUP_NAME" 19 | fi 20 | else 21 | GROUP_NAME="shinkrogrp" 22 | # Optionally create a default group if PGID is not set 23 | fi 24 | 25 | # Attempt to create the user if PUID is provided and does not already exist 26 | if [ -n "$PUID" ]; then 27 | USER_NAME=$(get_user_name_by_uid "$PUID") 28 | if [ -z "$USER_NAME" ]; then 29 | USER_NAME="shinkrousr" 30 | adduser -D -u "$PUID" -G "$GROUP_NAME" "$USER_NAME" 31 | fi 32 | else 33 | USER_NAME="shinkrousr" 34 | # Optionally create a default user if PUID is not set 35 | fi 36 | 37 | # Transform and export the ANIME_LIBRARIES environment variable 38 | if [ -n "$ANIME_LIBRARIES" ]; then 39 | ANIME_LIBRARIES=$(echo "$ANIME_LIBRARIES" | sed 's/,/","/g') 40 | export ANIME_LIBRARIES="\"$ANIME_LIBRARIES\"" 41 | fi 42 | 43 | # Generate and export the SHINKRO_APIKEY 44 | SHINKRO_APIKEY=$(shinkro genkey) 45 | export SHINKRO_APIKEY="$SHINKRO_APIKEY" 46 | 47 | # Only generate config.toml from the template if it doesn't already exist 48 | if [ ! -f /config/config.toml ]; then 49 | if [ -n "$PUID" ] && [ -n "$PGID" ]; then 50 | gosu "$USER_NAME" envsubst /config/config.toml 51 | else 52 | envsubst /config/config.toml 53 | fi 54 | fi 55 | 56 | # Change ownership of /config, considering existing or newly created user/group 57 | chown -R "$PUID":"$PGID" /config 58 | 59 | # Execute the main process 60 | if [ -n "$PUID" ] && [ -n "$PGID" ]; then 61 | exec gosu "$USER_NAME" /usr/local/bin/shinkro --config /config "$@" 62 | else 63 | exec /usr/local/bin/shinkro --config /config "$@" 64 | fi 65 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/varoOP/shinkro 2 | 3 | go 1.23 4 | 5 | require github.com/DATA-DOG/go-sqlmock v1.5.2 6 | 7 | require ( 8 | github.com/go-chi/chi/v5 v5.1.0 9 | github.com/knadh/koanf v1.5.0 10 | github.com/mitchellh/go-homedir v1.1.0 11 | github.com/nstratos/go-myanimelist v0.9.5 12 | github.com/pkg/errors v0.9.1 13 | github.com/robfig/cron/v3 v3.0.1 14 | github.com/rs/zerolog v1.33.0 15 | github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 16 | github.com/spf13/pflag v1.0.5 17 | github.com/stretchr/testify v1.9.0 18 | golang.org/x/oauth2 v0.23.0 19 | golang.org/x/text v0.19.0 20 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 21 | gopkg.in/yaml.v3 v3.0.1 22 | modernc.org/sqlite v1.33.1 23 | ) 24 | 25 | require ( 26 | github.com/davecgh/go-spew v1.1.1 // indirect 27 | github.com/dustin/go-humanize v1.0.1 // indirect 28 | github.com/fsnotify/fsnotify v1.8.0 // indirect 29 | github.com/google/uuid v1.6.0 // indirect 30 | github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 31 | github.com/mattn/go-colorable v0.1.13 // indirect 32 | github.com/mattn/go-isatty v0.0.20 // indirect 33 | github.com/mitchellh/copystructure v1.2.0 // indirect 34 | github.com/mitchellh/mapstructure v1.5.0 // indirect 35 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 36 | github.com/ncruces/go-strftime v0.1.9 // indirect 37 | github.com/pelletier/go-toml v1.9.5 // indirect 38 | github.com/pmezard/go-difflib v1.0.0 // indirect 39 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 40 | github.com/rs/xid v1.6.0 // indirect 41 | golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect 42 | golang.org/x/sys v0.26.0 // indirect 43 | modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852 // indirect 44 | modernc.org/libc v1.61.0 // indirect 45 | modernc.org/mathutil v1.6.0 // indirect 46 | modernc.org/memory v1.8.0 // indirect 47 | modernc.org/strutil v1.2.0 // indirect 48 | modernc.org/token v1.1.0 // indirect 49 | ) 50 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 3 | cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= 4 | cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= 5 | cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= 6 | cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= 7 | cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= 8 | cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= 9 | cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= 10 | cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= 11 | cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= 12 | cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= 13 | cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= 14 | cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= 15 | cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= 16 | cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= 17 | cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= 18 | cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= 19 | cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= 20 | cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= 21 | cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= 22 | cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= 23 | cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= 24 | cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= 25 | cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= 26 | cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= 27 | cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= 28 | cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= 29 | cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= 30 | cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= 31 | cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= 32 | cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= 33 | dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= 34 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 35 | github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= 36 | github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= 37 | github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= 38 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 39 | github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 40 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 41 | github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 42 | github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= 43 | github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= 44 | github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= 45 | github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= 46 | github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= 47 | github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= 48 | github.com/aws/aws-sdk-go-v2 v1.9.2/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4= 49 | github.com/aws/aws-sdk-go-v2/config v1.8.3/go.mod h1:4AEiLtAb8kLs7vgw2ZV3p2VZ1+hBavOc84hqxVNpCyw= 50 | github.com/aws/aws-sdk-go-v2/credentials v1.4.3/go.mod h1:FNNC6nQZQUuyhq5aE5c7ata8o9e4ECGmS4lAXC7o1mQ= 51 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.6.0/go.mod h1:gqlclDEZp4aqJOancXK6TN24aKhT0W0Ae9MHk3wzTMM= 52 | github.com/aws/aws-sdk-go-v2/internal/ini v1.2.4/go.mod h1:ZcBrrI3zBKlhGFNYWvju0I3TR93I7YIgAfy82Fh4lcQ= 53 | github.com/aws/aws-sdk-go-v2/service/appconfig v1.4.2/go.mod h1:FZ3HkCe+b10uFZZkFdvf98LHW21k49W8o8J366lqVKY= 54 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.3.2/go.mod h1:72HRZDLMtmVQiLG2tLfQcaWLCssELvGl+Zf2WVxMmR8= 55 | github.com/aws/aws-sdk-go-v2/service/sso v1.4.2/go.mod h1:NBvT9R1MEF+Ud6ApJKM0G+IkPchKS7p7c2YPKwHmBOk= 56 | github.com/aws/aws-sdk-go-v2/service/sts v1.7.2/go.mod h1:8EzeIqfWt2wWT4rJVu3f21TfrhJ8AEMzVybRNSb/b4g= 57 | github.com/aws/smithy-go v1.8.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E= 58 | github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= 59 | github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= 60 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 61 | github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= 62 | github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= 63 | github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 64 | github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 65 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 66 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 67 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 68 | github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= 69 | github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= 70 | github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= 71 | github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 72 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 73 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 74 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 75 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 76 | github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= 77 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 78 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 79 | github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 80 | github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 81 | github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= 82 | github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= 83 | github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= 84 | github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= 85 | github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= 86 | github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= 87 | github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 88 | github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= 89 | github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 90 | github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= 91 | github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= 92 | github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 93 | github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= 94 | github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= 95 | github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= 96 | github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= 97 | github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= 98 | github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= 99 | github.com/go-ldap/ldap v3.0.2+incompatible/go.mod h1:qfd9rJvER9Q0/D/Sqn1DfHRoBp40uXYvFoEVrNEPqRc= 100 | github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= 101 | github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= 102 | github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= 103 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 104 | github.com/go-test/deep v1.0.2-0.20181118220953-042da051cf31/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= 105 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 106 | github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 107 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 108 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 109 | github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 110 | github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 111 | github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 112 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 113 | github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 114 | github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= 115 | github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= 116 | github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= 117 | github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= 118 | github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= 119 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 120 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 121 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 122 | github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= 123 | github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= 124 | github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= 125 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 126 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 127 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 128 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 129 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 130 | github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= 131 | github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 132 | github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 133 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 134 | github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 135 | github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 136 | github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 137 | github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 138 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 139 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 140 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 141 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 142 | github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 143 | github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 144 | github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 145 | github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 146 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 147 | github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 148 | github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= 149 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 150 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 151 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 152 | github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= 153 | github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= 154 | github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 155 | github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 156 | github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= 157 | github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= 158 | github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= 159 | github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= 160 | github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= 161 | github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= 162 | github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= 163 | github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 164 | github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 165 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 166 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 167 | github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= 168 | github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= 169 | github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= 170 | github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= 171 | github.com/hashicorp/consul/api v1.13.0/go.mod h1:ZlVrynguJKcYr54zGaDbaL3fOvKC9m72FhPvA8T35KQ= 172 | github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms= 173 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 174 | github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= 175 | github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= 176 | github.com/hashicorp/go-hclog v0.0.0-20180709165350-ff2cf002a8dd/go.mod h1:9bjs9uLqI8l75knNv3lV1kA55veR+WUPSiKIWcQHudI= 177 | github.com/hashicorp/go-hclog v0.8.0/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= 178 | github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= 179 | github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= 180 | github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= 181 | github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= 182 | github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= 183 | github.com/hashicorp/go-plugin v1.0.1/go.mod h1:++UyYGoz3o5w9ZzAdZxtQKrWWP+iqPBn3cQptSMzBuY= 184 | github.com/hashicorp/go-retryablehttp v0.5.4/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= 185 | github.com/hashicorp/go-rootcerts v1.0.1/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= 186 | github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= 187 | github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= 188 | github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= 189 | github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= 190 | github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= 191 | github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= 192 | github.com/hashicorp/go-version v1.1.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= 193 | github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 194 | github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 195 | github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 196 | github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 197 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 198 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 199 | github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= 200 | github.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc= 201 | github.com/hashicorp/memberlist v0.3.0/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= 202 | github.com/hashicorp/serf v0.9.6/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpTwn9UV4= 203 | github.com/hashicorp/vault/api v1.0.4/go.mod h1:gDcqh3WGcR1cpF5AJz/B1UFheUEneMoIospckxBxk6Q= 204 | github.com/hashicorp/vault/sdk v0.1.13/go.mod h1:B+hVj7TpuQY1Y/GPbCpffmgd+tSEwvhkWnjtSYCaS2M= 205 | github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= 206 | github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= 207 | github.com/hjson/hjson-go/v4 v4.0.0 h1:wlm6IYYqHjOdXH1gHev4VoXCaW20HdQAGCxdOEEg2cs= 208 | github.com/hjson/hjson-go/v4 v4.0.0/go.mod h1:KaYt3bTw3zhBjYqnXkYywcYctk0A2nxeEFTse3rH13E= 209 | github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= 210 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 211 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 212 | github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= 213 | github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= 214 | github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= 215 | github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= 216 | github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= 217 | github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= 218 | github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= 219 | github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= 220 | github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= 221 | github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= 222 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 223 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 224 | github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= 225 | github.com/knadh/koanf v1.5.0 h1:q2TSd/3Pyc/5yP9ldIrSdIz26MCcyNQzW0pEAugLPNs= 226 | github.com/knadh/koanf v1.5.0/go.mod h1:Hgyjp4y8v44hpZtPzs7JZfRAW5AhN7KfZcwv1RYggDs= 227 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 228 | github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 229 | github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= 230 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 231 | github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs= 232 | github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 233 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 234 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 235 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 236 | github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= 237 | github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= 238 | github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= 239 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 240 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 241 | github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= 242 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 243 | github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= 244 | github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= 245 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 246 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 247 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 248 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 249 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 250 | github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= 251 | github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= 252 | github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= 253 | github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= 254 | github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= 255 | github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= 256 | github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= 257 | github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= 258 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 259 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 260 | github.com/mitchellh/go-testing-interface v0.0.0-20171004221916-a61a99592b77/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= 261 | github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= 262 | github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= 263 | github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= 264 | github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= 265 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 266 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 267 | github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= 268 | github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= 269 | github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= 270 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 271 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 272 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 273 | github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 274 | github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= 275 | github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= 276 | github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= 277 | github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 278 | github.com/npillmayer/nestext v0.1.3/go.mod h1:h2lrijH8jpicr25dFY+oAJLyzlya6jhnuG+zWp9L0Uk= 279 | github.com/nstratos/go-myanimelist v0.9.5 h1:veOQTuCpGmcWj88T83yxeILeOVUWXvq0TpUlUECAvoU= 280 | github.com/nstratos/go-myanimelist v0.9.5/go.mod h1:8R947UE3+5W0B3TrUgRIy+h2YVxG/69fLpgq94J91ns= 281 | github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= 282 | github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= 283 | github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= 284 | github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE= 285 | github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= 286 | github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= 287 | github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= 288 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 289 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 290 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 291 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 292 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 293 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 294 | github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= 295 | github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= 296 | github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= 297 | github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= 298 | github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= 299 | github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= 300 | github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= 301 | github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 302 | github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 303 | github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 304 | github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= 305 | github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= 306 | github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= 307 | github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= 308 | github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= 309 | github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= 310 | github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= 311 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 312 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 313 | github.com/rhnvrm/simples3 v0.6.1/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA= 314 | github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= 315 | github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= 316 | github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= 317 | github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 318 | github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= 319 | github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= 320 | github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= 321 | github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= 322 | github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= 323 | github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= 324 | github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= 325 | github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= 326 | github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= 327 | github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= 328 | github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= 329 | github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= 330 | github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= 331 | github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= 332 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 333 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 334 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 335 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 336 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 337 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 338 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 339 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 340 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 341 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 342 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 343 | github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 344 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 345 | github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 346 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 347 | github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 348 | go.etcd.io/etcd/api/v3 v3.5.4/go.mod h1:5GB2vv4A4AOn3yk7MftYGHkUfGtDHnEraIjym4dYz5A= 349 | go.etcd.io/etcd/client/pkg/v3 v3.5.4/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= 350 | go.etcd.io/etcd/client/v3 v3.5.4/go.mod h1:ZaRkVgBZC+L+dLCjTcF1hRXpgZXQPOvnA/Ak/gq3kiY= 351 | go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= 352 | go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= 353 | go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= 354 | go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= 355 | go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= 356 | go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 357 | go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= 358 | go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= 359 | golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 360 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 361 | golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 362 | golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 363 | golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= 364 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 365 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 366 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 367 | golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 368 | golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= 369 | golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= 370 | golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= 371 | golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= 372 | golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= 373 | golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= 374 | golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= 375 | golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= 376 | golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY= 377 | golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8= 378 | golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= 379 | golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 380 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 381 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 382 | golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 383 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 384 | golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 385 | golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 386 | golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 387 | golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= 388 | golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= 389 | golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= 390 | golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= 391 | golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= 392 | golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= 393 | golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 394 | golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= 395 | golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 396 | golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 397 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 398 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 399 | golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 400 | golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= 401 | golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= 402 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 403 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 404 | golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 405 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 406 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 407 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 408 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 409 | golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 410 | golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 411 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 412 | golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 413 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 414 | golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 415 | golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 416 | golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 417 | golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 418 | golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 419 | golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 420 | golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 421 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 422 | golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 423 | golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 424 | golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 425 | golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 426 | golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 427 | golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 428 | golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= 429 | golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= 430 | golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= 431 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 432 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 433 | golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 434 | golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= 435 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 436 | golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 437 | golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 438 | golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 439 | golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 440 | golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= 441 | golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= 442 | golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 443 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 444 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 445 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 446 | golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 447 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 448 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 449 | golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 450 | golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 451 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 452 | golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 453 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 454 | golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= 455 | golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 456 | golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 457 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 458 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 459 | golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 460 | golang.org/x/sys v0.0.0-20190129075346-302c3dd5f1cc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 461 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 462 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 463 | golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 464 | golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 465 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 466 | golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 467 | golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 468 | golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 469 | golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 470 | golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 471 | golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 472 | golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 473 | golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 474 | golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 475 | golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 476 | golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 477 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 478 | golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 479 | golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 480 | golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 481 | golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 482 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 483 | golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 484 | golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 485 | golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 486 | golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 487 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 488 | golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 489 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 490 | golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 491 | golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 492 | golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 493 | golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 494 | golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 495 | golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 496 | golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 497 | golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 498 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 499 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 500 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 501 | golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 502 | golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 503 | golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 504 | golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 505 | golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 506 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 507 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 508 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 509 | golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= 510 | golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 511 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 512 | golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 513 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 514 | golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 515 | golang.org/x/text v0.3.1-0.20181227161524-e6919f6577db/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 516 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 517 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 518 | golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 519 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 520 | golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= 521 | golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= 522 | golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 523 | golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 524 | golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 525 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 526 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 527 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 528 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 529 | golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 530 | golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 531 | golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 532 | golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 533 | golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 534 | golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 535 | golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 536 | golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 537 | golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 538 | golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 539 | golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 540 | golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 541 | golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 542 | golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 543 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 544 | golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 545 | golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 546 | golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 547 | golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 548 | golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 549 | golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 550 | golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 551 | golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 552 | golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 553 | golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 554 | golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 555 | golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 556 | golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= 557 | golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= 558 | golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= 559 | golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 560 | golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 561 | golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 562 | golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 563 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 564 | golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= 565 | golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= 566 | golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= 567 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 568 | golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 569 | golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= 570 | golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= 571 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 572 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 573 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 574 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 575 | google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= 576 | google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= 577 | google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= 578 | google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= 579 | google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= 580 | google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= 581 | google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= 582 | google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= 583 | google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= 584 | google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= 585 | google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= 586 | google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= 587 | google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= 588 | google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= 589 | google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= 590 | google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= 591 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 592 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 593 | google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 594 | google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= 595 | google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 596 | google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 597 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 598 | google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 599 | google.golang.org/genproto v0.0.0-20190404172233-64821d5d2107/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 600 | google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 601 | google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 602 | google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 603 | google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 604 | google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 605 | google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= 606 | google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 607 | google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 608 | google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 609 | google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 610 | google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 611 | google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 612 | google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= 613 | google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 614 | google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 615 | google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 616 | google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 617 | google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 618 | google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 619 | google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 620 | google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 621 | google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 622 | google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= 623 | google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= 624 | google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= 625 | google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= 626 | google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= 627 | google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= 628 | google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= 629 | google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= 630 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 631 | google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= 632 | google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= 633 | google.golang.org/grpc v1.22.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= 634 | google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= 635 | google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= 636 | google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 637 | google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 638 | google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 639 | google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= 640 | google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= 641 | google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= 642 | google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= 643 | google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= 644 | google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= 645 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 646 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 647 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 648 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 649 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 650 | google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 651 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 652 | google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 653 | google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= 654 | google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= 655 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 656 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 657 | gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= 658 | gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw= 659 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 660 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 661 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 662 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 663 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 664 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= 665 | gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= 666 | gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= 667 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 668 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 669 | gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 670 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 671 | gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 672 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 673 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 674 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 675 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 676 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 677 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 678 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 679 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 680 | honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 681 | honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 682 | honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 683 | honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 684 | honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= 685 | honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= 686 | modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= 687 | modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= 688 | modernc.org/ccgo/v4 v4.21.0 h1:kKPI3dF7RIag8YcToh5ZwDcVMIv6VGa0ED5cvh0LMW4= 689 | modernc.org/ccgo/v4 v4.21.0/go.mod h1:h6kt6H/A2+ew/3MW/p6KEoQmrq/i3pr0J/SiwiaF/g0= 690 | modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= 691 | modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= 692 | modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M= 693 | modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= 694 | modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852 h1:IYXPPTTjjoSHvUClZIYexDiO7g+4x+XveKT4gCIAwiY= 695 | modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= 696 | modernc.org/libc v1.61.0 h1:eGFcvWpqlnoGwzZeZe3PWJkkKbM/3SUGyk1DVZQ0TpE= 697 | modernc.org/libc v1.61.0/go.mod h1:DvxVX89wtGTu+r72MLGhygpfi3aUGgZRdAYGCAVVud0= 698 | modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= 699 | modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= 700 | modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= 701 | modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= 702 | modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= 703 | modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= 704 | modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= 705 | modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= 706 | modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM= 707 | modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= 708 | modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= 709 | modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= 710 | modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= 711 | modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= 712 | rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= 713 | rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= 714 | rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= 715 | sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= 716 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "crypto/rand" 5 | "log" 6 | "math/big" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | "time" 11 | 12 | "github.com/knadh/koanf" 13 | "github.com/knadh/koanf/parsers/toml" 14 | "github.com/knadh/koanf/providers/file" 15 | "github.com/pkg/errors" 16 | "github.com/rs/zerolog" 17 | "github.com/varoOP/shinkro/internal/domain" 18 | ) 19 | 20 | type AppConfig struct { 21 | log zerolog.Logger 22 | Config *domain.Config 23 | } 24 | 25 | func NewConfig(dir string) *AppConfig { 26 | if dir == "" { 27 | log.Println("path to configuration not set") 28 | log.Fatal("Run: shinkro help, for the help message.") 29 | } 30 | 31 | c := &AppConfig{ 32 | log: zerolog.New( 33 | zerolog.ConsoleWriter{ 34 | Out: os.Stdout, 35 | TimeFormat: time.DateTime, 36 | }).With().Timestamp().Logger(), 37 | } 38 | 39 | c.defaultConfig(dir) 40 | err := c.parseConfig(dir) 41 | if err != nil { 42 | c.log.Fatal().Err(err).Msg("unable to parse config.toml") 43 | } 44 | 45 | c.checkConfig(dir) 46 | return c 47 | } 48 | 49 | func (c *AppConfig) defaultConfig(dir string) { 50 | c.Config = &domain.Config{ 51 | ConfigPath: filepath.Join(dir, "config.toml"), 52 | Host: "127.0.0.1", 53 | Port: 7011, 54 | PlexUser: "", 55 | PlexUrl: "", 56 | PlexToken: "", 57 | AnimeLibraries: []string{}, 58 | ApiKey: GenApikey(), 59 | BaseUrl: "/", 60 | CustomMapTVDB: false, 61 | CustomMapTVDBPath: filepath.Join(dir, "tvdb-mal.yaml"), 62 | CustomMapTMDB: false, 63 | CustomMapTMDBPath: filepath.Join(dir, "tmdb-mal.yaml"), 64 | TMDBMalMap: nil, 65 | TVDBMalMap: nil, 66 | DiscordWebHookURL: "", 67 | LogLevel: "INFO", 68 | LogMaxSize: 50, 69 | LogMaxBackups: 3, 70 | } 71 | } 72 | 73 | func (c *AppConfig) createConfig(dir string) error { 74 | var config = `###Example config.toml for shinkro 75 | ###[shinkro] 76 | ### Username and Password is required for basic authentication. 77 | ###Discord webhook, and BaseUrl are optional. 78 | ###LogLevel can be set to any one of the following: "INFO", "ERROR", "DEBUG", "TRACE" 79 | ###LogxMaxSize is in MB. 80 | ###[plex] 81 | ###PlexUser and AnimeLibraries must be set to the correct values. 82 | ###AnimeLibraries is a list of your plex library names that contain anime - the ones shinkro will use to update your MAL account. 83 | ###Example: AnimeLibraries = ["Anime", "Anime Movies"] 84 | ###Url and Token are optional - only required if you have anime libraries that use the plex agents. 85 | 86 | [shinkro] 87 | Username = "" 88 | Password = "" 89 | Host = "127.0.0.1" 90 | Port = 7011 91 | ApiKey = "` + c.Config.ApiKey + `" 92 | #BaseUrl = "/shinkro" 93 | #DiscordWebhookUrl = "" 94 | LogLevel = "INFO" 95 | LogMaxSize = 50 96 | LogMaxBackups = 3 97 | 98 | [plex] 99 | PlexUsername = "" 100 | AnimeLibraries = [] 101 | #Url = "http://127.0.0.1:32400" 102 | #Token = "" 103 | ` 104 | 105 | err := os.MkdirAll(dir, os.ModePerm) 106 | if err != nil { 107 | return err 108 | } 109 | 110 | f, err := os.Create(c.Config.ConfigPath) 111 | if err != nil { 112 | return err 113 | } 114 | defer f.Close() 115 | 116 | _, err = f.WriteString(config) 117 | if err != nil { 118 | return err 119 | } 120 | 121 | return nil 122 | } 123 | 124 | func (c *AppConfig) checkConfig(dir string) { 125 | if c.Config.ApiKey == "" { 126 | c.log.Fatal().Msgf("shinkro.ApiKey not set in %v/config.toml", dir) 127 | } 128 | 129 | if c.Config.Username == "" { 130 | c.log.Fatal().Msgf("shinkro.Username not set in %v/config.toml", dir) 131 | } 132 | 133 | if c.Config.Password == "" { 134 | c.log.Fatal().Msgf("shinkro.Password not set in %v/config.toml", dir) 135 | } 136 | 137 | if c.Config.PlexUser == "" { 138 | c.log.Fatal().Msgf("plex.PlexUsername not set in %v/config.toml", dir) 139 | } 140 | 141 | if len(c.Config.AnimeLibraries) < 1 { 142 | c.log.Fatal().Msgf("plex.AnimeLibraries not set in %v/config.toml", dir) 143 | } 144 | 145 | for i, v := range c.Config.AnimeLibraries { 146 | c.Config.AnimeLibraries[i] = strings.TrimSpace(v) 147 | } 148 | } 149 | 150 | func (c *AppConfig) parseConfig(dir string) error { 151 | if _, err := os.Stat(c.Config.ConfigPath); err != nil { 152 | err = c.createConfig(dir) 153 | if err != nil { 154 | c.log.Fatal().Msg("unable to write shinkro configuration file") 155 | } 156 | 157 | c.log.Fatal().Err(errors.New("shinkro configuration file not found")).Msgf("No config.toml found, example config.toml created at %v. Edit and run shinkro again", c.Config.ConfigPath) 158 | } 159 | 160 | k := koanf.New(".") 161 | if err := k.Load(file.Provider(c.Config.ConfigPath), toml.Parser()); err != nil { 162 | return err 163 | } 164 | 165 | err := k.Unmarshal("shinkro", c.Config) 166 | if err != nil { 167 | return err 168 | } 169 | 170 | err = k.Unmarshal("plex", c.Config) 171 | if err != nil { 172 | return err 173 | } 174 | 175 | c.Config.LocalMapsExist() 176 | return nil 177 | } 178 | 179 | func GenApikey() string { 180 | allowed := []rune("abcdefghijklmnopqrstuvwxyz0123456789") 181 | b := make([]rune, 32) 182 | for i := range b { 183 | n, err := rand.Int(rand.Reader, big.NewInt(int64(len(allowed)))) 184 | if err != nil { 185 | log.Fatal(err) 186 | } 187 | 188 | b[i] = allowed[n.Int64()] 189 | } 190 | 191 | return string(b) 192 | } 193 | -------------------------------------------------------------------------------- /internal/database/database.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "path/filepath" 7 | 8 | "github.com/pkg/errors" 9 | 10 | "github.com/rs/zerolog" 11 | _ "modernc.org/sqlite" 12 | ) 13 | 14 | type DB struct { 15 | Handler *sql.DB 16 | log zerolog.Logger 17 | } 18 | 19 | func NewDB(dir string, log *zerolog.Logger) *DB { 20 | db := &DB{ 21 | log: log.With().Str("module", "database").Logger(), 22 | } 23 | 24 | var ( 25 | err error 26 | DSN = filepath.Join(dir, "shinkro.db") + "?_pragma=busy_timeout%3d1000" 27 | ) 28 | 29 | db.Handler, err = sql.Open("sqlite", DSN) 30 | if err != nil { 31 | db.log.Fatal().Err(err).Msg("unable to connect to database") 32 | } 33 | 34 | if _, err = db.Handler.Exec(`PRAGMA journal_mode = wal;`); err != nil { 35 | db.log.Fatal().Err(err).Msg("unable to enable WAL mode") 36 | } 37 | 38 | return db 39 | } 40 | 41 | func (db *DB) MigrateDB() { 42 | const migrations = `CREATE TABLE IF NOT EXISTS malauth_temp ( 43 | id INTEGER PRIMARY KEY, 44 | client_id TEXT, 45 | client_secret TEXT, 46 | access_token TEXT 47 | ); 48 | 49 | INSERT INTO malauth_temp (id, client_id, client_secret, access_token) 50 | SELECT 1, client_id, client_secret, access_token FROM malauth; 51 | 52 | DROP TABLE malauth; 53 | 54 | ALTER TABLE malauth_temp RENAME TO malauth;` 55 | 56 | _, err := db.Handler.Exec(migrations) 57 | db.check(err) 58 | } 59 | 60 | func (db *DB) CreateDB() { 61 | const scheme = `CREATE TABLE IF NOT EXISTS anime ( 62 | mal_id INTEGER PRIMARY KEY, 63 | title TEXT, 64 | en_title TEXT, 65 | anidb_id INTEGER, 66 | tvdb_id INTEGER, 67 | tmdb_id INTEGER, 68 | type TEXT, 69 | releaseDate TEXT 70 | ); 71 | CREATE TABLE IF NOT EXISTS malauth ( 72 | id INTEGER PRIMARY KEY, 73 | client_id TEXT, 74 | client_secret TEXT, 75 | access_token TEXT 76 | );` 77 | 78 | _, err := db.Handler.Exec(scheme) 79 | db.check(err) 80 | } 81 | 82 | func (db *DB) UpdateAnime() { 83 | 84 | db.log.Trace().Msg("Updating anime in database") 85 | a, err := getAnime() 86 | if err != nil { 87 | db.log.Error().Err(err).Msg("Unable to update anime in database") 88 | return 89 | } 90 | 91 | const addAnime = `INSERT OR REPLACE INTO anime ( 92 | mal_id, 93 | title, 94 | en_title, 95 | anidb_id, 96 | tvdb_id, 97 | tmdb_id, 98 | type, 99 | releaseDate 100 | ) values (?, ?, ?, ?, ?, ?, ?, ?)` 101 | 102 | tx, err := db.Handler.Begin() 103 | db.check(err) 104 | 105 | defer tx.Rollback() 106 | 107 | stmt, err := tx.Prepare(addAnime) 108 | db.check(err) 109 | 110 | defer stmt.Close() 111 | 112 | for _, anime := range a { 113 | _, err := stmt.Exec(anime.MalID, anime.MainTitle, anime.EnglishTitle, anime.AnidbID, anime.TvdbID, anime.TmdbID, anime.Type, anime.ReleaseDate) 114 | db.check(err) 115 | } 116 | 117 | if err = tx.Commit(); err != nil { 118 | db.check(err) 119 | } 120 | 121 | if _, err = db.Handler.Exec(`PRAGMA wal_checkpoint(TRUNCATE);`); err != nil { 122 | db.check(err) 123 | } 124 | 125 | _, err = db.Handler.Exec("PRAGMA user_version = 1") 126 | db.check(err) 127 | 128 | db.log.Trace().Msg("Updated anime in database") 129 | } 130 | 131 | func (db *DB) UpdateMalAuth(m map[string]string) { 132 | const addMalauth = `INSERT OR REPLACE INTO malauth ( 133 | id, 134 | client_id, 135 | client_secret, 136 | access_token 137 | ) values (?, ?, ?, ?)` 138 | 139 | stmt, err := db.Handler.Prepare(addMalauth) 140 | db.check(err) 141 | defer stmt.Close() 142 | 143 | _, err = stmt.Exec(1, m["client_id"], m["client_secret"], m["access_token"]) 144 | db.check(err) 145 | 146 | if _, err = db.Handler.Exec(`PRAGMA wal_checkpoint(TRUNCATE);`); err != nil { 147 | db.check(err) 148 | } 149 | } 150 | 151 | func (db *DB) GetMalCreds(ctx context.Context) (map[string]string, error) { 152 | var ( 153 | client_id string 154 | client_secret string 155 | access_token string 156 | ) 157 | 158 | sqlstmt := "SELECT client_id, client_secret, access_token from malauth;" 159 | 160 | row := db.Handler.QueryRowContext(ctx, sqlstmt) 161 | err := row.Scan(&client_id, &client_secret, &access_token) 162 | if err != nil { 163 | return nil, err 164 | } 165 | 166 | return map[string]string{ 167 | "client_id": client_id, 168 | "client_secret": client_secret, 169 | "access_token": access_token, 170 | }, nil 171 | } 172 | 173 | func (db *DB) Close() { 174 | db.Handler.Close() 175 | } 176 | 177 | func (db *DB) check(err error) { 178 | if err != nil { 179 | db.log.Fatal().Err(errors.WithStack(err)).Msg("Database operation failed") 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /internal/database/media.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "regexp" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/pkg/errors" 11 | 12 | "github.com/varoOP/shinkro/pkg/plex" 13 | ) 14 | 15 | type Media struct { 16 | Type string 17 | Title string 18 | Agent string 19 | IdSource string 20 | Id int 21 | Season int 22 | Ep int 23 | } 24 | 25 | var AgentRegExMap = map[string]string{ 26 | "hama": `//(.* ?)-(\d+ ?)`, 27 | "mal": `.(m.*)://(\d+ ?)`, 28 | } 29 | 30 | func NewMedia(pw *plex.PlexWebhook, agent string, pc *plex.PlexClient, usePlex bool) (*Media, error) { 31 | var ( 32 | idSource string 33 | title string = pw.Metadata.GrandparentTitle 34 | mediaType string = pw.Metadata.Type 35 | id int 36 | season int = pw.Metadata.ParentIndex 37 | ep int = pw.Metadata.Index 38 | err error 39 | ) 40 | 41 | switch agent { 42 | case "mal", "hama": 43 | idSource, id, err = hamaMALAgent(pw.Metadata.GUID.GUID, agent) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | case "plex": 49 | if !usePlex { 50 | return nil, errors.New("plex token not provided or invalid") 51 | } 52 | 53 | guid := pw.Metadata.GUID 54 | if mediaType == "episode" { 55 | g, err := GetShowID(pc, pw.Metadata.GrandparentKey) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | guid = *g 61 | } 62 | 63 | idSource, id, err = plexAgent(guid, mediaType) 64 | if err != nil { 65 | return nil, err 66 | } 67 | } 68 | 69 | if idSource == "myanimelist" { 70 | idSource = "mal" 71 | } 72 | 73 | if mediaType == "movie" { 74 | season = 1 75 | ep = 1 76 | title = pw.Metadata.Title 77 | } 78 | 79 | return &Media{ 80 | Type: mediaType, 81 | Title: title, 82 | Agent: agent, 83 | IdSource: idSource, 84 | Id: id, 85 | Season: season, 86 | Ep: ep, 87 | }, nil 88 | } 89 | 90 | func (m *Media) GetMalID(ctx context.Context, db *DB) (int, error) { 91 | var malid int 92 | switch m.Agent { 93 | case "mal": 94 | malid = m.Id 95 | 96 | default: 97 | sqlstmt := fmt.Sprintf("SELECT mal_id from anime where %v_id=?;", m.IdSource) 98 | row := db.Handler.QueryRowContext(ctx, sqlstmt, m.Id) 99 | err := row.Scan(&malid) 100 | if err != nil { 101 | return -1, errors.Errorf("mal_id of %v (%v:%v) not found in database, add to custom map", m.Title, m.IdSource, m.Id) 102 | } 103 | } 104 | 105 | return malid, nil 106 | } 107 | 108 | func (m *Media) ConvertToTVDB(ctx context.Context, db *DB) { 109 | if m.IdSource == "anidb" { 110 | var tvdbid int 111 | sqlstmt := "SELECT tvdb_id from anime where anidb_id=?;" 112 | row := db.Handler.QueryRowContext(ctx, sqlstmt, m.Id) 113 | err := row.Scan(&tvdbid) 114 | if err != nil || tvdbid == 0 { 115 | return 116 | } 117 | 118 | m.IdSource = "tvdb" 119 | m.Id = tvdbid 120 | } 121 | } 122 | 123 | func hamaMALAgent(guid, agent string) (string, int, error) { 124 | r := regexp.MustCompile(AgentRegExMap[agent]) 125 | if !r.MatchString(guid) { 126 | return "", -1, errors.Errorf("unable to parse GUID: %v", guid) 127 | } 128 | 129 | mm := r.FindStringSubmatch(guid) 130 | source := mm[1] 131 | id, err := strconv.Atoi(mm[2]) 132 | if err != nil { 133 | return "", -1, errors.Wrap(err, "conversion of id failed") 134 | } 135 | 136 | return source, id, nil 137 | } 138 | 139 | func plexAgent(guid plex.GUID, mediaType string) (string, int, error) { 140 | for _, gid := range guid.GUIDS { 141 | dbid := strings.Split(gid.ID, "://") 142 | if (mediaType == "episode" && dbid[0] == "tvdb") || (mediaType == "movie" && dbid[0] == "tmdb") { 143 | id, err := strconv.Atoi(dbid[1]) 144 | if err != nil { 145 | return "", -1, errors.Wrap(err, "id conversion failed") 146 | } 147 | 148 | return dbid[0], id, nil 149 | } 150 | } 151 | 152 | return "", -1, errors.New("no supported db found") 153 | } 154 | 155 | func GetShowID(p *plex.PlexClient, key string) (*plex.GUID, error) { 156 | guid, err := p.GetShowID(key) 157 | if err != nil { 158 | return nil, err 159 | } 160 | 161 | return guid, nil 162 | } 163 | -------------------------------------------------------------------------------- /internal/database/media_test.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/varoOP/shinkro/pkg/plex" 8 | ) 9 | 10 | func TestHamaMalAgent(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | guid string 14 | agent string 15 | want1 string 16 | want2 int 17 | want3 error 18 | }{ 19 | { 20 | name: "HamaTVTVDB", 21 | guid: "com.plexapp.agents.hama://tvdb-81797/4/1?lang=en", 22 | agent: "hama", 23 | want1: "tvdb", 24 | want2: 81797, 25 | want3: nil, 26 | }, 27 | { 28 | name: "HamaTVAniDB", 29 | guid: "com.plexapp.agents.hama://anidb-17449/1/4?lang=en", 30 | agent: "hama", 31 | want1: "anidb", 32 | want2: 17449, 33 | want3: nil, 34 | }, 35 | { 36 | name: "HAMAMovieAniDB", 37 | guid: "com.plexapp.agents.hama://anidb-5693?lang=en", 38 | agent: "hama", 39 | want1: "anidb", 40 | want2: 5693, 41 | want3: nil, 42 | }, 43 | { 44 | name: "HAMAMovieTMDB", 45 | guid: "com.plexapp.agents.hama://tmdb-23150?lang=en", 46 | agent: "hama", 47 | want1: "tmdb", 48 | want2: 23150, 49 | want3: nil, 50 | }, 51 | { 52 | name: "MALTV", 53 | guid: "net.fribbtastic.coding.plex.myanimelist://52305/1/1?lang=en", 54 | agent: "mal", 55 | want1: "myanimelist", 56 | want2: 52305, 57 | want3: nil, 58 | }, 59 | { 60 | name: "MALMovie", 61 | guid: "net.fribbtastic.coding.plex.myanimelist://2593?lang=en", 62 | agent: "mal", 63 | want1: "myanimelist", 64 | want2: 2593, 65 | want3: nil, 66 | }, 67 | } 68 | 69 | for _, tt := range tests { 70 | t.Run(tt.name, func(t *testing.T) { 71 | got1, got2, got3 := hamaMALAgent(tt.guid, tt.agent) 72 | assert.Equal(t, tt.want1, got1) 73 | assert.Equal(t, tt.want2, got2) 74 | assert.Equal(t, tt.want3, got3) 75 | }) 76 | } 77 | } 78 | 79 | func TestPlexAgent(t *testing.T) { 80 | tests := []struct { 81 | name string 82 | guid plex.GUID 83 | mediaType string 84 | want1 string 85 | want2 int 86 | want3 error 87 | }{ 88 | { 89 | name: "PlexTV", 90 | guid: plex.GUID{GUIDS: []struct { 91 | ID string "json:\"id\"" 92 | }{{ID: "imdb://tt21210326"}, {ID: "tmdb://205308"}, {ID: "tvdb://421994"}}, 93 | GUID: "plex://show/63031ea849f1f16d698849ba"}, 94 | mediaType: "episode", 95 | want1: "tvdb", 96 | want2: 421994, 97 | want3: nil, 98 | }, 99 | { 100 | name: "PlexMovie", 101 | guid: plex.GUID{GUIDS: []struct { 102 | ID string "json:\"id\"" 103 | }{{ID: "imdb://tt0259534"}, {ID: "tmdb://84092"}, {ID: "tvdb://64694"}}, 104 | GUID: "plex://movie/5d7768d196b655001fdc2678"}, 105 | mediaType: "movie", 106 | want1: "tmdb", 107 | want2: 84092, 108 | want3: nil, 109 | }, 110 | } 111 | 112 | for _, tt := range tests { 113 | t.Run(tt.name, func(t *testing.T) { 114 | got1, got2, got3 := plexAgent(tt.guid, tt.mediaType) 115 | assert.Equal(t, tt.want1, got1) 116 | assert.Equal(t, tt.want2, got2) 117 | assert.Equal(t, tt.want3, got3) 118 | }) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /internal/database/shinkrodb.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | 8 | "github.com/pkg/errors" 9 | ) 10 | 11 | type Anime struct { 12 | MainTitle string `json:"title"` 13 | EnglishTitle string `json:"enTitle"` 14 | MalID int `json:"malid"` 15 | AnidbID int `json:"anidbid"` 16 | TvdbID int `json:"tvdbid"` 17 | TmdbID int `json:"tmdbid"` 18 | Type string `json:"type"` 19 | ReleaseDate string `json:"releaseDate"` 20 | } 21 | 22 | func getAnime() ([]Anime, error) { 23 | resp, err := http.Get("https://github.com/varoOP/shinkro-mapping/raw/main/shinkrodb/for-shinkro.json") 24 | if err != nil { 25 | return nil, errors.Wrap(err, "failed to get response from shinkrodb") 26 | } 27 | 28 | defer resp.Body.Close() 29 | body, err := io.ReadAll(resp.Body) 30 | if err != nil { 31 | return nil, errors.Wrap(err, "failed to read response") 32 | } 33 | 34 | a := []Anime{} 35 | err = json.Unmarshal(body, &a) 36 | if err != nil { 37 | return nil, errors.Wrap(err, "failed to unmarshal json") 38 | } 39 | 40 | return a, nil 41 | } 42 | -------------------------------------------------------------------------------- /internal/domain/config.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | type Config struct { 4 | ConfigPath string 5 | Username string `koanf:"Username"` 6 | Password string `koanf:"Password"` 7 | Host string `koanf:"Host"` 8 | Port int `koanf:"Port"` 9 | PlexUser string `koanf:"PlexUsername"` 10 | PlexUrl string `koanf:"Url"` 11 | PlexToken string `koanf:"Token"` 12 | AnimeLibraries []string `koanf:"AnimeLibraries"` 13 | ApiKey string `koanf:"ApiKey"` 14 | BaseUrl string `koanf:"BaseUrl"` 15 | CustomMapTVDB bool 16 | CustomMapTVDBPath string 17 | CustomMapTMDB bool 18 | CustomMapTMDBPath string 19 | TVDBMalMap *AnimeTVDBMap 20 | TMDBMalMap *AnimeMovies 21 | DiscordWebHookURL string `koanf:"DiscordWebhookUrl"` 22 | LogLevel string `koanf:"LogLevel"` 23 | LogMaxSize int `koanf:"LogMaxSize"` 24 | LogMaxBackups int `koanf:"LogMaxBackups"` 25 | } 26 | 27 | func (cfg *Config) LocalMapsExist() { 28 | cfg.CustomMapTMDB = false 29 | if fileExists(cfg.CustomMapTMDBPath) { 30 | cfg.CustomMapTMDB = true 31 | } 32 | 33 | cfg.CustomMapTVDB = false 34 | if fileExists(cfg.CustomMapTVDBPath) { 35 | cfg.CustomMapTVDB = true 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /internal/domain/helpers.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | "os" 8 | 9 | "gopkg.in/yaml.v3" 10 | ) 11 | 12 | func updateStart(ctx context.Context, s int) int { 13 | if s == 0 { 14 | return 1 15 | } 16 | return s 17 | } 18 | 19 | func readYamlHTTP(resp *http.Response, mapping interface{}) error { 20 | body, err := io.ReadAll(resp.Body) 21 | if err != nil { 22 | return err 23 | } 24 | 25 | defer resp.Body.Close() 26 | err = yaml.Unmarshal(body, mapping) 27 | if err != nil { 28 | return err 29 | } 30 | 31 | return nil 32 | } 33 | 34 | func readYamlFile(f *os.File, mapping interface{}) error { 35 | defer f.Close() 36 | body, err := io.ReadAll(f) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | err = yaml.Unmarshal(body, mapping) 42 | if err != nil { 43 | return err 44 | } 45 | 46 | return nil 47 | } 48 | 49 | func fileExists(path string) bool { 50 | _, err := os.Open(path) 51 | return err == nil 52 | } 53 | -------------------------------------------------------------------------------- /internal/domain/mapping.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "os" 7 | 8 | "github.com/santhosh-tekuri/jsonschema/v5" 9 | _ "github.com/santhosh-tekuri/jsonschema/v5/httploader" 10 | ) 11 | 12 | const ( 13 | communityMapTVDB = "https://github.com/varoOP/shinkro-mapping/raw/main/tvdb-mal.yaml" 14 | TVDBSchema = "https://github.com/varoOP/shinkro-mapping/raw/main/.github/schema-tvdb.json" 15 | communityMapTMDB = "https://github.com/varoOP/shinkro-mapping/raw/main/tmdb-mal.yaml" 16 | TMDBSchema = "https://github.com/varoOP/shinkro-mapping/raw/main/.github/schema-tmdb.json" 17 | ) 18 | 19 | type AnimeTVDBMap struct { 20 | Anime []Anime `yaml:"AnimeMap" json:"AnimeMap"` 21 | } 22 | 23 | type Anime struct { 24 | Malid int `yaml:"malid" json:"malid"` 25 | Title string `yaml:"title" json:"title"` 26 | Type string `yaml:"type" json:"type"` 27 | Tvdbid int `yaml:"tvdbid" json:"tvdbid"` 28 | TvdbSeason int `yaml:"tvdbseason" json:"tvdbseason"` 29 | Start int `yaml:"start" json:"start"` 30 | UseMapping bool `yaml:"useMapping" json:"useMapping"` 31 | AnimeMapping []AnimeMapping `yaml:"animeMapping" json:"animeMapping"` 32 | } 33 | 34 | type AnimeMapping struct { 35 | TvdbSeason int `yaml:"tvdbseason" json:"tvdbseason"` 36 | Start int `yaml:"start" json:"start"` 37 | } 38 | 39 | type AnimeMovies struct { 40 | AnimeMovie []AnimeMovie `yaml:"animeMovies" json:"animeMovies"` 41 | } 42 | 43 | type AnimeMovie struct { 44 | MainTitle string `yaml:"mainTitle" json:"mainTitle"` 45 | TMDBID int `yaml:"tmdbid" json:"tmdbid"` 46 | MALID int `yaml:"malid" json:"malid"` 47 | } 48 | 49 | func NewAnimeMaps(cfg *Config) (*AnimeTVDBMap, *AnimeMovies, error) { 50 | cfg.LocalMapsExist() 51 | err := loadCommunityMaps(cfg) 52 | if err != nil { 53 | return nil, nil, err 54 | } 55 | 56 | err = loadLocalMaps(cfg) 57 | if err != nil { 58 | return nil, nil, err 59 | } 60 | 61 | return cfg.TVDBMalMap, cfg.TMDBMalMap, nil 62 | } 63 | 64 | func (s *AnimeTVDBMap) CheckMap(tvdbid, tvdbseason, ep int) (bool, *Anime) { 65 | candidates := s.findMatchingAnime(tvdbid, tvdbseason) 66 | if len(candidates) == 1 { 67 | return true, &candidates[0] 68 | } else if len(candidates) > 1 { 69 | anime := s.findBestMatchingAnime(ep, candidates) 70 | return true, &anime 71 | } 72 | 73 | return false, nil 74 | } 75 | 76 | func (s *AnimeTVDBMap) findMatchingAnime(tvdbid, tvdbseason int) []Anime { 77 | var matchingAnime []Anime 78 | for _, anime := range s.Anime { 79 | if tvdbid != anime.Tvdbid { 80 | continue 81 | } 82 | 83 | if !anime.UseMapping && tvdbseason == anime.TvdbSeason { 84 | matchingAnime = append(matchingAnime, anime) 85 | continue 86 | } 87 | 88 | matchingMappedAnime := s.findMatchingMappedAnime(anime, tvdbseason) 89 | if matchingMappedAnime != nil { 90 | return []Anime{*matchingMappedAnime} 91 | } 92 | } 93 | 94 | return matchingAnime 95 | } 96 | 97 | func (s *AnimeTVDBMap) findMatchingMappedAnime(anime Anime, tvdbseason int) *Anime { 98 | if !anime.UseMapping { 99 | return nil 100 | } 101 | 102 | for _, animeMap := range anime.AnimeMapping { 103 | if tvdbseason == animeMap.TvdbSeason { 104 | anime.TvdbSeason = animeMap.TvdbSeason 105 | anime.Start = animeMap.Start 106 | return &anime 107 | } 108 | } 109 | 110 | return nil 111 | } 112 | 113 | func (s *AnimeTVDBMap) findBestMatchingAnime(ep int, candidates []Anime) Anime { 114 | var anime Anime 115 | largestStart := 0 116 | for _, v := range candidates { 117 | if ep >= v.Start && v.Start >= largestStart { 118 | largestStart = v.Start 119 | anime = v 120 | } 121 | } 122 | 123 | return anime 124 | } 125 | 126 | func (am *AnimeMovies) CheckMap(tmdbid int) (bool, *AnimeMovie) { 127 | for _, animeMovie := range am.AnimeMovie { 128 | if animeMovie.TMDBID == tmdbid { 129 | return true, &animeMovie 130 | } 131 | } 132 | 133 | return false, nil 134 | } 135 | 136 | func loadCommunityMaps(cfg *Config) error { 137 | if !cfg.CustomMapTVDB { 138 | s := &AnimeTVDBMap{} 139 | respTVDB, err := http.Get(communityMapTVDB) 140 | if err != nil { 141 | return err 142 | } 143 | 144 | err = readYamlHTTP(respTVDB, s) 145 | if err != nil { 146 | return err 147 | } 148 | 149 | cfg.TVDBMalMap = s 150 | } 151 | 152 | if !cfg.CustomMapTMDB { 153 | am := &AnimeMovies{} 154 | respTMDB, err := http.Get(communityMapTMDB) 155 | if err != nil { 156 | return err 157 | } 158 | 159 | err = readYamlHTTP(respTMDB, am) 160 | if err != nil { 161 | return err 162 | } 163 | 164 | cfg.TMDBMalMap = am 165 | } 166 | 167 | return nil 168 | } 169 | 170 | func loadLocalMaps(cfg *Config) error { 171 | if cfg.CustomMapTVDB { 172 | s := &AnimeTVDBMap{} 173 | fTVDB, err := os.Open(cfg.CustomMapTVDBPath) 174 | if err != nil { 175 | return err 176 | } 177 | 178 | err = readYamlFile(fTVDB, s) 179 | if err != nil { 180 | return err 181 | } 182 | 183 | cfg.TVDBMalMap = s 184 | } 185 | 186 | if cfg.CustomMapTMDB { 187 | am := &AnimeMovies{} 188 | fTMDB, err := os.Open(cfg.CustomMapTMDBPath) 189 | if err != nil { 190 | return err 191 | } 192 | 193 | err = readYamlFile(fTMDB, am) 194 | if err != nil { 195 | return err 196 | } 197 | 198 | cfg.TMDBMalMap = am 199 | } 200 | 201 | return nil 202 | } 203 | 204 | func ChecklocalMaps(cfg *Config) (error, bool) { 205 | loadLocalMaps(cfg) 206 | localMapLoaded := false 207 | if cfg.CustomMapTVDB { 208 | if err := validateYaml(TVDBSchema, cfg.TVDBMalMap); err != nil { 209 | return err, false 210 | } 211 | 212 | localMapLoaded = true 213 | } 214 | 215 | if cfg.CustomMapTMDB { 216 | if err := validateYaml(TMDBSchema, cfg.TMDBMalMap); err != nil { 217 | return err, false 218 | } 219 | 220 | localMapLoaded = true 221 | } 222 | 223 | return nil, localMapLoaded 224 | } 225 | 226 | func validateYaml(schema string, yaml any) error { 227 | compiler := jsonschema.NewCompiler() 228 | sch, err := compiler.Compile(schema) 229 | if err != nil { 230 | return err 231 | } 232 | 233 | var v interface{} 234 | b, err := json.Marshal(yaml) 235 | if err != nil { 236 | return err 237 | } 238 | 239 | err = json.Unmarshal(b, &v) 240 | if err != nil { 241 | return err 242 | } 243 | 244 | if err := sch.Validate(v); err != nil { 245 | return err 246 | } 247 | 248 | return nil 249 | } 250 | -------------------------------------------------------------------------------- /internal/domain/mapping_test.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestMappingCheckMap(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | animeMap *AnimeTVDBMap 13 | tvdbid int 14 | tvdbseason int 15 | ep int 16 | want1 bool 17 | want2 int //malid 18 | want3 int //start 19 | }{ 20 | { 21 | name: "DanMachi S4-1", 22 | animeMap: &AnimeTVDBMap{ 23 | Anime: []Anime{ 24 | { 25 | Malid: 47164, 26 | Tvdbid: 289882, 27 | TvdbSeason: 4, 28 | Start: 0, 29 | UseMapping: false, 30 | }, 31 | { 32 | Malid: 53111, 33 | Tvdbid: 289882, 34 | TvdbSeason: 4, 35 | Start: 12, 36 | UseMapping: false, 37 | }, 38 | }, 39 | }, 40 | tvdbid: 289882, 41 | tvdbseason: 4, 42 | ep: 22, 43 | want1: true, 44 | want2: 53111, 45 | want3: 12, 46 | }, 47 | { 48 | name: "DanMachi S4-2", 49 | animeMap: &AnimeTVDBMap{ 50 | Anime: []Anime{ 51 | { 52 | Malid: 47164, 53 | Tvdbid: 289882, 54 | TvdbSeason: 4, 55 | Start: 0, 56 | UseMapping: false, 57 | }, 58 | { 59 | Malid: 53111, 60 | Tvdbid: 289882, 61 | TvdbSeason: 4, 62 | Start: 12, 63 | UseMapping: false, 64 | }, 65 | }, 66 | }, 67 | tvdbid: 289882, 68 | tvdbseason: 4, 69 | ep: 10, 70 | want1: true, 71 | want2: 47164, 72 | want3: 0, 73 | }, 74 | { 75 | name: "SPYXFAMILY S1-2", 76 | animeMap: &AnimeTVDBMap{ 77 | Anime: []Anime{ 78 | { 79 | Malid: 50602, 80 | Tvdbid: 405920, 81 | TvdbSeason: 1, 82 | Start: 13, 83 | UseMapping: false, 84 | }, 85 | { 86 | Malid: 50265, 87 | Tvdbid: 405920, 88 | TvdbSeason: 1, 89 | Start: 0, 90 | UseMapping: false, 91 | }, 92 | }, 93 | }, 94 | tvdbid: 405920, 95 | tvdbseason: 1, 96 | ep: 19, 97 | want1: true, 98 | want2: 50602, 99 | want3: 13, 100 | }, 101 | { 102 | name: "One Piece-1", 103 | animeMap: &AnimeTVDBMap{ 104 | Anime: []Anime{ 105 | { 106 | Malid: 21, 107 | Tvdbid: 81797, 108 | TvdbSeason: 0, 109 | Start: 0, 110 | UseMapping: true, 111 | AnimeMapping: []AnimeMapping{ 112 | { 113 | TvdbSeason: 10, 114 | Start: 196, 115 | }, 116 | { 117 | TvdbSeason: 12, 118 | Start: 326, 119 | }, 120 | { 121 | TvdbSeason: 15, 122 | Start: 517, 123 | }, 124 | { 125 | TvdbSeason: 21, 126 | Start: 892, 127 | }, 128 | }, 129 | }, 130 | }, 131 | }, 132 | tvdbid: 81797, 133 | tvdbseason: 21, 134 | ep: 186, 135 | want1: true, 136 | want2: 21, 137 | want3: 892, 138 | }, 139 | { 140 | name: "One Piece-2", 141 | animeMap: &AnimeTVDBMap{ 142 | Anime: []Anime{ 143 | { 144 | Malid: 21, 145 | Tvdbid: 81797, 146 | TvdbSeason: 0, 147 | Start: 0, 148 | UseMapping: true, 149 | AnimeMapping: []AnimeMapping{ 150 | { 151 | TvdbSeason: 10, 152 | Start: 196, 153 | }, 154 | { 155 | TvdbSeason: 12, 156 | Start: 326, 157 | }, 158 | { 159 | TvdbSeason: 15, 160 | Start: 517, 161 | }, 162 | { 163 | TvdbSeason: 21, 164 | Start: 892, 165 | }, 166 | }, 167 | }, 168 | }, 169 | }, 170 | tvdbid: 81797, 171 | tvdbseason: 10, 172 | ep: 30, 173 | want1: true, 174 | want2: 21, 175 | want3: 196, 176 | }, 177 | } 178 | 179 | for _, tt := range tests { 180 | t.Run(tt.name, func(t *testing.T) { 181 | got1, got2 := tt.animeMap.CheckMap(tt.tvdbid, tt.tvdbseason, tt.ep) 182 | assert.Equal(t, tt.want1, got1) 183 | assert.Equal(t, tt.want2, got2.Malid) 184 | assert.Equal(t, tt.want3, got2.Start) 185 | }) 186 | } 187 | } -------------------------------------------------------------------------------- /internal/domain/notification.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | type Notification struct { 4 | Error chan error 5 | Anime chan AnimeUpdate 6 | PayLoad *NotificationPayload 7 | Url string 8 | } 9 | 10 | type NotificationPayload struct { 11 | Event string 12 | Title string 13 | Url string 14 | Status string 15 | Score int 16 | StartDate string 17 | FinishDate string 18 | TotalEps int 19 | WatchedEps int 20 | TimesRewatched int 21 | ImageUrl string 22 | Message string 23 | } 24 | -------------------------------------------------------------------------------- /internal/domain/update.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/nstratos/go-myanimelist/mal" 8 | "github.com/pkg/errors" 9 | "github.com/rs/zerolog" 10 | "github.com/varoOP/shinkro/internal/database" 11 | "github.com/varoOP/shinkro/internal/malauth" 12 | "github.com/varoOP/shinkro/pkg/plex" 13 | ) 14 | 15 | type AnimeUpdate struct { 16 | Client *mal.Client 17 | DB *database.DB 18 | Config *Config 19 | Plex *plex.PlexWebhook 20 | Anime *Anime 21 | AnimeMovie *AnimeMovie 22 | TVDBMapping *AnimeTVDBMap 23 | TMDBMapping *AnimeMovies 24 | InTVDBMap bool 25 | InTMDBMap bool 26 | Media *database.Media 27 | Malid int 28 | Start int 29 | Ep int 30 | MyList *MyList 31 | Malresp *mal.AnimeListStatus 32 | Log zerolog.Logger 33 | Notify *Notification 34 | } 35 | 36 | type MyList struct { 37 | Status mal.AnimeStatus 38 | RewatchNum int 39 | EpNum int 40 | WatchedNum int 41 | Title string 42 | Picture string 43 | } 44 | 45 | type Key string 46 | 47 | const ( 48 | PlexPayload Key = "plexPayload" 49 | Agent Key = "agent" 50 | ) 51 | 52 | func NewAnimeUpdate(db *database.DB, cfg *Config, log *zerolog.Logger, n *Notification) AnimeUpdate { 53 | return AnimeUpdate{ 54 | DB: db, 55 | Config: cfg, 56 | Malid: -1, 57 | Start: -1, 58 | Log: log.With().Str("action", "animeUpdate").Logger(), 59 | Notify: n, 60 | } 61 | } 62 | 63 | func (a *AnimeUpdate) SendUpdate(ctx context.Context) error { 64 | c, err := malauth.NewOauth2Client(ctx, a.DB) 65 | if err != nil { 66 | return errors.Wrap(err, "unable to create new mal oauth2 client") 67 | } 68 | 69 | a.Client = mal.NewClient(c) 70 | if err := a.parseMedia(ctx); err != nil { 71 | return err 72 | } 73 | 74 | if err := a.getMapping(ctx); err != nil { 75 | return err 76 | } 77 | 78 | switch a.Plex.Event { 79 | case "media.scrobble": 80 | err := a.processScrobble(ctx) 81 | if err != nil { 82 | return err 83 | } 84 | 85 | return nil 86 | 87 | case "media.rate": 88 | err := a.processRate(ctx) 89 | if err != nil { 90 | return err 91 | } 92 | 93 | return nil 94 | } 95 | 96 | return errors.Wrap(errors.Errorf("unable to send update for %v (%v-%v)", a.Media.Title, a.Media.IdSource, a.Media.Id), "plex event check failed") 97 | } 98 | 99 | func (a *AnimeUpdate) processScrobble(ctx context.Context) error { 100 | var err error 101 | if a.InTVDBMap { 102 | err = a.tvdbtoMal(ctx) 103 | if err != nil { 104 | return err 105 | } 106 | 107 | err = a.updateWatchStatus(ctx) 108 | if err != nil { 109 | return err 110 | } 111 | return nil 112 | } 113 | 114 | if a.InTMDBMap { 115 | a.Malid = a.AnimeMovie.MALID 116 | err = a.updateWatchStatus(ctx) 117 | if err != nil { 118 | return err 119 | } 120 | 121 | return nil 122 | } 123 | 124 | if a.Media.Season == 1 || a.Media.IdSource == "mal" { 125 | a.Malid, err = a.Media.GetMalID(ctx, a.DB) 126 | if err != nil { 127 | return err 128 | } 129 | 130 | a.Ep = a.Media.Ep 131 | err = a.updateWatchStatus(ctx) 132 | if err != nil { 133 | return err 134 | } 135 | 136 | return nil 137 | } 138 | 139 | return errors.Wrap(errors.Errorf("unable to scrobble %v (%v-%v-%v)", a.Media.Title, a.Media.Type, a.Media.Agent, a.Media.Id), "not found in database or mapping") 140 | } 141 | 142 | func (a *AnimeUpdate) processRate(ctx context.Context) error { 143 | var err error 144 | if a.InTVDBMap { 145 | err := a.getStartID(ctx) 146 | if err != nil { 147 | return err 148 | } 149 | 150 | err = a.updateRating(ctx) 151 | if err != nil { 152 | return err 153 | } 154 | 155 | return nil 156 | } 157 | 158 | if a.InTMDBMap { 159 | a.Malid = a.AnimeMovie.MALID 160 | err = a.updateRating(ctx) 161 | if err != nil { 162 | return err 163 | } 164 | 165 | return nil 166 | } 167 | 168 | if a.Media.Season == 1 || a.Media.IdSource == "mal" { 169 | a.Malid, err = a.Media.GetMalID(ctx, a.DB) 170 | if err != nil { 171 | return err 172 | } 173 | 174 | err := a.updateRating(ctx) 175 | if err != nil { 176 | return err 177 | } 178 | 179 | return nil 180 | } 181 | 182 | return errors.Wrap(errors.Errorf("unable to rate %v (%v-%v-%v)", a.Media.Title, a.Media.Type, a.Media.Agent, a.Media.Id), "not found in database or mapping") 183 | } 184 | 185 | func (a *AnimeUpdate) tvdbtoMal(ctx context.Context) error { 186 | err := a.getStartID(ctx) 187 | if err != nil { 188 | return err 189 | } 190 | 191 | if a.Anime.UseMapping { 192 | a.Ep = a.Start + a.Media.Ep - 1 193 | } else { 194 | a.Ep = a.Media.Ep - a.Start + 1 195 | } 196 | 197 | if a.Ep <= 0 { 198 | return errors.Wrap(errors.New("episode calculated incorrectly"), "episode 0 or negative") 199 | } 200 | 201 | return nil 202 | } 203 | 204 | func (a *AnimeUpdate) updateWatchStatus(ctx context.Context) error { 205 | options, complete, err := a.newOptions(ctx) 206 | if err != nil { 207 | return err 208 | } 209 | 210 | if complete { 211 | a.Log.Info().Msgf("%v is already marked complete on MAL", a.MyList.Title) 212 | return errors.New("complete") 213 | } 214 | 215 | l, _, err := a.Client.Anime.UpdateMyListStatus(ctx, a.Malid, options...) 216 | if err != nil { 217 | return err 218 | } 219 | 220 | a.Malresp = l 221 | return nil 222 | } 223 | 224 | func (a *AnimeUpdate) newOptions(ctx context.Context) ([]mal.UpdateMyAnimeListStatusOption, bool, error) { 225 | err := a.checkAnime(ctx) 226 | if err != nil { 227 | return nil, false, err 228 | } 229 | 230 | if a.Ep > a.MyList.EpNum && a.MyList.EpNum != 0 { 231 | return nil, true, errors.Wrap(errors.Errorf("%v (%v-%v): anime in plex has more episodes for season than mal", a.Media.Title, a.Media.IdSource, a.Media.Id), "update custom mappping to fix") 232 | } 233 | 234 | var options []mal.UpdateMyAnimeListStatusOption 235 | if a.MyList.Status == mal.AnimeStatusCompleted { 236 | if a.MyList.EpNum == a.Ep { 237 | a.MyList.RewatchNum++ 238 | options = append(options, mal.NumTimesRewatched(a.MyList.RewatchNum)) 239 | return options, false, nil 240 | } 241 | 242 | return nil, true, nil 243 | } 244 | 245 | if a.MyList.EpNum == a.Ep { 246 | a.MyList.Status = mal.AnimeStatusCompleted 247 | options = append(options, mal.FinishDate(time.Now().Local())) 248 | } 249 | 250 | if a.Ep == 1 && a.MyList.WatchedNum == 0 { 251 | options = append(options, mal.StartDate(time.Now().Local())) 252 | } 253 | 254 | if (a.Ep < a.MyList.EpNum || a.MyList.EpNum == 0) && a.Ep >= 1 { 255 | a.MyList.Status = mal.AnimeStatusWatching 256 | } 257 | 258 | options = append(options, mal.NumEpisodesWatched(a.Ep)) 259 | options = append(options, a.MyList.Status) 260 | return options, false, nil 261 | } 262 | 263 | func (a *AnimeUpdate) checkAnime(ctx context.Context) error { 264 | aa, _, err := a.Client.Anime.Details(ctx, a.Malid, mal.Fields{"num_episodes", "title", "main_picture{medium,large}", "my_list_status{status,num_times_rewatched,num_episodes_watched}"}) 265 | if err != nil { 266 | return err 267 | } 268 | 269 | picture := aa.MainPicture.Large 270 | if picture == "" { 271 | picture = aa.MainPicture.Medium 272 | } 273 | 274 | a.MyList = &MyList{ 275 | Status: aa.MyListStatus.Status, 276 | RewatchNum: aa.MyListStatus.NumTimesRewatched, 277 | EpNum: aa.NumEpisodes, 278 | WatchedNum: aa.MyListStatus.NumEpisodesWatched, 279 | Title: aa.Title, 280 | Picture: picture, 281 | } 282 | 283 | return nil 284 | } 285 | 286 | func (a *AnimeUpdate) updateRating(ctx context.Context) error { 287 | err := a.checkAnime(ctx) 288 | if err != nil { 289 | return err 290 | } 291 | 292 | l, _, err := a.Client.Anime.UpdateMyListStatus(ctx, a.Malid, mal.Score(a.Plex.Rating)) 293 | if err != nil { 294 | return err 295 | } 296 | 297 | a.Malresp = l 298 | return nil 299 | } 300 | 301 | func (a *AnimeUpdate) getStartID(ctx context.Context) error { 302 | a.Malid = a.Anime.Malid 303 | a.Start = a.Anime.Start 304 | a.Start = updateStart(ctx, a.Start) 305 | return nil 306 | } 307 | 308 | func (a *AnimeUpdate) getMapping(ctx context.Context) error { 309 | var err error 310 | a.Media.ConvertToTVDB(ctx, a.DB) 311 | a.TVDBMapping, a.TMDBMapping, err = NewAnimeMaps(a.Config) 312 | if err != nil { 313 | return errors.Wrap(errors.New("unable to load custom mapping"), "check custom mapping against schema") 314 | } 315 | 316 | if a.Media.Type == "episode" { 317 | a.InTVDBMap, a.Anime = a.TVDBMapping.CheckMap(a.Media.Id, a.Media.Season, a.Media.Ep) 318 | } 319 | 320 | if a.Media.Type == "movie" { 321 | a.InTMDBMap, a.AnimeMovie = a.TMDBMapping.CheckMap(a.Media.Id) 322 | } 323 | 324 | return nil 325 | } 326 | 327 | func (a *AnimeUpdate) parseMedia(ctx context.Context) error { 328 | var ( 329 | err error 330 | pc *plex.PlexClient 331 | usePlex bool = false 332 | ) 333 | 334 | if a.Config.PlexToken != "" { 335 | pc = plex.NewPlexClient(a.Config.PlexUrl, a.Config.PlexToken) 336 | usePlex = true 337 | } 338 | 339 | a.Media, err = database.NewMedia(a.Plex, ctx.Value(Agent).(string), pc, usePlex) 340 | if err != nil { 341 | return err 342 | } 343 | 344 | return nil 345 | } 346 | -------------------------------------------------------------------------------- /internal/domain/update_test.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/varoOP/shinkro/internal/database" 9 | ) 10 | 11 | func TestUpdateTvdbToMal(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | have *AnimeUpdate 15 | want1 int 16 | want2 int 17 | want3 int 18 | }{ 19 | { 20 | name: "One Piece", 21 | have: &AnimeUpdate{ 22 | Anime: &Anime{ 23 | Malid: 21, 24 | UseMapping: true, 25 | TvdbSeason: 21, 26 | Start: 892, 27 | }, 28 | Media: &database.Media{ 29 | Season: 21, 30 | Ep: 162, 31 | }, 32 | Malid: -1, 33 | Start: -1, 34 | }, 35 | want1: 21, 36 | want2: 892, 37 | want3: 1053, 38 | }, 39 | { 40 | name: "DanMachi", 41 | have: &AnimeUpdate{ 42 | Anime: &Anime{ 43 | Malid: 53111, 44 | UseMapping: false, 45 | TvdbSeason: 4, 46 | Start: 12, 47 | }, 48 | Media: &database.Media{ 49 | Season: 4, 50 | Ep: 13, 51 | }, 52 | Malid: -1, 53 | Start: -1, 54 | }, 55 | want1: 53111, 56 | want2: 12, 57 | want3: 2, 58 | }, 59 | { 60 | name: "Vinland Saga", 61 | have: &AnimeUpdate{ 62 | Anime: &Anime{ 63 | Malid: 49387, 64 | TvdbSeason: 2, 65 | Start: 0, 66 | UseMapping: false, 67 | }, 68 | Media: &database.Media{ 69 | Season: 2, 70 | Ep: 9, 71 | }, 72 | Malid: -1, 73 | Start: -1, 74 | }, 75 | want1: 49387, 76 | want2: 1, 77 | want3: 9, 78 | }, 79 | } 80 | 81 | for _, tt := range tests { 82 | t.Run(tt.name, func(t *testing.T) { 83 | err := tt.have.tvdbtoMal(context.Background()) 84 | if err != nil { 85 | t.Error(err) 86 | } 87 | 88 | assert.Equal(t, tt.have.Malid, tt.want1) 89 | assert.Equal(t, tt.have.Start, tt.want2) 90 | assert.Equal(t, tt.have.Ep, tt.want3) 91 | }) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /internal/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "path/filepath" 7 | "time" 8 | 9 | "github.com/rs/zerolog" 10 | "github.com/rs/zerolog/pkgerrors" 11 | "github.com/varoOP/shinkro/internal/domain" 12 | "gopkg.in/natefinch/lumberjack.v2" 13 | ) 14 | 15 | func NewLogger(path string, c *domain.Config) *zerolog.Logger { 16 | logPath := filepath.Join(path, "shinkro.log") 17 | lumberlog := &lumberjack.Logger{ 18 | Filename: logPath, 19 | MaxSize: c.LogMaxSize, 20 | MaxBackups: c.LogMaxBackups, 21 | } 22 | 23 | zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack 24 | mw := io.MultiWriter( 25 | zerolog.ConsoleWriter{ 26 | TimeFormat: time.DateTime, 27 | Out: os.Stdout, 28 | }, 29 | zerolog.ConsoleWriter{ 30 | TimeFormat: time.DateTime, 31 | Out: lumberlog, 32 | }, 33 | ) 34 | 35 | log := zerolog.New(mw).With().Timestamp().Logger() 36 | switch c.LogLevel { 37 | case "TRACE": 38 | log = log.Level(zerolog.TraceLevel) 39 | case "DEBUG": 40 | log = log.Level(zerolog.DebugLevel) 41 | case "ERROR": 42 | log = log.Level(zerolog.ErrorLevel) 43 | case "INFO": 44 | log = log.Level(zerolog.InfoLevel) 45 | } 46 | 47 | return &log 48 | } 49 | -------------------------------------------------------------------------------- /internal/malauth/malauth.go: -------------------------------------------------------------------------------- 1 | package malauth 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "encoding/base64" 7 | "encoding/json" 8 | "log" 9 | "net/http" 10 | 11 | "github.com/varoOP/shinkro/internal/database" 12 | "golang.org/x/oauth2" 13 | ) 14 | 15 | func NewOauth2Client(ctx context.Context, db *database.DB) (*http.Client, error) { 16 | creds, err := db.GetMalCreds(ctx) 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | cfg := GetCfg(creds) 22 | t := &oauth2.Token{} 23 | err = json.Unmarshal([]byte(creds["access_token"]), t) 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | fresh_token, err := cfg.TokenSource(ctx, t).Token() 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | if err == nil && (fresh_token != t) { 34 | SaveToken(fresh_token, creds["client_id"], creds["client_secret"], db) 35 | } 36 | 37 | client := cfg.Client(ctx, fresh_token) 38 | return client, nil 39 | } 40 | 41 | func GetOauth(ctx context.Context, clientId, clientSecret string) (*oauth2.Config, map[string]string) { 42 | var ( 43 | pkce string = randomString(128) 44 | state string = randomString(32) 45 | CodeChallenge oauth2.AuthCodeOption = oauth2.SetAuthURLParam("code_challenge", pkce) 46 | ResponseType oauth2.AuthCodeOption = oauth2.SetAuthURLParam("response_type", "code") 47 | creds = map[string]string{ 48 | "client_id": clientId, 49 | "client_secret": clientSecret, 50 | } 51 | ) 52 | 53 | cfg := GetCfg(creds) 54 | return cfg, map[string]string{ 55 | "AuthCodeURL": cfg.AuthCodeURL(state, CodeChallenge, ResponseType), 56 | "state": state, 57 | "pkce": pkce, 58 | } 59 | } 60 | 61 | func SaveToken(token *oauth2.Token, clientId, clientSecret string, db *database.DB) { 62 | t, err := json.Marshal(token) 63 | if err != nil { 64 | log.Fatal(err) 65 | } 66 | 67 | db.UpdateMalAuth(map[string]string{ 68 | "client_id": clientId, 69 | "client_secret": clientSecret, 70 | "access_token": string(t), 71 | }) 72 | } 73 | 74 | func GetCfg(creds map[string]string) *oauth2.Config { 75 | return &oauth2.Config{ 76 | ClientID: creds["client_id"], 77 | ClientSecret: creds["client_secret"], 78 | Endpoint: oauth2.Endpoint{ 79 | AuthURL: "https://myanimelist.net/v1/oauth2/authorize", 80 | TokenURL: "https://myanimelist.net/v1/oauth2/token", 81 | AuthStyle: oauth2.AuthStyleInParams, 82 | }, 83 | } 84 | } 85 | 86 | func randomString(l int) string { 87 | random := make([]byte, l) 88 | _, err := rand.Read(random) 89 | if err != nil { 90 | log.Fatalln(err) 91 | } 92 | 93 | return base64.URLEncoding.EncodeToString(random)[:l] 94 | } 95 | -------------------------------------------------------------------------------- /internal/notification/discord.go: -------------------------------------------------------------------------------- 1 | package notification 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "strconv" 11 | 12 | "github.com/pkg/errors" 13 | 14 | "github.com/rs/zerolog" 15 | "github.com/varoOP/shinkro/internal/domain" 16 | "golang.org/x/text/cases" 17 | "golang.org/x/text/language" 18 | ) 19 | 20 | const ( 21 | ColorCompleted = 40704 22 | ColorWatching = 49087 23 | ColorError = 12517376 24 | ) 25 | 26 | type Discord struct { 27 | Webhook DiscordWebhook 28 | Url string 29 | log *zerolog.Logger 30 | } 31 | 32 | type DiscordWebhook struct { 33 | Embeds []Embeds `json:"embeds"` 34 | } 35 | 36 | type Embeds struct { 37 | Title string `json:"title"` 38 | URL string `json:"url"` 39 | Color int `json:"color"` 40 | Description string `json:"description"` 41 | Fields []Fields `json:"fields"` 42 | Image Image `json:"image"` 43 | } 44 | 45 | type Fields struct { 46 | Name string `json:"name"` 47 | Value string `json:"value"` 48 | Inline bool `json:"inline"` 49 | } 50 | 51 | type Image struct { 52 | URL string `json:"url"` 53 | } 54 | 55 | func NewDicord(url string, n *domain.NotificationPayload, log *zerolog.Logger) *Discord { 56 | d := &Discord{ 57 | Url: url, 58 | log: log, 59 | } 60 | 61 | d.buildWebhook(n) 62 | return d 63 | } 64 | 65 | func (d *Discord) SendNotification(ctx context.Context) error { 66 | p, err := json.Marshal(d.Webhook) 67 | if err != nil { 68 | return err 69 | } 70 | 71 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.Url, bytes.NewBuffer(p)) 72 | if err != nil { 73 | return err 74 | } 75 | 76 | req.Header.Add("Content-Type", "application/json") 77 | 78 | resp, err := http.DefaultClient.Do(req) 79 | if err != nil { 80 | return err 81 | } 82 | 83 | defer resp.Body.Close() 84 | 85 | if resp.StatusCode != http.StatusNoContent { 86 | body, err := io.ReadAll(resp.Body) 87 | if err != nil { 88 | return err 89 | } 90 | 91 | d.log.Trace().RawJSON("discordResponse", body).Msg("discord response dump") 92 | return errors.New("something went wrong with sending discord notification") 93 | } 94 | 95 | d.log.Trace().Msg("sent discord notification") 96 | return nil 97 | } 98 | 99 | func (d *Discord) buildWebhook(n *domain.NotificationPayload) { 100 | var ( 101 | title = n.Title 102 | url = n.Url 103 | status string 104 | imageUrl = n.ImageUrl 105 | color int 106 | fields []Fields 107 | ) 108 | 109 | switch status = n.Status; status { 110 | case "watching": 111 | color = ColorWatching 112 | case "completed": 113 | color = ColorCompleted 114 | case "": 115 | color = ColorError 116 | } 117 | 118 | if n.Message == "" { 119 | fields = buildFields(n) 120 | } 121 | 122 | d.Webhook = DiscordWebhook{ 123 | Embeds: []Embeds{ 124 | { 125 | Title: title, 126 | URL: url, 127 | Color: color, 128 | Description: n.Message, 129 | Fields: fields, 130 | Image: Image{ 131 | URL: imageUrl, 132 | }, 133 | }, 134 | }, 135 | } 136 | } 137 | 138 | func buildFields(n *domain.NotificationPayload) []Fields { 139 | var ( 140 | event = n.Event 141 | status = n.Status 142 | score = strconv.Itoa(n.Score) 143 | startDate = n.StartDate 144 | finishDate = n.FinishDate 145 | totalEps = strconv.Itoa(n.TotalEps) 146 | watchedEps = strconv.Itoa(n.WatchedEps) 147 | timesRewatched = strconv.Itoa(n.TimesRewatched) 148 | f []Fields 149 | ) 150 | 151 | switch event { 152 | case "media.rate": 153 | event = "Update Score" 154 | case "media.scrobble": 155 | event = "Update Status" 156 | } 157 | 158 | f = append(f, Fields{ 159 | Name: "Event", 160 | Value: event, 161 | Inline: false, 162 | }) 163 | 164 | f = append(f, Fields{ 165 | Name: "Status", 166 | Value: cases.Title(language.Und).String(status), 167 | Inline: false, 168 | }) 169 | 170 | if totalEps == "0" { 171 | totalEps = "?" 172 | } 173 | 174 | f = append(f, Fields{ 175 | Name: "Episodes Seen", 176 | Value: fmt.Sprintf("%v / %v", watchedEps, totalEps), 177 | Inline: false, 178 | }) 179 | 180 | if score == "0" { 181 | score = "Not Scored" 182 | } else { 183 | score = fmt.Sprintf("%v / %v", score, "10") 184 | } 185 | 186 | f = append(f, Fields{ 187 | Name: "Score", 188 | Value: score, 189 | Inline: false, 190 | }) 191 | 192 | if startDate != "" { 193 | f = append(f, Fields{ 194 | Name: "Start Date", 195 | Value: startDate, 196 | Inline: false, 197 | }) 198 | } 199 | 200 | if finishDate != "" { 201 | f = append(f, Fields{ 202 | Name: "Finish Date", 203 | Value: finishDate, 204 | Inline: false, 205 | }) 206 | } 207 | 208 | if timesRewatched != "0" { 209 | f = append(f, Fields{ 210 | Name: "Times Rewatched", 211 | Value: timesRewatched, 212 | Inline: false, 213 | }) 214 | } 215 | 216 | return f 217 | } 218 | -------------------------------------------------------------------------------- /internal/notification/notification.go: -------------------------------------------------------------------------------- 1 | package notification 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/rs/zerolog" 8 | "github.com/varoOP/shinkro/internal/domain" 9 | ) 10 | 11 | type AppNotification struct { 12 | Notification *domain.Notification 13 | Discord *Discord 14 | log zerolog.Logger 15 | } 16 | 17 | func NewAppNotification(url string, log *zerolog.Logger) *AppNotification { 18 | return &AppNotification{ 19 | Notification: &domain.Notification{ 20 | Error: make(chan error), 21 | Anime: make(chan domain.AnimeUpdate), 22 | Url: url, 23 | }, 24 | log: log.With().Str("module", "notification").Logger(), 25 | } 26 | } 27 | 28 | func NewNotificaitonPayload(a *domain.AnimeUpdate, err error) *domain.NotificationPayload { 29 | if a != nil { 30 | return &domain.NotificationPayload{ 31 | Event: a.Plex.Event, 32 | Title: a.MyList.Title, 33 | Url: fmt.Sprintf("https://myanimelist.net/anime/%v", a.Malid), 34 | Status: string(a.Malresp.Status), 35 | Score: a.Malresp.Score, 36 | StartDate: a.Malresp.StartDate, 37 | FinishDate: a.Malresp.FinishDate, 38 | TotalEps: a.MyList.EpNum, 39 | WatchedEps: a.Malresp.NumEpisodesWatched, 40 | TimesRewatched: a.Malresp.NumTimesRewatched, 41 | ImageUrl: a.MyList.Picture, 42 | } 43 | } else { 44 | return &domain.NotificationPayload{ 45 | Title: "Error", 46 | Message: fmt.Sprintf("`%v`", err), 47 | } 48 | } 49 | } 50 | 51 | func (n *AppNotification) ListenforNotification() { 52 | if n.Notification.Url == "" { 53 | return 54 | } 55 | 56 | for { 57 | select { 58 | case err := <-n.Notification.Error: 59 | n.log.Trace().Msg("received error in error channel") 60 | n.Notification.PayLoad = NewNotificaitonPayload(nil, err) 61 | n.CreateDiscord() 62 | case a := <-n.Notification.Anime: 63 | n.log.Trace().Msg("received notification in notification channel") 64 | n.Notification.PayLoad = NewNotificaitonPayload(&a, nil) 65 | n.CreateDiscord() 66 | } 67 | } 68 | } 69 | 70 | func (n *AppNotification) CreateDiscord() { 71 | n.Discord = NewDicord(n.Notification.Url, n.Notification.PayLoad, &n.log) 72 | n.log.Trace().Msg("built discord notification") 73 | if err := n.Discord.SendNotification(context.Background()); err != nil { 74 | n.log.Info().Err(err).Msg("unable to send discord notification") 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /internal/server/handlers.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "text/template" 7 | 8 | "github.com/nstratos/go-myanimelist/mal" 9 | "github.com/rs/zerolog" 10 | "github.com/varoOP/shinkro/internal/database" 11 | "github.com/varoOP/shinkro/internal/domain" 12 | "github.com/varoOP/shinkro/internal/malauth" 13 | "github.com/varoOP/shinkro/pkg/plex" 14 | "golang.org/x/oauth2" 15 | ) 16 | 17 | var ( 18 | pkce string 19 | state string 20 | authConfig *oauth2.Config 21 | CodeChallenge oauth2.AuthCodeOption 22 | ResponseType oauth2.AuthCodeOption = oauth2.SetAuthURLParam("response_type", "code") 23 | GrantType oauth2.AuthCodeOption = oauth2.SetAuthURLParam("grant_type", "authorization_code") 24 | CodeVerify oauth2.AuthCodeOption 25 | ) 26 | 27 | type authPageData struct { 28 | IsAuthenticated bool 29 | ActionURL string 30 | RetryURL string 31 | } 32 | 33 | func plexHandler(db *database.DB, cfg *domain.Config, log *zerolog.Logger, n *domain.Notification) func(w http.ResponseWriter, r *http.Request) { 34 | return func(w http.ResponseWriter, r *http.Request) { 35 | var err error 36 | a := domain.NewAnimeUpdate(db, cfg, log, n) 37 | a.Plex = r.Context().Value(domain.PlexPayload).(*plex.PlexWebhook) 38 | err = a.SendUpdate(r.Context()) 39 | if err != nil && err.Error() == "complete" { 40 | return 41 | } 42 | 43 | notify(&a, err) 44 | if err != nil { 45 | http.Error(w, "internal server error", http.StatusInternalServerError) 46 | a.Log.Error().Stack().Err(err).Msg("failed to send update to myanimelist") 47 | return 48 | } 49 | 50 | a.Log.Info(). 51 | Str("title", string(a.Media.Title)). 52 | Interface("listStatus", a.Malresp). 53 | Msg("Updated myanimelist successfully!") 54 | 55 | w.WriteHeader(http.StatusNoContent) 56 | } 57 | } 58 | 59 | func malAuthLogin() func(w http.ResponseWriter, r *http.Request) { 60 | return func(w http.ResponseWriter, r *http.Request) { 61 | if r.Method == http.MethodPost { 62 | var authMap map[string]string 63 | clientID := r.FormValue("clientID") 64 | clientSecret := r.FormValue("clientSecret") 65 | authConfig, authMap = malauth.GetOauth(r.Context(), clientID, clientSecret) 66 | pkce = authMap["pkce"] 67 | state = authMap["state"] 68 | http.Redirect(w, r, authMap["AuthCodeURL"], http.StatusFound) 69 | return 70 | } 71 | } 72 | } 73 | 74 | func malAuthCallback(cfg *domain.Config, db *database.DB, log *zerolog.Logger) func(w http.ResponseWriter, r *http.Request) { 75 | return func(w http.ResponseWriter, r *http.Request) { 76 | var code string 77 | u := joinUrlPath(cfg.BaseUrl, "/malauth/status") 78 | q := r.URL.Query() 79 | if len(q["code"]) >= 1 && len(q["state"]) >= 1 { 80 | code = q["code"][0] 81 | if state != q["state"][0] { 82 | http.Redirect(w, r, u, http.StatusSeeOther) 83 | log.Error().Err(errors.New("state did not match")).Str("state", q["state"][0]).Msg("") 84 | return 85 | } 86 | } 87 | 88 | CodeVerify = oauth2.SetAuthURLParam("code_verifier", pkce) 89 | token, err := authConfig.Exchange(r.Context(), code, GrantType, CodeVerify) 90 | if err != nil { 91 | http.Redirect(w, r, u, http.StatusSeeOther) 92 | log.Error().Err(err).Msg("") 93 | return 94 | } 95 | 96 | malauth.SaveToken(token, authConfig.ClientID, authConfig.ClientSecret, db) 97 | http.Redirect(w, r, u, http.StatusSeeOther) 98 | } 99 | } 100 | 101 | func malAuthStatus(cfg *domain.Config, db *database.DB) func(w http.ResponseWriter, r *http.Request) { 102 | return func(w http.ResponseWriter, r *http.Request) { 103 | if r.Method != http.MethodGet { 104 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 105 | return 106 | } 107 | 108 | tmpl, err := template.New("malauthstatus").Parse(malauth_statustpl) 109 | if err != nil { 110 | http.Error(w, "Unable to load template", http.StatusInternalServerError) 111 | return 112 | } 113 | 114 | isAuthenticated := false 115 | client, _ := malauth.NewOauth2Client(r.Context(), db) 116 | c := mal.NewClient(client) 117 | _, _, err = c.User.MyInfo(r.Context()) 118 | if err == nil { 119 | isAuthenticated = true 120 | } 121 | 122 | data := authPageData{ 123 | IsAuthenticated: isAuthenticated, 124 | RetryURL: joinUrlPath(cfg.BaseUrl, "/malauth"), 125 | } 126 | 127 | err = tmpl.Execute(w, data) 128 | if err != nil { 129 | http.Error(w, "Error rendering template", http.StatusInternalServerError) 130 | } 131 | } 132 | } 133 | 134 | func malAuth(cfg *domain.Config) func(w http.ResponseWriter, r *http.Request) { 135 | return func(w http.ResponseWriter, r *http.Request) { 136 | if r.Method != http.MethodGet { 137 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 138 | return 139 | } 140 | 141 | data := authPageData{ 142 | ActionURL: joinUrlPath(cfg.BaseUrl, "/malauth/login"), 143 | } 144 | 145 | tmpl, err := template.New("malauth").Parse(malauthtpl) 146 | if err != nil { 147 | http.Error(w, "Unable to load template", http.StatusInternalServerError) 148 | return 149 | } 150 | 151 | err = tmpl.Execute(w, data) 152 | if err != nil { 153 | http.Error(w, "Error rendering template", http.StatusInternalServerError) 154 | } 155 | } 156 | } 157 | 158 | func notFound(cfg *domain.Config) func(w http.ResponseWriter, r *http.Request) { 159 | return func(w http.ResponseWriter, r *http.Request) { 160 | u := joinUrlPath(cfg.BaseUrl, "/malauth") 161 | http.Redirect(w, r, u, http.StatusSeeOther) 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /internal/server/handlers_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "io" 8 | "net/http" 9 | "net/http/httptest" 10 | "os" 11 | "testing" 12 | 13 | "github.com/DATA-DOG/go-sqlmock" 14 | "github.com/rs/zerolog" 15 | "github.com/varoOP/shinkro/internal/database" 16 | "github.com/varoOP/shinkro/internal/domain" 17 | "github.com/varoOP/shinkro/pkg/plex" 18 | "golang.org/x/oauth2" 19 | ) 20 | 21 | type have struct { 22 | data string 23 | event string 24 | cfg *domain.Config 25 | db *database.DB 26 | } 27 | 28 | const ( 29 | scrobbleEvent = "media.scrobble" 30 | rateEvent = "media.rate" 31 | ) 32 | 33 | func TestPlex(t *testing.T) { 34 | 35 | tests := []struct { 36 | name string 37 | have have 38 | }{ 39 | { 40 | name: "HAMA_Episode_DB_Rate_1", 41 | have: have{ 42 | data: `{ 43 | "rating": 8.0, 44 | "event": "media.rate", 45 | "Account": { 46 | "title": "TestPlexUser" 47 | }, 48 | "Metadata": { 49 | "guid": "com.plexapp.agents.hama://anidb-17494/1/7?lang=en", 50 | "type": "episode", 51 | "parentIndex": 1, 52 | "index": 7, 53 | "grandparentTitle": "Tomo-chan wa Onnanoko!" 54 | } 55 | }`, 56 | event: rateEvent, 57 | cfg: &domain.Config{ 58 | PlexUser: "TestPlexUser", 59 | }, 60 | db: createMockDB(t, 52305), 61 | }, 62 | }, 63 | { 64 | name: "HAMA_Episode_DB_Scrobble_1", 65 | have: have{ 66 | data: `{ 67 | "event": "media.scrobble", 68 | "Account": { 69 | "title": "TestPlexUser" 70 | }, 71 | "Metadata": { 72 | "guid": "com.plexapp.agents.hama://anidb-17290/1/9?lang=en", 73 | "type": "episode", 74 | "parentIndex": 1, 75 | "index": 9, 76 | "grandparentTitle": "Isekai Nonbiri Nouka" 77 | } 78 | }`, 79 | event: scrobbleEvent, 80 | cfg: &domain.Config{ 81 | PlexUser: "TestPlexUser", 82 | }, 83 | db: createMockDB(t, 51462), 84 | }, 85 | }, 86 | { 87 | name: "HAMA_Episode_Mapping_Scrobble_1", 88 | have: have{ 89 | data: `{ 90 | "event": "media.scrobble", 91 | "Account": { 92 | "title": "TestPlexUser" 93 | }, 94 | "Metadata": { 95 | "guid": "com.plexapp.agents.hama://tvdb-289882/4/22?lang=en", 96 | "type": "episode", 97 | "parentIndex": 4, 98 | "index": 22, 99 | "grandparentTitle": "Dungeon ni Deai o Motomeru no wa Machigatte Iru Darouka: Familia Myth" 100 | } 101 | }`, 102 | event: scrobbleEvent, 103 | cfg: &domain.Config{ 104 | PlexUser: "TestPlexUser", 105 | }, 106 | db: createMockDB(t, 0), 107 | }, 108 | }, 109 | { 110 | name: "HAMA_Episode_Mapping_Scrobble_2", 111 | have: have{ 112 | data: `{ 113 | "event": "media.scrobble", 114 | "Account": { 115 | "title": "TestPlexUser" 116 | }, 117 | "Metadata": { 118 | "guid": "com.plexapp.agents.hama://tvdb-316842/0/38?lang=en", 119 | "type": "episode", 120 | "parentIndex": 0, 121 | "index": 38, 122 | "grandparentTitle": "Mahou Tsukai no Yome" 123 | } 124 | }`, 125 | event: scrobbleEvent, 126 | cfg: &domain.Config{ 127 | PlexUser: "TestPlexUser", 128 | }, 129 | db: createMockDB(t, 0), 130 | }, 131 | }, 132 | { 133 | name: "MAL_Movie_Scrobble_1", 134 | have: have{ 135 | data: `{ 136 | "event": "media.scrobble", 137 | "Account": { 138 | "title": "TestPlexUser" 139 | }, 140 | "Metadata": { 141 | "guid": "net.fribbtastic.coding.plex.myanimelist://28805?lang=en", 142 | "type": "movie", 143 | "parentIndex": 1, 144 | "index": 1, 145 | "title":"Mal_Movie" 146 | } 147 | }`, 148 | event: scrobbleEvent, 149 | cfg: &domain.Config{ 150 | PlexUser: "TestPlexUser", 151 | }, 152 | db: createMockDB(t, 0), 153 | }, 154 | }, 155 | { 156 | name: "MAL_Movie_Rate_1", 157 | have: have{ 158 | data: `{ 159 | "rating": 8.0, 160 | "event": "media.rate", 161 | "Account": { 162 | "title": "TestPlexUser" 163 | }, 164 | "Metadata": { 165 | "guid": "net.fribbtastic.coding.plex.myanimelist://32281?lang=en", 166 | "type": "movie", 167 | "parentIndex": 1, 168 | "index": 1, 169 | "title": "Mal_Movie" 170 | } 171 | }`, 172 | event: rateEvent, 173 | cfg: &domain.Config{ 174 | PlexUser: "TestPlexUser", 175 | }, 176 | db: createMockDB(t, 0), 177 | }, 178 | }, 179 | { 180 | name: "MAL_Episode_Scrobble_1", 181 | have: have{ 182 | data: `{ 183 | "event": "media.scrobble", 184 | "Account": { 185 | "title": "TestPlexUser" 186 | }, 187 | "Metadata": { 188 | "guid": "net.fribbtastic.coding.plex.myanimelist://52173/1/5?lang=en", 189 | "type": "episode", 190 | "parentIndex": 1, 191 | "index": 5, 192 | "grandparentTitle": "Mal_Episode" 193 | } 194 | }`, 195 | event: scrobbleEvent, 196 | cfg: &domain.Config{ 197 | PlexUser: "TestPlexUser", 198 | }, 199 | db: createMockDB(t, 0), 200 | }, 201 | }, 202 | { 203 | name: "MAL_Episode_Scrobble_2", 204 | have: have{ 205 | data: `{ 206 | "event": "media.scrobble", 207 | "Account": { 208 | "title": "TestPlexUser" 209 | }, 210 | "Metadata": { 211 | "guid": "net.fribbtastic.coding.plex.myanimelist://47160/2/2?lang=en", 212 | "type": "episode", 213 | "parentIndex": 2, 214 | "index": 2, 215 | "grandparentTitle": "Goblin Slayer II" 216 | } 217 | }`, 218 | event: scrobbleEvent, 219 | cfg: &domain.Config{ 220 | PlexUser: "TestPlexUser", 221 | }, 222 | db: createMockDB(t, 0), 223 | }, 224 | }, 225 | { 226 | name: "MAL_Episode_Rate_1", 227 | have: have{ 228 | data: `{ 229 | "rating": 7.0, 230 | "event": "media.rate", 231 | "Account": { 232 | "title": "TestPlexUser" 233 | }, 234 | "Metadata": { 235 | "guid": "net.fribbtastic.coding.plex.myanimelist://52305/1/7?lang=en", 236 | "type": "episode", 237 | "parentIndex": 1, 238 | "index": 7, 239 | "grandparentTitle": "Mal_Episode" 240 | } 241 | }`, 242 | event: rateEvent, 243 | cfg: &domain.Config{ 244 | PlexUser: "TestPlexUser", 245 | }, 246 | db: createMockDB(t, 0), 247 | }, 248 | }, 249 | } 250 | 251 | rr := httptest.NewRecorder() 252 | log := zerolog.New(os.Stdout).With().Logger() 253 | 254 | for _, tt := range tests { 255 | t.Run(tt.name, func(t *testing.T) { 256 | req := createRequest(t, tt.have.data) 257 | ServeHTTP := plexHandler(tt.have.db, tt.have.cfg, &log, &domain.Notification{}) 258 | ServeHTTP(rr, req) 259 | if rr.Result().StatusCode != 204 { 260 | t.Errorf("%s test failed", tt.name) 261 | } 262 | }) 263 | } 264 | } 265 | 266 | // func createMultipartForm(t *testing.T, data string) (*bytes.Buffer, *multipart.Writer) { 267 | 268 | // body := &bytes.Buffer{} 269 | 270 | // w := multipart.NewWriter(body) 271 | // defer w.Close() 272 | 273 | // fw, err := w.CreateFormField("payload") 274 | // if err != nil { 275 | // t.Fatal(err) 276 | // } 277 | 278 | // _, err = io.Copy(fw, strings.NewReader(data)) 279 | // if err != nil { 280 | // t.Fatal(err) 281 | // } 282 | 283 | // return body, w 284 | 285 | // } 286 | 287 | func createPlexPayload(data string) (*plex.PlexWebhook, error) { 288 | p, err := plex.NewPlexWebhook([]byte(data)) 289 | if err != nil { 290 | return nil, err 291 | } 292 | 293 | return p, nil 294 | } 295 | 296 | func createMalclient(t *testing.T) []string { 297 | var creds map[string]string 298 | token := &oauth2.Token{} 299 | 300 | unmarshal(t, "testdata/mal-credentials.json", &creds) 301 | unmarshal(t, "testdata/token.json", token) 302 | 303 | tt, err := json.Marshal(token) 304 | if err != nil { 305 | t.Fatal(err) 306 | } 307 | 308 | return []string{creds["client-id"], creds["client-secret"], string(tt)} 309 | } 310 | 311 | func createMockDB(t *testing.T, malid int) *database.DB { 312 | db, mock, err := sqlmock.New() 313 | if err != nil { 314 | t.Fatal("error creating mock database") 315 | } 316 | 317 | r := createMalclient(t) 318 | rows := sqlmock.NewRows([]string{"client_id", "client_secret", "access_token"}).AddRow(r[0], r[1], r[2]) 319 | mock.ExpectQuery(`SELECT client_id, client_secret, access_token from malauth;`).WillReturnRows(rows) 320 | rows = sqlmock.NewRows([]string{"mal_id"}).AddRow(malid) 321 | mock.ExpectQuery("SELECT mal_id from anime").WillReturnRows(rows) 322 | 323 | return &database.DB{ 324 | Handler: db, 325 | } 326 | } 327 | 328 | func unmarshal(t *testing.T, path string, v any) { 329 | f, err := os.Open(path) 330 | if err != nil { 331 | t.Skip() 332 | } 333 | 334 | defer f.Close() 335 | 336 | b, err := io.ReadAll(f) 337 | if err != nil { 338 | t.Fatal(err) 339 | } 340 | 341 | err = json.Unmarshal(b, v) 342 | if err != nil { 343 | t.Fatal(err) 344 | } 345 | } 346 | 347 | func createRequest(t *testing.T, data string) *http.Request { 348 | req := httptest.NewRequest("POST", "/", bytes.NewBuffer([]byte(""))) 349 | req.Header.Set("Content-Type", "application/json") 350 | p, err := createPlexPayload(data) 351 | if err != nil { 352 | t.Errorf("failed to create plex payload. err: %v", err) 353 | } 354 | 355 | _, agent := isMetadataAgent(p) 356 | 357 | ctx := req.Context() 358 | ctx = context.WithValue(ctx, domain.PlexPayload, p) 359 | ctx = context.WithValue(ctx, domain.Agent, agent) 360 | return req.WithContext(ctx) 361 | } 362 | -------------------------------------------------------------------------------- /internal/server/helpers.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "net/url" 7 | "strings" 8 | 9 | "github.com/pkg/errors" 10 | 11 | "github.com/rs/zerolog/hlog" 12 | "github.com/varoOP/shinkro/internal/domain" 13 | "github.com/varoOP/shinkro/internal/tautulli" 14 | "github.com/varoOP/shinkro/pkg/plex" 15 | ) 16 | 17 | const InternalServerError string = "internal server error" 18 | 19 | func isMetadataAgent(p *plex.PlexWebhook) (bool, string) { 20 | agents := map[string]string{ 21 | "agents.hama": "hama", 22 | "myanimelist": "mal", 23 | "plex://": "plex", 24 | } 25 | 26 | for key, value := range agents { 27 | if strings.Contains(p.Metadata.GUID.GUID, key) || strings.Contains(p.Metadata.GrandparentGUID, key) { 28 | return true, value 29 | } 30 | } 31 | 32 | return false, "" 33 | } 34 | 35 | func isPlexUser(p *plex.PlexWebhook, c *domain.Config) bool { 36 | return p.Account.Title == c.PlexUser 37 | } 38 | 39 | func isEvent(p *plex.PlexWebhook) bool { 40 | return p.Event == "media.rate" || p.Event == "media.scrobble" 41 | } 42 | 43 | func isAnimeLibrary(p *plex.PlexWebhook, c *domain.Config) bool { 44 | l := strings.Join(c.AnimeLibraries, ",") 45 | return strings.Contains(l, p.Metadata.LibrarySectionTitle) 46 | } 47 | 48 | func mediaType(p *plex.PlexWebhook) bool { 49 | if p.Metadata.Type == "episode" { 50 | return true 51 | } 52 | 53 | if p.Metadata.Type == "movie" { 54 | return true 55 | } 56 | 57 | return false 58 | } 59 | 60 | func notify(a *domain.AnimeUpdate, err error) { 61 | if a.Notify.Url == "" { 62 | return 63 | } 64 | 65 | if err != nil { 66 | a.Notify.Error <- err 67 | return 68 | } 69 | 70 | a.Notify.Anime <- *a 71 | } 72 | 73 | func isAuthorized(apiKey string, in map[string][]string) bool { 74 | if keys, ok := in["apiKey"]; ok { 75 | for _, vv := range keys { 76 | if vv == apiKey { 77 | return true 78 | } 79 | } 80 | } 81 | 82 | if keys, ok := in["Shinkro-Api-Key"]; ok { 83 | for _, vv := range keys { 84 | if vv == apiKey { 85 | return true 86 | } 87 | } 88 | } 89 | 90 | return false 91 | } 92 | 93 | func contentType(r *http.Request) string { 94 | contentType := r.Header.Get("Content-Type") 95 | if strings.Contains(contentType, "multipart/form-data") { 96 | return "plexWebhook" 97 | } 98 | 99 | if strings.Contains(contentType, "application/json") { 100 | return "tautulli" 101 | } 102 | 103 | return contentType 104 | } 105 | 106 | func readRequest(r *http.Request) (string, error) { 107 | b, err := io.ReadAll(r.Body) 108 | if err != nil { 109 | return "", err 110 | } 111 | 112 | defer r.Body.Close() 113 | return string(b), nil 114 | } 115 | 116 | func joinUrlPath(base, extra string) string { 117 | u, err := url.JoinPath(base, extra) 118 | if err != nil { 119 | return extra 120 | } 121 | 122 | return u 123 | } 124 | 125 | func parsePayloadBySourceType(w http.ResponseWriter, r *http.Request, sourceType string) (*plex.PlexWebhook, error) { 126 | log := hlog.FromRequest(r) 127 | switch sourceType { 128 | case "plexWebhook": 129 | return handlePlexWebhook(w, r) 130 | 131 | case "tautulli": 132 | return handleTautulli(w, r) 133 | 134 | default: 135 | log.Error().Str("sourceType", sourceType).Msg("sourceType not supported") 136 | return nil, errors.New("unsupported source type") 137 | } 138 | } 139 | 140 | func handlePlexWebhook(w http.ResponseWriter, r *http.Request) (*plex.PlexWebhook, error) { 141 | log := hlog.FromRequest(r) 142 | if err := r.ParseMultipartForm(0); err != nil { 143 | http.Error(w, "received bad request", http.StatusBadRequest) 144 | log.Trace().Err(err).Msg("received bad request") 145 | return nil, err 146 | } 147 | 148 | ps := r.PostFormValue("payload") 149 | if ps == "" { 150 | log.Info().Msg("Received empty payload from Plex, webhook added successfully.") 151 | w.WriteHeader(http.StatusNoContent) 152 | return nil, errors.New("empty paylod") 153 | } 154 | 155 | log.Trace().RawJSON("rawPlexPayload", []byte(ps)).Msg("") 156 | return plex.NewPlexWebhook([]byte(ps)) 157 | } 158 | 159 | func handleTautulli(w http.ResponseWriter, r *http.Request) (*plex.PlexWebhook, error) { 160 | log := hlog.FromRequest(r) 161 | ps, err := readRequest(r) 162 | if err != nil { 163 | http.Error(w, InternalServerError, http.StatusInternalServerError) 164 | log.Trace().Err(err).Msg(InternalServerError) 165 | return nil, err 166 | } 167 | 168 | log.Trace().RawJSON("rawPlexPayload", []byte(ps)).Msg("") 169 | return tautulli.ToPlex([]byte(ps)) 170 | } 171 | -------------------------------------------------------------------------------- /internal/server/middleware.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "strings" 8 | 9 | "github.com/nstratos/go-myanimelist/mal" 10 | "github.com/pkg/errors" 11 | 12 | "github.com/rs/zerolog/hlog" 13 | "github.com/varoOP/shinkro/internal/database" 14 | "github.com/varoOP/shinkro/internal/domain" 15 | "github.com/varoOP/shinkro/internal/malauth" 16 | "github.com/varoOP/shinkro/pkg/plex" 17 | ) 18 | 19 | func auth(cfg *domain.Config) func(next http.Handler) http.Handler { 20 | return func(next http.Handler) http.Handler { 21 | fn := func(w http.ResponseWriter, r *http.Request) { 22 | log := hlog.FromRequest(r) 23 | if !isAuthorized(cfg.ApiKey, r.URL.Query()) && !isAuthorized(cfg.ApiKey, r.Header) { 24 | http.Error(w, "Unauthorized", http.StatusUnauthorized) 25 | log.Error().Err(errors.New("ApiKey invalid")).Msg("") 26 | log.Debug().Str("query", fmt.Sprintf("%v", r.URL.Query())).Str("headers", fmt.Sprintf("%v", r.Header)).Msg("") 27 | return 28 | } 29 | 30 | next.ServeHTTP(w, r) 31 | } 32 | 33 | return http.HandlerFunc(fn) 34 | } 35 | } 36 | 37 | func basicAuth(username, password string) func(http.Handler) http.Handler { 38 | return func(next http.Handler) http.Handler { 39 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 40 | user, pass, ok := r.BasicAuth() 41 | if !ok || user != username || pass != password { 42 | w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`) 43 | http.Error(w, "Unauthorized.", http.StatusUnauthorized) 44 | return 45 | } 46 | next.ServeHTTP(w, r) 47 | }) 48 | } 49 | } 50 | 51 | func checkMalAuth(db *database.DB) func(next http.Handler) http.Handler { 52 | return func(next http.Handler) http.Handler { 53 | fn := func(w http.ResponseWriter, r *http.Request) { 54 | log := hlog.FromRequest(r) 55 | client, _ := malauth.NewOauth2Client(r.Context(), db) 56 | c := mal.NewClient(client) 57 | _, _, err := c.User.MyInfo(r.Context()) 58 | if err == nil { 59 | w.Write([]byte("Authentication with myanimelist is successful.")) 60 | log.Trace().Msg("user already authenticated") 61 | return 62 | } 63 | 64 | next.ServeHTTP(w, r) 65 | } 66 | 67 | return http.HandlerFunc(fn) 68 | } 69 | } 70 | 71 | func onlyAllowPost(next http.Handler) http.Handler { 72 | fn := func(w http.ResponseWriter, r *http.Request) { 73 | log := hlog.FromRequest(r) 74 | if r.Method != http.MethodPost { 75 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 76 | log.Error().Err(errors.New("method not allowed")).Msg("") 77 | return 78 | } 79 | 80 | next.ServeHTTP(w, r) 81 | } 82 | 83 | return http.HandlerFunc(fn) 84 | } 85 | 86 | func parsePlexPayload(next http.Handler) http.Handler { 87 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 88 | log := hlog.FromRequest(r) 89 | 90 | sourceType := contentType(r) 91 | log.Trace().Str("sourceType", sourceType).Msg("") 92 | 93 | payload, err := parsePayloadBySourceType(w, r, sourceType) 94 | if err != nil { 95 | return 96 | } 97 | 98 | ctx := context.WithValue(r.Context(), domain.PlexPayload, payload) 99 | log.Debug().Str("parsedPlexPayload", fmt.Sprintf("%+v", payload)).Msg("") 100 | next.ServeHTTP(w, r.WithContext(ctx)) 101 | }) 102 | } 103 | 104 | func checkPlexPayload(cfg *domain.Config) func(next http.Handler) http.Handler { 105 | return func(next http.Handler) http.Handler { 106 | fn := func(w http.ResponseWriter, r *http.Request) { 107 | log := hlog.FromRequest(r) 108 | p := r.Context().Value(domain.PlexPayload).(*plex.PlexWebhook) 109 | aa := "Accepted" 110 | if !isPlexUser(p, cfg) { 111 | http.Error(w, "Unauthorized", http.StatusUnauthorized) 112 | log.Error().Err(errors.New("unauthorized plex user")). 113 | Str("plexUserReceived", p.Account.Title). 114 | Str("AuthorizedPlexUser", cfg.PlexUser). 115 | Msg("") 116 | 117 | return 118 | } 119 | 120 | if !isEvent(p) { 121 | http.Error(w, aa, http.StatusAccepted) 122 | log.Trace().Err(errors.New("incorrect event")). 123 | Str("event", p.Event). 124 | Str("allowedEvents", "media.scrobble, media.rate"). 125 | Msg("") 126 | 127 | return 128 | } 129 | 130 | if !isAnimeLibrary(p, cfg) { 131 | http.Error(w, aa, http.StatusAccepted) 132 | log.Error().Err(errors.New("not an anime library")). 133 | Str("library received", p.Metadata.LibrarySectionTitle). 134 | Str("anime libraries", strings.Join(cfg.AnimeLibraries, ",")). 135 | Msg("") 136 | 137 | return 138 | } 139 | 140 | allowed, agent := isMetadataAgent(p) 141 | if !allowed { 142 | http.Error(w, aa, http.StatusAccepted) 143 | log.Debug().Err(errors.New("unsupported metadata agent")). 144 | Str("guid", string(p.Metadata.GUID.GUID)). 145 | Str("supported metadata agents", "HAMA, MyAnimeList.bundle, Plex Series, Plex Movie"). 146 | Msg("") 147 | 148 | return 149 | } 150 | 151 | mediaTypeOk := mediaType(p) 152 | if !mediaTypeOk { 153 | http.Error(w, aa, http.StatusAccepted) 154 | log.Debug().Err(errors.New("unsupported media type")). 155 | Str("media type", p.Metadata.Type). 156 | Str("supported media types", "episode, movie"). 157 | Msg("") 158 | 159 | return 160 | } 161 | 162 | ctx := context.WithValue(r.Context(), domain.Agent, agent) 163 | next.ServeHTTP(w, r.WithContext(ctx)) 164 | } 165 | 166 | return http.HandlerFunc(fn) 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /internal/server/routes.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | "time" 7 | 8 | "github.com/go-chi/chi/v5" 9 | "github.com/go-chi/chi/v5/middleware" 10 | "github.com/rs/zerolog" 11 | "github.com/rs/zerolog/hlog" 12 | "github.com/varoOP/shinkro/internal/database" 13 | "github.com/varoOP/shinkro/internal/domain" 14 | ) 15 | 16 | func NewRouter(cfg *domain.Config, db *database.DB, n *domain.Notification, log zerolog.Logger) chi.Router { 17 | r := chi.NewRouter() 18 | r.Use(middleware.Recoverer) 19 | r.Use(hlog.NewHandler(log)) 20 | r.Use(hlog.AccessHandler(func(r *http.Request, status, size int, duration time.Duration) { 21 | hlog.FromRequest(r).Debug(). 22 | Int("status", status). 23 | Dur("duration", duration). 24 | Msg("Request processed") 25 | })) 26 | 27 | baseUrl, err := url.JoinPath("/", cfg.BaseUrl) 28 | if err != nil { 29 | log.Error().Err(err).Msg("") 30 | } 31 | 32 | r.Route(baseUrl, func(r chi.Router) { 33 | r.Route("/api", func(r chi.Router) { 34 | r.Use(auth(cfg)) 35 | r.Route("/plex", func(r chi.Router) { 36 | r.Use(onlyAllowPost, middleware.AllowContentType("application/json", "multipart/form-data"), parsePlexPayload, checkPlexPayload(cfg)) 37 | r.Post("/", plexHandler(db, cfg, &log, n)) 38 | }) 39 | }) 40 | 41 | r.Route("/malauth", func(r chi.Router) { 42 | r.Use(basicAuth(cfg.Username, cfg.Password)) 43 | r.With(checkMalAuth(db)).Get("/", malAuth(cfg)) 44 | r.Post("/login", malAuthLogin()) 45 | r.Get("/callback", malAuthCallback(cfg, db, &log)) 46 | r.Get("/status", malAuthStatus(cfg, db)) 47 | }) 48 | 49 | r.NotFound(notFound(cfg)) 50 | }) 51 | 52 | return r 53 | } 54 | -------------------------------------------------------------------------------- /internal/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/rs/zerolog" 8 | "github.com/varoOP/shinkro/internal/database" 9 | "github.com/varoOP/shinkro/internal/domain" 10 | ) 11 | 12 | type Server struct { 13 | config *domain.Config 14 | notify *domain.Notification 15 | db *database.DB 16 | log zerolog.Logger 17 | } 18 | 19 | func NewServer(cfg *domain.Config, n *domain.Notification, db *database.DB, log *zerolog.Logger) *Server { 20 | return &Server{ 21 | config: cfg, 22 | notify: n, 23 | db: db, 24 | log: log.With().Str("module", "server").Logger(), 25 | } 26 | } 27 | 28 | func (s *Server) Start() { 29 | router := NewRouter(s.config, s.db, s.notify, s.log) 30 | addr := fmt.Sprintf("%v:%v", s.config.Host, s.config.Port) 31 | s.log.Info().Msgf("Starting HTTP server on %v", addr) 32 | if err := http.ListenAndServe(addr, router); err != nil { 33 | s.log.Fatal().Err(err).Msg("failed to start http server") 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /internal/server/templates.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | const malauthtpl string = ` 4 | 5 | 6 | 7 | 8 | 9 | shinkro 10 | 15 | 81 | 82 | 83 |
84 |
85 | Logo 89 |
90 |

shinkro

91 |

Authenticate with myanimelist.net

92 | 93 | 94 | 95 | 96 | 97 |
98 | 99 | 100 | ` 101 | 102 | const malauth_statustpl string = ` 103 | 104 | 105 | 106 | 107 | 108 | shinkro 109 | 114 | 153 | 154 | 155 |
156 | Logo 160 |
161 |

shinkro

162 | {{if .IsAuthenticated}} 163 |

Authentication Success

164 |

You can close this window now.

165 | {{else}} 166 |

Authentication Error

167 | 168 | 169 | 170 | {{end}} 171 | 172 | 173 | ` 174 | -------------------------------------------------------------------------------- /internal/server/testdata/mal-credentials.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "client-id": "", 3 | "client-secret": "" 4 | } -------------------------------------------------------------------------------- /internal/server/testdata/token.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "access_token": "", 3 | "token_type": "Bearer", 4 | "refresh_token": "", 5 | "expiry": "" 6 | } 7 | -------------------------------------------------------------------------------- /internal/tautulli/tautulli.go: -------------------------------------------------------------------------------- 1 | package tautulli 2 | 3 | import ( 4 | "encoding/json" 5 | "strconv" 6 | 7 | "github.com/varoOP/shinkro/pkg/plex" 8 | ) 9 | 10 | type Tautulli struct { 11 | Account struct { 12 | Title string `json:"title"` 13 | } `json:"Account"` 14 | Metadata struct { 15 | GrandparentKey string `json:"grandparentKey"` 16 | GrandparentTitle string `json:"grandparentTitle"` 17 | GUID plex.GUID `json:"guid"` 18 | Index string `json:"index"` 19 | LibrarySectionTitle string `json:"librarySectionTitle"` 20 | ParentIndex string `json:"parentIndex"` 21 | Title string `json:"title"` 22 | Type string `json:"type"` 23 | } `json:"Metadata"` 24 | Event string `json:"event"` 25 | } 26 | 27 | func NewTautulli(b []byte) (*Tautulli, error) { 28 | t := &Tautulli{} 29 | err := json.Unmarshal(b, t) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | return t, nil 35 | } 36 | 37 | func ToPlex(b []byte) (*plex.PlexWebhook, error) { 38 | t, err := NewTautulli(b) 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | parentIndex, err := strconv.Atoi(t.Metadata.ParentIndex) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | index, err := strconv.Atoi(t.Metadata.Index) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | return &plex.PlexWebhook{ 54 | Event: t.Event, 55 | Account: struct { 56 | Id int `json:"id"` 57 | ThumbnailUrl string `json:"thumb"` 58 | Title string `json:"title"` 59 | }{ 60 | Title: t.Account.Title, 61 | }, 62 | Metadata: plex.Metadata{ 63 | GrandparentKey: t.Metadata.GrandparentKey, 64 | GrandparentTitle: t.Metadata.GrandparentTitle, 65 | GUID: t.Metadata.GUID, 66 | Index: index, 67 | LibrarySectionTitle: t.Metadata.LibrarySectionTitle, 68 | ParentIndex: parentIndex, 69 | Title: t.Metadata.Title, 70 | Type: t.Metadata.Type, 71 | }, 72 | }, nil 73 | } 74 | -------------------------------------------------------------------------------- /pkg/plex/client.go: -------------------------------------------------------------------------------- 1 | package plex 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | "runtime" 10 | 11 | "github.com/pkg/errors" 12 | ) 13 | 14 | func NewPlexClient(url string, token string) *PlexClient { 15 | return &PlexClient{ 16 | Url: url, 17 | Token: token, 18 | } 19 | } 20 | 21 | func (p *PlexClient) GetShowID(key string) (*GUID, error) { 22 | baseUrl, err := url.Parse(p.Url) 23 | if err != nil { 24 | return nil, errors.Wrap(err, "plex url invalid") 25 | } 26 | 27 | baseUrl = baseUrl.JoinPath(key) 28 | params := url.Values{} 29 | params.Add("X-Plex-Token", p.Token) 30 | baseUrl.RawQuery = params.Encode() 31 | req, err := http.NewRequest(http.MethodGet, baseUrl.String(), nil) 32 | if err != nil { 33 | return nil, errors.Errorf("%v, request=%v", err, *req) 34 | } 35 | 36 | req.Header.Set("Content-Type", "application/json") 37 | req.Header.Set("Accept", "application/json") 38 | req.Header.Add("ContainerStart", "X-Plex-Container-Start=0") 39 | req.Header.Add("ContainerSize", "Plex-Container-Size=100") 40 | req.Header.Set("User-Agent", fmt.Sprintf("shinkro/%v (%v;%v)", runtime.Version(), runtime.GOOS, runtime.GOARCH)) 41 | 42 | resp, err := p.Client.Do(req) 43 | if err != nil { 44 | return nil, errors.Wrap(err, "network error") 45 | } 46 | 47 | body, err := io.ReadAll(resp.Body) 48 | if err != nil { 49 | return nil, errors.Errorf("%v, response status: %v, response body: %v", err, resp.StatusCode, string(body)) 50 | } 51 | 52 | defer resp.Body.Close() 53 | err = json.Unmarshal(body, &p.Resp) 54 | if err != nil { 55 | return nil, errors.Errorf("%v, response status: %v, response body: %v", err, resp.StatusCode, string(body)) 56 | } 57 | 58 | if len(p.Resp.MediaContainer.Metadata) == 1 { 59 | return &p.Resp.MediaContainer.Metadata[0].GUID, nil 60 | } 61 | 62 | return nil, errors.Errorf("something went wrong in getting guid from plex:%v, response status: %v, response body: %v", err, resp.StatusCode, string(body)) 63 | } 64 | -------------------------------------------------------------------------------- /pkg/plex/plex.go: -------------------------------------------------------------------------------- 1 | package plex 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | ) 8 | 9 | type PlexClient struct { 10 | Url string 11 | Token string 12 | Client http.Client 13 | Resp PlexResponse 14 | } 15 | 16 | type PlexResponse struct { 17 | MediaContainer struct { 18 | Size int `json:"size"` 19 | AllowSync bool `json:"allowSync"` 20 | Identifier string `json:"identifier"` 21 | LibrarySectionID int `json:"librarySectionID"` 22 | LibrarySectionTitle string `json:"librarySectionTitle"` 23 | LibrarySectionUUID string `json:"librarySectionUUID"` 24 | MediaTagPrefix string `json:"mediaTagPrefix"` 25 | MediaTagVersion int `json:"mediaTagVersion"` 26 | Metadata []Metadata `json:"metadata"` 27 | } `json:"MediaContainer"` 28 | } 29 | 30 | type Metadata struct { 31 | RatingGlobal float32 `json:"rating"` 32 | RatingKey string `json:"ratingKey"` 33 | Key string `json:"key"` 34 | ParentRatingKey string `json:"parentRatingKey"` 35 | GrandparentRatingKey string `json:"grandparentRatingKey"` 36 | GUID GUID `json:"guid"` 37 | ParentGUID string `json:"parentGuid"` 38 | GrandparentGUID string `json:"grandparentGuid"` 39 | Type string `json:"type"` 40 | Title string `json:"title"` 41 | GrandparentKey string `json:"grandparentKey"` 42 | ParentKey string `json:"parentKey"` 43 | LibrarySectionTitle string `json:"librarySectionTitle"` 44 | LibrarySectionID int `json:"librarySectionID"` 45 | LibrarySectionKey string `json:"librarySectionKey"` 46 | GrandparentTitle string `json:"grandparentTitle"` 47 | ParentTitle string `json:"parentTitle"` 48 | OriginalTitle string `json:"originalTitle"` 49 | ContentRating string `json:"contentRating"` 50 | Summary string `json:"summary"` 51 | Index int `json:"index"` 52 | ParentIndex int `json:"parentIndex"` 53 | AudienceRating float64 `json:"audienceRating"` 54 | UserRating float64 `json:"userRating"` 55 | LastRatedAt int `json:"lastRatedAt"` 56 | Year int `json:"year"` 57 | Thumb string `json:"thumb"` 58 | Art string `json:"art"` 59 | GrandparentThumb string `json:"grandparentThumb"` 60 | GrandparentArt string `json:"grandparentArt"` 61 | Duration int `json:"duration"` 62 | OriginallyAvailableAt string `json:"originallyAvailableAt"` 63 | AddedAt int `json:"addedAt"` 64 | UpdatedAt int `json:"updatedAt"` 65 | AudienceRatingImage string `json:"audienceRatingImage"` 66 | Media []struct { 67 | ID int `json:"id"` 68 | Duration int `json:"duration"` 69 | Bitrate int `json:"bitrate"` 70 | Width int `json:"width"` 71 | Height int `json:"height"` 72 | AspectRatio float64 `json:"aspectRatio"` 73 | AudioChannels int `json:"audioChannels"` 74 | AudioCodec string `json:"audioCodec"` 75 | VideoCodec string `json:"videoCodec"` 76 | VideoResolution string `json:"videoResolution"` 77 | Container string `json:"container"` 78 | VideoFrameRate string `json:"videoFrameRate"` 79 | AudioProfile string `json:"audioProfile"` 80 | VideoProfile string `json:"videoProfile"` 81 | Part []struct { 82 | ID int `json:"id"` 83 | Key string `json:"key"` 84 | Duration int `json:"duration"` 85 | File string `json:"file"` 86 | Size int `json:"size"` 87 | AudioProfile string `json:"audioProfile"` 88 | Container string `json:"container"` 89 | Indexes string `json:"indexes"` 90 | VideoProfile string `json:"videoProfile"` 91 | Stream []struct { 92 | ID int `json:"id"` 93 | StreamType int `json:"streamType"` 94 | Default bool `json:"default"` 95 | Codec string `json:"codec"` 96 | Index int `json:"index"` 97 | Bitrate int `json:"bitrate,omitempty"` 98 | BitDepth int `json:"bitDepth,omitempty"` 99 | ChromaLocation string `json:"chromaLocation,omitempty"` 100 | ChromaSubsampling string `json:"chromaSubsampling,omitempty"` 101 | CodedHeight int `json:"codedHeight,omitempty"` 102 | CodedWidth int `json:"codedWidth,omitempty"` 103 | ColorPrimaries string `json:"colorPrimaries,omitempty"` 104 | ColorRange string `json:"colorRange,omitempty"` 105 | ColorSpace string `json:"colorSpace,omitempty"` 106 | ColorTrc string `json:"colorTrc,omitempty"` 107 | FrameRate float64 `json:"frameRate,omitempty"` 108 | HasScalingMatrix bool `json:"hasScalingMatrix,omitempty"` 109 | Height int `json:"height,omitempty"` 110 | Level int `json:"level,omitempty"` 111 | Profile string `json:"profile,omitempty"` 112 | RefFrames int `json:"refFrames,omitempty"` 113 | ScanType string `json:"scanType,omitempty"` 114 | Width int `json:"width,omitempty"` 115 | DisplayTitle string `json:"displayTitle"` 116 | ExtendedDisplayTitle string `json:"extendedDisplayTitle"` 117 | Selected bool `json:"selected,omitempty"` 118 | Channels int `json:"channels,omitempty"` 119 | Language string `json:"language,omitempty"` 120 | LanguageTag string `json:"languageTag,omitempty"` 121 | LanguageCode string `json:"languageCode,omitempty"` 122 | AudioChannelLayout string `json:"audioChannelLayout,omitempty"` 123 | SamplingRate int `json:"samplingRate,omitempty"` 124 | Title string `json:"title,omitempty"` 125 | } `json:"Stream"` 126 | } `json:"Part"` 127 | } `json:"Media"` 128 | Rating []struct { 129 | Image string `json:"image"` 130 | Value float64 `json:"value"` 131 | Type string `json:"type"` 132 | } `json:"Rating"` 133 | Director []struct { 134 | ID int `json:"id"` 135 | Filter string `json:"filter"` 136 | Tag string `json:"tag"` 137 | TagKey string `json:"tagKey"` 138 | } `json:"Director"` 139 | Writer []struct { 140 | ID int `json:"id"` 141 | Filter string `json:"filter"` 142 | Tag string `json:"tag"` 143 | TagKey string `json:"tagKey"` 144 | Thumb string `json:"thumb"` 145 | } `json:"Writer"` 146 | Role []struct { 147 | ID int `json:"id"` 148 | Filter string `json:"filter"` 149 | Tag string `json:"tag"` 150 | TagKey string `json:"tagKey"` 151 | Role string `json:"role"` 152 | Thumb string `json:"thumb,omitempty"` 153 | } `json:"Role"` 154 | } 155 | 156 | type GUID struct { 157 | GUIDS []struct { 158 | ID string `json:"id"` 159 | } 160 | 161 | GUID string 162 | } 163 | 164 | func (g *GUID) UnmarshalJSON(data []byte) error { 165 | // Try to unmarshal as a string first 166 | if err := json.Unmarshal(data, &g.GUID); err == nil { 167 | return nil 168 | } 169 | 170 | // If it's not a string, try to unmarshal as an anonymous slice of struct 171 | if err := json.Unmarshal(data, &g.GUIDS); err == nil { 172 | return nil 173 | } 174 | 175 | return fmt.Errorf("guid: cannot unmarshal %q", data) 176 | } 177 | -------------------------------------------------------------------------------- /pkg/plex/webhook.go: -------------------------------------------------------------------------------- 1 | package plex 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/pkg/errors" 7 | ) 8 | 9 | type PlexWebhook struct { 10 | Rating float32 `json:"rating"` 11 | Event string `json:"event"` 12 | User bool `json:"user"` 13 | Owner bool `json:"owner"` 14 | Account struct { 15 | Id int `json:"id"` 16 | ThumbnailUrl string `json:"thumb"` 17 | Title string `json:"title"` 18 | } `json:"Account"` 19 | Server struct { 20 | Title string `json:"title"` 21 | UUID string `json:"uuid"` 22 | } `json:"Server"` 23 | Player struct { 24 | Local bool `json:"local"` 25 | PublicAddress string `json:"publicAddress"` 26 | Title string `json:"title"` 27 | UUID string `json:"uuid"` 28 | } `json:"Player"` 29 | Metadata Metadata `json:"Metadata"` 30 | } 31 | 32 | func NewPlexWebhook(payload []byte) (*PlexWebhook, error) { 33 | p := &PlexWebhook{} 34 | err := json.Unmarshal(payload, p) 35 | if err != nil { 36 | return nil, errors.Wrap(err, "failed to unmarshal plex payload") 37 | } 38 | 39 | return p, nil 40 | } 41 | --------------------------------------------------------------------------------