├── .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 | 
3 | shinkro
4 |
5 |
6 | An application to sync Plex watch status to myanimelist.
7 |
8 |

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 |
98 |
99 |
100 | `
101 |
102 | const malauth_statustpl string = `
103 |
104 |
105 |
106 |
107 |
108 | shinkro
109 |
114 |
153 |
154 |
155 |
156 |

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