├── .dockerignore ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.yml ├── dependabot.yml └── workflows │ ├── codeql.yml │ └── release.yml ├── .gitignore ├── .goreleaser.yaml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── ci.Dockerfile ├── cmd ├── genToken.go ├── pack.go ├── parse.go ├── root.go ├── start.go ├── test.go └── version.go ├── config.yaml ├── distrib └── systemd │ └── seasonpackarr@.service ├── docker-compose.yml ├── go.mod ├── go.sum ├── internal ├── api │ └── token.go ├── buildinfo │ └── buildinfo.go ├── config │ └── config.go ├── domain │ ├── config.go │ ├── http.go │ ├── notification.go │ └── release.go ├── files │ └── hardlink.go ├── format │ ├── format.go │ └── format_test.go ├── http │ ├── health.go │ ├── middleware.go │ ├── processor.go │ ├── server.go │ └── webhook.go ├── logger │ └── logger.go ├── metadata │ ├── metadata.go │ ├── tvdb.go │ ├── tvmaze.go │ └── tvmaze_test.go ├── notification │ ├── discord.go │ └── message_builder.go ├── payload │ └── payload.go ├── release │ ├── release.go │ └── release_test.go ├── slices │ ├── slices.go │ └── slices_test.go └── torrents │ ├── decode.go │ ├── mock.go │ └── torrents.go ├── main.go ├── pkg └── errors │ └── errors.go └── schemas └── config-schema.json /.dockerignore: -------------------------------------------------------------------------------- 1 | .github 2 | distrib 3 | .gitignore 4 | .goreleaser.yaml 5 | config.toml 6 | config.yaml 7 | docker-compose.yml 8 | Dockerfile 9 | README.md -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Create a report to help me improve 3 | title: "[Bug]" 4 | labels: [ "Type: Bug", "Status: Needs Triage" ] 5 | body: 6 | - type: dropdown 7 | id: version 8 | attributes: 9 | label: Version 10 | description: The seasonpackarr version you're running. 11 | options: 12 | - v0.12.1 13 | - v0.12.0 14 | - v0.11.0 15 | - v0.10.1 16 | - v0.10.0 17 | - v0.9.0 18 | - v0.8.5 19 | - v0.8.4 20 | - v0.8.3 21 | - v0.8.2 22 | - v0.8.1 23 | - v0.8.0 24 | - v0.7.0 25 | - v0.6.0 26 | - v0.5.0 27 | - v0.4.0 28 | - v0.3.0 29 | - v0.2.6 30 | - v0.2.5 31 | - v0.2.4 32 | - v0.2.3 33 | - v0.2.2 34 | - v0.2.1 35 | - v0.2.0 36 | - v0.1.0 37 | - v0.0.2 38 | - v0.0.1 39 | default: 0 40 | validations: 41 | required: true 42 | - type: textarea 43 | id: description 44 | attributes: 45 | label: Description 46 | description: A clear and concise description of what the bug is. 47 | placeholder: Describe the bug... 48 | validations: 49 | required: true 50 | - type: textarea 51 | id: expected-behavior 52 | attributes: 53 | label: Expected Behavior 54 | description: A clear and concise description of what you expected to happen. 55 | placeholder: Describe what you expected to happen... 56 | validations: 57 | required: true 58 | - type: textarea 59 | id: logs 60 | attributes: 61 | label: Relevant log output 62 | description: Please copy and paste any relevant log output. This will be automatically formatted into code. 63 | render: shell 64 | validations: 65 | required: true 66 | - type: textarea 67 | id: screenshots 68 | attributes: 69 | label: Screenshots 70 | description: If applicable, add screenshots to help explain your problem. You can add image URLs or use any other method to display the screenshot (provided GitHub supports it). 71 | placeholder: Add screenshot... 72 | validations: 73 | required: false 74 | - type: textarea 75 | id: context 76 | attributes: 77 | label: Additional Context 78 | description: Add any other context about the problem here. 79 | placeholder: Add context... 80 | validations: 81 | required: false 82 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest an idea for this project 3 | title: "[Feature Request]" 4 | body: 5 | - type: textarea 6 | id: description 7 | attributes: 8 | label: Description 9 | description: A clear and concise description of what you want to happen. 10 | placeholder: Describe the feature... 11 | validations: 12 | required: true 13 | - type: textarea 14 | id: alternatives 15 | attributes: 16 | label: Alternatives 17 | description: A clear and concise description of any alternative solutions or features you've considered. 18 | placeholder: Describe alternative solutions... 19 | validations: 20 | required: false 21 | - type: textarea 22 | id: screenshots 23 | attributes: 24 | label: Screenshots 25 | description: If applicable, add screenshots to help explain your requested feature. You can add image URLs or use any other method to display the screenshot (provided GitHub supports it). 26 | placeholder: Add screenshot... 27 | validations: 28 | required: false 29 | - type: textarea 30 | id: context 31 | attributes: 32 | label: Additional Context 33 | description: Add any other context about the feature here. 34 | placeholder: Add context... 35 | validations: 36 | required: false -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: weekly 7 | day: saturday 8 | time: "07:00" 9 | groups: 10 | github: 11 | patterns: 12 | - "*" 13 | 14 | - package-ecosystem: gomod 15 | directory: / 16 | schedule: 17 | interval: monthly 18 | groups: 19 | golang: 20 | patterns: 21 | - "*" -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "develop" ] 17 | pull_request: 18 | branches: [ "develop" ] 19 | schedule: 20 | - cron: '0 4 * * 6' 21 | 22 | env: 23 | GO_VERSION: '1.24.2' 24 | 25 | jobs: 26 | analyze: 27 | name: Analyze (${{ matrix.language }}) 28 | # Runner size impacts CodeQL analysis time. To learn more, please see: 29 | # - https://gh.io/recommended-hardware-resources-for-running-codeql 30 | # - https://gh.io/supported-runners-and-hardware-resources 31 | # - https://gh.io/using-larger-runners (GitHub.com only) 32 | # Consider using larger runners or machines with greater resources for possible analysis time improvements. 33 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} 34 | timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} 35 | permissions: 36 | # required for all workflows 37 | security-events: write 38 | 39 | # required to fetch internal or private CodeQL packs 40 | packages: read 41 | 42 | # only required for workflows in private repositories 43 | actions: read 44 | contents: read 45 | 46 | strategy: 47 | fail-fast: false 48 | matrix: 49 | include: 50 | - language: go 51 | build-mode: autobuild 52 | # CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' 53 | # Use `c-cpp` to analyze code written in C, C++ or both 54 | # Use 'java-kotlin' to analyze code written in Java, Kotlin or both 55 | # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both 56 | # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, 57 | # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. 58 | # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how 59 | # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages 60 | steps: 61 | - name: Checkout repository 62 | uses: actions/checkout@v4 63 | 64 | - name: Set up Go 65 | uses: actions/setup-go@v5 66 | with: 67 | go-version: ${{ env.GO_VERSION }} 68 | cache: true 69 | 70 | # Initializes the CodeQL tools for scanning. 71 | - name: Initialize CodeQL 72 | uses: github/codeql-action/init@v3 73 | with: 74 | languages: ${{ matrix.language }} 75 | build-mode: ${{ matrix.build-mode }} 76 | # If you wish to specify custom queries, you can do so here or in a config file. 77 | # By default, queries listed here will override any specified in a config file. 78 | # Prefix the list here with "+" to use these queries and those in the config file. 79 | 80 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 81 | # queries: security-extended,security-and-quality 82 | 83 | - name: Perform CodeQL Analysis 84 | uses: github/codeql-action/analyze@v3 85 | with: 86 | category: "/language:${{matrix.language}}" -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build, Test & Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'develop' 7 | tags: 8 | - 'v*' 9 | paths-ignore: 10 | - '.github/ISSUE_TEMPLATE/**' 11 | - 'distrib/**' 12 | - 'config.yaml' 13 | - 'docker-compose.yml' 14 | - '**.md' 15 | pull_request: 16 | paths-ignore: 17 | - '.github/ISSUE_TEMPLATE/**' 18 | - 'distrib/**' 19 | - 'config.yaml' 20 | - 'docker-compose.yml' 21 | - '**.md' 22 | 23 | concurrency: 24 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 25 | cancel-in-progress: true 26 | 27 | env: 28 | REGISTRY: ghcr.io 29 | REGISTRY_IMAGE: ghcr.io/${{ github.repository }} 30 | GO_VERSION: '1.24.2' 31 | 32 | permissions: 33 | contents: write 34 | packages: write 35 | 36 | jobs: 37 | test: 38 | name: Test 39 | runs-on: ubuntu-latest 40 | steps: 41 | - name: Checkout 42 | uses: actions/checkout@v4 43 | with: 44 | fetch-depth: 0 45 | 46 | - name: Set up Go 47 | uses: actions/setup-go@v5 48 | with: 49 | go-version: ${{ env.GO_VERSION }} 50 | cache: true 51 | 52 | - name: Test 53 | run: go test -v ./... 54 | 55 | goreleaser: 56 | name: Build distribution binaries 57 | runs-on: ubuntu-latest 58 | needs: test 59 | steps: 60 | - name: Checkout 61 | uses: actions/checkout@v4 62 | with: 63 | fetch-depth: 0 64 | 65 | - name: Set up Go 66 | uses: actions/setup-go@v5 67 | with: 68 | go-version: ${{ env.GO_VERSION }} 69 | cache: true 70 | 71 | - name: Run GoReleaser build 72 | if: github.event_name == 'pull_request' 73 | uses: goreleaser/goreleaser-action@v6 74 | with: 75 | distribution: goreleaser 76 | version: "~> v2" 77 | args: release --clean --skip=validate,publish --parallelism 5 78 | env: 79 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 80 | 81 | - name: Run GoReleaser build and publish tags 82 | if: startsWith(github.ref, 'refs/tags/') 83 | uses: goreleaser/goreleaser-action@v6 84 | with: 85 | distribution: goreleaser 86 | version: "~> v2" 87 | args: release --clean 88 | env: 89 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 90 | 91 | - name: Upload assets 92 | uses: actions/upload-artifact@v4 93 | with: 94 | name: seasonpackarr 95 | path: | 96 | dist/*.tar.gz 97 | dist/*.zip 98 | 99 | docker: 100 | name: Build Docker images 101 | runs-on: ubuntu-latest 102 | needs: test 103 | strategy: 104 | fail-fast: true 105 | matrix: 106 | platform: 107 | - linux/386 108 | - linux/amd64 109 | - linux/amd64/v2 110 | - linux/amd64/v3 111 | - linux/arm/v6 112 | - linux/arm/v7 113 | - linux/arm64 114 | steps: 115 | - name: Checkout 116 | uses: actions/checkout@v4 117 | with: 118 | fetch-depth: 0 119 | 120 | - name: Login to GitHub Container Registry 121 | uses: docker/login-action@v3 122 | with: 123 | registry: ${{ env.REGISTRY }} 124 | username: ${{ github.repository_owner }} 125 | password: ${{ secrets.GITHUB_TOKEN }} 126 | 127 | - name: Extract metadata 128 | id: meta 129 | uses: docker/metadata-action@v5 130 | with: 131 | images: ${{ env.REGISTRY_IMAGE }} 132 | tags: | 133 | type=semver,pattern={{version}},prefix=v 134 | type=semver,pattern={{major}}.{{minor}},prefix=v 135 | type=ref,event=branch 136 | type=ref,event=pr 137 | flavor: | 138 | latest=auto 139 | 140 | - name: Get version info 141 | id: version 142 | run: | 143 | VERSION="${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}" 144 | if [[ "${{ github.ref }}" == "refs/heads/develop" ]]; then 145 | LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") 146 | COMMITS_SINCE_TAG=$(git rev-list ${LATEST_TAG}..HEAD --count) 147 | VERSION="${LATEST_TAG}-dev${COMMITS_SINCE_TAG}" 148 | fi 149 | echo "Version: ${VERSION}" 150 | echo "version=${VERSION}" >> $GITHUB_OUTPUT 151 | 152 | - name: Set up QEMU 153 | uses: docker/setup-qemu-action@v3 154 | 155 | - name: Set up Docker Buildx 156 | uses: docker/setup-buildx-action@v3 157 | 158 | - name: Supported Architectures 159 | run: docker buildx ls 160 | 161 | - name: Build and publish image 162 | id: docker_build 163 | uses: docker/build-push-action@v6 164 | with: 165 | context: . 166 | file: ./ci.Dockerfile 167 | platforms: ${{ matrix.platform }} 168 | outputs: type=image,name=${{ env.REGISTRY_IMAGE }},push-by-digest=true,name-canonical=true,push=${{ (github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request') && 'true' || 'false' }} 169 | labels: | 170 | ${{ steps.meta.outputs.labels }} 171 | org.opencontainers.image.version=${{ steps.version.outputs.version }} 172 | build-args: | 173 | BUILDTIME=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }} 174 | VERSION=${{ steps.version.outputs.version }} 175 | REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }} 176 | cache-from: type=gha 177 | cache-to: type=gha,mode=max 178 | provenance: false 179 | 180 | - name: Export image digest 181 | id: digest-prep 182 | run: | 183 | mkdir -p /tmp/digests 184 | digest="${{ steps.docker_build.outputs.digest }}" 185 | echo "manifest-hash=${digest#sha256:}" >> "$GITHUB_OUTPUT" 186 | touch "/tmp/digests/${digest#sha256:}" 187 | 188 | - name: Upload image digest 189 | uses: actions/upload-artifact@v4 190 | with: 191 | name: docker-digests-${{ steps.digest-prep.outputs.manifest-hash }} 192 | path: /tmp/digests/* 193 | if-no-files-found: error 194 | retention-days: 1 195 | 196 | docker-merge: 197 | name: Publish Docker multi-arch manifest 198 | if: ${{ github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request' }} 199 | runs-on: ubuntu-latest 200 | needs: [ docker, test ] 201 | steps: 202 | - name: Download image digests 203 | uses: actions/download-artifact@v4 204 | with: 205 | path: /tmp/digests 206 | pattern: docker-digests-* 207 | merge-multiple: true 208 | 209 | - name: Set up Docker Buildx 210 | uses: docker/setup-buildx-action@v3 211 | 212 | - name: Login to GitHub Container Registry 213 | uses: docker/login-action@v3 214 | with: 215 | registry: ${{ env.REGISTRY }} 216 | username: ${{ github.repository_owner }} 217 | password: ${{ secrets.GITHUB_TOKEN }} 218 | 219 | - name: Extract metadata 220 | id: meta 221 | uses: docker/metadata-action@v5 222 | with: 223 | images: ${{ env.REGISTRY_IMAGE }} 224 | tags: | 225 | type=semver,pattern={{version}},prefix=v 226 | type=semver,pattern={{major}}.{{minor}},prefix=v 227 | type=ref,event=branch 228 | type=ref,event=pr 229 | flavor: | 230 | latest=auto 231 | 232 | - name: Create manifest list and push 233 | working-directory: /tmp/digests 234 | run: | 235 | docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ 236 | $(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *) 237 | 238 | - name: Inspect image 239 | run: | 240 | docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }} 241 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | .DS_Store* 11 | 12 | # Test binary, built with `go test -c` 13 | *.test 14 | 15 | # Output of the go coverage tool, specifically when used with LiteIDE 16 | *.out 17 | 18 | # Dependency directories (remove the comment below to include it) 19 | # vendor/ 20 | 21 | # Go workspace file 22 | go.work 23 | 24 | # Additional files 25 | .idea/ 26 | .vscode/ 27 | dist/ -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | before: 4 | hooks: 5 | - go mod tidy 6 | 7 | builds: 8 | - id: seasonpackarr 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 | main: main.go 34 | binary: seasonpackarr 35 | ldflags: 36 | - -s -w -X github.com/nuxencs/seasonpackarr/internal/buildinfo.Version=v{{.Version}} -X github.com/nuxencs/seasonpackarr/internal/buildinfo.Commit={{.Commit}} -X github.com/nuxencs/seasonpackarr/internal/buildinfo.Date={{.Date}} 37 | 38 | archives: 39 | - id: seasonpackarr 40 | builds: 41 | - seasonpackarr 42 | format_overrides: 43 | - goos: windows 44 | formats: [ "zip" ] 45 | files: 46 | - none* 47 | name_template: >- 48 | {{ .ProjectName }}_ 49 | {{- .Version }}_ 50 | {{- .Os }}_ 51 | {{- if eq .Arch "amd64" }}x86_64 52 | {{- else }}{{ .Arch }}{{ end }} 53 | 54 | release: 55 | prerelease: auto 56 | footer: | 57 | **Full Changelog**: https://github.com/nuxencs/seasonpackarr/compare/{{ .PreviousTag }}...{{ .Tag }} 58 | 59 | ## Docker images 60 | 61 | - `docker pull ghcr.io/nuxencs/seasonpackarr:{{ .Tag }}` 62 | 63 | ## What to do next? 64 | 65 | - Read the [documentation](https://github.com/nuxencs/seasonpackarr#readme) 66 | 67 | checksum: 68 | name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt" 69 | 70 | changelog: 71 | sort: asc 72 | use: github 73 | filters: 74 | exclude: 75 | - Merge pull request 76 | - Merge remote-tracking branch 77 | - Merge branch 78 | groups: 79 | - title: "New Features" 80 | regexp: "^.*feat[(\\w)]*:+.*$" 81 | order: 0 82 | - title: "Bug fixes" 83 | regexp: "^.*fix[(\\w)]*:+.*$" 84 | order: 10 85 | - title: Other work 86 | order: 999 87 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, caste, color, religion, or sexual 11 | identity and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the overall 27 | community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or advances of 32 | any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email address, 36 | without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official email address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at 64 | theonenuxen@proton.me. 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series of 87 | actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or permanent 94 | ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within the 114 | community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.1, available at 120 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 121 | 122 | Community Impact Guidelines were inspired by 123 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 127 | [https://www.contributor-covenant.org/translations][translations]. 128 | 129 | [homepage]: https://www.contributor-covenant.org 130 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 131 | [Mozilla CoC]: https://github.com/mozilla/diversity 132 | [FAQ]: https://www.contributor-covenant.org/faq 133 | [translations]: https://www.contributor-covenant.org/translations 134 | 135 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributing 3 | 4 | Whether you're fixing a bug, adding a feature, or improving documentation, your help is appreciated. Here's how you can contribute: 5 | 6 | ## Reporting Issues and Suggestions 7 | 8 | - **Report Bugs:** Encountered a bug? Please report it using the [bug report template](/.github/ISSUE_TEMPLATE/bug_report.yml). Include detailed steps to reproduce, expected behavior, and any relevant screenshots or logs. 9 | - **Feature Requests:** Submit it using the [feature request template](/.github/ISSUE_TEMPLATE/feature_request.yml). Describe your idea and how it will improve `seasonpackarr`. 10 | 11 | ## Code Contributions 12 | 13 | - **Fork and Clone:** Fork the `seasonpackarr` repository and clone it to start working on your changes. 14 | - **Branching:** Create a new branch for your changes. Use a descriptive name for easy understanding. 15 | - **Coding:** Ensure your code is well-commented for clarity. 16 | - **Commit Guidelines:** We appreciate the use of [Conventional Commit Guidelines](https://www.conventionalcommits.org/en/v1.0.0/#summary) when writing your commits. 17 | - There is no need for force pushing or rebasing. I squash commits on merge to keep the history clean and manageable. 18 | - **Pull Requests:** Submit a pull request with a clear description of your changes. Reference any related issues. 19 | - **Code Review:** Be open to feedback during the code review process. -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # build app 2 | FROM golang:1.24.2-alpine3.21 AS app-builder 3 | 4 | WORKDIR /src 5 | 6 | ENV SERVICE=seasonpackarr 7 | ARG VERSION=dev \ 8 | REVISION=dev \ 9 | BUILDTIME \ 10 | 11 | COPY go.mod go.sum ./ 12 | RUN go mod download 13 | COPY . ./ 14 | 15 | RUN --network=none \ 16 | go build -ldflags "-s -w -X github.com/nuxencs/seasonpackarr/internal/buildinfo.Version=${VERSION} -X github.com/nuxencs/seasonpackarr/internal/buildinfo.Commit=${REVISION} -X github.com/nuxencs/seasonpackarr/internal/buildinfo.Date=${BUILDTIME}" -o bin/seasonpackarr main.go 17 | 18 | # build runner 19 | FROM alpine:latest 20 | RUN apk add --no-cache ca-certificates curl tzdata jq 21 | 22 | LABEL org.opencontainers.image.source="https://github.com/nuxencs/seasonpackarr" \ 23 | org.opencontainers.image.licenses="GPL-2.0-or-later" \ 24 | org.opencontainers.image.base.name="alpine:latest" 25 | 26 | ENV HOME="/config" \ 27 | XDG_CONFIG_HOME="/config" \ 28 | XDG_DATA_HOME="/config" 29 | 30 | WORKDIR /app 31 | VOLUME /config 32 | EXPOSE 42069 33 | 34 | COPY --from=app-builder /src/bin/seasonpackarr /usr/bin/ 35 | 36 | ENTRYPOINT ["/usr/bin/seasonpackarr", "start", "--config", "/config"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

seasonpackarr

2 |

3 | 4 | License 5 | 6 | 7 | Go Report 8 | 9 | 10 | Build 11 | 12 | 13 | Latest Release 14 | 15 | 16 | Discord 17 | 18 |

19 | 20 |

21 | seasonpackarr is a companion app for autobrr that automagically hardlinks downloaded episodes into a season folder when a season pack is 22 | announced, eliminating the need for re-downloading existing episodes. 23 |

24 | 25 | > [!WARNING] 26 | > This application is currently under active development. If you encounter any bugs, please report them in the dedicated 27 | > #seasonpackarr channel on the TRaSH-Guides [Discord server](https://trash-guides.info/discord) or create a new issue 28 | > on GitHub, so I can fix them. 29 | 30 | ## Installation 31 | 32 | ### Linux 33 | 34 | To download the latest release, you can use one of the following methods: 35 | 36 | ```bash 37 | # using curl 38 | curl -s https://api.github.com/repos/nuxencs/seasonpackarr/releases/latest | grep download | grep linux_x86_64 | cut -d\" -f4 | xargs curl -LO 39 | 40 | # using wget 41 | wget -qO- https://api.github.com/repos/nuxencs/seasonpackarr/releases/latest | grep download | grep linux_x86_64 | cut -d\" -f4 | xargs wget 42 | ``` 43 | 44 | Alternatively, you can download the [source code](https://github.com/nuxencs/seasonpackarr/releases/latest) and build it yourself using `go build`. 45 | 46 | #### Unpack 47 | 48 | Run with `root` or `sudo`. If you do not have root, or are on a shared system, place the binary somewhere in your home 49 | directory like `~/.bin`. 50 | 51 | ```bash 52 | tar -C /usr/bin -xzf seasonpackarr*.tar.gz 53 | ``` 54 | 55 | This will extract `seasonpackarr` to `/usr/bin`. 56 | 57 | Afterwards you need to make the binary executable by running the following command. 58 | 59 | ```bash 60 | chmod +x /usr/bin/seasonpackarr 61 | ``` 62 | 63 | Note: If the commands fail, prefix them with `sudo ` and run them again. 64 | 65 | #### Systemd (Recommended) 66 | 67 | On Linux-based systems, it is recommended to run seasonpackarr as a sort of service with auto-restarting capabilities, 68 | in order to account for potential downtime. The most common way is to do it via systemd. 69 | 70 | You will need to create a service file in `/etc/systemd/system/` called `seasonpackarr@.service`. 71 | 72 | ```bash 73 | touch /etc/systemd/system/seasonpackarr@.service 74 | ``` 75 | 76 | Then place the following content inside the file (e.g. via nano/vim/ed): 77 | 78 | ```systemd title="/etc/systemd/system/seasonpackarr@.service" 79 | [Unit] 80 | Description=seasonpackarr service for %i 81 | After=syslog.target network-online.target 82 | 83 | [Service] 84 | Type=simple 85 | User=%i 86 | Group=%i 87 | ExecStart=/usr/bin/seasonpackarr start --config=/home/%i/.config/seasonpackarr 88 | 89 | [Install] 90 | WantedBy=multi-user.target 91 | ``` 92 | 93 | Start the service. Enable will make it startup on reboot. 94 | 95 | ```bash 96 | sudo systemctl enable -q --now seasonpackarr@$USER 97 | ``` 98 | 99 | Make sure it's running and **active**. 100 | 101 | ```bash 102 | sudo systemctl status seasonpackarr@$USER 103 | ``` 104 | 105 | On first run it will create a default config, `~/.config/seasonpackarr/config.yaml` that you will need to edit. 106 | 107 | After the config is edited you need to restart the service. 108 | 109 | ```bash 110 | sudo systemctl restart seasonpackarr@$USER.service 111 | ``` 112 | 113 | ### Docker 114 | 115 | Docker images can be found on the right under the "Packages" section. 116 | 117 | See `docker-compose.yml` for an example. 118 | 119 | Make sure you use the correct path you have mapped within the container in the config file. After the first start you 120 | will need to adjust the created config file to your needs and start the container again. 121 | 122 | ## Configuration 123 | 124 | You can configure a decent part of the features seasonpackarr provides. I will explain the most important ones here in 125 | more detail. 126 | 127 | ### Smart Mode 128 | 129 | Can be enabled in the config by setting `smartMode` to `true`. Works together with `smartModeThreshold` to determine if 130 | a season pack should get grabbed or not. Here's an example that explains it pretty well: 131 | 132 | Let's say you have 8 episodes of a season in your client released by `RlsGrpA`. You also have 12 episodes of the same 133 | season in your client released by `RlsGrpB` and there are a total of 12 episodes in that season. If you have smart 134 | mode enabled with a threshold set to `0.75`, only the season pack from `RlsGrpB` will get grabbed, because `8/12 = 0.67` 135 | which is below the threshold. 136 | 137 | ### Parse Torrent 138 | 139 | Can be enabled in the config by setting `parseTorrentFile` to `true`. This option will make sure that the season pack 140 | folder that gets created by seasonpackarr will always have the correct name. One example that will make the benefit 141 | of this clearer: 142 | 143 | - Announce name: `Show.S01.1080p.WEB-DL.DDPA5.1.H.264-RlsGrp` 144 | - Folder name: `Show.S01.1080p.WEB-DL.DDP5.1.H.264-RlsGrp` 145 | 146 | Using the announce name would create the wrong folder and would lead to all the files in the torrent being downloaded 147 | again. The issue in the given example is the additional `A` after `DDP` which is not present in the folder name. By 148 | using the parsed folder name the files will be hardlinked into the exact folder that is being used in the torrent. 149 | 150 | You can take a look at the [Webhook](#webhook) section to see what you would need to add in your autobrr filter to 151 | make use of this feature. 152 | 153 | ### Fuzzy Matching 154 | 155 | In this section, you can toggle comparing rules. I will explain each of them in more detail here. 156 | 157 | 1. **skipRepackCompare**: When set to `true`, the comparer skips checking the repack status of the season pack release 158 | against the episodes in your client. The episode in the example will only be accepted as a match by seasonpackarr if 159 | you enable this option: 160 | - Announce name: `Show.S01.1080p.WEB-DL.DDPA5.1.H.264-RlsGrp` 161 | - Episode name: `Show.S01E01.1080p.WEB-DL.REPACK.DDPA5.1.H.264-RlsGrp` 162 | 163 | 2. **simplifyHdrCompare**: If set to `true`, this option simplifies the HDR formats `HDR10`, `HDR10+`, and `HDR+` to 164 | just `HDR`. This increases the likelihood of matching renamed releases that specify a more advanced HDR format in the 165 | announce name than in the episode title: 166 | - Announce name: `Show.S01.2160p.WEB-DL.DDPA5.1.DV.HDR10+.H.265-RlsGrp` 167 | - Episode name: `Show.S01E01.2160p.WEB-DL.DDPA5.1.DV.HDR.H.265-RlsGrp` 168 | 169 | ### Recommended options 170 | 171 | Keep in mind, these settings are suggestions based on my own use case so feel free to adjust them according to your 172 | specific needs. 173 | 174 | ```yaml 175 | smartMode: true 176 | smartModeThreshold: 0.75 177 | parseTorrentFile: true 178 | skipRepackCompare: true 179 | simplifyHdrCompare: false 180 | ``` 181 | 182 | These will filter out most unwanted season packs and prevent mismatches, while still making sure that 183 | renamed season packs and episodes can get matched. 184 | 185 | ## autobrr Filter setup 186 | 187 | Support for multiple Sonarr and qBittorrent instances with different pre import directories was added with v0.4.0, so 188 | you will need to run multiple instances of seasonpackarr and create multiple filters to achieve the same functionality 189 | in lower versions. If you are running v0.4.0 or above you just need to set up your filters according to [External Filters](#external-filters). 190 | The following is a simple example filter that only allows 1080p season packs to be matched. 191 | 192 | ### Create Filter 193 | 194 | To import it into autobrr you need to navigate to `Filters` and click on the arrow next to `+ Create Filter` to see the 195 | option `Import filter`. Just paste the content below into the text box that appeared and click on `Import`. 196 | 197 | ```json 198 | { 199 | "name": "arr-Seasonpackarr", 200 | "version": "1.0", 201 | "data": { 202 | "enabled": true, 203 | "seasons": "1-99", 204 | "episodes": "0", 205 | "resolutions": [ 206 | "1080p", 207 | "1080i" 208 | ] 209 | } 210 | } 211 | ``` 212 | 213 | In the `General` tab you will need to adjust the value of `Priority` to be set higher than all your TV show filters. For 214 | instance, if your Sonarr filter is set at `10` and a TV filter that sends to qBittorrent is at `15`, then you should set 215 | the `seasonpackarr` filter to at least `16`. This ensures that it will execute before the others. It's perfectly fine to 216 | have a `cross-seed` filter positioned above the `seasonpackarr` filter. 217 | 218 | ### External Filters 219 | 220 | After adding the filter, you need to head to the `External` tab of the filter, click on `Add new` and select `Webhook` 221 | in the `Type` field. The `Endpoint` field should look like this, with `host` and `port` taken from your config: 222 | 223 | ``` 224 | http://host:port/api/pack 225 | ``` 226 | 227 | `HTTP Method` needs to be set to `POST`, `Expected HTTP status` has to be set to `250` and the `Data (JSON)` field needs 228 | to look like this: 229 | 230 | ```json 231 | { 232 | "name": "{{ .TorrentName }}", 233 | "clientname": "default" 234 | } 235 | ``` 236 | 237 | Replace the `clientname` value, in this case `default`, with the name you gave your desired qBittorrent client in your 238 | config under the `clients` section. If you don't specify a `clientname` in the JSON payload, seasonpackarr will try to 239 | use the `default` client; if you renamed or removed the `default` client the request will fail. 240 | 241 | #### API Authentication 242 | 243 | I strongly suggest enabling API authentication by providing an API token in the config. The following command will 244 | generate a token for you that you can copy and paste into your config: 245 | 246 | ```bash 247 | seasonpackarr gen-token 248 | ``` 249 | 250 | After you've set the API token in your config, you'll need to either include it in the `Endpoint` field or pass it 251 | along in the `HTTP Request Headers` of your autobrr request; if not, the request will be rejected. I recommend using 252 | headers to pass the API token, but I'll explain both options here. 253 | 254 | 1. **Header**: Edit the `HTTP Request Headers` field and replace `api_token` with the token you set in your config. 255 | ``` 256 | X-API-Token=api_token 257 | ``` 258 | 2. **Query Parameter**: Append `?apikey=api_token` at the end of your `Endpoint` field and replace `api_token` with the 259 | token you've set in your config. 260 | ``` 261 | http://host:port/api/pack?apikey=api_token 262 | ``` 263 | 264 | The external filter you just created will be disabled by default. To avoid unwanted downloads, make sure to enable it! 265 | 266 | ### Actions 267 | 268 | Now, you need to decide whether you want to enable torrent parsing. By activating this feature, seasonpackarr will parse 269 | the torrent file for the season pack folder name to ensure the creation of the correct folder. You can enable this 270 | functionality by setting `parseTorrentFile` to `true` in your config file. 271 | 272 | If you choose to enable this feature, first follow the instructions in the [Webhook](#webhook) section, and then proceed 273 | to the [qBittorrent](#qbittorrent) section. If you leave this feature disabled, you can skip the Webhook section and go 274 | straight to the qBittorrent section. 275 | 276 | > [!WARNING] 277 | > If you enable that option you need to make sure that the Webhook action is above the qBittorrent action, otherwise the 278 | > feature won't work correctly. 279 | 280 | #### Webhook 281 | 282 | Navigate to the `Actions` tab, click on `Add new` and change the `Action type` of the newly added action to `Webhook`. 283 | The `Endpoint` field should look like this, with `host`, `port` and `api_token` taken from your config: 284 | 285 | ``` 286 | http://host:port/api/parse?apikey=api_token 287 | ``` 288 | 289 | Append the API query parameter `?apikey=api_token` only if you have enabled API authentication by providing an API token 290 | in your config. 291 | 292 | Finally, complete the `Payload (JSON)` field as shown below. Ensure that the value of `clientname` is the same as in the `External Filter`: 293 | 294 | ```json 295 | { 296 | "name":"{{ .TorrentName }}", 297 | "torrent":"{{ .TorrentDataRawBytes | js }}", 298 | "clientname": "default" 299 | } 300 | ``` 301 | 302 | #### qBittorrent 303 | 304 | Navigate to the `Actions` tab, click on `Add new` and change the `Action type` of the newly added action to `qBittorrent`. 305 | Depending on whether you intend to only send to qBittorrent or also integrate with Sonarr, you'll need to fill out different fields. 306 | 307 | 1. **Only qBittorrent**: Fill in the `Save Path` field with the directory where your torrent data resides, for instance 308 | `/data/torrents`, or the `Category` field with a qBittorrent category that saves to your desired location. 309 | 2. **Sonarr Integration**: Fill in the `Category` field with the category that Sonarr utilizes for all its downloads, 310 | such as `tv-hd` or `tv-uhd`. 311 | 312 | Last but not least, under `Rules`, make sure that `Skip Hash Check` remains disabled. This precaution prevents torrents 313 | added by seasonpackarr from causing errors in your qBittorrent client when some episodes of a season are missing. 314 | 315 | > [!WARNING] 316 | > If you enable that option regardless, you will most likely have to deal with errored torrents, which would require you 317 | > to manually trigger a recheck on them to fix the issue. 318 | 319 | ## Credits 320 | 321 | Huge credit goes to [upgraderr](https://github.com/KyleSanderson/upgraderr) and specifically [@KyleSanderson](https://github.com/KyleSanderson), whose 322 | project provided great functions that I could make use of. Additionally, I would also like to mention [@zze0s](https://github.com/zze0s), who was 323 | really helpful regarding any question I had as well as providing me with a lot of the structure this project has now. 324 | Credits also go to the [TVmaze API](https://www.tvmaze.com/api) and [TheTVDB API](https://thetvdb.com/api-information) for providing comprehensive 325 | data on the total number of episodes for a show in a specific season. Last but not least, a big thank you to [JetBrains](http://www.jetbrains.com/) 326 | for providing me with free licenses to their great tools, in this case [GoLand](https://www.jetbrains.com/go/). 327 | -------------------------------------------------------------------------------- /ci.Dockerfile: -------------------------------------------------------------------------------- 1 | # build base 2 | FROM --platform=$BUILDPLATFORM golang:1.24.2-alpine3.21 AS app-base 3 | 4 | WORKDIR /src 5 | 6 | ENV SERVICE=seasonpackarr 7 | ARG VERSION=dev \ 8 | REVISION=dev \ 9 | BUILDTIME \ 10 | TARGETOS TARGETARCH TARGETVARIANT 11 | 12 | COPY go.mod go.sum ./ 13 | RUN go mod download 14 | COPY . ./ 15 | 16 | # build seasonpackarr 17 | FROM --platform=$BUILDPLATFORM app-base AS seasonpackarr 18 | RUN --network=none --mount=target=. \ 19 | export GOOS=$TARGETOS; \ 20 | export GOARCH=$TARGETARCH; \ 21 | [[ "$GOARCH" == "amd64" ]] && export GOAMD64=$TARGETVARIANT; \ 22 | [[ "$GOARCH" == "arm" ]] && [[ "$TARGETVARIANT" == "v6" ]] && export GOARM=6; \ 23 | [[ "$GOARCH" == "arm" ]] && [[ "$TARGETVARIANT" == "v7" ]] && export GOARM=7; \ 24 | echo $GOARCH $GOOS $GOARM$GOAMD64; \ 25 | go build -ldflags "-s -w -X github.com/nuxencs/seasonpackarr/internal/buildinfo.Version=${VERSION} -X github.com/nuxencs/seasonpackarr/internal/buildinfo.Commit=${REVISION} -X github.com/nuxencs/seasonpackarr/internal/buildinfo.Date=${BUILDTIME}" -o /out/bin/seasonpackarr main.go 26 | 27 | # build runner 28 | FROM alpine:latest AS runner 29 | RUN apk add --no-cache ca-certificates curl tzdata jq 30 | 31 | LABEL org.opencontainers.image.source="https://github.com/nuxencs/seasonpackarr" \ 32 | org.opencontainers.image.licenses="GPL-2.0-or-later" \ 33 | org.opencontainers.image.base.name="alpine:latest" 34 | 35 | ENV HOME="/config" \ 36 | XDG_CONFIG_HOME="/config" \ 37 | XDG_DATA_HOME="/config" 38 | 39 | WORKDIR /app 40 | VOLUME /config 41 | EXPOSE 42069 42 | 43 | COPY --link --from=seasonpackarr /out/bin/seasonpackarr /usr/bin/ 44 | 45 | ENTRYPOINT ["/usr/bin/seasonpackarr", "start", "--config", "/config"] -------------------------------------------------------------------------------- /cmd/genToken.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 - 2025, nuxen and the seasonpackarr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package cmd 5 | 6 | import ( 7 | "fmt" 8 | 9 | "github.com/nuxencs/seasonpackarr/internal/api" 10 | 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | // genTokenCmd represents the gen-token command 15 | var genTokenCmd = &cobra.Command{ 16 | Use: "gen-token", 17 | Short: "Generate an api token", 18 | Run: func(cmd *cobra.Command, args []string) { 19 | key := api.GenerateToken() 20 | fmt.Printf("API Token: %v\nJust copy and paste it into your config file!\n", key) 21 | }, 22 | } 23 | -------------------------------------------------------------------------------- /cmd/pack.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 - 2025, nuxen and the seasonpackarr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package cmd 5 | 6 | import ( 7 | "fmt" 8 | 9 | "github.com/nuxencs/seasonpackarr/internal/payload" 10 | 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | // packCmd represents the pack command 15 | var packCmd = &cobra.Command{ 16 | Use: "pack", 17 | Short: "Test the pack api endpoint for a specified release", 18 | Example: ` seasonpackarr test pack “Series.S01.1080p.WEB-DL.H.264-RlsGrp” --client "default" --host "127.0.0.1" --port 42069 --api "your-api-key"`, 19 | Run: func(cmd *cobra.Command, args []string) { 20 | if len(args) == 0 { 21 | fmt.Println("Please provide a release name") 22 | return 23 | } 24 | 25 | rlsName = args[0] 26 | 27 | body, err := payload.CompilePack(rlsName, clientName) 28 | if err != nil { 29 | fmt.Println(err.Error()) 30 | return 31 | } 32 | 33 | err = payload.Exec(fmt.Sprintf("http://%s:%d/api/pack", host, port), body, apiKey) 34 | if err != nil { 35 | fmt.Println(err.Error()) 36 | return 37 | } 38 | }, 39 | } 40 | -------------------------------------------------------------------------------- /cmd/parse.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 - 2025, nuxen and the seasonpackarr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package cmd 5 | 6 | import ( 7 | "fmt" 8 | "io" 9 | "io/fs" 10 | "os" 11 | "path/filepath" 12 | "strings" 13 | 14 | "github.com/nuxencs/seasonpackarr/internal/payload" 15 | "github.com/nuxencs/seasonpackarr/internal/torrents" 16 | "github.com/nuxencs/seasonpackarr/pkg/errors" 17 | 18 | "github.com/spf13/cobra" 19 | ) 20 | 21 | // parseCmd represents the parse command 22 | var parseCmd = &cobra.Command{ 23 | Use: "parse", 24 | Short: "Test the parse api endpoint for a specified release", 25 | Example: ` seasonpackarr test parse “Series.S01.1080p.WEB-DL.H.264-RlsGrp” --client "default" --host "127.0.0.1" --port 42069 --api "your-api-key" 26 | seasonpackarr test parse “/path/to/Series.S01.1080p.WEB-DL.H.264-RlsGrp.torrent” --client "default" --host "127.0.0.1" --port 42069 --api "your-api-key"`, 27 | Run: func(cmd *cobra.Command, args []string) { 28 | var torrentBytes []byte 29 | var body io.Reader 30 | var err error 31 | 32 | if len(args) == 0 { 33 | fmt.Println("Please provide either a release name or a .torrent file to parse") 34 | return 35 | } 36 | 37 | torrentFile = args[0] 38 | // trim .torrent extension and remove full path for rlsName 39 | rlsName = strings.TrimSuffix(filepath.Base(torrentFile), ".torrent") 40 | 41 | if filepath.Ext(torrentFile) != ".torrent" { 42 | torrentBytes, err = torrents.TorrentFromRls(rlsName, 5) 43 | if err != nil { 44 | fmt.Println(err.Error()) 45 | return 46 | } 47 | } else { 48 | if len(torrentFile) == 0 { 49 | fmt.Println("The path of the torrent file can't be empty") 50 | return 51 | } 52 | 53 | if _, err := os.Stat(torrentFile); errors.Is(err, fs.ErrNotExist) { 54 | fmt.Println("The specified torrent file doesn't exist", err.Error()) 55 | return 56 | } 57 | 58 | torrentBytes, err = os.ReadFile(torrentFile) 59 | if err != nil { 60 | fmt.Println(err.Error()) 61 | return 62 | } 63 | } 64 | 65 | body, err = payload.CompileParse(rlsName, torrentBytes, clientName) 66 | if err != nil { 67 | fmt.Println(err.Error()) 68 | return 69 | } 70 | 71 | err = payload.Exec(fmt.Sprintf("http://%s:%d/api/parse", host, port), body, apiKey) 72 | if err != nil { 73 | fmt.Println(err.Error()) 74 | return 75 | } 76 | }, 77 | } 78 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 - 2025, nuxen and the seasonpackarr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package cmd 5 | 6 | import ( 7 | "os" 8 | 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | var ( 13 | configPath string 14 | rlsName string 15 | torrentFile string 16 | clientName string 17 | host string 18 | port int 19 | apiKey string 20 | ) 21 | 22 | var rootCmd = &cobra.Command{ 23 | Use: "seasonpackarr", 24 | Short: "Automagically hardlink already downloaded episode files into a season folder when a matching season pack announce hits autobrr.", 25 | Long: `Automagically hardlink already downloaded episode files into a season folder when a matching season pack announce hits autobrr. 26 | 27 | Provide a configuration file using one of the following methods: 28 | 1. Use the --config or -c flag. 29 | 2. Place a config.yaml file in the default user configuration directory (e.g., ~/.config/seasonpackarr/). 30 | 3. Place a config.yaml file a folder inside your home directory (e.g., ~/.seasonpackarr/). 31 | 4. Place a config.yaml file in the directory of the binary. 32 | 33 | For more information and examples, visit https://github.com/nuxencs/seasonpackarr`, 34 | } 35 | 36 | func init() { 37 | startCmd.Flags().StringVarP(&configPath, "config", "c", "", "path to the configuration directory") 38 | 39 | testCmd.PersistentFlags().StringVarP(&clientName, "client", "n", "", "name of the client you want to test") 40 | testCmd.PersistentFlags().StringVarP(&host, "host", "i", "127.0.0.1", "host used by seasonpackarr") 41 | testCmd.PersistentFlags().IntVarP(&port, "port", "p", 42069, "port used by seasonpackarr") 42 | testCmd.PersistentFlags().StringVarP(&apiKey, "api", "a", "", "api key used by seasonpackarr") 43 | 44 | rootCmd.AddCommand(genTokenCmd, startCmd, testCmd, versionCmd) 45 | testCmd.AddCommand(packCmd, parseCmd) 46 | } 47 | 48 | func Execute() { 49 | err := rootCmd.Execute() 50 | if err != nil { 51 | os.Exit(1) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /cmd/start.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 - 2025, nuxen and the seasonpackarr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package cmd 5 | 6 | import ( 7 | "context" 8 | "os" 9 | "os/signal" 10 | "syscall" 11 | 12 | "github.com/nuxencs/seasonpackarr/internal/buildinfo" 13 | "github.com/nuxencs/seasonpackarr/internal/config" 14 | "github.com/nuxencs/seasonpackarr/internal/http" 15 | "github.com/nuxencs/seasonpackarr/internal/logger" 16 | "github.com/nuxencs/seasonpackarr/internal/metadata" 17 | "github.com/nuxencs/seasonpackarr/internal/notification" 18 | "github.com/nuxencs/seasonpackarr/pkg/errors" 19 | 20 | "github.com/spf13/cobra" 21 | ) 22 | 23 | // startCmd represents the start command 24 | var startCmd = &cobra.Command{ 25 | Use: "start", 26 | Short: "Start seasonpackarr", 27 | Run: func(cmd *cobra.Command, args []string) { 28 | // read config 29 | cfg := config.New(configPath, buildinfo.Version) 30 | 31 | // init new logger 32 | log := logger.New(cfg.Config) 33 | 34 | if err := cfg.UpdateConfig(); err != nil { 35 | log.Error().Err(err).Msgf("error updating config") 36 | } 37 | 38 | // init dynamic config 39 | cfg.DynamicReload(log) 40 | 41 | // init notification sender 42 | noti := notification.NewDiscordSender(log, cfg) 43 | 44 | // init metadata providers 45 | metadata := metadata.NewMetadataProvider(log, cfg.Config.Metadata) 46 | 47 | srv := http.NewServer(log, cfg, noti, metadata) 48 | 49 | log.Info().Msgf("Starting seasonpackarr") 50 | log.Info().Msgf("Version: %s", buildinfo.Version) 51 | log.Info().Msgf("Commit: %s", buildinfo.Commit) 52 | log.Info().Msgf("Build date: %s", buildinfo.Date) 53 | log.Info().Msgf("Log-level: %s", cfg.Config.LogLevel) 54 | 55 | errorChannel := make(chan error) 56 | go func() { 57 | err := srv.Open() 58 | if err != nil { 59 | if !errors.Is(err, http.ErrServerClosed) { 60 | errorChannel <- err 61 | } 62 | } 63 | }() 64 | 65 | sigCh := make(chan os.Signal, 1) 66 | signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM) 67 | 68 | select { 69 | case sig := <-sigCh: 70 | log.Info().Msgf("received signal: %q, shutting down server.", sig) 71 | os.Exit(0) 72 | 73 | case err := <-errorChannel: 74 | log.Error().Err(err).Msg("unexpected error from server") 75 | } 76 | if err := srv.Shutdown(context.Background()); err != nil { 77 | log.Error().Err(err).Msg("error during http shutdown") 78 | os.Exit(1) 79 | } 80 | 81 | os.Exit(0) 82 | }, 83 | } 84 | -------------------------------------------------------------------------------- /cmd/test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 - 2025, nuxen and the seasonpackarr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package cmd 5 | 6 | import ( 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // testCmd represents the test command 11 | var testCmd = &cobra.Command{ 12 | Use: "test", 13 | Short: "Test a specified feature", 14 | } 15 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 - 2025, nuxen and the seasonpackarr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package cmd 5 | 6 | import ( 7 | "encoding/json" 8 | "fmt" 9 | "net/http" 10 | "os" 11 | "time" 12 | 13 | "github.com/nuxencs/seasonpackarr/internal/buildinfo" 14 | "github.com/nuxencs/seasonpackarr/pkg/errors" 15 | 16 | "github.com/spf13/cobra" 17 | ) 18 | 19 | // versionCmd represents the version command 20 | var versionCmd = &cobra.Command{ 21 | Use: "version", 22 | Short: "Display version info", 23 | Run: func(cmd *cobra.Command, args []string) { 24 | fmt.Printf("Version: %v\nCommit: %v\nBuild date: %v\n", buildinfo.Version, buildinfo.Commit, buildinfo.Date) 25 | 26 | // get the latest release tag from api 27 | client := http.Client{ 28 | Timeout: 10 * time.Second, 29 | } 30 | 31 | resp, err := client.Get("https://api.github.com/repos/nuxencs/seasonpackarr/releases/latest") 32 | if err != nil { 33 | if errors.Is(err, http.ErrHandlerTimeout) { 34 | fmt.Println("Server timed out while fetching latest release from api") 35 | } else { 36 | fmt.Printf("Failed to fetch latest release from api: %v\n", err) 37 | } 38 | os.Exit(1) 39 | } 40 | defer resp.Body.Close() 41 | 42 | // api returns 500 instead of 404 here 43 | if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusInternalServerError { 44 | fmt.Print("No release found") 45 | os.Exit(1) 46 | } 47 | 48 | var rel struct { 49 | TagName string `json:"tag_name"` 50 | } 51 | if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { 52 | fmt.Printf("Failed to decode response from api: %v\n", err) 53 | os.Exit(1) 54 | } 55 | fmt.Printf("Latest release: %v\n", rel.TagName) 56 | }, 57 | } 58 | -------------------------------------------------------------------------------- /config.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://raw.githubusercontent.com/nuxencs/seasonpackarr/develop/schemas/config-schema.json 2 | # config.yaml 3 | 4 | # Hostname / IP 5 | # 6 | # Default: "0.0.0.0" 7 | # 8 | host: "0.0.0.0" 9 | 10 | # Port 11 | # 12 | # Default: 42069 13 | # 14 | port: 42069 15 | 16 | clients: 17 | # Client name used in the autobrr filter, can be customized to whatever you like 18 | # Note that a client name has to be unique and can only be used once 19 | # 20 | # Default: default 21 | # 22 | default: 23 | # qBittorrent Hostname / IP 24 | # 25 | # Default: "127.0.0.1" 26 | # 27 | host: "127.0.0.1" 28 | 29 | # qBittorrent Port 30 | # 31 | # Default: 8080 32 | # 33 | port: 8080 34 | 35 | # qBittorrent Username 36 | # 37 | # Default: "admin" 38 | # 39 | username: "admin" 40 | 41 | # qBittorrent Password 42 | # 43 | # Default: "adminadmin" 44 | # 45 | password: "adminadmin" 46 | 47 | # Pre Import Path of qBittorrent for Sonarr 48 | # Needs to be filled out correctly, e.g. "/data/torrents/tv-hd" 49 | # 50 | # Default: "" 51 | # 52 | preImportPath: "" 53 | 54 | # Below you can find an example on how to define a second qBittorrent client 55 | # If you want to define even more clients just copy this segment and adjust the values accordingly 56 | # 57 | #multi_client_example: 58 | # host: "127.0.0.1" 59 | # 60 | # port: 9090 61 | # 62 | # username: "example" 63 | # 64 | # password: "example" 65 | # 66 | # preImportPath: "" 67 | 68 | # seasonpackarr logs file 69 | # If not defined, logs to stdout 70 | # Make sure to use forward slashes and include the filename with extension. eg: "logs/seasonpackarr.log", "C:/seasonpackarr/logs/seasonpackarr.log" 71 | # 72 | # Optional 73 | # 74 | # logPath: "" 75 | 76 | # Log level 77 | # 78 | # Default: "DEBUG" 79 | # 80 | # Options: "ERROR", "DEBUG", "INFO", "WARN", "TRACE" 81 | # 82 | logLevel: "DEBUG" 83 | 84 | # Log Max Size 85 | # Max log size in megabytes 86 | # 87 | # Default: 50 88 | # 89 | # logMaxSize: 50 90 | 91 | # Log Max Backups 92 | # Max amount of old log files 93 | # 94 | # Default: 3 95 | # 96 | # logMaxBackups: 3 97 | 98 | # Smart Mode 99 | # Toggles smart mode to only download season packs that have a certain amount of episodes from a release group 100 | # already in the client 101 | # 102 | # Default: false 103 | # 104 | # smartMode: false 105 | 106 | # Smart Mode Threshold 107 | # Sets the threshold for the percentage of episodes out of a season that must be present in the client 108 | # In this example 75% of the episodes in a season must be present in the client for it to be downloaded 109 | # 110 | # Default: 0.75 111 | # 112 | # smartModeThreshold: 0.75 113 | 114 | # Parse Torrent File 115 | # Toggles torrent file parsing to get the correct folder name 116 | # 117 | # Default: false 118 | # 119 | # parseTorrentFile: false 120 | 121 | # Fuzzy Matching 122 | # You can decide for which criteria the matching should be less strict, e.g. repack status and HDR format 123 | # 124 | fuzzyMatching: 125 | # Skip Repack Compare 126 | # Toggle comparing of the repack status of a release, e.g. repacked episodes will be treated the same as a non-repacked ones 127 | # 128 | # Default: false 129 | # 130 | skipRepackCompare: false 131 | 132 | # Simplify HDR Compare 133 | # Toggle simplification of HDR formats for comparing, e.g. HDR10+ will be treated the same as HDR 134 | # 135 | # Default: false 136 | # 137 | simplifyHdrCompare: false 138 | 139 | # Metadata 140 | # Here you can provide credentials for additional metadata providers 141 | # 142 | metadata: 143 | # TVDB API Key 144 | # Your TVDB API Key. If you have a user-subscription key, also provide your subscriber PIN in tvdbPIN. 145 | # 146 | # Optional 147 | # 148 | tvdbAPIKey: "" 149 | 150 | # TVDB PIN 151 | # Your TVDB Subscriber PIN. 152 | # 153 | # Optional 154 | # 155 | tvdbPIN: "" 156 | 157 | # API Token 158 | # If not defined, removes api authentication 159 | # 160 | # Optional 161 | # 162 | # apiToken: "" 163 | 164 | # Notifications 165 | # You can decide which notifications you want to receive 166 | # 167 | notifications: 168 | # Notification Level 169 | # Decides what notifications you want to receive 170 | # 171 | # Default: [ "MATCH", "ERROR" ] 172 | # 173 | # Options: "MATCH", "INFO", "ERROR" 174 | # 175 | # Examples: 176 | # [ "MATCH", "INFO", "ERROR" ] would send everything 177 | # [ "MATCH", "INFO" ] would send all matches and rejection infos 178 | # [ "MATCH", "ERROR" ] would send all matches and errors 179 | # [ "ERROR" ] would only send all errors 180 | # 181 | notificationLevel: [ "MATCH", "ERROR" ] 182 | 183 | # Discord 184 | # Uses the given Discord webhook to send notifications for various events 185 | # 186 | # Optional 187 | # 188 | discord: "" 189 | -------------------------------------------------------------------------------- /distrib/systemd/seasonpackarr@.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=seasonpackarr service for %i 3 | After=syslog.target network-online.target 4 | 5 | [Service] 6 | Type=simple 7 | User=%i 8 | Group=%i 9 | ExecStart=/usr/bin/seasonpackarr start --config=/home/%i/.config/seasonpackarr 10 | 11 | [Install] 12 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | seasonpackarr: 3 | container_name: seasonpackarr 4 | image: ghcr.io/nuxencs/seasonpackarr 5 | restart: unless-stopped 6 | user: ${PUID}:${PGID} #UID and GID 7 | ports: 8 | - "127.0.0.1:42069:42069" 9 | environment: 10 | # Core settings 11 | SEASONPACKARR__DISABLE_CONFIG_FILE: false # set to true to not rely on config.yaml 12 | SEASONPACKARR__HOST: 0.0.0.0 13 | SEASONPACKARR__PORT: 42069 14 | SEASONPACKARR__API_TOKEN: 15 | 16 | # Logging settings 17 | SEASONPACKARR__LOG_LEVEL: DEBUG 18 | SEASONPACKARR__LOG_PATH: 19 | SEASONPACKARR__LOG_MAX_SIZE: 50 20 | SEASONPACKARR__LOG_MAX_BACKUPS: 3 21 | 22 | # Feature settings 23 | SEASONPACKARR__SMART_MODE: false 24 | SEASONPACKARR__SMART_MODE_THRESHOLD: 0.75 25 | SEASONPACKARR__PARSE_TORRENT_FILE: false 26 | 27 | # Fuzzy matching settings 28 | SEASONPACKARR__FUZZY_MATCHING_SKIP_REPACK_COMPARE: false 29 | SEASONPACKARR__FUZZY_MATCHING_SIMPLIFY_HDR_COMPARE: false 30 | 31 | # Notification settings 32 | SEASONPACKARR__NOTIFICATIONS_DISCORD: 33 | SEASONPACKARR__NOTIFICATIONS_NOTIFICATION_LEVEL: MATCH,ERROR 34 | 35 | # Client settings (can have multiple clients by changing DEFAULT to another name) 36 | SEASONPACKARR__CLIENTS_DEFAULT_HOST: 127.0.0.1 37 | SEASONPACKARR__CLIENTS_DEFAULT_PORT: 8080 38 | SEASONPACKARR__CLIENTS_DEFAULT_USERNAME: admin 39 | SEASONPACKARR__CLIENTS_DEFAULT_PASSWORD: adminadmin 40 | SEASONPACKARR__CLIENTS_DEFAULT_PREIMPORTPATH: 41 | volumes: 42 | - ${DOCKERCONFDIR}/seasonpackarr:/config # location of the config file 43 | - /data/torrents:/data/torrents # your torrent data directory 44 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/nuxencs/seasonpackarr 2 | 3 | go 1.24.2 4 | 5 | require ( 6 | github.com/anacrolix/torrent v1.58.1 7 | github.com/autobrr/go-qbittorrent v1.14.0 8 | github.com/dlclark/regexp2 v1.11.5 9 | github.com/gin-contrib/cors v1.7.5 10 | github.com/gin-contrib/requestid v1.0.5 11 | github.com/gin-gonic/gin v1.10.1 12 | github.com/google/uuid v1.6.0 13 | github.com/knadh/koanf/parsers/yaml v1.0.0 14 | github.com/knadh/koanf/providers/file v1.2.0 15 | github.com/knadh/koanf/v2 v2.2.0 16 | github.com/moistari/rls v0.5.13 17 | github.com/mrobinsn/go-tvmaze v1.2.1 18 | github.com/pkg/errors v0.9.1 19 | github.com/puzpuzpuz/xsync/v3 v3.5.1 20 | github.com/rs/zerolog v1.34.0 21 | github.com/spf13/cobra v1.9.1 22 | github.com/stretchr/testify v1.10.0 23 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 24 | ) 25 | 26 | require github.com/fatih/structs v1.1.0 // indirect 27 | 28 | require ( 29 | github.com/Masterminds/semver v1.5.0 // indirect 30 | github.com/anacrolix/generics v0.0.3-0.20240902042256-7fb2702ef0ca // indirect 31 | github.com/anacrolix/missinggo v1.3.0 // indirect 32 | github.com/anacrolix/missinggo/v2 v2.8.0 // indirect 33 | github.com/avast/retry-go v3.0.0+incompatible // indirect 34 | github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect 35 | github.com/bytedance/sonic v1.13.2 // indirect 36 | github.com/bytedance/sonic/loader v0.2.4 // indirect 37 | github.com/cloudwego/base64x v0.1.5 // indirect 38 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 39 | github.com/fsnotify/fsnotify v1.9.0 // indirect 40 | github.com/gabriel-vasile/mimetype v1.4.9 // indirect 41 | github.com/gin-contrib/sse v1.1.0 // indirect 42 | github.com/go-playground/locales v0.14.1 // indirect 43 | github.com/go-playground/universal-translator v0.18.1 // indirect 44 | github.com/go-playground/validator/v10 v10.26.0 // indirect 45 | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect 46 | github.com/goccy/go-json v0.10.5 // indirect 47 | github.com/huandu/xstrings v1.5.0 // indirect 48 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 49 | github.com/json-iterator/go v1.1.12 // indirect 50 | github.com/klauspost/cpuid/v2 v2.2.10 // indirect 51 | github.com/knadh/koanf/maps v0.1.2 // indirect 52 | github.com/knadh/koanf/providers/structs v1.0.0 53 | github.com/leodido/go-urn v1.4.0 // indirect 54 | github.com/mattn/go-colorable v0.1.14 // indirect 55 | github.com/mattn/go-isatty v0.0.20 // indirect 56 | github.com/minio/sha256-simd v1.0.1 // indirect 57 | github.com/mitchellh/copystructure v1.2.0 // indirect 58 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 59 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 60 | github.com/modern-go/reflect2 v1.0.2 // indirect 61 | github.com/mr-tron/base58 v1.2.0 // indirect 62 | github.com/multiformats/go-multihash v0.2.3 // indirect 63 | github.com/multiformats/go-varint v0.0.7 // indirect 64 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 65 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 66 | github.com/sirupsen/logrus v1.9.3 // indirect 67 | github.com/spaolacci/murmur3 v1.1.0 // indirect 68 | github.com/spf13/pflag v1.0.6 // indirect 69 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 70 | github.com/ugorji/go/codec v1.2.12 // indirect 71 | golang.org/x/arch v0.17.0 // indirect 72 | golang.org/x/crypto v0.38.0 // indirect 73 | golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect 74 | golang.org/x/net v0.40.0 // indirect 75 | golang.org/x/sync v0.14.0 // indirect 76 | golang.org/x/sys v0.33.0 // indirect 77 | golang.org/x/text v0.25.0 // indirect 78 | google.golang.org/protobuf v1.36.6 // indirect 79 | gopkg.in/yaml.v3 v3.0.1 // indirect 80 | lukechampine.com/blake3 v1.4.1 // indirect 81 | ) 82 | -------------------------------------------------------------------------------- /internal/api/token.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 - 2025, nuxen and the seasonpackarr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package api 5 | 6 | import ( 7 | "github.com/google/uuid" 8 | ) 9 | 10 | func GenerateToken() string { 11 | return uuid.New().String() 12 | } 13 | -------------------------------------------------------------------------------- /internal/buildinfo/buildinfo.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 - 2025, nuxen and the seasonpackarr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package buildinfo 5 | 6 | var ( 7 | Version = "dev" 8 | Commit = "" 9 | Date = "" 10 | ) 11 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 - 2025, Ludvig Lundgren and the autobrr contributors. 2 | // Code is heavily modified for use with seasonpackarr 3 | // SPDX-License-Identifier: GPL-2.0-or-later 4 | 5 | package config 6 | 7 | import ( 8 | "bytes" 9 | "fmt" 10 | "io/fs" 11 | "log" 12 | "os" 13 | "path" 14 | "path/filepath" 15 | "strconv" 16 | "strings" 17 | "sync" 18 | "text/template" 19 | 20 | "github.com/nuxencs/seasonpackarr/internal/domain" 21 | "github.com/nuxencs/seasonpackarr/internal/logger" 22 | "github.com/nuxencs/seasonpackarr/pkg/errors" 23 | 24 | "github.com/knadh/koanf/parsers/yaml" 25 | "github.com/knadh/koanf/providers/file" 26 | "github.com/knadh/koanf/providers/structs" 27 | "github.com/knadh/koanf/v2" 28 | ) 29 | 30 | var configTemplate = `# yaml-language-server: $schema=https://raw.githubusercontent.com/nuxencs/seasonpackarr/develop/schemas/config-schema.json 31 | # config.yaml 32 | 33 | # Hostname / IP 34 | # 35 | # Default: "0.0.0.0" 36 | # 37 | host: "{{ .host }}" 38 | 39 | # Port 40 | # 41 | # Default: 42069 42 | # 43 | port: 42069 44 | 45 | clients: 46 | # Client name used in the autobrr filter, can be customized to whatever you like 47 | # Note that a client name has to be unique and can only be used once 48 | # 49 | # Default: default 50 | # 51 | default: 52 | # qBittorrent Hostname / IP 53 | # 54 | # Default: "127.0.0.1" 55 | # 56 | host: "127.0.0.1" 57 | 58 | # qBittorrent Port 59 | # 60 | # Default: 8080 61 | # 62 | port: 8080 63 | 64 | # qBittorrent Username 65 | # 66 | # Default: "admin" 67 | # 68 | username: "admin" 69 | 70 | # qBittorrent Password 71 | # 72 | # Default: "adminadmin" 73 | # 74 | password: "adminadmin" 75 | 76 | # Pre Import Path of qBittorrent for Sonarr 77 | # Needs to be filled out correctly, e.g. "/data/torrents/tv-hd" 78 | # 79 | # Default: "" 80 | # 81 | preImportPath: "" 82 | 83 | # Below you can find an example on how to define a second qBittorrent client 84 | # If you want to define even more clients just copy this segment and adjust the values accordingly 85 | # 86 | #multi_client_example: 87 | # host: "127.0.0.1" 88 | # 89 | # port: 9090 90 | # 91 | # username: "example" 92 | # 93 | # password: "example" 94 | # 95 | # preImportPath: "" 96 | 97 | # seasonpackarr logs file 98 | # If not defined, logs to stdout 99 | # Make sure to use forward slashes and include the filename with extension. eg: "logs/seasonpackarr.log", "C:/seasonpackarr/logs/seasonpackarr.log" 100 | # 101 | # Optional 102 | # 103 | # logPath: "" 104 | 105 | # Log level 106 | # 107 | # Default: "DEBUG" 108 | # 109 | # Options: "ERROR", "DEBUG", "INFO", "WARN", "TRACE" 110 | # 111 | logLevel: "DEBUG" 112 | 113 | # Log Max Size 114 | # Max log size in megabytes 115 | # 116 | # Default: 50 117 | # 118 | # logMaxSize: 50 119 | 120 | # Log Max Backups 121 | # Max amount of old log files 122 | # 123 | # Default: 3 124 | # 125 | # logMaxBackups: 3 126 | 127 | # Smart Mode 128 | # Toggles smart mode to only download season packs that have a certain amount of episodes from a release group 129 | # already in the client 130 | # 131 | # Default: false 132 | # 133 | # smartMode: false 134 | 135 | # Smart Mode Threshold 136 | # Sets the threshold for the percentage of episodes out of a season that must be present in the client 137 | # In this example 75% of the episodes in a season must be present in the client for it to be downloaded 138 | # 139 | # Default: 0.75 140 | # 141 | # smartModeThreshold: 0.75 142 | 143 | # Parse Torrent File 144 | # Toggles torrent file parsing to get the correct folder name 145 | # 146 | # Default: false 147 | # 148 | # parseTorrentFile: false 149 | 150 | # Fuzzy Matching 151 | # You can decide for which criteria the matching should be less strict, e.g. repack status and HDR format 152 | # 153 | fuzzyMatching: 154 | # Skip Repack Compare 155 | # Toggle comparing of the repack status of a release, e.g. repacked episodes will be treated the same as a non-repacked ones 156 | # 157 | # Default: false 158 | # 159 | skipRepackCompare: false 160 | 161 | # Simplify HDR Compare 162 | # Toggle simplification of HDR formats for comparing, e.g. HDR10+ will be treated the same as HDR 163 | # 164 | # Default: false 165 | # 166 | simplifyHdrCompare: false 167 | 168 | # Metadata 169 | # Here you can provide credentials for additional metadata providers 170 | # 171 | metadata: 172 | # TVDB API Key 173 | # Your TVDB API Key. If you have a user-subscription key, also provide your subscriber PIN in tvdbPIN. 174 | # 175 | # Optional 176 | # 177 | tvdbAPIKey: "" 178 | 179 | # TVDB PIN 180 | # Your TVDB Subscriber PIN. 181 | # 182 | # Optional 183 | # 184 | tvdbPIN: "" 185 | 186 | # API Token 187 | # If not defined, removes api authentication 188 | # 189 | # Optional 190 | # 191 | # apiToken: "" 192 | 193 | # Notifications 194 | # You can decide which notifications you want to receive 195 | # 196 | notifications: 197 | # Notification Level 198 | # Decides what notifications you want to receive 199 | # 200 | # Default: [ "MATCH", "ERROR" ] 201 | # 202 | # Options: "MATCH", "INFO", "ERROR" 203 | # 204 | # Examples: 205 | # [ "MATCH", "INFO", "ERROR" ] would send everything 206 | # [ "MATCH", "INFO" ] would send all matches and rejection infos 207 | # [ "MATCH", "ERROR" ] would send all matches and errors 208 | # [ "ERROR" ] would only send all errors 209 | # 210 | notificationLevel: [ "MATCH", "ERROR" ] 211 | 212 | # Discord 213 | # Uses the given Discord webhook to send notifications for various events 214 | # 215 | # Optional 216 | # 217 | discord: "" 218 | ` 219 | 220 | func (c *AppConfig) writeConfig(configPath string, configFile string) error { 221 | cfgPath := filepath.Join(configPath, configFile) 222 | 223 | // check if configPath exists, if not create it 224 | if _, err := os.Stat(configPath); errors.Is(err, fs.ErrNotExist) { 225 | err := os.MkdirAll(configPath, os.ModePerm) 226 | if err != nil { 227 | log.Println(err) 228 | return err 229 | } 230 | } 231 | 232 | // check if config exists, if not create it 233 | if _, err := os.Stat(cfgPath); errors.Is(err, fs.ErrNotExist) { 234 | // set default host 235 | host := "0.0.0.0" 236 | 237 | if _, err := os.Stat("/.dockerenv"); err == nil { 238 | // docker creates a .dockerenv file at the root 239 | // of the directory tree inside the container. 240 | // if this file exists then the viewer is running 241 | // from inside a docker container so return true 242 | host = "0.0.0.0" 243 | } else if _, err := os.Stat("/dev/.lxc-boot-id"); err == nil { 244 | // lxc creates this file containing the uuid 245 | // of the container in every boot. 246 | // if this file exists then the viewer is running 247 | // from inside a lxc container so return true 248 | host = "0.0.0.0" 249 | } else if pd, _ := os.Open("/proc/1/cgroup"); pd != nil { 250 | defer pd.Close() 251 | b := make([]byte, 4096) 252 | pd.Read(b) 253 | if strings.Contains(string(b), "/docker") || strings.Contains(string(b), "/lxc") { 254 | host = "0.0.0.0" 255 | } 256 | } 257 | 258 | f, err := os.Create(cfgPath) 259 | if err != nil { // perm 0666 260 | // handle failed create 261 | log.Printf("error creating file: %q", err) 262 | return err 263 | } 264 | defer f.Close() 265 | 266 | // setup text template to inject variables into 267 | tmpl, err := template.New("config").Parse(configTemplate) 268 | if err != nil { 269 | return errors.Wrap(err, "could not create config template") 270 | } 271 | 272 | tmplVars := map[string]string{ 273 | "host": host, 274 | } 275 | 276 | var buffer bytes.Buffer 277 | if err = tmpl.Execute(&buffer, &tmplVars); err != nil { 278 | return errors.Wrap(err, "could not write template output") 279 | } 280 | 281 | if _, err = f.WriteString(buffer.String()); err != nil { 282 | log.Printf("error writing contents to file: %v %q", configPath, err) 283 | return err 284 | } 285 | 286 | return f.Sync() 287 | } 288 | 289 | return nil 290 | } 291 | 292 | type Config interface { 293 | UpdateConfig() error 294 | DynamicReload(log logger.Logger) 295 | } 296 | 297 | type AppConfig struct { 298 | Config *domain.Config 299 | m *sync.Mutex 300 | k *koanf.Koanf 301 | } 302 | 303 | func New(configPath string, version string) *AppConfig { 304 | if _, err := os.Stat(filepath.Join(configPath, "config.toml")); err == nil { 305 | log.Fatalf("A legacy 'config.toml' file has been detected. " + 306 | "To continue, please migrate your configuration to the new 'config.yaml' format. " + 307 | "You can easily do this by copying the settings from 'config.toml' to 'config.yaml' and then renaming 'config.toml' to 'config.toml.old'. " + 308 | "The only difference between the old and the new config is, that the qbit client info is now stored in an array to allow for multiple clients to be configured.") 309 | } 310 | 311 | // init app config 312 | c := &AppConfig{ 313 | Config: &domain.Config{ 314 | Version: version, 315 | ConfigPath: configPath, 316 | }, 317 | m: new(sync.Mutex), 318 | k: koanf.New("."), 319 | } 320 | 321 | c.defaults() 322 | c.Config.DisableConfigFile = os.Getenv("SEASONPACKARR__DISABLE_CONFIG_FILE") == "true" 323 | 324 | if !c.Config.DisableConfigFile { 325 | c.load() 326 | } 327 | 328 | c.loadFromEnv() 329 | 330 | for clientName, client := range c.Config.Clients { 331 | if client.PreImportPath == "" { 332 | log.Fatalf("preImportPath for client %q can't be empty, please provide a valid path to the directory you want seasonpacks to be hardlinked to", clientName) 333 | } 334 | 335 | if _, err := os.Stat(client.PreImportPath); errors.Is(err, fs.ErrNotExist) { 336 | log.Fatalf("preImportPath for client %q doesn't exist, please make sure you entered the correct path", clientName) 337 | } 338 | } 339 | 340 | return c 341 | } 342 | 343 | func (c *AppConfig) defaults() { 344 | // Set default values 345 | c.Config.DisableConfigFile = false 346 | c.Config.Host = "0.0.0.0" 347 | c.Config.Port = 42069 348 | c.Config.Clients = make(map[string]*domain.Client) 349 | c.Config.LogPath = "" 350 | c.Config.LogLevel = "DEBUG" 351 | c.Config.LogMaxSize = 50 352 | c.Config.LogMaxBackups = 3 353 | c.Config.SmartMode = false 354 | c.Config.SmartModeThreshold = 0.75 355 | c.Config.ParseTorrentFile = false 356 | c.Config.FuzzyMatching = domain.FuzzyMatching{ 357 | SkipRepackCompare: false, 358 | SimplifyHdrCompare: false, 359 | } 360 | c.Config.Metadata = domain.Metadata{ 361 | TVDBAPIKey: "", 362 | TVDBPIN: "", 363 | // SonarrHost: "", 364 | // SonarrPort: 0, 365 | // SonarrAPIKey: "", 366 | } 367 | c.Config.APIToken = "" 368 | c.Config.Notifications = domain.Notifications{ 369 | NotificationLevel: []string{"MATCH", "ERROR"}, 370 | Discord: "", 371 | } 372 | 373 | // load default values into koanf 374 | if err := c.k.Load(structs.Provider(c.Config, "yaml"), nil); err != nil { 375 | log.Fatalf("could not load default values into config: %q", err) 376 | } 377 | } 378 | 379 | func (c *AppConfig) loadFromEnv() { 380 | prefix := "SEASONPACKARR__" 381 | 382 | logLevel := c.Config.LogLevel 383 | if envLogLevel := os.Getenv(prefix + "LOG_LEVEL"); envLogLevel != "" { 384 | logLevel = envLogLevel 385 | } 386 | 387 | // create a temporary logger with the detected log level 388 | zLog := logger.New(&domain.Config{ 389 | LogLevel: logLevel, 390 | Version: c.Config.Version, 391 | }) 392 | 393 | envs := os.Environ() 394 | for _, env := range envs { 395 | if strings.HasPrefix(env, prefix) { 396 | envPair := strings.SplitN(env, "=", 2) 397 | envKey, envValue := envPair[0], envPair[1] 398 | 399 | // Determine if this is a sensitive value that should be redacted in logs 400 | sensitiveKeys := []string{"PASSWORD", "API_TOKEN", "DISCORD"} 401 | logValue := envValue 402 | 403 | for _, sensitive := range sensitiveKeys { 404 | if strings.Contains(envKey, sensitive) { 405 | logValue = "**REDACTED**" 406 | break 407 | } 408 | } 409 | 410 | zLog.Trace().Msgf("processing environment variable: %s=%s", envKey, logValue) 411 | 412 | if envValue != "" { 413 | switch envKey { 414 | // disable config file 415 | case prefix + "DISABLE_CONFIG_FILE": 416 | if b, err := strconv.ParseBool(envValue); err == nil { 417 | c.Config.DisableConfigFile = b 418 | } 419 | 420 | // server settings 421 | case prefix + "HOST": 422 | c.Config.Host = envValue 423 | case prefix + "PORT": 424 | if i, _ := strconv.ParseInt(envValue, 10, 32); i > 0 { 425 | c.Config.Port = int(i) 426 | } 427 | 428 | // log settings 429 | case prefix + "LOG_LEVEL": 430 | c.Config.LogLevel = envValue 431 | case prefix + "LOG_PATH": 432 | c.Config.LogPath = envValue 433 | case prefix + "LOG_MAX_SIZE": 434 | if i, _ := strconv.ParseInt(envValue, 10, 32); i > 0 { 435 | c.Config.LogMaxSize = int(i) 436 | } 437 | case prefix + "LOG_MAX_BACKUPS": 438 | if i, _ := strconv.ParseInt(envValue, 10, 32); i > 0 { 439 | c.Config.LogMaxBackups = int(i) 440 | } 441 | 442 | // smart mode settings 443 | case prefix + "SMART_MODE": 444 | if b, err := strconv.ParseBool(envValue); err == nil { 445 | c.Config.SmartMode = b 446 | } 447 | case prefix + "SMART_MODE_THRESHOLD": 448 | if f, _ := strconv.ParseFloat(envValue, 32); f > 0 { 449 | c.Config.SmartModeThreshold = float32(f) 450 | } 451 | 452 | // parse torrent file 453 | case prefix + "PARSE_TORRENT_FILE": 454 | if b, err := strconv.ParseBool(envValue); err == nil { 455 | c.Config.ParseTorrentFile = b 456 | } 457 | 458 | // api token 459 | case prefix + "API_TOKEN": 460 | c.Config.APIToken = envValue 461 | 462 | // fuzzy matching settings 463 | case prefix + "FUZZY_MATCHING_SKIP_REPACK_COMPARE": 464 | if b, err := strconv.ParseBool(envValue); err == nil { 465 | c.Config.FuzzyMatching.SkipRepackCompare = b 466 | } 467 | case prefix + "FUZZY_MATCHING_SIMPLIFY_HDR_COMPARE": 468 | if b, err := strconv.ParseBool(envValue); err == nil { 469 | c.Config.FuzzyMatching.SimplifyHdrCompare = b 470 | } 471 | 472 | // metadata settings 473 | case prefix + "METADATA_TVDB_API_KEY": 474 | c.Config.Metadata.TVDBAPIKey = envValue 475 | case prefix + "METADATA_TVDB_PIN": 476 | c.Config.Metadata.TVDBPIN = envValue 477 | 478 | // notifications settings 479 | case prefix + "NOTIFICATIONS_DISCORD": 480 | c.Config.Notifications.Discord = envValue 481 | case prefix + "NOTIFICATIONS_NOTIFICATION_LEVEL": 482 | levels := strings.Split(envValue, ",") 483 | for i, level := range levels { 484 | levels[i] = strings.TrimSpace(level) 485 | } 486 | c.Config.Notifications.NotificationLevel = levels 487 | } 488 | 489 | // client settings 490 | if strings.HasPrefix(envKey, prefix+"CLIENTS_") { 491 | parts := strings.Split(strings.TrimPrefix(envKey, prefix+"CLIENTS_"), "_") 492 | if len(parts) == 2 { 493 | clientName := strings.ToLower(parts[0]) 494 | setting := parts[1] 495 | 496 | // initialize client if it doesn't exist 497 | if c.Config.Clients[clientName] == nil { 498 | c.Config.Clients[clientName] = &domain.Client{} 499 | } 500 | 501 | switch setting { 502 | case "HOST": 503 | c.Config.Clients[clientName].Host = envValue 504 | case "PORT": 505 | if i, _ := strconv.ParseInt(envValue, 10, 32); i > 0 { 506 | c.Config.Clients[clientName].Port = int(i) 507 | } 508 | case "USERNAME": 509 | c.Config.Clients[clientName].Username = envValue 510 | case "PASSWORD": 511 | c.Config.Clients[clientName].Password = envValue 512 | case "PREIMPORTPATH": 513 | c.Config.Clients[clientName].PreImportPath = envValue 514 | } 515 | } 516 | } 517 | } 518 | } 519 | } 520 | 521 | // load parsed env variables into koanf 522 | if err := c.k.Load(structs.Provider(c.Config, "yaml"), nil); err != nil { 523 | log.Fatalf("could not load env vars into config: %q", err) 524 | } 525 | } 526 | 527 | func (c *AppConfig) load() { 528 | // clean trailing slash from c.Config.ConfigPath 529 | configPath := path.Clean(c.Config.ConfigPath) 530 | 531 | var configFile string 532 | 533 | if configPath != "" { 534 | // check if path and file exists 535 | // if not, create path and file 536 | if err := c.writeConfig(configPath, "config.yaml"); err != nil { 537 | log.Printf("config write error: %q", err) 538 | } 539 | 540 | configFile = path.Join(configPath, "config.yaml") 541 | } else { 542 | // try to find config in standard locations 543 | locations := []string{ 544 | "./config.yaml", 545 | "$HOME/.config/seasonpackarr/config.yaml", 546 | "$HOME/.seasonpackarr/config.yaml", 547 | } 548 | 549 | for _, loc := range locations { 550 | expandedLoc := os.ExpandEnv(loc) 551 | if _, err := os.Stat(expandedLoc); err == nil { 552 | configFile = expandedLoc 553 | break 554 | } 555 | } 556 | 557 | if configFile == "" { 558 | log.Fatalf("could not find config file") 559 | } 560 | } 561 | 562 | // load the config file 563 | if err := c.k.Load(file.Provider(configFile), yaml.Parser()); err != nil { 564 | log.Fatalf("config read error: %q", err) 565 | } 566 | 567 | // unmarshal the config into the Config struct 568 | if err := c.k.Unmarshal("", c.Config); err != nil { 569 | log.Fatalf("could not unmarshal config file: %v: err %q", configFile, err) 570 | } 571 | } 572 | 573 | func (c *AppConfig) DynamicReload(log logger.Logger) { 574 | if c.Config.DisableConfigFile { 575 | return 576 | } 577 | 578 | configFile := path.Join(c.Config.ConfigPath, "config.yaml") 579 | 580 | // use koanf's built-in file watcher 581 | f := file.Provider(configFile) 582 | 583 | // watch the config file for changes 584 | f.Watch(func(event any, err error) { 585 | if err != nil { 586 | log.Error().Err(err).Msg("error watching config file") 587 | return 588 | } 589 | 590 | c.m.Lock() 591 | defer c.m.Unlock() 592 | 593 | // create a new koanf instance for reloading 594 | k := koanf.New(".") 595 | 596 | // load the config file 597 | if err := k.Load(f, yaml.Parser()); err != nil { 598 | log.Error().Err(err).Msg("failed to reload config file") 599 | return 600 | } 601 | 602 | // unmarshal the updated config into the Config struct 603 | if err := k.Unmarshal("", c.Config); err != nil { 604 | log.Error().Err(err).Msg("failed to unmarshal updated config") 605 | return 606 | } 607 | 608 | log.SetLogLevel(c.Config.LogLevel) 609 | 610 | log.Debug().Msg("config file reloaded!") 611 | }) 612 | } 613 | 614 | func (c *AppConfig) UpdateConfig() error { 615 | if c.Config.DisableConfigFile { 616 | return nil 617 | } 618 | 619 | configFile := path.Join(c.Config.ConfigPath, "config.yaml") 620 | 621 | f, err := os.ReadFile(configFile) 622 | if err != nil { 623 | return errors.Wrap(err, "could not read config configFile: %s", configFile) 624 | } 625 | 626 | lines := strings.Split(string(f), "\n") 627 | lines = c.processLines(lines) 628 | 629 | output := strings.Join(lines, "\n") 630 | if err := os.WriteFile(configFile, []byte(output), 0o644); err != nil { 631 | return errors.Wrap(err, "could not write config file: %s", configFile) 632 | } 633 | 634 | return nil 635 | } 636 | 637 | func (c *AppConfig) processLines(lines []string) []string { 638 | // keep track of not found values to append at bottom 639 | var ( 640 | foundLineLogLevel = false 641 | foundLineLogPath = false 642 | foundLineSmartMode = false 643 | foundLineSmartModeThreshold = false 644 | foundLineParseTorrentFile = false 645 | foundLineFuzzyMatching = false 646 | foundLineSkipRepackCompare = false 647 | foundLineSimplifyHdrCompare = false 648 | foundLineMetadata = false 649 | foundLineMetadataTVDBAPIKey = false 650 | foundLineMetadataTVDBPIN = false 651 | foundLineAPIToken = false 652 | foundLineNotifications = false 653 | foundLineNotificationLevel = false 654 | foundLineDiscord = false 655 | ) 656 | 657 | for i, line := range lines { 658 | if !foundLineLogLevel && strings.Contains(line, "logLevel:") { 659 | lines[i] = fmt.Sprintf("logLevel: \"%s\"", c.Config.LogLevel) 660 | foundLineLogLevel = true 661 | } 662 | if !foundLineLogPath && strings.Contains(line, "logPath:") { 663 | if c.Config.LogPath == "" { 664 | lines[i] = "# logPath: \"\"" 665 | } else { 666 | lines[i] = fmt.Sprintf("logPath: \"%s\"", c.Config.LogPath) 667 | } 668 | foundLineLogPath = true 669 | } 670 | if !foundLineSmartMode && strings.Contains(line, "smartMode:") { 671 | lines[i] = fmt.Sprintf("smartMode: %t", c.Config.SmartMode) 672 | foundLineSmartMode = true 673 | } 674 | if !foundLineSmartModeThreshold && strings.Contains(line, "smartModeThreshold:") { 675 | lines[i] = fmt.Sprintf("smartModeThreshold: %.2f", c.Config.SmartModeThreshold) 676 | foundLineSmartModeThreshold = true 677 | } 678 | if !foundLineParseTorrentFile && strings.Contains(line, "parseTorrentFile:") { 679 | lines[i] = fmt.Sprintf("parseTorrentFile: %t", c.Config.ParseTorrentFile) 680 | foundLineParseTorrentFile = true 681 | } 682 | if !foundLineFuzzyMatching && strings.Contains(line, "fuzzyMatching:") { 683 | foundLineFuzzyMatching = true 684 | } 685 | if foundLineFuzzyMatching && !foundLineSkipRepackCompare && strings.Contains(line, "skipRepackCompare:") { 686 | lines[i] = fmt.Sprintf(" skipRepackCompare: %t", c.Config.FuzzyMatching.SkipRepackCompare) 687 | foundLineSkipRepackCompare = true 688 | } 689 | if foundLineFuzzyMatching && !foundLineSimplifyHdrCompare && strings.Contains(line, "simplifyHdrCompare:") { 690 | lines[i] = fmt.Sprintf(" simplifyHdrCompare: %t", c.Config.FuzzyMatching.SimplifyHdrCompare) 691 | foundLineSimplifyHdrCompare = true 692 | } 693 | if !foundLineMetadata && strings.Contains(line, "metadata:") { 694 | foundLineMetadata = true 695 | } 696 | if foundLineMetadata && !foundLineMetadataTVDBAPIKey && strings.Contains(line, "tvdbAPIKey:") { 697 | lines[i] = fmt.Sprintf(" tvdbAPIKey: \"%s\"", c.Config.Metadata.TVDBAPIKey) 698 | foundLineMetadataTVDBAPIKey = true 699 | } 700 | if foundLineMetadata && !foundLineMetadataTVDBPIN && strings.Contains(line, "tvdbPIN:") { 701 | lines[i] = fmt.Sprintf(" tvdbPIN: \"%s\"", c.Config.Metadata.TVDBPIN) 702 | foundLineMetadataTVDBPIN = true 703 | } 704 | if !foundLineAPIToken && strings.Contains(line, "apiToken:") { 705 | if c.Config.APIToken == "" { 706 | lines[i] = "# apiToken: \"\"" 707 | } else { 708 | lines[i] = fmt.Sprintf("apiToken: \"%s\"", c.Config.APIToken) 709 | } 710 | foundLineAPIToken = true 711 | } 712 | if !foundLineNotifications && strings.Contains(line, "notifications:") { 713 | foundLineNotifications = true 714 | } 715 | if foundLineNotifications && !foundLineNotificationLevel && strings.Contains(line, "notificationLevel:") { 716 | printLevels := make([]string, len(c.Config.Notifications.NotificationLevel)) 717 | for i, level := range c.Config.Notifications.NotificationLevel { 718 | printLevels[i] = fmt.Sprintf("%q", strings.TrimSpace(level)) 719 | } 720 | lines[i] = fmt.Sprintf(" notificationLevel: [%s]", strings.Join(printLevels, ", ")) 721 | foundLineNotificationLevel = true 722 | } 723 | if foundLineNotifications && !foundLineDiscord && strings.Contains(line, "discord:") { 724 | lines[i] = fmt.Sprintf(" discord: \"%s\"", c.Config.Notifications.Discord) 725 | foundLineDiscord = true 726 | } 727 | } 728 | 729 | if !foundLineLogLevel { 730 | lines = append(lines, "# Log level") 731 | lines = append(lines, "#") 732 | lines = append(lines, `# Default: "DEBUG"`) 733 | lines = append(lines, "#") 734 | lines = append(lines, `# Options: "ERROR", "DEBUG", "INFO", "WARN", "TRACE"`) 735 | lines = append(lines, "#") 736 | lines = append(lines, fmt.Sprintf("logLevel: \"%s\"\n", c.Config.LogLevel)) 737 | } 738 | 739 | if !foundLineLogPath { 740 | lines = append(lines, "# Log Path") 741 | lines = append(lines, "#") 742 | lines = append(lines, "# Optional") 743 | lines = append(lines, "#") 744 | if c.Config.LogPath == "" { 745 | lines = append(lines, "# logPath: \"\"") 746 | lines = append(lines, "") 747 | } else { 748 | lines = append(lines, fmt.Sprintf("logPath: \"%s\"\n", c.Config.LogPath)) 749 | lines = append(lines, "") 750 | } 751 | } 752 | 753 | if !foundLineSmartMode { 754 | lines = append(lines, "# Smart Mode") 755 | lines = append(lines, "# Toggles smart mode to only download season packs that have a certain amount of episodes from a release group") 756 | lines = append(lines, "# already in the client") 757 | lines = append(lines, "#") 758 | lines = append(lines, "# Default: false") 759 | lines = append(lines, "#") 760 | lines = append(lines, fmt.Sprintf("# smartMode: %t\n", c.Config.SmartMode)) 761 | } 762 | 763 | if !foundLineSmartMode { 764 | lines = append(lines, "# Smart Mode Threshold") 765 | lines = append(lines, "# Sets the threshold for the percentage of episodes out of a season that must be present in the client") 766 | lines = append(lines, "# In this example 75% of the episodes in a season must be present in the client for it to be downloaded") 767 | lines = append(lines, "#") 768 | lines = append(lines, "# Default: 0.75") 769 | lines = append(lines, "#") 770 | lines = append(lines, fmt.Sprintf("# smartModeThreshold: %.2f\n", c.Config.SmartModeThreshold)) 771 | } 772 | 773 | if !foundLineParseTorrentFile { 774 | lines = append(lines, "# Parse Torrent File") 775 | lines = append(lines, "# Toggles torrent file parsing to get the correct folder name") 776 | lines = append(lines, "#") 777 | lines = append(lines, "# Default: false") 778 | lines = append(lines, "#") 779 | lines = append(lines, fmt.Sprintf("# parseTorrentFile: %t\n", c.Config.ParseTorrentFile)) 780 | } 781 | 782 | if !foundLineFuzzyMatching { 783 | lines = append(lines, "# Fuzzy Matching") 784 | lines = append(lines, "# You can decide for which criteria the matching should be less strict, e.g. repack status and HDR format") 785 | lines = append(lines, "#") 786 | lines = append(lines, "fuzzyMatching:") 787 | if !foundLineSkipRepackCompare { 788 | lines = append(lines, " # Skip Repack Compare") 789 | lines = append(lines, " # Toggle comparing of the repack status of a release, e.g. repacked episodes will be treated the same as a non-repacked ones") 790 | lines = append(lines, " #") 791 | lines = append(lines, " # Default: false") 792 | lines = append(lines, " #") 793 | lines = append(lines, fmt.Sprintf(" skipRepackCompare: %t\n", c.Config.FuzzyMatching.SkipRepackCompare)) 794 | } 795 | if !foundLineSimplifyHdrCompare { 796 | lines = append(lines, " # Simplify HDR Compare") 797 | lines = append(lines, " # Toggle simplification of HDR formats for comparing, e.g. HDR10+ will be treated the same as HDR") 798 | lines = append(lines, " #") 799 | lines = append(lines, " # Default: false") 800 | lines = append(lines, " #") 801 | lines = append(lines, fmt.Sprintf(" simplifyHdrCompare: %t\n", c.Config.FuzzyMatching.SimplifyHdrCompare)) 802 | } 803 | } 804 | 805 | if !foundLineMetadata { 806 | lines = append(lines, "# Metadata") 807 | lines = append(lines, "# Here you can provide credentials for additional metadata providers") 808 | lines = append(lines, "#") 809 | lines = append(lines, "metadata:") 810 | if !foundLineMetadataTVDBAPIKey { 811 | lines = append(lines, " # TVDB API Key") 812 | lines = append(lines, " # Your TVDB API Key. If you have a user-subscription key, also provide your subscriber PIN in tvdbPIN.") 813 | lines = append(lines, " #") 814 | lines = append(lines, " # Optional") 815 | lines = append(lines, " #") 816 | lines = append(lines, fmt.Sprintf(" tvdbAPIKey: \"%s\"\n", c.Config.Metadata.TVDBAPIKey)) 817 | } 818 | if !foundLineMetadataTVDBPIN { 819 | lines = append(lines, " # TVDB PIN") 820 | lines = append(lines, " # Your TVDB Subscriber PIN.") 821 | lines = append(lines, " #") 822 | lines = append(lines, " # Optional") 823 | lines = append(lines, " #") 824 | lines = append(lines, fmt.Sprintf(" tvdbPIN: \"%s\"\n", c.Config.Metadata.TVDBPIN)) 825 | } 826 | } 827 | 828 | if !foundLineAPIToken { 829 | lines = append(lines, "# API Token") 830 | lines = append(lines, "# If not defined, removes api authentication") 831 | lines = append(lines, "#") 832 | lines = append(lines, "# Optional") 833 | lines = append(lines, "#") 834 | if c.Config.APIToken == "" { 835 | lines = append(lines, "# apiToken: \"\"\n") 836 | } else { 837 | lines = append(lines, fmt.Sprintf("apiToken: \"%s\"\n", c.Config.APIToken)) 838 | } 839 | } 840 | 841 | if !foundLineNotifications { 842 | lines = append(lines, "# Notifications") 843 | lines = append(lines, "# You can decide which notifications you want to receive") 844 | lines = append(lines, "#") 845 | lines = append(lines, "notifications:") 846 | if !foundLineNotificationLevel { 847 | lines = append(lines, " # Notification Level") 848 | lines = append(lines, " # Decides what notifications you want to receive") 849 | lines = append(lines, " #") 850 | lines = append(lines, " # Default: [ \"MATCH\", \"ERROR\" ]") 851 | lines = append(lines, " #") 852 | lines = append(lines, " # Options: \"MATCH\", \"INFO\", \"ERROR\"") 853 | lines = append(lines, " #") 854 | lines = append(lines, " # Examples:") 855 | lines = append(lines, " # [ \"MATCH\", \"INFO\", \"ERROR\" ] would send everything") 856 | lines = append(lines, " # [ \"MATCH\", \"INFO\" ] would send all matches and rejection infos") 857 | lines = append(lines, " # [ \"MATCH\", \"ERROR\" ] would send all matches and errors") 858 | lines = append(lines, " # [ \"ERROR\" ] would only send all errors") 859 | lines = append(lines, " #") 860 | lines = append(lines, fmt.Sprintf(" notificationLevel: %s\n", c.Config.Notifications.NotificationLevel)) 861 | } 862 | if !foundLineDiscord { 863 | lines = append(lines, " # Discord") 864 | lines = append(lines, " # Uses the given Discord webhook to send notifications for various events") 865 | lines = append(lines, " #") 866 | lines = append(lines, " # Optional") 867 | lines = append(lines, " #") 868 | lines = append(lines, fmt.Sprintf(" discord: \"%s\"\n", c.Config.Notifications.Discord)) 869 | } 870 | } 871 | 872 | return lines 873 | } 874 | -------------------------------------------------------------------------------- /internal/domain/config.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 - 2025, Ludvig Lundgren and the autobrr contributors. 2 | // Code is modified for use with seasonpackarr 3 | // SPDX-License-Identifier: GPL-2.0-or-later 4 | 5 | package domain 6 | 7 | type Client struct { 8 | Host string `yaml:"host"` 9 | Port int `yaml:"port"` 10 | Username string `yaml:"username"` 11 | Password string `yaml:"password"` 12 | PreImportPath string `yaml:"preImportPath"` 13 | } 14 | 15 | type FuzzyMatching struct { 16 | SkipRepackCompare bool `yaml:"skipRepackCompare"` 17 | SimplifyHdrCompare bool `yaml:"simplifyHdrCompare"` 18 | } 19 | 20 | type Notifications struct { 21 | NotificationLevel []string `yaml:"notificationLevel"` 22 | Discord string `yaml:"discord"` 23 | // Notifiarr string `yaml:"notifiarr"` 24 | // Shoutrrr string `yaml:"shoutrrr"` 25 | } 26 | 27 | type Metadata struct { 28 | TVDBAPIKey string `yaml:"tvdbAPIKey"` 29 | TVDBPIN string `yaml:"tvdbPIN"` 30 | // SonarrHost string `yaml:"sonarrHost"` 31 | // SonarrPort int `yaml:"sonarrPort"` 32 | // SonarrAPIKey string `yaml:"sonarrAPIKey"` 33 | } 34 | 35 | type Config struct { 36 | Version string 37 | ConfigPath string 38 | DisableConfigFile bool 39 | Host string `yaml:"host"` 40 | Port int `yaml:"port"` 41 | Clients map[string]*Client `yaml:"clients"` 42 | LogPath string `yaml:"logPath"` 43 | LogLevel string `yaml:"logLevel"` 44 | LogMaxSize int `yaml:"logMaxSize"` 45 | LogMaxBackups int `yaml:"logMaxBackups"` 46 | SmartMode bool `yaml:"smartMode"` 47 | SmartModeThreshold float32 `yaml:"smartModeThreshold"` 48 | ParseTorrentFile bool `yaml:"parseTorrentFile"` 49 | FuzzyMatching FuzzyMatching `yaml:"fuzzyMatching"` 50 | Metadata Metadata `yaml:"metadata"` 51 | APIToken string `yaml:"apiToken"` 52 | Notifications Notifications `yaml:"notifications"` 53 | } 54 | -------------------------------------------------------------------------------- /internal/domain/http.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 - 2025, nuxen and the seasonpackarr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package domain 5 | 6 | import "fmt" 7 | 8 | type StatusCode int 9 | 10 | const ( 11 | StatusNoMatches StatusCode = 200 12 | StatusResolutionMismatch StatusCode = 201 13 | StatusSourceMismatch StatusCode = 202 14 | StatusRlsGrpMismatch StatusCode = 203 15 | StatusCutMismatch StatusCode = 204 16 | StatusEditionMismatch StatusCode = 205 17 | StatusRepackStatusMismatch StatusCode = 206 18 | StatusHdrMismatch StatusCode = 207 19 | StatusStreamingServiceMismatch StatusCode = 208 20 | StatusAlreadyInClient StatusCode = 210 21 | StatusNotASeasonPack StatusCode = 211 22 | StatusSizeMismatch StatusCode = 212 23 | StatusSeasonMismatch StatusCode = 213 24 | StatusEpisodeMismatch StatusCode = 214 25 | StatusBelowThreshold StatusCode = 230 26 | StatusSuccessfulMatch StatusCode = 250 27 | StatusSuccessfulHardlink StatusCode = 250 28 | StatusFailedHardlink StatusCode = 440 29 | StatusFailedMatchToTorrentEps StatusCode = 445 30 | StatusClientNotFound StatusCode = 472 31 | StatusGetClientError StatusCode = 471 32 | StatusDecodingError StatusCode = 470 33 | StatusAnnounceNameError StatusCode = 469 34 | StatusGetTorrentsError StatusCode = 468 35 | StatusTorrentBytesError StatusCode = 467 36 | StatusDecodeTorrentBytesError StatusCode = 466 37 | StatusParseTorrentInfoError StatusCode = 465 38 | StatusGetEpisodesError StatusCode = 464 39 | StatusEpisodeCountError StatusCode = 450 40 | ) 41 | 42 | func (s StatusCode) String() string { 43 | switch s { 44 | case StatusNoMatches: 45 | return "no matching releases in client" 46 | case StatusResolutionMismatch: 47 | return "resolution did not match" 48 | case StatusSourceMismatch: 49 | return "source did not match" 50 | case StatusRlsGrpMismatch: 51 | return "release group did not match" 52 | case StatusCutMismatch: 53 | return "cut did not match" 54 | case StatusEditionMismatch: 55 | return "edition did not match" 56 | case StatusRepackStatusMismatch: 57 | return "repack status did not match" 58 | case StatusHdrMismatch: 59 | return "HDR metadata did not match" 60 | case StatusStreamingServiceMismatch: 61 | return "streaming service did not match" 62 | case StatusAlreadyInClient: 63 | return "release already in client" 64 | case StatusNotASeasonPack: 65 | return "release is not a season pack" 66 | case StatusSizeMismatch: 67 | return "size did not match" 68 | case StatusSeasonMismatch: 69 | return "season did not match" 70 | case StatusEpisodeMismatch: 71 | return "episode did not match" 72 | case StatusBelowThreshold: 73 | return "number of matches below threshold" 74 | case StatusSuccessfulMatch: 75 | return "successful match" 76 | case StatusFailedHardlink: 77 | return "could not create hardlinks" 78 | case StatusFailedMatchToTorrentEps: 79 | return "could not match episodes to files in pack" 80 | case StatusClientNotFound: 81 | return "could not find client in config" 82 | case StatusGetClientError: 83 | return "could not get client" 84 | case StatusDecodingError: 85 | return "error decoding request" 86 | case StatusAnnounceNameError: 87 | return "could not get announce name" 88 | case StatusGetTorrentsError: 89 | return "could not get torrents" 90 | case StatusTorrentBytesError: 91 | return "could not get torrent bytes" 92 | case StatusDecodeTorrentBytesError: 93 | return "could not decode torrent bytes" 94 | case StatusParseTorrentInfoError: 95 | return "could not parse torrent info" 96 | case StatusGetEpisodesError: 97 | return "could not get episodes" 98 | case StatusEpisodeCountError: 99 | return "could not get episode count" 100 | default: 101 | return "" 102 | } 103 | } 104 | 105 | func (s StatusCode) Code() int { 106 | return int(s) 107 | } 108 | 109 | func (s StatusCode) Error() error { 110 | return fmt.Errorf("%s", s) 111 | } 112 | 113 | var NotificationStatusMap = map[string][]StatusCode{ 114 | NotificationLevelMatch: { 115 | StatusSuccessfulMatch, 116 | }, 117 | NotificationLevelInfo: { 118 | StatusNoMatches, 119 | StatusResolutionMismatch, 120 | StatusSourceMismatch, 121 | StatusRlsGrpMismatch, 122 | StatusCutMismatch, 123 | StatusEditionMismatch, 124 | StatusRepackStatusMismatch, 125 | StatusHdrMismatch, 126 | StatusStreamingServiceMismatch, 127 | StatusAlreadyInClient, 128 | StatusNotASeasonPack, 129 | StatusBelowThreshold, 130 | }, 131 | NotificationLevelError: { 132 | StatusFailedHardlink, 133 | StatusFailedMatchToTorrentEps, 134 | StatusClientNotFound, 135 | StatusGetClientError, 136 | StatusDecodingError, 137 | StatusAnnounceNameError, 138 | StatusGetTorrentsError, 139 | StatusTorrentBytesError, 140 | StatusDecodeTorrentBytesError, 141 | StatusParseTorrentInfoError, 142 | StatusGetEpisodesError, 143 | StatusEpisodeCountError, 144 | }, 145 | } 146 | -------------------------------------------------------------------------------- /internal/domain/notification.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 - 2025, Ludvig Lundgren and the autobrr contributors. 2 | // Code is heavily modified for use with seasonpackarr 3 | // SPDX-License-Identifier: GPL-2.0-or-later 4 | 5 | package domain 6 | 7 | type Sender interface { 8 | Name() string 9 | Send(statusCode StatusCode, payload NotificationPayload) error 10 | } 11 | 12 | const ( 13 | NotificationLevelInfo = "INFO" 14 | NotificationLevelError = "ERROR" 15 | NotificationLevelMatch = "MATCH" 16 | ) 17 | 18 | type NotificationPayload struct { 19 | Subject string 20 | Message string 21 | ReleaseName string 22 | Client string 23 | Action string 24 | Error error 25 | } 26 | -------------------------------------------------------------------------------- /internal/domain/release.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 - 2025, nuxen and the seasonpackarr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package domain 5 | 6 | type CompareInfo struct { 7 | StatusCode StatusCode 8 | RejectValueA any 9 | RejectValueB any 10 | } 11 | -------------------------------------------------------------------------------- /internal/files/hardlink.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 - 2025, nuxen and the seasonpackarr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package files 5 | 6 | import ( 7 | "os" 8 | "path/filepath" 9 | ) 10 | 11 | func CreateHardlink(sourcePath, targetPath string) error { 12 | targetDirectory := filepath.Dir(targetPath) 13 | 14 | // create the target directory if it doesn't exist 15 | if err := os.MkdirAll(targetDirectory, 0o755); err != nil { 16 | return err 17 | } 18 | 19 | // link source path to target path 20 | if err := os.Link(sourcePath, targetPath); err != nil { 21 | return err 22 | } 23 | 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /internal/format/format.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 - 2025, nuxen and the seasonpackarr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package format 5 | 6 | import ( 7 | "fmt" 8 | "regexp" 9 | "strings" 10 | 11 | "github.com/moistari/rls" 12 | ) 13 | 14 | var ( 15 | // regex for groups that don't need the folder name to be adjusted 16 | ignoredRlsGrps = regexp.MustCompile(`(?i)^(ZR)$`) 17 | 18 | illegal = regexp.MustCompile(`(?i)[\\/:"*?<>|]`) 19 | audio = regexp.MustCompile(`(?i)(AAC|DDP)\.(\d\.\d)`) 20 | dots = regexp.MustCompile(`(?i)\.+`) 21 | ) 22 | 23 | func ComparableTitle(r rls.Release) string { 24 | s := fmt.Sprintf("%s%d%d", rls.MustNormalize(r.Title), r.Year, r.Series) 25 | 26 | return s 27 | } 28 | 29 | func CleanAnnounceTitle(release rls.Release) string { 30 | packName := release.String() 31 | 32 | // check if RlsGrp of release is in ignore regex 33 | if !ignoredRlsGrps.MatchString(release.Group) { 34 | // remove illegal characters 35 | packName = illegal.ReplaceAllString(packName, "") 36 | // replace spaces with periods 37 | packName = strings.ReplaceAll(packName, " ", ".") 38 | // replace wrong audio naming 39 | packName = audio.ReplaceAllString(packName, "$1$2") 40 | // replace multiple dots with only one 41 | packName = dots.ReplaceAllString(packName, ".") 42 | } 43 | 44 | return packName 45 | } 46 | -------------------------------------------------------------------------------- /internal/format/format_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 - 2025, nuxen and the seasonpackarr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package format 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/moistari/rls" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func Test_ComparableTitle(t *testing.T) { 14 | tests := []struct { 15 | name string 16 | packName string 17 | want string 18 | }{ 19 | { 20 | name: "pack_1", 21 | packName: "Prehistoric Planet 2022 S02 1080p ATVP WEB-DL DDP 5.1 Atmos H.264-FLUX", 22 | want: "prehistoric planet20222", 23 | }, 24 | { 25 | name: "pack_2", 26 | packName: "Rabbit Hole S01 1080p AMZN WEB-DL DDP 5.1 H.264-NTb", 27 | want: "rabbit hole01", 28 | }, 29 | { 30 | name: "pack_3", 31 | packName: "Star Wars Visions S01 REPACK 1080p DSNP WEB-DL DDP 5.1 H.264-FLUX", 32 | want: "star wars visions01", 33 | }, 34 | { 35 | name: "pack_4", 36 | packName: "Star Wars Visions S02 1080p DSNP WEB-DL DDP 5.1 H.264-NTb", 37 | want: "star wars visions02", 38 | }, 39 | { 40 | name: "pack_5", 41 | packName: "The Good Doctor S06 1080p AMZN WEB-DL DDP 5.1 H.264-NTb", 42 | want: "the good doctor06", 43 | }, 44 | { 45 | name: "pack_6", 46 | packName: "The Good Doctor S06 REPACK 1080p AMZN WEB-DL DDP 5.1 H.264-NTb", 47 | want: "the good doctor06", 48 | }, 49 | { 50 | name: "pack_7", 51 | packName: "The Mandalorian S03 1080p DSNP WEB-DL DDP 5.1 Atmos H.264-FLUX", 52 | want: "the mandalorian03", 53 | }, 54 | { 55 | name: "pack_8", 56 | packName: "Gold Rush: White Water S06 1080p AMZN WEB-DL DDP 2.0 H.264-NTb", 57 | want: "gold rush white water06", 58 | }, 59 | { 60 | name: "pack_9", 61 | packName: "Transplant S03 1080p iT WEB-DL AAC 2.0 H.264-NTb", 62 | want: "transplant03", 63 | }, 64 | { 65 | name: "pack_10", 66 | packName: "Mayans M.C. S05 1080p AMZN WEB-DL DDP 5.1 H.264-NTb", 67 | want: "mayans m c05", 68 | }, 69 | { 70 | name: "pack_11", 71 | packName: "What If... S01 1080p DNSP WEB-DL DDP 5.1 H.264-FLUX", 72 | want: "what if01", 73 | }, 74 | { 75 | name: "pack_12", 76 | packName: "Demon Slayer Kimetsu no Yaiba S04 2023 1080p WEB-DL AVC AAC 2.0 Dual Audio -ZR-", 77 | want: "demon slayer kimetsu no yaiba20234", 78 | }, 79 | { 80 | name: "pack_13", 81 | packName: "The Continental 2023 S01 2160p PCOK WEB-DL DDP5.1 Atmos DV HDR H.265-FLUX", 82 | want: "the continental20231", 83 | }, 84 | { 85 | name: "pack_14", 86 | packName: "The Continental 2023 S01 2160p PCOK WEB-DL DDP5.1 Atmos HDR DV H.265-FLUX", 87 | want: "the continental20231", 88 | }, 89 | } 90 | for _, tt := range tests { 91 | t.Run(tt.name, func(t *testing.T) { 92 | r := rls.ParseString(tt.packName) 93 | assert.Equalf(t, tt.want, ComparableTitle(r), "ComparableTitle(%s)", tt.packName) 94 | }) 95 | } 96 | } 97 | 98 | func Test_CleanAnnounceTitle(t *testing.T) { 99 | tests := []struct { 100 | name string 101 | packName string 102 | want string 103 | }{ 104 | { 105 | name: "pack_1", 106 | packName: "Prehistoric Planet 2022 S02 1080p ATVP WEB-DL DDP 5.1 Atmos H.264-FLUX", 107 | want: "Prehistoric.Planet.2022.S02.1080p.ATVP.WEB-DL.DDP5.1.Atmos.H.264-FLUX", 108 | }, 109 | { 110 | name: "pack_2", 111 | packName: "Rabbit Hole S01 1080p AMZN WEB-DL DDP 5.1 H.264-NTb", 112 | want: "Rabbit.Hole.S01.1080p.AMZN.WEB-DL.DDP5.1.H.264-NTb", 113 | }, 114 | { 115 | name: "pack_3", 116 | packName: "Star Wars Visions S01 REPACK 1080p DSNP WEB-DL DDP 5.1 H.264-FLUX", 117 | want: "Star.Wars.Visions.S01.REPACK.1080p.DSNP.WEB-DL.DDP5.1.H.264-FLUX", 118 | }, 119 | { 120 | name: "pack_4", 121 | packName: "Star Wars Visions S02 1080p DSNP WEB-DL DDP 5.1 H.264-NTb", 122 | want: "Star.Wars.Visions.S02.1080p.DSNP.WEB-DL.DDP5.1.H.264-NTb", 123 | }, 124 | { 125 | name: "pack_5", 126 | packName: "The Good Doctor S06 1080p AMZN WEB-DL DDP 5.1 H.264-NTb", 127 | want: "The.Good.Doctor.S06.1080p.AMZN.WEB-DL.DDP5.1.H.264-NTb", 128 | }, 129 | { 130 | name: "pack_6", 131 | packName: "The Good Doctor S06 REPACK 1080p AMZN WEB-DL DDP 5.1 H.264-NTb", 132 | want: "The.Good.Doctor.S06.REPACK.1080p.AMZN.WEB-DL.DDP5.1.H.264-NTb", 133 | }, 134 | { 135 | name: "pack_7", 136 | packName: "The Mandalorian S03 1080p DSNP WEB-DL DDP 5.1 Atmos H.264-FLUX", 137 | want: "The.Mandalorian.S03.1080p.DSNP.WEB-DL.DDP5.1.Atmos.H.264-FLUX", 138 | }, 139 | { 140 | name: "pack_8", 141 | packName: "Gold Rush: White Water S06 1080p AMZN WEB-DL DDP 2.0 H.264-NTb", 142 | want: "Gold.Rush.White.Water.S06.1080p.AMZN.WEB-DL.DDP2.0.H.264-NTb", 143 | }, 144 | { 145 | name: "pack_9", 146 | packName: "Transplant S03 1080p iT WEB-DL AAC 2.0 H.264-NTb", 147 | want: "Transplant.S03.1080p.iT.WEB-DL.AAC2.0.H.264-NTb", 148 | }, 149 | { 150 | name: "pack_10", 151 | packName: "Transplant.S03.1080p.iT.WEB-DL.AAC.2.0.H.264-NTb", 152 | want: "Transplant.S03.1080p.iT.WEB-DL.AAC2.0.H.264-NTb", 153 | }, 154 | { 155 | name: "pack_11", 156 | packName: "Mayans M.C. S05 1080p AMZN WEB-DL DDP 5.1 H.264-NTb", 157 | want: "Mayans.M.C.S05.1080p.AMZN.WEB-DL.DDP5.1.H.264-NTb", 158 | }, 159 | { 160 | name: "pack_12", 161 | packName: "What If... S01 1080p DNSP WEB-DL DDP 5.1 H.264-FLUX", 162 | want: "What.If.S01.1080p.DNSP.WEB-DL.DDP5.1.H.264-FLUX", 163 | }, 164 | { 165 | name: "pack_13", 166 | packName: "Demon Slayer Kimetsu no Yaiba S04 2023 1080p WEB-DL AVC AAC 2.0 Dual Audio -ZR-", 167 | want: "Demon Slayer Kimetsu no Yaiba S04 2023 1080p WEB-DL AVC AAC 2.0 Dual Audio -ZR-", 168 | }, 169 | } 170 | for _, tt := range tests { 171 | t.Run(tt.name, func(t *testing.T) { 172 | r := rls.ParseString(tt.packName) 173 | assert.Equalf(t, tt.want, CleanAnnounceTitle(r), "CleanAnnounceTitle(%s)", tt.packName) 174 | }) 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /internal/http/health.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 - 2025, Ludvig Lundgren and the autobrr contributors. 2 | // Code is slightly modified for use with seasonpackarr 3 | // SPDX-License-Identifier: GPL-2.0-or-later 4 | 5 | package http 6 | 7 | import ( 8 | "net/http" 9 | 10 | "github.com/gin-gonic/gin" 11 | ) 12 | 13 | type healthHandler struct{} 14 | 15 | func newHealthHandler() *healthHandler { 16 | return &healthHandler{} 17 | } 18 | 19 | func (h *healthHandler) Routes(r *gin.RouterGroup) { 20 | r.GET("/liveness", h.handleLiveness) 21 | r.GET("/readiness", h.handleReadiness) 22 | } 23 | 24 | func (h *healthHandler) handleLiveness(c *gin.Context) { 25 | writeHealthy(c) 26 | } 27 | 28 | func (h *healthHandler) handleReadiness(c *gin.Context) { 29 | writeHealthy(c) 30 | } 31 | 32 | func writeHealthy(c *gin.Context) { 33 | c.Header("Content-Type", "text/plain") 34 | c.String(http.StatusOK, "OK") 35 | } 36 | 37 | func writeUnhealthy(c *gin.Context) { 38 | c.Header("Content-Type", "text/plain") 39 | c.String(http.StatusInternalServerError, "Unhealthy") 40 | } 41 | -------------------------------------------------------------------------------- /internal/http/middleware.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 - 2025, Ludvig Lundgren and the autobrr contributors. 2 | // Code is modified for use with seasonpackarr 3 | // SPDX-License-Identifier: GPL-2.0-or-later 4 | 5 | package http 6 | 7 | import ( 8 | "net/http" 9 | "runtime/debug" 10 | "time" 11 | 12 | "github.com/nuxencs/seasonpackarr/internal/logger" 13 | 14 | "github.com/gin-contrib/cors" 15 | "github.com/gin-gonic/gin" 16 | ) 17 | 18 | func (s *Server) AuthMiddleware() gin.HandlerFunc { 19 | return func(c *gin.Context) { 20 | // Allow access if apiToken value is set to an empty string 21 | if s.cfg.Config.APIToken == "" { 22 | c.Next() 23 | return 24 | } 25 | 26 | // Check the X-API-Token header 27 | if token := c.GetHeader("X-API-Token"); token != "" { 28 | if token != s.cfg.Config.APIToken { 29 | s.log.Error().Msgf("unauthorized access attempt with incorrect API token in header from IP: %s", c.ClientIP()) 30 | c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) 31 | return 32 | } 33 | } else if key := c.Query("apikey"); key != "" { 34 | // Check the query parameter ?apikey=TOKEN 35 | if key != s.cfg.Config.APIToken { 36 | s.log.Error().Msgf("unauthorized access attempt with incorrect API token in query parameters from IP: %s", c.ClientIP()) 37 | c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) 38 | return 39 | } 40 | } else { 41 | // Neither header nor query parameter provided a token 42 | s.log.Error().Msgf("unauthorized access attempt without API token from IP: %s", c.ClientIP()) 43 | c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) 44 | return 45 | } 46 | 47 | c.Next() 48 | } 49 | } 50 | 51 | func CorsMiddleware() gin.HandlerFunc { 52 | return cors.New(cors.Config{ 53 | AllowMethods: []string{"GET", "POST"}, 54 | AllowCredentials: true, 55 | AllowAllOrigins: true, 56 | MaxAge: 12 * time.Hour, 57 | }) 58 | } 59 | 60 | func LoggerMiddleware(logger logger.Logger) gin.HandlerFunc { 61 | return func(c *gin.Context) { 62 | log := logger.With().Logger() 63 | 64 | start := time.Now() 65 | 66 | defer func() { 67 | if rec := recover(); rec != nil { 68 | log.Error(). 69 | Str("type", "error"). 70 | Interface("recover_info", rec). 71 | Bytes("debug_stack", debug.Stack()). 72 | Msg("log system error") 73 | c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) 74 | } 75 | 76 | if c.Request.URL.Path != "/api/healthz/liveness" && c.Request.URL.Path != "/api/healthz/readiness" { 77 | latency := float64(time.Since(start).Nanoseconds()) / 1e6 78 | log.Trace(). 79 | Str("type", "access"). 80 | Timestamp(). 81 | Fields(map[string]interface{}{ 82 | "remote_ip": c.ClientIP(), 83 | "url": c.Request.URL.Path, 84 | "proto": c.Request.Proto, 85 | "method": c.Request.Method, 86 | "user_agent": c.Request.UserAgent(), 87 | "status": c.Writer.Status(), 88 | "latency_ms": latency, 89 | "bytes_in": c.GetHeader("Content-Length"), 90 | "bytes_out": c.Writer.Size(), 91 | }). 92 | Msg("incoming_request") 93 | } 94 | }() 95 | 96 | c.Next() 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /internal/http/processor.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 - 2025, nuxen and the seasonpackarr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package http 5 | 6 | import ( 7 | "encoding/json" 8 | "fmt" 9 | "path/filepath" 10 | "sync" 11 | "time" 12 | 13 | "github.com/nuxencs/seasonpackarr/internal/config" 14 | "github.com/nuxencs/seasonpackarr/internal/domain" 15 | "github.com/nuxencs/seasonpackarr/internal/files" 16 | "github.com/nuxencs/seasonpackarr/internal/format" 17 | "github.com/nuxencs/seasonpackarr/internal/logger" 18 | "github.com/nuxencs/seasonpackarr/internal/metadata" 19 | "github.com/nuxencs/seasonpackarr/internal/release" 20 | "github.com/nuxencs/seasonpackarr/internal/slices" 21 | "github.com/nuxencs/seasonpackarr/internal/torrents" 22 | "github.com/nuxencs/seasonpackarr/pkg/errors" 23 | 24 | "github.com/autobrr/go-qbittorrent" 25 | "github.com/gin-gonic/gin" 26 | "github.com/moistari/rls" 27 | "github.com/puzpuzpuz/xsync/v3" 28 | "github.com/rs/zerolog" 29 | ) 30 | 31 | type processor struct { 32 | log zerolog.Logger 33 | cfg *config.AppConfig 34 | noti domain.Sender 35 | meta *metadata.Provider 36 | req *request 37 | } 38 | 39 | type request struct { 40 | Name string 41 | Torrent json.RawMessage 42 | Client *qbittorrent.Client 43 | ClientName string 44 | } 45 | 46 | type entry struct { 47 | torrent qbittorrent.Torrent 48 | release rls.Release 49 | } 50 | 51 | type entryCache struct { 52 | entriesMap map[string][]entry 53 | rlsMap map[string]rls.Release 54 | lastUpdated time.Time 55 | mu sync.Mutex 56 | } 57 | 58 | type matchInfo struct { 59 | clientEpPath string 60 | clientEpSize int64 61 | announcedEpPath string 62 | } 63 | 64 | var ( 65 | clientMap = xsync.NewMapOf[string, *qbittorrent.Client]() 66 | matchMap = xsync.NewMapOf[string, []matchInfo]() 67 | entryMap = xsync.NewMapOf[string, *entryCache]() 68 | ) 69 | 70 | func newProcessor(log logger.Logger, config *config.AppConfig, notification domain.Sender, metadata *metadata.Provider) *processor { 71 | return &processor{ 72 | log: log.With().Str("module", "processor").Logger(), 73 | cfg: config, 74 | noti: notification, 75 | meta: metadata, 76 | } 77 | } 78 | 79 | func (p *processor) getClient(client *domain.Client, clientName string) error { 80 | c, ok := clientMap.Load(clientName) 81 | if !ok { 82 | clientCfg := qbittorrent.Config{ 83 | Host: fmt.Sprintf("http://%s:%d", client.Host, client.Port), 84 | Username: client.Username, 85 | Password: client.Password, 86 | } 87 | 88 | c = qbittorrent.NewClient(clientCfg) 89 | 90 | if err := c.Login(); err != nil { 91 | return errors.Wrap(err, "failed to login to qbittorrent") 92 | } 93 | 94 | clientMap.Store(clientName, c) 95 | } 96 | 97 | p.req.Client = c 98 | return nil 99 | } 100 | 101 | func (p *processor) getAllTorrents(clientName string) (map[string][]entry, error) { 102 | f := func() *entryCache { 103 | tre, ok := entryMap.Load(clientName) 104 | if ok { 105 | return tre 106 | } 107 | 108 | entries := &entryCache{rlsMap: make(map[string]rls.Release)} 109 | entryMap.Store(clientName, entries) 110 | return entries 111 | } 112 | 113 | entries := f() 114 | cur := time.Now() 115 | if entries.lastUpdated.After(cur) { 116 | return entries.entriesMap, nil 117 | } 118 | 119 | entries.mu.Lock() 120 | defer entries.mu.Unlock() 121 | 122 | entries = f() 123 | if entries.lastUpdated.After(cur) { 124 | return entries.entriesMap, nil 125 | } 126 | 127 | ts, err := p.req.Client.GetTorrents(qbittorrent.TorrentFilterOptions{}) 128 | if err != nil { 129 | return nil, errors.Wrap(err, "failed to get torrents") 130 | } 131 | 132 | after := time.Now() 133 | entries = &entryCache{entriesMap: make(map[string][]entry), lastUpdated: after.Add(after.Sub(cur)), rlsMap: entries.rlsMap} 134 | 135 | for _, t := range ts { 136 | r, ok := entries.rlsMap[t.Name] 137 | if !ok { 138 | r = rls.ParseString(t.Name) 139 | entries.rlsMap[t.Name] = r 140 | } 141 | 142 | comparableTitle := format.ComparableTitle(r) 143 | entries.entriesMap[comparableTitle] = append(entries.entriesMap[comparableTitle], entry{torrent: t, release: r}) 144 | } 145 | 146 | entryMap.Store(clientName, entries) 147 | return entries.entriesMap, nil 148 | } 149 | 150 | func (p *processor) getFiles(hash string) (string, int64, error) { 151 | torrentFiles, err := p.req.Client.GetFilesInformation(hash) 152 | if err != nil { 153 | return "", 0, err 154 | } 155 | 156 | var fileName string 157 | var size int64 158 | for _, f := range *torrentFiles { 159 | if !release.IsValidEpisodeFile(f.Name) { 160 | continue 161 | } 162 | 163 | fileName = f.Name 164 | size = f.Size 165 | break 166 | } 167 | switch { 168 | case len(fileName) == 0: 169 | return "", 0, errors.New("file name is empty") 170 | case size == 0: 171 | return "", 0, errors.New("file size is empty") 172 | } 173 | 174 | return fileName, size, nil 175 | } 176 | 177 | func (p *processor) getClientName() string { 178 | if len(p.req.ClientName) == 0 { 179 | p.req.ClientName = "default" 180 | p.log.Info().Msg("no clientname defined. trying to use default client") 181 | 182 | return "default" 183 | } 184 | 185 | return p.req.ClientName 186 | } 187 | 188 | func (p *processor) ProcessSeasonPackHandler(c *gin.Context) { 189 | p.log.Info().Msg("starting to process season pack request") 190 | 191 | if err := json.NewDecoder(c.Request.Body).Decode(&p.req); err != nil { 192 | p.log.Error().Err(err).Msgf("%s", domain.StatusDecodingError) 193 | c.AbortWithStatusJSON(domain.StatusDecodingError.Code(), gin.H{ 194 | "statusCode": domain.StatusDecodingError.Code(), 195 | "error": err.Error(), 196 | }) 197 | return 198 | } 199 | 200 | statusCode, err := p.processSeasonPack() 201 | if err != nil { 202 | go func() { 203 | if sendErr := p.noti.Send(statusCode, domain.NotificationPayload{ 204 | ReleaseName: p.req.Name, 205 | Client: p.req.ClientName, 206 | Action: "Pack", 207 | Error: err, 208 | }); sendErr != nil { 209 | p.log.Error().Err(sendErr).Msgf("error sending %s notification for %d", p.noti.Name(), statusCode) 210 | } 211 | }() 212 | 213 | p.log.Error().Err(err).Msg("error processing season pack") 214 | c.AbortWithStatusJSON(statusCode.Code(), gin.H{ 215 | "statusCode": statusCode.Code(), 216 | "error": err.Error(), 217 | }) 218 | return 219 | } 220 | 221 | go func() { 222 | if sendErr := p.noti.Send(statusCode, domain.NotificationPayload{ 223 | ReleaseName: p.req.Name, 224 | Client: p.req.ClientName, 225 | Action: "Pack", 226 | }); sendErr != nil { 227 | p.log.Error().Err(sendErr).Msgf("error sending %s notification for %d", p.noti.Name(), statusCode) 228 | } 229 | }() 230 | 231 | p.log.Info().Msg("successfully matched season pack to episodes in client") 232 | c.String(statusCode.Code(), statusCode.String()) 233 | } 234 | 235 | func (p *processor) processSeasonPack() (domain.StatusCode, error) { 236 | clientName := p.getClientName() 237 | 238 | p.log.UpdateContext(func(c zerolog.Context) zerolog.Context { 239 | return c.Str("release", p.req.Name).Str("clientname", clientName) 240 | }) 241 | 242 | if len(p.req.Name) == 0 { 243 | return domain.StatusAnnounceNameError, domain.StatusAnnounceNameError.Error() 244 | } 245 | 246 | clientCfg, ok := p.cfg.Config.Clients[clientName] 247 | if !ok { 248 | return domain.StatusClientNotFound, domain.StatusClientNotFound.Error() 249 | } 250 | p.log.Info().Msgf("using %s client serving at %s:%d", clientName, clientCfg.Host, clientCfg.Port) 251 | 252 | if err := p.getClient(clientCfg, clientName); err != nil { 253 | return domain.StatusGetClientError, fmt.Errorf("%s: %w", domain.StatusGetClientError, err) 254 | } 255 | 256 | entries, err := p.getAllTorrents(clientName) 257 | if err != nil { 258 | return domain.StatusGetTorrentsError, fmt.Errorf("%s: %w", domain.StatusGetTorrentsError, err) 259 | } 260 | 261 | requestRls := rls.ParseString(p.req.Name) 262 | filteredEntries, ok := entries[format.ComparableTitle(requestRls)] 263 | if !ok { 264 | return domain.StatusNoMatches, domain.StatusNoMatches.Error() 265 | } 266 | 267 | announcedPackName := format.CleanAnnounceTitle(requestRls) 268 | p.log.Debug().Msgf("formatted season pack name: %s", announcedPackName) 269 | 270 | for _, filteredEntry := range filteredEntries { 271 | switch compareInfo := release.CheckCandidates(requestRls, filteredEntry.release, p.cfg.Config.FuzzyMatching); compareInfo.StatusCode { 272 | case domain.StatusAlreadyInClient, domain.StatusNotASeasonPack: 273 | return compareInfo.StatusCode, compareInfo.StatusCode.Error() 274 | } 275 | } 276 | 277 | codeSet := make(map[domain.StatusCode]bool) 278 | epsSet := make(map[int]struct{}) 279 | matches := make([]matchInfo, 0, len(filteredEntries)) 280 | 281 | for _, filteredEntry := range filteredEntries { 282 | switch compareInfo := release.CheckCandidates(requestRls, filteredEntry.release, p.cfg.Config.FuzzyMatching); compareInfo.StatusCode { 283 | case domain.StatusAlreadyInClient, domain.StatusNotASeasonPack: 284 | return compareInfo.StatusCode, compareInfo.StatusCode.Error() 285 | 286 | case domain.StatusResolutionMismatch, domain.StatusSourceMismatch, domain.StatusRlsGrpMismatch, 287 | domain.StatusCutMismatch, domain.StatusEditionMismatch, domain.StatusRepackStatusMismatch, 288 | domain.StatusHdrMismatch, domain.StatusStreamingServiceMismatch: 289 | p.log.Info().Msgf("%s: request(%s => %v), client(%s => %v)", 290 | compareInfo.StatusCode, requestRls.String(), compareInfo.RejectValueA, 291 | filteredEntry.release.String(), compareInfo.RejectValueB) 292 | codeSet[compareInfo.StatusCode] = true 293 | continue 294 | 295 | case domain.StatusSuccessfulMatch: 296 | fileName, size, err := p.getFiles(filteredEntry.torrent.Hash) 297 | if err != nil { 298 | p.log.Error().Err(err).Msgf("error getting file info: %s", filteredEntry.torrent.Name) 299 | continue 300 | } 301 | 302 | clientEpPath := filepath.Join(filteredEntry.torrent.SavePath, fileName) 303 | announcedEpPath := filepath.Join(clientCfg.PreImportPath, announcedPackName, filepath.Base(fileName)) 304 | 305 | epsSet[filteredEntry.release.Episode] = struct{}{} 306 | 307 | // append current matchInfo to matches slice 308 | matches = append(matches, matchInfo{ 309 | clientEpPath: clientEpPath, 310 | clientEpSize: size, 311 | announcedEpPath: announcedEpPath, 312 | }) 313 | 314 | p.log.Debug().Msgf("matched torrent from client: name(%s), size(%d), hash(%s)", 315 | filteredEntry.torrent.Name, size, filteredEntry.torrent.Hash) 316 | codeSet[compareInfo.StatusCode] = true 317 | continue 318 | } 319 | } 320 | 321 | if !codeSet[domain.StatusSuccessfulMatch] { 322 | return domain.StatusNoMatches, domain.StatusNoMatches.Error() 323 | } 324 | 325 | if p.cfg.Config.SmartMode { 326 | totalEps, err := p.meta.EpisodesInSeason(requestRls) 327 | if err != nil { 328 | return domain.StatusEpisodeCountError, fmt.Errorf("%s: %w", domain.StatusEpisodeCountError, err) 329 | } 330 | 331 | foundEps := len(epsSet) 332 | percentEps := release.PercentOfTotalEpisodes(totalEps, foundEps) 333 | 334 | p.log.Info().Msgf("found %d/%d (%.2f%%) episodes in client", foundEps, totalEps, percentEps*100) 335 | 336 | if percentEps < p.cfg.Config.SmartModeThreshold { 337 | return domain.StatusBelowThreshold, domain.StatusBelowThreshold.Error() 338 | } 339 | } 340 | 341 | // dedupe matches and store in matchMap 342 | matches = slices.Dedupe(matches) 343 | matchMap.Store(p.req.Name, matches) 344 | 345 | if p.cfg.Config.ParseTorrentFile { 346 | return domain.StatusSuccessfulMatch, nil 347 | } 348 | 349 | successfulHardlink := false 350 | 351 | for _, match := range matches { 352 | if err := files.CreateHardlink(match.clientEpPath, match.announcedEpPath); err != nil { 353 | p.log.Error().Err(err).Msgf("error creating hardlink: %s", match.clientEpPath) 354 | continue 355 | } 356 | p.log.Info().Msgf("created hardlink: source(%s), target(%s)", match.clientEpPath, match.announcedEpPath) 357 | successfulHardlink = true 358 | } 359 | 360 | if !successfulHardlink { 361 | return domain.StatusFailedHardlink, domain.StatusFailedHardlink.Error() 362 | } 363 | 364 | return domain.StatusSuccessfulHardlink, nil 365 | } 366 | 367 | func (p *processor) ParseTorrentHandler(c *gin.Context) { 368 | p.log.Info().Msg("starting to parse season pack torrent") 369 | 370 | if err := json.NewDecoder(c.Request.Body).Decode(&p.req); err != nil { 371 | p.log.Error().Err(err).Msgf("%s", domain.StatusDecodingError) 372 | c.AbortWithStatusJSON(domain.StatusDecodingError.Code(), gin.H{ 373 | "statusCode": domain.StatusDecodingError.Code(), 374 | "error": err.Error(), 375 | }) 376 | return 377 | } 378 | 379 | statusCode, err := p.parseTorrent() 380 | if err != nil { 381 | go func() { 382 | if sendErr := p.noti.Send(statusCode, domain.NotificationPayload{ 383 | ReleaseName: p.req.Name, 384 | Client: p.req.ClientName, 385 | Action: "Parse", 386 | Error: err, 387 | }); sendErr != nil { 388 | p.log.Error().Err(sendErr).Msgf("error sending %s notification for %d", p.noti.Name(), statusCode) 389 | } 390 | }() 391 | 392 | p.log.Error().Err(err).Msg("error parsing torrent") 393 | c.AbortWithStatusJSON(statusCode.Code(), gin.H{ 394 | "statusCode": statusCode.Code(), 395 | "error": err.Error(), 396 | }) 397 | return 398 | } 399 | 400 | go func() { 401 | if sendErr := p.noti.Send(statusCode, domain.NotificationPayload{ 402 | ReleaseName: p.req.Name, 403 | Client: p.req.ClientName, 404 | Action: "Parse", 405 | }); sendErr != nil { 406 | p.log.Error().Err(sendErr).Msgf("error sending %s notification for %d", p.noti.Name(), statusCode) 407 | } 408 | }() 409 | 410 | p.log.Info().Msg("successfully parsed torrent and hardlinked episodes") 411 | c.String(statusCode.Code(), statusCode.String()) 412 | } 413 | 414 | func (p *processor) parseTorrent() (domain.StatusCode, error) { 415 | clientName := p.getClientName() 416 | 417 | p.log.UpdateContext(func(c zerolog.Context) zerolog.Context { 418 | return c.Str("release", p.req.Name).Str("clientname", clientName) 419 | }) 420 | 421 | clientCfg, ok := p.cfg.Config.Clients[clientName] 422 | if !ok { 423 | return domain.StatusClientNotFound, domain.StatusClientNotFound.Error() 424 | } 425 | 426 | if len(p.req.Name) == 0 { 427 | return domain.StatusAnnounceNameError, domain.StatusAnnounceNameError.Error() 428 | } 429 | 430 | if len(p.req.Torrent) == 0 { 431 | return domain.StatusTorrentBytesError, domain.StatusTorrentBytesError.Error() 432 | } 433 | 434 | torrentBytes, err := torrents.DecodeTorrentBytes(p.req.Torrent) 435 | if err != nil { 436 | return domain.StatusDecodeTorrentBytesError, fmt.Errorf("%s: %w", domain.StatusDecodeTorrentBytesError, err) 437 | } 438 | p.req.Torrent = torrentBytes 439 | 440 | torrentInfo, err := torrents.Info(p.req.Torrent) 441 | if err != nil { 442 | return domain.StatusParseTorrentInfoError, fmt.Errorf("%s: %w", domain.StatusParseTorrentInfoError, err) 443 | } 444 | parsedPackName := torrentInfo.BestName() 445 | p.log.Debug().Msgf("parsed season pack name: %s", parsedPackName) 446 | 447 | torrentEps, err := torrents.Episodes(torrentInfo) 448 | if err != nil { 449 | return domain.StatusGetEpisodesError, fmt.Errorf("%s: %w", domain.StatusGetEpisodesError, err) 450 | } 451 | for _, torrentEp := range torrentEps { 452 | p.log.Debug().Msgf("found episode in pack: name(%s), size(%d)", torrentEp.Path, torrentEp.Size) 453 | } 454 | 455 | matches, ok := matchMap.Load(p.req.Name) 456 | if !ok { 457 | return domain.StatusNoMatches, domain.StatusNoMatches.Error() 458 | } 459 | 460 | successfulEpMatch := false 461 | successfulHardlink := false 462 | 463 | var matchedEpPath string 464 | var compareInfo domain.CompareInfo 465 | 466 | targetPackDir := filepath.Join(clientCfg.PreImportPath, parsedPackName) 467 | 468 | for _, match := range matches { 469 | for _, torrentEp := range torrentEps { 470 | var targetEpPath string 471 | 472 | matchedEpPath, compareInfo = release.MatchEpToSeasonPackEp(match.clientEpPath, match.clientEpSize, 473 | torrentEp.Path, torrentEp.Size) 474 | if len(matchedEpPath) == 0 { 475 | p.log.Debug().Msgf("%s: client(%s => %v), torrent(%s => %v)", compareInfo.StatusCode, 476 | filepath.Base(match.clientEpPath), compareInfo.RejectValueA, torrentEp.Path, compareInfo.RejectValueB) 477 | continue 478 | } 479 | targetEpPath = filepath.Join(targetPackDir, matchedEpPath) 480 | successfulEpMatch = true 481 | 482 | if err = files.CreateHardlink(match.clientEpPath, targetEpPath); err != nil { 483 | p.log.Error().Err(err).Msgf("error creating hardlink: %s", match.clientEpPath) 484 | continue 485 | } 486 | p.log.Info().Msgf("created hardlink: source(%s), target(%s)", match.clientEpPath, targetEpPath) 487 | successfulHardlink = true 488 | 489 | break 490 | } 491 | if len(matchedEpPath) == 0 { 492 | p.log.Error().Msgf("error matching episode to file in pack, skipping hardlink: %s", 493 | filepath.Base(match.clientEpPath)) 494 | continue 495 | } 496 | } 497 | 498 | if !successfulEpMatch { 499 | return domain.StatusFailedMatchToTorrentEps, domain.StatusFailedMatchToTorrentEps.Error() 500 | } 501 | 502 | if !successfulHardlink { 503 | return domain.StatusFailedHardlink, domain.StatusFailedHardlink.Error() 504 | } 505 | 506 | return domain.StatusSuccessfulHardlink, nil 507 | } 508 | -------------------------------------------------------------------------------- /internal/http/server.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 - 2025, Ludvig Lundgren and the autobrr contributors. 2 | // Code is slightly modified for use with seasonpackarr 3 | // SPDX-License-Identifier: GPL-2.0-or-later 4 | 5 | package http 6 | 7 | import ( 8 | "context" 9 | "fmt" 10 | "net" 11 | "net/http" 12 | "time" 13 | 14 | "github.com/nuxencs/seasonpackarr/internal/config" 15 | "github.com/nuxencs/seasonpackarr/internal/domain" 16 | "github.com/nuxencs/seasonpackarr/internal/logger" 17 | "github.com/nuxencs/seasonpackarr/internal/metadata" 18 | "github.com/nuxencs/seasonpackarr/pkg/errors" 19 | 20 | "github.com/gin-contrib/requestid" 21 | "github.com/gin-gonic/gin" 22 | ) 23 | 24 | var ErrServerClosed = http.ErrServerClosed 25 | 26 | type Server struct { 27 | log logger.Logger 28 | cfg *config.AppConfig 29 | noti domain.Sender 30 | meta *metadata.Provider 31 | 32 | httpServer http.Server 33 | } 34 | 35 | func NewServer(log logger.Logger, config *config.AppConfig, notification domain.Sender, metadata *metadata.Provider) *Server { 36 | return &Server{ 37 | log: log, 38 | cfg: config, 39 | noti: notification, 40 | meta: metadata, 41 | } 42 | } 43 | 44 | func (s *Server) Open() error { 45 | var err error 46 | addr := fmt.Sprintf("%s:%d", s.cfg.Config.Host, s.cfg.Config.Port) 47 | 48 | for _, proto := range []string{"tcp", "tcp4", "tcp6"} { 49 | if err = s.tryToServe(addr, proto); err == nil { 50 | return nil 51 | } 52 | s.log.Error().Err(err).Msgf("Failed to start %s server on %s", proto, addr) 53 | } 54 | 55 | return fmt.Errorf("unable to start server on any protocol") 56 | } 57 | 58 | func (s *Server) tryToServe(addr, proto string) error { 59 | listener, err := net.Listen(proto, addr) 60 | if err != nil { 61 | return err 62 | } 63 | 64 | s.log.Info().Msgf("Starting server on %s with %s", listener.Addr().String(), proto) 65 | 66 | s.httpServer = http.Server{ 67 | Addr: addr, 68 | Handler: s.Handler(), 69 | ReadHeaderTimeout: 15 * time.Second, 70 | } 71 | 72 | if err := s.httpServer.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { 73 | return err 74 | } 75 | 76 | return nil 77 | } 78 | 79 | func (s *Server) Shutdown(ctx context.Context) error { 80 | s.log.Info().Msg("Shutting down the server gracefully...") 81 | if err := s.httpServer.Shutdown(ctx); err != nil { 82 | return fmt.Errorf("failed to shutdown server: %w", err) 83 | } 84 | return nil 85 | } 86 | 87 | func (s *Server) Handler() http.Handler { 88 | // disable debug mode 89 | gin.SetMode(gin.ReleaseMode) 90 | 91 | g := gin.New() 92 | 93 | g.Use(gin.Recovery()) 94 | g.Use(requestid.New()) 95 | g.Use(CorsMiddleware()) 96 | g.Use(LoggerMiddleware(s.log)) 97 | 98 | api := g.Group("/api") 99 | { 100 | newHealthHandler().Routes(api.Group("/healthz")) 101 | 102 | api.Use(s.AuthMiddleware()) 103 | { 104 | newWebhookHandler(s.log, s.cfg, s.noti, s.meta).Routes(api.Group("/")) 105 | } 106 | } 107 | 108 | return g 109 | } 110 | -------------------------------------------------------------------------------- /internal/http/webhook.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 - 2025, nuxen and the seasonpackarr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package http 5 | 6 | import ( 7 | "github.com/nuxencs/seasonpackarr/internal/config" 8 | "github.com/nuxencs/seasonpackarr/internal/domain" 9 | "github.com/nuxencs/seasonpackarr/internal/logger" 10 | "github.com/nuxencs/seasonpackarr/internal/metadata" 11 | 12 | "github.com/gin-gonic/gin" 13 | ) 14 | 15 | type webhookHandler struct { 16 | log logger.Logger 17 | cfg *config.AppConfig 18 | noti domain.Sender 19 | meta *metadata.Provider 20 | } 21 | 22 | func newWebhookHandler(log logger.Logger, cfg *config.AppConfig, notification domain.Sender, metadata *metadata.Provider) *webhookHandler { 23 | return &webhookHandler{ 24 | log: log, 25 | cfg: cfg, 26 | noti: notification, 27 | meta: metadata, 28 | } 29 | } 30 | 31 | func (h *webhookHandler) Routes(r *gin.RouterGroup) { 32 | r.POST("/pack", h.pack) 33 | r.POST("/parse", h.parse) 34 | } 35 | 36 | func (h *webhookHandler) pack(c *gin.Context) { 37 | newProcessor(h.log, h.cfg, h.noti, h.meta).ProcessSeasonPackHandler(c) 38 | } 39 | 40 | func (h *webhookHandler) parse(c *gin.Context) { 41 | newProcessor(h.log, h.cfg, h.noti, h.meta).ParseTorrentHandler(c) 42 | } 43 | -------------------------------------------------------------------------------- /internal/logger/logger.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 - 2025, Ludvig Lundgren and the autobrr contributors. 2 | // Code is slightly modified for use with seasonpackarr 3 | // SPDX-License-Identifier: GPL-2.0-or-later 4 | 5 | package logger 6 | 7 | import ( 8 | "io" 9 | "os" 10 | "time" 11 | 12 | "github.com/nuxencs/seasonpackarr/internal/domain" 13 | 14 | "github.com/rs/zerolog" 15 | "github.com/rs/zerolog/pkgerrors" 16 | "gopkg.in/natefinch/lumberjack.v2" 17 | ) 18 | 19 | // Logger interface 20 | type Logger interface { 21 | Log() *zerolog.Event 22 | Fatal() *zerolog.Event 23 | Err(err error) *zerolog.Event 24 | Error() *zerolog.Event 25 | Warn() *zerolog.Event 26 | Info() *zerolog.Event 27 | Trace() *zerolog.Event 28 | Debug() *zerolog.Event 29 | With() zerolog.Context 30 | SetLogLevel(level string) 31 | } 32 | 33 | // DefaultLogger default logging controller 34 | type DefaultLogger struct { 35 | log zerolog.Logger 36 | level zerolog.Level 37 | writers []io.Writer 38 | } 39 | 40 | func New(cfg *domain.Config) Logger { 41 | l := &DefaultLogger{ 42 | writers: make([]io.Writer, 0), 43 | level: zerolog.DebugLevel, 44 | } 45 | 46 | // set log level 47 | l.SetLogLevel(cfg.LogLevel) 48 | 49 | // use pretty logging for dev only 50 | if cfg.Version == "dev" { 51 | // setup console writer 52 | consoleWriter := zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339} 53 | 54 | l.writers = append(l.writers, consoleWriter) 55 | } else { 56 | // default to stderr 57 | l.writers = append(l.writers, os.Stderr) 58 | } 59 | 60 | if cfg.LogPath != "" { 61 | l.writers = append(l.writers, 62 | &lumberjack.Logger{ 63 | Filename: cfg.LogPath, 64 | MaxSize: cfg.LogMaxSize, // megabytes 65 | MaxBackups: cfg.LogMaxBackups, 66 | }, 67 | ) 68 | } 69 | 70 | // set some defaults 71 | zerolog.TimeFieldFormat = time.RFC3339 72 | zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack 73 | 74 | // init new logger 75 | l.log = zerolog.New(zerolog.MultiLevelWriter(l.writers...)).With().Stack().Logger() 76 | 77 | return l 78 | } 79 | 80 | func (l *DefaultLogger) SetLogLevel(level string) { 81 | lvl, err := zerolog.ParseLevel(level) 82 | if err != nil { 83 | lvl = zerolog.DebugLevel 84 | } 85 | 86 | zerolog.SetGlobalLevel(lvl) 87 | } 88 | 89 | // Log log something at fatal level. 90 | func (l *DefaultLogger) Log() *zerolog.Event { 91 | return l.log.Log().Timestamp() 92 | } 93 | 94 | // Fatal log something at fatal level. This will panic! 95 | func (l *DefaultLogger) Fatal() *zerolog.Event { 96 | return l.log.Fatal().Timestamp() 97 | } 98 | 99 | // Error log something at Error level 100 | func (l *DefaultLogger) Error() *zerolog.Event { 101 | return l.log.Error().Timestamp() 102 | } 103 | 104 | // Err log something at Err level 105 | func (l *DefaultLogger) Err(err error) *zerolog.Event { 106 | return l.log.Err(err).Timestamp() 107 | } 108 | 109 | // Warn log something at warning level. 110 | func (l *DefaultLogger) Warn() *zerolog.Event { 111 | return l.log.Warn().Timestamp() 112 | } 113 | 114 | // Info log something at fatal level. 115 | func (l *DefaultLogger) Info() *zerolog.Event { 116 | return l.log.Info().Timestamp() 117 | } 118 | 119 | // Debug log something at debug level. 120 | func (l *DefaultLogger) Debug() *zerolog.Event { 121 | return l.log.Debug().Timestamp() 122 | } 123 | 124 | // Trace log something at fatal level. This will panic! 125 | func (l *DefaultLogger) Trace() *zerolog.Event { 126 | return l.log.Trace().Timestamp() 127 | } 128 | 129 | // With log with context 130 | func (l *DefaultLogger) With() zerolog.Context { 131 | return l.log.With().Timestamp() 132 | } 133 | -------------------------------------------------------------------------------- /internal/metadata/metadata.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 - 2025, nuxen and the seasonpackarr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package metadata 5 | 6 | import ( 7 | "fmt" 8 | "sync" 9 | 10 | "github.com/nuxencs/seasonpackarr/internal/domain" 11 | "github.com/nuxencs/seasonpackarr/internal/logger" 12 | 13 | "github.com/moistari/rls" 14 | "github.com/rs/zerolog" 15 | ) 16 | 17 | type provider interface { 18 | episodesInSeason(release rls.Release) (int, error) 19 | } 20 | 21 | type Provider struct { 22 | log zerolog.Logger 23 | tvmazeClient provider 24 | tvdbClient provider 25 | } 26 | 27 | func NewMetadataProvider(log logger.Logger, metadata domain.Metadata) *Provider { 28 | tvmaze := newTVMaze() 29 | var tvdb provider 30 | 31 | if metadata.TVDBAPIKey != "" { 32 | tvdb = newTVDB(metadata.TVDBAPIKey, metadata.TVDBPIN) 33 | } 34 | 35 | return &Provider{ 36 | log: log.With().Logger(), 37 | tvmazeClient: tvmaze, 38 | tvdbClient: tvdb, 39 | } 40 | } 41 | 42 | func (m *Provider) EpisodesInSeason(release rls.Release) (int, error) { 43 | if m.tvdbClient == nil { 44 | return m.tvmazeClient.episodesInSeason(release) 45 | } 46 | 47 | type result struct { 48 | episodes int 49 | err error 50 | } 51 | 52 | var wg sync.WaitGroup 53 | wg.Add(2) 54 | 55 | var tvdbResult, tvmazeResult result 56 | go func() { 57 | defer wg.Done() 58 | episodes, err := m.tvdbClient.episodesInSeason(release) 59 | tvdbResult = result{episodes, err} 60 | }() 61 | 62 | go func() { 63 | defer wg.Done() 64 | episodes, err := m.tvmazeClient.episodesInSeason(release) 65 | tvmazeResult = result{episodes, err} 66 | }() 67 | 68 | wg.Wait() 69 | 70 | if tvdbResult.err == nil && tvmazeResult.err == nil { 71 | if tvdbResult.episodes != tvmazeResult.episodes { 72 | m.log.Debug().Msgf("episode count differs for %s S%02d: TVDB=%d, TVMaze=%d, using TVDB", 73 | release.Title, release.Series, tvdbResult.episodes, tvmazeResult.episodes) 74 | } 75 | 76 | return tvdbResult.episodes, nil 77 | } 78 | 79 | if tvdbResult.err == nil { 80 | m.log.Debug().Msgf("TVMaze query failed with error: %v, using TVDB", tvdbResult.err) 81 | return tvdbResult.episodes, nil 82 | } 83 | 84 | if tvmazeResult.err == nil { 85 | m.log.Debug().Msgf("TVDB query failed with error: %v, using TVMaze", tvmazeResult.err) 86 | return tvmazeResult.episodes, nil 87 | } 88 | 89 | return 0, fmt.Errorf("failed to get episodes: TVDB error: %w, TVMaze error: %v", tvdbResult.err, tvmazeResult.err) 90 | } 91 | -------------------------------------------------------------------------------- /internal/metadata/tvdb.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 - 2025, nuxen and the seasonpackarr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package metadata 5 | 6 | import ( 7 | "bufio" 8 | "bytes" 9 | "encoding/json" 10 | "fmt" 11 | "io" 12 | "net/http" 13 | "net/url" 14 | "time" 15 | 16 | "github.com/nuxencs/seasonpackarr/pkg/errors" 17 | 18 | "github.com/moistari/rls" 19 | ) 20 | 21 | type tvdbClient struct { 22 | apiKey string 23 | pin string 24 | token string 25 | expiresAt time.Time 26 | httpClient *http.Client 27 | } 28 | 29 | type loginResponse struct { 30 | Data struct { 31 | Token string `json:"token"` 32 | } `json:"data"` 33 | Status string `json:"status"` 34 | } 35 | 36 | type searchResponse struct { 37 | Status string `json:"status"` 38 | Data []struct { 39 | Name string `json:"name"` 40 | TvdbID string `json:"tvdb_id"` 41 | Year string `json:"year"` 42 | RemoteIds []struct { 43 | ID string `json:"id"` 44 | Type int `json:"type"` 45 | SourceName string `json:"sourceName"` 46 | } `json:"remote_ids,omitempty"` 47 | } `json:"data"` 48 | } 49 | 50 | type seriesResponse struct { 51 | Data struct { 52 | DefaultSeasonType int `json:"defaultSeasonType"` 53 | Episodes []struct { 54 | SeasonNumber int `json:"seasonNumber"` 55 | SeriesID int `json:"seriesId"` 56 | SeasonName string `json:"seasonName"` 57 | Year string `json:"year"` 58 | } `json:"episodes"` 59 | Year string `json:"year"` 60 | } `json:"data"` 61 | Status string `json:"status"` 62 | } 63 | 64 | const ( 65 | tvdbBaseURL = "https://api4.thetvdb.com/v4" 66 | ) 67 | 68 | func newTVDB(apiKey, pin string) provider { 69 | return &tvdbClient{ 70 | apiKey: apiKey, 71 | pin: pin, 72 | httpClient: &http.Client{ 73 | Timeout: 30 * time.Second, 74 | }, 75 | } 76 | } 77 | 78 | func (t *tvdbClient) authenticate() error { 79 | // Check if token exists and is not expiring within the next day 80 | if t.token != "" && time.Now().Add(24*time.Hour).Before(t.expiresAt) { 81 | return nil 82 | } 83 | 84 | if t.apiKey == "" { 85 | return fmt.Errorf("tvdbAPIKey is not set") 86 | } 87 | 88 | loginRequest := make(map[string]string) 89 | 90 | if t.pin != "" { 91 | loginRequest["pin"] = t.pin 92 | } 93 | loginRequest["apikey"] = t.apiKey 94 | 95 | requestBody, err := json.Marshal(loginRequest) 96 | if err != nil { 97 | return err 98 | } 99 | 100 | req, err := http.NewRequest(http.MethodPost, tvdbBaseURL+"/login", bytes.NewBuffer(requestBody)) 101 | if err != nil { 102 | return err 103 | } 104 | 105 | req.Header.Set("Content-Type", "application/json") 106 | 107 | resp, err := t.httpClient.Do(req) 108 | if err != nil { 109 | return err 110 | } 111 | defer resp.Body.Close() 112 | 113 | if resp.StatusCode != http.StatusOK { 114 | return fmt.Errorf("failed to authenticate with tvdb: %s", resp.Status) 115 | } 116 | 117 | var loginResponse loginResponse 118 | if err := json.NewDecoder(resp.Body).Decode(&loginResponse); err != nil { 119 | return err 120 | } 121 | 122 | if loginResponse.Status != "success" { 123 | return fmt.Errorf("failed to authenticate with tvdb: %s", loginResponse.Status) 124 | } 125 | 126 | t.token = loginResponse.Data.Token 127 | t.expiresAt = time.Now().Add(30 * 24 * time.Hour) 128 | 129 | return nil 130 | } 131 | 132 | func (t *tvdbClient) getAuthHeader() (string, error) { 133 | if err := t.authenticate(); err != nil { 134 | return "", err 135 | } 136 | 137 | return fmt.Sprintf("Bearer %s", t.token), nil 138 | } 139 | 140 | func (t *tvdbClient) makeAPIRequest(endpoint string) ([]byte, error) { 141 | authHeader, err := t.getAuthHeader() 142 | if err != nil { 143 | return nil, err 144 | } 145 | 146 | req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s%s", tvdbBaseURL, endpoint), nil) 147 | if err != nil { 148 | return nil, err 149 | } 150 | 151 | req.Header.Set("Content-Type", "application/json") 152 | req.Header.Set("Authorization", authHeader) 153 | 154 | resp, err := t.httpClient.Do(req) 155 | if err != nil { 156 | return nil, err 157 | } 158 | defer resp.Body.Close() 159 | 160 | buf := bufio.NewReader(resp.Body) 161 | 162 | body, err := io.ReadAll(buf) 163 | if err != nil { 164 | return nil, err 165 | } 166 | 167 | return body, nil 168 | } 169 | 170 | func (t *tvdbClient) search(title string, year int) (string, error) { 171 | resp, err := t.makeAPIRequest(fmt.Sprintf("/search?query=%s&type=series&year=%d", url.QueryEscape(rls.MustNormalize(title)), year)) 172 | if err != nil { 173 | return "", err 174 | } 175 | 176 | var searchResp searchResponse 177 | if err := json.NewDecoder(bytes.NewReader(resp)).Decode(&searchResp); err != nil { 178 | return "", err 179 | } 180 | 181 | if searchResp.Status != "success" { 182 | return "", fmt.Errorf("failed to get data from tvdb") 183 | } 184 | 185 | if len(searchResp.Data) == 0 { 186 | return "", fmt.Errorf("failed to find show %q on tvdb", title) 187 | } 188 | 189 | return searchResp.Data[0].TvdbID, nil 190 | } 191 | 192 | // episodesInSeason returns the number of episodes in a season of a show. 193 | func (t *tvdbClient) episodesInSeason(release rls.Release) (int, error) { 194 | tvdbID, err := t.search(release.Title, release.Year) 195 | if err != nil { 196 | return 0, fmt.Errorf("failed to find show %q on tvdb: %w", release.Title, err) 197 | } 198 | 199 | resp, err := t.makeAPIRequest(fmt.Sprintf("/series/%s/episodes/default?page=0&season=%d", tvdbID, release.Series)) 200 | if err != nil { 201 | return 0, errors.Wrap(err, "failed to get episodes from tvdb") 202 | } 203 | 204 | var seriesResp seriesResponse 205 | if err := json.NewDecoder(bytes.NewReader(resp)).Decode(&seriesResp); err != nil { 206 | return 0, errors.Wrap(err, "failed to decode response from tvdb") 207 | } 208 | 209 | if seriesResp.Status != "success" { 210 | return 0, errors.Wrap(err, "failed to get data from tvdb") 211 | } 212 | 213 | if len(seriesResp.Data.Episodes) == 0 { 214 | return 0, fmt.Errorf("failed to find episodes in season %d of %q", release.Series, release.Title) 215 | } 216 | 217 | return len(seriesResp.Data.Episodes), nil 218 | } 219 | -------------------------------------------------------------------------------- /internal/metadata/tvmaze.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 - 2025, nuxen and the seasonpackarr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package metadata 5 | 6 | import ( 7 | "fmt" 8 | 9 | "github.com/nuxencs/seasonpackarr/pkg/errors" 10 | 11 | "github.com/moistari/rls" 12 | "github.com/mrobinsn/go-tvmaze/tvmaze" 13 | ) 14 | 15 | var errNotFound = "received error status code (404): 404 Not Found" 16 | 17 | type tvmazeClient struct{} 18 | 19 | func newTVMaze() provider { 20 | return &tvmazeClient{} 21 | } 22 | 23 | // episodesInSeason returns the number of episodes in a season of a show. 24 | func (t *tvmazeClient) episodesInSeason(release rls.Release) (int, error) { 25 | // try finding the show with the parsed title first 26 | show, showErr := tvmaze.DefaultClient.GetShow(release.Title) 27 | if showErr != nil { 28 | if showErr.Error() != errNotFound { 29 | return 0, fmt.Errorf("failed to find show %q on tvmaze: %w", release.Title, showErr) 30 | 31 | } 32 | 33 | // retry with the normalized title if the parsed title fails 34 | show, showErr = tvmaze.DefaultClient.GetShow(rls.MustNormalize(release.Title)) 35 | if showErr != nil { 36 | return 0, fmt.Errorf("failed to find show %q on tvmaze: %w", release.Title, showErr) 37 | } 38 | } 39 | 40 | episodes, err := show.GetEpisodes() 41 | if err != nil { 42 | return 0, errors.Wrap(err, "failed to get episodes from tvmaze") 43 | } 44 | 45 | var totalEpisodes int 46 | for _, episode := range episodes { 47 | if episode.Season == release.Series { 48 | totalEpisodes++ 49 | } 50 | } 51 | 52 | if totalEpisodes == 0 { 53 | return 0, fmt.Errorf("failed to find episodes in season %d of %q", release.Series, release.Title) 54 | } 55 | 56 | return totalEpisodes, nil 57 | } 58 | -------------------------------------------------------------------------------- /internal/metadata/tvmaze_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 - 2025, nuxen and the seasonpackarr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package metadata 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/moistari/rls" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func Test_TVMaze_EpisodesInSeason(t *testing.T) { 14 | tests := []struct { 15 | name string 16 | release rls.Release 17 | want int 18 | wantErr bool 19 | }{ 20 | { 21 | name: "some_show", 22 | release: rls.Release{ 23 | Title: "Halo", 24 | Series: 1, 25 | }, 26 | want: 9, 27 | wantErr: false, 28 | }, 29 | { 30 | name: "anime_show", 31 | release: rls.Release{ 32 | Title: "Demon Slayer", 33 | Series: 1, 34 | }, 35 | want: 26, 36 | wantErr: false, 37 | }, 38 | { 39 | name: "season_doesnt_exist", 40 | release: rls.Release{ 41 | Title: "Game of Thrones", 42 | Series: 15, 43 | }, 44 | want: 0, 45 | wantErr: true, 46 | }, 47 | { 48 | name: "show_doesnt_exist", 49 | release: rls.Release{ 50 | Title: "Test123", 51 | Series: 0, 52 | }, 53 | want: 0, 54 | wantErr: true, 55 | }, 56 | { 57 | name: "some_recent_show", 58 | release: rls.Release{ 59 | Title: "Echo", 60 | Series: 1, 61 | }, 62 | want: 5, 63 | wantErr: false, 64 | }, 65 | { 66 | name: "show_with_punctuation", 67 | release: rls.Release{ 68 | Title: "Orphan Black - Echoes", 69 | Series: 1, 70 | }, 71 | want: 10, 72 | wantErr: false, 73 | }, 74 | } 75 | for _, tt := range tests { 76 | t.Run(tt.name, func(t *testing.T) { 77 | tvmazeClient := newTVMaze() 78 | 79 | got, err := tvmazeClient.episodesInSeason(tt.release) 80 | 81 | if (err != nil) != tt.wantErr { 82 | t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr) 83 | return 84 | } 85 | assert.Equalf(t, tt.want, got, "TVDB EpisodesInSeason(%s, %d)", tt.release.Title, tt.release.Series) 86 | }) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /internal/notification/discord.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 - 2025, Ludvig Lundgren and the autobrr contributors. 2 | // Code is heavily modified for use with seasonpackarr 3 | // SPDX-License-Identifier: GPL-2.0-or-later 4 | 5 | package notification 6 | 7 | import ( 8 | "bufio" 9 | "bytes" 10 | "encoding/json" 11 | "fmt" 12 | "io" 13 | "net/http" 14 | "slices" 15 | "time" 16 | 17 | "github.com/nuxencs/seasonpackarr/internal/config" 18 | "github.com/nuxencs/seasonpackarr/internal/domain" 19 | "github.com/nuxencs/seasonpackarr/internal/logger" 20 | "github.com/nuxencs/seasonpackarr/pkg/errors" 21 | 22 | "github.com/rs/zerolog" 23 | ) 24 | 25 | type DiscordMessage struct { 26 | Content interface{} `json:"content"` 27 | Embeds []DiscordEmbeds `json:"embeds,omitempty"` 28 | } 29 | 30 | type DiscordEmbeds struct { 31 | Title string `json:"title"` 32 | Description string `json:"description"` 33 | Color int `json:"color"` 34 | Fields []DiscordEmbedsFields `json:"fields,omitempty"` 35 | Timestamp time.Time `json:"timestamp"` 36 | } 37 | 38 | type DiscordEmbedsFields struct { 39 | Name string `json:"name"` 40 | Value string `json:"value"` 41 | Inline bool `json:"inline,omitempty"` 42 | } 43 | 44 | type EmbedColors int 45 | 46 | const ( 47 | RED EmbedColors = 0xed4245 48 | GREEN EmbedColors = 0x57f287 49 | GRAY EmbedColors = 0x99aab5 50 | ) 51 | 52 | type discordSender struct { 53 | log zerolog.Logger 54 | cfg *config.AppConfig 55 | 56 | httpClient *http.Client 57 | } 58 | 59 | func NewDiscordSender(log logger.Logger, config *config.AppConfig) domain.Sender { 60 | return &discordSender{ 61 | log: log.With().Str("sender", "discord").Logger(), 62 | cfg: config, 63 | httpClient: &http.Client{ 64 | Timeout: time.Second * 30, 65 | }, 66 | } 67 | } 68 | 69 | func (s *discordSender) Name() string { 70 | return "discord" 71 | } 72 | 73 | func (s *discordSender) Send(statusCode domain.StatusCode, payload domain.NotificationPayload) error { 74 | if !s.isEnabled() { 75 | s.log.Debug().Msg("no webhook defined, skipping notification") 76 | return nil 77 | } 78 | 79 | if !s.shouldSend(statusCode) { 80 | s.log.Debug().Msg("no notification wanted for this status, skipping notification") 81 | return nil 82 | } 83 | 84 | m := DiscordMessage{ 85 | Content: nil, 86 | Embeds: []DiscordEmbeds{s.buildEmbed(statusCode, payload)}, 87 | } 88 | 89 | jsonData, err := json.Marshal(m) 90 | if err != nil { 91 | return errors.Wrap(err, "could not marshal json request for status: %v payload: %v", statusCode, payload) 92 | } 93 | 94 | req, err := http.NewRequest(http.MethodPost, s.cfg.Config.Notifications.Discord, bytes.NewBuffer(jsonData)) 95 | if err != nil { 96 | return errors.Wrap(err, "could not create request for status: %v payload: %v", statusCode, payload) 97 | } 98 | 99 | req.Header.Set("Content-Type", "application/json") 100 | // req.Header.Set("User-Agent", "seasonpackarr") 101 | 102 | res, err := s.httpClient.Do(req) 103 | if err != nil { 104 | return errors.Wrap(err, "client request error for status: %v payload: %v", statusCode, payload) 105 | } 106 | 107 | defer res.Body.Close() 108 | 109 | s.log.Trace().Msgf("discord response status: %d", res.StatusCode) 110 | 111 | // discord responds with 204, Notifiarr with 204 so lets take all 200 as ok 112 | if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusNoContent { 113 | body, err := io.ReadAll(bufio.NewReader(res.Body)) 114 | if err != nil { 115 | return errors.Wrap(err, "could not read body for status: %v payload: %v", statusCode, payload) 116 | } 117 | 118 | return errors.New("unexpected status: %v body: %v", res.StatusCode, string(body)) 119 | } 120 | 121 | s.log.Debug().Msg("notification successfully sent to discord") 122 | 123 | return nil 124 | } 125 | 126 | func (s *discordSender) isEnabled() bool { 127 | return len(s.cfg.Config.Notifications.Discord) != 0 128 | } 129 | 130 | func (s *discordSender) shouldSend(statusCode domain.StatusCode) bool { 131 | if len(s.cfg.Config.Notifications.NotificationLevel) == 0 { 132 | return false 133 | } 134 | 135 | statusCodes := make(map[domain.StatusCode]struct{}) 136 | 137 | for _, level := range s.cfg.Config.Notifications.NotificationLevel { 138 | if codes, ok := domain.NotificationStatusMap[level]; ok { 139 | for _, code := range codes { 140 | statusCodes[code] = struct{}{} 141 | } 142 | } 143 | } 144 | 145 | _, shouldSend := statusCodes[statusCode] 146 | return shouldSend 147 | } 148 | 149 | func (s *discordSender) buildEmbed(statusCode domain.StatusCode, payload domain.NotificationPayload) DiscordEmbeds { 150 | var color EmbedColors 151 | 152 | if slices.Contains(domain.NotificationStatusMap[domain.NotificationLevelInfo], statusCode) { // not matching 153 | color = GRAY 154 | } else if slices.Contains(domain.NotificationStatusMap[domain.NotificationLevelError], statusCode) { // error processing 155 | color = RED 156 | } else { // success 157 | color = GREEN 158 | } 159 | 160 | var fields []DiscordEmbedsFields 161 | 162 | if payload.ReleaseName != "" { 163 | f := DiscordEmbedsFields{ 164 | Name: "Release Name", 165 | Value: payload.ReleaseName, 166 | Inline: true, 167 | } 168 | fields = append(fields, f) 169 | } 170 | 171 | if payload.Client != "" { 172 | f := DiscordEmbedsFields{ 173 | Name: "Client", 174 | Value: payload.Client, 175 | Inline: true, 176 | } 177 | fields = append(fields, f) 178 | } 179 | 180 | if payload.Action != "" { 181 | f := DiscordEmbedsFields{ 182 | Name: "Action", 183 | Value: payload.Action, 184 | Inline: true, 185 | } 186 | fields = append(fields, f) 187 | } 188 | 189 | if payload.Error != nil { 190 | // actual error? 191 | if slices.Contains(domain.NotificationStatusMap[domain.NotificationLevelError], statusCode) { 192 | f := DiscordEmbedsFields{ 193 | Name: "Error", 194 | Value: fmt.Sprintf("```%s```", payload.Error.Error()), 195 | Inline: false, 196 | } 197 | fields = append(fields, f) 198 | } 199 | } 200 | 201 | embed := DiscordEmbeds{ 202 | Title: BuildTitle(statusCode), 203 | Color: int(color), 204 | Fields: fields, 205 | Timestamp: time.Now(), 206 | } 207 | 208 | return embed 209 | } 210 | -------------------------------------------------------------------------------- /internal/notification/message_builder.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 - 2025, Ludvig Lundgren and the autobrr contributors. 2 | // Code is heavily modified for use with seasonpackarr 3 | // SPDX-License-Identifier: GPL-2.0-or-later 4 | 5 | package notification 6 | 7 | import ( 8 | "strings" 9 | 10 | "github.com/nuxencs/seasonpackarr/internal/domain" 11 | ) 12 | 13 | // BuildTitle constructs the title of the notification message. 14 | func BuildTitle(statusCode domain.StatusCode) string { 15 | return strings.ToUpper(string(statusCode.String()[0])) + statusCode.String()[1:] 16 | } 17 | -------------------------------------------------------------------------------- /internal/payload/payload.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 - 2025, nuxen and the seasonpackarr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package payload 5 | 6 | import ( 7 | "bytes" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | "text/template" 12 | "time" 13 | ) 14 | 15 | const payloadPack = ` 16 | { 17 | "name": "{{ .TorrentName }}", 18 | "clientname": "{{ .ClientName }}" 19 | }` 20 | 21 | const payloadParse = ` 22 | { 23 | "name":"{{ .TorrentName }}", 24 | "torrent":"{{ .TorrentDataRawBytes }}", 25 | "clientname": "{{ .ClientName }}" 26 | }` 27 | 28 | type packVars struct { 29 | TorrentName string 30 | ClientName string 31 | } 32 | 33 | type parseVars struct { 34 | TorrentName string 35 | TorrentDataRawBytes []byte 36 | ClientName string 37 | } 38 | 39 | func CompilePack(torrentName string, clientName string) (io.Reader, error) { 40 | var buffer bytes.Buffer 41 | 42 | tmplVars := packVars{ 43 | TorrentName: torrentName, 44 | ClientName: clientName, 45 | } 46 | 47 | tmpl, err := template.New("Request").Parse(payloadPack) 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | if err = tmpl.Execute(&buffer, &tmplVars); err != nil { 53 | return nil, err 54 | } 55 | 56 | return &buffer, nil 57 | } 58 | 59 | func CompileParse(torrentName string, torrentBytes []byte, clientName string) (io.Reader, error) { 60 | var buffer bytes.Buffer 61 | 62 | tmplVars := parseVars{ 63 | TorrentName: torrentName, 64 | TorrentDataRawBytes: torrentBytes, 65 | ClientName: clientName, 66 | } 67 | 68 | tmpl, err := template.New("Request").Parse(payloadParse) 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | if err = tmpl.Execute(&buffer, &tmplVars); err != nil { 74 | return nil, err 75 | } 76 | 77 | return &buffer, nil 78 | } 79 | 80 | func Exec(url string, body io.Reader, apiToken string) error { 81 | req, err := http.NewRequest(http.MethodPost, url, body) 82 | if err != nil { 83 | return err 84 | } 85 | req.Header.Set("X-API-Token", apiToken) 86 | 87 | c := &http.Client{ 88 | Timeout: 30 * time.Second, 89 | } 90 | 91 | resp, err := c.Do(req) 92 | if err != nil { 93 | return err 94 | } 95 | defer resp.Body.Close() 96 | 97 | fmt.Printf("Completed the request with the following response: %d\n"+ 98 | "For more details take a look at the logs!", resp.StatusCode) 99 | 100 | return nil 101 | } 102 | -------------------------------------------------------------------------------- /internal/release/release.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 - 2025, nuxen and the seasonpackarr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package release 5 | 6 | import ( 7 | "path/filepath" 8 | 9 | "github.com/nuxencs/seasonpackarr/internal/domain" 10 | "github.com/nuxencs/seasonpackarr/internal/slices" 11 | 12 | "github.com/moistari/rls" 13 | ) 14 | 15 | func CheckCandidates(requestRls, clientRls rls.Release, fuzzyMatching domain.FuzzyMatching) domain.CompareInfo { 16 | // check if season pack or no extension 17 | if !requestRls.Type.Is(rls.Series) || requestRls.Ext != "" { 18 | // not a season pack 19 | return domain.CompareInfo{StatusCode: domain.StatusNotASeasonPack} 20 | } 21 | 22 | return compare(requestRls, clientRls, fuzzyMatching) 23 | } 24 | 25 | func compare(requestRls, clientRls rls.Release, fuzzyMatching domain.FuzzyMatching) domain.CompareInfo { 26 | if requestRls.Resolution != clientRls.Resolution { 27 | return domain.CompareInfo{ 28 | StatusCode: domain.StatusResolutionMismatch, 29 | RejectValueA: requestRls.Resolution, 30 | RejectValueB: clientRls.Resolution, 31 | } 32 | } 33 | 34 | if requestRls.Source != clientRls.Source { 35 | return domain.CompareInfo{ 36 | StatusCode: domain.StatusSourceMismatch, 37 | RejectValueA: requestRls.Source, 38 | RejectValueB: clientRls.Source, 39 | } 40 | } 41 | 42 | if rls.MustNormalize(requestRls.Group) != rls.MustNormalize(clientRls.Group) { 43 | return domain.CompareInfo{ 44 | StatusCode: domain.StatusRlsGrpMismatch, 45 | RejectValueA: requestRls.Group, 46 | RejectValueB: clientRls.Group, 47 | } 48 | } 49 | 50 | if !slices.EqualElements(requestRls.Cut, clientRls.Cut) { 51 | return domain.CompareInfo{ 52 | StatusCode: domain.StatusCutMismatch, 53 | RejectValueA: requestRls.Cut, 54 | RejectValueB: clientRls.Cut, 55 | } 56 | } 57 | 58 | if !slices.EqualElements(requestRls.Edition, clientRls.Edition) { 59 | return domain.CompareInfo{ 60 | StatusCode: domain.StatusEditionMismatch, 61 | RejectValueA: requestRls.Edition, 62 | RejectValueB: clientRls.Edition, 63 | } 64 | } 65 | 66 | // skip comparing repack status when skipRepackCompare is enabled 67 | if !fuzzyMatching.SkipRepackCompare { 68 | if !slices.EqualElements(requestRls.Other, clientRls.Other) { 69 | return domain.CompareInfo{ 70 | StatusCode: domain.StatusRepackStatusMismatch, 71 | RejectValueA: requestRls.Other, 72 | RejectValueB: clientRls.Other, 73 | } 74 | } 75 | } 76 | 77 | // normalize any HDR format down to plain HDR when simplifyHdrCompare is enabled 78 | if fuzzyMatching.SimplifyHdrCompare { 79 | requestRls.HDR = slices.SimplifyHDR(requestRls.HDR) 80 | clientRls.HDR = slices.SimplifyHDR(clientRls.HDR) 81 | } 82 | 83 | if !slices.EqualElements(requestRls.HDR, clientRls.HDR) { 84 | return domain.CompareInfo{ 85 | StatusCode: domain.StatusHdrMismatch, 86 | RejectValueA: requestRls.HDR, 87 | RejectValueB: clientRls.HDR, 88 | } 89 | } 90 | 91 | if requestRls.Collection != clientRls.Collection { 92 | return domain.CompareInfo{ 93 | StatusCode: domain.StatusStreamingServiceMismatch, 94 | RejectValueA: requestRls.Collection, 95 | RejectValueB: clientRls.Collection, 96 | } 97 | } 98 | 99 | if requestRls.Episode == clientRls.Episode { 100 | return domain.CompareInfo{StatusCode: domain.StatusAlreadyInClient} 101 | } 102 | 103 | return domain.CompareInfo{StatusCode: domain.StatusSuccessfulMatch} 104 | } 105 | 106 | func MatchEpToSeasonPackEp(clientEpPath string, clientEpSize int64, torrentEpPath string, torrentEpSize int64) (string, domain.CompareInfo) { 107 | if clientEpSize != torrentEpSize { 108 | return "", domain.CompareInfo{ 109 | StatusCode: domain.StatusSizeMismatch, 110 | RejectValueA: clientEpSize, 111 | RejectValueB: torrentEpSize, 112 | } 113 | } 114 | 115 | clientEpRls := rls.ParseString(filepath.Base(clientEpPath)) 116 | torrentEpRls := rls.ParseString(filepath.Base(torrentEpPath)) 117 | 118 | switch { 119 | case clientEpRls.Series != torrentEpRls.Series: 120 | return "", domain.CompareInfo{ 121 | StatusCode: domain.StatusSeasonMismatch, 122 | RejectValueA: clientEpRls.Series, 123 | RejectValueB: torrentEpRls.Series, 124 | } 125 | case clientEpRls.Episode != torrentEpRls.Episode: 126 | return "", domain.CompareInfo{ 127 | StatusCode: domain.StatusEpisodeMismatch, 128 | RejectValueA: clientEpRls.Episode, 129 | RejectValueB: torrentEpRls.Episode, 130 | } 131 | case clientEpRls.Resolution != torrentEpRls.Resolution: 132 | return "", domain.CompareInfo{ 133 | StatusCode: domain.StatusResolutionMismatch, 134 | RejectValueA: clientEpRls.Resolution, 135 | RejectValueB: torrentEpRls.Resolution, 136 | } 137 | case rls.MustNormalize(clientEpRls.Group) != rls.MustNormalize(torrentEpRls.Group): 138 | return "", domain.CompareInfo{ 139 | StatusCode: domain.StatusRlsGrpMismatch, 140 | RejectValueA: clientEpRls.Group, 141 | RejectValueB: torrentEpRls.Group, 142 | } 143 | } 144 | 145 | return torrentEpPath, domain.CompareInfo{} 146 | } 147 | 148 | func PercentOfTotalEpisodes(totalEps int, foundEps int) float32 { 149 | if totalEps == 0 { 150 | return 0 151 | } 152 | 153 | return float32(foundEps) / float32(totalEps) 154 | } 155 | 156 | func IsValidEpisodeFile(torrentFileName string) bool { 157 | torrentFileRls := rls.ParseString(filepath.Base(torrentFileName)) 158 | 159 | // ignore non video files 160 | if torrentFileRls.Ext != "mkv" { 161 | return false 162 | } 163 | 164 | // ignore sample files 165 | if rls.MustNormalize(torrentFileRls.Group) == "sample" { 166 | return false 167 | } 168 | 169 | return true 170 | } 171 | -------------------------------------------------------------------------------- /internal/release/release_test.go: -------------------------------------------------------------------------------- 1 | package release 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/nuxencs/seasonpackarr/internal/domain" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func Test_MatchEpToSeasonPackEp(t *testing.T) { 12 | type args struct { 13 | clientEpPath string 14 | clientEpSize int64 15 | torrentEpPath string 16 | torrentEpSize int64 17 | } 18 | 19 | type compare struct { 20 | path string 21 | info domain.CompareInfo 22 | } 23 | 24 | tests := []struct { 25 | name string 26 | args args 27 | want compare 28 | }{ 29 | { 30 | name: "found_match", 31 | args: args{ 32 | clientEpPath: "Series Title 2022 S02E01 1080p ATVP WEB-DL DDP 5.1 Atmos H.264-RlsGrp.mkv", 33 | clientEpSize: 2316560346, 34 | torrentEpPath: "Series Title 2022 S02E01 1080p Test ATVP WEB-DL DDP 5.1 Atmos H.264-RlsGrp.mkv", 35 | torrentEpSize: 2316560346, 36 | }, 37 | want: compare{ 38 | path: "Series Title 2022 S02E01 1080p Test ATVP WEB-DL DDP 5.1 Atmos H.264-RlsGrp.mkv", 39 | info: domain.CompareInfo{}, 40 | }, 41 | }, 42 | { 43 | name: "wrong_episode", 44 | args: args{ 45 | clientEpPath: "Series Title 2022 S02E01 1080p ATVP WEB-DL DDP 5.1 Atmos H.264-RlsGrp.mkv", 46 | clientEpSize: 2316560346, 47 | torrentEpPath: "Series Title 2022 S02E02 1080p Test ATVP WEB-DL DDP 5.1 Atmos H.264-RlsGrp.mkv", 48 | torrentEpSize: 2316560346, 49 | }, 50 | want: compare{ 51 | path: "", 52 | info: domain.CompareInfo{ 53 | StatusCode: domain.StatusEpisodeMismatch, 54 | RejectValueA: 1, 55 | RejectValueB: 2, 56 | }, 57 | }, 58 | }, 59 | { 60 | name: "wrong_season", 61 | args: args{ 62 | clientEpPath: "Series Title 2022 S02E01 1080p ATVP WEB-DL DDP 5.1 Atmos H.264-RlsGrp.mkv", 63 | clientEpSize: 2316560346, 64 | torrentEpPath: "Series Title 2022 S03E01 1080p Test ATVP WEB-DL DDP 5.1 Atmos H.264-RlsGrp.mkv", 65 | torrentEpSize: 2316560346, 66 | }, 67 | want: compare{ 68 | path: "", 69 | info: domain.CompareInfo{ 70 | StatusCode: domain.StatusSeasonMismatch, 71 | RejectValueA: 2, 72 | RejectValueB: 3, 73 | }, 74 | }, 75 | }, 76 | { 77 | name: "wrong_resolution", 78 | args: args{ 79 | clientEpPath: "Series Title 2022 S02E01 1080p ATVP WEB-DL DDP 5.1 Atmos H.264-RlsGrp.mkv", 80 | clientEpSize: 2316560346, 81 | torrentEpPath: "Series Title 2022 S02E01 2160p Test ATVP WEB-DL DDP 5.1 Atmos H.264-RlsGrp.mkv", 82 | torrentEpSize: 2316560346, 83 | }, 84 | want: compare{ 85 | path: "", 86 | info: domain.CompareInfo{ 87 | StatusCode: domain.StatusResolutionMismatch, 88 | RejectValueA: "1080p", 89 | RejectValueB: "2160p", 90 | }, 91 | }, 92 | }, 93 | { 94 | name: "wrong_rlsgrp", 95 | args: args{ 96 | clientEpPath: "Series Title 2022 S02E01 1080p ATVP WEB-DL DDP 5.1 Atmos H.264-RlsGrp.mkv", 97 | clientEpSize: 2316560346, 98 | torrentEpPath: "Series Title 2022 S02E01 1080p Test ATVP WEB-DL DDP 5.1 Atmos H.264-OtherRlsGrp.mkv", 99 | torrentEpSize: 2316560346, 100 | }, 101 | want: compare{ 102 | path: "", 103 | info: domain.CompareInfo{ 104 | StatusCode: domain.StatusRlsGrpMismatch, 105 | RejectValueA: "RlsGrp", 106 | RejectValueB: "OtherRlsGrp", 107 | }, 108 | }, 109 | }, 110 | { 111 | name: "wrong_size", 112 | args: args{ 113 | clientEpPath: "Series Title 2022 S02E01 1080p ATVP WEB-DL DDP 5.1 Atmos H.264-RlsGrp.mkv", 114 | clientEpSize: 2316560346, 115 | torrentEpPath: "Series Title 2022 S02E01 1080p Test ATVP WEB-DL DDP 5.1 Atmos H.264-RlsGrp.mkv", 116 | torrentEpSize: 2278773077, 117 | }, 118 | want: compare{ 119 | path: "", 120 | info: domain.CompareInfo{ 121 | StatusCode: domain.StatusSizeMismatch, 122 | RejectValueA: int64(2316560346), 123 | RejectValueB: int64(2278773077), 124 | }, 125 | }, 126 | }, 127 | { 128 | name: "subfolder_in_client", 129 | args: args{ 130 | clientEpPath: "Test/Series Title 2022 S02E01 1080p ATVP WEB-DL DDP 5.1 Atmos H.264-RlsGrp.mkv", 131 | clientEpSize: 2316560346, 132 | torrentEpPath: "Series Title 2022 S02E01 Test 1080p ATVP WEB-DL DDP 5.1 Atmos H.264-RlsGrp.mkv", 133 | torrentEpSize: 2316560346, 134 | }, 135 | want: compare{ 136 | path: "Series Title 2022 S02E01 Test 1080p ATVP WEB-DL DDP 5.1 Atmos H.264-RlsGrp.mkv", 137 | info: domain.CompareInfo{}, 138 | }, 139 | }, 140 | { 141 | name: "subfolder_in_torrent", 142 | args: args{ 143 | clientEpPath: "Series Title 2022 S02E01 1080p ATVP WEB-DL DDP 5.1 Atmos H.264-RlsGrp.mkv", 144 | clientEpSize: 2316560346, 145 | torrentEpPath: "Test/Series Title 2022 S02E01 Test 1080p ATVP WEB-DL DDP 5.1 Atmos H.264-RlsGrp.mkv", 146 | torrentEpSize: 2316560346, 147 | }, 148 | want: compare{ 149 | path: "Test/Series Title 2022 S02E01 Test 1080p ATVP WEB-DL DDP 5.1 Atmos H.264-RlsGrp.mkv", 150 | info: domain.CompareInfo{}, 151 | }, 152 | }, 153 | { 154 | name: "subfolder_in_both", 155 | args: args{ 156 | clientEpPath: "Test/Series Title 2022 S02E01 1080p ATVP WEB-DL DDP 5.1 Atmos H.264-RlsGrp.mkv", 157 | clientEpSize: 2316560346, 158 | torrentEpPath: "Test/Series Title 2022 S02E01 Test 1080p ATVP WEB-DL DDP 5.1 Atmos H.264-RlsGrp.mkv", 159 | torrentEpSize: 2316560346, 160 | }, 161 | want: compare{ 162 | path: "Test/Series Title 2022 S02E01 Test 1080p ATVP WEB-DL DDP 5.1 Atmos H.264-RlsGrp.mkv", 163 | info: domain.CompareInfo{}, 164 | }, 165 | }, 166 | { 167 | name: "multi_subfolder", 168 | args: args{ 169 | clientEpPath: "/data/torrents/tv/Test/Series Title 2022 S02E01 1080p ATVP WEB-DL DDP 5.1 Atmos H.264-RlsGrp.mkv", 170 | clientEpSize: 2316560346, 171 | torrentEpPath: "Series Title 2022 S02/Test/Series Title 2022 S02E01 Test 1080p ATVP WEB-DL DDP 5.1 Atmos H.264-RlsGrp.mkv", 172 | torrentEpSize: 2316560346, 173 | }, 174 | want: compare{ 175 | path: "Series Title 2022 S02/Test/Series Title 2022 S02E01 Test 1080p ATVP WEB-DL DDP 5.1 Atmos H.264-RlsGrp.mkv", 176 | info: domain.CompareInfo{}, 177 | }, 178 | }, 179 | } 180 | for _, tt := range tests { 181 | t.Run(tt.name, func(t *testing.T) { 182 | gotPath, gotInfo := MatchEpToSeasonPackEp(tt.args.clientEpPath, tt.args.clientEpSize, tt.args.torrentEpPath, tt.args.torrentEpSize) 183 | 184 | got := compare{ 185 | path: gotPath, 186 | info: gotInfo, 187 | } 188 | 189 | assert.Equalf(t, tt.want, got, "MatchEpToSeasonPackEp(%v, %v, %v, %v)", 190 | tt.args.clientEpPath, tt.args.clientEpSize, tt.args.torrentEpPath, tt.args.torrentEpSize) 191 | }) 192 | } 193 | } 194 | 195 | func Test_IsValidEpisodeFile(t *testing.T) { 196 | type args struct { 197 | torrentFileName string 198 | } 199 | tests := []struct { 200 | name string 201 | args args 202 | want bool 203 | }{ 204 | { 205 | name: "sample_with_dash", 206 | args: args{ 207 | torrentFileName: "test.release.s06e03.dutch.1080p.web.h264-rlsgrp-sample.mkv", 208 | }, 209 | want: false, 210 | }, 211 | { 212 | name: "sample_with_dot", 213 | args: args{ 214 | torrentFileName: "test.release.s06e03.dutch.1080p.web.h264-rlsgrp.sample.mkv", 215 | }, 216 | want: false, 217 | }, 218 | { 219 | name: "wrong_ext", 220 | args: args{ 221 | torrentFileName: "test.release.s06e03.dutch.1080p.web.h264-rlsgrp.nfo", 222 | }, 223 | want: false, 224 | }, 225 | { 226 | name: "wrong_ext_and_sample", 227 | args: args{ 228 | torrentFileName: "test.release.s06e03.dutch.1080p.web.h264-rlsgrp.sample.nfo", 229 | }, 230 | want: false, 231 | }, 232 | { 233 | name: "valid_release", 234 | args: args{ 235 | torrentFileName: "test.release.s06e03.dutch.1080p.web.h264-rlsgrp.mkv", 236 | }, 237 | want: true, 238 | }, 239 | } 240 | for _, tt := range tests { 241 | t.Run(tt.name, func(t *testing.T) { 242 | assert.Equalf(t, tt.want, IsValidEpisodeFile(tt.args.torrentFileName), "IsValidEpisodeFile(%v)", tt.args.torrentFileName) 243 | }) 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /internal/slices/slices.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 - 2025, nuxen and the seasonpackarr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package slices 5 | 6 | import ( 7 | "strings" 8 | ) 9 | 10 | func Dedupe[T comparable](s []T) []T { 11 | resultSet := make(map[T]struct{}) 12 | for _, i := range s { 13 | resultSet[i] = struct{}{} 14 | } 15 | 16 | result := make([]T, 0, len(resultSet)) 17 | for item := range resultSet { 18 | result = append(result, item) 19 | } 20 | 21 | return result 22 | } 23 | 24 | func EqualElements[T comparable](x, y []T) bool { 25 | if len(x) != len(y) { 26 | return false 27 | } 28 | 29 | freqMap := make(map[T]int) 30 | for _, i := range x { 31 | freqMap[i]++ 32 | } 33 | 34 | for _, i := range y { 35 | if freqMap[i] == 0 { 36 | return false 37 | } 38 | freqMap[i]-- 39 | } 40 | 41 | return true 42 | } 43 | 44 | func SimplifyHDR(hdrSlice []string) []string { 45 | for i := range hdrSlice { 46 | if strings.Contains(hdrSlice[i], "HDR") { 47 | hdrSlice[i] = "HDR" 48 | } 49 | } 50 | 51 | return hdrSlice 52 | } 53 | -------------------------------------------------------------------------------- /internal/slices/slices_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 - 2025, nuxen and the seasonpackarr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package slices 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func Test_Dedupe(t *testing.T) { 13 | tests := []struct { 14 | name string 15 | slice interface{} 16 | want interface{} 17 | }{ 18 | { 19 | name: "string_slice_some_duplicates", 20 | slice: []string{"string_1", "string_2", "string_3", "string_2", "string_1"}, 21 | want: []string{"string_1", "string_2", "string_3"}, 22 | }, 23 | { 24 | name: "string_slice_all_duplicates", 25 | slice: []string{"string_1", "string_1", "string_1", "string_1", "string_1"}, 26 | want: []string{"string_1"}, 27 | }, 28 | { 29 | name: "string_slice_no_duplicates", 30 | slice: []string{"string_1", "string_2", "string_3"}, 31 | want: []string{"string_1", "string_2", "string_3"}, 32 | }, 33 | { 34 | name: "string_slice_empty", 35 | slice: []string{}, 36 | want: []string{}, 37 | }, 38 | { 39 | name: "int_slice_some_duplicates", 40 | slice: []int{1, 2, 3, 2, 1}, 41 | want: []int{1, 2, 3}, 42 | }, 43 | { 44 | name: "int_slice_all_duplicates", 45 | slice: []int{1, 1, 1, 1, 1}, 46 | want: []int{1}, 47 | }, 48 | { 49 | name: "int_slice_no_duplicates", 50 | slice: []int{1, 2, 3}, 51 | want: []int{1, 2, 3}, 52 | }, 53 | { 54 | name: "int_slice_empty", 55 | slice: []int{}, 56 | want: []int{}, 57 | }, 58 | } 59 | for _, tt := range tests { 60 | t.Run(tt.name, func(t *testing.T) { 61 | switch v := tt.slice.(type) { 62 | case []string: 63 | assert.ElementsMatchf(t, tt.want, Dedupe(v), "Dedupe(%v)", v) 64 | case []int: 65 | assert.ElementsMatchf(t, tt.want, Dedupe(v), "Dedupe(%v)", v) 66 | default: 67 | t.Errorf("Unsupported slice type in test case: %v", tt.name) 68 | } 69 | }) 70 | } 71 | } 72 | 73 | func Test_EqualElements(t *testing.T) { 74 | tests := []struct { 75 | name string 76 | x interface{} 77 | y interface{} 78 | want bool 79 | }{ 80 | { 81 | name: "string_slice_identical_elements", 82 | x: []string{"a", "b", "c"}, 83 | y: []string{"a", "b", "c"}, 84 | want: true, 85 | }, 86 | { 87 | name: "string_slice_different_order", 88 | x: []string{"a", "b", "c"}, 89 | y: []string{"c", "b", "a"}, 90 | want: true, 91 | }, 92 | { 93 | name: "string_slice_different_elements", 94 | x: []string{"a", "b", "c"}, 95 | y: []string{"a", "b", "d"}, 96 | want: false, 97 | }, 98 | { 99 | name: "string_slice_different_lengths", 100 | x: []string{"a", "b", "c"}, 101 | y: []string{"a", "b"}, 102 | want: false, 103 | }, 104 | { 105 | name: "int_slice_identical_elements", 106 | x: []int{1, 2, 3}, 107 | y: []int{1, 2, 3}, 108 | want: true, 109 | }, 110 | { 111 | name: "int_slice_different_order", 112 | x: []int{1, 2, 3}, 113 | y: []int{3, 2, 1}, 114 | want: true, 115 | }, 116 | { 117 | name: "int_slice_different_elements", 118 | x: []int{1, 2, 3}, 119 | y: []int{1, 2, 4}, 120 | want: false, 121 | }, 122 | { 123 | name: "int_slice_different_lengths", 124 | x: []int{1, 2, 3}, 125 | y: []int{1, 2}, 126 | want: false, 127 | }, 128 | { 129 | name: "empty_slices", 130 | x: []int{}, 131 | y: []int{}, 132 | want: true, 133 | }, 134 | { 135 | name: "one_empty_slice", 136 | x: []int{}, 137 | y: []int{1}, 138 | want: false, 139 | }, 140 | } 141 | for _, tt := range tests { 142 | t.Run(tt.name, func(t *testing.T) { 143 | switch v1 := tt.x.(type) { 144 | case []string: 145 | v2 := tt.y.([]string) 146 | assert.Equalf(t, tt.want, EqualElements(v1, v2), "EqualElements(%v, %v)", v1, v2) 147 | case []int: 148 | v2 := tt.y.([]int) 149 | assert.Equalf(t, tt.want, EqualElements(v1, v2), "EqualElements(%v, %v)", v1, v2) 150 | default: 151 | t.Errorf("Unsupported slice type in test case: %v", tt.name) 152 | } 153 | }) 154 | } 155 | } 156 | 157 | func Test_SimplifyHDR(t *testing.T) { 158 | tests := []struct { 159 | name string 160 | input []string 161 | want []string 162 | }{ 163 | { 164 | name: "contains_HDR", 165 | input: []string{"HDR10", "HDR10+", "HDR"}, 166 | want: []string{"HDR", "HDR", "HDR"}, 167 | }, 168 | { 169 | name: "no_HDR", 170 | input: []string{"SDR", "DV"}, 171 | want: []string{"SDR", "DV"}, 172 | }, 173 | { 174 | name: "empty_slice", 175 | input: []string{}, 176 | want: []string{}, 177 | }, 178 | { 179 | name: "mixed_HDR_and_others", 180 | input: []string{"HDR10", "DV", "SDR", "HDR10+"}, 181 | want: []string{"HDR", "DV", "SDR", "HDR"}, 182 | }, 183 | } 184 | for _, tt := range tests { 185 | t.Run(tt.name, func(t *testing.T) { 186 | assert.Equalf(t, tt.want, SimplifyHDR(tt.input), "SimplifyHDR(%v)", tt.input) 187 | }) 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /internal/torrents/decode.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 - 2025, KyleSanderson, nuxen and the seasonpackarr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package torrents 5 | 6 | import ( 7 | "encoding/base64" 8 | "strings" 9 | "unicode" 10 | 11 | "github.com/nuxencs/seasonpackarr/internal/domain" 12 | ) 13 | 14 | func DecodeTorrentBytes(torrentBytes []byte) ([]byte, error) { 15 | var tb []byte 16 | var err error 17 | 18 | if tb, err = base64.StdEncoding.DecodeString(strings.Trim(strings.TrimSpace(string(torrentBytes)), `"`)); err == nil { 19 | return tb, nil 20 | } else { 21 | ts := strings.Trim(strings.TrimSpace(string(torrentBytes)), `\"[`) 22 | b := make([]byte, 0, len(ts)/3) 23 | 24 | for { 25 | r, valid, z := atoi(ts) 26 | if !valid { 27 | break 28 | } 29 | 30 | b = append(b, byte(r)) 31 | ts = z 32 | } 33 | 34 | if len(b) != 0 { 35 | return b, nil 36 | } 37 | } 38 | 39 | return []byte{}, domain.StatusDecodeTorrentBytesError.Error() 40 | } 41 | 42 | func atoi(buf string) (ret int, valid bool, pos string) { 43 | if len(buf) == 0 { 44 | return ret, false, buf 45 | } 46 | 47 | i := 0 48 | for unicode.IsSpace(rune(buf[i])) { 49 | i++ 50 | } 51 | 52 | r := buf[i] 53 | if r == '-' || r == '+' { 54 | i++ 55 | } 56 | 57 | for ; i != len(buf); i++ { 58 | d := int(buf[i] - '0') 59 | if d < 0 || d > 9 { 60 | break 61 | } 62 | 63 | valid = true 64 | ret *= 10 65 | ret += d 66 | } 67 | 68 | if r == '-' { 69 | ret *= -1 70 | } 71 | 72 | return ret, valid, buf[i:] 73 | } 74 | -------------------------------------------------------------------------------- /internal/torrents/mock.go: -------------------------------------------------------------------------------- 1 | package torrents 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/anacrolix/torrent/bencode" 11 | "github.com/anacrolix/torrent/metainfo" 12 | regexp "github.com/dlclark/regexp2" 13 | ) 14 | 15 | var seasonRegex = regexp.MustCompile(`\bS\d+\b(?!E\d+\b)`, regexp.IgnoreCase) 16 | 17 | func mockEpisodes(dir string, numEpisodes int) error { 18 | match, err := seasonRegex.FindStringMatch(filepath.Base(dir)) 19 | if err != nil { 20 | return err 21 | } 22 | 23 | if match == nil { 24 | return fmt.Errorf("no season information found in release name") 25 | } 26 | 27 | season := match.String() 28 | 29 | for i := 1; i <= numEpisodes; i++ { 30 | episodeName := strings.Replace(filepath.Base(dir), season, season+fmt.Sprintf("E%02d", i), -1) + ".mkv" 31 | episodePath := filepath.Join(dir, episodeName) 32 | 33 | // Create a minimal file. 34 | if err = os.WriteFile(episodePath, []byte("0"), 0o644); err != nil { 35 | return err 36 | } 37 | } 38 | 39 | return nil 40 | } 41 | 42 | func torrentFromFolder(folderPath string) ([]byte, error) { 43 | mi := metainfo.MetaInfo{ 44 | AnnounceList: [][]string{}, 45 | } 46 | 47 | info := metainfo.Info{ 48 | PieceLength: 256 * 1024, 49 | } 50 | 51 | err := info.BuildFromFilePath(folderPath) 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | mi.InfoBytes, err = bencode.Marshal(info) 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | torrentBytes := bytes.Buffer{} 62 | err = mi.Write(&torrentBytes) 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | return torrentBytes.Bytes(), nil 68 | } 69 | 70 | func TorrentFromRls(rlsName string, numEpisodes int) ([]byte, error) { 71 | tempDirPath := filepath.Join(os.TempDir(), rlsName) 72 | 73 | // Create the directory with the specified name 74 | err := os.Mkdir(tempDirPath, os.ModePerm) 75 | if err != nil { 76 | return nil, err 77 | } 78 | defer os.RemoveAll(tempDirPath) 79 | 80 | if err = mockEpisodes(tempDirPath, numEpisodes); err != nil { 81 | return nil, err 82 | } 83 | 84 | return torrentFromFolder(tempDirPath) 85 | } 86 | -------------------------------------------------------------------------------- /internal/torrents/torrents.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 - 2025, nuxen and the seasonpackarr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package torrents 5 | 6 | import ( 7 | "bytes" 8 | "cmp" 9 | "fmt" 10 | "path/filepath" 11 | "slices" 12 | 13 | "github.com/anacrolix/torrent/metainfo" 14 | ) 15 | 16 | type Episode struct { 17 | Path string 18 | Size int64 19 | } 20 | 21 | func Info(torrent []byte) (metainfo.Info, error) { 22 | metaInfo, err := metainfo.Load(bytes.NewReader(torrent)) 23 | if err != nil { 24 | return metainfo.Info{}, err 25 | } 26 | 27 | return metaInfo.UnmarshalInfo() 28 | } 29 | 30 | func Episodes(info metainfo.Info) ([]Episode, error) { 31 | if !info.IsDir() { 32 | return []Episode{}, fmt.Errorf("not a directory") 33 | } 34 | 35 | files := info.UpvertedFiles() 36 | episodes := make([]Episode, 0, len(files)) 37 | 38 | for _, file := range files { 39 | path := file.DisplayPath(&info) 40 | 41 | if filepath.Ext(path) != ".mkv" { 42 | continue 43 | } 44 | 45 | episodes = append(episodes, Episode{ 46 | Path: path, 47 | Size: file.Length, 48 | }) 49 | } 50 | 51 | if len(episodes) == 0 { 52 | return []Episode{}, fmt.Errorf("no .mkv files found") 53 | } 54 | 55 | if len(episodes) > 1 { 56 | slices.SortStableFunc(episodes, func(a, b Episode) int { 57 | return cmp.Compare(a.Path, b.Path) 58 | }) 59 | } 60 | 61 | return episodes, nil 62 | } 63 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 - 2025, nuxen and the seasonpackarr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package main 5 | 6 | import ( 7 | "github.com/nuxencs/seasonpackarr/cmd" 8 | ) 9 | 10 | func main() { 11 | cmd.Execute() 12 | } 13 | -------------------------------------------------------------------------------- /pkg/errors/errors.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 - 2025, Ludvig Lundgren and the autobrr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package errors 5 | 6 | import ( 7 | "fmt" 8 | "reflect" 9 | "runtime" 10 | "unsafe" 11 | 12 | "github.com/pkg/errors" 13 | ) 14 | 15 | // Export a number of functions or variables from pkg/errors. We want people to be able to 16 | // use them, if only via the entrypoints we've vetted in this file. 17 | var ( 18 | As = errors.As 19 | Is = errors.Is 20 | Cause = errors.Cause 21 | Unwrap = errors.Unwrap 22 | ) 23 | 24 | // StackTrace should be aliases rather than newtype'd, so it can work with any of the 25 | // functions we export from pkg/errors. 26 | type StackTrace = errors.StackTrace 27 | 28 | type StackTracer interface { 29 | StackTrace() errors.StackTrace 30 | } 31 | 32 | // Sentinel is used to create compile-time errors that are intended to be value only, with 33 | // no associated stack trace. 34 | func Sentinel(msg string, args ...interface{}) error { 35 | return fmt.Errorf(msg, args...) 36 | } 37 | 38 | // New acts as pkg/errors.New does, producing a stack traced error, but supports 39 | // interpolating of message parameters. Use this when you want the stack trace to start at 40 | // the place you create the error. 41 | func New(msg string, args ...interface{}) error { 42 | return PopStack(errors.New(fmt.Sprintf(msg, args...))) 43 | } 44 | 45 | // Wrap creates a new error from a cause, decorating the original error message with a 46 | // prefix. 47 | // 48 | // It differs from the pkg/errors Wrap/Wrapf by idempotently creating a stack trace, 49 | // meaning we won't create another stack trace when there is already a stack trace present 50 | // that matches our current program position. 51 | func Wrap(cause error, msg string, args ...interface{}) error { 52 | causeStackTracer := new(StackTracer) 53 | if errors.As(cause, causeStackTracer) { 54 | // If our cause has set a stack trace, and that trace is a child of our own function 55 | // as inferred by prefix matching our current program counter stack, then we only want 56 | // to decorate the error message rather than add a redundant stack trace. 57 | if ancestorOfCause(callers(1), (*causeStackTracer).StackTrace()) { 58 | return errors.WithMessagef(cause, msg, args...) // no stack added, no pop required 59 | } 60 | } 61 | 62 | // Otherwise we can't see a stack trace that represents ourselves, so let's add one. 63 | return PopStack(errors.Wrapf(cause, msg, args...)) 64 | } 65 | 66 | // ancestorOfCause returns true if the caller looks to be an ancestor of the given stack 67 | // trace. We check this by seeing whether our stack prefix-matches the cause stack, which 68 | // should imply the error was generated directly from our goroutine. 69 | func ancestorOfCause(ourStack []uintptr, causeStack errors.StackTrace) bool { 70 | // Stack traces are ordered such that the deepest frame is first. We'll want to check 71 | // for prefix matching in reverse. 72 | // 73 | // As an example, imagine we have a prefix-matching stack for ourselves: 74 | // [ 75 | // "github.com/onsi/ginkgo/internal/leafnodes.(*runner).runSync", 76 | // "github.com/incident-io/core/server/pkg/errors_test.TestSuite", 77 | // "testing.tRunner", 78 | // "runtime.goexit" 79 | // ] 80 | // 81 | // We'll want to compare this against an error cause that will have happened further 82 | // down the stack. An example stack trace from such an error might be: 83 | // [ 84 | // "github.com/incident-io/core/server/pkg/errors.New", 85 | // "github.com/incident-io/core/server/pkg/errors_test.glob..func1.2.2.2.1",, 86 | // "github.com/onsi/ginkgo/internal/leafnodes.(*runner).runSync", 87 | // "github.com/incident-io/core/server/pkg/errors_test.TestSuite", 88 | // "testing.tRunner", 89 | // "runtime.goexit" 90 | // ] 91 | // 92 | // They prefix match, but we'll have to handle the match carefully as we need to match 93 | // from back to forward. 94 | 95 | // We can't possibly prefix match if our stack is larger than the cause stack. 96 | if len(ourStack) > len(causeStack) { 97 | return false 98 | } 99 | 100 | // We know the sizes are compatible, so compare program counters from back to front. 101 | for idx := 0; idx < len(ourStack); idx++ { 102 | if ourStack[len(ourStack)-1] != (uintptr)(causeStack[len(causeStack)-1]) { 103 | return false 104 | } 105 | } 106 | 107 | // All comparisons checked out, these stacks match 108 | return true 109 | } 110 | 111 | func callers(skip int) []uintptr { 112 | pc := make([]uintptr, 32) // assume we'll have at most 32 frames 113 | n := runtime.Callers(skip+3, pc) // capture those frames, skipping runtime.Callers, ourself and the calling function 114 | 115 | return pc[:n] // return everything that we captured 116 | } 117 | 118 | // RecoverPanic turns a panic into an error, adjusting the stacktrace so it originates at 119 | // the line that caused it. 120 | // 121 | // Example: 122 | // 123 | // func Do() (err error) { 124 | // defer func() { 125 | // errors.RecoverPanic(recover(), &err) 126 | // }() 127 | // } 128 | func RecoverPanic(r interface{}, errPtr *error) { 129 | var err error 130 | if r != nil { 131 | if panicErr, ok := r.(error); ok { 132 | err = errors.Wrap(panicErr, "caught panic") 133 | } else { 134 | err = errors.New(fmt.Sprintf("caught panic: %v", r)) 135 | } 136 | } 137 | 138 | if err != nil { 139 | // Pop twice: once for the errors package, then again for the defer function we must 140 | // run this under. We want the stacktrace to originate at the source of the panic, not 141 | // in the infrastructure that catches it. 142 | err = PopStack(err) // errors.go 143 | err = PopStack(err) // defer 144 | 145 | *errPtr = err 146 | } 147 | } 148 | 149 | // PopStack removes the top of the stack from an errors stack trace. 150 | func PopStack(err error) error { 151 | if err == nil { 152 | return err 153 | } 154 | 155 | // We want to remove us, the internal/errors.New function, from the error stack we just 156 | // produced. There's no official way of reaching into the error and adjusting this, as 157 | // the stack is stored as a private field on an unexported struct. 158 | // 159 | // This does some unsafe badness to adjust that field, which should not be repeated 160 | // anywhere else. 161 | stackField := reflect.ValueOf(err).Elem().FieldByName("stack") 162 | if stackField.IsZero() { 163 | return err 164 | } 165 | stackFieldPtr := (**[]uintptr)(unsafe.Pointer(stackField.UnsafeAddr())) 166 | 167 | // Remove the first of the frames, dropping 'us' from the error stack trace. 168 | frames := (**stackFieldPtr)[1:] 169 | 170 | // Assign to the internal stack field 171 | *stackFieldPtr = &frames 172 | 173 | return err 174 | } 175 | -------------------------------------------------------------------------------- /schemas/config-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "type": "object", 4 | "additionalProperties": false, 5 | "properties": { 6 | "host": { 7 | "type": "string", 8 | "default": "0.0.0.0" 9 | }, 10 | "port": { 11 | "type": "integer", 12 | "default": 42069 13 | }, 14 | "clients": { 15 | "$ref": "#/$defs/clients" 16 | }, 17 | "logPath": { 18 | "type": "string", 19 | "default": "" 20 | }, 21 | "logLevel": { 22 | "type": "string", 23 | "enum": [ 24 | "ERROR", 25 | "DEBUG", 26 | "INFO", 27 | "WARN", 28 | "TRACE" 29 | ], 30 | "default": "INFO" 31 | }, 32 | "logMaxSize": { 33 | "type": "integer", 34 | "default": 50 35 | }, 36 | "logMaxBackups": { 37 | "type": "integer", 38 | "default": 3 39 | }, 40 | "smartMode": { 41 | "type": "boolean", 42 | "default": false 43 | }, 44 | "smartModeThreshold": { 45 | "type": "number", 46 | "default": 0.75 47 | }, 48 | "parseTorrentFile": { 49 | "type": "boolean", 50 | "default": false 51 | }, 52 | "fuzzyMatching": { 53 | "$ref": "#/$defs/fuzzyMatching" 54 | }, 55 | "notifications": { 56 | "$ref": "#/$defs/notifications" 57 | }, 58 | "apiToken": { 59 | "type": "string", 60 | "default": "" 61 | } 62 | }, 63 | "required": [ 64 | "host", 65 | "port", 66 | "clients", 67 | "logLevel" 68 | ], 69 | "$defs": { 70 | "clients": { 71 | "type": "object", 72 | "additionalProperties": false, 73 | "patternProperties": { 74 | "^[a-z0-9]+$": { 75 | "$ref": "#/$defs/client" 76 | } 77 | } 78 | }, 79 | "client": { 80 | "type": "object", 81 | "additionalProperties": false, 82 | "properties": { 83 | "host": { 84 | "type": "string", 85 | "default": "127.0.0.1" 86 | }, 87 | "port": { 88 | "type": "integer", 89 | "default": 8080 90 | }, 91 | "username": { 92 | "type": "string", 93 | "default": "admin" 94 | }, 95 | "password": { 96 | "type": "string", 97 | "default": "adminadmin" 98 | }, 99 | "preImportPath": { 100 | "type": "string", 101 | "default": "" 102 | } 103 | }, 104 | "required": [ 105 | "host", 106 | "port", 107 | "username", 108 | "password", 109 | "preImportPath" 110 | ] 111 | }, 112 | "fuzzyMatching": { 113 | "type": "object", 114 | "additionalProperties": false, 115 | "properties": { 116 | "skipRepackCompare": { 117 | "type": "boolean", 118 | "default": false 119 | }, 120 | "simplifyHdrCompare": { 121 | "type": "boolean", 122 | "default": false 123 | } 124 | } 125 | }, 126 | "metadata": { 127 | "type": "object", 128 | "additionalProperties": false, 129 | "properties": { 130 | "tvdbAPIKey": { 131 | "type": "string", 132 | "default": "" 133 | }, 134 | "tvdbPIN": { 135 | "type": "string", 136 | "default": "" 137 | } 138 | } 139 | }, 140 | "notifications": { 141 | "type": "object", 142 | "additionalProperties": false, 143 | "properties": { 144 | "notificationLevel": { 145 | "type": "array", 146 | "items": { 147 | "type": "string", 148 | "enum": [ 149 | "MATCH", 150 | "INFO", 151 | "ERROR" 152 | ] 153 | }, 154 | "minItems": 1, 155 | "uniqueItems": true 156 | }, 157 | "discord": { 158 | "type": "string", 159 | "default": "" 160 | } 161 | } 162 | } 163 | } 164 | } --------------------------------------------------------------------------------